diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index b73350c2..d296c0cc 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -4060,3 +4060,34 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang Fix: drive both roster surfaces from TeamBroker membership (expose a listenable roster or change stream on the broker), delete TeamMemberJoined/TeamMemberLeft from kernel events/types.dart, and remove the dead listeners. Coordinate with T-395 (meta sidebar split) — whichever lands second adapts. Acceptance: spawning a team session through the orchestrator makes the member appear in both surfaces (widget test); the ghost event types are gone from types.dart; no kernel.events team-member subscriptions remain.', NULL, '2026-06-12 00:12:04', '2026-06-12 00:12:04', '2026-06-12 00:12:04', NULL, 'f04b7af4488069d004d99acbf9717cc6', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCXFF5V1RT6QJETS2K4C0G', 'status', 'in_progress', 'done', NULL, '2026-06-12 00:23:13', '2026-06-12 00:23:13', '2026-06-12 00:23:13', NULL, '8c817e20084503af9ab849d4974eef97', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCXFF5V1RT6QJETS2K4C0G', 'description', 'Verified-dead code worth one sweep (coverage denominator benefits too): + +- Legacy free-function git API (~250 LOC duplicating GitClient, kept alive only by its own tests, and carrying its own latent pipe-deadlock bug) — delete API + tests. +- ToolCheck — zero callers. +- ~60% of lib/src/pty/ffi/libc.dart — fd-passing-era bindings unused since D-56. +- GraphView — unreachable placeholder (note: the Governance Graph idea (see Q-records from this review) may later want the slot; deleting now is still right, it is a 17-line stub). +- ColumnHat — duplicated line-for-line in app.dart, kept alive by a zero-coverage test; the app.dart split ticket removes the duplicate, this sweep removes the orphan. +- tmux-era team pipeline: TranscriptPublisher, TeamMemberJoined — nothing emits these events, yet the team roster UI listens to them exclusively (team tiles are populated by ghosts). Remove pipeline + dead listeners; if the roster UI stays, it needs a real data source first (surface that before deleting the UI). +- Dead ptyc binary still committed in native/linux-x64/ against D-62/D-63 — remove binary + licenses.yaml entry if present. +- mocktail — pinned, documented in D-25 as the IO-mocking strategy, imported by zero files: either adopt it where mocks are hand-rolled or drop the dep AND amend D-25. + +Each bullet is one commit. Run make test + coverage after each; expect the floor to ratchet up. + +Correction (2026-06-12, verified during T-394 breakdown): ColumnHat is NOT duplicated line-for-line in app.dart — it exists only in lib/widgets/src/clide_column_hat.dart. Before deleting it, verify it actually has zero non-test callers; if it is genuinely used by app chrome, drop that bullet from this sweep.', 'Verified-dead code worth one sweep (coverage denominator benefits too): + +- Legacy free-function git API (~250 LOC duplicating GitClient, kept alive only by its own tests, and carrying its own latent pipe-deadlock bug) — delete API + tests. +- ToolCheck — zero callers. +- ~60% of lib/src/pty/ffi/libc.dart — fd-passing-era bindings unused since D-56. +- GraphView — unreachable placeholder (note: the Governance Graph idea (see Q-records from this review) may later want the slot; deleting now is still right, it is a 17-line stub). +- ColumnHat — duplicated line-for-line in app.dart, kept alive by a zero-coverage test; the app.dart split ticket removes the duplicate, this sweep removes the orphan. +- tmux-era team pipeline: TranscriptPublisher, TeamMemberJoined — nothing emits these events, yet the team roster UI listens to them exclusively (team tiles are populated by ghosts). Remove pipeline + dead listeners; if the roster UI stays, it needs a real data source first (surface that before deleting the UI). +- Dead ptyc binary still committed in native/linux-x64/ against D-62/D-63 — remove binary + licenses.yaml entry if present. +- mocktail — pinned, documented in D-25 as the IO-mocking strategy, imported by zero files: either adopt it where mocks are hand-rolled or drop the dep AND amend D-25. + +Each bullet is one commit. Run make test + coverage after each; expect the floor to ratchet up. + +Correction (2026-06-12, verified during T-394 breakdown): ColumnHat is NOT duplicated line-for-line in app.dart — it exists only in lib/widgets/src/clide_column_hat.dart. Before deleting it, verify it actually has zero non-test callers; if it is genuinely used by app chrome, drop that bullet from this sweep. + +Done 2026-06-12 across six commits. Notes: ColumnHat''s file carried the LIVE hatHeight constant (app hat bar + menu bar) — moved to widgets/src/chrome_metrics.dart before deleting the dead widget. TranscriptPublisher class removed; the ClaudeConversation addressing constants stay (still consumed). The TeamMemberJoined ghost-event rewiring is real work, split out as T-396. mocktail dropped with D-25 amended (hand-rolled fakes throughout). ptyc binary untracked+deleted (no licenses.yaml entry existed). Coverage rose 95.03% → 95.13% with the dead denominator gone; full push-check green.', NULL, '2026-06-12 00:23:24', '2026-06-12 00:23:24', '2026-06-12 00:23:24', NULL, '0791573d68f344d3edab4cd8b89d2d69', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDGPXQN31NNRPJ00PFRAG4', 'status', 'backlog', 'in_progress', NULL, '2026-06-12 00:23:56', '2026-06-12 00:23:56', '2026-06-12 00:23:56', NULL, 'e2cf08e3a17b8f8ad8288061d263744c', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 7d4dc8bf..a99e4723 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -4252,3 +4252,50 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat Fix: drive both roster surfaces from TeamBroker membership (expose a listenable roster or change stream on the broker), delete TeamMemberJoined/TeamMemberLeft from kernel events/types.dart, and remove the dead listeners. Coordinate with T-395 (meta sidebar split) — whichever lands second adapts. Acceptance: spawning a team session through the orchestrator makes the member appear in both surfaces (widget test); the ghost event types are gone from types.dart; no kernel.events team-member subscriptions remain.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 00:11:50', '2026-06-12 00:12:04', NULL, '57f7e568fe7c411fc4b45c6b140d8167', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCXFF5V1RT6QJETS2K4C0G', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Rat extermination: dead-code sweep (legacy git API, ToolCheck, libc, GraphView, ColumnHat, tmux-era team pipeline, ptyc binary, mocktail)', 'Verified-dead code worth one sweep (coverage denominator benefits too): + +- Legacy free-function git API (~250 LOC duplicating GitClient, kept alive only by its own tests, and carrying its own latent pipe-deadlock bug) — delete API + tests. +- ToolCheck — zero callers. +- ~60% of lib/src/pty/ffi/libc.dart — fd-passing-era bindings unused since D-56. +- GraphView — unreachable placeholder (note: the Governance Graph idea (see Q-records from this review) may later want the slot; deleting now is still right, it is a 17-line stub). +- ColumnHat — duplicated line-for-line in app.dart, kept alive by a zero-coverage test; the app.dart split ticket removes the duplicate, this sweep removes the orphan. +- tmux-era team pipeline: TranscriptPublisher, TeamMemberJoined — nothing emits these events, yet the team roster UI listens to them exclusively (team tiles are populated by ghosts). Remove pipeline + dead listeners; if the roster UI stays, it needs a real data source first (surface that before deleting the UI). +- Dead ptyc binary still committed in native/linux-x64/ against D-62/D-63 — remove binary + licenses.yaml entry if present. +- mocktail — pinned, documented in D-25 as the IO-mocking strategy, imported by zero files: either adopt it where mocks are hand-rolled or drop the dep AND amend D-25. + +Each bullet is one commit. Run make test + coverage after each; expect the floor to ratchet up. + +Correction (2026-06-12, verified during T-394 breakdown): ColumnHat is NOT duplicated line-for-line in app.dart — it exists only in lib/widgets/src/clide_column_hat.dart. Before deleting it, verify it actually has zero non-test callers; if it is genuinely used by app chrome, drop that bullet from this sweep.', 'done', 'low', NULL, NULL, NULL, '2026-06-11 22:00:44', '2026-06-12 00:23:13', NULL, '9563a30ce5944315595343d612572909', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHCXFF5V1RT6QJETS2K4C0G', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Rat extermination: dead-code sweep (legacy git API, ToolCheck, libc, GraphView, ColumnHat, tmux-era team pipeline, ptyc binary, mocktail)', 'Verified-dead code worth one sweep (coverage denominator benefits too): + +- Legacy free-function git API (~250 LOC duplicating GitClient, kept alive only by its own tests, and carrying its own latent pipe-deadlock bug) — delete API + tests. +- ToolCheck — zero callers. +- ~60% of lib/src/pty/ffi/libc.dart — fd-passing-era bindings unused since D-56. +- GraphView — unreachable placeholder (note: the Governance Graph idea (see Q-records from this review) may later want the slot; deleting now is still right, it is a 17-line stub). +- ColumnHat — duplicated line-for-line in app.dart, kept alive by a zero-coverage test; the app.dart split ticket removes the duplicate, this sweep removes the orphan. +- tmux-era team pipeline: TranscriptPublisher, TeamMemberJoined — nothing emits these events, yet the team roster UI listens to them exclusively (team tiles are populated by ghosts). Remove pipeline + dead listeners; if the roster UI stays, it needs a real data source first (surface that before deleting the UI). +- Dead ptyc binary still committed in native/linux-x64/ against D-62/D-63 — remove binary + licenses.yaml entry if present. +- mocktail — pinned, documented in D-25 as the IO-mocking strategy, imported by zero files: either adopt it where mocks are hand-rolled or drop the dep AND amend D-25. + +Each bullet is one commit. Run make test + coverage after each; expect the floor to ratchet up. + +Correction (2026-06-12, verified during T-394 breakdown): ColumnHat is NOT duplicated line-for-line in app.dart — it exists only in lib/widgets/src/clide_column_hat.dart. Before deleting it, verify it actually has zero non-test callers; if it is genuinely used by app chrome, drop that bullet from this sweep. + +Done 2026-06-12 across six commits. Notes: ColumnHat''s file carried the LIVE hatHeight constant (app hat bar + menu bar) — moved to widgets/src/chrome_metrics.dart before deleting the dead widget. TranscriptPublisher class removed; the ClaudeConversation addressing constants stay (still consumed). The TeamMemberJoined ghost-event rewiring is real work, split out as T-396. mocktail dropped with D-25 amended (hand-rolled fakes throughout). ptyc binary untracked+deleted (no licenses.yaml entry existed). Coverage rose 95.03% → 95.13% with the dead denominator gone; full push-check green.', 'done', 'low', NULL, NULL, NULL, '2026-06-11 22:00:44', '2026-06-12 00:23:24', NULL, '0fedb8cda245d2ac1ece80f565e53049', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHDGPXQN31NNRPJ00PFRAG4', 'task', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Split lib/app.dart (1187 LOC, five concerns)', 'lib/app.dart is 1187 LOC mixing five confirmed concerns. Split plan (verified against the file 2026-06-12): + +Inventory: ClideApp (14-29), _AppRoot (31-45), _RootShell/_RootShellState keyboard+intent routing (47-229), RootLayout (231-350), hat bar family _HatBar/_LeftHatContent/_RightHatContent/_WinBtn (352-439), project switcher family _ProjectSwitcherButton/_ProjectSwitcherDropdown/_RecentProjectRow/_ActionRow (441-672), SlotHost/_SlotHostState/_SlotBody/_SidebarSlot/_WorkspaceSlot/_RevealedTab/_ContextSlot (674-1030), _BottomRail (1032-1078), StatusbarCollapseToggle (1093-1126), StatusbarHost (1128-1170), _EditorDragHandle (924-1010), _WelcomeOverlay (1172-1187). + +Target layout: +- app.dart keeps ClideApp + _AppRoot and RE-EXPORTS the public symbols so tests keep importing package:clide/app.dart. +- lib/widgets/root_shell.dart: _RootShell/_RootShellState (keyboard routing; depends on ModifierTapTracker, MenuBarController). +- lib/builtin/hat/hat_bar.dart: _HatBar, _LeftHatContent, _RightHatContent, _WinBtn, hatHeight. +- lib/builtin/hat/project_switcher.dart: the switcher family (~230 LOC). +- lib/widgets/slot_host.dart: SlotHost + slot bodies (NOTE: _RevealedTab references _SlotBody._resolveTitle — keep them together or extract the helper). +- lib/widgets/layout_status.dart: RootLayout internals, StatusbarHost, StatusbarCollapseToggle, _BottomRail, _EditorDragHandle(+Intent), _WelcomeOverlay. + +Order: mechanical first (hat bar, switcher rows, welcome overlay), then root shell, then slot host (tangled: _SlotHostState registers focus scopes via ClideKernel.of in didChangeDependencies), then layout/status. + +Tests importing app.dart: test/app_test.dart (RootLayout, StatusbarHost, ClideApp), test/app_statusbar_test.dart (StatusbarHost), test/app_collapse_toggle_test.dart (StatusbarCollapseToggle) — re-exports keep them unchanged. + +CORRECTION to fable-ous.md: ColumnHat is NOT duplicated line-for-line in app.dart (verified); ColumnHat lives only in lib/widgets/src/clide_column_hat.dart. The rat-sweep ticket T-385 was annotated accordingly — verify whether ColumnHat is dead before deleting.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-11 22:03:21', '2026-06-12 00:23:56', NULL, '06f8ead95c7934a07b4b74813f74f001', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/lib/app.dart b/lib/app.dart index 0a467320..342b2ee8 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,16 +1,18 @@ -import 'dart:async'; -import 'dart:io' show Platform; +/// App entry widget. The shell itself lives under `lib/src/shell/` +/// (T-394 split): keyboard routing in root_shell.dart, the hat bar + +/// project switcher, slot hosting, and the layout grid + status bar. +/// The public layout symbols are re-exported here so existing imports +/// of `package:clide/app.dart` keep working. +library; -import 'package:clide/builtin/menubar/menubar.dart'; -import 'package:clide/builtin/welcome/src/welcome_view.dart'; import 'package:clide/clide.dart' show clideName; -import 'package:clide/extension/src/contribution.dart'; import 'package:clide/kernel/kernel.dart'; -import 'package:clide/widgets/widgets.dart'; -import 'package:flutter/foundation.dart' show kIsWeb; -import 'package:flutter/services.dart'; +import 'package:clide/src/shell/root_shell.dart'; import 'package:flutter/widgets.dart'; +export 'package:clide/src/shell/layout.dart' show RootLayout, StatusbarCollapseToggle, StatusbarHost; +export 'package:clide/src/shell/slot_host.dart' show SlotHost; + class ClideApp extends StatelessWidget { const ClideApp({super.key, required this.services}); @@ -39,1149 +41,7 @@ class _AppRoot extends StatelessWidget { title: clideName, color: const Color(0xFF000000), pageRouteBuilder: (RouteSettings settings, WidgetBuilder builder) => PageRouteBuilder(settings: settings, pageBuilder: (ctx, _, _) => builder(ctx)), - home: _RootShell(services: services), - ); - } -} - -class _RootShell extends StatefulWidget { - const _RootShell({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"). Bare modifiers never resolve as a single - // chord, so this is the only path that handles them (T-341). - final ModifierTapTracker _modTap = ModifierTapTracker(); - - @override - void initState() { - super.initState(); - _keyFocus = FocusNode()..requestFocus(); - widget.services.textZoom.addListener(_onZoom); - } - - @override - void dispose() { - 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: >{ - TextScaleIncreaseIntent: CallbackAction( - onInvoke: (_) { - widget.services.textZoom.increase(); - return null; - }, - ), - TextScaleDecreaseIntent: CallbackAction( - onInvoke: (_) { - widget.services.textZoom.decrease(); - return null; - }, - ), - TextScaleResetIntent: CallbackAction( - onInvoke: (_) { - widget.services.textZoom.reset(); - return null; - }, - ), - InvokeCommandIntent: CallbackAction( - onInvoke: (intent) { - widget.services.commands.execute(intent.commandId); - return null; - }, - ), - PaletteOpenIntent: CallbackAction( - onInvoke: (_) { - widget.services.palette.open(); - return null; - }, - ), - QuickOpenIntent: CallbackAction( - onInvoke: (_) { - widget.services.quickOpen.open(); - return null; - }, - ), - FindInFilesIntent: CallbackAction( - 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( - onInvoke: (_) { - widget.services.focus.focusNextSlot(); - return null; - }, - ), - FocusPreviousPanelIntent: CallbackAction( - 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; - // Double-tapped bare modifier (e.g. double-Shift → quick-open). Handle - // it here because a bare modifier never forms a single chord — an - // intervening non-modifier key breaks the gesture (T-341). - if (event is KeyDownEvent) { - final mod = KeyChord.modifierForLogicalKey(event.logicalKey); - if (mod != null) { - if (_modTap.tap(mod, DateTime.now()) != null) { - final seq = [KeyChord.bareModifier(mod), KeyChord.bareModifier(mod)]; - final tapIntent = widget.services.keymap.resolveSequence(seq); - if (tapIntent != null) _dispatchIntent(tapIntent); - } - return; // a bare modifier resolves nothing else - } - _modTap.reset(); - } - final intent = widget.services.keymap.resolveEvent(event, HardwareKeyboard.instance); - if (intent == null) return; - _dispatchIntent(intent); - } - - 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+` 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 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 _HatBar extends StatelessWidget { - const _HatBar({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), - ), - ); - } -} - -class _ProjectSwitcherButton extends StatelessWidget { - const _ProjectSwitcherButton({required this.kernel, required this.tokens}); - final KernelServices kernel; - final SurfaceTokens tokens; - - void _openSwitcher() { - kernel.dialog.show((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 _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), - ], - ), - ), - ); - } -} - -class SlotHost extends StatefulWidget { - const SlotHost({super.key, required this.slot}); - final SlotId slot; - - @override - State createState() => _SlotHostState(); -} - -class _SlotHostState extends State { - 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 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: _resolveTitle(context, t))], - activeId: active.id, - onSelect: (id) => kernel.panels.activateTab(slot, id), - ), - ClideDivider(), - Expanded(child: active.build(context)), - ], - ), - ); - } - - static String _resolveTitle(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); - } -} - -class _SidebarSlot extends StatelessWidget { - const _SidebarSlot({required this.tabs, required this.active, required this.activeId, required this.onSelect}); - - final List tabs; - final TabContribution active; - final String activeId; - final ValueChanged 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 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(_SlotBody._resolveTitle(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 { - 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: >{ - _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 tabs; - final TabContribution active; - final String activeId; - final ValueChanged 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)); - } -} - -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: _SlotBody._resolveTitle(ctx, t))], - 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().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), - ], - ), - ); - }, - ); - } -} - -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()); - }, + home: RootShell(services: services), ); } } diff --git a/lib/src/shell/hat_bar.dart b/lib/src/shell/hat_bar.dart new file mode 100644 index 00000000..761cbde3 --- /dev/null +++ b/lib/src/shell/hat_bar.dart @@ -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), + ), + ); + } +} diff --git a/lib/src/shell/layout.dart b/lib/src/shell/layout.dart new file mode 100644 index 00000000..331cc0c6 --- /dev/null +++ b/lib/src/shell/layout.dart @@ -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))], + 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().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), + ], + ), + ); + }, + ); + } +} diff --git a/lib/src/shell/project_switcher.dart b/lib/src/shell/project_switcher.dart new file mode 100644 index 00000000..a68ae364 --- /dev/null +++ b/lib/src/shell/project_switcher.dart @@ -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((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 _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), + ], + ), + ), + ); + } +} diff --git a/lib/src/shell/root_shell.dart b/lib/src/shell/root_shell.dart new file mode 100644 index 00000000..05dfac68 --- /dev/null +++ b/lib/src/shell/root_shell.dart @@ -0,0 +1,215 @@ +/// 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 '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 createState() => RootShellState(); +} + +class RootShellState extends State { + late final FocusNode _keyFocus; + final MenuBarController _menuBar = MenuBarController(); + // Detects double-tapped bare modifiers (e.g. double-Shift → quick-open, + // JetBrains "Search Everywhere"). Bare modifiers never resolve as a single + // chord, so this is the only path that handles them (T-341). + final ModifierTapTracker _modTap = ModifierTapTracker(); + + @override + void initState() { + super.initState(); + _keyFocus = FocusNode()..requestFocus(); + widget.services.textZoom.addListener(_onZoom); + } + + @override + void dispose() { + 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: >{ + TextScaleIncreaseIntent: CallbackAction( + onInvoke: (_) { + widget.services.textZoom.increase(); + return null; + }, + ), + TextScaleDecreaseIntent: CallbackAction( + onInvoke: (_) { + widget.services.textZoom.decrease(); + return null; + }, + ), + TextScaleResetIntent: CallbackAction( + onInvoke: (_) { + widget.services.textZoom.reset(); + return null; + }, + ), + InvokeCommandIntent: CallbackAction( + onInvoke: (intent) { + widget.services.commands.execute(intent.commandId); + return null; + }, + ), + PaletteOpenIntent: CallbackAction( + onInvoke: (_) { + widget.services.palette.open(); + return null; + }, + ), + QuickOpenIntent: CallbackAction( + onInvoke: (_) { + widget.services.quickOpen.open(); + return null; + }, + ), + FindInFilesIntent: CallbackAction( + 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( + onInvoke: (_) { + widget.services.focus.focusNextSlot(); + return null; + }, + ), + FocusPreviousPanelIntent: CallbackAction( + 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; + // Double-tapped bare modifier (e.g. double-Shift → quick-open). Handle + // it here because a bare modifier never forms a single chord — an + // intervening non-modifier key breaks the gesture (T-341). + if (event is KeyDownEvent) { + final mod = KeyChord.modifierForLogicalKey(event.logicalKey); + if (mod != null) { + if (_modTap.tap(mod, DateTime.now()) != null) { + final seq = [KeyChord.bareModifier(mod), KeyChord.bareModifier(mod)]; + final tapIntent = widget.services.keymap.resolveSequence(seq); + if (tapIntent != null) _dispatchIntent(tapIntent); + } + return; // a bare modifier resolves nothing else + } + _modTap.reset(); + } + final intent = widget.services.keymap.resolveEvent(event, HardwareKeyboard.instance); + if (intent == null) return; + _dispatchIntent(intent); + } + + 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+` 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()); + }, + ); + } +} diff --git a/lib/src/shell/slot_host.dart b/lib/src/shell/slot_host.dart new file mode 100644 index 00000000..80d90e38 --- /dev/null +++ b/lib/src/shell/slot_host.dart @@ -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 createState() => _SlotHostState(); +} + +class _SlotHostState extends State { + 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 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 tabs; + final TabContribution active; + final String activeId; + final ValueChanged 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 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 { + 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: >{ + _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 tabs; + final TabContribution active; + final String activeId; + final ValueChanged 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); +}