feat(welcome): initialize a non-repo folder as a project (T-489)

Closes the new-project story (T-486). The dead-end "not a git repo" dialog now
offers to initialize the folder: project.init runs git init + a non-clobbering
scaffold, then opens + announces on projectCreatedChannel so the account
roadblock fires — the same path a brand-new project takes. Adds initExistingProject
+ the `clide project init [--dir]` verb (default: the current workspace).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 08:42:02 +02:00
co-authored by Claude Opus 4.8
parent 25430686cc
commit a46677facf
10 changed files with 198 additions and 31 deletions
@@ -7262,3 +7262,5 @@ Considerations:
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; 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 ('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; 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;
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 ('06FGSQ8YSZA5YNP1JE8SJHXWBG', 'status', 'backlog', 'done', NULL, '2026-06-28 06:41:47', '2026-06-28 06:41:47.934', '2026-06-28 06:41:47.934', NULL, 'df08fd309db50c322d5ea27267d23f7b', 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 ('06FGPG4WNNH3BWDRVZXYQTW7Z0', 'status', 'backlog', 'done', NULL, '2026-06-28 06:41:47', '2026-06-28 06:41:47.971', '2026-06-28 06:41:47.971', NULL, '8e792d212a7f92e5201296ed01873607', 2) ON CONFLICT(hash) DO NOTHING;
+31
View File
@@ -9355,3 +9355,34 @@ Considerations:
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); 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 ('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); 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);
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 ('06FGSQ8YSZA5YNP1JE8SJHXWBG', 'task', '06FGPG4WNNH3BWDRVZXYQTW7Z0', 'Evolve _NotARepoDialog into ''Initialize this folder as a clide project?'' (reuse the init + account flow)', NULL, 'done', 'medium', NULL, NULL, NULL, '2026-06-28 06:14:05.775', '2026-06-28 06:41:47.934', NULL, '5d85035e5b90e90e18bdeb2849f6a4ec', 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 ('06FGPG4WNNH3BWDRVZXYQTW7Z0', 'story', NULL, 'New project creation flow (with Claude account roadblock)', 'clide can only OPEN existing git repos today; there is no way to create a new project, and opening a non-repo folder dead-ends at _NotARepoDialog (welcome_view.dart:633). Add a first-class new-project flow. Per the per-repo account design (T-476), the account choice is a real decision only at project birth, so account selection is the flow''s final roadblock step — existing projects never prompt (they keep their binding, or default to the main account; switch later via Settings or the pane badge).
## Entry points (D-6 parity)
- Welcome view START section: a ''New project...'' action (peer of ''Open folder...'').
- CLI: `clide project new <name> [--dir <parent>] [--account <name>]`.
- Non-repo open: _NotARepoDialog evolves from a dead end into ''Initialize this folder as a clide project?'' the same init + account steps.
## Flow
1. Location + name pick a parent dir (window.pickDirectory()), enter a name target <parent>/<name>/. Validate: non-empty name, target does not already exist.
2. Initialize create the dir, `git init`, write a minimal non-prescriptive scaffold (a CLAUDE.md stub + .gitignore). No language/templates.
3. Account roadblock final screen: pick the Claude account this project runs under (registered accounts + Default + ''Add account...''). Selecting binds the new workspace (AccountRegistry.bindWorkspace + accountActionChannel); ''Add account...'' runs add + login (T-485).
4. Open project.open the new dir so the workspace goes live; the primary Claude pane spawns under the bound account.
## Reuses (already shipped under T-476)
- Account picker + binding: account_settings_control.dart (ClaudeAccountsListControl / bindWorkspaceAccount), accountActionChannel.
- git init: lib/src/git/ client.
- project open: kernel.project.
## Acceptance
1. ''New project...'' creates <parent>/<name>/, git-inits it, and opens it as the active workspace.
2. The final step binds the chosen account (or Default); a fresh claude in the new project runs under that account''s CLAUDE_CONFIG_DIR.
3. Opening an EXISTING project shows no account prompt.
4. Opening a non-repo folder offers to initialize it (no dead end).
5. `clide project new` mirrors the UI (D-6); every UI action has a CLI counterpart.
6. Tests cover the dialog flow, git-init, the account-bind step, and the non-repo init path.
## Out of scope
- Language/framework templates (git init + minimal files only).
- Remote creation (GitHub repo create) local init only.
- Re-homing existing projects between accounts beyond the existing Settings/badge switch.', 'done', 'medium', NULL, NULL, NULL, '2026-06-27 22:43:31.629', '2026-06-28 06:41:47.971', NULL, '01a340c5205eae82e1b2bb7efb3be179', 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);
+3
View File
@@ -22,6 +22,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
opens a project, then prompts for the Claude account to bind it to — the 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, per-repo account roadblock fires only for freshly-created projects. (T-488,
story T-486) story T-486)
- **Initialize a non-repo folder.** Opening a folder that isn't a git repo now
offers to initialize it as a clide project (`git init` + scaffold + the
account roadblock) instead of dead-ending. Also `clide project init`. (T-489)
- **`clide project new <name> [--dir <parent>]`.** Create a new clide project — - **`clide project new <name> [--dir <parent>]`.** Create a new clide project —
a fresh dir, `git init`, and a minimal scaffold. `--dir` defaults to the a fresh dir, `git init`, and a minimal scaffold. `--dir` defaults to the
current workspace's parent. (T-487, story T-486) current workspace's parent. (T-487, story T-486)
+3 -1
View File
@@ -35,6 +35,8 @@
"button.opening": { "translation": "Opening…" }, "button.opening": { "translation": "Opening…" },
"button.ok": { "translation": "OK" }, "button.ok": { "translation": "OK" },
"dialog.notRepo.title": { "translation": "No git repo found" }, "dialog.notRepo.title": { "translation": "No git repo found" },
"dialog.notRepo.body": { "translation": "A clide project root requires a git repository." }, "dialog.notRepo.body": { "translation": "A clide project needs a git repository. Initialize this folder as one?" },
"dialog.notRepo.initialize": { "translation": "Initialize project" },
"button.initializing": { "translation": "Initializing…" },
"command.workspace.open-project": { "translation": "Workspace: Open project…" } "command.workspace.open-project": { "translation": "Workspace: Open project…" }
} }
+3 -1
View File
@@ -35,6 +35,8 @@
"button.opening": { "translation": "Openen…" }, "button.opening": { "translation": "Openen…" },
"button.ok": { "translation": "OK" }, "button.ok": { "translation": "OK" },
"dialog.notRepo.title": { "translation": "Geen git-repo gevonden" }, "dialog.notRepo.title": { "translation": "Geen git-repo gevonden" },
"dialog.notRepo.body": { "translation": "Een clide-projecthoofdmap vereist een git-repository." }, "dialog.notRepo.body": { "translation": "Een clide-project heeft een git-repository nodig. Deze map als project initialiseren?" },
"dialog.notRepo.initialize": { "translation": "Project initialiseren" },
"button.initializing": { "translation": "Initialiseren…" },
"command.workspace.open-project": { "translation": "Werkruimte: Project openen…" } "command.workspace.open-project": { "translation": "Werkruimte: Project openen…" }
} }
+52 -22
View File
@@ -221,7 +221,7 @@ class _StartColumn extends StatelessWidget {
if (ok) { if (ok) {
kernel.panels.activateTab(Slots.workspace, 'claude.primary'); kernel.panels.activateTab(Slots.workspace, 'claude.primary');
} else { } else {
kernel.dialog.show((ctx, dismiss) => _NotARepoDialog(path: picked, onDismiss: () => dismiss())); kernel.dialog.show((ctx, dismiss) => _NotARepoDialog(kernel: kernel, path: picked, onDismiss: () => dismiss()));
} }
} }
return; return;
@@ -641,16 +641,56 @@ class _OpenProjectDialogState extends State<_OpenProjectDialog> {
} }
} }
class _NotARepoDialog extends StatelessWidget { /// Shown when the opened folder isn't a git repo. Rather than a dead end, it
const _NotARepoDialog({required this.path, required this.onDismiss}); /// offers to initialize the folder as a clide project (T-489): `project.init`,
/// open, and announce on [projectCreatedChannel] so the account roadblock fires
/// — the same path a brand-new project takes.
class _NotARepoDialog extends StatefulWidget {
const _NotARepoDialog({required this.kernel, required this.path, required this.onDismiss});
final KernelServices kernel;
final String path; final String path;
final VoidCallback onDismiss; final VoidCallback onDismiss;
@override
State<_NotARepoDialog> createState() => _NotARepoDialogState();
}
class _NotARepoDialogState extends State<_NotARepoDialog> {
bool _loading = false;
String? _error;
Future<void> _initialize() async {
setState(() {
_loading = true;
_error = null;
});
final r = await widget.kernel.ipc.request(
'project.init',
args: {
'positional': <String>[],
'flags': {'dir': widget.path},
},
);
if (!mounted) return;
if (!r.ok) {
return setState(() {
_loading = false;
_error = r.error?.message ?? 'Could not initialize this folder.';
});
}
final opened = await widget.kernel.project.open(widget.path);
if (opened) widget.kernel.panels.activateTab(Slots.workspace, 'claude.primary');
widget.kernel.messages.publish('welcome', projectCreatedChannel, {'dir': widget.path});
widget.onDismiss();
}
String _t(String key, String fallback) => ClideSettings.i18n.string(context, key, namespace: 'builtin.welcome', placeholder: fallback);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface; final tokens = ClideSettings.theme.of(context).surface;
return Container( return Container(
width: 420, width: 460,
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: tokens.modalSurfaceBackground, color: tokens.modalSurfaceBackground,
@@ -661,31 +701,21 @@ class _NotARepoDialog extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ClideText( ClideText(_t('dialog.notRepo.title', 'No git repo found'), fontSize: clideFontDialogTitle, fontWeight: FontWeight.w600),
ClideSettings.i18n.string(context, 'dialog.notRepo.title', namespace: 'builtin.welcome', placeholder: 'No git repo found'),
fontSize: clideFontDialogTitle,
fontWeight: FontWeight.w600,
),
const SizedBox(height: 8), const SizedBox(height: 8),
ClideText(path, muted: true, fontSize: clideFontMeta), ClideText(widget.path, muted: true, fontSize: clideFontMeta),
const SizedBox(height: 8), const SizedBox(height: 8),
ClideText( ClideText(_t('dialog.notRepo.body', 'A clide project needs a git repository. Initialize this folder as one?'), muted: true, fontSize: clideFontMeta),
ClideSettings.i18n.string( if (_error != null) ...[const SizedBox(height: 8), ClideText(_error!, color: tokens.statusError, fontSize: clideFontSmall)],
context,
'dialog.notRepo.body',
namespace: 'builtin.welcome',
placeholder: 'A clide project root requires a git repository.',
),
muted: true,
fontSize: clideFontMeta,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
ClideButton(label: _t('button.cancel', 'Cancel'), onPressed: widget.onDismiss),
const SizedBox(width: 8),
ClideButton( ClideButton(
label: ClideSettings.i18n.string(context, 'button.ok', namespace: 'builtin.welcome', placeholder: 'OK'), label: _loading ? _t('button.initializing', 'Initializing…') : _t('dialog.notRepo.initialize', 'Initialize project'),
onPressed: () => onDismiss(), onPressed: _loading ? null : _initialize,
), ),
], ],
), ),
+1
View File
@@ -340,6 +340,7 @@ Future<void> main() async {
dispatcher, dispatcher,
gitInit: (dir) => GitClient(toolchain: tc, workDir: Directory(dir)).init(), gitInit: (dir) => GitClient(toolchain: tc, workDir: Directory(dir)).init(),
defaultParent: () => workRoot.parent.path, defaultParent: () => workRoot.parent.path,
defaultInitPath: () => workRoot.path,
); );
final pql = PqlClient(workDir: workRoot, toolchain: tc); final pql = PqlClient(workDir: workRoot, toolchain: tc);
registerPqlCommands(dispatcher, pql); registerPqlCommands(dispatcher, pql);
+33 -7
View File
@@ -66,11 +66,27 @@ Future<NewProjectResult> createNewProject({required String parent, required Stri
return NewProjectResult.ok(target); return NewProjectResult.ok(target);
} }
/// `git init` an EXISTING folder that isn't a repo yet (T-489) — the "initialize
/// this folder as a clide project" path. Unlike [createNewProject] it never
/// creates the dir and never clobbers existing files; the scaffold is only
/// written where absent.
Future<NewProjectResult> initExistingProject({required String path, required ProjectGitInit gitInit}) async {
final trimmed = _stripTrailingSep(path.trim());
if (trimmed.isEmpty) return NewProjectResult.err('a directory is required');
if (!Directory(trimmed).existsSync()) return NewProjectResult.err('directory does not exist: $trimmed');
await gitInit(trimmed);
_writeScaffold(trimmed, trimmed.split('/').where((s) => s.isNotEmpty).lastOrNull ?? 'project');
return NewProjectResult.ok(trimmed);
}
void _writeScaffold(String dir, String name) { void _writeScaffold(String dir, String name) {
// Minimal + non-prescriptive: keep clide's own state out of git, and orient // Minimal + non-prescriptive: keep clide's own state out of git, and orient
// Claude with an empty CLAUDE.md stub. No language/framework templates. // Claude with an empty CLAUDE.md stub. No language/framework templates. Never
File('$dir/.gitignore').writeAsStringSync('# clide\n.clide/\n'); // clobbers — safe to run over an existing folder (T-489).
File('$dir/CLAUDE.md').writeAsStringSync('# $name\n\nGuidance for Claude Code in this project.\n'); final gitignore = File('$dir/.gitignore');
if (!gitignore.existsSync()) gitignore.writeAsStringSync('# clide\n.clide/\n');
final claudeMd = File('$dir/CLAUDE.md');
if (!claudeMd.existsSync()) claudeMd.writeAsStringSync('# $name\n\nGuidance for Claude Code in this project.\n');
} }
String _stripTrailingSep(String p) { String _stripTrailingSep(String p) {
@@ -81,10 +97,12 @@ String _stripTrailingSep(String p) {
return s; return s;
} }
/// Register `project.new`. [gitInit] runs git init; [defaultParent] supplies the /// Register `project.new` + `project.init`. [gitInit] runs git init;
/// parent dir when `--dir` is omitted (main.dart passes the current workspace's /// [defaultParent] supplies the new-project parent when `--dir` is omitted (the
/// parent, so a new project lands beside the current one). /// current workspace's parent); [defaultInitPath] supplies the init target when
void registerProjectCommands(DaemonDispatcher d, {required ProjectGitInit gitInit, String? Function()? defaultParent}) { /// `--dir` is omitted (the current workspace, so `clide project init` inits the
/// folder you're in).
void registerProjectCommands(DaemonDispatcher d, {required ProjectGitInit gitInit, String? Function()? defaultParent, String? Function()? defaultInitPath}) {
d.register('project.new', (req) async { d.register('project.new', (req) async {
final name = (req.args['name'] as String?)?.trim(); final name = (req.args['name'] as String?)?.trim();
if (name == null || name.isEmpty) return _err(req.id, 'project new requires a <name>'); if (name == null || name.isEmpty) return _err(req.id, 'project new requires a <name>');
@@ -96,6 +114,14 @@ void registerProjectCommands(DaemonDispatcher d, {required ProjectGitInit gitIni
if (!result.ok) return _err(req.id, result.error!); if (!result.ok) return _err(req.id, result.error!);
return IpcResponse.ok(id: req.id, data: {'path': result.path, 'name': name}); return IpcResponse.ok(id: req.id, data: {'path': result.path, 'name': name});
}, schema: const CommandSchema(positional: ['name'], args: {'name': ArgSpec(required: true, rejectLeadingDash: true), 'dir': ArgSpec()})); }, schema: const CommandSchema(positional: ['name'], args: {'name': ArgSpec(required: true, rejectLeadingDash: true), 'dir': ArgSpec()}));
d.register('project.init', (req) async {
final path = (req.args['dir'] as String?)?.trim() ?? defaultInitPath?.call();
if (path == null || path.isEmpty) return _err(req.id, 'no directory to initialize', hint: 'pass --dir <path>');
final result = await initExistingProject(path: path, gitInit: gitInit);
if (!result.ok) return _err(req.id, result.error!);
return IpcResponse.ok(id: req.id, data: {'path': result.path});
}, schema: const CommandSchema(args: {'dir': ArgSpec()}));
} }
IpcResponse _err(String id, String message, {String? hint}) => IpcResponse.err( IpcResponse _err(String id, String message, {String? hint}) => IpcResponse.err(
@@ -9,6 +9,7 @@ import 'package:clide/builtin/welcome/src/welcome_view.dart';
import 'package:clide/clide.dart'; import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart'; import 'package:clide/kernel/kernel.dart';
import 'package:clide/src/daemon/project_commands.dart' show projectCreatedChannel; import 'package:clide/src/daemon/project_commands.dart' show projectCreatedChannel;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -79,4 +80,36 @@ void main() {
expect(called, isFalse); expect(called, isFalse);
expect(find.text('Enter a project name.'), findsOneWidget); expect(find.text('Enter a project name.'), findsOneWidget);
}); });
testWidgets('opening a non-repo folder offers Initialize, which dispatches project.init + announces (T-489)', (tester) async {
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(const MethodChannel('clide/window'), (call) async {
return call.method == 'pickDirectory' ? '/tmp/not-a-repo' : null;
});
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(const MethodChannel('clide/window'), null));
Map<String, Object?>? initArgs;
f.ipc.setConnected(true);
f.ipc.stub('project.init', (args) async {
initArgs = args;
return IpcResponse.ok(id: 'r', data: {'path': '/tmp/not-a-repo'});
});
final announced = <Map<String, Object?>>[];
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('Open folder…'));
await tester.pumpAndSettle();
expect(find.text('Initialize project'), findsOneWidget);
await tester.tap(find.text('Initialize project'));
await tester.pumpAndSettle();
expect((initArgs?['flags'] as Map)['dir'], '/tmp/not-a-repo');
expect(announced.single['dir'], '/tmp/not-a-repo');
});
} }
+37
View File
@@ -57,6 +57,43 @@ void main() {
}); });
}); });
group('initExistingProject + project.init', () {
test('inits an existing folder, scaffolds absent files, never clobbers', () async {
final inited = <String>[];
File('${parent.path}/CLAUDE.md').writeAsStringSync('keep me');
final r = await initExistingProject(path: parent.path, gitInit: (d) async => inited.add(d));
expect(r.ok, isTrue, reason: r.error);
expect(r.path, parent.path);
expect(inited, [parent.path]);
expect(File('${parent.path}/CLAUDE.md').readAsStringSync(), 'keep me', reason: 'existing file untouched');
expect(File('${parent.path}/.gitignore').existsSync(), isTrue, reason: 'absent file scaffolded');
});
test('refuses a missing directory', () async {
expect((await initExistingProject(path: '/no/such/dir/xyz', gitInit: (_) async {})).error, contains('does not exist'));
});
test('project.init verb inits --dir, and falls back to the default init path', () async {
final d = DaemonDispatcher();
registerProjectCommands(d, gitInit: (_) async {}, defaultInitPath: () => parent.path);
final r1 = await d.dispatch(
IpcRequest(
id: '1',
cmd: 'project.init',
args: {
'positional': const <String>[],
'flags': {'dir': parent.path},
},
),
);
expect(r1.ok, isTrue, reason: r1.error?.message);
expect(r1.data['path'], parent.path);
final r2 = await d.dispatch(IpcRequest(id: '2', cmd: 'project.init', args: {'positional': const <String>[], 'flags': const {}}));
expect(r2.ok, isTrue, reason: r2.error?.message);
expect(r2.data['path'], parent.path);
});
});
group('project.new command', () { group('project.new command', () {
Future<IpcResponse> run(List<String> positional, {Map<String, Object?>? flags, String? defaultParent}) { Future<IpcResponse> run(List<String> positional, {Map<String, Object?>? flags, String? defaultParent}) {
final d = DaemonDispatcher(); final d = DaemonDispatcher();