diff --git a/CHANGELOG.md b/CHANGELOG.md index 77bf4846..769c24b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Fixed +- Recent-project rows (welcome screen and the project switcher) now ellipsize a + long path instead of overflowing the row — a long repo path no longer spills + past the edge. (T-122) - CLI commands that take arguments now work: `clide editor open `, `clide files read `, `clide pane focus ` / `resize `, etc. now bind positional/flag argv to the handler's named args (they diff --git a/lib/app.dart b/lib/app.dart index 14f56217..add3de52 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -583,7 +583,12 @@ class _RecentProjectRow extends StatelessWidget { if (project.branch != null) Row( children: [ - ClideText(project.relativePath, muted: true, fontSize: 12, fontFamily: clideMonoFamily), + // 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.gitBranch, size: 10, color: tokens.globalTextMuted), const SizedBox(width: 3), @@ -591,7 +596,7 @@ class _RecentProjectRow extends StatelessWidget { ], ) else - ClideText(project.relativePath, muted: true, fontSize: 12, fontFamily: clideMonoFamily), + ClideText(project.relativePath, muted: true, fontSize: 12, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis), ], ), ), diff --git a/test/app_test.dart b/test/app_test.dart new file mode 100644 index 00000000..43e257da --- /dev/null +++ b/test/app_test.dart @@ -0,0 +1,255 @@ +/// Widget coverage for the app shell in lib/app.dart — the root layout, +/// hat bar, slot hosts (sidebar / workspace / context), bottom rails, the +/// project switcher dropdown + open-folder dialog, the editor split, the +/// welcome overlay, and the global intent/keymap wiring in _RootShell. +/// +/// Two lifecycle rules keep this suite robust (learned the hard way — a +/// dispose-order bug here wedged the runner for minutes): +/// 1. Services are disposed via addTearDown in setUp, so (LIFO) they die +/// AFTER the per-test teardown that unmounts the widget tree. Disposing +/// services while ClideApp is still mounted makes ClidePalette.dispose +/// touch an already-disposed KeymapService. +/// 2. Every pump helper's teardown unmounts the tree (pump a bare box) +/// BEFORE resetting tester.view — otherwise a still-mounted EditableText +/// reacts to the metrics change on a deactivated element. +/// +/// The shell renders side panels (sidebar 400 + context 420), which overflow +/// the default 800px surface, so every pump uses a wide ultrawide surface via +/// tester.view (T-239 / T-241). +library; + +import 'dart:io'; + +import 'package:clide/app.dart'; +import 'package:clide/builtin/default_layout/default_layout.dart'; +import 'package:clide/clide.dart' show clideName; +import 'package:clide/extension/extension.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'helpers/kernel_fixture.dart'; + +void main() { + late KernelFixture f; + + setUp(() async { + f = await KernelFixture.create(); + // The classic preset makes all four slots visible + sized, and registers + // keybindings/commands so the keymap resolves real bindings. + f.services.extensions.register(DefaultLayoutExtension()); + await f.services.extensions.activateAll(); + // Disposed LAST (LIFO) — after any per-test teardown unmounts the tree. + addTearDown(() async => f.dispose()); + }); + + void registerTabs() { + final p = f.services.panels; + p.contribute(TabContribution(id: 'files.tree', slot: Slots.sidebar, title: 'Files', build: (_) => const Text('SIDEBAR'))); + p.contribute(TabContribution(id: 'claude.primary', slot: Slots.workspace, title: 'Claude', build: (_) => const Text('CLAUDE'))); + p.contribute(TabContribution(id: 'editor.active', slot: Slots.workspace, title: 'Editor', build: (_) => const Text('EDITOR'))); + p.contribute(TabContribution(id: 'markdown.viewer', slot: Slots.contextPanel, title: 'Preview', build: (_) => const Text('CONTEXT'))); + } + + // Sizes the surface wide and registers the unmount-before-reset teardown. + void prepareView(WidgetTester tester) { + tester.view.physicalSize = const Size(1600, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(() async { + await tester.pumpWidget(const SizedBox()); + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + } + + // RootLayout in a tight tree — no WidgetsApp / palette / text fields, so the + // bulk of the shell (slots, rails, spines, workspace split, statusbar) is + // exercised without the heavyweight overlay machinery. + Future pumpLayout(WidgetTester tester) async { + prepareView(tester); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ClideKernel( + services: f.services, + child: ClideTheme( + controller: f.services.theme, + child: MediaQuery( + data: const MediaQueryData(size: Size(1600, 900)), + child: const Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 1600, height: 900, child: RootLayout()), + ), + ), + ), + ), + ), + ); + await tester.pump(); + } + + // The whole ClideApp (WidgetsApp + hat bar + overlays). + Future pumpApp(WidgetTester tester) async { + prepareView(tester); + await tester.pumpWidget(ClideApp(services: f.services)); + await tester.pump(); + } + + testWidgets('RootLayout renders every visible slot and its bottom rails', (tester) async { + registerTabs(); + await pumpLayout(tester); + + expect(tester.takeException(), isNull); + expect(find.text('SIDEBAR'), findsOneWidget); + expect(find.text('CLAUDE'), findsOneWidget); + expect(find.text('CONTEXT'), findsOneWidget); + expect(find.byType(StatusbarHost), findsOneWidget); + expect(find.byType(ClideIconRail), findsNWidgets(2)); + }); + + testWidgets('RootLayout collapses side panels into spines with the active-tab label', (tester) async { + registerTabs(); + f.services.panels.activateTab(Slots.sidebar, 'files.tree'); + f.services.arrangement.setCollapsed(Slots.sidebar, true); + f.services.arrangement.setCollapsed(Slots.contextPanel, true); + await pumpLayout(tester); + + expect(tester.takeException(), isNull); + expect(find.byType(ClideSpine), findsNWidgets(2)); + expect(find.text('files'), findsOneWidget); // sidebar spine label + expect(find.text('context'), findsOneWidget); // context spine label + }); + + testWidgets('RootLayout shows the editor split above the primary pane when the editor is open', (tester) async { + registerTabs(); + f.services.arrangement.openEditor(); + await pumpLayout(tester); + + expect(tester.takeException(), isNull); + expect(find.text('EDITOR'), findsOneWidget); + expect(find.text('CLAUDE'), findsOneWidget); + + // Nudge the editor split via the kernel to exercise the ratio path. + f.services.arrangement.setEditorRatio(0.5); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + + testWidgets('global intents dispatch through the app-root Actions', (tester) async { + await pumpApp(tester); + final ctx = tester.element(find.byType(RootLayout)); + + final before = f.services.textZoom.scale; + Actions.invoke(ctx, const TextScaleIncreaseIntent()); + expect(f.services.textZoom.scale, greaterThan(before)); + Actions.invoke(ctx, const TextScaleDecreaseIntent()); + Actions.invoke(ctx, const TextScaleResetIntent()); + expect(f.services.textZoom.scale, before); + + Actions.invoke(ctx, const PaletteOpenIntent()); + expect(f.services.palette.isOpen, isTrue); + f.services.palette.close(); + Actions.invoke(ctx, const QuickOpenIntent()); + expect(f.services.quickOpen.isOpen, isTrue); + f.services.quickOpen.close(); + + Actions.invoke(ctx, const FindInFilesIntent()); + Actions.invoke(ctx, const FocusNextPanelIntent()); + Actions.invoke(ctx, const FocusPreviousPanelIntent()); + Actions.invoke(ctx, const InvokeCommandIntent('noop.command.does.not.exist')); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + + testWidgets('key events dispatch via _onKey for bound keys and no-op for unbound', (tester) async { + await pumpApp(tester); + // F6 is bound to focus.nextPanel in the shipped preset → resolves + dispatches. + await tester.sendKeyEvent(LogicalKeyboardKey.f6); + await tester.pump(); + // An unbound key resolves to null → the early-return branch. + await tester.sendKeyEvent(LogicalKeyboardKey.f9); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + + testWidgets('window control buttons render and tap as no-ops in tests', (tester) async { + await pumpApp(tester); + // _RightHatContent renders ClideTappable window buttons on non-macOS; + // tapping exercises the WindowControls method-channel no-op path. + expect(find.byType(ClideTappable), findsWidgets); + expect(tester.takeException(), isNull); + }); + + // --- Project switcher dropdown + dialogs (lib/app.dart) ------------------ + // Opening a project and loading recents do real git/file I/O, so they run + // inside tester.runAsync (real event loop) — awaiting them in the fake-async + // test body would strand the test (the T-122 lesson). The repo root is a + // real git repo, so project.open succeeds and lands a recent. + + testWidgets('project switcher opens, lists the recent, filters, and Esc-closes', (tester) async { + final repo = Directory.current.path; + final name = repo.split('/').last; + await tester.runAsync(() async { + await f.services.project.open(repo); + }); + registerTabs(); + await pumpApp(tester); + + // Hat-bar switcher label is "clide > " once a project is open. + expect(find.text('$clideName > $name'), findsOneWidget); + await tester.tap(find.text('$clideName > $name')); + await tester.pump(); + + // _ProjectSwitcherDropdown: header, recent row, action rows. + expect(find.text('Recent Projects'), findsOneWidget); + expect(find.text('Open Local Project'), findsOneWidget); + expect(find.text('New Window'), findsOneWidget); + + // Type a non-matching filter — exercises the .where filter branch and the + // empty-results render path (line coverage; the dropdown rebuilds). + await tester.enterText(find.byType(EditableText), 'zzz-no-such-project'); + await tester.pump(); + await tester.pump(); + + // Esc dismisses the dropdown (unmounts its EditableText). + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(find.text('Recent Projects'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('switcher → Open Local Project falls back to the path dialog (no native picker)', (tester) async { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('clide/window'), + (call) async { + if (call.method == 'pickDirectory') throw MissingPluginException(); + return null; + }, + ); + addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(const MethodChannel('clide/window'), null)); + + final repo = Directory.current.path; + final name = repo.split('/').last; + await tester.runAsync(() async { + await f.services.project.open(repo); + }); + await pumpApp(tester); + + await tester.tap(find.text('$clideName > $name')); + await tester.pump(); + await tester.tap(find.text('Open Local Project')); + await tester.pump(); + await tester.pump(); + expect(find.text('Open project'), findsOneWidget); // _OpenFolderDialog + + // Empty submit = no-op early return; then Cancel unmounts the dialog. + await tester.tap(find.text('Open')); + await tester.pump(); + await tester.tap(find.text('Cancel')); + await tester.pump(); + expect(find.text('Open project'), findsNothing); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/builtin/welcome/widget_test.dart b/test/builtin/welcome/widget_test.dart index 4da81051..b80c208e 100644 --- a/test/builtin/welcome/widget_test.dart +++ b/test/builtin/welcome/widget_test.dart @@ -129,34 +129,101 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets( - 'sticky-startup toggle renders + flips when tapped (T-115)', - (tester) async { - // Seed a recent directly so we don't need a real git repo. - await f.services.settings.set( - 'app.recentProjects', - '[{"path":"/tmp/clide-fixture","name":"clide-fixture","lastOpened":"2026-05-18T00:00:00.000Z"}]', + // T-122 ROOT CAUSE + FIX (2026-06-05): the strand was NOT in the recents + // widgets — it was the seeding. SettingsStore.set does real file I/O + // (writeAsString); awaiting settings.set + project.loadRecents INSIDE a + // testWidgets body runs that I/O in fake-async, where the completion + // callback is trapped and the await never returns (a +0 strand only + // SIGKILL clears). Seeding inside tester.runAsync() runs it on the real + // event loop, so the recents render fine. The bounded-vs-unbounded width + // was a red herring — but a tight tree is still used here (the shared + // harness()'s unbounded width breaks WelcomeView's Positioned status line + // + Flexible rows independently). + Widget tightWelcome() => Directionality( + textDirection: TextDirection.ltr, + child: ClideKernel( + services: f.services, + child: ClideTheme( + controller: f.services.theme, + child: const MediaQuery( + data: MediaQueryData(size: Size(1200, 900)), + child: Align( + alignment: Alignment.topLeft, + child: SizedBox(width: 1200, height: 900, child: WelcomeView()), + ), + ), + ), + ), ); + + Future seedRecents(WidgetTester tester, String json) async { + // runAsync: real event loop, so SettingsStore's file I/O completes. + await tester.runAsync(() async { + await f.services.settings.set('app.recentProjects', json); await f.services.project.loadRecents(); - tester.view.physicalSize = const Size(1200, 900); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - await tester.pumpWidget(harness(f, const WelcomeView())); - await tester.pump(); - expect(find.text('clide-fixture'), findsOneWidget); - final toggle = find.byKey(const ValueKey('welcome.sticky./tmp/clide-fixture')); - expect(toggle, findsOneWidget); - await tester.tap(toggle, warnIfMissed: false); - await tester.pump(); - expect(f.services.project.recents.first.startupSticky, isTrue); - }, - // T-122: pumpWidget(WelcomeView) with a non-empty recents list - // strands the test until the 10-min Flutter timeout, even after - // ruling out ClideTooltip and find/tap shape. Cause not yet - // localized — skip until reproduced in isolation. - skip: true, - ); + }); + } + + testWidgets('recent rows render with branch + sticky variants (T-122)', (tester) async { + await seedRecents( + tester, + '[{"path":"/tmp/alpha","name":"alpha","branch":"main","lastOpened":"2026-05-18T00:00:00.000Z","startupSticky":true},' + '{"path":"/tmp/beta","name":"beta","lastOpened":"2026-05-18T00:00:00.000Z"}]', + ); + tester.view.physicalSize = const Size(1200, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(tightWelcome()); + await tester.pump(); + + expect(find.text('alpha'), findsOneWidget); + expect(find.text('beta'), findsOneWidget); + expect(find.text('main'), findsOneWidget); // branch chip on the row that has one + expect(find.byKey(const ValueKey('welcome.sticky./tmp/alpha')), findsOneWidget); + expect(find.byKey(const ValueKey('welcome.sticky./tmp/beta')), findsOneWidget); + }); + + testWidgets('sticky-startup toggle flips when tapped (T-115/T-122)', (tester) async { + await seedRecents( + tester, + '[{"path":"/tmp/clide-fixture","name":"clide-fixture","lastOpened":"2026-05-18T00:00:00.000Z"}]', + ); + tester.view.physicalSize = const Size(1200, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(tightWelcome()); + await tester.pump(); + expect(find.text('clide-fixture'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('welcome.sticky./tmp/clide-fixture')), warnIfMissed: false); + await tester.pump(); + expect(f.services.project.recents.first.startupSticky, isTrue); + }); + + testWidgets('tapping a recent row kicks off _openRecent without throwing (T-122)', (tester) async { + await seedRecents( + tester, + '[{"path":"/tmp/clide-fixture","name":"clide-fixture","lastOpened":"2026-05-18T00:00:00.000Z"}]', + ); + tester.view.physicalSize = const Size(1200, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(tightWelcome()); + await tester.pump(); + // /tmp/clide-fixture is not a git repo → project.open returns false, + // no tab activation, no throw. Just exercises the _openRecent path. + await tester.runAsync(() async { + await tester.tap(find.text('clide-fixture'), warnIfMissed: false); + }); + await tester.pump(); + expect(tester.takeException(), isNull); + }); testWidgets('Open folder opens the fallback dialog when the picker throws MissingPluginException', (tester) async { // Pre-register a mock that throws — emulating a platform without diff --git a/test/kernel/src/events/types_test.dart b/test/kernel/src/events/types_test.dart index ee6e5c9d..fb3837dc 100644 --- a/test/kernel/src/events/types_test.dart +++ b/test/kernel/src/events/types_test.dart @@ -61,6 +61,57 @@ void main() { }); }); + group('Team events', () { + test('TeamMemberJoined includes only the set optional fields in payload', () { + const minimal = TeamMemberJoined( + team: 'alpha', + agentId: 'bob@alpha', + name: 'bob', + agentType: 'reviewer', + paneId: '%3', + ); + expect(minimal.subsystem, 'team'); + expect(minimal.kind, 'member-joined'); + expect(minimal.payload(), { + 'team': 'alpha', + 'agentId': 'bob@alpha', + 'name': 'bob', + 'agentType': 'reviewer', + 'paneId': '%3', + }); + + const full = TeamMemberJoined( + team: 'alpha', + agentId: 'bob@alpha', + name: 'bob', + agentType: 'reviewer', + paneId: '%3', + model: 'opus', + color: '#ff0000', + cwd: '/tmp/work', + transcriptPath: '/tmp/t.jsonl', + ); + expect(full.payload(), { + 'team': 'alpha', + 'agentId': 'bob@alpha', + 'name': 'bob', + 'agentType': 'reviewer', + 'paneId': '%3', + 'model': 'opus', + 'color': '#ff0000', + 'cwd': '/tmp/work', + 'transcriptPath': '/tmp/t.jsonl', + }); + }); + + test('TeamMemberLeft', () { + const e = TeamMemberLeft(team: 'alpha', agentId: 'bob@alpha', paneId: '%3'); + expect(e.subsystem, 'team'); + expect(e.kind, 'member-left'); + expect(e.payload(), {'team': 'alpha', 'agentId': 'bob@alpha', 'paneId': '%3'}); + }); + }); + group('ClideEventEnvelope', () { test('toJson builds a v1 envelope around the event', () { final ts = DateTime.utc(2026, 5, 11, 9, 30);