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:
@@ -9,6 +9,7 @@ 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/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@@ -79,4 +80,36 @@ void main() {
|
||||
expect(called, isFalse);
|
||||
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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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', () {
|
||||
Future<IpcResponse> run(List<String> positional, {Map<String, Object?>? flags, String? defaultParent}) {
|
||||
final d = DaemonDispatcher();
|
||||
|
||||
Reference in New Issue
Block a user