From 25430686cceeebec9ce90a94e424f85874dda176 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 28 Jun 2026 08:35:30 +0200 Subject: [PATCH] feat(welcome): New project flow + per-repo account roadblock (T-488) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI half of the new-project flow (story T-486). A "New project…" welcome action opens a dialog (location + name) that dispatches project.new (T-487), opens the result, and announces it on projectCreatedChannel. The Claude extension consumes that and shows the account roadblock — the embedded per-workspace picker + accounts list, so a fresh project gets bound to an account (or Default) right at birth. The two halves stay decoupled: the welcome builtin only publishes the event (no claude import); the claude builtin owns the account dialog. Only freshly- created projects announce, so existing opens are never prompted. Closes T-488. The non-repo "initialize as a project" path (T-489) is next. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 1 + .pql/changelog/tickets/2026-06.sql | 1 + CHANGELOG.md | 4 + assets/i18n/en_us/builtin.welcome.json | 5 + assets/i18n/nl_nl/builtin.welcome.json | 5 + .../claude/src/account_roadblock_dialog.dart | 76 +++++++ lib/builtin/claude/src/extension.dart | 15 ++ lib/builtin/welcome/src/welcome_view.dart | 186 ++++++++++++++++++ lib/src/daemon/project_commands.dart | 7 + .../claude/account_roadblock_dialog_test.dart | 57 ++++++ .../welcome/new_project_dialog_test.dart | 82 ++++++++ 11 files changed, 439 insertions(+) create mode 100644 lib/builtin/claude/src/account_roadblock_dialog.dart create mode 100644 test/builtin/claude/account_roadblock_dialog_test.dart create mode 100644 test/builtin/welcome/new_project_dialog_test.dart diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 85db8710..cb79c106 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -7261,3 +7261,4 @@ Considerations: 3. A --resume of an existing session does NOT get re-injected. 4. The text is short and does not crowd out clideContextNote.', NULL, '2026-06-28 06:15:53', '2026-06-28 06:15:53.132', '2026-06-28 06:15:53.132', NULL, 'd5caf6198c0cad2fbd79b01261659d82', 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 ('06FGSQ789MDB00EQAXTXBVJDXM', 'status', 'in_progress', 'done', NULL, '2026-06-28 06:23:06', '2026-06-28 06:23:06.440', '2026-06-28 06:23:06.440', NULL, 'f6b2c96ac6770981215615c3c467d907', 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 ('06FGSQ8B7WDKZ174GJDGWW0GS0', 'status', 'backlog', 'done', NULL, '2026-06-28 06:35:16', '2026-06-28 06:35:16.152', '2026-06-28 06:35:16.152', NULL, '15de4a7ee941ffa6bb5dfa762e6d7ffc', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index c60fe518..54e7d16d 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -9354,3 +9354,4 @@ Considerations: 3. A --resume of an existing session does NOT get re-injected. 4. The text is short and does not crowd out clideContextNote.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-28 06:15:53.077', '2026-06-28 06:15:53.131', NULL, '8fca222cfd1b972d7a66ee3ddaaaf879', 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 ('06FGSQ789MDB00EQAXTXBVJDXM', 'task', '06FGPG4WNNH3BWDRVZXYQTW7Z0', 'project.new backend: create dir + git init + minimal scaffold, and the clide project new CLI verb', NULL, 'done', 'medium', NULL, NULL, NULL, '2026-06-28 06:13:51.821', '2026-06-28 06:23:06.439', NULL, 'b94604117d0511c2f7b3e24746d87c46', 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 ('06FGSQ8B7WDKZ174GJDGWW0GS0', 'task', '06FGPG4WNNH3BWDRVZXYQTW7Z0', 'New-project dialog: location + name -> account roadblock -> open; welcome New project action', NULL, 'done', 'medium', NULL, NULL, NULL, '2026-06-28 06:14:00.767', '2026-06-28 06:35:16.152', NULL, 'b54b32a51b09504e2b091c10ae48e9a0', 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/CHANGELOG.md b/CHANGELOG.md index 9288a920..29a1ade2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- **New project flow.** A "New project…" action in the welcome view creates + + opens a project, then prompts for the Claude account to bind it to — the + per-repo account roadblock fires only for freshly-created projects. (T-488, + story T-486) - **`clide project new [--dir ]`.** Create a new clide project — a fresh dir, `git init`, and a minimal scaffold. `--dir` defaults to the current workspace's parent. (T-487, story T-486) diff --git a/assets/i18n/en_us/builtin.welcome.json b/assets/i18n/en_us/builtin.welcome.json index 67e6430e..d7dd48c9 100644 --- a/assets/i18n/en_us/builtin.welcome.json +++ b/assets/i18n/en_us/builtin.welcome.json @@ -14,6 +14,11 @@ "tips.findInFiles": { "translation": "Find in files" }, "tips.focusMode": { "translation": "Focus mode" }, "action.openFolder": { "translation": "Open folder…" }, + "action.newProject": { "translation": "New project…" }, + "dialog.newProject.title": { "translation": "New project" }, + "dialog.newProject.body": { "translation": "Creates a git repo + a CLAUDE.md, then opens it." }, + "button.create": { "translation": "Create" }, + "button.creating": { "translation": "Creating…" }, "recent.empty": { "translation": "No recent projects." }, "sticky.label": { "translation": "always open this project on launch" }, "sticky.tooltip": { "translation": "Always open this project on launch" }, diff --git a/assets/i18n/nl_nl/builtin.welcome.json b/assets/i18n/nl_nl/builtin.welcome.json index c7e52b3f..b8df023f 100644 --- a/assets/i18n/nl_nl/builtin.welcome.json +++ b/assets/i18n/nl_nl/builtin.welcome.json @@ -14,6 +14,11 @@ "tips.findInFiles": { "translation": "Zoeken in bestanden" }, "tips.focusMode": { "translation": "Focusmodus" }, "action.openFolder": { "translation": "Map openen…" }, + "action.newProject": { "translation": "Nieuw project…" }, + "dialog.newProject.title": { "translation": "Nieuw project" }, + "dialog.newProject.body": { "translation": "Maakt een git-repo + een CLAUDE.md, en opent het." }, + "button.create": { "translation": "Aanmaken" }, + "button.creating": { "translation": "Aanmaken…" }, "recent.empty": { "translation": "Geen recente projecten." }, "sticky.label": { "translation": "dit project altijd openen bij opstarten" }, "sticky.tooltip": { "translation": "Dit project altijd openen bij opstarten" }, diff --git a/lib/builtin/claude/src/account_roadblock_dialog.dart b/lib/builtin/claude/src/account_roadblock_dialog.dart new file mode 100644 index 00000000..4ea7d992 --- /dev/null +++ b/lib/builtin/claude/src/account_roadblock_dialog.dart @@ -0,0 +1,76 @@ +/// The per-repo account roadblock shown right after a new project is created +/// (T-488, story T-486). Only a freshly-created project reaches here (the +/// welcome dialog announces it on projectCreatedChannel) — existing opens never +/// prompt. The new project is already the open workspace, so the embedded +/// per-workspace picker binds it directly; the accounts list lets a first-run +/// user add + sign in to an account before picking. No-Material (D-7). +library; + +import 'package:clide/builtin/claude/src/account_settings_control.dart'; +import 'package:clide/widgets/widgets.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +class ClaudeAccountRoadblockDialog extends StatelessWidget { + const ClaudeAccountRoadblockDialog({super.key, required this.projectName, required this.onClose}); + + final String projectName; + final VoidCallback onClose; + + KeyEventResult _onKey(FocusNode node, KeyEvent e) { + if (e is KeyDownEvent && e.logicalKey == LogicalKeyboardKey.escape) { + onClose(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final theme = ClideSettings.theme.of(context).surface; + return Focus( + autofocus: true, + onKeyEvent: _onKey, + child: Container( + width: 560, + constraints: const BoxConstraints(maxHeight: 540), + decoration: BoxDecoration( + color: theme.modalSurfaceBackground, + border: Border.all(color: theme.modalSurfaceBorder), + borderRadius: BorderRadius.circular(8), + ), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ClideText('Claude account for $projectName', fontSize: clideFontDialogTitle, fontWeight: FontWeight.w600, color: theme.globalForeground), + const SizedBox(height: 4), + ClideText( + 'Pick which Claude account this new project runs under, or keep the default system login. You can change it later in Settings or the pane badge.', + muted: true, + fontSize: clideFontMeta, + ), + const SizedBox(height: 18), + ClideText('Account for this project', fontSize: clideFontMeta, muted: true), + const SizedBox(height: 6), + const Align(alignment: Alignment.centerLeft, child: ClaudeWorkspaceAccountControl()), + const SizedBox(height: 18), + ClideText('Accounts', fontSize: clideFontMeta, muted: true), + const SizedBox(height: 6), + const ClaudeAccountsListControl(), + const SizedBox(height: 18), + Align( + alignment: Alignment.centerRight, + child: ClideButton(label: 'Continue', onPressed: onClose), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index 7acf5541..9578517e 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:clide/clide.dart'; import 'package:clide/builtin/claude/src/account_registry.dart'; import 'package:clide/builtin/claude/src/account_login_dialog.dart'; +import 'package:clide/builtin/claude/src/account_roadblock_dialog.dart'; import 'package:clide/builtin/claude/src/account_settings_control.dart'; import 'package:clide/builtin/claude/src/activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey, nextFoldLevel; import 'package:clide/builtin/claude/src/claude_config.dart'; @@ -20,6 +21,7 @@ import 'package:clide/builtin/claude/src/session_storage.dart'; import 'package:clide/builtin/claude/src/ticket_pick_up.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart' show ImageMessage; import 'package:clide/src/daemon/claude_account_commands.dart' show accountActionChannel; +import 'package:clide/src/daemon/project_commands.dart' show projectCreatedChannel; import 'package:clide/src/daemon/image_commands.dart' show imageShowChannel; import 'package:clide/builtin/claude/src/team_chat_sidebar.dart' show TeamChatPane; import 'package:clide/builtin/claude/src/team_panel_host.dart'; @@ -530,6 +532,19 @@ class ClaudeExtension extends ClideExtension { // writes the registry then publishes here; only the UI layer can respawn // the workspace's panes onto the newly-bound account or delete a config dir. _subs.add(ctx.messages.subscribe(channel: accountActionChannel).listen(_onAccountAction)); + + // A freshly-created project (T-488) announces itself once it's open; show the + // per-repo account roadblock so the user binds it now (existing opens, which + // never announce, are never prompted). + _subs.add(ctx.messages.subscribe(channel: projectCreatedChannel).listen(_onProjectCreated)); + } + + void _onProjectCreated(Message m) { + final dir = m.data['dir'] as String?; + final ctx = _ctx; + if (dir == null || ctx == null) return; + final name = dir.split('/').where((s) => s.isNotEmpty).lastOrNull ?? dir; + ctx.dialog.show((c, dismiss) => ClaudeAccountRoadblockDialog(projectName: name, onClose: dismiss)); } /// Side-effects for the `claude account` verbs (T-480). The dispatcher does diff --git a/lib/builtin/welcome/src/welcome_view.dart b/lib/builtin/welcome/src/welcome_view.dart index ae84bacf..49b8b6fc 100644 --- a/lib/builtin/welcome/src/welcome_view.dart +++ b/lib/builtin/welcome/src/welcome_view.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:clide/clide.dart' show clideName, clideTagline, clideVersion; import 'package:clide/kernel/kernel.dart'; +import 'package:clide/src/daemon/project_commands.dart' show projectCreatedChannel; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/services.dart' show MissingPluginException; import 'package:flutter/widgets.dart'; @@ -198,10 +199,20 @@ class _StartColumn extends StatelessWidget { tokens: tokens, onTap: () => _openFolder(context), ), + _ActionRow( + icon: PhosphorIcons.byName('folder-plus'), + label: ClideSettings.i18n.string(context, 'action.newProject', namespace: 'builtin.welcome', placeholder: 'New project…'), + tokens: tokens, + onTap: () => _newProject(context), + ), ], ); } + void _newProject(BuildContext context) { + kernel.dialog.show((ctx, dismiss) => _NewProjectDialog(kernel: kernel, onClose: () => dismiss())); + } + void _openFolder(BuildContext context) async { try { final picked = await kernel.window.pickDirectory(); @@ -683,3 +694,178 @@ class _NotARepoDialog extends StatelessWidget { ); } } + +/// New-project dialog (T-488, story T-486): pick a location + name, create the +/// project via `project.new`, open it, and announce it on [projectCreatedChannel] +/// so the Claude extension can run the per-repo account roadblock. Stays +/// claude-free — the account step is the consumer's job, not this dialog's. +class _NewProjectDialog extends StatefulWidget { + const _NewProjectDialog({required this.kernel, required this.onClose}); + final KernelServices kernel; + final VoidCallback onClose; + + @override + State<_NewProjectDialog> createState() => _NewProjectDialogState(); +} + +class _NewProjectDialogState extends State<_NewProjectDialog> { + final TextEditingController _parent = TextEditingController(); + final TextEditingController _name = TextEditingController(); + final FocusNode _parentFocus = FocusNode(debugLabel: 'new-project-parent'); + final FocusNode _nameFocus = FocusNode(debugLabel: 'new-project-name'); + String? _error; + bool _loading = false; + + @override + void dispose() { + _parent.dispose(); + _name.dispose(); + _parentFocus.dispose(); + _nameFocus.dispose(); + super.dispose(); + } + + Future _browse() async { + try { + final picked = await widget.kernel.window.pickDirectory(); + if (picked != null && mounted) setState(() => _parent.text = picked); + } on MissingPluginException { + // No native picker on this platform — the user types the path instead. + } + } + + Future _create() async { + final name = _name.text.trim(); + final parent = _parent.text.trim(); + if (name.isEmpty) return setState(() => _error = 'Enter a project name.'); + if (parent.isEmpty) return setState(() => _error = 'Choose a location.'); + setState(() { + _loading = true; + _error = null; + }); + final r = await widget.kernel.ipc.request( + 'project.new', + args: { + 'positional': [name], + 'flags': {'dir': parent}, + }, + ); + if (!mounted) return; + if (!r.ok) { + return setState(() { + _loading = false; + _error = r.error?.message ?? 'Could not create the project.'; + }); + } + final path = r.data['path'] as String; + // Open the new workspace, then announce it — only a freshly-created project + // announces, so only it triggers the account roadblock (T-488). + final opened = await widget.kernel.project.open(path); + if (opened) widget.kernel.panels.activateTab(Slots.workspace, 'claude.primary'); + widget.kernel.messages.publish('welcome', projectCreatedChannel, {'dir': path}); + widget.onClose(); + } + + @override + Widget build(BuildContext context) { + final tokens = ClideSettings.theme.of(context).surface; + return Container( + width: 460, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: tokens.modalSurfaceBackground, + border: Border.all(color: tokens.modalSurfaceBorder), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClideText( + ClideSettings.i18n.string(context, 'dialog.newProject.title', namespace: 'builtin.welcome', placeholder: 'New project'), + fontSize: clideFontDialogTitle, + fontWeight: FontWeight.w600, + ), + const SizedBox(height: 4), + ClideText( + ClideSettings.i18n.string( + context, + 'dialog.newProject.body', + namespace: 'builtin.welcome', + placeholder: 'Creates a git repo + a CLAUDE.md, then opens it.', + ), + muted: true, + fontSize: clideFontMeta, + ), + const SizedBox(height: 16), + _field( + tokens, + 'Location', + _parent, + _parentFocus, + trailing: ClideButton(label: 'Browse…', onPressed: _browse), + ), + const SizedBox(height: 10), + _field(tokens, 'Name', _name, _nameFocus, onSubmit: _create), + if (_error != null) ...[const SizedBox(height: 8), ClideText(_error!, color: tokens.statusError, fontSize: clideFontSmall)], + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ClideButton( + label: ClideSettings.i18n.string(context, 'button.cancel', namespace: 'builtin.welcome', placeholder: 'Cancel'), + onPressed: widget.onClose, + ), + const SizedBox(width: 8), + ClideButton( + label: _loading + ? ClideSettings.i18n.string(context, 'button.creating', namespace: 'builtin.welcome', placeholder: 'Creating…') + : ClideSettings.i18n.string(context, 'button.create', namespace: 'builtin.welcome', placeholder: 'Create'), + onPressed: _loading ? null : _create, + ), + ], + ), + ], + ), + ); + } + + Widget _field(SurfaceTokens tokens, String label, TextEditingController c, FocusNode f, {Widget? trailing, Future Function()? onSubmit}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClideText(label, fontSize: clideFontMeta, muted: true), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: tokens.panelBackground, + border: Border.all(color: f.hasFocus ? tokens.panelActiveBorder : tokens.globalBorder), + borderRadius: BorderRadius.circular(4), + ), + child: EditableText( + controller: c, + focusNode: f, + style: TextStyle( + color: tokens.globalForeground, + fontSize: clideFontCaption, + fontFamily: ClideSettings.fonts.monoOf(context), + fontFamilyFallback: clideMonoFamilyFallback, + ), + cursorColor: tokens.globalForeground, + backgroundCursorColor: tokens.globalTextMuted, + maxLines: 1, + onSubmitted: onSubmit == null ? null : (_) => unawaited(onSubmit()), + ), + ), + ), + if (trailing != null) ...[const SizedBox(width: 8), trailing], + ], + ), + ], + ); + } +} diff --git a/lib/src/daemon/project_commands.dart b/lib/src/daemon/project_commands.dart index b3e738ac..42d2966b 100644 --- a/lib/src/daemon/project_commands.dart +++ b/lib/src/daemon/project_commands.dart @@ -15,6 +15,13 @@ import '../ipc/envelope.dart'; import '../ipc/schema_v1.dart'; import 'dispatcher.dart'; +/// MessageBus channel announcing a freshly-created project (path in `dir`). The +/// welcome new-project dialog publishes it after create + open; the Claude +/// extension consumes it to show the per-repo account roadblock (T-488) — only +/// NEW projects prompt, existing opens never do. Lives here in the neutral +/// daemon layer so both builtins name one literal without coupling to each other. +const projectCreatedChannel = 'project.created'; + /// Runs `git init` in [dir]. Injected so this stays Flutter-free + testable; /// main.dart wires it to the real toolchain. typedef ProjectGitInit = Future Function(String dir); diff --git a/test/builtin/claude/account_roadblock_dialog_test.dart b/test/builtin/claude/account_roadblock_dialog_test.dart new file mode 100644 index 00000000..52f4d90a --- /dev/null +++ b/test/builtin/claude/account_roadblock_dialog_test.dart @@ -0,0 +1,57 @@ +/// T-488: the new-project account roadblock dialog renders the project title, +/// hosts the per-workspace picker + the accounts list (both already tested on +/// their own), and dismisses on Continue / Escape. +library; + +import 'package:clide/builtin/claude/src/account_registry.dart'; +import 'package:clide/builtin/claude/src/account_roadblock_dialog.dart'; +import 'package:clide/builtin/claude/src/account_settings_control.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + Future pump(WidgetTester tester, {required VoidCallback onClose}) async { + await tester.runAsync(() async { + await f.services.settings.setProjectDir(f.tempDir); + await AccountRegistry(f.services.settings).registerAccount('work', '/home/u/.claude-work'); + }); + await tester.pumpWidget( + harness( + f, + Align( + alignment: Alignment.center, + child: ClaudeAccountRoadblockDialog(projectName: 'myapp', onClose: onClose), + ), + ), + ); + await tester.pump(); + } + + testWidgets('renders the title + both account controls; Continue dismisses', (tester) async { + var closed = false; + await pump(tester, onClose: () => closed = true); + expect(find.text('Claude account for myapp'), findsOneWidget); + expect(find.byType(ClaudeWorkspaceAccountControl), findsOneWidget); + expect(find.byType(ClaudeAccountsListControl), findsOneWidget); + + await tester.tap(find.text('Continue')); + await tester.pump(); + expect(closed, isTrue); + }); + + testWidgets('Escape dismisses', (tester) async { + var closed = false; + await pump(tester, onClose: () => closed = true); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(closed, isTrue); + }); +} diff --git a/test/builtin/welcome/new_project_dialog_test.dart b/test/builtin/welcome/new_project_dialog_test.dart new file mode 100644 index 00000000..114c9f17 --- /dev/null +++ b/test/builtin/welcome/new_project_dialog_test.dart @@ -0,0 +1,82 @@ +/// T-488: the welcome New-project dialog. Tapping "New project…" opens it; +/// filling location + name and pressing Create dispatches `project.new`, opens +/// the result, and announces it on projectCreatedChannel (so the Claude +/// extension can run the account roadblock). Reached through WelcomeView since +/// the dialog is private, wrapped in a DialogHost so kernel.dialog.show renders. +library; + +import 'package:clide/builtin/welcome/src/welcome_view.dart'; +import 'package:clide/clide.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/src/daemon/project_commands.dart' show projectCreatedChannel; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/kernel_fixture.dart'; +import '../../helpers/widget_harness.dart'; + +Widget _harness(KernelFixture f, Widget child) => harness(f, DialogHost(router: f.services.dialog, child: child)); + +void main() { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() async => f.dispose()); + + testWidgets('Create dispatches project.new, opens the result, and announces it', (tester) async { + Map? newArgs; + f.ipc.setConnected(true); + f.ipc.stub('project.new', (args) async { + newArgs = args; + return IpcResponse.ok(id: 'r', data: {'path': '/tmp/np/myapp', 'name': 'myapp'}); + }); + final announced = >[]; + final sub = f.services.messages.subscribe(channel: projectCreatedChannel).listen((m) => announced.add(m.data)); + addTearDown(sub.cancel); + + 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.pumpAndSettle(); + await tester.tap(find.text('New project…')); + await tester.pumpAndSettle(); + expect(find.text('New project'), findsOneWidget); + + final fields = find.byType(EditableText); + await tester.enterText(fields.at(0), '/tmp/np'); // location + await tester.enterText(fields.at(1), 'myapp'); // name + await tester.tap(find.text('Create')); + await tester.pumpAndSettle(); + + expect(newArgs?['positional'], ['myapp']); + expect((newArgs?['flags'] as Map)['dir'], '/tmp/np'); + expect(announced.single['dir'], '/tmp/np/myapp'); + }); + + testWidgets('Create with an empty name surfaces an error and does not dispatch', (tester) async { + var called = false; + f.ipc.setConnected(true); + f.ipc.stub('project.new', (args) async { + called = true; + return IpcResponse.ok(id: 'r', data: {'path': '/x', 'name': 'x'}); + }); + + 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.pumpAndSettle(); + await tester.tap(find.text('New project…')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(EditableText).at(0), '/tmp/np'); // location only + await tester.tap(find.text('Create')); + await tester.pumpAndSettle(); + + expect(called, isFalse); + expect(find.text('Enter a project name.'), findsOneWidget); + }); +}