Merge main into windows-support
Brings windows-support up to date with main (T-404/405/406, T-413–416, T-421, the T-422 workspace-lifecycle epic, and the 2.4.0 release). Conflict resolutions: - terminal_pane.dart: keep the Windows PowerShell shell selection and main's workspace-cwd fix (T-381) together. - tool_check.dart: accept main's deletion (dead, unreferenced code). - CHANGELOG.md: keep both Unreleased sections. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import 'dart:io' show FileSystemException;
|
||||
|
||||
import '../editor/buffer.dart' show Selection;
|
||||
import '../editor/registry.dart';
|
||||
import '../files/path_safety.dart' show PathOutsideRoot;
|
||||
import '../ipc/command_schema.dart';
|
||||
import '../ipc/envelope.dart';
|
||||
import '../ipc/errno_mapping.dart';
|
||||
@@ -84,6 +85,13 @@ Future<IpcResponse> _open(IpcRequest req, EditorRegistry r) async {
|
||||
r.setSelection(buf.id, Selection.collapsed(_offsetForLine(buf.content, line)));
|
||||
}
|
||||
return IpcResponse.ok(id: req.id, data: buf.toJson());
|
||||
} on PathOutsideRoot {
|
||||
// Same containment contract as files.read (T-363); a buffer is a
|
||||
// write surface, so no D-80 extra-root widening here.
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'path outside workspace: $path'),
|
||||
);
|
||||
} on FileSystemException catch (e) {
|
||||
final errno = e.osError?.errorCode;
|
||||
if (errno != null) {
|
||||
@@ -190,7 +198,17 @@ Future<IpcResponse> _setContent(IpcRequest req, EditorRegistry r) async {
|
||||
Future<IpcResponse> _save(IpcRequest req, EditorRegistry r) async {
|
||||
final id = _resolveId(req, r);
|
||||
if (id == null) return _notFound(req.id, 'no active buffer');
|
||||
final ok = await r.save(id);
|
||||
final bool ok;
|
||||
try {
|
||||
ok = await r.save(id);
|
||||
} on PathOutsideRoot {
|
||||
// Defense in depth — open already validates, but a symlink can be
|
||||
// swapped in under the buffer's path between open and save (T-363).
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'path outside workspace'),
|
||||
);
|
||||
}
|
||||
if (!ok) return _notFound(req.id, 'no such buffer: $id');
|
||||
return IpcResponse.ok(id: req.id, data: {'id': id, 'saved': true});
|
||||
}
|
||||
|
||||
@@ -52,6 +52,15 @@ class SearchService {
|
||||
_active.remove(id)?.cancel();
|
||||
}
|
||||
|
||||
/// Cancel every in-flight search. Called when the workspace service
|
||||
/// set is torn down on project switch (T-367).
|
||||
Future<void> shutdown() async {
|
||||
for (final c in _active.values) {
|
||||
c.cancel();
|
||||
}
|
||||
_active.clear();
|
||||
}
|
||||
|
||||
/// Compute (preview) or perform (apply) a search-and-replace.
|
||||
///
|
||||
/// Preview returns per-file before/after edits without touching disk.
|
||||
|
||||
@@ -9,6 +9,7 @@ library;
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../files/path_safety.dart';
|
||||
import '../ipc/envelope.dart';
|
||||
import '../panes/event_sink.dart';
|
||||
import 'buffer.dart';
|
||||
@@ -212,10 +213,13 @@ class EditorRegistry {
|
||||
events.emit(IpcEvent(subsystem: 'editor', kind: kind, timestamp: DateTime.now().toUtc(), data: data));
|
||||
}
|
||||
|
||||
/// Resolve a buffer path to disk under the workspace root, with the
|
||||
/// same traversal/symlink containment as files.* (T-363). A buffer is
|
||||
/// a WRITE surface (save), so the D-80 extra read roots do not apply —
|
||||
/// strictly workspace-confined. Throws [PathOutsideRoot] on escape.
|
||||
String _absolutePathOf(String repoRelative) {
|
||||
if (repoRelative.startsWith('/')) return repoRelative;
|
||||
final sep = Platform.pathSeparator;
|
||||
return '${workspaceRoot.absolute.path}$sep${repoRelative.replaceAll('/', sep)}';
|
||||
return resolveUnderRootFollowingSymlinks(workspaceRoot, repoRelative.replaceAll('/', sep));
|
||||
}
|
||||
|
||||
// Support JSON decode of Selection from IPC args.
|
||||
|
||||
@@ -43,6 +43,11 @@ Future<List<FileEntry>> listDir({required Directory root, required String dir, r
|
||||
await for (final e in resolved.list(followLinks: false)) {
|
||||
final name = e.uri.pathSegments.isNotEmpty ? e.uri.pathSegments.where((s) => s.isNotEmpty).last : '';
|
||||
final rel = dir.isEmpty ? name : '$dir/$name';
|
||||
// With followLinks: false the lister yields Link entities for symlinks —
|
||||
// that's the symlink signal. stat() follows the link (target type/size,
|
||||
// notFound for broken links), so its type can never be `link` and must
|
||||
// not be used for detection (T-365).
|
||||
final isLink = e is Link;
|
||||
final stat = await e.stat();
|
||||
final isDir = stat.type == FileSystemEntityType.directory;
|
||||
if (ignore.isIgnored(rel, isDirectory: isDir)) continue;
|
||||
@@ -51,7 +56,7 @@ Future<List<FileEntry>> listDir({required Directory root, required String dir, r
|
||||
name: name,
|
||||
path: rel,
|
||||
isDirectory: isDir,
|
||||
isSymlink: stat.type == FileSystemEntityType.link,
|
||||
isSymlink: isLink,
|
||||
sizeBytes: isDir ? null : stat.size,
|
||||
modifiedMs: stat.modified.millisecondsSinceEpoch,
|
||||
),
|
||||
@@ -79,9 +84,10 @@ class WalkResult {
|
||||
|
||||
/// Recursively walk [root], returning every non-ignored *file*
|
||||
/// (directories are descended into but not emitted), pruned by
|
||||
/// [ignore]. Reuses [listDir] per directory, so ignore filtering,
|
||||
/// symlink-escape safety (`followLinks: false`), and per-directory
|
||||
/// sorting are inherited.
|
||||
/// [ignore]. Reuses [listDir] per directory, so ignore filtering and
|
||||
/// per-directory sorting are inherited. Symlinks are never descended —
|
||||
/// a symlinked directory would be an escape hatch out of the workspace
|
||||
/// and a cycle risk (T-365); symlinks to files are emitted as entries.
|
||||
///
|
||||
/// Capped at [maxFiles] to bound work on pathological trees; when the
|
||||
/// cap is hit the walk stops early and [WalkResult.truncated] is set so
|
||||
@@ -97,7 +103,7 @@ Future<WalkResult> walkFiles({required Directory root, required IgnoreSet ignore
|
||||
final entries = await listDir(root: root, dir: dir, ignore: ignore);
|
||||
for (final e in entries) {
|
||||
if (e.isDirectory) {
|
||||
stack.add(e.path);
|
||||
if (!e.isSymlink) stack.add(e.path);
|
||||
} else {
|
||||
out.add(e);
|
||||
if (out.length >= maxFiles) {
|
||||
|
||||
+6
-201
@@ -1,8 +1,9 @@
|
||||
/// Git operations — staging, committing, stashing, log, pull, push.
|
||||
///
|
||||
/// Each function shells out to `git` and returns either a typed result
|
||||
/// or throws [GitException] on failure. All operations are workspace-
|
||||
/// rooted (take a [Directory] argument).
|
||||
/// Shared git plumbing: the resolved `git` binary path, the typed
|
||||
/// failure ([GitException]), the ref-shaped-argument validator, and the
|
||||
/// log entry model. The legacy free-function operation API that used to
|
||||
/// live here duplicated [GitClient] verb-for-verb, had no non-test
|
||||
/// callers, and carried a latent pipe deadlock in its hunk-apply path —
|
||||
/// removed in the T-385 dead-code sweep; use [GitClient].
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
@@ -71,199 +72,3 @@ class GitLogEntry {
|
||||
if (body.isNotEmpty) 'body': body,
|
||||
};
|
||||
}
|
||||
|
||||
/// Stage files. Empty [paths] means stage all (`git add -A`).
|
||||
Future<void> gitStage(Directory workDir, List<String> paths) async {
|
||||
final args = ['add'];
|
||||
if (paths.isEmpty) {
|
||||
args.add('-A');
|
||||
} else {
|
||||
args.add('--');
|
||||
args.addAll(paths);
|
||||
}
|
||||
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git add failed', stderr: r.stderr as String);
|
||||
}
|
||||
}
|
||||
|
||||
/// Unstage files. Empty [paths] means unstage all.
|
||||
Future<void> gitUnstage(Directory workDir, List<String> paths) async {
|
||||
final args = ['reset', 'HEAD'];
|
||||
if (paths.isNotEmpty) {
|
||||
args.add('--');
|
||||
args.addAll(paths);
|
||||
}
|
||||
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git reset failed', stderr: r.stderr as String);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage a single hunk via `git apply --cached`.
|
||||
Future<void> gitStageHunk(Directory workDir, String patch) async {
|
||||
await _applyPatch(workDir, patch, cached: true);
|
||||
}
|
||||
|
||||
/// Unstage a single hunk via `git apply --cached --reverse`.
|
||||
Future<void> gitUnstageHunk(Directory workDir, String patch) async {
|
||||
await _applyPatch(workDir, patch, cached: true, reverse: true);
|
||||
}
|
||||
|
||||
/// Discard unstaged changes for [paths]. Uses `git checkout -- <paths>`.
|
||||
Future<void> gitDiscard(Directory workDir, List<String> paths) async {
|
||||
if (paths.isEmpty) return;
|
||||
final r = await Process.run(gitBin, ['checkout', '--', ...paths], workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git checkout failed', stderr: r.stderr as String);
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit staged changes.
|
||||
Future<String> gitCommit(Directory workDir, String message, {bool amend = false}) async {
|
||||
final args = ['commit', '-m', message];
|
||||
if (amend) args.add('--amend');
|
||||
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git commit failed', stderr: r.stderr as String);
|
||||
}
|
||||
// Return the new commit hash.
|
||||
final hashResult = await Process.run(gitBin, ['rev-parse', 'HEAD'], workingDirectory: workDir.path);
|
||||
return (hashResult.stdout as String).trim();
|
||||
}
|
||||
|
||||
/// Stash working changes.
|
||||
Future<void> gitStash(Directory workDir, {String? message, bool includeUntracked = false}) async {
|
||||
final args = ['stash', 'push'];
|
||||
if (message != null) {
|
||||
args.addAll(['-m', message]);
|
||||
}
|
||||
if (includeUntracked) args.add('--include-untracked');
|
||||
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git stash failed', stderr: r.stderr as String);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pop the top stash entry.
|
||||
Future<void> gitStashPop(Directory workDir) async {
|
||||
final r = await Process.run(gitBin, ['stash', 'pop'], workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git stash pop failed', stderr: r.stderr as String);
|
||||
}
|
||||
}
|
||||
|
||||
/// Git log. Returns the most recent [count] entries.
|
||||
Future<List<GitLogEntry>> gitLog(Directory workDir, {int count = 20}) async {
|
||||
final r = await Process.run(gitBin, ['log', '--format=%H%x00%h%x00%s%x00%an%x00%aI%x00%b%x01', '-n', '$count'], workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) return const [];
|
||||
return _parseLog(r.stdout as String);
|
||||
}
|
||||
|
||||
/// Pull from remote.
|
||||
Future<String> gitPull(Directory workDir) async {
|
||||
final r = await Process.run(gitBin, ['pull'], workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git pull failed', stderr: r.stderr as String);
|
||||
}
|
||||
return (r.stdout as String).trim();
|
||||
}
|
||||
|
||||
/// Push to remote.
|
||||
Future<String> gitPush(Directory workDir, {String? remote, String? branch, bool setUpstream = false}) async {
|
||||
if (remote != null) validateGitRef(remote, kind: 'remote');
|
||||
if (branch != null) validateGitRef(branch, kind: 'branch');
|
||||
final args = ['push'];
|
||||
if (setUpstream) args.add('-u');
|
||||
// `--` terminates option parsing — belt-and-suspenders alongside
|
||||
// the ref validator above. Without it a future caller that bypasses
|
||||
// the validator could still inject `--upload-pack=...`.
|
||||
args.add('--');
|
||||
if (remote != null) args.add(remote);
|
||||
if (branch != null) args.add(branch);
|
||||
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git push failed', stderr: r.stderr as String);
|
||||
}
|
||||
return ((r.stdout as String) + (r.stderr as String)).trim();
|
||||
}
|
||||
|
||||
/// List local branches. Returns (name, isCurrent) pairs.
|
||||
Future<List<({String name, bool current})>> gitBranches(Directory workDir) async {
|
||||
final r = await Process.run(gitBin, ['branch', '--format=%(refname:short)|%(HEAD)'], workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) return const [];
|
||||
final out = <({String name, bool current})>[];
|
||||
for (final line in (r.stdout as String).split('\n')) {
|
||||
if (line.trim().isEmpty) continue;
|
||||
final sep = line.lastIndexOf('|');
|
||||
if (sep < 0) continue;
|
||||
final name = line.substring(0, sep);
|
||||
final head = line.substring(sep + 1).trim();
|
||||
out.add((name: name, current: head == '*'));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Checkout a branch.
|
||||
///
|
||||
/// `git checkout` overloads positionals: `-- <name>` means "restore
|
||||
/// pathspec `<name>`", not "checkout branch `<name>`". So this can't
|
||||
/// use `--` as an option terminator without changing semantics — the
|
||||
/// [validateGitRef] guard against `-`-prefixed values is the only
|
||||
/// argv-injection defence here. Use `gitSwitch` if/when we adopt it.
|
||||
Future<void> gitCheckout(Directory workDir, String branch) async {
|
||||
validateGitRef(branch, kind: 'branch');
|
||||
final r = await Process.run(gitBin, ['checkout', branch], workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git checkout failed', stderr: r.stderr as String);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current branch name.
|
||||
Future<String?> gitCurrentBranch(Directory workDir) async {
|
||||
final r = await Process.run(gitBin, ['symbolic-ref', '--short', 'HEAD'], workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) return null;
|
||||
return (r.stdout as String).trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
List<GitLogEntry> _parseLog(String output) {
|
||||
if (output.trim().isEmpty) return const [];
|
||||
final records = output.split('\x01');
|
||||
final entries = <GitLogEntry>[];
|
||||
for (final record in records) {
|
||||
final trimmed = record.trim();
|
||||
if (trimmed.isEmpty) continue;
|
||||
final fields = trimmed.split('\x00');
|
||||
if (fields.length < 5) continue;
|
||||
entries.add(
|
||||
GitLogEntry(
|
||||
hash: fields[0],
|
||||
shortHash: fields[1],
|
||||
subject: fields[2],
|
||||
author: fields[3],
|
||||
date: fields[4],
|
||||
body: fields.length > 5 ? fields[5].trim() : '',
|
||||
),
|
||||
);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
Future<void> _applyPatch(Directory workDir, String patch, {bool cached = false, bool reverse = false}) async {
|
||||
final args = ['apply'];
|
||||
if (cached) args.add('--cached');
|
||||
if (reverse) args.add('--reverse');
|
||||
args.add('--unidiff-zero');
|
||||
args.add('-');
|
||||
|
||||
final proc = await Process.start('git', args, workingDirectory: workDir.path);
|
||||
proc.stdin.write(patch);
|
||||
await proc.stdin.close();
|
||||
final exitCode = await proc.exitCode;
|
||||
if (exitCode != 0) {
|
||||
final stderr = await proc.stderr.transform(const SystemEncoding().decoder).join();
|
||||
throw GitException('git apply failed', stderr: stderr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ library;
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
@@ -33,6 +34,12 @@ import 'package:clide/src/ipc/envelope.dart';
|
||||
/// separate `/ide` minimum (D-68).
|
||||
const String _clideToolPrefix = 'mcp__clide__';
|
||||
|
||||
/// Auth header Claude Code's `/ide` client sends, populated from the lock
|
||||
/// file's `authToken`. Every request must carry it (T-362): the unix socket
|
||||
/// is gated by 0600 per D-71, and an unauthenticated localhost HTTP port
|
||||
/// would bypass that gate wholesale.
|
||||
const String kMcpAuthHeader = 'x-claude-code-ide-authorization';
|
||||
|
||||
/// One connected SSE client. Each session has its own response
|
||||
/// stream; POST /messages routes back to the right one via the
|
||||
/// `sessionId` query param.
|
||||
@@ -89,6 +96,7 @@ class McpServer {
|
||||
HttpServer? _http;
|
||||
String? _lockFile;
|
||||
int? _port;
|
||||
String? _authToken;
|
||||
final Map<String, _McpSession> _sessions = {};
|
||||
int _sessionCounter = 0;
|
||||
|
||||
@@ -96,11 +104,16 @@ class McpServer {
|
||||
int? get port => _port;
|
||||
String? get lockFilePath => _lockFile;
|
||||
|
||||
/// The per-start bearer token clients must present in [kMcpAuthHeader].
|
||||
/// Published to legitimate clients via the 0600 lock file only.
|
||||
String? get authToken => _authToken;
|
||||
|
||||
Future<void> start() async {
|
||||
if (isRunning) return;
|
||||
final server = await HttpServer.bind(bindHost, bindPort);
|
||||
_http = server;
|
||||
_port = server.port;
|
||||
_authToken = _generateToken();
|
||||
_lockFile = await _writeDiscoveryFile();
|
||||
server.listen(
|
||||
_route,
|
||||
@@ -136,6 +149,13 @@ class McpServer {
|
||||
// -- routing --------------------------------------------------------------
|
||||
|
||||
Future<void> _route(HttpRequest req) async {
|
||||
// Token gate first, on every path (T-362). Without it, any local
|
||||
// process could drive the entire dispatcher D-71's 0600 socket guards.
|
||||
if (req.headers.value(kMcpAuthHeader) != _authToken) {
|
||||
req.response.statusCode = HttpStatus.unauthorized;
|
||||
await req.response.close();
|
||||
return;
|
||||
}
|
||||
final path = req.uri.path;
|
||||
if (path == '/sse' && req.method == 'GET') {
|
||||
await _openSseStream(req);
|
||||
@@ -327,8 +347,39 @@ class McpServer {
|
||||
dirHandle.createSync(recursive: true);
|
||||
}
|
||||
final path = '$dir/$pid.lock';
|
||||
final body = jsonEncode({'pid': pid, 'workspace': workspaceRoot, 'transport': 'sse', 'url': 'http://$bindHost:$_port/sse'});
|
||||
final body = jsonEncode({
|
||||
'pid': pid,
|
||||
'workspace': workspaceRoot,
|
||||
'transport': 'sse',
|
||||
'url': 'http://$bindHost:$_port/sse',
|
||||
// Claude Code's /ide lock format carries the bearer token here; the
|
||||
// 0600 below is what scopes it to this user (T-362).
|
||||
'authToken': _authToken,
|
||||
});
|
||||
File(path).writeAsStringSync(body);
|
||||
try {
|
||||
await _chmod(path, '600');
|
||||
} catch (e) {
|
||||
// Not fatal like the socket's chmod (D-71): the lock lives under
|
||||
// ~/.claude which the home-dir perms usually already protect. But say so.
|
||||
log.warn('mcp', 'chmod 600 on $path failed: $e — the auth token may be readable by other local users');
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/// 32 bytes of CSPRNG entropy, base64url — the per-start bearer token.
|
||||
static String _generateToken() {
|
||||
final rng = Random.secure();
|
||||
final bytes = List<int>.generate(32, (_) => rng.nextInt(256));
|
||||
return base64UrlEncode(bytes).replaceAll('=', '');
|
||||
}
|
||||
|
||||
/// `chmod` via `chmod(1)` — dart:io doesn't expose mode bits (same
|
||||
/// approach as the unix-socket server, D-71).
|
||||
static Future<void> _chmod(String path, String octal) async {
|
||||
final r = await Process.run('chmod', [octal, path]);
|
||||
if (r.exitCode != 0) {
|
||||
throw ProcessException('chmod', [octal, path], r.stderr.toString(), r.exitCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
-27
@@ -150,33 +150,26 @@ class IpcServer {
|
||||
|
||||
void _onClient(Socket client) {
|
||||
_clients.add(client);
|
||||
final buffer = StringBuffer();
|
||||
late StreamSubscription<List<int>> sub;
|
||||
sub = client.listen(
|
||||
(chunk) async {
|
||||
buffer.write(utf8.decode(chunk, allowMalformed: true));
|
||||
var idx = buffer.toString().indexOf('\n');
|
||||
while (idx >= 0) {
|
||||
final raw = buffer.toString().substring(0, idx);
|
||||
// Trim consumed bytes by rebuilding the buffer with the
|
||||
// tail — StringBuffer can't slice in place.
|
||||
final tail = buffer.toString().substring(idx + 1);
|
||||
buffer.clear();
|
||||
buffer.write(tail);
|
||||
await _handleLine(client, raw);
|
||||
idx = buffer.toString().indexOf('\n');
|
||||
}
|
||||
},
|
||||
onError: (Object e, StackTrace st) {
|
||||
log.warn('ipc', 'client read error: $e');
|
||||
},
|
||||
onDone: () {
|
||||
_clients.remove(client);
|
||||
_subscribers.remove(client);
|
||||
sub.cancel();
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
unawaited(_serveClient(client));
|
||||
}
|
||||
|
||||
/// One read loop per connection: persistent UTF-8 decode, line framing,
|
||||
/// and true serial dispatch in a single `await for` (D-72, T-372). The
|
||||
/// old async onData handler never paused its subscription — pipelined
|
||||
/// requests interleaved mid-handler, the shared StringBuffer could
|
||||
/// re-frame while an await was in flight, and per-chunk decode corrupted
|
||||
/// runes split across reads.
|
||||
Future<void> _serveClient(Socket client) async {
|
||||
try {
|
||||
await for (final line in client.cast<List<int>>().transform(const Utf8Decoder(allowMalformed: true)).transform(const LineSplitter())) {
|
||||
await _handleLine(client, line);
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('ipc', 'client read error: $e');
|
||||
} finally {
|
||||
_clients.remove(client);
|
||||
_subscribers.remove(client);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleLine(Socket client, String line) async {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/// DaemonTransport (T-331): the seam between the local app and its
|
||||
/// backend. The UI's [DaemonClient] talks JSON-lines through a
|
||||
/// [DaemonTransport] instead of a hard-coded unix-socket connect, so a
|
||||
/// remote transport (SSH-tunnelled agent socket or ssh-exec channel,
|
||||
/// T-329/Q-23) can slot in without touching the client's correlation,
|
||||
/// reconnect, or event-forwarding logic.
|
||||
///
|
||||
/// The wire protocol is unchanged either way: one JSON envelope
|
||||
/// (IpcRequest/IpcResponse/IpcEvent, see envelope.dart) per line.
|
||||
///
|
||||
/// Kept Flutter-free — this file runs under plain `dart test`.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
/// How the app reaches its backend. Implementations own endpoint
|
||||
/// resolution + connection establishment; the caller owns retry policy
|
||||
/// (the client's backoff loop calls [open] again after a failure).
|
||||
abstract interface class DaemonTransport {
|
||||
/// Stable, human-readable endpoint description — the unix socket path
|
||||
/// locally, a `ssh://host/path` form remotely. Used for logs, status
|
||||
/// surfaces, and same-endpoint reconnect short-circuits.
|
||||
String get endpoint;
|
||||
|
||||
/// Establish one connection. Throws on failure (caller retries).
|
||||
Future<DaemonConnection> open();
|
||||
}
|
||||
|
||||
/// One live backend connection carrying JSON-lines both ways.
|
||||
abstract interface class DaemonConnection {
|
||||
/// Incoming lines, one JSON envelope each. Done/error signals the
|
||||
/// connection dropped.
|
||||
Stream<String> get lines;
|
||||
|
||||
/// Send one JSON envelope line (the newline is appended here).
|
||||
void writeLine(String line);
|
||||
|
||||
Future<void> close();
|
||||
}
|
||||
|
||||
/// Today's path: connect to the workspace-derived unix domain socket
|
||||
/// (D-70) the in-process IpcServer is bound to.
|
||||
class LocalSocketTransport implements DaemonTransport {
|
||||
LocalSocketTransport(this.socketPath);
|
||||
|
||||
final String socketPath;
|
||||
|
||||
@override
|
||||
String get endpoint => socketPath;
|
||||
|
||||
@override
|
||||
Future<DaemonConnection> open() async {
|
||||
final addr = InternetAddress(socketPath, type: InternetAddressType.unix);
|
||||
return _SocketConnection(await Socket.connect(addr, 0));
|
||||
}
|
||||
}
|
||||
|
||||
class _SocketConnection implements DaemonConnection {
|
||||
_SocketConnection(this._socket);
|
||||
|
||||
final Socket _socket;
|
||||
|
||||
@override
|
||||
Stream<String> get lines => _socket.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter());
|
||||
|
||||
@override
|
||||
void writeLine(String line) => _socket.writeln(line);
|
||||
|
||||
@override
|
||||
Future<void> close() => _socket.close();
|
||||
}
|
||||
+20
-181
@@ -1,63 +1,46 @@
|
||||
/// Raw FFI bindings to the libc functions the PTY wrapper needs.
|
||||
/// Raw FFI bindings to the libc symbols the PTY layer still needs.
|
||||
///
|
||||
/// `dart:io` doesn't expose `forkpty`, `read`/`write` on raw fds,
|
||||
/// `ioctl`, or `poll` — FFI is the minimum tool for the job.
|
||||
/// `dart:io` doesn't expose `socketpair`, `close` on raw fds, `errno`,
|
||||
/// or the `poll()` event bits — FFI is the minimum tool for the job.
|
||||
/// The fd-passing-era surface that used to live here (recvmsg + the
|
||||
/// msghdr/cmsghdr/iovec structs, read/write, ioctl/winsize, fcntl
|
||||
/// non-blocking helpers) had no callers since the daemon dissolution
|
||||
/// (D-56) and was removed in the T-385 dead-code sweep; `NativePty`
|
||||
/// binds its own symbols.
|
||||
///
|
||||
/// Linux + macOS only for now. Windows is covered by platform checks
|
||||
/// higher up; when Windows support lands it'll need a parallel binding
|
||||
/// set against the Win32 API (named pipes instead of unix sockets).
|
||||
library;
|
||||
|
||||
// File-wide analyzer exceptions, with reason — see CLAUDE.md
|
||||
// no-lint-suppression rule. These are the textbook FFI-binding
|
||||
// case where the lints work against the file's purpose:
|
||||
// File-wide analyzer exception, with reason — see CLAUDE.md
|
||||
// no-lint-suppression rule. This is the textbook FFI-binding case
|
||||
// where the lint works against the file's purpose:
|
||||
//
|
||||
// * `non_constant_identifier_names` — struct field names map 1:1
|
||||
// to POSIX (`man 2 socketpair`, `recvmsg`, `iovec`, `msghdr`).
|
||||
// Keeping snake_case makes the code greppable against the spec
|
||||
// and the field offsets readable next to the C ABI. Dart FFI
|
||||
// layout depends on declaration order + types, not names, so
|
||||
// this is purely a readability call.
|
||||
// * `library_private_types_in_public_api` — the C / Dart function-
|
||||
// signature typedefs (`_SocketpairC`, `_SocketpairDart`, etc.)
|
||||
// are implementation details consumed only by the public
|
||||
// `lookupFunction<...>()` calls in this file. Promoting them
|
||||
// to public would just add noise to the import surface.
|
||||
// signature typedefs (`_SocketpairC`, `_SocketpairD`, etc.) are
|
||||
// implementation details consumed only by the public
|
||||
// `lookupFunction<...>()` calls in this file. Promoting them to
|
||||
// public would just add noise to the import surface.
|
||||
//
|
||||
// ignore_for_file: non_constant_identifier_names, library_private_types_in_public_api
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
|
||||
import 'dart:ffi' as ffi;
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:ffi/ffi.dart' as pkg_ffi;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants (POSIX — platform-dispatched where Linux/macOS diverge)
|
||||
// Constants (POSIX — identical numeric values on Linux + macOS for the
|
||||
// entries we touch)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const int afUnix = 1;
|
||||
const int sockStream = 1;
|
||||
|
||||
final int solSocket = Platform.isMacOS ? 0xffff : 1;
|
||||
final int scmRights = Platform.isMacOS ? 0x01 : 1;
|
||||
|
||||
final int oNonblock = Platform.isMacOS ? 0x0004 : 0x0800;
|
||||
const int fGetFl = 3;
|
||||
const int fSetFl = 4;
|
||||
|
||||
final int tiocswinsz = Platform.isMacOS ? 0x80087467 : 0x5414;
|
||||
|
||||
// poll() event bits (POSIX — same numeric values on Linux + macOS).
|
||||
// poll() event bits.
|
||||
const int pollin = 0x0001;
|
||||
const int pollerr = 0x0008;
|
||||
const int pollhup = 0x0010;
|
||||
const int pollnval = 0x0020;
|
||||
const int pollAnyErr = pollerr | pollhup | pollnval;
|
||||
|
||||
// Signal numbers used from the PTY layer (POSIX standard; identical
|
||||
// across Linux + macOS for the entries we touch).
|
||||
// Signal numbers used from the PTY layer.
|
||||
const int sighup = 1;
|
||||
const int sigkill = 9;
|
||||
const int sigwinch = 28;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -67,108 +50,12 @@ const int sigwinch = 28;
|
||||
typedef _SocketpairC = ffi.Int32 Function(ffi.Int32 domain, ffi.Int32 type, ffi.Int32 protocol, ffi.Pointer<ffi.Int32> sv);
|
||||
typedef _SocketpairD = int Function(int domain, int type, int protocol, ffi.Pointer<ffi.Int32> sv);
|
||||
|
||||
typedef _RecvmsgC = ffi.IntPtr Function(ffi.Int32 sockfd, ffi.Pointer<Msghdr> msg, ffi.Int32 flags);
|
||||
typedef _RecvmsgD = int Function(int sockfd, ffi.Pointer<Msghdr> msg, int flags);
|
||||
|
||||
typedef _RecvmsgDarwinC = ffi.IntPtr Function(ffi.Int32 sockfd, ffi.Pointer<MsghdrDarwin> msg, ffi.Int32 flags);
|
||||
typedef _RecvmsgDarwinD = int Function(int sockfd, ffi.Pointer<MsghdrDarwin> msg, int flags);
|
||||
|
||||
typedef _ReadC = ffi.IntPtr Function(ffi.Int32 fd, ffi.Pointer<ffi.Uint8> buf, ffi.IntPtr count);
|
||||
typedef _ReadD = int Function(int fd, ffi.Pointer<ffi.Uint8> buf, int count);
|
||||
|
||||
typedef _WriteC = ffi.IntPtr Function(ffi.Int32 fd, ffi.Pointer<ffi.Uint8> buf, ffi.IntPtr count);
|
||||
typedef _WriteD = int Function(int fd, ffi.Pointer<ffi.Uint8> buf, int count);
|
||||
|
||||
typedef _CloseC = ffi.Int32 Function(ffi.Int32 fd);
|
||||
typedef _CloseD = int Function(int fd);
|
||||
|
||||
typedef _IoctlPtrC = ffi.Int32 Function(ffi.Int32 fd, ffi.UnsignedLong request, ffi.Pointer<Winsize> argp);
|
||||
typedef _IoctlPtrD = int Function(int fd, int request, ffi.Pointer<Winsize> argp);
|
||||
|
||||
typedef _FcntlIntC = ffi.Int32 Function(ffi.Int32 fd, ffi.Int32 cmd, ffi.Int32 arg);
|
||||
typedef _FcntlIntD = int Function(int fd, int cmd, int arg);
|
||||
|
||||
typedef _ErrnoLocationC = ffi.Pointer<ffi.Int32> Function();
|
||||
typedef _ErrnoLocationD = ffi.Pointer<ffi.Int32> Function();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native structs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// POSIX `struct iovec`.
|
||||
final class Iovec extends ffi.Struct {
|
||||
external ffi.Pointer<ffi.Uint8> iov_base;
|
||||
@ffi.IntPtr()
|
||||
external int iov_len;
|
||||
}
|
||||
|
||||
/// Linux `struct msghdr`. msg_iovlen/msg_controllen are size_t (8 bytes
|
||||
/// on 64-bit). macOS uses int/socklen_t (4 bytes) — see MsghdrDarwin.
|
||||
final class Msghdr extends ffi.Struct {
|
||||
external ffi.Pointer<ffi.Void> msg_name;
|
||||
@ffi.Uint32()
|
||||
external int msg_namelen;
|
||||
external ffi.Pointer<Iovec> msg_iov;
|
||||
@ffi.IntPtr()
|
||||
external int msg_iovlen;
|
||||
external ffi.Pointer<ffi.Void> msg_control;
|
||||
@ffi.IntPtr()
|
||||
external int msg_controllen;
|
||||
@ffi.Int32()
|
||||
external int msg_flags;
|
||||
}
|
||||
|
||||
/// macOS `struct msghdr`. msg_iovlen is int (4 bytes), msg_controllen
|
||||
/// is socklen_t (4 bytes) — smaller than Linux's size_t fields.
|
||||
final class MsghdrDarwin extends ffi.Struct {
|
||||
external ffi.Pointer<ffi.Void> msg_name;
|
||||
@ffi.Uint32()
|
||||
external int msg_namelen;
|
||||
external ffi.Pointer<Iovec> msg_iov;
|
||||
@ffi.Int32()
|
||||
external int msg_iovlen;
|
||||
external ffi.Pointer<ffi.Void> msg_control;
|
||||
@ffi.Uint32()
|
||||
external int msg_controllen;
|
||||
@ffi.Int32()
|
||||
external int msg_flags;
|
||||
}
|
||||
|
||||
/// POSIX `struct cmsghdr` prefix. We treat the rest of the control
|
||||
/// buffer as a raw byte region and compute offsets by hand.
|
||||
// On Linux, cmsg_len is size_t (8 bytes on 64-bit).
|
||||
// On macOS, cmsg_len is socklen_t (4 bytes, always).
|
||||
// Use platform-specific structs.
|
||||
final class CmsghdrLinux extends ffi.Struct {
|
||||
@ffi.IntPtr()
|
||||
external int cmsg_len;
|
||||
@ffi.Int32()
|
||||
external int cmsg_level;
|
||||
@ffi.Int32()
|
||||
external int cmsg_type;
|
||||
}
|
||||
|
||||
final class CmsghdrDarwin extends ffi.Struct {
|
||||
@ffi.Uint32()
|
||||
external int cmsg_len;
|
||||
@ffi.Int32()
|
||||
external int cmsg_level;
|
||||
@ffi.Int32()
|
||||
external int cmsg_type;
|
||||
}
|
||||
|
||||
/// POSIX `struct winsize` for `TIOCSWINSZ`.
|
||||
final class Winsize extends ffi.Struct {
|
||||
@ffi.Uint16()
|
||||
external int ws_row;
|
||||
@ffi.Uint16()
|
||||
external int ws_col;
|
||||
@ffi.Uint16()
|
||||
external int ws_xpixel;
|
||||
@ffi.Uint16()
|
||||
external int ws_ypixel;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Library handle + lazy-resolved function pointers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -184,20 +71,8 @@ ffi.DynamicLibrary _openLibc() {
|
||||
|
||||
final _SocketpairD socketpair = _libc.lookupFunction<_SocketpairC, _SocketpairD>('socketpair');
|
||||
|
||||
final _RecvmsgD recvmsgLinux = _libc.lookupFunction<_RecvmsgC, _RecvmsgD>('recvmsg');
|
||||
|
||||
final _RecvmsgDarwinD recvmsgDarwin = _libc.lookupFunction<_RecvmsgDarwinC, _RecvmsgDarwinD>('recvmsg');
|
||||
|
||||
final _ReadD read = _libc.lookupFunction<_ReadC, _ReadD>('read');
|
||||
|
||||
final _WriteD write = _libc.lookupFunction<_WriteC, _WriteD>('write');
|
||||
|
||||
final _CloseD close = _libc.lookupFunction<_CloseC, _CloseD>('close');
|
||||
|
||||
final _IoctlPtrD ioctlWinsize = _libc.lookupFunction<_IoctlPtrC, _IoctlPtrD>('ioctl');
|
||||
|
||||
final _FcntlIntD fcntlInt = _libc.lookupFunction<_FcntlIntC, _FcntlIntD>('fcntl');
|
||||
|
||||
/// Resolve `errno` through the platform-appropriate thread-local
|
||||
/// accessor. glibc exposes `__errno_location`, musl the same, macOS
|
||||
/// uses `__error`.
|
||||
@@ -211,39 +86,3 @@ int get errno {
|
||||
final fn = _libc.lookupFunction<_ErrnoLocationC, _ErrnoLocationD>('__error');
|
||||
return fn().value;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience — scoped allocations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Allocate a typed native block, run [action], free. Frees even if
|
||||
/// [action] throws.
|
||||
T withBuffer<T>(int bytes, T Function(ffi.Pointer<ffi.Uint8>) action) {
|
||||
final p = pkg_ffi.calloc<ffi.Uint8>(bytes);
|
||||
try {
|
||||
return action(p);
|
||||
} finally {
|
||||
pkg_ffi.calloc.free(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set [fd] non-blocking. Returns whether the flag was changed.
|
||||
bool setNonBlocking(int fd) {
|
||||
final flags = fcntlInt(fd, fGetFl, 0);
|
||||
if (flags < 0) return false;
|
||||
if ((flags & oNonblock) != 0) return false;
|
||||
fcntlInt(fd, fSetFl, flags | oNonblock);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Apply `TIOCSWINSZ` to the master PTY fd.
|
||||
int setWinsize(int fd, int cols, int rows) {
|
||||
final ws = pkg_ffi.calloc<Winsize>();
|
||||
try {
|
||||
ws.ref.ws_col = cols;
|
||||
ws.ref.ws_row = rows;
|
||||
return ioctlWinsize(fd, tiocswinsz, ws);
|
||||
} finally {
|
||||
pkg_ffi.calloc.free(ws);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,6 +451,11 @@ class NativePty implements PtySession {
|
||||
void _reap() {
|
||||
if (_dead) return;
|
||||
_dead = true;
|
||||
// The reader isolate sends EOF only after exiting its poll loop, so
|
||||
// nothing touches the master fd anymore. Release it here — close()
|
||||
// short-circuits on _dead, so skipping this leaks the fd and its pty
|
||||
// device for the life of the app on every natural child exit (T-360).
|
||||
_nativeClose(_fd);
|
||||
final s = calloc<ffi.Int32>();
|
||||
_waitpid(pid, s, _kWnohang);
|
||||
calloc.free(s);
|
||||
|
||||
@@ -56,11 +56,11 @@ Stream<List<SearchMatch>> grepWorkspace({
|
||||
final walk = await walkFiles(root: root, ignore: ignore);
|
||||
if (cancel?.isCancelled ?? false) return;
|
||||
|
||||
final includes = [for (final g in query.include) _globToRegExp(g)];
|
||||
final excludes = [for (final g in query.exclude) _globToRegExp(g)];
|
||||
final includes = [for (final g in query.include) globToRegExp(g)];
|
||||
final excludes = [for (final g in query.exclude) globToRegExp(g)];
|
||||
final candidates = <String>[];
|
||||
for (final e in walk.files) {
|
||||
if (_acceptGlobs(e.path, includes, excludes)) candidates.add(e.path);
|
||||
if (acceptGlobs(e.path, includes, excludes)) candidates.add(e.path);
|
||||
}
|
||||
if (candidates.isEmpty) return;
|
||||
|
||||
@@ -193,7 +193,10 @@ class CompiledQuery {
|
||||
|
||||
// -- Glob filtering ----------------------------------------------------------
|
||||
|
||||
bool _acceptGlobs(String path, List<RegExp> includes, List<RegExp> excludes) {
|
||||
/// Whether [path] passes the compiled include/exclude filters. Shared with
|
||||
/// the replace engine so search and replace can never disagree on scope
|
||||
/// (T-364).
|
||||
bool acceptGlobs(String path, List<RegExp> includes, List<RegExp> excludes) {
|
||||
if (includes.isNotEmpty && !includes.any((r) => r.hasMatch(path))) return false;
|
||||
if (excludes.any((r) => r.hasMatch(path))) return false;
|
||||
return true;
|
||||
@@ -202,7 +205,7 @@ bool _acceptGlobs(String path, List<RegExp> includes, List<RegExp> excludes) {
|
||||
/// Compile a gitignore-flavoured glob to a full-path regex. A `/` in
|
||||
/// the glob anchors it to the workspace root; otherwise it may match at
|
||||
/// any depth (basename-style). Supports `*`, `**`, `?`.
|
||||
RegExp _globToRegExp(String glob) {
|
||||
RegExp globToRegExp(String glob) {
|
||||
final anchored = glob.contains('/');
|
||||
final b = StringBuffer('^');
|
||||
if (!anchored) b.write(r'(?:.*/)?');
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'dart:io';
|
||||
|
||||
import '../files/ignore.dart';
|
||||
import '../files/listing.dart';
|
||||
import 'grep_engine.dart' show acceptGlobs, globToRegExp;
|
||||
import 'match.dart';
|
||||
|
||||
/// One changed line within a file.
|
||||
@@ -133,9 +134,14 @@ Future<List<FileReplacement>> computeReplacements({
|
||||
|
||||
final walk = await walkFiles(root: root, ignore: ignore);
|
||||
final rootPath = root.absolute.path;
|
||||
// Same compiled glob filters as the grep engine — replace must never
|
||||
// touch a file the equivalent search wouldn't have matched (T-364).
|
||||
final includes = [for (final g in query.include) globToRegExp(g)];
|
||||
final excludes = [for (final g in query.exclude) globToRegExp(g)];
|
||||
final out = <FileReplacement>[];
|
||||
for (final entry in walk.files) {
|
||||
if (out.length >= maxFiles) break;
|
||||
if (!acceptGlobs(entry.path, includes, excludes)) continue;
|
||||
final fr = _replaceInFile(rootPath, entry.path, query, replacement);
|
||||
if (fr != null) out.add(fr);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/// The top window-chrome bar (D-57): drag region, menu bar, project
|
||||
/// switcher, window controls. Split out of app.dart (T-394).
|
||||
library;
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:clide/builtin/menubar/menubar.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/shell/project_switcher.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class HatBar extends StatelessWidget {
|
||||
const HatBar({super.key, required this.kernel, required this.menuBar});
|
||||
final KernelServices kernel;
|
||||
final MenuBarController menuBar;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return GestureDetector(
|
||||
onPanStart: (_) => kernel.window.startDrag(),
|
||||
child: Container(
|
||||
height: hatHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.chromeBackground,
|
||||
border: Border(bottom: BorderSide(color: tokens.chromeBorder, width: 1)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
_LeftHatContent(tokens: tokens, wc: kernel.window),
|
||||
MenuBar(controller: menuBar),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: ProjectSwitcherButton(kernel: kernel, tokens: tokens),
|
||||
),
|
||||
),
|
||||
_RightHatContent(tokens: tokens, wc: kernel.window),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LeftHatContent extends StatelessWidget {
|
||||
const _LeftHatContent({required this.tokens, required this.wc});
|
||||
final SurfaceTokens tokens;
|
||||
final WindowControls wc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (kIsWeb) return const SizedBox.shrink();
|
||||
// On macOS the native titlebar draws traffic lights; skip duplicates.
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
class _RightHatContent extends StatelessWidget {
|
||||
const _RightHatContent({required this.tokens, required this.wc});
|
||||
final SurfaceTokens tokens;
|
||||
final WindowControls wc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (kIsWeb) return const SizedBox.shrink();
|
||||
if (!kIsWeb && Platform.isMacOS) return const SizedBox.shrink();
|
||||
return Row(
|
||||
children: [
|
||||
_WinBtn(icon: const PhosphorIconPainter(0xe32a), onTap: wc.minimize, tokens: tokens),
|
||||
_WinBtn(icon: const PhosphorIconPainter(0xe45e), onTap: wc.toggleMaximize, tokens: tokens),
|
||||
_WinBtn(icon: PhosphorIcons.byName('x'), onTap: wc.close, tokens: tokens, isClose: true),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WinBtn extends StatelessWidget {
|
||||
const _WinBtn({required this.icon, required this.onTap, required this.tokens, this.isClose = false});
|
||||
final ClideIconPainter icon;
|
||||
final VoidCallback onTap;
|
||||
final SurfaceTokens tokens;
|
||||
final bool isClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hoverBg = isClose ? tokens.windowControlCloseHoverBackground : tokens.listItemHoverBackground;
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
width: 36,
|
||||
height: hatHeight,
|
||||
color: hovered ? hoverBg : null,
|
||||
alignment: Alignment.center,
|
||||
child: ClideIcon(icon, size: 14, color: hovered && isClose ? tokens.windowControlCloseHoverForeground : tokens.chromeForeground),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/// The root three-column layout grid, the status bar, and its
|
||||
/// collapse toggles + bottom icon rails. Split out of app.dart (T-394).
|
||||
library;
|
||||
|
||||
import 'package:clide/extension/src/contribution.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/shell/slot_host.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class RootLayout extends StatelessWidget {
|
||||
const RootLayout({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([kernel.panels, kernel.arrangement]),
|
||||
builder: (ctx, _) {
|
||||
final a = kernel.arrangement;
|
||||
final sidebarVisible = a.isVisible(Slots.sidebar);
|
||||
final sidebarCollapsed = a.isCollapsed(Slots.sidebar);
|
||||
final contextVisible = a.isVisible(Slots.contextPanel);
|
||||
final contextCollapsed = a.isCollapsed(Slots.contextPanel);
|
||||
final statusVisible = a.isVisible(Slots.statusbar);
|
||||
final sidebarSize = a.sizeOf(Slots.sidebar) ?? 400;
|
||||
final contextSize = a.sizeOf(Slots.contextPanel) ?? 420;
|
||||
final statusHeight = a.sizeOf(Slots.statusbar) ?? 26;
|
||||
// Bottom output dock (T-54 / D-87): pushes the workspace up when open,
|
||||
// capped at half the window so Claude stays the largest surface (the
|
||||
// D-47 amendment).
|
||||
final dockVisible = a.isVisible(Slots.dock);
|
||||
final dockMax = (((MediaQuery.of(ctx).size.height) - statusHeight) * 0.5).clamp(80.0, double.infinity).toDouble();
|
||||
final dockHeight = dockVisible ? ((a.sizeOf(Slots.dock) ?? 200).clamp(0.0, dockMax)).toDouble() : 0.0;
|
||||
|
||||
final column = Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
if (sidebarVisible && sidebarCollapsed)
|
||||
ClideSpine(label: _sidebarSpineLabel(kernel), side: SpineSide.left, onExpand: () => a.setCollapsed(Slots.sidebar, false))
|
||||
else if (sidebarVisible) ...[
|
||||
SizedBox(
|
||||
width: sidebarSize,
|
||||
child: SlotHost(slot: Slots.sidebar),
|
||||
),
|
||||
DragResizeHandle(arrangement: a, slot: Slots.sidebar, axis: Axis.horizontal),
|
||||
],
|
||||
const Expanded(child: SlotHost(slot: Slots.workspace)),
|
||||
if (contextVisible && contextCollapsed)
|
||||
ClideSpine(label: 'context', side: SpineSide.right, onExpand: () => a.setCollapsed(Slots.contextPanel, false))
|
||||
else if (contextVisible) ...[
|
||||
DragResizeHandle(arrangement: a, slot: Slots.contextPanel, axis: Axis.horizontal),
|
||||
SizedBox(
|
||||
width: contextSize,
|
||||
child: SlotHost(slot: Slots.contextPanel),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (dockVisible)
|
||||
SizedBox(
|
||||
height: dockHeight,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.chromeBorder)),
|
||||
),
|
||||
child: SlotHost(slot: Slots.dock),
|
||||
),
|
||||
),
|
||||
if (statusVisible)
|
||||
Container(
|
||||
height: statusHeight,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.chromeBorder)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Collapse toggles are pinned to the screen edges (outermost
|
||||
// children) so they never shift when a pane collapses (T-294).
|
||||
StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: sidebarCollapsed, visible: sidebarVisible),
|
||||
if (sidebarVisible && !sidebarCollapsed)
|
||||
SizedBox(
|
||||
width: sidebarSize,
|
||||
child: _BottomRail(slot: Slots.sidebar),
|
||||
)
|
||||
else if (sidebarVisible && sidebarCollapsed)
|
||||
const SizedBox(width: ClideSpine.width),
|
||||
const Expanded(child: StatusbarHost()),
|
||||
if (contextVisible && !contextCollapsed)
|
||||
SizedBox(
|
||||
width: contextSize,
|
||||
child: _BottomRail(slot: Slots.contextPanel),
|
||||
)
|
||||
else if (contextVisible && contextCollapsed)
|
||||
const SizedBox(width: ClideSpine.width),
|
||||
StatusbarCollapseToggle(slot: Slots.contextPanel, collapsed: contextCollapsed, visible: contextVisible),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
// When the status bar is hidden it no longer occupies the window's
|
||||
// bottom edge, so the bottom-most content (the Claude composer, an
|
||||
// editor, a terminal) would otherwise run flush into the resize-drag
|
||||
// strip and look jammed against the window bottom (T-298). Reserve a
|
||||
// matching inset so the interaction zone bottom-anchors consistently,
|
||||
// independent of status-bar visibility.
|
||||
if (statusVisible) return column;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: ClideResizeBorder.edgeThickness),
|
||||
child: column,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static String _sidebarSpineLabel(KernelServices kernel) {
|
||||
final activeTab = kernel.panels.activeTabIn(Slots.sidebar);
|
||||
if (activeTab == null) return 'overview';
|
||||
final tabs = kernel.panels.tabsFor(Slots.sidebar);
|
||||
for (final t in tabs) {
|
||||
if (t.id == activeTab) return t.title.toLowerCase();
|
||||
}
|
||||
return 'overview';
|
||||
}
|
||||
}
|
||||
|
||||
class _BottomRail extends StatelessWidget {
|
||||
const _BottomRail({required this.slot});
|
||||
final SlotId slot;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.panels,
|
||||
builder: (ctx, _) {
|
||||
final tabs = kernel.panels.tabsFor(slot);
|
||||
if (tabs.isEmpty) return Container(color: tokens.chromeBackground);
|
||||
final activeId = kernel.panels.activeTabIn(slot) ?? tabs.first.id;
|
||||
return Container(
|
||||
color: tokens.chromeBackground,
|
||||
child: ClideIconRail(
|
||||
items: [for (final t in tabs) ClideIconRailItem(id: t.id, icon: _iconFor(slot, t), tooltip: resolveTabTitle(ctx, t), iconColor: t.iconColor)],
|
||||
activeId: activeId,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static ClideIconPainter _iconFor(SlotId slot, TabContribution t) {
|
||||
if (t.icon is ClideIconPainter) return t.icon as ClideIconPainter;
|
||||
if (slot == Slots.sidebar) {
|
||||
return switch (t.id) {
|
||||
'files.tree' => PhosphorIcons.byName('folder'),
|
||||
'git.panel' => PhosphorIcons.byName('git-branch'),
|
||||
'pql.panel' => PhosphorIcons.byName('magnifying-glass'),
|
||||
'problems.panel' => PhosphorIcons.byName('warning-circle'),
|
||||
'decisions.panel' => PhosphorIcons.byName('lightbulb'),
|
||||
'tickets.panel' => PhosphorIcons.byName('ticket'),
|
||||
_ => PhosphorIcons.byName('circles-four'),
|
||||
};
|
||||
}
|
||||
return switch (t.id) {
|
||||
'markdown.viewer' => PhosphorIcons.byName('eye'),
|
||||
'graph.view' => PhosphorIcons.byName('graph'),
|
||||
'pql.backlinks' => PhosphorIcons.byName('link'),
|
||||
_ => PhosphorIcons.byName('circles-four'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// A fixed-position collapse/expand toggle bookending the status bar (T-294).
|
||||
/// The left cell controls the sidebar, the right cell the context pane; both
|
||||
/// fire the existing `sidebar.collapse` / `context.collapse` commands and flip a
|
||||
/// caret-line chevron per `arrangement.isCollapsed` (outward = expand, inward =
|
||||
/// collapse). The collapse behaviour itself lives in the commands (D-51/D-54);
|
||||
/// this is the mouse affordance for the keyboard/CLI-addressable action (D-6).
|
||||
/// A fixed collapse/expand toggle pinned to a screen edge of the status bar
|
||||
/// (T-294). Lives at the outer ends of the bar — NOT inside the centre
|
||||
/// [StatusbarHost] — so it never shifts when a pane collapses and the centre
|
||||
/// bar resizes. [collapsed]/[visible] are passed in (not read from the
|
||||
/// arrangement here) so the widget varies with state and rebuilds when its
|
||||
/// parent's `ListenableBuilder` fires — a const widget reading the arrangement
|
||||
/// itself is skipped as identical on rebuild, freezing the chevron.
|
||||
class StatusbarCollapseToggle extends StatelessWidget {
|
||||
const StatusbarCollapseToggle({super.key, required this.slot, required this.collapsed, required this.visible});
|
||||
|
||||
final SlotId slot;
|
||||
final bool collapsed;
|
||||
final bool visible;
|
||||
|
||||
bool get _isSidebar => slot == Slots.sidebar;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
if (!visible) return const SizedBox(width: 24);
|
||||
// The chevron points the DIRECTION OF THE ACTION: collapsing tucks the pane
|
||||
// toward its own edge, expanding brings it back toward the centre.
|
||||
final icon = _isSidebar
|
||||
? (collapsed ? PhosphorIcons.byName('caret-line-right') : PhosphorIcons.byName('caret-line-left'))
|
||||
: (collapsed ? PhosphorIcons.byName('caret-line-left') : PhosphorIcons.byName('caret-line-right'));
|
||||
final what = _isSidebar ? 'sidebar' : 'context panel';
|
||||
return SizedBox(
|
||||
width: 24,
|
||||
child: ClideTappable(
|
||||
onTap: () => kernel.commands.execute(_isSidebar ? 'sidebar.collapse' : 'context.collapse'),
|
||||
tooltip: collapsed ? 'Show $what' : 'Hide $what',
|
||||
builder: (ctx, hovered, focused) => Container(
|
||||
alignment: Alignment.center,
|
||||
color: (hovered || focused) ? tokens.listItemHoverBackground : null,
|
||||
child: ClideIcon(icon, size: 13, color: tokens.statusBarForeground),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StatusbarHost extends StatelessWidget {
|
||||
const StatusbarHost({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.panels,
|
||||
builder: (ctx, _) {
|
||||
final items = kernel.panels.contributionsFor(Slots.statusbar).whereType<StatusItemContribution>().toList();
|
||||
final left = items.where((i) => i.priority < 100).toList();
|
||||
final right = items.where((i) => i.priority >= 100).toList();
|
||||
// Two explicit columns within the center (workspace) bar: the LEFT
|
||||
// group lives in an Expanded so it absorbs all free space and is
|
||||
// start-aligned, and the RIGHT group (tool status, theme switcher)
|
||||
// trails it at intrinsic width — so it hugs the workspace block's
|
||||
// right edge by construction, no Spacer to fight a flex item (T-239).
|
||||
// Left items with flex > 0 wrap in Flexible(loose) so they yield width
|
||||
// when tight (T-160).
|
||||
return Container(
|
||||
color: tokens.chromeBackground,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
for (final item in left)
|
||||
if (item.flex > 0) Flexible(flex: item.flex, fit: FlexFit.loose, child: item.build(ctx)) else item.build(ctx),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final item in right) item.build(ctx),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/// The hat bar's project switcher: current-project label opening a
|
||||
/// recents + file-actions dropdown. Split out of app.dart (T-394).
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:clide/clide.dart' show clideName;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ProjectSwitcherButton extends StatelessWidget {
|
||||
const ProjectSwitcherButton({super.key, required this.kernel, required this.tokens});
|
||||
final KernelServices kernel;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
void _openSwitcher() {
|
||||
kernel.dialog.show<String>((ctx, dismiss) {
|
||||
return _ProjectSwitcherDropdown(kernel: kernel, onDismiss: dismiss);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.project,
|
||||
builder: (ctx, _) {
|
||||
final name = kernel.project.current?.path.split('/').last;
|
||||
final label = name != null ? '$clideName > $name' : clideName;
|
||||
return ClideTappable(
|
||||
onTap: _openSwitcher,
|
||||
builder: (context, hovered, _) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText(label, fontSize: 12, color: hovered ? tokens.globalForeground : tokens.chromeForeground, fontFamily: clideMonoFamily),
|
||||
const SizedBox(width: 4),
|
||||
ClideIcon(PhosphorIcons.byName('caret-down'), size: 8, color: tokens.chromeForeground),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProjectSwitcherDropdown extends StatefulWidget {
|
||||
const _ProjectSwitcherDropdown({required this.kernel, required this.onDismiss});
|
||||
final KernelServices kernel;
|
||||
final void Function([String?]) onDismiss;
|
||||
|
||||
@override
|
||||
State<_ProjectSwitcherDropdown> createState() => _ProjectSwitcherDropdownState();
|
||||
}
|
||||
|
||||
class _ProjectSwitcherDropdownState extends State<_ProjectSwitcherDropdown> {
|
||||
String _filter = '';
|
||||
late final FocusNode _focus;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focus = FocusNode()..requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _openProject(String path) async {
|
||||
final ok = await widget.kernel.project.open(path);
|
||||
if (ok) {
|
||||
widget.kernel.panels.activateTab(Slots.workspace, 'claude.primary');
|
||||
widget.onDismiss();
|
||||
}
|
||||
}
|
||||
|
||||
// File actions now live as commands (file.openFolder / file.newWindow /
|
||||
// file.closeWorkspace) owned by the menu-bar extension (T-48). The switcher
|
||||
// dismisses itself and dispatches the command so both surfaces share one
|
||||
// implementation.
|
||||
void _runFileCommand(String command) {
|
||||
widget.onDismiss();
|
||||
unawaited(widget.kernel.commands.execute(command));
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
||||
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
widget.onDismiss();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final recents = widget.kernel.project.recents;
|
||||
final lf = _filter.toLowerCase();
|
||||
final filtered = lf.isEmpty ? recents : recents.where((r) => r.name.toLowerCase().contains(lf) || r.path.toLowerCase().contains(lf)).toList();
|
||||
|
||||
return Focus(
|
||||
focusNode: _focus,
|
||||
onKeyEvent: _onKey,
|
||||
child: Container(
|
||||
width: 480,
|
||||
constraints: const BoxConstraints(maxHeight: 420),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.dropdownBackground,
|
||||
border: Border.all(color: tokens.dropdownBorder),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClideFilterBox(hint: 'Search projects…', onChanged: (v) => setState(() => _filter = v)),
|
||||
if (filtered.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ClideText('Recent Projects', fontSize: clideFontCaption, color: tokens.globalTextMuted),
|
||||
),
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (ctx, i) => _RecentProjectRow(project: filtered[i], tokens: tokens, onTap: () => _openProject(filtered[i].path)),
|
||||
),
|
||||
),
|
||||
] else
|
||||
const Padding(padding: EdgeInsets.all(12), child: ClideText('No recent projects.', muted: true)),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: tokens.dividerColor)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_ActionRow(
|
||||
label: 'Open Local Project',
|
||||
shortcut: Platform.isMacOS ? '⌘O' : 'Ctrl+O',
|
||||
tokens: tokens,
|
||||
onTap: () => _runFileCommand('file.openFolder'),
|
||||
),
|
||||
_ActionRow(
|
||||
label: 'New Window',
|
||||
shortcut: Platform.isMacOS ? '⌘⇧N' : 'Ctrl+Shift+N',
|
||||
tokens: tokens,
|
||||
onTap: () => _runFileCommand('file.newWindow'),
|
||||
),
|
||||
if (widget.kernel.project.isOpen)
|
||||
_ActionRow(label: 'Close Project', shortcut: '', tokens: tokens, onTap: () => _runFileCommand('file.closeWorkspace')),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentProjectRow extends StatelessWidget {
|
||||
const _RecentProjectRow({required this.project, required this.tokens, required this.onTap});
|
||||
final RecentProject project;
|
||||
final SurfaceTokens tokens;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideIcon(PhosphorIcons.byName('folder'), size: 14, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(project.name, fontSize: 14),
|
||||
if (project.branch != null)
|
||||
Row(
|
||||
children: [
|
||||
// Elide a long path instead of overflowing the row
|
||||
// (matches the welcome recents row; T-160 discipline).
|
||||
Flexible(
|
||||
child: ClideText(
|
||||
project.relativePath,
|
||||
muted: true,
|
||||
fontSize: 12,
|
||||
fontFamily: clideMonoFamily,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
ClideText(' · ', muted: true, fontSize: 12),
|
||||
ClideIcon(PhosphorIcons.byName('git-branch'), size: 10, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 3),
|
||||
ClideText(project.branch!, muted: true, fontSize: 12, fontFamily: clideMonoFamily),
|
||||
],
|
||||
)
|
||||
else
|
||||
ClideText(project.relativePath, muted: true, fontSize: 12, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
),
|
||||
ClideText(project.timeAgo, muted: true, fontSize: 11),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionRow extends StatelessWidget {
|
||||
const _ActionRow({required this.label, this.shortcut, required this.tokens, required this.onTap});
|
||||
final String label;
|
||||
final String? shortcut;
|
||||
final SurfaceTokens tokens;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: ClideText(label, fontSize: 14)),
|
||||
if (shortcut != null && shortcut!.isNotEmpty) ClideText(shortcut!, fontSize: 12, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/// The application root shell: global keyboard/intent routing (keymap
|
||||
/// resolution, double-tap modifiers, menu mnemonics), the hat bar, and
|
||||
/// the overlay stack (palette, quick-open, welcome, toasts). Split out
|
||||
/// of app.dart (T-394).
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/menubar/menubar.dart';
|
||||
import 'package:clide/builtin/welcome/src/welcome_view.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/shell/hat_bar.dart';
|
||||
import 'package:clide/src/shell/layout.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class RootShell extends StatefulWidget {
|
||||
const RootShell({super.key, required this.services});
|
||||
final KernelServices services;
|
||||
|
||||
@override
|
||||
State<RootShell> createState() => RootShellState();
|
||||
}
|
||||
|
||||
class RootShellState extends State<RootShell> {
|
||||
late final FocusNode _keyFocus;
|
||||
final MenuBarController _menuBar = MenuBarController();
|
||||
// Detects double-tapped bare modifiers (e.g. double-Shift → quick-open,
|
||||
// JetBrains "Search Everywhere"). Fed from a HardwareKeyboard handler, not
|
||||
// the focus tree: a focused editor consumes the chorded key of `Shift+;`,
|
||||
// so the gesture must observe every event to know a press wasn't bare
|
||||
// (T-341, T-409).
|
||||
final ModifierTapTracker _modTap = ModifierTapTracker();
|
||||
|
||||
// Global multi-chord matcher for window/tab commands (ctrl+w h, gt …) (T-404).
|
||||
// The passive KeyboardListener can't run sequences or consume the second
|
||||
// chord (a focused editor/pane swallows it), so this lives at the
|
||||
// HardwareKeyboard level where returning true consumes the event before focus
|
||||
// dispatch. It only engages for chords that START a multi-chord binding in the
|
||||
// active keymap, so single-chord presets (default/vscode/jetbrains) are
|
||||
// untouched.
|
||||
late final SequenceMatcher _globalSeq;
|
||||
Timer? _seqTimeout;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_keyFocus = FocusNode()..requestFocus();
|
||||
widget.services.textZoom.addListener(_onZoom);
|
||||
_globalSeq = SequenceMatcher(
|
||||
keymap: () => widget.services.keymap.keymap ?? Keymap(const []),
|
||||
context: () => widget.services.keymap.scope,
|
||||
captureCounts: false,
|
||||
);
|
||||
HardwareKeyboard.instance.addHandler(_onRawKey);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
HardwareKeyboard.instance.removeHandler(_onRawKey);
|
||||
_seqTimeout?.cancel();
|
||||
widget.services.textZoom.removeListener(_onZoom);
|
||||
_menuBar.dispose();
|
||||
_keyFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onZoom() => setState(() {});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
color: tokens.globalForeground,
|
||||
fontSize: 15,
|
||||
height: clideLineHeight,
|
||||
fontWeight: clideUiDefaultWeight,
|
||||
fontFamily: clideUiFamily,
|
||||
fontFamilyFallback: clideUiFamilyFallback,
|
||||
),
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(widget.services.textZoom.scale)),
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{
|
||||
TextScaleIncreaseIntent: CallbackAction<TextScaleIncreaseIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.textZoom.increase();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
TextScaleDecreaseIntent: CallbackAction<TextScaleDecreaseIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.textZoom.decrease();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
TextScaleResetIntent: CallbackAction<TextScaleResetIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.textZoom.reset();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
InvokeCommandIntent: CallbackAction<InvokeCommandIntent>(
|
||||
onInvoke: (intent) {
|
||||
widget.services.commands.execute(intent.commandId);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
PaletteOpenIntent: CallbackAction<PaletteOpenIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.palette.open();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
QuickOpenIntent: CallbackAction<QuickOpenIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.quickOpen.open();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
FindInFilesIntent: CallbackAction<FindInFilesIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.arrangement.setVisible(Slots.sidebar, true);
|
||||
widget.services.arrangement.setCollapsed(Slots.sidebar, false);
|
||||
widget.services.panels.activateTab(Slots.sidebar, 'search.findInFiles');
|
||||
return null;
|
||||
},
|
||||
),
|
||||
FocusNextPanelIntent: CallbackAction<FocusNextPanelIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.focus.focusNextSlot();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
FocusPreviousPanelIntent: CallbackAction<FocusPreviousPanelIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.focus.focusPreviousSlot();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: KeyboardListener(
|
||||
focusNode: _keyFocus,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: ColoredBox(
|
||||
color: tokens.globalBackground,
|
||||
child: ClideResizeBorder(
|
||||
windowControls: widget.services.window,
|
||||
child: Column(
|
||||
children: [
|
||||
HatBar(kernel: widget.services, menuBar: _menuBar),
|
||||
Expanded(
|
||||
child: DialogHost(
|
||||
router: widget.services.dialog,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: RootLayout()),
|
||||
const ClidePalette(),
|
||||
const QuickOpenOverlay(),
|
||||
const Positioned.fill(child: _WelcomeOverlay()),
|
||||
const ToastOverlay(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onKey(KeyEvent event) {
|
||||
if (_handleMenuMnemonic(event)) return;
|
||||
final intent = widget.services.keymap.resolveEvent(event, HardwareKeyboard.instance);
|
||||
if (intent == null) return;
|
||||
_dispatchIntent(intent);
|
||||
}
|
||||
|
||||
/// Double-tapped bare modifier (e.g. double-Shift → quick-open). Observed
|
||||
/// at the HardwareKeyboard level — before focus dispatch and regardless of
|
||||
/// who consumes the event — so a chorded key the focused editor swallows
|
||||
/// (the `;` of `Shift+;`) still dirties the press (T-341, T-409). Fires on
|
||||
/// the second clean *release*; never consumes anything.
|
||||
bool _onRawKey(KeyEvent event) {
|
||||
// Global window/tab sequences (ctrl+w h, gt …) get first claim — handled
|
||||
// here so a focused editor/pane can't swallow the second chord (T-404).
|
||||
if (_handleGlobalSequence(event)) return true;
|
||||
if (event is KeyDownEvent) {
|
||||
var mod = KeyChord.modifierForLogicalKey(event.logicalKey);
|
||||
// A modifier pressed while a non-modifier is already held (rolled
|
||||
// `a`+Shift) is a chord, not a tap.
|
||||
if (mod != null && _nonModifierHeld()) mod = null;
|
||||
_modTap.down(mod);
|
||||
} else if (event is KeyUpEvent) {
|
||||
final mod = _modTap.up(KeyChord.modifierForLogicalKey(event.logicalKey), DateTime.now());
|
||||
if (mod != null) {
|
||||
final seq = [KeyChord.bareModifier(mod), KeyChord.bareModifier(mod)];
|
||||
final tapIntent = widget.services.keymap.resolveSequence(seq);
|
||||
if (tapIntent != null) _dispatchIntent(tapIntent);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _nonModifierHeld() => HardwareKeyboard.instance.logicalKeysPressed.any((k) => KeyChord.modifierForLogicalKey(k) == null);
|
||||
|
||||
/// Feed one key into the global multi-chord matcher (T-404). Returns true to
|
||||
/// CONSUME the event (suppressing focus dispatch) while a sequence is being
|
||||
/// built or completes; false leaves the normal single-chord [_onKey] path
|
||||
/// untouched. Only KeyDown events drive it — a held key must not re-fire a
|
||||
/// window command.
|
||||
bool _handleGlobalSequence(KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return false;
|
||||
final chord = KeyChord.fromKeyEvent(event, HardwareKeyboard.instance);
|
||||
if (chord == null) return false;
|
||||
final km = widget.services.keymap.keymap;
|
||||
if (km == null) return false;
|
||||
final scope = widget.services.keymap.scope;
|
||||
// Not mid-sequence: only START on a MODIFIED chord that's a sequence prefix
|
||||
// (ctrl+w …). Bare-key sequences (gg, dd) are editor/pane-local — the
|
||||
// focused widget owns them, so a global grab would steal the first chord
|
||||
// before the editor ever saw it. Once pending, the bare second chord (the
|
||||
// `h` of `ctrl+w h`) is consumed normally. Single-chord presets are
|
||||
// untouched (no prefix → no engage).
|
||||
if (!_globalSeq.hasPending) {
|
||||
final modified = chord.modifiers.any((m) => m != KeyModifier.shift);
|
||||
if (!modified || !km.match([chord], scope).isPrefix) return false;
|
||||
}
|
||||
final r = _globalSeq.feed(chord);
|
||||
switch (r.outcome) {
|
||||
case SeqOutcome.pending:
|
||||
_armSeqTimeout();
|
||||
return true;
|
||||
case SeqOutcome.fired:
|
||||
_cancelSeqTimeout();
|
||||
_dispatchIntent(r.intent!);
|
||||
return true;
|
||||
case SeqOutcome.unmatched:
|
||||
// The sequence broke — drop the buffer and let this lone key through to
|
||||
// normal handling (the abandoned prefix, e.g. a bare ctrl+w, simply
|
||||
// does nothing rather than firing late).
|
||||
_cancelSeqTimeout();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// After a pending prefix, fire its buffered exact match (bare ctrl+w →
|
||||
/// editor.close) if no completing chord arrives in time — the d-vs-dd timeout
|
||||
/// (D-82), applied globally.
|
||||
void _armSeqTimeout() {
|
||||
_seqTimeout?.cancel();
|
||||
_seqTimeout = Timer(const Duration(milliseconds: 400), () {
|
||||
final r = _globalSeq.flush();
|
||||
if (r.outcome == SeqOutcome.fired) _dispatchIntent(r.intent!);
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelSeqTimeout() {
|
||||
_seqTimeout?.cancel();
|
||||
_seqTimeout = null;
|
||||
}
|
||||
|
||||
void _dispatchIntent(Intent intent) {
|
||||
// Try the focused context first so feature widgets (palette, editor, …)
|
||||
// get a chance to handle their own intents; fall back to the app root's
|
||||
// Actions for global ones (text scale, generic command bridge).
|
||||
final ctx = FocusManager.instance.primaryFocus?.context ?? context;
|
||||
Actions.maybeInvoke(ctx, intent);
|
||||
}
|
||||
|
||||
/// `Alt+<mnemonic>` opens (or toggles) the matching application menu (T-48).
|
||||
/// Returns true when consumed so it never falls through to keymap resolution.
|
||||
bool _handleMenuMnemonic(KeyEvent event) {
|
||||
if (event is! KeyDownEvent || !HardwareKeyboard.instance.isAltPressed) return false;
|
||||
final label = event.logicalKey.keyLabel.toLowerCase();
|
||||
if (label.length != 1) return false;
|
||||
final idx = _menuBar.indexForMnemonic(label);
|
||||
if (idx == null) return false;
|
||||
_menuBar.toggle(idx);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class _WelcomeOverlay extends StatelessWidget {
|
||||
const _WelcomeOverlay();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.project,
|
||||
builder: (ctx, _) {
|
||||
if (kernel.project.isOpen) return const SizedBox.shrink();
|
||||
final tokens = ClideTheme.of(ctx).surface;
|
||||
return ColoredBox(color: tokens.globalBackground, child: const WelcomeView());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
/// Slot hosting: mounts a slot's tab contributions, integrates focus
|
||||
/// scopes, and renders the slot-specific bodies (sidebar / workspace
|
||||
/// split incl. the editor drag handle / context). Split out of
|
||||
/// app.dart (T-394).
|
||||
library;
|
||||
|
||||
import 'package:clide/extension/src/contribution.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class SlotHost extends StatefulWidget {
|
||||
const SlotHost({super.key, required this.slot});
|
||||
final SlotId slot;
|
||||
|
||||
@override
|
||||
State<SlotHost> createState() => _SlotHostState();
|
||||
}
|
||||
|
||||
class _SlotHostState extends State<SlotHost> {
|
||||
late final FocusScopeNode _scope = FocusScopeNode(debugLabel: 'SlotScope:${widget.slot.value}');
|
||||
FocusTracker? _tracker;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final kernel = ClideKernel.of(context);
|
||||
if (!identical(_tracker, kernel.focus)) {
|
||||
_tracker?.unregisterSlotScope(widget.slot, _scope);
|
||||
_tracker = kernel.focus;
|
||||
_tracker!.registerSlotScope(widget.slot, _scope);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tracker?.unregisterSlotScope(widget.slot, _scope);
|
||||
_scope.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFocusChange(bool hasFocus) {
|
||||
if (!hasFocus || _tracker == null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
final activeId = kernel.panels.activeTabIn(widget.slot);
|
||||
if (activeId != null) {
|
||||
_tracker!.setActive(slot: widget.slot, contributionId: activeId);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return FocusScope(
|
||||
node: _scope,
|
||||
onFocusChange: _onFocusChange,
|
||||
child: FocusTraversalGroup(
|
||||
child: ListenableBuilder(
|
||||
listenable: Listenable.merge([kernel.panels, kernel.i18n]),
|
||||
builder: (ctx, _) {
|
||||
final tabs = kernel.panels.tabsFor(widget.slot);
|
||||
if (tabs.isEmpty) {
|
||||
return Container(color: tokens.panelBackground);
|
||||
}
|
||||
final activeId = kernel.panels.activeTabIn(widget.slot) ?? tabs.first.id;
|
||||
final active = tabs.firstWhere((t) => t.id == activeId, orElse: () => tabs.first);
|
||||
return _SlotBody(slot: widget.slot, tabs: tabs, active: active, activeId: activeId);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SlotBody extends StatelessWidget {
|
||||
const _SlotBody({required this.slot, required this.tabs, required this.active, required this.activeId});
|
||||
final SlotId slot;
|
||||
final List<TabContribution> tabs;
|
||||
final TabContribution active;
|
||||
final String activeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
|
||||
if (slot == Slots.sidebar) {
|
||||
return _SidebarSlot(tabs: tabs, active: active, activeId: activeId, onSelect: (id) => kernel.panels.activateTab(slot, id));
|
||||
}
|
||||
|
||||
if (slot == Slots.contextPanel) {
|
||||
return _ContextSlot(tabs: tabs, active: active, activeId: activeId, onSelect: (id) => kernel.panels.activateTab(slot, id));
|
||||
}
|
||||
|
||||
if (slot == Slots.workspace) {
|
||||
return _WorkspaceSlot(tabs: tabs, active: active);
|
||||
}
|
||||
|
||||
return Container(
|
||||
color: tokens.panelBackground,
|
||||
child: Column(
|
||||
children: [
|
||||
ClideTabBar(
|
||||
items: [for (final t in tabs) ClideTabItem(id: t.id, title: resolveTabTitle(context, t))],
|
||||
activeId: active.id,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
),
|
||||
ClideDivider(),
|
||||
Expanded(child: active.build(context)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SidebarSlot extends StatelessWidget {
|
||||
const _SidebarSlot({required this.tabs, required this.active, required this.activeId, required this.onSelect});
|
||||
|
||||
final List<TabContribution> tabs;
|
||||
final TabContribution active;
|
||||
final String activeId;
|
||||
final ValueChanged<String> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(
|
||||
color: tokens.chromeBackground,
|
||||
alignment: Alignment.topLeft,
|
||||
padding: const EdgeInsets.fromLTRB(2, 2, 0, 0),
|
||||
child: active.build(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Stable identity for the workspace's primary pane (Claude). Opening the
|
||||
// editor reparents it from a direct child into a Column/Expanded; without a
|
||||
// stable key Flutter disposes + rebuilds the subtree, and the Claude
|
||||
// conversation's SelectableRegion then runs a pending selection update
|
||||
// against now-inactive elements ("selectable not in this registrar" /
|
||||
// "renderObject of inactive element"). The GlobalKey makes Flutter MOVE the
|
||||
// element instead, preserving the selection subtree.
|
||||
final GlobalKey _kWorkspacePrimary = GlobalKey(debugLabel: 'workspace.primary');
|
||||
|
||||
class _WorkspaceSlot extends StatelessWidget {
|
||||
const _WorkspaceSlot({required this.tabs, required this.active});
|
||||
|
||||
final List<TabContribution> tabs;
|
||||
final TabContribution active;
|
||||
|
||||
static const _editorTabId = 'editor.active';
|
||||
static const _claudeTabId = 'claude.primary';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.arrangement,
|
||||
builder: (ctx, _) {
|
||||
final editorOpen = kernel.arrangement.editorOpen;
|
||||
final editorTab = tabs.where((t) => t.id == _editorTabId).firstOrNull;
|
||||
|
||||
final claude = tabs.where((t) => t.id == _claudeTabId).firstOrNull;
|
||||
final primaryPane = KeyedSubtree(key: _kWorkspacePrimary, child: (claude ?? active).build(ctx));
|
||||
|
||||
// A non-Claude, non-editor workspace tab being the active one (e.g.
|
||||
// diff.view revealed by `clide ui open diff`, T-233) shows in the split
|
||||
// region above Claude — "review alongside the conversation" — with a
|
||||
// close affordance back to full-Claude. Only when Claude exists below
|
||||
// it; with no Claude pane the active tab just takes the whole slot, as
|
||||
// before. The editor keeps its own editorOpen-gated split.
|
||||
final reveal = (claude != null && active.id != _claudeTabId && active.id != _editorTabId) ? active : null;
|
||||
final topTab = reveal ?? (editorOpen ? editorTab : null);
|
||||
|
||||
if (topTab == null) {
|
||||
return Container(color: tokens.panelBackground, child: primaryPane);
|
||||
}
|
||||
|
||||
final ratio = kernel.arrangement.editorRatio;
|
||||
return Container(
|
||||
color: tokens.panelBackground,
|
||||
child: LayoutBuilder(
|
||||
builder: (ctx, constraints) {
|
||||
final totalHeight = constraints.maxHeight;
|
||||
final topHeight = (totalHeight * ratio).clamp(60.0, totalHeight - 60.0);
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: topHeight,
|
||||
child: reveal != null
|
||||
? _RevealedTab(tab: reveal, onClose: () => kernel.panels.activateTab(Slots.workspace, _claudeTabId))
|
||||
: topTab.build(ctx),
|
||||
),
|
||||
_EditorDragHandle(arrangement: kernel.arrangement, totalHeight: totalHeight),
|
||||
Expanded(child: primaryPane),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-Claude workspace tab revealed in the split region above Claude
|
||||
/// (T-233): a thin chrome header (title + close) over the tab's body, so the
|
||||
/// user can review it alongside the conversation and dismiss it back to
|
||||
/// full-Claude. The editor uses its own split path and never renders here.
|
||||
class _RevealedTab extends StatelessWidget {
|
||||
const _RevealedTab({required this.tab, required this.onClose});
|
||||
|
||||
final TabContribution tab;
|
||||
final VoidCallback onClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 28,
|
||||
padding: const EdgeInsets.only(left: 10, right: 4),
|
||||
color: tokens.panelHeader,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(resolveTabTitle(context, tab), fontSize: clideFontCaption, color: tokens.panelHeaderForeground, maxLines: 1),
|
||||
),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Close',
|
||||
excludeSemantics: true,
|
||||
onTap: onClose,
|
||||
child: ClideTappable(
|
||||
onTap: onClose,
|
||||
tooltip: 'Close',
|
||||
builder: (_, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: ClideIcon(PhosphorIcons.byName('x'), size: 12, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: tab.build(context)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EditorDragHandle extends StatefulWidget {
|
||||
const _EditorDragHandle({required this.arrangement, required this.totalHeight});
|
||||
|
||||
final LayoutArrangement arrangement;
|
||||
final double totalHeight;
|
||||
|
||||
@override
|
||||
State<_EditorDragHandle> createState() => _EditorDragHandleState();
|
||||
}
|
||||
|
||||
class _EditorDragHandleState extends State<_EditorDragHandle> {
|
||||
bool _hovered = false;
|
||||
bool _focused = false;
|
||||
double? _dragStartRatio;
|
||||
double? _dragStartY;
|
||||
|
||||
// Editor split is a 0..1 fraction; the kernel clamps to 0.15..0.70.
|
||||
// 2% per fine step, 10% per Shift step keeps keyboard feel close to
|
||||
// the pixel-based DragResizeHandle.
|
||||
static const double _stepFine = 0.02;
|
||||
static const double _stepCoarse = 0.10;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final lineColor = (_hovered || _focused) ? tokens.panelActiveBorder : tokens.panelBorder;
|
||||
|
||||
final ratio = widget.arrangement.editorRatio;
|
||||
String pct(double r) => '${(r.clamp(0.15, 0.70) * 100).round()}%';
|
||||
return Semantics(
|
||||
container: true,
|
||||
slider: true,
|
||||
label: 'Editor split',
|
||||
value: pct(ratio),
|
||||
// increase/decrease actions require matching increased/decreased
|
||||
// values, or Flutter asserts on every semantics flush.
|
||||
increasedValue: pct(ratio + _stepFine),
|
||||
decreasedValue: pct(ratio - _stepFine),
|
||||
onIncrease: () => _bump(_stepFine),
|
||||
onDecrease: () => _bump(-_stepFine),
|
||||
child: FocusableActionDetector(
|
||||
onShowFocusHighlight: (v) => setState(() => _focused = v),
|
||||
shortcuts: const <ShortcutActivator, Intent>{
|
||||
SingleActivator(LogicalKeyboardKey.arrowUp): _EditorBumpIntent(-_stepFine),
|
||||
SingleActivator(LogicalKeyboardKey.arrowDown): _EditorBumpIntent(_stepFine),
|
||||
SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): _EditorBumpIntent(-_stepCoarse),
|
||||
SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): _EditorBumpIntent(_stepCoarse),
|
||||
},
|
||||
actions: <Type, Action<Intent>>{
|
||||
_EditorBumpIntent: CallbackAction<_EditorBumpIntent>(
|
||||
onInvoke: (intent) {
|
||||
_bump(intent.delta);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeRow,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Listener(
|
||||
onPointerDown: (e) {
|
||||
_dragStartRatio = widget.arrangement.editorRatio;
|
||||
_dragStartY = e.position.dy;
|
||||
},
|
||||
onPointerMove: (e) {
|
||||
final startR = _dragStartRatio;
|
||||
final startY = _dragStartY;
|
||||
if (startR == null || startY == null || widget.totalHeight <= 0) return;
|
||||
final deltaRatio = (e.position.dy - startY) / widget.totalHeight;
|
||||
widget.arrangement.setEditorRatio(startR + deltaRatio);
|
||||
},
|
||||
onPointerUp: (_) {
|
||||
_dragStartRatio = null;
|
||||
_dragStartY = null;
|
||||
},
|
||||
child: Container(height: 4, color: lineColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _bump(double delta) {
|
||||
widget.arrangement.setEditorRatio(widget.arrangement.editorRatio + delta);
|
||||
}
|
||||
}
|
||||
|
||||
class _EditorBumpIntent extends Intent {
|
||||
const _EditorBumpIntent(this.delta);
|
||||
final double delta;
|
||||
}
|
||||
|
||||
class _ContextSlot extends StatelessWidget {
|
||||
const _ContextSlot({required this.tabs, required this.active, required this.activeId, required this.onSelect});
|
||||
|
||||
final List<TabContribution> tabs;
|
||||
final TabContribution active;
|
||||
final String activeId;
|
||||
final ValueChanged<String> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(color: tokens.panelBackground, alignment: Alignment.topLeft, padding: const EdgeInsets.only(right: 2), child: active.build(context));
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a tab's display title through i18n when it carries a key +
|
||||
/// namespace, else its static title. Shared by the slot bodies, the
|
||||
/// revealed-tab header, and the bottom icon rails.
|
||||
String resolveTabTitle(BuildContext context, TabContribution t) {
|
||||
final key = t.titleKey;
|
||||
final ns = t.i18nNamespace;
|
||||
if (key == null || ns == null) return t.title;
|
||||
return ClideKernel.of(context).i18n.string(key, namespace: ns, placeholder: t.title);
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
// CSI handlers: cursor movement, erase/scroll/line/char ops, device
|
||||
// attributes + status reports, margins, tab clear, repeat, and window
|
||||
// manipulation. Split out of parser.dart (T-123); dispatched from the
|
||||
// _csiHandlers table in the EscapeParser core.
|
||||
|
||||
part of 'parser.dart';
|
||||
|
||||
mixin _CsiHandlers on _EscapeParserBase {
|
||||
/// `ESC [ Ps a` Cursor Horizontal Position Relative (HPR)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sa/
|
||||
// void _csiHandleCursorHorizontalRelative() {
|
||||
// if (_csi.params.isEmpty) {
|
||||
// handler.cursorHorizontal(1);
|
||||
// } else {
|
||||
// handler.cursorHorizontal(_csi.params[0]);
|
||||
// }
|
||||
// }
|
||||
/// `ESC [ Ps b` Repeat Previous Character (REP)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sb/
|
||||
void _csiHandleRepeatPreviousCharacter() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.repeatPreviousCharacter(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps c` Device Attributes (DA)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sc/
|
||||
void _csiHandleSendDeviceAttributes() {
|
||||
switch (_csi.prefix) {
|
||||
case Ascii.greaterThan:
|
||||
return handler.sendSecondaryDeviceAttributes();
|
||||
case Ascii.equal:
|
||||
return handler.sendTertiaryDeviceAttributes();
|
||||
default:
|
||||
handler.sendPrimaryDeviceAttributes();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps d` Cursor Vertical Position Absolute (VPA)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sd/
|
||||
void _csiHandleLinePositionAbsolute() {
|
||||
var y = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
y = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.setCursorY(y - 1);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps ; Ps f` Alias: Set Cursor Position
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sf/
|
||||
void _csiHandleCursorPosition() {
|
||||
var row = 1;
|
||||
var col = 1;
|
||||
|
||||
if (_csi.params.length == 2) {
|
||||
row = _csi.params[0];
|
||||
col = _csi.params[1];
|
||||
}
|
||||
|
||||
handler.setCursor(col - 1, row - 1);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps g` Tab Clear (TBC)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sg/
|
||||
void _csiHandelClearTabStop() {
|
||||
var cmd = 0;
|
||||
|
||||
if (_csi.params.length == 1) {
|
||||
cmd = _csi.params[0];
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 0:
|
||||
return handler.clearTabStopUnderCursor();
|
||||
default:
|
||||
return handler.clearAllTabStops();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps n` Device Status Report [Dispatch] (DSR)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sn/
|
||||
void _csiHandleDeviceStatusReport() {
|
||||
if (_csi.params.isEmpty) return;
|
||||
|
||||
switch (_csi.params[0]) {
|
||||
case 5:
|
||||
return handler.sendOperatingStatus();
|
||||
case 6:
|
||||
return handler.sendCursorPosition();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps ; Ps r` Set Top and Bottom Margins (DECSTBM)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sr/
|
||||
void _csiHandleSetMargins() {
|
||||
var top = 1;
|
||||
int? bottom;
|
||||
|
||||
if (_csi.params.length > 2) return;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
top = _csi.params[0];
|
||||
|
||||
if (_csi.params.length == 2) {
|
||||
bottom = _csi.params[1] - 1;
|
||||
}
|
||||
}
|
||||
|
||||
handler.setMargins(top - 1, bottom);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps t` Window operations [DISPATCH]
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_st/
|
||||
void _csiWindowManipulation() {
|
||||
// The sequence needs at least one parameter.
|
||||
if (_csi.params.isEmpty) {
|
||||
return;
|
||||
}
|
||||
// Most the commands in this group are either of the scope of this package,
|
||||
// or should be disabled for security risks.
|
||||
switch (_csi.params.first) {
|
||||
// Window handling is currently not in the scope of the package.
|
||||
case 1: // Restore Terminal Window (show window if minimized)
|
||||
case 2: // Minimize Terminal Window
|
||||
case 3: // Set Terminal Window Position
|
||||
case 4: // Set Terminal Window Size in Pixels
|
||||
case 5: // Raise Terminal Window
|
||||
case 6: // Lower Terminal Window
|
||||
case 7: // Refresh/Redraw Terminal Window
|
||||
return;
|
||||
case 8: // Set Terminal Window Size (in characters)
|
||||
// This CSI contains 2 more parameters: width and height.
|
||||
if (_csi.params.length != 3) {
|
||||
return;
|
||||
}
|
||||
final rows = _csi.params[1];
|
||||
final cols = _csi.params[2];
|
||||
handler.resize(cols, rows);
|
||||
return;
|
||||
// Window handling is currently no in the scope of the package.
|
||||
case 9: // Maximize Terminal Window
|
||||
case 10: // Alias: Maximize Terminal Window
|
||||
case 11: // Report Terminal Window State
|
||||
case 13: // Report Terminal Window Position
|
||||
case 14: // Report Terminal Window Size in Pixels
|
||||
case 15: // Report Screen Size in Pixels
|
||||
case 16: // Report Cell Size in Pixels
|
||||
return;
|
||||
case 18: // Report Terminal Size (in characters)
|
||||
handler.sendSize();
|
||||
return;
|
||||
// Screen handling is currently no in the scope of the package.
|
||||
case 19: // Report Screen Size (in characters)
|
||||
// Disabled as these can a security risk.
|
||||
case 20: // Get Icon Title
|
||||
case 21: // Get Terminal Title
|
||||
// Not implemented.
|
||||
case 22: // Push Terminal Title
|
||||
case 23: // Pop Terminal Title
|
||||
return;
|
||||
// Unknown CSI.
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps A` Cursor Up (CUU)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ca/
|
||||
void _csiHandleCursorUp() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorY(-amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps B` Cursor Down (CUD)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cb/
|
||||
void _csiHandleCursorDown() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorY(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps C` Cursor Right (CUF)
|
||||
///
|
||||
/// Cursor Right (CUF)
|
||||
void _csiHandleCursorForward() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorX(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps D` Cursor Left (CUB)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cd/
|
||||
void _csiHandleCursorBackward() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorX(-amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps E` Cursor Next Line (CNL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ce/
|
||||
void _csiHandleCursorNextLine() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.cursorNextLine(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps F` Cursor Previous Line (CPL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cf/
|
||||
void _csiHandleCursorPrecedingLine() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.cursorPrecedingLine(amount);
|
||||
}
|
||||
|
||||
void _csiHandleCursorHorizontalAbsolute() {
|
||||
var x = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
x = _csi.params[0];
|
||||
if (x == 0) x = 1;
|
||||
}
|
||||
|
||||
handler.setCursorX(x - 1);
|
||||
}
|
||||
|
||||
/// ESC [ Ps J Erase Display [Dispatch] (ED)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cj/
|
||||
void _csiHandleEraseDisplay() {
|
||||
var cmd = 0;
|
||||
|
||||
if (_csi.params.length == 1) {
|
||||
cmd = _csi.params[0];
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 0:
|
||||
return handler.eraseDisplayBelow();
|
||||
case 1:
|
||||
return handler.eraseDisplayAbove();
|
||||
case 2:
|
||||
return handler.eraseDisplay();
|
||||
case 3:
|
||||
return handler.eraseScrollbackOnly();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps K` Erase Line [Dispatch] (EL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ck/
|
||||
void _csiHandleEraseLine() {
|
||||
var cmd = 0;
|
||||
|
||||
if (_csi.params.length == 1) {
|
||||
cmd = _csi.params[0];
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 0:
|
||||
return handler.eraseLineRight();
|
||||
case 1:
|
||||
return handler.eraseLineLeft();
|
||||
case 2:
|
||||
return handler.eraseLine();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps L` Insert Line (IL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cl/
|
||||
void _csiHandleInsertLines() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.insertLines(amount);
|
||||
}
|
||||
|
||||
/// ESC [ Ps M Delete Line (DL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cm/
|
||||
void _csiHandleDeleteLines() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.deleteLines(amount);
|
||||
}
|
||||
|
||||
/// ESC [ Ps P Delete Character (DCH)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cp/
|
||||
void _csiHandleDelete() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.deleteChars(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps S` Scroll Up (SU)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cs/
|
||||
void _csiHandleScrollUp() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.scrollUp(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps T `Scroll Down (SD)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ct_1param/
|
||||
void _csiHandleScrollDown() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.scrollDown(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps X` Erase Character (ECH)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cx/
|
||||
void _csiHandleEraseCharacters() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.eraseChars(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps @` Insert Blanks (ICH)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_x40_at/
|
||||
///
|
||||
/// Inserts amount spaces at current cursor position moving existing cell
|
||||
/// contents to the right. The contents of the amount right-most columns in
|
||||
/// the scroll region are lost. The cursor position is not changed.
|
||||
void _csiHandleInsertBlankCharacters() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.insertBlankChars(amount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
// ANSI + DEC private mode set/reset (CSI h / CSI l, with and without
|
||||
// the ? prefix). Split out of parser.dart (T-123).
|
||||
|
||||
part of 'parser.dart';
|
||||
|
||||
mixin _ModeHandlers on _EscapeParserBase {
|
||||
/// - `ESC [ [ Pm ] h Set Mode (SM)` https://terminalguide.namepad.de/seq/csi_sm/
|
||||
/// - `ESC [ ? [ Pm ] h` Set Mode (?) (SM) https://terminalguide.namepad.de/seq/csi_sh__p/
|
||||
/// - `ESC [ [ Pm ] l` Reset Mode (RM) https://terminalguide.namepad.de/seq/csi_rm/
|
||||
/// - `ESC [ ? [ Pm ] l` Reset Mode (?) (RM) https://terminalguide.namepad.de/seq/csi_sl__p/
|
||||
void _csiHandleMode() {
|
||||
final isEnabled = _csi.finalByte == Ascii.h;
|
||||
|
||||
final isDecModes = _csi.prefix == Ascii.questionMark;
|
||||
|
||||
if (isDecModes) {
|
||||
for (var mode in _csi.params) {
|
||||
_setDecMode(mode, isEnabled);
|
||||
}
|
||||
} else {
|
||||
for (var mode in _csi.params) {
|
||||
_setMode(mode, isEnabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _setMode(int mode, bool enabled) {
|
||||
switch (mode) {
|
||||
case 4:
|
||||
return handler.setInsertMode(enabled);
|
||||
case 20:
|
||||
return handler.setLineFeedMode(enabled);
|
||||
default:
|
||||
return handler.setUnknownMode(mode, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
void _setDecMode(int mode, bool enabled) {
|
||||
switch (mode) {
|
||||
case 1:
|
||||
return handler.setCursorKeysMode(enabled);
|
||||
case 3:
|
||||
return handler.setColumnMode(enabled);
|
||||
case 5:
|
||||
return handler.setReverseDisplayMode(enabled);
|
||||
case 6:
|
||||
return handler.setOriginMode(enabled);
|
||||
case 7:
|
||||
return handler.setAutoWrapMode(enabled);
|
||||
case 9:
|
||||
return enabled ? handler.setMouseMode(MouseMode.clickOnly) : handler.setMouseMode(MouseMode.none);
|
||||
case 12:
|
||||
case 13:
|
||||
return handler.setCursorBlinkMode(enabled);
|
||||
case 25:
|
||||
return handler.setCursorVisibleMode(enabled);
|
||||
case 47:
|
||||
if (enabled) {
|
||||
return handler.useAltBuffer();
|
||||
} else {
|
||||
return handler.useMainBuffer();
|
||||
}
|
||||
case 66:
|
||||
return handler.setAppKeypadMode(enabled);
|
||||
case 1000:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScroll) : handler.setMouseMode(MouseMode.none);
|
||||
case 1001:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScroll) : handler.setMouseMode(MouseMode.none);
|
||||
case 1002:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScrollDrag) : handler.setMouseMode(MouseMode.none);
|
||||
case 1003:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScrollMove) : handler.setMouseMode(MouseMode.none);
|
||||
case 1004:
|
||||
return handler.setReportFocusMode(enabled);
|
||||
case 1005:
|
||||
return enabled ? handler.setMouseReportMode(MouseReportMode.utf) : handler.setMouseReportMode(MouseReportMode.normal);
|
||||
case 1006:
|
||||
return enabled ? handler.setMouseReportMode(MouseReportMode.sgr) : handler.setMouseReportMode(MouseReportMode.normal);
|
||||
case 1007:
|
||||
return handler.setAltBufferMouseScrollMode(enabled);
|
||||
case 1015:
|
||||
return enabled ? handler.setMouseReportMode(MouseReportMode.urxvt) : handler.setMouseReportMode(MouseReportMode.normal);
|
||||
case 1047:
|
||||
if (enabled) {
|
||||
handler.useAltBuffer();
|
||||
} else {
|
||||
handler.clearAltBuffer();
|
||||
handler.useMainBuffer();
|
||||
}
|
||||
return;
|
||||
case 1048:
|
||||
if (enabled) {
|
||||
return handler.saveCursor();
|
||||
} else {
|
||||
return handler.restoreCursor();
|
||||
}
|
||||
case 1049:
|
||||
if (enabled) {
|
||||
handler.saveCursor();
|
||||
handler.clearAltBuffer();
|
||||
handler.useAltBuffer();
|
||||
} else {
|
||||
handler.useMainBuffer();
|
||||
}
|
||||
return;
|
||||
case 2004:
|
||||
return handler.setBracketedPasteMode(enabled);
|
||||
default:
|
||||
return handler.setUnknownDecMode(mode, enabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
// OSC string parsing + dispatch (title / icon name / private
|
||||
// pass-through), BEL or ST terminated. Split out of parser.dart
|
||||
// (T-123).
|
||||
|
||||
part of 'parser.dart';
|
||||
|
||||
mixin _OscHandlers on _EscapeParserBase {
|
||||
/// Parse a OSC sequence from the queue. Returns true if a sequence was
|
||||
/// found and handled.
|
||||
bool _escHandleOSC() {
|
||||
final consumed = _consumeOsc();
|
||||
if (!consumed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_osc.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Common OSCs
|
||||
if (_osc.length >= 2) {
|
||||
final ps = _osc[0];
|
||||
final pt = _osc[1];
|
||||
|
||||
switch (ps) {
|
||||
case '0':
|
||||
handler.setTitle(pt);
|
||||
handler.setIconName(pt);
|
||||
return true;
|
||||
case '1':
|
||||
handler.setIconName(pt);
|
||||
return true;
|
||||
case '2':
|
||||
handler.setTitle(pt);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Private extensions
|
||||
handler.unknownOSC(_osc[0], _osc.sublist(1));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
final _osc = <String>[];
|
||||
|
||||
bool _consumeOsc() {
|
||||
_osc.clear();
|
||||
final param = StringBuffer();
|
||||
|
||||
while (true) {
|
||||
if (_queue.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final char = _queue.consume();
|
||||
|
||||
// OSC terminates with BEL
|
||||
if (char == Ascii.BEL) {
|
||||
_osc.add(param.toString());
|
||||
return true;
|
||||
}
|
||||
|
||||
/// OSC terminates with ST
|
||||
if (char == Ascii.ESC) {
|
||||
if (_queue.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_queue.consume() == Ascii.backslash) {
|
||||
_osc.add(param.toString());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Parse next parameter
|
||||
if (char == Ascii.semicolon) {
|
||||
_osc.add(param.toString());
|
||||
param.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
param.writeCharCode(char);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,16 +8,18 @@ import 'package:clide/src/terminal/src/utils/byte_consumer.dart';
|
||||
import 'package:clide/src/terminal/src/utils/char_code.dart';
|
||||
import 'package:clide/src/terminal/src/utils/lookup_table.dart';
|
||||
|
||||
/// [EscapeParser] translates control characters and escape sequences into
|
||||
/// function calls that the terminal can handle.
|
||||
///
|
||||
/// Design goals:
|
||||
/// * Zero object allocation during processing.
|
||||
/// * No internal state. Same input will always produce same output.
|
||||
class EscapeParser {
|
||||
final EscapeHandler handler;
|
||||
part 'csi_handlers.dart';
|
||||
part 'mode_handlers.dart';
|
||||
part 'osc_handlers.dart';
|
||||
part 'sgr_handlers.dart';
|
||||
|
||||
EscapeParser(this.handler);
|
||||
/// Shared parser state the handler mixins operate on: the escape
|
||||
/// handler sink, the byte queue, token bookkeeping, and the reusable
|
||||
/// CSI scratch object (zero-allocation design — see [EscapeParser]).
|
||||
abstract class _EscapeParserBase {
|
||||
_EscapeParserBase(this.handler);
|
||||
|
||||
final EscapeHandler handler;
|
||||
|
||||
final _queue = ByteConsumer();
|
||||
|
||||
@@ -27,6 +29,24 @@ class EscapeParser {
|
||||
/// End of sequence or character being processed. Useful for debugging.
|
||||
int get tokenEnd => _queue.totalConsumed;
|
||||
|
||||
/// The last parsed [_Csi]. This is a mutable singletion by design to reduce
|
||||
/// object allocations.
|
||||
final _csi = _Csi(finalByte: 0, params: []);
|
||||
}
|
||||
|
||||
/// [EscapeParser] translates control characters and escape sequences into
|
||||
/// function calls that the terminal can handle.
|
||||
///
|
||||
/// Design goals:
|
||||
/// * Zero object allocation during processing.
|
||||
/// * No internal state. Same input will always produce same output.
|
||||
///
|
||||
/// The handler groups live as mixins in this library's part files
|
||||
/// (csi/sgr/mode/osc handlers, T-123); this core owns the byte queue,
|
||||
/// the dispatch tables, and the ESC/CSI consumers.
|
||||
class EscapeParser extends _EscapeParserBase with _CsiHandlers, _ModeHandlers, _OscHandlers, _SgrHandlers {
|
||||
EscapeParser(super.handler);
|
||||
|
||||
void write(String chunk) {
|
||||
_queue.unrefConsumedBlocks();
|
||||
_queue.add(chunk);
|
||||
@@ -197,7 +217,11 @@ class EscapeParser {
|
||||
final consumed = _consumeCsi();
|
||||
if (!consumed) return false;
|
||||
|
||||
final csiHandler = _csiHandlers[_csi.finalByte];
|
||||
// An intermediate byte changes the meaning of the final byte
|
||||
// (`CSI 5 SP @` is scroll-left, not insert-blank). None of the
|
||||
// intermediate forms are implemented, so report them as unknown
|
||||
// rather than mis-dispatching on the bare final byte.
|
||||
final csiHandler = _csi.intermediates.isEmpty ? _csiHandlers[_csi.finalByte] : null;
|
||||
|
||||
if (csiHandler == null) {
|
||||
handler.unknownCSI(_csi.finalByte);
|
||||
@@ -208,10 +232,6 @@ class EscapeParser {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The last parsed [_Csi]. This is a mutable singletion by design to reduce
|
||||
/// object allocations.
|
||||
final _csi = _Csi(finalByte: 0, params: []);
|
||||
|
||||
/// Parse a CSI from the head of the queue. Return false if the CSI isn't
|
||||
/// complete. After a CSI is successfully parsed, [_csi] is updated.
|
||||
bool _consumeCsi() {
|
||||
@@ -220,6 +240,8 @@ class EscapeParser {
|
||||
}
|
||||
|
||||
_csi.params.clear();
|
||||
_csi.subParam.clear();
|
||||
_csi.intermediates.clear();
|
||||
|
||||
// test whether the csi is a `CSI ? Ps ...` or `CSI Ps ...`
|
||||
final prefix = _queue.peek();
|
||||
@@ -232,6 +254,11 @@ class EscapeParser {
|
||||
|
||||
var param = 0;
|
||||
var hasParam = false;
|
||||
// Whether the value being accumulated was attached to its predecessor
|
||||
// with a colon (ECMA-48 sub-parameter separator, ITU T.416 SGR colors).
|
||||
// Before T-369 colons were silently dropped mid-sequence, fusing
|
||||
// `38:2:255:0:0` into one bogus parameter.
|
||||
var linkedToPrev = false;
|
||||
while (true) {
|
||||
// The sequence isn't completed, just ignore it.
|
||||
if (_queue.isEmpty) {
|
||||
@@ -243,8 +270,21 @@ class EscapeParser {
|
||||
if (char == Ascii.semicolon) {
|
||||
if (hasParam) {
|
||||
_csi.params.add(param);
|
||||
_csi.subParam.add(linkedToPrev);
|
||||
}
|
||||
param = 0;
|
||||
linkedToPrev = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char == Ascii.colon) {
|
||||
// Push the current value even when empty — `38:2::r:g:b` carries an
|
||||
// empty colorspace slot that must keep its position in the group.
|
||||
_csi.params.add(hasParam ? param : 0);
|
||||
_csi.subParam.add(linkedToPrev);
|
||||
hasParam = true;
|
||||
param = 0;
|
||||
linkedToPrev = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -255,14 +295,20 @@ class EscapeParser {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char >= Ascii.space && char <= Ascii.slash) {
|
||||
_csi.intermediates.add(char);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char > Ascii.NULL && char < Ascii.num0) {
|
||||
// intermediates.add(char);
|
||||
// Other C0 controls embedded in a CSI: ignore, as before.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char >= Ascii.atSign && char <= Ascii.tilde) {
|
||||
if (hasParam) {
|
||||
_csi.params.add(param);
|
||||
_csi.subParam.add(linkedToPrev);
|
||||
}
|
||||
|
||||
_csi.finalByte = char;
|
||||
@@ -302,827 +348,26 @@ class EscapeParser {
|
||||
'X'.codeUnitAt(0): _csiHandleEraseCharacters,
|
||||
'@'.codeUnitAt(0): _csiHandleInsertBlankCharacters,
|
||||
});
|
||||
|
||||
/// `ESC [ Ps a` Cursor Horizontal Position Relative (HPR)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sa/
|
||||
// void _csiHandleCursorHorizontalRelative() {
|
||||
// if (_csi.params.isEmpty) {
|
||||
// handler.cursorHorizontal(1);
|
||||
// } else {
|
||||
// handler.cursorHorizontal(_csi.params[0]);
|
||||
// }
|
||||
// }
|
||||
|
||||
/// `ESC [ Ps b` Repeat Previous Character (REP)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sb/
|
||||
void _csiHandleRepeatPreviousCharacter() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.repeatPreviousCharacter(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps c` Device Attributes (DA)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sc/
|
||||
void _csiHandleSendDeviceAttributes() {
|
||||
switch (_csi.prefix) {
|
||||
case Ascii.greaterThan:
|
||||
return handler.sendSecondaryDeviceAttributes();
|
||||
case Ascii.equal:
|
||||
return handler.sendTertiaryDeviceAttributes();
|
||||
default:
|
||||
handler.sendPrimaryDeviceAttributes();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps d` Cursor Vertical Position Absolute (VPA)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sd/
|
||||
void _csiHandleLinePositionAbsolute() {
|
||||
var y = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
y = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.setCursorY(y - 1);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps ; Ps f` Alias: Set Cursor Position
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sf/
|
||||
void _csiHandleCursorPosition() {
|
||||
var row = 1;
|
||||
var col = 1;
|
||||
|
||||
if (_csi.params.length == 2) {
|
||||
row = _csi.params[0];
|
||||
col = _csi.params[1];
|
||||
}
|
||||
|
||||
handler.setCursor(col - 1, row - 1);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps g` Tab Clear (TBC)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sg/
|
||||
void _csiHandelClearTabStop() {
|
||||
var cmd = 0;
|
||||
|
||||
if (_csi.params.length == 1) {
|
||||
cmd = _csi.params[0];
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 0:
|
||||
return handler.clearTabStopUnderCursor();
|
||||
default:
|
||||
return handler.clearAllTabStops();
|
||||
}
|
||||
}
|
||||
|
||||
/// - `ESC [ [ Pm ] h Set Mode (SM)` https://terminalguide.namepad.de/seq/csi_sm/
|
||||
/// - `ESC [ ? [ Pm ] h` Set Mode (?) (SM) https://terminalguide.namepad.de/seq/csi_sh__p/
|
||||
/// - `ESC [ [ Pm ] l` Reset Mode (RM) https://terminalguide.namepad.de/seq/csi_rm/
|
||||
/// - `ESC [ ? [ Pm ] l` Reset Mode (?) (RM) https://terminalguide.namepad.de/seq/csi_sl__p/
|
||||
void _csiHandleMode() {
|
||||
final isEnabled = _csi.finalByte == Ascii.h;
|
||||
|
||||
final isDecModes = _csi.prefix == Ascii.questionMark;
|
||||
|
||||
if (isDecModes) {
|
||||
for (var mode in _csi.params) {
|
||||
_setDecMode(mode, isEnabled);
|
||||
}
|
||||
} else {
|
||||
for (var mode in _csi.params) {
|
||||
_setMode(mode, isEnabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ [ Ps ] m` Select Graphic Rendition (SGR)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sm/
|
||||
void _csiHandleSgr() {
|
||||
final params = _csi.params;
|
||||
|
||||
if (params.isEmpty) {
|
||||
return handler.resetCursorStyle();
|
||||
}
|
||||
|
||||
for (var i = 0; i < _csi.params.length; i++) {
|
||||
final param = params[i];
|
||||
switch (param) {
|
||||
case 0:
|
||||
handler.resetCursorStyle();
|
||||
continue;
|
||||
case 1:
|
||||
handler.setCursorBold();
|
||||
continue;
|
||||
case 2:
|
||||
handler.setCursorFaint();
|
||||
continue;
|
||||
case 3:
|
||||
handler.setCursorItalic();
|
||||
continue;
|
||||
case 4:
|
||||
handler.setCursorUnderline();
|
||||
continue;
|
||||
case 5:
|
||||
handler.setCursorBlink();
|
||||
continue;
|
||||
case 7:
|
||||
handler.setCursorInverse();
|
||||
continue;
|
||||
case 8:
|
||||
handler.setCursorInvisible();
|
||||
continue;
|
||||
case 9:
|
||||
handler.setCursorStrikethrough();
|
||||
continue;
|
||||
|
||||
case 21:
|
||||
handler.unsetCursorBold();
|
||||
continue;
|
||||
case 22:
|
||||
handler.unsetCursorFaint();
|
||||
continue;
|
||||
case 23:
|
||||
handler.unsetCursorItalic();
|
||||
continue;
|
||||
case 24:
|
||||
handler.unsetCursorUnderline();
|
||||
continue;
|
||||
case 25:
|
||||
handler.unsetCursorBlink();
|
||||
continue;
|
||||
case 27:
|
||||
handler.unsetCursorInverse();
|
||||
continue;
|
||||
case 28:
|
||||
handler.unsetCursorInvisible();
|
||||
continue;
|
||||
case 29:
|
||||
handler.unsetCursorStrikethrough();
|
||||
continue;
|
||||
|
||||
case 30:
|
||||
handler.setForegroundColor16(NamedColor.black);
|
||||
continue;
|
||||
case 31:
|
||||
handler.setForegroundColor16(NamedColor.red);
|
||||
continue;
|
||||
case 32:
|
||||
handler.setForegroundColor16(NamedColor.green);
|
||||
continue;
|
||||
case 33:
|
||||
handler.setForegroundColor16(NamedColor.yellow);
|
||||
continue;
|
||||
case 34:
|
||||
handler.setForegroundColor16(NamedColor.blue);
|
||||
continue;
|
||||
case 35:
|
||||
handler.setForegroundColor16(NamedColor.magenta);
|
||||
continue;
|
||||
case 36:
|
||||
handler.setForegroundColor16(NamedColor.cyan);
|
||||
continue;
|
||||
case 37:
|
||||
handler.setForegroundColor16(NamedColor.white);
|
||||
continue;
|
||||
case 38:
|
||||
final mode = params[i + 1];
|
||||
switch (mode) {
|
||||
case 2:
|
||||
final r = params[i + 2];
|
||||
final g = params[i + 3];
|
||||
final b = params[i + 4];
|
||||
handler.setForegroundColorRgb(r, g, b);
|
||||
i += 4;
|
||||
break;
|
||||
case 5:
|
||||
final index = params[i + 2];
|
||||
handler.setForegroundColor256(index);
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
case 39:
|
||||
handler.resetForeground();
|
||||
continue;
|
||||
|
||||
case 40:
|
||||
handler.setBackgroundColor16(NamedColor.black);
|
||||
continue;
|
||||
case 41:
|
||||
handler.setBackgroundColor16(NamedColor.red);
|
||||
continue;
|
||||
case 42:
|
||||
handler.setBackgroundColor16(NamedColor.green);
|
||||
continue;
|
||||
case 43:
|
||||
handler.setBackgroundColor16(NamedColor.yellow);
|
||||
continue;
|
||||
case 44:
|
||||
handler.setBackgroundColor16(NamedColor.blue);
|
||||
continue;
|
||||
case 45:
|
||||
handler.setBackgroundColor16(NamedColor.magenta);
|
||||
continue;
|
||||
case 46:
|
||||
handler.setBackgroundColor16(NamedColor.cyan);
|
||||
continue;
|
||||
case 47:
|
||||
handler.setBackgroundColor16(NamedColor.white);
|
||||
continue;
|
||||
case 48:
|
||||
final mode = params[i + 1];
|
||||
switch (mode) {
|
||||
case 2:
|
||||
final r = params[i + 2];
|
||||
final g = params[i + 3];
|
||||
final b = params[i + 4];
|
||||
handler.setBackgroundColorRgb(r, g, b);
|
||||
i += 4;
|
||||
break;
|
||||
case 5:
|
||||
final index = params[i + 2];
|
||||
handler.setBackgroundColor256(index);
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
case 49:
|
||||
handler.resetBackground();
|
||||
continue;
|
||||
|
||||
case 90:
|
||||
handler.setForegroundColor16(NamedColor.brightBlack);
|
||||
continue;
|
||||
case 91:
|
||||
handler.setForegroundColor16(NamedColor.brightRed);
|
||||
continue;
|
||||
case 92:
|
||||
handler.setForegroundColor16(NamedColor.brightGreen);
|
||||
continue;
|
||||
case 93:
|
||||
handler.setForegroundColor16(NamedColor.brightYellow);
|
||||
continue;
|
||||
case 94:
|
||||
handler.setForegroundColor16(NamedColor.brightBlue);
|
||||
continue;
|
||||
case 95:
|
||||
handler.setForegroundColor16(NamedColor.brightMagenta);
|
||||
continue;
|
||||
case 96:
|
||||
handler.setForegroundColor16(NamedColor.brightCyan);
|
||||
continue;
|
||||
case 97:
|
||||
handler.setForegroundColor16(NamedColor.brightWhite);
|
||||
continue;
|
||||
|
||||
case 100:
|
||||
handler.setBackgroundColor16(NamedColor.brightBlack);
|
||||
continue;
|
||||
case 101:
|
||||
handler.setBackgroundColor16(NamedColor.brightRed);
|
||||
continue;
|
||||
case 102:
|
||||
handler.setBackgroundColor16(NamedColor.brightGreen);
|
||||
continue;
|
||||
case 103:
|
||||
handler.setBackgroundColor16(NamedColor.brightYellow);
|
||||
continue;
|
||||
case 104:
|
||||
handler.setBackgroundColor16(NamedColor.brightBlue);
|
||||
continue;
|
||||
case 105:
|
||||
handler.setBackgroundColor16(NamedColor.brightMagenta);
|
||||
continue;
|
||||
case 106:
|
||||
handler.setBackgroundColor16(NamedColor.brightCyan);
|
||||
continue;
|
||||
case 107:
|
||||
handler.setBackgroundColor16(NamedColor.brightWhite);
|
||||
continue;
|
||||
|
||||
default:
|
||||
handler.unsupportedStyle(param);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps n` Device Status Report [Dispatch] (DSR)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sn/
|
||||
void _csiHandleDeviceStatusReport() {
|
||||
if (_csi.params.isEmpty) return;
|
||||
|
||||
switch (_csi.params[0]) {
|
||||
case 5:
|
||||
return handler.sendOperatingStatus();
|
||||
case 6:
|
||||
return handler.sendCursorPosition();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps ; Ps r` Set Top and Bottom Margins (DECSTBM)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sr/
|
||||
void _csiHandleSetMargins() {
|
||||
var top = 1;
|
||||
int? bottom;
|
||||
|
||||
if (_csi.params.length > 2) return;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
top = _csi.params[0];
|
||||
|
||||
if (_csi.params.length == 2) {
|
||||
bottom = _csi.params[1] - 1;
|
||||
}
|
||||
}
|
||||
|
||||
handler.setMargins(top - 1, bottom);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps t` Window operations [DISPATCH]
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_st/
|
||||
void _csiWindowManipulation() {
|
||||
// The sequence needs at least one parameter.
|
||||
if (_csi.params.isEmpty) {
|
||||
return;
|
||||
}
|
||||
// Most the commands in this group are either of the scope of this package,
|
||||
// or should be disabled for security risks.
|
||||
switch (_csi.params.first) {
|
||||
// Window handling is currently not in the scope of the package.
|
||||
case 1: // Restore Terminal Window (show window if minimized)
|
||||
case 2: // Minimize Terminal Window
|
||||
case 3: // Set Terminal Window Position
|
||||
case 4: // Set Terminal Window Size in Pixels
|
||||
case 5: // Raise Terminal Window
|
||||
case 6: // Lower Terminal Window
|
||||
case 7: // Refresh/Redraw Terminal Window
|
||||
return;
|
||||
case 8: // Set Terminal Window Size (in characters)
|
||||
// This CSI contains 2 more parameters: width and height.
|
||||
if (_csi.params.length != 3) {
|
||||
return;
|
||||
}
|
||||
final rows = _csi.params[1];
|
||||
final cols = _csi.params[2];
|
||||
handler.resize(cols, rows);
|
||||
return;
|
||||
// Window handling is currently no in the scope of the package.
|
||||
case 9: // Maximize Terminal Window
|
||||
case 10: // Alias: Maximize Terminal Window
|
||||
case 11: // Report Terminal Window State
|
||||
case 13: // Report Terminal Window Position
|
||||
case 14: // Report Terminal Window Size in Pixels
|
||||
case 15: // Report Screen Size in Pixels
|
||||
case 16: // Report Cell Size in Pixels
|
||||
return;
|
||||
case 18: // Report Terminal Size (in characters)
|
||||
handler.sendSize();
|
||||
return;
|
||||
// Screen handling is currently no in the scope of the package.
|
||||
case 19: // Report Screen Size (in characters)
|
||||
// Disabled as these can a security risk.
|
||||
case 20: // Get Icon Title
|
||||
case 21: // Get Terminal Title
|
||||
// Not implemented.
|
||||
case 22: // Push Terminal Title
|
||||
case 23: // Pop Terminal Title
|
||||
return;
|
||||
// Unknown CSI.
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps A` Cursor Up (CUU)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ca/
|
||||
void _csiHandleCursorUp() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorY(-amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps B` Cursor Down (CUD)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cb/
|
||||
void _csiHandleCursorDown() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorY(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps C` Cursor Right (CUF)
|
||||
///
|
||||
/// Cursor Right (CUF)
|
||||
void _csiHandleCursorForward() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorX(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps D` Cursor Left (CUB)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cd/
|
||||
void _csiHandleCursorBackward() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorX(-amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps E` Cursor Next Line (CNL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ce/
|
||||
void _csiHandleCursorNextLine() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.cursorNextLine(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps F` Cursor Previous Line (CPL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cf/
|
||||
void _csiHandleCursorPrecedingLine() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.cursorPrecedingLine(amount);
|
||||
}
|
||||
|
||||
void _csiHandleCursorHorizontalAbsolute() {
|
||||
var x = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
x = _csi.params[0];
|
||||
if (x == 0) x = 1;
|
||||
}
|
||||
|
||||
handler.setCursorX(x - 1);
|
||||
}
|
||||
|
||||
/// ESC [ Ps J Erase Display [Dispatch] (ED)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cj/
|
||||
void _csiHandleEraseDisplay() {
|
||||
var cmd = 0;
|
||||
|
||||
if (_csi.params.length == 1) {
|
||||
cmd = _csi.params[0];
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 0:
|
||||
return handler.eraseDisplayBelow();
|
||||
case 1:
|
||||
return handler.eraseDisplayAbove();
|
||||
case 2:
|
||||
return handler.eraseDisplay();
|
||||
case 3:
|
||||
return handler.eraseScrollbackOnly();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps K` Erase Line [Dispatch] (EL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ck/
|
||||
void _csiHandleEraseLine() {
|
||||
var cmd = 0;
|
||||
|
||||
if (_csi.params.length == 1) {
|
||||
cmd = _csi.params[0];
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 0:
|
||||
return handler.eraseLineRight();
|
||||
case 1:
|
||||
return handler.eraseLineLeft();
|
||||
case 2:
|
||||
return handler.eraseLine();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps L` Insert Line (IL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cl/
|
||||
void _csiHandleInsertLines() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.insertLines(amount);
|
||||
}
|
||||
|
||||
/// ESC [ Ps M Delete Line (DL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cm/
|
||||
void _csiHandleDeleteLines() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.deleteLines(amount);
|
||||
}
|
||||
|
||||
/// ESC [ Ps P Delete Character (DCH)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cp/
|
||||
void _csiHandleDelete() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.deleteChars(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps S` Scroll Up (SU)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cs/
|
||||
void _csiHandleScrollUp() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.scrollUp(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps T `Scroll Down (SD)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ct_1param/
|
||||
void _csiHandleScrollDown() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.scrollDown(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps X` Erase Character (ECH)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cx/
|
||||
void _csiHandleEraseCharacters() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.eraseChars(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps @` Insert Blanks (ICH)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_x40_at/
|
||||
///
|
||||
/// Inserts amount spaces at current cursor position moving existing cell
|
||||
/// contents to the right. The contents of the amount right-most columns in
|
||||
/// the scroll region are lost. The cursor position is not changed.
|
||||
void _csiHandleInsertBlankCharacters() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.insertBlankChars(amount);
|
||||
}
|
||||
|
||||
void _setMode(int mode, bool enabled) {
|
||||
switch (mode) {
|
||||
case 4:
|
||||
return handler.setInsertMode(enabled);
|
||||
case 20:
|
||||
return handler.setLineFeedMode(enabled);
|
||||
default:
|
||||
return handler.setUnknownMode(mode, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
void _setDecMode(int mode, bool enabled) {
|
||||
switch (mode) {
|
||||
case 1:
|
||||
return handler.setCursorKeysMode(enabled);
|
||||
case 3:
|
||||
return handler.setColumnMode(enabled);
|
||||
case 5:
|
||||
return handler.setReverseDisplayMode(enabled);
|
||||
case 6:
|
||||
return handler.setOriginMode(enabled);
|
||||
case 7:
|
||||
return handler.setAutoWrapMode(enabled);
|
||||
case 9:
|
||||
return enabled ? handler.setMouseMode(MouseMode.clickOnly) : handler.setMouseMode(MouseMode.none);
|
||||
case 12:
|
||||
case 13:
|
||||
return handler.setCursorBlinkMode(enabled);
|
||||
case 25:
|
||||
return handler.setCursorVisibleMode(enabled);
|
||||
case 47:
|
||||
if (enabled) {
|
||||
return handler.useAltBuffer();
|
||||
} else {
|
||||
return handler.useMainBuffer();
|
||||
}
|
||||
case 66:
|
||||
return handler.setAppKeypadMode(enabled);
|
||||
case 1000:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScroll) : handler.setMouseMode(MouseMode.none);
|
||||
case 1001:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScroll) : handler.setMouseMode(MouseMode.none);
|
||||
case 1002:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScrollDrag) : handler.setMouseMode(MouseMode.none);
|
||||
case 1003:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScrollMove) : handler.setMouseMode(MouseMode.none);
|
||||
case 1004:
|
||||
return handler.setReportFocusMode(enabled);
|
||||
case 1005:
|
||||
return enabled ? handler.setMouseReportMode(MouseReportMode.utf) : handler.setMouseReportMode(MouseReportMode.normal);
|
||||
case 1006:
|
||||
return enabled ? handler.setMouseReportMode(MouseReportMode.sgr) : handler.setMouseReportMode(MouseReportMode.normal);
|
||||
case 1007:
|
||||
return handler.setAltBufferMouseScrollMode(enabled);
|
||||
case 1015:
|
||||
return enabled ? handler.setMouseReportMode(MouseReportMode.urxvt) : handler.setMouseReportMode(MouseReportMode.normal);
|
||||
case 1047:
|
||||
if (enabled) {
|
||||
handler.useAltBuffer();
|
||||
} else {
|
||||
handler.clearAltBuffer();
|
||||
handler.useMainBuffer();
|
||||
}
|
||||
return;
|
||||
case 1048:
|
||||
if (enabled) {
|
||||
return handler.saveCursor();
|
||||
} else {
|
||||
return handler.restoreCursor();
|
||||
}
|
||||
case 1049:
|
||||
if (enabled) {
|
||||
handler.saveCursor();
|
||||
handler.clearAltBuffer();
|
||||
handler.useAltBuffer();
|
||||
} else {
|
||||
handler.useMainBuffer();
|
||||
}
|
||||
return;
|
||||
case 2004:
|
||||
return handler.setBracketedPasteMode(enabled);
|
||||
default:
|
||||
return handler.setUnknownDecMode(mode, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a OSC sequence from the queue. Returns true if a sequence was
|
||||
/// found and handled.
|
||||
bool _escHandleOSC() {
|
||||
final consumed = _consumeOsc();
|
||||
if (!consumed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_osc.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Common OSCs
|
||||
if (_osc.length >= 2) {
|
||||
final ps = _osc[0];
|
||||
final pt = _osc[1];
|
||||
|
||||
switch (ps) {
|
||||
case '0':
|
||||
handler.setTitle(pt);
|
||||
handler.setIconName(pt);
|
||||
return true;
|
||||
case '1':
|
||||
handler.setIconName(pt);
|
||||
return true;
|
||||
case '2':
|
||||
handler.setTitle(pt);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Private extensions
|
||||
handler.unknownOSC(_osc[0], _osc.sublist(1));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
final _osc = <String>[];
|
||||
|
||||
bool _consumeOsc() {
|
||||
_osc.clear();
|
||||
final param = StringBuffer();
|
||||
|
||||
while (true) {
|
||||
if (_queue.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final char = _queue.consume();
|
||||
|
||||
// OSC terminates with BEL
|
||||
if (char == Ascii.BEL) {
|
||||
_osc.add(param.toString());
|
||||
return true;
|
||||
}
|
||||
|
||||
/// OSC terminates with ST
|
||||
if (char == Ascii.ESC) {
|
||||
if (_queue.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_queue.consume() == Ascii.backslash) {
|
||||
_osc.add(param.toString());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Parse next parameter
|
||||
if (char == Ascii.semicolon) {
|
||||
_osc.add(param.toString());
|
||||
param.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
param.writeCharCode(char);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Csi {
|
||||
_Csi({
|
||||
required this.params,
|
||||
required this.finalByte,
|
||||
// required this.intermediates,
|
||||
});
|
||||
_Csi({required this.params, required this.finalByte});
|
||||
|
||||
int? prefix;
|
||||
|
||||
List<int> params;
|
||||
|
||||
/// Parallel to [params]: true when that parameter was attached to its
|
||||
/// predecessor with a colon (ECMA-48 sub-parameter, ITU T.416 — T-369).
|
||||
final List<bool> subParam = [];
|
||||
|
||||
int finalByte;
|
||||
// final List<int> intermediates;
|
||||
|
||||
/// Intermediate bytes (0x20–0x2f) between the parameters and the final
|
||||
/// byte — `SP` in `CSI Ps SP q` (DECSCUSR), `!` in `CSI ! p` (DECSTR).
|
||||
/// They change the meaning of the final byte, so dispatch must not fall
|
||||
/// through to the bare-final handler when any are present.
|
||||
final List<int> intermediates = [];
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
// SGR (Select Graphic Rendition) handling, including the guarded
|
||||
// extended-color (38/48) path with ITU T.416 colon sub-parameters
|
||||
// (T-369). Split out of parser.dart (T-123).
|
||||
|
||||
part of 'parser.dart';
|
||||
|
||||
mixin _SgrHandlers on _EscapeParserBase {
|
||||
/// `ESC [ [ Ps ] m` Select Graphic Rendition (SGR)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sm/
|
||||
void _csiHandleSgr() {
|
||||
final params = _csi.params;
|
||||
|
||||
if (params.isEmpty) {
|
||||
return handler.resetCursorStyle();
|
||||
}
|
||||
|
||||
for (var i = 0; i < _csi.params.length; i++) {
|
||||
final param = params[i];
|
||||
switch (param) {
|
||||
case 0:
|
||||
handler.resetCursorStyle();
|
||||
continue;
|
||||
case 1:
|
||||
handler.setCursorBold();
|
||||
continue;
|
||||
case 2:
|
||||
handler.setCursorFaint();
|
||||
continue;
|
||||
case 3:
|
||||
handler.setCursorItalic();
|
||||
continue;
|
||||
case 4:
|
||||
handler.setCursorUnderline();
|
||||
continue;
|
||||
case 5:
|
||||
handler.setCursorBlink();
|
||||
continue;
|
||||
case 7:
|
||||
handler.setCursorInverse();
|
||||
continue;
|
||||
case 8:
|
||||
handler.setCursorInvisible();
|
||||
continue;
|
||||
case 9:
|
||||
handler.setCursorStrikethrough();
|
||||
continue;
|
||||
|
||||
case 21:
|
||||
handler.unsetCursorBold();
|
||||
continue;
|
||||
case 22:
|
||||
handler.unsetCursorFaint();
|
||||
continue;
|
||||
case 23:
|
||||
handler.unsetCursorItalic();
|
||||
continue;
|
||||
case 24:
|
||||
handler.unsetCursorUnderline();
|
||||
continue;
|
||||
case 25:
|
||||
handler.unsetCursorBlink();
|
||||
continue;
|
||||
case 27:
|
||||
handler.unsetCursorInverse();
|
||||
continue;
|
||||
case 28:
|
||||
handler.unsetCursorInvisible();
|
||||
continue;
|
||||
case 29:
|
||||
handler.unsetCursorStrikethrough();
|
||||
continue;
|
||||
|
||||
case 30:
|
||||
handler.setForegroundColor16(NamedColor.black);
|
||||
continue;
|
||||
case 31:
|
||||
handler.setForegroundColor16(NamedColor.red);
|
||||
continue;
|
||||
case 32:
|
||||
handler.setForegroundColor16(NamedColor.green);
|
||||
continue;
|
||||
case 33:
|
||||
handler.setForegroundColor16(NamedColor.yellow);
|
||||
continue;
|
||||
case 34:
|
||||
handler.setForegroundColor16(NamedColor.blue);
|
||||
continue;
|
||||
case 35:
|
||||
handler.setForegroundColor16(NamedColor.magenta);
|
||||
continue;
|
||||
case 36:
|
||||
handler.setForegroundColor16(NamedColor.cyan);
|
||||
continue;
|
||||
case 37:
|
||||
handler.setForegroundColor16(NamedColor.white);
|
||||
continue;
|
||||
case 38:
|
||||
i = _csiHandleExtendedColor(i, foreground: true);
|
||||
continue;
|
||||
case 39:
|
||||
handler.resetForeground();
|
||||
continue;
|
||||
|
||||
case 40:
|
||||
handler.setBackgroundColor16(NamedColor.black);
|
||||
continue;
|
||||
case 41:
|
||||
handler.setBackgroundColor16(NamedColor.red);
|
||||
continue;
|
||||
case 42:
|
||||
handler.setBackgroundColor16(NamedColor.green);
|
||||
continue;
|
||||
case 43:
|
||||
handler.setBackgroundColor16(NamedColor.yellow);
|
||||
continue;
|
||||
case 44:
|
||||
handler.setBackgroundColor16(NamedColor.blue);
|
||||
continue;
|
||||
case 45:
|
||||
handler.setBackgroundColor16(NamedColor.magenta);
|
||||
continue;
|
||||
case 46:
|
||||
handler.setBackgroundColor16(NamedColor.cyan);
|
||||
continue;
|
||||
case 47:
|
||||
handler.setBackgroundColor16(NamedColor.white);
|
||||
continue;
|
||||
case 48:
|
||||
i = _csiHandleExtendedColor(i, foreground: false);
|
||||
continue;
|
||||
case 49:
|
||||
handler.resetBackground();
|
||||
continue;
|
||||
|
||||
case 90:
|
||||
handler.setForegroundColor16(NamedColor.brightBlack);
|
||||
continue;
|
||||
case 91:
|
||||
handler.setForegroundColor16(NamedColor.brightRed);
|
||||
continue;
|
||||
case 92:
|
||||
handler.setForegroundColor16(NamedColor.brightGreen);
|
||||
continue;
|
||||
case 93:
|
||||
handler.setForegroundColor16(NamedColor.brightYellow);
|
||||
continue;
|
||||
case 94:
|
||||
handler.setForegroundColor16(NamedColor.brightBlue);
|
||||
continue;
|
||||
case 95:
|
||||
handler.setForegroundColor16(NamedColor.brightMagenta);
|
||||
continue;
|
||||
case 96:
|
||||
handler.setForegroundColor16(NamedColor.brightCyan);
|
||||
continue;
|
||||
case 97:
|
||||
handler.setForegroundColor16(NamedColor.brightWhite);
|
||||
continue;
|
||||
|
||||
case 100:
|
||||
handler.setBackgroundColor16(NamedColor.brightBlack);
|
||||
continue;
|
||||
case 101:
|
||||
handler.setBackgroundColor16(NamedColor.brightRed);
|
||||
continue;
|
||||
case 102:
|
||||
handler.setBackgroundColor16(NamedColor.brightGreen);
|
||||
continue;
|
||||
case 103:
|
||||
handler.setBackgroundColor16(NamedColor.brightYellow);
|
||||
continue;
|
||||
case 104:
|
||||
handler.setBackgroundColor16(NamedColor.brightBlue);
|
||||
continue;
|
||||
case 105:
|
||||
handler.setBackgroundColor16(NamedColor.brightMagenta);
|
||||
continue;
|
||||
case 106:
|
||||
handler.setBackgroundColor16(NamedColor.brightCyan);
|
||||
continue;
|
||||
case 107:
|
||||
handler.setBackgroundColor16(NamedColor.brightWhite);
|
||||
continue;
|
||||
|
||||
default:
|
||||
handler.unsupportedStyle(param);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extended fg/bg color (SGR 38/48), semicolon or colon form.
|
||||
///
|
||||
/// Returns the index of the last parameter consumed. Never reads past the
|
||||
/// end of the parameter list — a truncated sequence (`ESC [38m`,
|
||||
/// `ESC [38;2;255m`) is ignored instead of throwing; an emulator must never
|
||||
/// throw on hostile bytes (T-369). Colon-form sub-parameters per ITU T.416
|
||||
/// (`38:2:r:g:b`, `38:2:<colorspace>:r:g:b`, `38:5:n`) are treated as one
|
||||
/// logical group: parsed equivalently to the semicolon form, and dropped
|
||||
/// whole when malformed so they never spill into neighbouring parameters.
|
||||
int _csiHandleExtendedColor(int i, {required bool foreground}) {
|
||||
final params = _csi.params;
|
||||
final sub = _csi.subParam;
|
||||
|
||||
// End of the colon-linked group starting at params[i] (exclusive).
|
||||
var end = i + 1;
|
||||
while (end < params.length && sub[end]) {
|
||||
end++;
|
||||
}
|
||||
|
||||
if (end > i + 1) {
|
||||
// Colon form. Group is params[i..end-1]; n includes the 38/48 itself.
|
||||
final n = end - i;
|
||||
final mode = params[i + 1];
|
||||
if (mode == 5 && n >= 3) {
|
||||
foreground ? handler.setForegroundColor256(params[i + 2]) : handler.setBackgroundColor256(params[i + 2]);
|
||||
} else if (mode == 2) {
|
||||
// A 6+ element group carries the T.416 colorspace id slot — skip it.
|
||||
final base = n >= 6 ? i + 3 : i + 2;
|
||||
if (base + 2 < end) {
|
||||
foreground
|
||||
? handler.setForegroundColorRgb(params[base], params[base + 1], params[base + 2])
|
||||
: handler.setBackgroundColorRgb(params[base], params[base + 1], params[base + 2]);
|
||||
}
|
||||
}
|
||||
return end - 1;
|
||||
}
|
||||
|
||||
// Semicolon form (legacy).
|
||||
if (i + 1 >= params.length) return i; // bare 38/48 — ignore
|
||||
switch (params[i + 1]) {
|
||||
case 2:
|
||||
if (i + 4 >= params.length) return params.length - 1; // truncated — ignore
|
||||
foreground
|
||||
? handler.setForegroundColorRgb(params[i + 2], params[i + 3], params[i + 4])
|
||||
: handler.setBackgroundColorRgb(params[i + 2], params[i + 3], params[i + 4]);
|
||||
return i + 4;
|
||||
case 5:
|
||||
if (i + 2 >= params.length) return params.length - 1; // truncated — ignore
|
||||
foreground ? handler.setForegroundColor256(params[i + 2]) : handler.setBackgroundColor256(params[i + 2]);
|
||||
return i + 2;
|
||||
}
|
||||
// Unknown mode — consume it so it isn't re-interpreted as an SGR code.
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
import 'dart:convert' show ByteConversionSink, Utf8Decoder;
|
||||
import 'dart:math' show max;
|
||||
|
||||
import 'package:clide/src/terminal/src/base/observable.dart';
|
||||
@@ -215,11 +216,28 @@ class Terminal with Observable implements TerminalState, EscapeHandler {
|
||||
/// Writes the data from the underlying program to the terminal. Calling this
|
||||
/// updates the states of the terminal and emits events such as [onBell] or
|
||||
/// [onTitleChange] when the escape sequences in [data] request it.
|
||||
///
|
||||
/// Byte-stream consumers (PTY output, file tails) should use [writeBytes]
|
||||
/// instead — decoding per-chunk corrupts a multi-byte rune split across
|
||||
/// reads (T-373). This String entry point stays for tests and
|
||||
/// programmatic writes.
|
||||
void write(String data) {
|
||||
_parser.write(data);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Persistent chunked UTF-8 decoder feeding [write] — carries partial
|
||||
/// rune state across [writeBytes] calls so a glyph split across two PTY
|
||||
/// reads still renders as one glyph (T-373).
|
||||
late final ByteConversionSink _byteSink = const Utf8Decoder(allowMalformed: true).startChunkedConversion(_WriteSink(this));
|
||||
|
||||
/// Byte-stream twin of [write]: decodes UTF-8 with state retained across
|
||||
/// calls, so chunk boundaries can never split a rune into U+FFFD garbage.
|
||||
void writeBytes(List<int> bytes) {
|
||||
if (bytes.isEmpty) return;
|
||||
_byteSink.add(bytes);
|
||||
}
|
||||
|
||||
/// Sends a key event to the underlying program.
|
||||
///
|
||||
/// See also:
|
||||
@@ -863,3 +881,15 @@ class Terminal with Observable implements TerminalState, EscapeHandler {
|
||||
onPrivateOSC?.call(ps, pt);
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes the chunked UTF-8 decoder's output into [Terminal.write] (T-373).
|
||||
class _WriteSink implements Sink<String> {
|
||||
_WriteSink(this._terminal);
|
||||
final Terminal _terminal;
|
||||
|
||||
@override
|
||||
void add(String data) => _terminal.write(data);
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/// Replay-latest broadcast value holder (T-386).
|
||||
///
|
||||
/// Broadcast streams drop the current value for late subscribers — the
|
||||
/// recurring bug factory behind T-274 (status bar blank because the
|
||||
/// `system/init` event fired before the pane subscribed) and the
|
||||
/// per-site `initialData` workarounds. A [ValueStream] carries STATE,
|
||||
/// not events: every new subscriber immediately receives the latest
|
||||
/// value (when one exists), then live updates.
|
||||
///
|
||||
/// Pure Dart — usable from the IPC/daemon layer and under `dart test`.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
class ValueStream<T> {
|
||||
ValueStream();
|
||||
|
||||
ValueStream.seeded(T value) : _value = value, _hasValue = true;
|
||||
|
||||
final StreamController<T> _ctl = StreamController<T>.broadcast();
|
||||
T? _value;
|
||||
bool _hasValue = false;
|
||||
|
||||
/// Whether a value has been added (or seeded) yet. A fresh, unseeded
|
||||
/// holder replays nothing — subscribers wait for the first [add].
|
||||
bool get hasValue => _hasValue;
|
||||
|
||||
/// The latest value, or null before the first [add]. For a nullable
|
||||
/// [T], disambiguate with [hasValue].
|
||||
T? get valueOrNull => _value;
|
||||
|
||||
/// The latest value. Throws [StateError] before the first [add] —
|
||||
/// callers that can race the first value should use [valueOrNull].
|
||||
T get value {
|
||||
if (!_hasValue) throw StateError('ValueStream has no value yet');
|
||||
return _value as T;
|
||||
}
|
||||
|
||||
void add(T value) {
|
||||
_value = value;
|
||||
_hasValue = true;
|
||||
if (!_ctl.isClosed) _ctl.add(value);
|
||||
}
|
||||
|
||||
/// A stream that replays the latest value (if any) to its subscriber,
|
||||
/// then follows live updates. Each access returns a fresh
|
||||
/// single-subscription stream, so every listener gets its own replay.
|
||||
Stream<T> get stream {
|
||||
late StreamController<T> out;
|
||||
StreamSubscription<T>? sub;
|
||||
out = StreamController<T>(
|
||||
onListen: () {
|
||||
if (_hasValue) out.add(_value as T);
|
||||
if (_ctl.isClosed) {
|
||||
out.close();
|
||||
return;
|
||||
}
|
||||
sub = _ctl.stream.listen(out.add, onError: out.addError, onDone: out.close);
|
||||
},
|
||||
onPause: () => sub?.pause(),
|
||||
onResume: () => sub?.resume(),
|
||||
onCancel: () => sub?.cancel(),
|
||||
);
|
||||
return out.stream;
|
||||
}
|
||||
|
||||
bool get isClosed => _ctl.isClosed;
|
||||
|
||||
Future<void> close() => _ctl.close();
|
||||
}
|
||||
Reference in New Issue
Block a user