Skip to content

Commit 5927b61

Browse files
authored
feat(create-project): add project creation flow (#149)
- add start-from-scratch and existing-folder creation UI - persist last-used parent directories and create folders via IPC - centralize project path helpers and improve WSL handling
1 parent 24a9c7d commit 5927b61

30 files changed

Lines changed: 1379 additions & 167 deletions

.agents/docs/ui-patterns.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ Before creating a new component, check if an existing one handles the use case.
2525
- **`BranchSelector`** handles branch picking and worktree creation in `ThreadDraftView`. Reuse it for any branch-related UI.
2626
- **`OptionMenu`** is the dropdown for model/effort/permission selections. It supports custom label formatters via the provider registry.
2727

28+
### Dialogs
29+
30+
Match the canonical dialog look — do not restyle. Reference: `CreatePrModal`, `ContinueInProviderDialog`.
31+
32+
- **Form / input dialogs:** HeroUI `Modal` (`Modal.Backdrop``Container``Dialog`), kept **compact** (`Dialog` `sm:max-w-[~460px]`, `Modal.Body className="p-4"` with inner `gap-3`). Include a `Modal.CloseTrigger`.
33+
- **Footer buttons:** Cancel is a **muted ghost**`<Button slot="close" variant="ghost" className="text-muted">Cancel</Button>`. The confirm/primary action is the **white tertiary**`variant="tertiary"`. Do **not** use `variant="primary"` for the action in these dialogs.
34+
- **Destructive confirms:** use the shared `ConfirmDialog` (`AlertDialog`) with `confirmVariant="danger"`; its Cancel is `variant="tertiary"` by convention.
35+
- Keep dialog body height stable — avoid controls that appear/disappear as the user types (fold previews into an existing control rather than adding a conditional line).
36+
2837
## ACP Composer Behavior
2938

3039
- **Inline file mentions stay text-first, then serialize to structured segments.** `MentionInput` + `serializeMentions` accept raw `@path` tokens, so repo-relative references like `@.agents/docs/ui-patterns.md` become `{ kind: "file" }` prompt segments on submit without requiring a picker chip.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Create Project — Design
2+
3+
Date: 2026-06-08
4+
5+
## Goal
6+
7+
Implement a unified "create project" flow with:
8+
9+
- A `+` menu offering **Start from scratch** and **Use an existing folder**.
10+
- A runtime picker for **Native / WSL** (WSL shown only when distros exist).
11+
- A folder picker whose default is preselected as **last-used → home**, scoped **per runtime**.
12+
13+
## Decisions (confirmed)
14+
15+
- **Start from scratch** = name the project → pick runtime + parent folder (in a custom modal) → create `<parent>/<name>` on disk (`mkdir`) → open it as the new project.
16+
- **Use an existing folder** = opens the **native OS folder picker directly** (no custom modal), exactly like the original flow → opens the chosen directory as a project. The picked path is authoritative for the runtime (a `\\wsl...` path → WSL project, else native). The dialog opens at the last-used native directory → home.
17+
- **Runtime picker** lives in the scratch modal only (the OS dialog cannot host a Native/WSL toggle); for existing folders the runtime is inferred from the picked path.
18+
- **Last-used directory** is remembered **per runtime** (`"native"` or the WSL distro name), falling back to that runtime's home.
19+
- Both the sidebar `+` and the `WelcomeOverlay` CTA route through the same flow (default decision for consistency).
20+
21+
## Current state (baseline)
22+
23+
- `SidebarHeaderControls.tsx`: `FolderPlus` button. Windows → dropdown ("Add Windows Project" / "Add WSL Project"); macOS/Linux → direct `pickFolder()`. Flow: `pickFolder()``addProject(location)``autoDetectSetupScript``openDraft`.
24+
- `WelcomeOverlay.tsx`: "Add Project" → `pickFolder()``addProject`.
25+
- `projectSlice.addProject(location, nameOverride?)``nameOverride` exists but is unused.
26+
- `ProjectLocation` (`src/shared/contracts/common.ts`): discriminated union `windows | wsl | posix`.
27+
- Helpers (`src/shared/wsl.ts`): `parseWslUncPath`, `getProjectName`, `toWslUncPath`, `getProjectFsPath`.
28+
- IPC: `pickFolder(defaultPath?)`, `listWslDistros()` already exist (`procedures/app.ts`, `localHandlers.ts`).
29+
- Settings: JSON at `~/.lightcode/settings.json`; renderer store `sharedSettingsStore.ts`; schema `src/shared/settings.ts`. **No last-used directory persisted today.** **No create-directory IPC today.**
30+
31+
The screenshots' "Start from scratch" / "Use an existing folder" menu and "Name project" modal do **not** exist in code yet — they are the target.
32+
33+
## Architecture
34+
35+
### Entry points
36+
37+
- Sidebar `+` (`SidebarHeaderControls`) and the `WelcomeOverlay` CTA both render `CreateProjectMenu` (two items).
38+
- "Start from scratch" → `panelStore.openCreateProjectModal()` (the scratch modal, mounted once in `AppOverlays`).
39+
- "Use an existing folder" → `addExistingProject()` → native `pickFolder` → create project. No modal.
40+
41+
### `CreateProjectModal` (scratch only)
42+
43+
HeroUI `Modal` (mirrors `CreatePrModal`). Fields:
44+
45+
- **Runtime selector** — visible only when WSL distros exist. Options: `Native` + one per distro. Hidden on macOS/Linux (runtime = `posix`). Changing it re-resolves the default location.
46+
- **Location** (read-only path + **Browse**`pickFolder(defaultPath)`):
47+
- scratch: "Parent folder".
48+
- existing: "Folder".
49+
- Default on open / runtime change: `lastUsedProjectDirs[runtimeKey]` → else runtime home (`homeDir` native; `\\wsl.localhost\<distro>\home` for WSL).
50+
- **Name** (text input, placeholder "New project"):
51+
- scratch: required; legal single path segment.
52+
- existing: prefilled with basename of picked folder; editable; display-name only.
53+
- **Footer**: Cancel / Save. Save disabled until valid. Scratch shows a live preview of the final path.
54+
55+
`runtimeKey` = `"native"` for windows/posix native, else the distro name.
56+
57+
### Save behavior
58+
59+
1. Derive `ProjectLocation` from the final path (`deriveLocationFromPath`): `parseWslUncPath` succeeds → `wsl`; else `isWindows()``windows`; else `posix`. The picked path is authoritative.
60+
2. scratch: `createProjectDirectory({ parent, name, kind })` → returns created absolute path → derive location from it.
61+
3. `addProject(location, name)``autoDetectSetupScript(project)``openDraft(project.id)`.
62+
4. `setLastUsedProjectDir(runtimeKey, parentDir)` where `parentDir` is the directory the user browsed (scratch: the parent field; existing: `dirname(folder)`).
63+
5. Guards (inline modal errors, not silent fallbacks): scratch+WSL requires a `\\wsl...` parent; scratch+native rejects a WSL UNC parent.
64+
65+
### Persistence & IPC
66+
67+
- `sharedSettingsSchema` + `defaultSharedSettings`: add `lastUsedProjectDirs: Record<string,string>` (default `{}`).
68+
- `sharedSettingsStore`: add `setLastUsedProjectDir(runtimeKey, dir)` mirroring `pushRecentModel` (merge → `persistSettings`).
69+
- Native home: reuse the existing `getHomeScopeLocation()` IPC (cached via `loadHomeScopeLocation()`) — no preload change needed. WSL home is `\\wsl.localhost\<distro>\home`.
70+
- New main-local IPC `createProjectDirectory` (`procedures/app.ts` + `localHandlers.ts`): `{ parent, name, kind }``{ path }`. `path.win32.join` for windows/wsl, `path.posix.join` for posix; refuses to clobber an existing folder; non-recursive `mkdir` so a missing parent is reported; maps common `errno` codes (EACCES/ENOSPC/ENOENT/…) to user-grade messages.
71+
- Reuse `pickFolder`, `listWslDistros`, `parseWslUncPath`, `getProjectName`.
72+
73+
### State / components / files
74+
75+
- `panelStore`: `createProjectModal: { open, mode }` + `openCreateProject(mode)` / `closeCreateProject()`.
76+
- New files: `CreateProjectMenu`, `CreateProjectModal`, `useCreateProjectFlow` controller (with pure `deriveLocationFromPath`, name validation, target-path build).
77+
- Edits: `SidebarHeaderControls.tsx`, `WelcomeOverlay.tsx`, `settings.ts`, `sharedSettingsStore.ts`, preload + bridge type, IPC procedure map + handler, `AppOverlays`.
78+
79+
## Testing (TDD)
80+
81+
- Pure unit: `deriveLocationFromPath` (UNC→wsl / win→windows / posix), legal-name validation, scratch target-path build, runtime/parent mismatch guards.
82+
- Settings store: `setLastUsedProjectDir` merge + persist.
83+
- Main handler: `createProjectDirectory` mkdir + conflict error.
84+
- Component: `CreateProjectModal` — Save gating, runtime-selector visibility, scratch path preview.
85+
86+
## Out of scope
87+
88+
- Cloning a repo from a URL.
89+
- Multi-folder / monorepo workspace projects.
90+
- Renaming/migrating existing projects' runtimes.

src/main/ipc/localHandlers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
saveClipboardImageFile,
2626
saveHandoffContextFile,
2727
} from "../attachments/localFiles";
28+
import { createProjectDirectory } from "../projectDirectory";
2829
import { readSharedSettingsFile, writeSharedSettingsFile } from "../sharedSettingsFile";
2930
import { readKeybindingsFile } from "../keybindingsFile";
3031
import type { AutoUpdaterController } from "../updates/autoUpdater";
@@ -102,6 +103,7 @@ export function createLocalIpcHandlers(
102103
saveClipboardImageFile(options.requireLightcodePaths(), payload),
103104
saveHandoffContext: (payload) =>
104105
saveHandoffContextFile(options.requireLightcodePaths(), payload),
106+
createProjectDirectory: (payload) => createProjectDirectory(payload),
105107
openExternal: async (url) => {
106108
const safeUrl = assertSafeExternalUrl(url);
107109
const browserPanel = options.getBrowserPanelManager();

src/main/projectDirectory.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
5+
import { createProjectDirectory, describeMkdirError } from "./projectDirectory";
6+
7+
describe("createProjectDirectory", () => {
8+
let root: string;
9+
10+
beforeEach(() => {
11+
root = mkdtempSync(join(tmpdir(), "lc-create-project-"));
12+
});
13+
14+
afterEach(() => {
15+
rmSync(root, { recursive: true, force: true });
16+
});
17+
18+
test("creates the folder under the parent and returns its path", async () => {
19+
const result = await createProjectDirectory({ parent: root, name: "new-app", kind: "posix" });
20+
21+
const expected = join(root, "new-app");
22+
expect(result.path).toBe(expected);
23+
expect(existsSync(expected)).toBe(true);
24+
expect(statSync(expected).isDirectory()).toBe(true);
25+
});
26+
27+
test("throws when a folder with that name already exists", async () => {
28+
await createProjectDirectory({ parent: root, name: "dup", kind: "posix" });
29+
30+
await expect(
31+
createProjectDirectory({ parent: root, name: "dup", kind: "posix" }),
32+
).rejects.toThrow(/already exists/i);
33+
});
34+
35+
test("surfaces a friendly message when the parent does not exist", async () => {
36+
await expect(
37+
createProjectDirectory({ parent: join(root, "missing"), name: "app", kind: "posix" }),
38+
).rejects.toThrow(/no longer exists/i);
39+
});
40+
});
41+
42+
describe("describeMkdirError", () => {
43+
test("maps permission errors", () => {
44+
expect(describeMkdirError({ code: "EACCES" }, "x")).toMatch(/permission/i);
45+
expect(describeMkdirError({ code: "EPERM" }, "x")).toMatch(/permission/i);
46+
});
47+
48+
test("maps out-of-space, missing-parent and not-a-directory codes", () => {
49+
expect(describeMkdirError({ code: "ENOSPC" }, "x")).toMatch(/disk space/i);
50+
expect(describeMkdirError({ code: "ENOENT" }, "x")).toMatch(/no longer exists/i);
51+
expect(describeMkdirError({ code: "ENOTDIR" }, "x")).toMatch(/not a folder/i);
52+
});
53+
54+
test("falls back to the raw error message", () => {
55+
expect(describeMkdirError(new Error("boom"), "x")).toBe("boom");
56+
});
57+
});

src/main/projectDirectory.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { mkdir } from "node:fs/promises";
2+
import { posix, win32 } from "node:path";
3+
import type { ScratchKind } from "@/shared/createProject";
4+
5+
export interface CreateProjectDirectoryPayload {
6+
/** Absolute parent directory (native path, or a `\\wsl...` UNC path). */
7+
parent: string;
8+
/** New folder name (already validated by the renderer). */
9+
name: string;
10+
kind: ScratchKind;
11+
}
12+
13+
/** Translate a Node `mkdir` error into a message fit to show the user. */
14+
export function describeMkdirError(error: unknown, name: string): string {
15+
const code = (error as NodeJS.ErrnoException | null)?.code;
16+
switch (code) {
17+
case "EACCES":
18+
case "EPERM":
19+
return `You don't have permission to create "${name}" there.`;
20+
case "ENOSPC":
21+
return "There isn't enough disk space to create the folder.";
22+
case "ENOENT":
23+
return "That parent folder no longer exists. Pick another location.";
24+
case "ENOTDIR":
25+
return "The chosen location is not a folder.";
26+
case "EEXIST":
27+
return `A folder named "${name}" already exists here.`;
28+
default:
29+
return error instanceof Error ? error.message : `Couldn't create "${name}".`;
30+
}
31+
}
32+
33+
/**
34+
* Create the directory for a "start from scratch" project. Joins with the
35+
* separator appropriate to the location kind (posix uses `/`; windows and WSL
36+
* UNC paths use `\`), refusing to clobber an existing folder. The parent must
37+
* already exist — `mkdir` is non-recursive so a stale/removed parent (ENOENT)
38+
* or an existing target (EEXIST) surfaces as a clear error rather than silently
39+
* materializing the path elsewhere or clobbering it.
40+
*/
41+
export async function createProjectDirectory(
42+
payload: CreateProjectDirectoryPayload,
43+
): Promise<{ path: string }> {
44+
const join = payload.kind === "posix" ? posix.join : win32.join;
45+
const target = join(payload.parent, payload.name);
46+
47+
try {
48+
await mkdir(target);
49+
} catch (error) {
50+
throw new Error(describeMkdirError(error, payload.name), { cause: error });
51+
}
52+
return { path: target };
53+
}

src/main/sharedSettingsFile.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ describe("sharedSettingsFile", () => {
7272
gitReviewMode: "panel",
7373
providerConfigs: {},
7474
lastPresentationModeByAgent: {},
75+
lastUsedProjectDirs: {},
7576
editorLspEnabled: false,
7677
searchUseIgnoreFiles: true,
7778
searchExclude: {},
@@ -155,6 +156,7 @@ describe("sharedSettingsFile", () => {
155156
gitReviewMode: "panel",
156157
providerConfigs: {},
157158
lastPresentationModeByAgent: {},
159+
lastUsedProjectDirs: {},
158160
editorLspEnabled: false,
159161
searchUseIgnoreFiles: true,
160162
searchExclude: {},
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { beforeEach, describe, expect, test, vi } from "vitest";
2+
3+
const mocks = vi.hoisted(() => ({
4+
createProjectDirectory: vi.fn<(p: unknown) => Promise<{ path: string }>>(),
5+
pickFolder: vi.fn<(d?: string) => Promise<string | null>>(),
6+
addProject: vi.fn<(location: unknown, name?: string) => unknown>((location, name) => ({
7+
id: "p1",
8+
name: name ?? "x",
9+
location,
10+
createdAt: "t",
11+
})),
12+
openDraft: vi.fn<(id: string) => void>(),
13+
setLastUsedProjectDir: vi.fn<(key: string, dir: string) => void>(),
14+
autoDetectSetupScript: vi.fn<(project: unknown) => void>(),
15+
loadHomeScopeLocation: vi.fn<() => Promise<{ kind: string; path: string }>>(),
16+
lastUsedProjectDirs: {} as Record<string, string>,
17+
}));
18+
19+
const { createProjectDirectory, addProject, openDraft, setLastUsedProjectDir } = mocks;
20+
21+
vi.mock("@/renderer/bridge", () => ({
22+
readBridge: () => ({
23+
platform: "darwin",
24+
createProjectDirectory: mocks.createProjectDirectory,
25+
pickFolder: mocks.pickFolder,
26+
}),
27+
}));
28+
vi.mock("@/renderer/actions/projectActions", () => ({
29+
loadHomeScopeLocation: mocks.loadHomeScopeLocation,
30+
}));
31+
vi.mock("@/renderer/state/appStore", () => ({
32+
useAppStore: { getState: () => ({ addProject: mocks.addProject, openDraft: mocks.openDraft }) },
33+
}));
34+
vi.mock("@/renderer/state/sharedSettingsStore", () => ({
35+
useSharedSettings: {
36+
getState: () => ({
37+
setLastUsedProjectDir: mocks.setLastUsedProjectDir,
38+
lastUsedProjectDirs: mocks.lastUsedProjectDirs,
39+
}),
40+
},
41+
}));
42+
vi.mock("@/renderer/utils/gitHelpers", () => ({
43+
autoDetectSetupScript: mocks.autoDetectSetupScript,
44+
}));
45+
46+
import { addExistingProject, commitCreateProject } from "./createProjectActions";
47+
48+
describe("commitCreateProject", () => {
49+
beforeEach(() => {
50+
vi.clearAllMocks();
51+
});
52+
53+
test("existing folder: adds the project and records its parent as last-used", async () => {
54+
await commitCreateProject({
55+
mode: "existing",
56+
choice: { kind: "native" },
57+
dir: "/Users/me/code/app",
58+
name: "app",
59+
});
60+
61+
expect(createProjectDirectory).not.toHaveBeenCalled();
62+
expect(addProject).toHaveBeenCalledWith({ kind: "posix", path: "/Users/me/code/app" }, "app");
63+
expect(setLastUsedProjectDir).toHaveBeenCalledWith("native", "/Users/me/code");
64+
expect(openDraft).toHaveBeenCalledWith("p1");
65+
});
66+
67+
test("scratch: creates the directory, then adds the project at the returned path", async () => {
68+
createProjectDirectory.mockResolvedValue({ path: "/Users/me/code/new" });
69+
70+
await commitCreateProject({
71+
mode: "scratch",
72+
choice: { kind: "native" },
73+
dir: "/Users/me/code",
74+
name: "new",
75+
});
76+
77+
expect(createProjectDirectory).toHaveBeenCalledWith({
78+
parent: "/Users/me/code",
79+
name: "new",
80+
kind: "posix",
81+
});
82+
expect(addProject).toHaveBeenCalledWith({ kind: "posix", path: "/Users/me/code/new" }, "new");
83+
// scratch records the parent the user browsed, not the new folder.
84+
expect(setLastUsedProjectDir).toHaveBeenCalledWith("native", "/Users/me/code");
85+
});
86+
87+
test("scratch failure propagates and does not add a project", async () => {
88+
createProjectDirectory.mockRejectedValue(
89+
new Error('A folder named "new" already exists here.'),
90+
);
91+
92+
await expect(
93+
commitCreateProject({
94+
mode: "scratch",
95+
choice: { kind: "native" },
96+
dir: "/Users/me/code",
97+
name: "new",
98+
}),
99+
).rejects.toThrow(/already exists/i);
100+
101+
expect(addProject).not.toHaveBeenCalled();
102+
expect(setLastUsedProjectDir).not.toHaveBeenCalled();
103+
});
104+
});
105+
106+
describe("addExistingProject", () => {
107+
beforeEach(() => {
108+
vi.clearAllMocks();
109+
mocks.lastUsedProjectDirs = {};
110+
mocks.loadHomeScopeLocation.mockResolvedValue({ kind: "posix", path: "/Users/me" });
111+
});
112+
113+
test("opens the picker at home when no last-used dir, then adds the picked folder", async () => {
114+
mocks.pickFolder.mockResolvedValue("/Users/me/code/app");
115+
116+
await addExistingProject();
117+
118+
expect(mocks.pickFolder).toHaveBeenCalledWith("/Users/me");
119+
expect(addProject).toHaveBeenCalledWith(
120+
{ kind: "posix", path: "/Users/me/code/app" },
121+
undefined,
122+
);
123+
expect(setLastUsedProjectDir).toHaveBeenCalledWith("native", "/Users/me/code");
124+
expect(createProjectDirectory).not.toHaveBeenCalled();
125+
});
126+
127+
test("opens the picker at the last-used native dir when present", async () => {
128+
mocks.lastUsedProjectDirs = { native: "/Users/me/projects" };
129+
mocks.pickFolder.mockResolvedValue("/Users/me/projects/app");
130+
131+
await addExistingProject();
132+
133+
expect(mocks.pickFolder).toHaveBeenCalledWith("/Users/me/projects");
134+
expect(mocks.loadHomeScopeLocation).not.toHaveBeenCalled();
135+
});
136+
137+
test("does nothing when the picker is cancelled", async () => {
138+
mocks.pickFolder.mockResolvedValue(null);
139+
140+
await addExistingProject();
141+
142+
expect(addProject).not.toHaveBeenCalled();
143+
expect(setLastUsedProjectDir).not.toHaveBeenCalled();
144+
});
145+
});

0 commit comments

Comments
 (0)