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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()}"');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)';
|
||||
}
|
||||
Reference in New Issue
Block a user