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:
2026-06-14 18:21:41 +02:00
co-authored by Claude Opus 4.8
152 changed files with 13684 additions and 4364 deletions
+19 -1
View File
@@ -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});
}
+9
View File
@@ -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.
+6 -2
View File
@@ -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.
+11 -5
View File
@@ -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
View File
@@ -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);
}
}
+52 -1
View File
@@ -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
View File
@@ -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 {
+73
View File
@@ -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
View File
@@ -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);
}
}
+5
View File
@@ -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);
+8 -5
View File
@@ -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'(?:.*/)?');
+6
View File
@@ -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);
}
+101
View File
@@ -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),
),
);
}
}
+270
View File
@@ -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),
],
),
);
},
);
}
}
+245
View File
@@ -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),
],
),
),
);
}
}
+306
View File
@@ -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());
},
);
}
}
+372
View File
@@ -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);
}
}
}
+72 -827
View File
@@ -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 (0x200x2f) 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;
}
}
+30
View File
@@ -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() {}
}
+70
View File
@@ -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();
}