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
+52 -22
View File
@@ -221,7 +221,7 @@ class _StartColumn extends StatelessWidget {
if (ok) {
kernel.panels.activateTab(Slots.workspace, 'claude.primary');
} else {
kernel.dialog.show((ctx, dismiss) => _NotARepoDialog(path: picked, onDismiss: () => dismiss()));
kernel.dialog.show((ctx, dismiss) => _NotARepoDialog(kernel: kernel, path: picked, onDismiss: () => dismiss()));
}
}
return;
@@ -641,16 +641,56 @@ class _OpenProjectDialogState extends State<_OpenProjectDialog> {
}
}
class _NotARepoDialog extends StatelessWidget {
const _NotARepoDialog({required this.path, required this.onDismiss});
/// Shown when the opened folder isn't a git repo. Rather than a dead end, it
/// 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 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
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface;
return Container(
width: 420,
width: 460,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: tokens.modalSurfaceBackground,
@@ -661,31 +701,21 @@ class _NotARepoDialog extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClideText(
ClideSettings.i18n.string(context, 'dialog.notRepo.title', namespace: 'builtin.welcome', placeholder: 'No git repo found'),
fontSize: clideFontDialogTitle,
fontWeight: FontWeight.w600,
),
ClideText(_t('dialog.notRepo.title', 'No git repo found'), fontSize: clideFontDialogTitle, fontWeight: FontWeight.w600),
const SizedBox(height: 8),
ClideText(path, muted: true, fontSize: clideFontMeta),
ClideText(widget.path, muted: true, fontSize: clideFontMeta),
const SizedBox(height: 8),
ClideText(
ClideSettings.i18n.string(
context,
'dialog.notRepo.body',
namespace: 'builtin.welcome',
placeholder: 'A clide project root requires a git repository.',
),
muted: true,
fontSize: clideFontMeta,
),
ClideText(_t('dialog.notRepo.body', 'A clide project needs a git repository. Initialize this folder as one?'), muted: true, fontSize: clideFontMeta),
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: _t('button.cancel', 'Cancel'), onPressed: widget.onDismiss),
const SizedBox(width: 8),
ClideButton(
label: ClideSettings.i18n.string(context, 'button.ok', namespace: 'builtin.welcome', placeholder: 'OK'),
onPressed: () => onDismiss(),
label: _loading ? _t('button.initializing', 'Initializing…') : _t('dialog.notRepo.initialize', 'Initialize project'),
onPressed: _loading ? null : _initialize,
),
],
),
+1
View File
@@ -340,6 +340,7 @@ Future<void> main() async {
dispatcher,
gitInit: (dir) => GitClient(toolchain: tc, workDir: Directory(dir)).init(),
defaultParent: () => workRoot.parent.path,
defaultInitPath: () => workRoot.path,
);
final pql = PqlClient(workDir: workRoot, toolchain: tc);
registerPqlCommands(dispatcher, pql);
+33 -7
View File
@@ -66,11 +66,27 @@ Future<NewProjectResult> createNewProject({required String parent, required Stri
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) {
// Minimal + non-prescriptive: keep clide's own state out of git, and orient
// Claude with an empty CLAUDE.md stub. No language/framework templates.
File('$dir/.gitignore').writeAsStringSync('# clide\n.clide/\n');
File('$dir/CLAUDE.md').writeAsStringSync('# $name\n\nGuidance for Claude Code in this project.\n');
// Claude with an empty CLAUDE.md stub. No language/framework templates. Never
// clobbers — safe to run over an existing folder (T-489).
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) {
@@ -81,10 +97,12 @@ String _stripTrailingSep(String p) {
return s;
}
/// Register `project.new`. [gitInit] runs git init; [defaultParent] supplies the
/// parent dir when `--dir` is omitted (main.dart passes the current workspace's
/// parent, so a new project lands beside the current one).
void registerProjectCommands(DaemonDispatcher d, {required ProjectGitInit gitInit, String? Function()? defaultParent}) {
/// Register `project.new` + `project.init`. [gitInit] runs git init;
/// [defaultParent] supplies the new-project parent when `--dir` is omitted (the
/// current workspace's parent); [defaultInitPath] supplies the init target when
/// `--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 {
final name = (req.args['name'] as String?)?.trim();
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!);
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()}));
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(