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
+43
View File
@@ -144,10 +144,17 @@ class ExtensionManager extends ChangeNotifier {
}
}
final ctx = _ExtensionContext(manager: this, id: ext.id);
// Transactional: a throw mid-activation must leave NOTHING mounted —
// the old path left earlier contributions live while the extension
// recorded as failed, and a retry double-applied them (T-377).
final applied = <ContributionPoint>[];
var extActivated = false;
try {
await ext.activate(ctx);
extActivated = true;
for (final c in ext.contributions) {
_applyContribution(c);
applied.add(c);
}
// Eagerly load the i18n catalog for any localized tab this extension
// contributes, so its title resolves without a "namespace not
@@ -165,6 +172,22 @@ class ExtensionManager extends ChangeNotifier {
notifyListeners();
log.info('extensions', 'activated $id');
} catch (e, st) {
for (final c in applied.reversed) {
try {
_removeContribution(c);
} catch (e2) {
log.warn('extensions', 'unwind of ${c.id} failed during $id rollback: $e2');
}
}
if (extActivated) {
// The extension's own activate() succeeded — give it the matching
// teardown so it doesn't hold resources for a failed activation.
try {
await ext.deactivate();
} catch (e2) {
log.warn('extensions', 'deactivate during $id rollback failed: $e2');
}
}
_failed[id] = e;
log.error('extensions', 'activate failed for $id', error: e, stackTrace: st);
notifyListeners();
@@ -175,6 +198,17 @@ class ExtensionManager extends ChangeNotifier {
if (!_activated.contains(id)) return;
final ext = _known[id];
if (ext == null) return;
// Refuse while active extensions depend on this one — deactivating
// underneath them leaves them running against missing services (T-377).
// Disable the dependents first.
final dependents = [
for (final e in _known.values)
if (_activated.contains(e.id) && e.dependsOn.contains(id)) e.id,
];
if (dependents.isNotEmpty) {
log.warn('extensions', 'refusing to deactivate $id: active dependents: ${dependents.join(', ')}');
return;
}
try {
await ext.deactivate();
for (final c in ext.contributions) {
@@ -196,8 +230,17 @@ class ExtensionManager extends ChangeNotifier {
case TabContribution _:
case StatusItemContribution _:
case ToolbarButtonContribution _:
// Reject duplicates instead of silently mounting a second copy —
// benign among curated builtins, hazardous once third-party
// extensions land (T-377). The throw rolls the activation back.
if (panels.hasContribution(c.id)) {
throw StateError('duplicate contribution id: ${c.id}');
}
panels.contribute(c);
case CommandContribution cmd:
if (commands.get(cmd.command) != null) {
throw StateError('duplicate command id: ${cmd.command}');
}
commands.register(cmd);
final binding = cmd.defaultBinding;
if (binding != null) {
+3 -3
View File
@@ -144,7 +144,7 @@ class KernelServices {
final messages = MessageBus();
final filterStates = FilterStateCache(messages: messages);
final settings = SettingsStore(appDir: appDir);
final settings = SettingsStore(appDir: appDir, onError: (m) => log.warn('settings', m));
await settings.load();
final i18n = I18n(loader: i18nLoader, log: log, defaultLocale: defaultLocale, initialLocale: initialLocale, availableLocales: availableLocales);
@@ -166,7 +166,7 @@ class KernelServices {
final readerNav = ReaderNavRegistry(messages);
final clipboard = ClideClipboard();
final files = FileServices(events);
final notify = Notifications();
final notify = Notifications(messages: messages);
final dialog = DialogRouter();
final tray = TrayRegistry();
final secrets = SecretsVault();
@@ -191,7 +191,7 @@ class KernelServices {
isolateClient ??
(daemonClientFactory != null
? daemonClientFactory(log, events, arrangement, panels)
: DaemonClient(
: DaemonClient.unixSocket(
// Legacy socket-client fallback — kept until T-127
// replaces it with the in-process socket loopback.
// Today nothing in production hits this branch
+53 -46
View File
@@ -1,6 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:clide/clide.dart';
@@ -10,14 +8,24 @@ import 'package:clide/kernel/src/log.dart';
import 'package:flutter/foundation.dart';
class DaemonClient extends ChangeNotifier {
DaemonClient({required String socketPath, required Logger log, required DaemonBus events}) : _socketPath = socketPath, _log = log, _events = events;
/// Connects through [transport] (T-331). The local app passes a
/// [LocalSocketTransport]; a remote workspace will pass an SSH-backed
/// transport without this class changing.
DaemonClient({required DaemonTransport transport, required Logger log, required DaemonBus events}) : _transport = transport, _log = log, _events = events;
String _socketPath;
String get socketPath => _socketPath;
/// Convenience for the local unix-socket path — today's only
/// production shape.
DaemonClient.unixSocket({required String socketPath, required Logger log, required DaemonBus events})
: this(transport: LocalSocketTransport(socketPath), log: log, events: events);
DaemonTransport _transport;
/// The backend endpoint description — the unix socket path locally.
String get socketPath => _transport.endpoint;
final Logger _log;
final DaemonBus _events;
Socket? _socket;
DaemonConnection? _conn;
bool _connected = false;
bool _disposed = false;
bool _started = false;
@@ -48,30 +56,33 @@ class DaemonClient extends ChangeNotifier {
_started = false;
_reconnectTimer?.cancel();
_reconnectTimer = null;
final s = _socket;
_socket = null;
await s?.close();
final c = _conn;
_conn = null;
await c?.close();
_failPending('client stopped');
_wakeConnectWaiters();
_setConnected(false);
}
/// Point the client at a different socket path and reconnect.
/// Point the client at a different local socket path and reconnect.
/// Used on project switch — the workspace-derived socket path
/// (D-70) changes when the user opens a different project, so the
/// client follows. Cancels the reconnect timer, closes the live
/// socket (failing in-flight requests with `disconnect`), updates
/// the path, and re-arms the connect loop. Idempotent if the new
/// path equals the current one.
Future<void> reconnectAt(String newPath) async {
if (newPath == _socketPath && _connected) return;
_socketPath = newPath;
/// client follows. Sugar over [reconnectWith].
Future<void> reconnectAt(String newPath) => reconnectWith(LocalSocketTransport(newPath));
/// Swap the backend transport and reconnect. Cancels the reconnect
/// timer, closes the live connection (failing in-flight requests with
/// `disconnect`), swaps the transport, and re-arms the connect loop.
/// Idempotent if the new endpoint equals the current connected one.
Future<void> reconnectWith(DaemonTransport transport) async {
if (transport.endpoint == _transport.endpoint && _connected) return;
_transport = transport;
_reconnectTimer?.cancel();
_reconnectTimer = null;
final s = _socket;
_socket = null;
await s?.close();
_failPending('socket path changed');
final c = _conn;
_conn = null;
await c?.close();
_failPending('backend endpoint changed');
_setConnected(false);
_disposed = false;
_started = true;
@@ -80,7 +91,7 @@ class DaemonClient extends ChangeNotifier {
}
Future<IpcResponse> request(String cmd, {Map<String, Object?> args = const {}}) async {
if (!_connected || _socket == null) {
if (!_connected || _conn == null) {
// A connection attempt is in flight (startup or reconnect) — wait
// for it rather than failing instantly, so queries issued during
// the startup window don't get a spurious not-connected error.
@@ -88,7 +99,7 @@ class DaemonClient extends ChangeNotifier {
if (_started && !_disposed) {
await _awaitConnected(_connectWait);
}
if (!_connected || _socket == null) {
if (!_connected || _conn == null) {
return IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'daemon not connected'),
@@ -99,7 +110,7 @@ class DaemonClient extends ChangeNotifier {
final completer = Completer<IpcResponse>();
_pending[id] = completer;
final req = IpcRequest(id: id, cmd: cmd, args: args);
_socket!.writeln(req.encode());
_conn!.writeLine(req.encode());
return completer.future;
}
@@ -126,30 +137,25 @@ class DaemonClient extends ChangeNotifier {
}
Future<void> _connect() async {
// Already connected? Don't open a second socket. Guards against
// Already connected? Don't open a second connection. Guards against
// racing connect attempts (e.g. start() arming the reconnect loop
// while swapIpcServer's reconnectAt connects on first boot).
// while swapBackend's reconnectAt connects on first boot).
if (_disposed || _connected) return;
try {
final addr = InternetAddress(_socketPath, type: InternetAddressType.unix);
final socket = await Socket.connect(addr, 0);
_socket = socket;
final conn = await _transport.open();
_conn = conn;
_backoff = const Duration(milliseconds: 200);
_setConnected(true);
_log.info('ipc', 'connected to $_socketPath');
socket
.cast<List<int>>()
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(
_handleLine,
onDone: _handleDisconnect,
onError: (Object e) {
_log.warn('ipc', 'socket error', error: e);
_handleDisconnect();
},
cancelOnError: true,
);
_log.info('ipc', 'connected to ${_transport.endpoint}');
conn.lines.listen(
_handleLine,
onDone: _handleDisconnect,
onError: (Object e) {
_log.warn('ipc', 'socket error', error: e);
_handleDisconnect();
},
cancelOnError: true,
);
} catch (e) {
_log.debug('ipc', 'connect failed ($e); retry in ${_backoff.inMilliseconds}ms');
_scheduleReconnect();
@@ -175,7 +181,7 @@ class DaemonClient extends ChangeNotifier {
}
void _handleDisconnect() {
_socket = null;
_conn = null;
_failPending('daemon disconnected');
_setConnected(false);
_scheduleReconnect();
@@ -218,8 +224,9 @@ class DaemonClient extends ChangeNotifier {
_disposed = true;
_started = false;
_reconnectTimer?.cancel();
unawaited(_socket?.close());
_socket = null;
final c = _conn;
if (c != null) unawaited(c.close());
_conn = null;
_failPending('client disposed');
_wakeConnectWaiters();
super.dispose();
+67
View File
@@ -98,6 +98,63 @@ class TextScaleResetIntent extends Intent {
const TextScaleResetIntent();
}
// -- Pane navigation (vim normal-mode motions outside the editor) ------------
/// Base for the preset-neutral navigation intents (T-406). A focused non-editor
/// pane (file tree, conversation, lists) runs its own [SequenceMatcher] and
/// dispatches the resolved [NavIntent] to its own handler — the vim preset binds
/// j/k/etc. to these; default/vscode/jetbrains can later bind arrows/page keys
/// to the same ids. Marker base so a pane's key handler can tell a nav motion
/// apart from any other fired intent.
sealed class NavIntent extends Intent {
const NavIntent();
}
/// Move the selection / scroll down one step (vim `j`).
class NavDownIntent extends NavIntent {
const NavDownIntent();
}
/// Move the selection / scroll up one step (vim `k`).
class NavUpIntent extends NavIntent {
const NavUpIntent();
}
/// Scroll down half a viewport (vim `ctrl+d`).
class NavPageDownIntent extends NavIntent {
const NavPageDownIntent();
}
/// Scroll up half a viewport (vim `ctrl+u`).
class NavPageUpIntent extends NavIntent {
const NavPageUpIntent();
}
/// Jump to the first item / top (vim `gg`).
class NavTopIntent extends NavIntent {
const NavTopIntent();
}
/// Jump to the last item / bottom (vim `G`).
class NavBottomIntent extends NavIntent {
const NavBottomIntent();
}
/// Expand the focused node, or step into it / move right (vim `l`).
class NavExpandOrRightIntent extends NavIntent {
const NavExpandOrRightIntent();
}
/// Collapse the focused node, or step out of it / move left (vim `h`).
class NavCollapseOrLeftIntent extends NavIntent {
const NavCollapseOrLeftIntent();
}
/// Activate the focused item — open the file, run the row (vim `o` / `enter`).
class NavActivateIntent extends NavIntent {
const NavActivateIntent();
}
// -- Command bridge ---------------------------------------------------------
/// Generic "invoke this CommandRegistry command id" intent. Used for
@@ -136,6 +193,16 @@ final Map<String, Intent Function()> builtinIntents = {
'quickOpen.selectPrevious': () => const QuickOpenSelectPreviousIntent(),
'quickOpen.accept': () => const QuickOpenAcceptIntent(),
'findInFiles.open': () => const FindInFilesIntent(),
// Pane navigation (T-406) — preset-neutral; the vim preset binds j/k/etc.
'nav.down': () => const NavDownIntent(),
'nav.up': () => const NavUpIntent(),
'nav.pageDown': () => const NavPageDownIntent(),
'nav.pageUp': () => const NavPageUpIntent(),
'nav.top': () => const NavTopIntent(),
'nav.bottom': () => const NavBottomIntent(),
'nav.expandOrRight': () => const NavExpandOrRightIntent(),
'nav.collapseOrLeft': () => const NavCollapseOrLeftIntent(),
'nav.activate': () => const NavActivateIntent(),
'text.scaleIncrease': () => const TextScaleIncreaseIntent(),
'text.scaleDecrease': () => const TextScaleDecreaseIntent(),
'text.scaleReset': () => const TextScaleResetIntent(),
+19 -2
View File
@@ -167,20 +167,37 @@ class KeymapService extends ChangeNotifier {
return km.match(sequence, _scope).exact;
}
/// Scope-flag producers clear their flags from widget dispose() — which
/// during app teardown runs AFTER KernelServices.dispose() has disposed
/// this notifier. Tolerate that ordering instead of asserting (the same
/// fire-and-forget pattern SettingsStore uses).
bool _disposed = false;
@override
void dispose() {
_disposed = true;
super.dispose();
}
void _safeNotify() {
if (_disposed) return;
notifyListeners();
}
/// Set a named scope flag. Producers should call this when their
/// state changes so when-clauses re-evaluate correctly. Notifies
/// listeners when the value actually changes.
void setScopeFlag(String name, bool value) {
if (_scope[name] == value) return;
_scope[name] = value;
notifyListeners();
_safeNotify();
}
/// Clear a named scope flag.
void clearScopeFlag(String name) {
if (!_scope.containsKey(name)) return;
_scope.remove(name);
notifyListeners();
_safeNotify();
}
/// Switch presets. Persists the new preset name to settings and
+46 -23
View File
@@ -1,10 +1,16 @@
/// Detects a double-tapped bare modifier (e.g. JetBrains "Search
/// Everywhere" = double-Shift). (T-341)
///
/// Headless and clock-injected: the caller (the global key handler) passes
/// the event time so it neither reads a clock nor consumes events. Feed it
/// every [KeyDownEvent]: a bare modifier press via [tap], any other key via
/// [reset] (an intervening key breaks the gesture, e.g. `Shift a Shift`).
/// A "tap" is a clean press-and-release: no other key may go down while the
/// modifier is held, otherwise the press was a chord (`Shift+;` typing a
/// colon) and must not count (T-409). The gesture therefore completes on the
/// second clean *release*, never on a key-down — at down time it's unknowable
/// whether the press will stay bare.
///
/// Headless and clock-injected: the caller (the root shell's raw-keyboard
/// handler) passes the event time so it neither reads a clock nor consumes
/// events. Feed every [KeyDownEvent] to [down] and every [KeyUpEvent] to
/// [up], passing the event's [KeyModifier] (null for non-modifier keys).
library;
import 'key_chord.dart';
@@ -12,33 +18,50 @@ import 'key_chord.dart';
class ModifierTapTracker {
ModifierTapTracker({this.window = const Duration(milliseconds: 350)});
/// Max gap between the two taps to count as a double-tap.
/// Max gap between the two tap releases to count as a double-tap.
final Duration window;
KeyModifier? _last;
DateTime? _lastAt;
/// Modifier currently held whose press is still bare (no chorded key yet).
KeyModifier? _pressing;
/// Record a bare-modifier press at [now]. Returns the modifier when this
/// press completes a double-tap of the *same* modifier within [window];
/// otherwise records it as the first tap and returns null.
KeyModifier? tap(KeyModifier m, DateTime now) {
final last = _last;
final lastAt = _lastAt;
if (last == m && lastAt != null) {
final gap = now.difference(lastAt);
/// Modifier of the last completed clean tap, arming the double-tap.
KeyModifier? _armed;
DateTime? _armedAt;
/// Record a key press. A non-modifier key ([mod] == null) — or any key
/// landing while a modifier is already held — is a chord: it dirties the
/// held press and breaks the armed gesture.
void down(KeyModifier? mod) {
if (mod == null || _pressing != null) {
_pressing = null;
_disarm();
return;
}
_pressing = mod;
}
/// Record a key release at [now]. Returns the modifier when this release
/// completes a double-tap: the second clean tap of the *same* modifier
/// within [window] of the first tap's release.
KeyModifier? up(KeyModifier? mod, DateTime now) {
if (mod == null) return null;
final pressing = _pressing;
_pressing = null;
if (pressing != mod) return null; // press went dirty (chorded) or stale
if (_armed == mod && _armedAt != null) {
final gap = now.difference(_armedAt!);
if (gap >= Duration.zero && gap <= window) {
reset();
return m;
_disarm();
return mod;
}
}
_last = m;
_lastAt = now;
_armed = mod;
_armedAt = now;
return null;
}
/// Break the gesture — any non-modifier key press resets the tracker.
void reset() {
_last = null;
_lastAt = null;
void _disarm() {
_armed = null;
_armedAt = null;
}
}
+122
View File
@@ -0,0 +1,122 @@
/// A reusable vim normal-mode navigation key handler for non-editor panes
/// (T-406).
///
/// The passive global key path is single-chord only and can't run sequences or
/// consume events (D-82), so — exactly like the editor's command-mode handler —
/// each pane that wants vim motions hosts its OWN [SequenceMatcher] inside a
/// `Focus.onKeyEvent`. [PaneKeyNav] is that handler, factored out so the file
/// tree, conversation, and lists share one implementation.
///
/// While a `vim.normal` scope flag is set and this region holds focus, bare and
/// shift-only chords (plus the two half-page chords `ctrl+d` / `ctrl+u`) feed
/// the matcher against the live keymap; a fired [NavIntent] is handed to
/// [onNav] with its repeat count. Everything else under `vim.normal` is
/// swallowed (vim normal mode is inert for unbound keys), except other-modifier
/// chords (palette, quick-open, …) which bubble to the global handler. Under a
/// non-vim preset or in insert mode the region is transparent — keys pass
/// straight through.
///
/// The vim preset binds nav.* `when: vim.normal && !editor.focused`, so a key
/// that also has an `editor.vim.*` motion (j/k/h/l/gg/G) resolves to the nav
/// intent here and to the editor motion in the editor — see vim.yaml.
library;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../facade.dart';
import 'intents.dart';
import 'key_chord.dart';
import 'keymap.dart';
import 'sequence_matcher.dart';
/// Signature for a fired navigation motion: the [intent] and its repeat
/// [count] (>= 1, from a leading digit prefix like `5j`).
typedef NavHandler = void Function(NavIntent intent, int count);
class PaneKeyNav extends StatefulWidget {
const PaneKeyNav({super.key, required this.child, required this.onNav, this.focusNode, this.autofocus = false, this.canRequestFocus = true});
final Widget child;
/// Called when a `nav.*` motion resolves while this region has focus.
final NavHandler onNav;
/// Focus node for the region. When null, [PaneKeyNav] owns one. Panes that
/// want to move focus here programmatically (a row tap, F6) pass their own.
final FocusNode? focusNode;
final bool autofocus;
/// Whether the region can take focus at all. False makes it a pure pass-through
/// (used when a pane temporarily routes keys elsewhere, e.g. a filter box).
final bool canRequestFocus;
@override
State<PaneKeyNav> createState() => _PaneKeyNavState();
}
class _PaneKeyNavState extends State<PaneKeyNav> {
FocusNode? _ownNode;
SequenceMatcher? _matcher;
FocusNode get _node => widget.focusNode ?? (_ownNode ??= FocusNode(debugLabel: 'PaneKeyNav'));
/// The half-page scroll chords are the only modified chords this handler
/// claims; every other modified chord bubbles to the global shortcut path.
static final KeyChord _ctrlD = KeyChord(modifiers: const {KeyModifier.ctrl}, key: LogicalKeyboardKey.keyD);
static final KeyChord _ctrlU = KeyChord(modifiers: const {KeyModifier.ctrl}, key: LogicalKeyboardKey.keyU);
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_matcher != null) return;
final kernel = ClideKernel.of(context);
_matcher = SequenceMatcher(keymap: () => kernel.keymap.keymap ?? Keymap(const []), context: () => kernel.keymap.scope);
}
@override
void dispose() {
_ownNode?.dispose();
super.dispose();
}
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) return KeyEventResult.ignored;
final kernel = ClideKernel.of(context);
// Only vim normal mode drives pane navigation. Insert/visual or a non-vim
// preset → transparent, keys pass through to whatever's below.
if (kernel.keymap.scope['vim.normal'] != true) return KeyEventResult.ignored;
final hw = HardwareKeyboard.instance;
final chord = KeyChord.fromKeyEvent(event, hw);
if (chord == null) return KeyEventResult.ignored;
// Bare + shift-only chords drive the matcher; ctrl+d/ctrl+u are the only
// modified chords we claim (half-page scroll). Any other modified chord is
// an app shortcut (palette, quick-open) — let it bubble to the global path.
final modified = chord.modifiers.any((m) => m != KeyModifier.shift);
if (modified && chord != _ctrlD && chord != _ctrlU) return KeyEventResult.ignored;
final r = _matcher!.feed(chord);
switch (r.outcome) {
case SeqOutcome.fired:
// The vim preset also binds these keys to editor.vim.* motions; in a
// pane only nav.* applies. A non-nav fired intent (e.g. a stray
// editor.vim.* with no focus guard) is swallowed, never executed here.
if (r.intent is NavIntent) widget.onNav(r.intent! as NavIntent, r.count);
return KeyEventResult.handled;
case SeqOutcome.pending:
return KeyEventResult.handled;
case SeqOutcome.unmatched:
// Vim normal mode beeps on unbound keys — swallow so a bare key never
// leaks to text input or the global handler.
return KeyEventResult.handled;
}
}
@override
Widget build(BuildContext context) {
return Focus(focusNode: _node, autofocus: widget.autofocus, canRequestFocus: widget.canRequestFocus, onKeyEvent: _onKey, child: widget.child);
}
}
+25
View File
@@ -1,5 +1,7 @@
import 'dart:async';
import 'package:clide/kernel/src/events/message_bus.dart';
import 'package:clide/kernel/src/toast.dart';
import 'package:flutter/foundation.dart';
enum NotificationLevel { info, warning, error, success }
@@ -18,6 +20,14 @@ class ClideNotification {
}
class Notifications extends ChangeNotifier {
Notifications({MessageBus? messages}) : _messages = messages;
/// When wired (the facade passes the kernel bus), every notification is
/// also published to the toast channel so it actually renders — the
/// in-memory list had zero widget consumers and messages vanished
/// silently (T-382).
final MessageBus? _messages;
final List<ClideNotification> _active = [];
final Map<String, Timer> _timers = {};
int _seq = 0;
@@ -41,6 +51,21 @@ class Notifications extends ChangeNotifier {
final n = ClideNotification(id: id, level: level, message: message, title: title, duration: duration ?? const Duration(seconds: 4));
_active.add(n);
_timers[id] = Timer(n.duration, () => dismiss(id));
final bus = _messages;
if (bus != null) {
publishToast(
bus,
'kernel.notify',
title == null ? message : '$title$message',
severity: switch (level) {
NotificationLevel.info => ToastSeverity.info,
NotificationLevel.warning => ToastSeverity.warning,
NotificationLevel.error => ToastSeverity.error,
NotificationLevel.success => ToastSeverity.success,
},
duration: duration,
);
}
notifyListeners();
}
+5
View File
@@ -30,6 +30,11 @@ class PanelRegistry extends ChangeNotifier {
notifyListeners();
}
/// Whether any slot already mounts a contribution with [id]. Used by the
/// extension manager to reject duplicate ids instead of silently mounting
/// a second copy (T-377).
bool hasContribution(String id) => _mounts.values.any((list) => list.any((c) => c.id == id));
void contribute(ContributionPoint point) {
final slot = point.slot;
if (slot == null) return;
+33 -1
View File
@@ -6,10 +6,20 @@ import 'package:clide/kernel/src/events/types.dart';
import 'package:clide/kernel/src/log.dart';
import 'package:clide/kernel/src/settings.dart';
import 'package:clide/kernel/src/toolchain.dart';
import 'package:clide/kernel/src/workspace_ref.dart';
import 'package:flutter/foundation.dart';
class RecentProject {
const RecentProject({required this.path, required this.name, this.branch, required this.lastOpened, this.startupSticky = false});
const RecentProject({
required this.path,
required this.name,
this.branch,
required this.lastOpened,
this.startupSticky = false,
this.host,
this.port,
this.user,
});
final String path;
final String name;
@@ -21,12 +31,27 @@ class RecentProject {
/// opens it directly; otherwise the welcome screen takes over (T-115).
final bool startupSticky;
/// Remote workspace identity (T-332/T-329): the SSH host (or
/// `~/.ssh/config` alias) the repo lives on. Absent = local — older
/// persisted recents deserialize as local automatically.
final String? host;
final int? port;
final String? user;
bool get isRemote => host != null;
/// This recent's location as a [WorkspaceRef].
WorkspaceRef get ref => host == null ? WorkspaceRef.local(path) : WorkspaceRef.remote(host: host!, path: path, port: port, user: user);
RecentProject copyWith({bool? startupSticky, DateTime? lastOpened, String? branch}) => RecentProject(
path: path,
name: name,
branch: branch ?? this.branch,
lastOpened: lastOpened ?? this.lastOpened,
startupSticky: startupSticky ?? this.startupSticky,
host: host,
port: port,
user: user,
);
Map<String, dynamic> toJson() => {
@@ -35,6 +60,9 @@ class RecentProject {
'branch': branch,
'lastOpened': lastOpened.toIso8601String(),
if (startupSticky) 'startupSticky': true,
if (host != null) 'host': host,
if (port != null) 'port': port,
if (user != null) 'user': user,
};
factory RecentProject.fromJson(Map<String, dynamic> json) => RecentProject(
@@ -43,9 +71,13 @@ class RecentProject {
branch: json['branch'] as String?,
lastOpened: DateTime.tryParse(json['lastOpened'] as String? ?? '') ?? DateTime.now(),
startupSticky: json['startupSticky'] as bool? ?? false,
host: json['host'] as String?,
port: json['port'] as int?,
user: json['user'] as String?,
);
String get relativePath {
if (isRemote) return '$host:$path';
final home = Platform.environment['HOME'] ?? '';
if (home.isNotEmpty && path.startsWith(home)) return '~${path.substring(home.length)}';
return path;
+42 -7
View File
@@ -6,11 +6,16 @@ import 'package:yaml/yaml.dart';
enum SettingsScope { app, project, ext }
class SettingsStore extends ChangeNotifier {
SettingsStore({required this.appDir, this.projectDir});
SettingsStore({required this.appDir, this.projectDir, this.onError});
final Directory appDir;
Directory? projectDir;
/// Surfaces load/parse problems (wired to the kernel Logger by the
/// facade). A parse failure must not pass silently — it used to reset
/// every setting on the next write (T-376).
final void Function(String message)? onError;
final Map<String, Object?> _appValues = <String, Object?>{};
final Map<String, Object?> _projectValues = <String, Object?>{};
@@ -93,17 +98,29 @@ class SettingsStore extends ChangeNotifier {
}
Future<Map<String, Object?>> _readFile(File f) async {
String txt;
try {
if (!await f.exists()) return <String, Object?>{};
final txt = await f.readAsString();
if (txt.trim().isEmpty) return <String, Object?>{};
txt = await f.readAsString();
} catch (_) {
// On web (or in sandboxes where the path isn't readable) silently
// degrade to an empty in-memory catalog. `set` will no-op too.
return <String, Object?>{};
}
if (txt.trim().isEmpty) return <String, Object?>{};
try {
final yaml = loadYaml(txt);
final out = <String, Object?>{};
if (yaml is Map) _flatten(yaml, '', out);
return out;
} catch (_) {
// On web (or in sandboxes where the path isn't writable) silently
// degrade to an empty in-memory catalog. `set` will no-op too.
} catch (e) {
// A parse failure must not silently reset the user's settings — the
// next `set` overwrites the file with the (now empty) in-memory map.
// Preserve the original for recovery and say so (T-376).
try {
await File('${f.path}.broken').writeAsString(txt);
} catch (_) {}
onError?.call('failed to parse ${f.path}: $e — original preserved at ${f.path}.broken');
return <String, Object?>{};
}
}
@@ -111,7 +128,11 @@ class SettingsStore extends ChangeNotifier {
Future<void> _writeFile(File f, Map<String, Object?> flat) async {
try {
await f.parent.create(recursive: true);
await f.writeAsString(_emitYaml(_unflatten(flat)));
// Temp-file + rename: a crash mid-write must not truncate the live
// settings file (T-376).
final tmp = File('${f.path}.tmp');
await tmp.writeAsString(_emitYaml(_unflatten(flat)));
await tmp.rename(f.path);
} catch (_) {
// Web / read-only sandbox: in-memory update remains valid, we
// just can't persist. Callers already called notifyListeners.
@@ -214,6 +235,20 @@ void _emitScalar(StringBuffer buf, Object? v) {
_emitScalar(buf, v[i]);
}
buf.write(']');
} else if (v is Map) {
// YAML flow mapping — maps nested inside lists (e.g. keymap overlay
// entries) used to fall through to toString() and corrupt on the
// next read (T-376).
buf.write('{');
var first = true;
v.forEach((k, vv) {
if (!first) buf.write(', ');
first = false;
_emitScalar(buf, '$k');
buf.write(': ');
_emitScalar(buf, vv);
});
buf.write('}');
} else {
buf.write('"${v.toString()}"');
}
-47
View File
@@ -1,47 +0,0 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import '../../src/pty/env.dart';
class ToolCheck extends ChangeNotifier {
bool pqlOk = false;
bool tmuxOk = false;
bool gitOk = false;
bool checked = false;
bool get allOk => pqlOk && tmuxOk && gitOk;
List<String> get errors => [if (!pqlOk) 'pql not found', if (!tmuxOk) 'tmux not found', if (!gitOk) 'git not found'];
/// Workspace root, set by the app at boot. Falls back to cwd.
static String? workspaceRoot;
Future<void> check() async {
pqlOk = _existsOnPath('pql');
// tmux has no Windows build; absence there is the documented
// no-tmux mode, not a failed check.
tmuxOk = Platform.isWindows || _existsOnPath('tmux');
gitOk = _existsOnPath('git');
checked = true;
notifyListeners();
}
/// Check if [name] exists as an executable in any PATH directory.
/// Uses direct file-existence checks — works inside a macOS sandbox
/// without needing to exec `which`.
static bool _existsOnPath(String name) {
final sep = Platform.isWindows ? ';' : ':';
for (final dir in expandedPath.split(sep)) {
if (dir.isEmpty) continue;
if (Platform.isWindows) {
for (final ext in const ['.exe', '.bat', '.cmd', '.com', '']) {
if (File('$dir\\$name$ext').existsSync()) return true;
}
} else {
if (File('$dir/$name').existsSync()) return true;
}
}
return false;
}
}
+64
View File
@@ -0,0 +1,64 @@
/// WorkspaceRef (T-332): where a workspace lives — a local repo root or
/// a repo on a remote host reached over SSH (T-329).
///
/// The remote form is written `ssh://[user@]host[:port]/abs/remote/path`
/// (host may be a `~/.ssh/config` alias — resolution happens at connect
/// time, not here). A bare string with no scheme is a local path.
library;
/// A reference to a workspace root. Immutable value type.
class WorkspaceRef {
const WorkspaceRef.local(this.path) : host = null, port = null, user = null;
const WorkspaceRef.remote({required String this.host, required this.path, this.port, this.user});
/// Remote host (or `~/.ssh/config` alias). Null means local.
final String? host;
/// SSH port; null means the ssh default / config-resolved port.
final int? port;
/// SSH user; null means the local username / config-resolved user.
final String? user;
/// Absolute workspace path — on [host] when remote, locally otherwise.
final String path;
bool get isRemote => host != null;
/// Parse either a plain local path or an `ssh://` URI. Returns null
/// for a malformed `ssh://` form (no host, or no absolute path).
static WorkspaceRef? parse(String input) {
if (!input.startsWith('ssh://')) return WorkspaceRef.local(input);
final Uri uri;
try {
uri = Uri.parse(input);
} on FormatException {
return null;
}
if (uri.host.isEmpty || uri.path.isEmpty || uri.path == '/') return null;
return WorkspaceRef.remote(host: uri.host, path: uri.path, port: uri.hasPort ? uri.port : null, user: uri.userInfo.isEmpty ? null : uri.userInfo);
}
/// The canonical string form: the bare path locally, the full
/// `ssh://` URI remotely. `parse(uri) == ref` round-trips.
String get uri {
if (!isRemote) return path;
final auth = user == null ? host! : '$user@$host';
final p = port == null ? '' : ':$port';
return 'ssh://$auth$p$path';
}
/// Compact human form for recents/switcher rows: `host:path` remotely
/// (e.g. `buildbox:/srv/repo`), the bare path locally.
String get display => isRemote ? '$host:$path' : path;
@override
bool operator ==(Object other) => other is WorkspaceRef && other.host == host && other.port == port && other.user == user && other.path == path;
@override
int get hashCode => Object.hash(host, port, user, path);
@override
String toString() => 'WorkspaceRef($uri)';
}