From 5e757a58f17d2ab246df12bf44178ae8da855462 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Mon, 8 Jun 2026 22:52:27 -0700 Subject: [PATCH] feat(create-project): add project creation flow - 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 --- .agents/docs/ui-patterns.md | 9 + .../specs/2026-06-08-create-project-design.md | 90 +++++++ src/main/ipc/localHandlers.ts | 2 + src/main/projectDirectory.test.ts | 57 ++++ src/main/projectDirectory.ts | 53 ++++ src/main/sharedSettingsFile.test.ts | 2 + .../actions/createProjectActions.test.ts | 145 ++++++++++ src/renderer/actions/createProjectActions.ts | 84 ++++++ src/renderer/hooks/useWslDetection.ts | 38 --- src/renderer/state/panelStore.test.ts | 21 ++ src/renderer/state/panelStore.ts | 9 + .../state/sharedSettingsStore.test.ts | 18 ++ src/renderer/state/sharedSettingsStore.ts | 8 + src/renderer/views/MainView/MainView.tsx | 7 +- .../views/MainView/parts/AppOverlays.tsx | 2 + .../CreateProject/CreateProjectMenu.test.tsx | 50 ++++ .../parts/CreateProject/CreateProjectMenu.tsx | 50 ++++ .../CreateProject/CreateProjectModal.test.tsx | 116 ++++++++ .../CreateProject/CreateProjectModal.tsx | 252 ++++++++++++++++++ .../views/MainView/parts/MainPageLayout.tsx | 6 +- .../MainView/parts/SidebarHeaderControls.tsx | 94 +------ src/renderer/views/WelcomeOverlay.tsx | 41 +-- src/shared/createProject.test.ts | 217 +++++++++++++++ src/shared/createProject.ts | 125 +++++++++ src/shared/ipc/procedureMap.ts | 1 + src/shared/ipc/procedures/app.ts | 7 + src/shared/ipc/schemas.ts | 13 + src/shared/settings.ts | 7 + src/shared/wsl.test.ts | 14 + src/shared/wsl.ts | 8 +- 30 files changed, 1379 insertions(+), 167 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-08-create-project-design.md create mode 100644 src/main/projectDirectory.test.ts create mode 100644 src/main/projectDirectory.ts create mode 100644 src/renderer/actions/createProjectActions.test.ts create mode 100644 src/renderer/actions/createProjectActions.ts delete mode 100644 src/renderer/hooks/useWslDetection.ts create mode 100644 src/renderer/views/MainView/parts/CreateProject/CreateProjectMenu.test.tsx create mode 100644 src/renderer/views/MainView/parts/CreateProject/CreateProjectMenu.tsx create mode 100644 src/renderer/views/MainView/parts/CreateProject/CreateProjectModal.test.tsx create mode 100644 src/renderer/views/MainView/parts/CreateProject/CreateProjectModal.tsx create mode 100644 src/shared/createProject.test.ts create mode 100644 src/shared/createProject.ts diff --git a/.agents/docs/ui-patterns.md b/.agents/docs/ui-patterns.md index 2477f9d2..3daaed7f 100644 --- a/.agents/docs/ui-patterns.md +++ b/.agents/docs/ui-patterns.md @@ -25,6 +25,15 @@ Before creating a new component, check if an existing one handles the use case. - **`BranchSelector`** handles branch picking and worktree creation in `ThreadDraftView`. Reuse it for any branch-related UI. - **`OptionMenu`** is the dropdown for model/effort/permission selections. It supports custom label formatters via the provider registry. +### Dialogs + +Match the canonical dialog look — do not restyle. Reference: `CreatePrModal`, `ContinueInProviderDialog`. + +- **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`. +- **Footer buttons:** Cancel is a **muted ghost** — ``. The confirm/primary action is the **white tertiary** — `variant="tertiary"`. Do **not** use `variant="primary"` for the action in these dialogs. +- **Destructive confirms:** use the shared `ConfirmDialog` (`AlertDialog`) with `confirmVariant="danger"`; its Cancel is `variant="tertiary"` by convention. +- 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). + ## ACP Composer Behavior - **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. diff --git a/docs/superpowers/specs/2026-06-08-create-project-design.md b/docs/superpowers/specs/2026-06-08-create-project-design.md new file mode 100644 index 00000000..cae63765 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-create-project-design.md @@ -0,0 +1,90 @@ +# Create Project — Design + +Date: 2026-06-08 + +## Goal + +Implement a unified "create project" flow with: + +- A `+` menu offering **Start from scratch** and **Use an existing folder**. +- A runtime picker for **Native / WSL** (WSL shown only when distros exist). +- A folder picker whose default is preselected as **last-used → home**, scoped **per runtime**. + +## Decisions (confirmed) + +- **Start from scratch** = name the project → pick runtime + parent folder (in a custom modal) → create `/` on disk (`mkdir`) → open it as the new project. +- **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. +- **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. +- **Last-used directory** is remembered **per runtime** (`"native"` or the WSL distro name), falling back to that runtime's home. +- Both the sidebar `+` and the `WelcomeOverlay` CTA route through the same flow (default decision for consistency). + +## Current state (baseline) + +- `SidebarHeaderControls.tsx`: `FolderPlus` button. Windows → dropdown ("Add Windows Project" / "Add WSL Project"); macOS/Linux → direct `pickFolder()`. Flow: `pickFolder()` → `addProject(location)` → `autoDetectSetupScript` → `openDraft`. +- `WelcomeOverlay.tsx`: "Add Project" → `pickFolder()` → `addProject`. +- `projectSlice.addProject(location, nameOverride?)` — `nameOverride` exists but is unused. +- `ProjectLocation` (`src/shared/contracts/common.ts`): discriminated union `windows | wsl | posix`. +- Helpers (`src/shared/wsl.ts`): `parseWslUncPath`, `getProjectName`, `toWslUncPath`, `getProjectFsPath`. +- IPC: `pickFolder(defaultPath?)`, `listWslDistros()` already exist (`procedures/app.ts`, `localHandlers.ts`). +- 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.** + +The screenshots' "Start from scratch" / "Use an existing folder" menu and "Name project" modal do **not** exist in code yet — they are the target. + +## Architecture + +### Entry points + +- Sidebar `+` (`SidebarHeaderControls`) and the `WelcomeOverlay` CTA both render `CreateProjectMenu` (two items). +- "Start from scratch" → `panelStore.openCreateProjectModal()` (the scratch modal, mounted once in `AppOverlays`). +- "Use an existing folder" → `addExistingProject()` → native `pickFolder` → create project. No modal. + +### `CreateProjectModal` (scratch only) + +HeroUI `Modal` (mirrors `CreatePrModal`). Fields: + +- **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. +- **Location** (read-only path + **Browse** → `pickFolder(defaultPath)`): + - scratch: "Parent folder". + - existing: "Folder". + - Default on open / runtime change: `lastUsedProjectDirs[runtimeKey]` → else runtime home (`homeDir` native; `\\wsl.localhost\\home` for WSL). +- **Name** (text input, placeholder "New project"): + - scratch: required; legal single path segment. + - existing: prefilled with basename of picked folder; editable; display-name only. +- **Footer**: Cancel / Save. Save disabled until valid. Scratch shows a live preview of the final path. + +`runtimeKey` = `"native"` for windows/posix native, else the distro name. + +### Save behavior + +1. Derive `ProjectLocation` from the final path (`deriveLocationFromPath`): `parseWslUncPath` succeeds → `wsl`; else `isWindows()` → `windows`; else `posix`. The picked path is authoritative. +2. scratch: `createProjectDirectory({ parent, name, kind })` → returns created absolute path → derive location from it. +3. `addProject(location, name)` → `autoDetectSetupScript(project)` → `openDraft(project.id)`. +4. `setLastUsedProjectDir(runtimeKey, parentDir)` where `parentDir` is the directory the user browsed (scratch: the parent field; existing: `dirname(folder)`). +5. Guards (inline modal errors, not silent fallbacks): scratch+WSL requires a `\\wsl...` parent; scratch+native rejects a WSL UNC parent. + +### Persistence & IPC + +- `sharedSettingsSchema` + `defaultSharedSettings`: add `lastUsedProjectDirs: Record` (default `{}`). +- `sharedSettingsStore`: add `setLastUsedProjectDir(runtimeKey, dir)` mirroring `pushRecentModel` (merge → `persistSettings`). +- Native home: reuse the existing `getHomeScopeLocation()` IPC (cached via `loadHomeScopeLocation()`) — no preload change needed. WSL home is `\\wsl.localhost\\home`. +- 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. +- Reuse `pickFolder`, `listWslDistros`, `parseWslUncPath`, `getProjectName`. + +### State / components / files + +- `panelStore`: `createProjectModal: { open, mode }` + `openCreateProject(mode)` / `closeCreateProject()`. +- New files: `CreateProjectMenu`, `CreateProjectModal`, `useCreateProjectFlow` controller (with pure `deriveLocationFromPath`, name validation, target-path build). +- Edits: `SidebarHeaderControls.tsx`, `WelcomeOverlay.tsx`, `settings.ts`, `sharedSettingsStore.ts`, preload + bridge type, IPC procedure map + handler, `AppOverlays`. + +## Testing (TDD) + +- Pure unit: `deriveLocationFromPath` (UNC→wsl / win→windows / posix), legal-name validation, scratch target-path build, runtime/parent mismatch guards. +- Settings store: `setLastUsedProjectDir` merge + persist. +- Main handler: `createProjectDirectory` mkdir + conflict error. +- Component: `CreateProjectModal` — Save gating, runtime-selector visibility, scratch path preview. + +## Out of scope + +- Cloning a repo from a URL. +- Multi-folder / monorepo workspace projects. +- Renaming/migrating existing projects' runtimes. diff --git a/src/main/ipc/localHandlers.ts b/src/main/ipc/localHandlers.ts index ed3a6b15..28fa7c04 100644 --- a/src/main/ipc/localHandlers.ts +++ b/src/main/ipc/localHandlers.ts @@ -25,6 +25,7 @@ import { saveClipboardImageFile, saveHandoffContextFile, } from "../attachments/localFiles"; +import { createProjectDirectory } from "../projectDirectory"; import { readSharedSettingsFile, writeSharedSettingsFile } from "../sharedSettingsFile"; import { readKeybindingsFile } from "../keybindingsFile"; import type { AutoUpdaterController } from "../updates/autoUpdater"; @@ -102,6 +103,7 @@ export function createLocalIpcHandlers( saveClipboardImageFile(options.requireLightcodePaths(), payload), saveHandoffContext: (payload) => saveHandoffContextFile(options.requireLightcodePaths(), payload), + createProjectDirectory: (payload) => createProjectDirectory(payload), openExternal: async (url) => { const safeUrl = assertSafeExternalUrl(url); const browserPanel = options.getBrowserPanelManager(); diff --git a/src/main/projectDirectory.test.ts b/src/main/projectDirectory.test.ts new file mode 100644 index 00000000..2e2c4917 --- /dev/null +++ b/src/main/projectDirectory.test.ts @@ -0,0 +1,57 @@ +import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { createProjectDirectory, describeMkdirError } from "./projectDirectory"; + +describe("createProjectDirectory", () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "lc-create-project-")); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + test("creates the folder under the parent and returns its path", async () => { + const result = await createProjectDirectory({ parent: root, name: "new-app", kind: "posix" }); + + const expected = join(root, "new-app"); + expect(result.path).toBe(expected); + expect(existsSync(expected)).toBe(true); + expect(statSync(expected).isDirectory()).toBe(true); + }); + + test("throws when a folder with that name already exists", async () => { + await createProjectDirectory({ parent: root, name: "dup", kind: "posix" }); + + await expect( + createProjectDirectory({ parent: root, name: "dup", kind: "posix" }), + ).rejects.toThrow(/already exists/i); + }); + + test("surfaces a friendly message when the parent does not exist", async () => { + await expect( + createProjectDirectory({ parent: join(root, "missing"), name: "app", kind: "posix" }), + ).rejects.toThrow(/no longer exists/i); + }); +}); + +describe("describeMkdirError", () => { + test("maps permission errors", () => { + expect(describeMkdirError({ code: "EACCES" }, "x")).toMatch(/permission/i); + expect(describeMkdirError({ code: "EPERM" }, "x")).toMatch(/permission/i); + }); + + test("maps out-of-space, missing-parent and not-a-directory codes", () => { + expect(describeMkdirError({ code: "ENOSPC" }, "x")).toMatch(/disk space/i); + expect(describeMkdirError({ code: "ENOENT" }, "x")).toMatch(/no longer exists/i); + expect(describeMkdirError({ code: "ENOTDIR" }, "x")).toMatch(/not a folder/i); + }); + + test("falls back to the raw error message", () => { + expect(describeMkdirError(new Error("boom"), "x")).toBe("boom"); + }); +}); diff --git a/src/main/projectDirectory.ts b/src/main/projectDirectory.ts new file mode 100644 index 00000000..edaadfbb --- /dev/null +++ b/src/main/projectDirectory.ts @@ -0,0 +1,53 @@ +import { mkdir } from "node:fs/promises"; +import { posix, win32 } from "node:path"; +import type { ScratchKind } from "@/shared/createProject"; + +export interface CreateProjectDirectoryPayload { + /** Absolute parent directory (native path, or a `\\wsl...` UNC path). */ + parent: string; + /** New folder name (already validated by the renderer). */ + name: string; + kind: ScratchKind; +} + +/** Translate a Node `mkdir` error into a message fit to show the user. */ +export function describeMkdirError(error: unknown, name: string): string { + const code = (error as NodeJS.ErrnoException | null)?.code; + switch (code) { + case "EACCES": + case "EPERM": + return `You don't have permission to create "${name}" there.`; + case "ENOSPC": + return "There isn't enough disk space to create the folder."; + case "ENOENT": + return "That parent folder no longer exists. Pick another location."; + case "ENOTDIR": + return "The chosen location is not a folder."; + case "EEXIST": + return `A folder named "${name}" already exists here.`; + default: + return error instanceof Error ? error.message : `Couldn't create "${name}".`; + } +} + +/** + * Create the directory for a "start from scratch" project. Joins with the + * separator appropriate to the location kind (posix uses `/`; windows and WSL + * UNC paths use `\`), refusing to clobber an existing folder. The parent must + * already exist — `mkdir` is non-recursive so a stale/removed parent (ENOENT) + * or an existing target (EEXIST) surfaces as a clear error rather than silently + * materializing the path elsewhere or clobbering it. + */ +export async function createProjectDirectory( + payload: CreateProjectDirectoryPayload, +): Promise<{ path: string }> { + const join = payload.kind === "posix" ? posix.join : win32.join; + const target = join(payload.parent, payload.name); + + try { + await mkdir(target); + } catch (error) { + throw new Error(describeMkdirError(error, payload.name), { cause: error }); + } + return { path: target }; +} diff --git a/src/main/sharedSettingsFile.test.ts b/src/main/sharedSettingsFile.test.ts index 44c99c60..1d843a37 100644 --- a/src/main/sharedSettingsFile.test.ts +++ b/src/main/sharedSettingsFile.test.ts @@ -72,6 +72,7 @@ describe("sharedSettingsFile", () => { gitReviewMode: "panel", providerConfigs: {}, lastPresentationModeByAgent: {}, + lastUsedProjectDirs: {}, editorLspEnabled: false, searchUseIgnoreFiles: true, searchExclude: {}, @@ -155,6 +156,7 @@ describe("sharedSettingsFile", () => { gitReviewMode: "panel", providerConfigs: {}, lastPresentationModeByAgent: {}, + lastUsedProjectDirs: {}, editorLspEnabled: false, searchUseIgnoreFiles: true, searchExclude: {}, diff --git a/src/renderer/actions/createProjectActions.test.ts b/src/renderer/actions/createProjectActions.test.ts new file mode 100644 index 00000000..93f4a385 --- /dev/null +++ b/src/renderer/actions/createProjectActions.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createProjectDirectory: vi.fn<(p: unknown) => Promise<{ path: string }>>(), + pickFolder: vi.fn<(d?: string) => Promise>(), + addProject: vi.fn<(location: unknown, name?: string) => unknown>((location, name) => ({ + id: "p1", + name: name ?? "x", + location, + createdAt: "t", + })), + openDraft: vi.fn<(id: string) => void>(), + setLastUsedProjectDir: vi.fn<(key: string, dir: string) => void>(), + autoDetectSetupScript: vi.fn<(project: unknown) => void>(), + loadHomeScopeLocation: vi.fn<() => Promise<{ kind: string; path: string }>>(), + lastUsedProjectDirs: {} as Record, +})); + +const { createProjectDirectory, addProject, openDraft, setLastUsedProjectDir } = mocks; + +vi.mock("@/renderer/bridge", () => ({ + readBridge: () => ({ + platform: "darwin", + createProjectDirectory: mocks.createProjectDirectory, + pickFolder: mocks.pickFolder, + }), +})); +vi.mock("@/renderer/actions/projectActions", () => ({ + loadHomeScopeLocation: mocks.loadHomeScopeLocation, +})); +vi.mock("@/renderer/state/appStore", () => ({ + useAppStore: { getState: () => ({ addProject: mocks.addProject, openDraft: mocks.openDraft }) }, +})); +vi.mock("@/renderer/state/sharedSettingsStore", () => ({ + useSharedSettings: { + getState: () => ({ + setLastUsedProjectDir: mocks.setLastUsedProjectDir, + lastUsedProjectDirs: mocks.lastUsedProjectDirs, + }), + }, +})); +vi.mock("@/renderer/utils/gitHelpers", () => ({ + autoDetectSetupScript: mocks.autoDetectSetupScript, +})); + +import { addExistingProject, commitCreateProject } from "./createProjectActions"; + +describe("commitCreateProject", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("existing folder: adds the project and records its parent as last-used", async () => { + await commitCreateProject({ + mode: "existing", + choice: { kind: "native" }, + dir: "/Users/me/code/app", + name: "app", + }); + + expect(createProjectDirectory).not.toHaveBeenCalled(); + expect(addProject).toHaveBeenCalledWith({ kind: "posix", path: "/Users/me/code/app" }, "app"); + expect(setLastUsedProjectDir).toHaveBeenCalledWith("native", "/Users/me/code"); + expect(openDraft).toHaveBeenCalledWith("p1"); + }); + + test("scratch: creates the directory, then adds the project at the returned path", async () => { + createProjectDirectory.mockResolvedValue({ path: "/Users/me/code/new" }); + + await commitCreateProject({ + mode: "scratch", + choice: { kind: "native" }, + dir: "/Users/me/code", + name: "new", + }); + + expect(createProjectDirectory).toHaveBeenCalledWith({ + parent: "/Users/me/code", + name: "new", + kind: "posix", + }); + expect(addProject).toHaveBeenCalledWith({ kind: "posix", path: "/Users/me/code/new" }, "new"); + // scratch records the parent the user browsed, not the new folder. + expect(setLastUsedProjectDir).toHaveBeenCalledWith("native", "/Users/me/code"); + }); + + test("scratch failure propagates and does not add a project", async () => { + createProjectDirectory.mockRejectedValue( + new Error('A folder named "new" already exists here.'), + ); + + await expect( + commitCreateProject({ + mode: "scratch", + choice: { kind: "native" }, + dir: "/Users/me/code", + name: "new", + }), + ).rejects.toThrow(/already exists/i); + + expect(addProject).not.toHaveBeenCalled(); + expect(setLastUsedProjectDir).not.toHaveBeenCalled(); + }); +}); + +describe("addExistingProject", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.lastUsedProjectDirs = {}; + mocks.loadHomeScopeLocation.mockResolvedValue({ kind: "posix", path: "/Users/me" }); + }); + + test("opens the picker at home when no last-used dir, then adds the picked folder", async () => { + mocks.pickFolder.mockResolvedValue("/Users/me/code/app"); + + await addExistingProject(); + + expect(mocks.pickFolder).toHaveBeenCalledWith("/Users/me"); + expect(addProject).toHaveBeenCalledWith( + { kind: "posix", path: "/Users/me/code/app" }, + undefined, + ); + expect(setLastUsedProjectDir).toHaveBeenCalledWith("native", "/Users/me/code"); + expect(createProjectDirectory).not.toHaveBeenCalled(); + }); + + test("opens the picker at the last-used native dir when present", async () => { + mocks.lastUsedProjectDirs = { native: "/Users/me/projects" }; + mocks.pickFolder.mockResolvedValue("/Users/me/projects/app"); + + await addExistingProject(); + + expect(mocks.pickFolder).toHaveBeenCalledWith("/Users/me/projects"); + expect(mocks.loadHomeScopeLocation).not.toHaveBeenCalled(); + }); + + test("does nothing when the picker is cancelled", async () => { + mocks.pickFolder.mockResolvedValue(null); + + await addExistingProject(); + + expect(addProject).not.toHaveBeenCalled(); + expect(setLastUsedProjectDir).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/actions/createProjectActions.ts b/src/renderer/actions/createProjectActions.ts new file mode 100644 index 00000000..d7ff8931 --- /dev/null +++ b/src/renderer/actions/createProjectActions.ts @@ -0,0 +1,84 @@ +import { startTransition } from "react"; +import type { ProjectLocation } from "@/shared/contracts"; +import { + deriveLocationFromPath, + parentDirOf, + runtimeKeyForLocation, + scratchKindForChoice, + type RuntimeChoice, +} from "@/shared/createProject"; +import { getProjectFsPath } from "@/shared/wsl"; +import { readBridge } from "@/renderer/bridge"; +import { loadHomeScopeLocation } from "@/renderer/actions/projectActions"; +import { useAppStore } from "@/renderer/state/appStore"; +import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; +import { autoDetectSetupScript } from "@/renderer/utils/gitHelpers"; + +/** Whether a project is being created from scratch or opened from an existing folder. */ +export type CreateProjectMode = "scratch" | "existing"; + +export interface CommitCreateProjectParams { + mode: CreateProjectMode; + choice: RuntimeChoice; + /** "scratch" → the parent directory; "existing" → the chosen project folder. */ + dir: string; + name: string; +} + +/** + * Finalize project creation: for "scratch" create the directory on disk first, + * then add the project, remember the browsed parent per runtime, and open its + * draft. Errors (e.g. a folder that already exists) propagate to the caller so + * the modal can surface them instead of failing silently. + */ +export async function commitCreateProject(params: CommitCreateProjectParams): Promise { + const platform = readBridge().platform; + const name = params.name.trim(); + + let location: ProjectLocation; + let lastUsedDir: string; + + if (params.mode === "scratch") { + const kind = scratchKindForChoice(params.choice, platform); + const { path } = await readBridge().createProjectDirectory({ parent: params.dir, name, kind }); + location = deriveLocationFromPath(path, platform); + lastUsedDir = params.dir; + } else { + location = deriveLocationFromPath(params.dir, platform); + lastUsedDir = parentDirOf(params.dir, location.kind); + } + + useSharedSettings.getState().setLastUsedProjectDir(runtimeKeyForLocation(location), lastUsedDir); + + startTransition(() => { + const project = useAppStore.getState().addProject(location, name || undefined); + autoDetectSetupScript(project); + useAppStore.getState().openDraft(project.id); + }); +} + +/** + * "Use an existing folder": open the native folder picker directly (no modal) + * and add the chosen directory as a project, just like the original flow. The + * picked path is authoritative for the runtime — a `\\wsl...` path becomes a + * WSL project, anything else a native one. The dialog opens at the last-used + * native directory, falling back to home. + */ +export async function addExistingProject(): Promise { + let defaultDir = useSharedSettings.getState().lastUsedProjectDirs.native; + if (!defaultDir) { + defaultDir = await loadHomeScopeLocation() + .then(getProjectFsPath) + .catch(() => undefined); + } + + const picked = await readBridge().pickFolder(defaultDir); + if (!picked) return; + + await commitCreateProject({ + mode: "existing", + choice: { kind: "native" }, + dir: picked, + name: "", + }); +} diff --git a/src/renderer/hooks/useWslDetection.ts b/src/renderer/hooks/useWslDetection.ts deleted file mode 100644 index bf93caf5..00000000 --- a/src/renderer/hooks/useWslDetection.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { startTransition, useEffect, useState } from "react"; -import { readBridge } from "@/renderer/bridge"; - -export function useWslDetection(storeHydrated: boolean) { - const [wslAvailable, setWslAvailable] = useState(false); - - useEffect(() => { - if (!storeHydrated) { - return; - } - - let isActive = true; - void readBridge() - .listWslDistros() - .then((distros) => { - if (!isActive) { - return; - } - startTransition(() => { - setWslAvailable(distros.length > 0); - }); - }) - .catch(() => { - if (!isActive) { - return; - } - startTransition(() => { - setWslAvailable(false); - }); - }); - - return () => { - isActive = false; - }; - }, [storeHydrated]); - - return { wslAvailable }; -} diff --git a/src/renderer/state/panelStore.test.ts b/src/renderer/state/panelStore.test.ts index 70449c70..abd32cf0 100644 --- a/src/renderer/state/panelStore.test.ts +++ b/src/renderer/state/panelStore.test.ts @@ -85,6 +85,27 @@ describe("selectAnyObstructingOverlayOpen", () => { }); }); +describe("create project modal", () => { + beforeEach(() => { + resetPanelStore(); + }); + afterEach(() => { + resetPanelStore(); + }); + + it("is closed by default", () => { + expect(usePanelStore.getState().createProjectModalOpen).toBe(false); + }); + + it("opens and closes the scratch modal", () => { + usePanelStore.getState().openCreateProjectModal(); + expect(usePanelStore.getState().createProjectModalOpen).toBe(true); + + usePanelStore.getState().closeCreateProjectModal(); + expect(usePanelStore.getState().createProjectModalOpen).toBe(false); + }); +}); + describe("browserOverlayMaximized lifecycle", () => { beforeEach(() => { resetPanelStore(); diff --git a/src/renderer/state/panelStore.ts b/src/renderer/state/panelStore.ts index 17ec7a42..d48487c9 100644 --- a/src/renderer/state/panelStore.ts +++ b/src/renderer/state/panelStore.ts @@ -40,6 +40,8 @@ interface PanelState { projectSettingsId: string | null; threadSortMode: ThreadSortMode; threadSearchOpen: boolean; + /** Whether the "Start from scratch" create-project modal is open. */ + createProjectModalOpen: boolean; setGitReviewContext: (ctx: GitReviewContext | null) => void; setThreadSortMode: (mode: ThreadSortMode) => void; setGitReviewAsPanel: (v: boolean) => void; @@ -62,6 +64,8 @@ interface PanelState { closeProjectSettings: () => void; openThreadSearch: () => void; closeThreadSearch: () => void; + openCreateProjectModal: () => void; + closeCreateProjectModal: () => void; closeAllPanels: () => void; } @@ -113,6 +117,7 @@ export const usePanelStore = create((set) => ({ projectSettingsId: null, threadSortMode: "updated", threadSearchOpen: false, + createProjectModalOpen: false, setGitReviewContext: (ctx) => { const prev = usePanelStore.getState().gitReviewContext; @@ -238,6 +243,10 @@ export const usePanelStore = create((set) => ({ set((state) => (state.threadSearchOpen ? {} : { threadSearchOpen: true })), closeThreadSearch: () => set((state) => (state.threadSearchOpen ? { threadSearchOpen: false } : {})), + openCreateProjectModal: () => + set((state) => (state.createProjectModalOpen ? {} : { createProjectModalOpen: true })), + closeCreateProjectModal: () => + set((state) => (state.createProjectModalOpen ? { createProjectModalOpen: false } : {})), closeAllPanels: () => { localStorage.removeItem(STORAGE_KEY); set((state) => { diff --git a/src/renderer/state/sharedSettingsStore.test.ts b/src/renderer/state/sharedSettingsStore.test.ts index 5d51d7b7..c305a17f 100644 --- a/src/renderer/state/sharedSettingsStore.test.ts +++ b/src/renderer/state/sharedSettingsStore.test.ts @@ -15,6 +15,7 @@ describe("sharedSettingsStore", () => { useWebGpu: true, }, providerConfigs: {}, + lastUsedProjectDirs: {}, }); }); @@ -62,4 +63,21 @@ describe("sharedSettingsStore", () => { thinking: true, }); }); + + it("records the last-used project directory per runtime key", () => { + useSharedSettings.getState().setLastUsedProjectDir("native", "/Users/me/code"); + useSharedSettings.getState().setLastUsedProjectDir("Ubuntu", "\\\\wsl.localhost\\Ubuntu\\home"); + + expect(useSharedSettings.getState().lastUsedProjectDirs).toEqual({ + native: "/Users/me/code", + Ubuntu: "\\\\wsl.localhost\\Ubuntu\\home", + }); + }); + + it("overwrites the directory for an existing runtime key", () => { + useSharedSettings.getState().setLastUsedProjectDir("native", "/Users/me/a"); + useSharedSettings.getState().setLastUsedProjectDir("native", "/Users/me/b"); + + expect(useSharedSettings.getState().lastUsedProjectDirs.native).toBe("/Users/me/b"); + }); }); diff --git a/src/renderer/state/sharedSettingsStore.ts b/src/renderer/state/sharedSettingsStore.ts index a86acd81..b327b867 100644 --- a/src/renderer/state/sharedSettingsStore.ts +++ b/src/renderer/state/sharedSettingsStore.ts @@ -71,6 +71,7 @@ interface SharedSettingsState extends SharedSettings { ) => void; setProviderConfig: (agentKind: string, config: ProviderDraftConfig) => void; setLastPresentationMode: (agentKind: string, mode: ThreadPresentationMode) => void; + setLastUsedProjectDir: (runtimeKey: string, dir: string) => void; setNotificationsEnabled: (value: boolean) => void; setNotificationSound: (value: boolean) => void; setNotificationFilter: (value: NotificationFilter) => void; @@ -364,6 +365,12 @@ export const useSharedSettings = create()((set, get) => ({ set({ lastPresentationModeByAgent: { ...current, [agentKind]: mode } }); persistSettings(selectSharedSettings(get())); }, + setLastUsedProjectDir: (runtimeKey, dir) => { + const current = get().lastUsedProjectDirs; + if (current[runtimeKey] === dir) return; + set({ lastUsedProjectDirs: { ...current, [runtimeKey]: dir } }); + persistSettings(selectSharedSettings(get())); + }, setNotificationsEnabled: (notificationsEnabled) => { if (get().notificationsEnabled === notificationsEnabled) return; set({ notificationsEnabled }); @@ -514,6 +521,7 @@ function selectSharedSettings(state: SharedSettingsState): SharedSettingsInput { gitReviewMode: state.gitReviewMode, providerConfigs: state.providerConfigs, lastPresentationModeByAgent: state.lastPresentationModeByAgent, + lastUsedProjectDirs: state.lastUsedProjectDirs, editorLspEnabled: state.editorLspEnabled, searchUseIgnoreFiles: state.searchUseIgnoreFiles, searchExclude: state.searchExclude, diff --git a/src/renderer/views/MainView/MainView.tsx b/src/renderer/views/MainView/MainView.tsx index 9c9c90b5..886e174a 100644 --- a/src/renderer/views/MainView/MainView.tsx +++ b/src/renderer/views/MainView/MainView.tsx @@ -11,7 +11,6 @@ import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { AppDndProvider } from "@/renderer/dnd"; import { useKeyboardShortcuts } from "@/renderer/hooks/useKeyboardShortcuts"; -import { useWslDetection } from "@/renderer/hooks/useWslDetection"; import { useGitRefresh } from "@/renderer/hooks/useGitRefresh"; import { useThreadLifecycle } from "@/renderer/hooks/useThreadLifecycle"; import { useDndHandlers } from "@/renderer/hooks/useDndHandlers"; @@ -39,7 +38,6 @@ export function MainView(props: { storeHydrated: boolean; loadT0: number }) { const sharedSettingsHydrated = useSharedSettings((state) => state.sharedSettingsHydrated); useThreadLifecycle(storeHydrated); - const { wslAvailable } = useWslDetection(storeHydrated); useKeyboardShortcuts(); useGitRefresh(storeHydrated); useBrowserSync(); @@ -105,10 +103,7 @@ export function MainView(props: { storeHydrated: boolean; loadT0: number }) { : buildPaneLayoutFromLegacy(["__placeholder__"]) } > - startTransition(() => openHome())} - /> + startTransition(() => openHome())} /> diff --git a/src/renderer/views/MainView/parts/AppOverlays.tsx b/src/renderer/views/MainView/parts/AppOverlays.tsx index 8cc67298..8ad28ad6 100644 --- a/src/renderer/views/MainView/parts/AppOverlays.tsx +++ b/src/renderer/views/MainView/parts/AppOverlays.tsx @@ -37,6 +37,7 @@ import type { UsageLoginConfirmationAction } from "@/shared/contracts"; import { WelcomeOverlay } from "@/renderer/views/WelcomeOverlay"; import { BrowserOverlay } from "@/renderer/views/MainView/parts/BrowserOverlay"; import { LoginTerminalOverlay } from "@/renderer/views/LoginTerminalOverlay/LoginTerminalOverlay"; +import { CreateProjectModal } from "@/renderer/views/MainView/parts/CreateProject/CreateProjectModal"; export function AppOverlays() { const projects = useAppStore((s) => s.projects); @@ -177,6 +178,7 @@ export function AppOverlays() { + ); } diff --git a/src/renderer/views/MainView/parts/CreateProject/CreateProjectMenu.test.tsx b/src/renderer/views/MainView/parts/CreateProject/CreateProjectMenu.test.tsx new file mode 100644 index 00000000..13b82d81 --- /dev/null +++ b/src/renderer/views/MainView/parts/CreateProject/CreateProjectMenu.test.tsx @@ -0,0 +1,50 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { Button } from "@heroui/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { usePanelStore } from "@/renderer/state/panelStore"; + +const mocks = vi.hoisted(() => ({ + addExistingProject: vi.fn<() => Promise>().mockResolvedValue(undefined), +})); + +vi.mock("@/renderer/actions/createProjectActions", () => ({ + addExistingProject: mocks.addExistingProject, +})); + +import { CreateProjectMenu } from "./CreateProjectMenu"; + +describe("CreateProjectMenu", () => { + beforeEach(() => { + vi.clearAllMocks(); + usePanelStore.setState({ createProjectModalOpen: false }); + }); + afterEach(() => usePanelStore.setState({ createProjectModalOpen: false })); + + it("opens the scratch modal when 'Start from scratch' is chosen", async () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Add project" })); + fireEvent.click(await screen.findByText("Start from scratch")); + + await waitFor(() => expect(usePanelStore.getState().createProjectModalOpen).toBe(true)); + expect(mocks.addExistingProject).not.toHaveBeenCalled(); + }); + + it("goes straight to the folder picker for 'Use an existing folder' (no modal)", async () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Add project" })); + fireEvent.click(await screen.findByText("Use an existing folder")); + + await waitFor(() => expect(mocks.addExistingProject).toHaveBeenCalledTimes(1)); + expect(usePanelStore.getState().createProjectModalOpen).toBe(false); + }); +}); diff --git a/src/renderer/views/MainView/parts/CreateProject/CreateProjectMenu.tsx b/src/renderer/views/MainView/parts/CreateProject/CreateProjectMenu.tsx new file mode 100644 index 00000000..9f65e9f4 --- /dev/null +++ b/src/renderer/views/MainView/parts/CreateProject/CreateProjectMenu.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from "react"; +import { FilePlus, FolderOpen } from "lucide-react"; +import { Dropdown, Label } from "@heroui/react"; +import { usePanelStore } from "@/renderer/state/panelStore"; +import { + addExistingProject, + type CreateProjectMode, +} from "@/renderer/actions/createProjectActions"; + +/** + * The "+" dropdown for creating a project. Wraps a caller-provided trigger + * (so it can sit in the sidebar header or the welcome screen). "Start from + * scratch" opens the create-project modal; "Use an existing folder" goes + * straight to the native folder picker, as it always has. `onSelect` fires + * before either action so callers can dismiss surrounding UI (e.g. the + * welcome overlay). + */ +export function CreateProjectMenu(props: { + children: ReactNode; + onSelect?: (mode: CreateProjectMode) => void; +}) { + return ( + + {props.children} + + { + const mode: CreateProjectMode = key === "scratch" ? "scratch" : "existing"; + props.onSelect?.(mode); + if (mode === "scratch") { + usePanelStore.getState().openCreateProjectModal(); + } else { + void addExistingProject(); + } + }} + > + + + + + + + + + + + + ); +} diff --git a/src/renderer/views/MainView/parts/CreateProject/CreateProjectModal.test.tsx b/src/renderer/views/MainView/parts/CreateProject/CreateProjectModal.test.tsx new file mode 100644 index 00000000..b2b3641b --- /dev/null +++ b/src/renderer/views/MainView/parts/CreateProject/CreateProjectModal.test.tsx @@ -0,0 +1,116 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + listWslDistros: vi.fn<() => Promise>().mockResolvedValue([]), + loadHomeScopeLocation: vi.fn<() => Promise<{ kind: string; path: string }>>(), + commitCreateProject: vi.fn<() => Promise>().mockResolvedValue(undefined), + pickFolder: vi.fn<(d?: string) => Promise>().mockResolvedValue(null), +})); + +vi.mock("@/renderer/bridge", () => ({ + readBridge: () => ({ + platform: "darwin", + listWslDistros: mocks.listWslDistros, + pickFolder: mocks.pickFolder, + }), + isWindows: () => false, +})); +vi.mock("@/renderer/actions/projectActions", () => ({ + loadHomeScopeLocation: mocks.loadHomeScopeLocation, +})); +vi.mock("@/renderer/actions/createProjectActions", () => ({ + commitCreateProject: mocks.commitCreateProject, +})); + +import { usePanelStore } from "@/renderer/state/panelStore"; +import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; +import { CreateProjectModal } from "./CreateProjectModal"; + +describe("CreateProjectModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.listWslDistros.mockResolvedValue([]); + mocks.loadHomeScopeLocation.mockResolvedValue({ kind: "posix", path: "/Users/me" }); + mocks.pickFolder.mockResolvedValue(null); + useSharedSettings.setState({ lastUsedProjectDirs: {} }); + usePanelStore.setState({ createProjectModalOpen: false }); + }); + + afterEach(() => { + usePanelStore.setState({ createProjectModalOpen: false }); + }); + + test("hides the runtime selector when no WSL distros exist", async () => { + usePanelStore.getState().openCreateProjectModal(); + render(); + + await waitFor(() => expect(screen.getByLabelText("Project name")).toBeInTheDocument()); + expect(screen.queryByLabelText("Runtime")).not.toBeInTheDocument(); + }); + + test("disables the create button until a valid name is entered (scratch)", async () => { + usePanelStore.getState().openCreateProjectModal(); + render(); + + // Parent prefilled from home once resolved. + await waitFor(() => expect(mocks.loadHomeScopeLocation).toHaveBeenCalled()); + + const createButton = screen.getByRole("button", { name: "Create project" }); + expect(createButton).toBeDisabled(); + + fireEvent.change(screen.getByLabelText("Project name"), { target: { value: "my-app" } }); + + await waitFor(() => expect(createButton).toBeEnabled()); + }); + + test("shows the full target path in the picker, with no separate preview line", async () => { + usePanelStore.getState().openCreateProjectModal(); + render(); + await waitFor(() => expect(mocks.loadHomeScopeLocation).toHaveBeenCalled()); + + const picker = screen.getByLabelText("Browse for parent folder"); + // Parent alone until a valid name is entered. + await waitFor(() => expect(picker).toHaveTextContent("/Users/me")); + expect(picker).not.toHaveTextContent("/Users/me/my-app"); + + fireEvent.change(screen.getByLabelText("Project name"), { target: { value: "my-app" } }); + + await waitFor(() => expect(picker).toHaveTextContent("/Users/me/my-app")); + expect(screen.queryByText(/Will create/i)).not.toBeInTheDocument(); + }); + + test("rejects an invalid name", async () => { + usePanelStore.getState().openCreateProjectModal(); + render(); + + await waitFor(() => expect(mocks.loadHomeScopeLocation).toHaveBeenCalled()); + fireEvent.change(screen.getByLabelText("Project name"), { target: { value: "a/b" } }); + + expect(screen.getByRole("button", { name: "Create project" })).toBeDisabled(); + }); + + test("keeps a browsed folder when shared-settings object identity changes (hydration)", async () => { + mocks.pickFolder.mockResolvedValue("/Users/me/projects/picked"); + usePanelStore.getState().openCreateProjectModal(); + render(); + await waitFor(() => expect(mocks.loadHomeScopeLocation).toHaveBeenCalled()); + + fireEvent.click(screen.getByLabelText("Browse for parent folder")); + await waitFor(() => + expect(screen.getByLabelText("Browse for parent folder")).toHaveTextContent( + "/Users/me/projects/picked", + ), + ); + + // Hydration replaces the lastUsedProjectDirs object with an equal-but-new + // reference; the user's picked folder must not be wiped. + act(() => { + useSharedSettings.setState({ lastUsedProjectDirs: {} }); + }); + + expect(screen.getByLabelText("Browse for parent folder")).toHaveTextContent( + "/Users/me/projects/picked", + ); + }); +}); diff --git a/src/renderer/views/MainView/parts/CreateProject/CreateProjectModal.tsx b/src/renderer/views/MainView/parts/CreateProject/CreateProjectModal.tsx new file mode 100644 index 00000000..39527ab7 --- /dev/null +++ b/src/renderer/views/MainView/parts/CreateProject/CreateProjectModal.tsx @@ -0,0 +1,252 @@ +import { useEffect, useState } from "react"; +import { ChevronDown, FolderOpen, Monitor } from "lucide-react"; +import { Button, Dropdown, Label, Modal } from "@heroui/react"; +import { Input, TuxIcon } from "@/renderer/components/common"; +import { + buildScratchTargetPath, + scratchKindForChoice, + splitPathLeaf, + validateProjectName, + validateScratchParent, + wslHomeDir, + type RuntimeChoice, +} from "@/shared/createProject"; +import { getProjectFsPath } from "@/shared/wsl"; +import { readBridge } from "@/renderer/bridge"; +import { loadHomeScopeLocation } from "@/renderer/actions/projectActions"; +import { commitCreateProject } from "@/renderer/actions/createProjectActions"; +import { usePanelStore } from "@/renderer/state/panelStore"; +import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; + +/** + * Modal for the "Start from scratch" flow: name a project, choose a runtime + * (Native / WSL when distros exist) and a parent folder, then create the new + * directory on disk. "Use an existing folder" does not use this modal — it goes + * straight to the native folder picker. + */ +export function CreateProjectModal() { + const open = usePanelStore((s) => s.createProjectModalOpen); + + return ( + { + if (!next) usePanelStore.getState().closeCreateProjectModal(); + }} + > + + + {open ? : null} + + + + ); +} + +function CreateProjectForm() { + const platform = readBridge().platform; + const lastUsedProjectDirs = useSharedSettings((s) => s.lastUsedProjectDirs); + + const [distros, setDistros] = useState([]); + const [runtimeKey, setRuntimeKey] = useState("native"); + const [defaultDir, setDefaultDir] = useState(""); + const [dir, setDir] = useState(""); + const [name, setName] = useState(""); + const [busy, setBusy] = useState(false); + const [submitError, setSubmitError] = useState(null); + + const choice: RuntimeChoice = + runtimeKey === "native" ? { kind: "native" } : { kind: "wsl", distro: runtimeKey }; + + useEffect(() => { + let active = true; + void readBridge() + .listWslDistros() + .then((list) => { + if (active) setDistros(list); + }) + .catch(() => undefined); + return () => { + active = false; + }; + }, []); + + // Switching runtime clears the user's pick. Keyed only on `runtimeKey` so an + // unrelated change to the settings object's identity (e.g. hydration) can't + // wipe a folder the user already browsed to. + useEffect(() => { + setDir(""); + setSubmitError(null); + }, [runtimeKey]); + + // Resolve the default browse directory (last-used → home) for the runtime. + // Depend on the resolved per-runtime value, not the whole map, so a + // new-but-equal map reference doesn't re-run this. + const lastForRuntime = lastUsedProjectDirs[runtimeKey]; + useEffect(() => { + let active = true; + if (lastForRuntime) { + setDefaultDir(lastForRuntime); + return; + } + if (runtimeKey !== "native") { + setDefaultDir(wslHomeDir(runtimeKey)); + return; + } + setDefaultDir(""); + void loadHomeScopeLocation() + .then((location) => { + if (active) setDefaultDir(getProjectFsPath(location)); + }) + .catch(() => undefined); + return () => { + active = false; + }; + }, [runtimeKey, lastForRuntime]); + + const scratchParent = dir || defaultDir; + const scratchKind = scratchKindForChoice(choice, platform); + const showRuntime = distros.length > 0; + + const nameError = validateProjectName(name); + const parentError = validateScratchParent(scratchParent, choice); + const validationError = nameError ?? parentError; + // Only surface a runtime/path *mismatch* inline (not the "nothing picked yet" + // case, which would flash an error while the home dir is still resolving). + const parentMismatch = scratchParent ? parentError : null; + const inlineError = submitError ?? parentMismatch; + + // Show the full target path right in the picker (no separate preview line, so + // the body height stays stable). Append the leaf only once the name is valid + // and the parent matches the runtime; otherwise show the parent alone. + const pickerPath = + scratchParent && !nameError && !parentMismatch && name.trim() + ? buildScratchTargetPath(scratchParent, name.trim(), scratchKind) + : scratchParent; + // Split for middle-ellipsis display so the leaf stays visible (see picker below). + const pickerLeaf = pickerPath ? splitPathLeaf(pickerPath) : null; + + async function handleBrowse() { + const picked = await readBridge().pickFolder(scratchParent || undefined); + if (!picked) return; + setDir(picked); + setSubmitError(null); + } + + async function handleSubmit() { + setBusy(true); + setSubmitError(null); + try { + await commitCreateProject({ mode: "scratch", choice, dir: scratchParent, name }); + usePanelStore.getState().closeCreateProjectModal(); + } catch (error) { + setSubmitError(error instanceof Error ? error.message : "Couldn't create the project."); + } finally { + setBusy(false); + } + } + + const runtimeLabel = runtimeKey === "native" ? "Native" : runtimeKey; + + return ( + <> + + + Start from scratch +

Name your project and choose where to create it.

+
+ +
+ + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !validationError && !busy) { + e.preventDefault(); + void handleSubmit(); + } + }} + /> +
+ + {showRuntime ? ( +
+ + + + + setRuntimeKey(String(key))} + > + + + + + {distros.map((distro) => ( + + + + + ))} + + + +
+ ) : null} + +
+ + +
+ + {inlineError ?

{inlineError}

: null} +
+ + + + + + ); +} diff --git a/src/renderer/views/MainView/parts/MainPageLayout.tsx b/src/renderer/views/MainView/parts/MainPageLayout.tsx index 34fe0331..b63b77bf 100644 --- a/src/renderer/views/MainView/parts/MainPageLayout.tsx +++ b/src/renderer/views/MainView/parts/MainPageLayout.tsx @@ -20,8 +20,8 @@ const FileEditorPanel = lazy(() => })), ); -export function MainPageLayout(props: { wslAvailable: boolean; onTitleClick: () => void }) { - const { wslAvailable, onTitleClick } = props; +export function MainPageLayout(props: { onTitleClick: () => void }) { + const { onTitleClick } = props; return ( } + sidebarHeaderChildren={} sidebar={} content={ diff --git a/src/renderer/views/MainView/parts/SidebarHeaderControls.tsx b/src/renderer/views/MainView/parts/SidebarHeaderControls.tsx index 5bdf908d..64df3852 100644 --- a/src/renderer/views/MainView/parts/SidebarHeaderControls.tsx +++ b/src/renderer/views/MainView/parts/SidebarHeaderControls.tsx @@ -1,10 +1,5 @@ -import { startTransition } from "react"; -import { FolderPlus, Globe, Monitor, Search } from "lucide-react"; +import { FolderPlus, Globe, Search } from "lucide-react"; import { Button, Dropdown, Label, Tooltip } from "@heroui/react"; -import { TuxIcon } from "@/renderer/components/common"; -import { parseWslUncPath } from "@/shared/wsl"; -import { isWindows, readBridge } from "@/renderer/bridge"; -import { useAppStore } from "@/renderer/state/appStore"; import { usePanelStore } from "@/renderer/state/panelStore"; import { type ThreadSortMode, @@ -12,12 +7,9 @@ import { sortModeIcon, sortModeLabel, } from "@/renderer/views/MainView/parts/Sidebar/parts/sortMode"; -import { autoDetectSetupScript } from "@/renderer/utils/gitHelpers"; +import { CreateProjectMenu } from "@/renderer/views/MainView/parts/CreateProject/CreateProjectMenu"; -export function SidebarHeaderControls(props: { wslAvailable: boolean }) { - const { wslAvailable } = props; - const addProject = useAppStore((state) => state.addProject); - const openDraft = useAppStore((state) => state.openDraft); +export function SidebarHeaderControls() { const threadSortMode = usePanelStore((s) => s.threadSortMode); const browserPanelOpen = usePanelStore((s) => s.browserPanelOpen); const rightPanelTab = usePanelStore((s) => s.rightPanelTab); @@ -40,93 +32,17 @@ export function SidebarHeaderControls(props: { wslAvailable: boolean }) { Search - {isWindows() ? ( - - - - { - if (key === "windows") { - void readBridge() - .pickFolder() - .then((path) => { - if (!path) return; - startTransition(() => { - const project = addProject({ kind: "windows", path }); - autoDetectSetupScript(project); - openDraft(project.id); - }); - }); - } - if (key === "wsl") { - void readBridge() - .listWslDistros() - .then((distros) => { - const distro = distros[0]; - const defaultPath = distro ? `\\\\wsl.localhost\\${distro}\\home` : undefined; - return readBridge().pickFolder(defaultPath); - }) - .then((selectedPath) => { - if (!selectedPath) return; - const parsed = parseWslUncPath(selectedPath); - if (!parsed) return; - startTransition(() => { - const project = addProject({ - kind: "wsl", - distro: parsed.distro, - linuxPath: parsed.linuxPath, - uncPath: selectedPath, - }); - autoDetectSetupScript(project); - openDraft(project.id); - }); - }); - } - }} - > - - - - - - - - - - - - ) : ( + - )} + - + + + diff --git a/src/shared/createProject.test.ts b/src/shared/createProject.test.ts new file mode 100644 index 00000000..ed444656 --- /dev/null +++ b/src/shared/createProject.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, test } from "vitest"; +import { + buildScratchTargetPath, + deriveLocationFromPath, + parentDirOf, + runtimeKeyForChoice, + runtimeKeyForLocation, + scratchKindForChoice, + splitPathLeaf, + validateProjectName, + validateScratchParent, + wslHomeDir, + type RuntimeChoice, +} from "./createProject"; + +describe("deriveLocationFromPath", () => { + test("derives a wsl location from a wsl.localhost UNC path", () => { + expect(deriveLocationFromPath("\\\\wsl.localhost\\Ubuntu\\home\\me\\repo", "win32")).toEqual({ + kind: "wsl", + distro: "Ubuntu", + linuxPath: "/home/me/repo", + uncPath: "\\\\wsl.localhost\\Ubuntu\\home\\me\\repo", + }); + }); + + test("derives a wsl location from a legacy wsl$ UNC path", () => { + expect(deriveLocationFromPath("\\\\wsl$\\Debian\\srv\\app", "win32")).toEqual({ + kind: "wsl", + distro: "Debian", + linuxPath: "/srv/app", + uncPath: "\\\\wsl$\\Debian\\srv\\app", + }); + }); + + test("derives a windows location for a native path on win32", () => { + expect(deriveLocationFromPath("C:\\Users\\me\\repo", "win32")).toEqual({ + kind: "windows", + path: "C:\\Users\\me\\repo", + }); + }); + + test("derives a posix location for a native path off win32", () => { + expect(deriveLocationFromPath("/Users/me/repo", "darwin")).toEqual({ + kind: "posix", + path: "/Users/me/repo", + }); + }); + + test("derives a wsl location for a bare distro-root UNC path", () => { + expect(deriveLocationFromPath("\\\\wsl.localhost\\Ubuntu", "win32")).toEqual({ + kind: "wsl", + distro: "Ubuntu", + linuxPath: "/", + uncPath: "\\\\wsl.localhost\\Ubuntu", + }); + }); +}); + +describe("validateProjectName", () => { + test("accepts a normal name", () => { + expect(validateProjectName("my-app")).toBeNull(); + }); + + test("rejects an empty / whitespace name", () => { + expect(validateProjectName("")).not.toBeNull(); + expect(validateProjectName(" ")).not.toBeNull(); + }); + + test("rejects names with path separators", () => { + expect(validateProjectName("a/b")).not.toBeNull(); + expect(validateProjectName("a\\b")).not.toBeNull(); + }); + + test("rejects names with characters illegal on Windows", () => { + for (const ch of [":", "*", "?", '"', "<", ">", "|"]) { + expect(validateProjectName(`bad${ch}name`)).not.toBeNull(); + } + }); + + test("rejects . and ..", () => { + expect(validateProjectName(".")).not.toBeNull(); + expect(validateProjectName("..")).not.toBeNull(); + }); +}); + +describe("buildScratchTargetPath", () => { + test("joins posix parent and name with a forward slash", () => { + expect(buildScratchTargetPath("/Users/me/code", "new-app", "posix")).toBe( + "/Users/me/code/new-app", + ); + }); + + test("joins windows parent and name with a backslash", () => { + expect(buildScratchTargetPath("C:\\code", "new-app", "windows")).toBe("C:\\code\\new-app"); + }); + + test("joins wsl UNC parent and name with a backslash", () => { + expect(buildScratchTargetPath("\\\\wsl.localhost\\Ubuntu\\home\\me", "app", "wsl")).toBe( + "\\\\wsl.localhost\\Ubuntu\\home\\me\\app", + ); + }); + + test("does not double the separator when the parent has a trailing slash", () => { + expect(buildScratchTargetPath("/Users/me/code/", "app", "posix")).toBe("/Users/me/code/app"); + expect(buildScratchTargetPath("C:\\code\\", "app", "windows")).toBe("C:\\code\\app"); + }); +}); + +describe("parentDirOf", () => { + test("returns the parent of a posix path", () => { + expect(parentDirOf("/Users/me/code/app", "posix")).toBe("/Users/me/code"); + }); + + test("returns the parent of a windows path", () => { + expect(parentDirOf("C:\\code\\app", "windows")).toBe("C:\\code"); + }); + + test("returns the parent of a wsl UNC path", () => { + expect(parentDirOf("\\\\wsl.localhost\\Ubuntu\\home\\me\\app", "wsl")).toBe( + "\\\\wsl.localhost\\Ubuntu\\home\\me", + ); + }); + + test("returns the posix root for a project directly under it", () => { + expect(parentDirOf("/proj", "posix")).toBe("/"); + }); + + test("returns the windows drive root for a project directly under it", () => { + expect(parentDirOf("C:\\proj", "windows")).toBe("C:\\"); + }); +}); + +describe("splitPathLeaf", () => { + test("splits a posix path into head and last segment", () => { + expect(splitPathLeaf("/Users/me/work/scripts/url-thing")).toEqual({ + head: "/Users/me/work/scripts", + tail: "/url-thing", + }); + }); + + test("splits a windows path on the last backslash", () => { + expect(splitPathLeaf("C:\\code\\app")).toEqual({ head: "C:\\code", tail: "\\app" }); + }); + + test("keeps the whole value as the tail when there is no separator", () => { + expect(splitPathLeaf("noseparator")).toEqual({ head: "", tail: "noseparator" }); + }); + + test("handles a root-adjacent leaf", () => { + expect(splitPathLeaf("/leaf")).toEqual({ head: "", tail: "/leaf" }); + }); +}); + +describe("runtime keys", () => { + test("runtimeKeyForChoice maps native and wsl", () => { + expect(runtimeKeyForChoice({ kind: "native" })).toBe("native"); + expect(runtimeKeyForChoice({ kind: "wsl", distro: "Ubuntu" })).toBe("Ubuntu"); + }); + + test("runtimeKeyForLocation maps location kinds to a key", () => { + expect(runtimeKeyForLocation({ kind: "windows", path: "C:\\x" })).toBe("native"); + expect(runtimeKeyForLocation({ kind: "posix", path: "/x" })).toBe("native"); + expect( + runtimeKeyForLocation({ + kind: "wsl", + distro: "Ubuntu", + linuxPath: "/x", + uncPath: "\\\\wsl.localhost\\Ubuntu\\x", + }), + ).toBe("Ubuntu"); + }); +}); + +describe("scratchKindForChoice", () => { + test("native resolves to windows on win32 and posix elsewhere", () => { + expect(scratchKindForChoice({ kind: "native" }, "win32")).toBe("windows"); + expect(scratchKindForChoice({ kind: "native" }, "darwin")).toBe("posix"); + expect(scratchKindForChoice({ kind: "native" }, "linux")).toBe("posix"); + }); + + test("wsl always resolves to wsl", () => { + expect(scratchKindForChoice({ kind: "wsl", distro: "Ubuntu" }, "win32")).toBe("wsl"); + }); +}); + +describe("validateScratchParent", () => { + const native: RuntimeChoice = { kind: "native" }; + const wsl: RuntimeChoice = { kind: "wsl", distro: "Ubuntu" }; + + test("native rejects a WSL UNC parent", () => { + expect(validateScratchParent("\\\\wsl.localhost\\Ubuntu\\home", native)).not.toBeNull(); + }); + + test("native accepts a non-UNC parent", () => { + expect(validateScratchParent("C:\\code", native)).toBeNull(); + expect(validateScratchParent("/Users/me/code", native)).toBeNull(); + }); + + test("wsl requires a WSL UNC parent", () => { + expect(validateScratchParent("C:\\code", wsl)).not.toBeNull(); + expect(validateScratchParent("\\\\wsl.localhost\\Ubuntu\\home", wsl)).toBeNull(); + }); + + test("wsl accepts the bare distro root as a parent", () => { + expect(validateScratchParent("\\\\wsl.localhost\\Ubuntu", wsl)).toBeNull(); + }); + + test("rejects an empty parent", () => { + expect(validateScratchParent("", native)).not.toBeNull(); + }); +}); + +describe("wslHomeDir", () => { + test("returns the distro home UNC path", () => { + expect(wslHomeDir("Ubuntu")).toBe("\\\\wsl.localhost\\Ubuntu\\home"); + }); +}); diff --git a/src/shared/createProject.ts b/src/shared/createProject.ts new file mode 100644 index 00000000..219cfefa --- /dev/null +++ b/src/shared/createProject.ts @@ -0,0 +1,125 @@ +import type { ProjectLocation } from "./contracts"; +import { parseWslUncPath, toWslUncPath } from "./wsl"; + +/** + * Where a new project should be created. `native` resolves to the host's own + * filesystem (windows or posix depending on platform); `wsl` targets a named + * WSL distribution. + */ +export type RuntimeChoice = { kind: "native" } | { kind: "wsl"; distro: string }; + +/** Concrete `ProjectLocation` kind, once a platform is known. */ +export type ScratchKind = ProjectLocation["kind"]; + +/** Characters that are illegal in a folder name on at least one supported OS. */ +const ILLEGAL_NAME_CHARS = /[/\\:*?"<>|]/; + +/** + * Derive a `ProjectLocation` from an absolute path. A WSL UNC path always wins + * (the path is authoritative); otherwise the host platform decides between a + * windows and a posix location. + */ +export function deriveLocationFromPath(path: string, platform: NodeJS.Platform): ProjectLocation { + const parsed = parseWslUncPath(path); + if (parsed) { + return { kind: "wsl", distro: parsed.distro, linuxPath: parsed.linuxPath, uncPath: path }; + } + if (platform === "win32") { + return { kind: "windows", path }; + } + return { kind: "posix", path }; +} + +/** + * Validate a folder name for "start from scratch". Returns an error message to + * show inline, or `null` when the name is acceptable. + */ +export function validateProjectName(name: string): string | null { + const trimmed = name.trim(); + if (!trimmed) return "Enter a project name."; + if (trimmed === "." || trimmed === "..") return "That name isn't allowed."; + if (ILLEGAL_NAME_CHARS.test(trimmed)) return "A name can't contain / \\ : * ? \" < > or |."; + if (trimmed.length > 255) return "That name is too long."; + return null; +} + +function separatorForKind(kind: ScratchKind): "\\" | "/" { + return kind === "posix" ? "/" : "\\"; +} + +/** + * Join a parent directory and a new folder name using the separator for the + * given kind. Mirrors what the main process does on disk; used for the modal's + * live path preview. + */ +export function buildScratchTargetPath(parent: string, name: string, kind: ScratchKind): string { + const sep = separatorForKind(kind); + const trimmedParent = parent.replace(/[\\/]+$/, ""); + return `${trimmedParent}${sep}${name}`; +} + +/** Return the parent directory of an absolute path for the given kind. */ +export function parentDirOf(path: string, kind: ScratchKind): string { + const sep = separatorForKind(kind); + const trimmed = path.replace(/[\\/]+$/, ""); + const idx = trimmed.lastIndexOf(sep); + if (idx < 0) return trimmed; + if (kind === "posix") { + // A single leading "/" means the parent is the filesystem root. + return idx === 0 ? "/" : trimmed.slice(0, idx); + } + const parent = trimmed.slice(0, idx); + // A bare drive ("C:") means the parent is the drive root ("C:\"). + return /^[A-Za-z]:$/.test(parent) ? `${parent}\\` : parent; +} + +/** + * Split a path into its leading portion and its last segment (with the leading + * separator) for middle-ellipsis display: render `head` truncating with `…` and + * `tail` pinned, so a long path collapses in the middle while the leaf stays + * visible. Accepts either separator since this is display-only. + */ +export function splitPathLeaf(path: string): { head: string; tail: string } { + const match = /^(.*)([\\/][^\\/]*)$/.exec(path); + return match ? { head: match[1]!, tail: match[2]! } : { head: "", tail: path }; +} + +/** Stable settings key under which a runtime's last-used directory is stored. */ +export function runtimeKeyForChoice(choice: RuntimeChoice): string { + return choice.kind === "wsl" ? choice.distro : "native"; +} + +/** Stable settings key for an already-derived location. */ +export function runtimeKeyForLocation(location: ProjectLocation): string { + return location.kind === "wsl" ? location.distro : "native"; +} + +/** Resolve a runtime choice to a concrete location kind for the host platform. */ +export function scratchKindForChoice( + choice: RuntimeChoice, + platform: NodeJS.Platform, +): ScratchKind { + if (choice.kind === "wsl") return "wsl"; + return platform === "win32" ? "windows" : "posix"; +} + +/** + * Validate that the chosen parent folder matches the selected runtime for + * "start from scratch". Returns an error message or `null`. + */ +export function validateScratchParent(parent: string, choice: RuntimeChoice): string | null { + if (!parent.trim()) return "Choose a parent folder."; + const isWslPath = parseWslUncPath(parent) !== null; + if (choice.kind === "wsl" && !isWslPath) { + return `Choose a folder inside ${choice.distro} (a \\\\wsl.localhost path).`; + } + if (choice.kind === "native" && isWslPath) { + return "That folder is inside WSL. Switch the runtime to that distro, or pick a native folder."; + } + return null; +} + +/** Default browse directory for a WSL distro: its `/home` over the UNC bridge. */ +export function wslHomeDir(distro: string): string { + return toWslUncPath(distro, "home"); +} diff --git a/src/shared/ipc/procedureMap.ts b/src/shared/ipc/procedureMap.ts index 9b3cf130..f93658d7 100644 --- a/src/shared/ipc/procedureMap.ts +++ b/src/shared/ipc/procedureMap.ts @@ -54,6 +54,7 @@ export const MAIN_LOCAL_PROCEDURE_NAMES = [ "pickFiles", "saveClipboardImage", "saveHandoffContext", + "createProjectDirectory", "openExternal", "openExternalNative", "openMicrophoneSettings", diff --git a/src/shared/ipc/procedures/app.ts b/src/shared/ipc/procedures/app.ts index 4f5fd11f..28501c01 100644 --- a/src/shared/ipc/procedures/app.ts +++ b/src/shared/ipc/procedures/app.ts @@ -3,10 +3,12 @@ import type { ProjectLocation } from "../../contracts"; import type { KeybindingsConfig } from "../../keybindings"; import { defineIpcProcedure, defineNoArgProcedure, definePayloadProcedure } from "../core"; import { + createProjectDirectoryPayloadSchema, openExternalPayloadSchema, pickFilesOptionsSchema, saveClipboardImagePayloadSchema, saveHandoffContextPayloadSchema, + type CreateProjectDirectoryResult, } from "../schemas"; export const appProcedures = { @@ -34,6 +36,11 @@ export const appProcedures = { string, "main-local" >("saveHandoffContext", "main-local", saveHandoffContextPayloadSchema), + createProjectDirectory: definePayloadProcedure< + z.infer, + CreateProjectDirectoryResult, + "main-local" + >("createProjectDirectory", "main-local", createProjectDirectoryPayloadSchema), listWslDistros: defineNoArgProcedure("listWslDistros", "supervisor"), openExternal: defineIpcProcedure<[string], string, void, "main-local">( "openExternal", diff --git a/src/shared/ipc/schemas.ts b/src/shared/ipc/schemas.ts index b266505c..5a3a2dd4 100644 --- a/src/shared/ipc/schemas.ts +++ b/src/shared/ipc/schemas.ts @@ -32,6 +32,19 @@ export const saveHandoffContextPayloadSchema = z.object({ content: z.string(), }); +export const createProjectDirectoryPayloadSchema = z.object({ + /** Absolute parent directory (native path, or a `\\wsl...` UNC path). */ + parent: z.string().min(1), + /** New folder name (validated by the renderer before sending). */ + name: z.string().min(1), + kind: z.enum(["windows", "wsl", "posix"]), +}); +export type CreateProjectDirectoryPayload = z.infer; +export interface CreateProjectDirectoryResult { + /** Absolute path of the newly-created directory. */ + path: string; +} + export const readThreadPayloadSchema = z.object({ threadId: z.string().min(1), }); diff --git a/src/shared/settings.ts b/src/shared/settings.ts index 34049f64..145bff63 100644 --- a/src/shared/settings.ts +++ b/src/shared/settings.ts @@ -198,6 +198,12 @@ export const sharedSettingsSchema = z.object({ * the user's previous choice. */ lastPresentationModeByAgent: z.record(z.string(), threadPresentationModeSchema), + /** + * Last-used parent directory for the create-project folder picker, keyed by + * runtime (`"native"` or a WSL distro name). Preselected when browsing for a + * new project; falls back to the runtime's home directory when absent. + */ + lastUsedProjectDirs: z.record(z.string(), z.string()), /** Enable LSP language servers for the file editor (type checking, completions, etc.). */ editorLspEnabled: z.boolean(), /** When true (VS Code default), the @file mention search honors `.gitignore`. */ @@ -306,6 +312,7 @@ export const defaultSharedSettings: SharedSettings = { gitReviewMode: "panel", providerConfigs: {}, lastPresentationModeByAgent: {}, + lastUsedProjectDirs: {}, editorLspEnabled: false, searchUseIgnoreFiles: true, searchExclude: { ...DEFAULT_SEARCH_EXCLUDE }, diff --git a/src/shared/wsl.test.ts b/src/shared/wsl.test.ts index 7ebffe31..483c9055 100644 --- a/src/shared/wsl.test.ts +++ b/src/shared/wsl.test.ts @@ -31,4 +31,18 @@ describe("wsl helpers", () => { it("returns null for a non-WSL path", () => { expect(parseWslUncPath("C:\\Users\\demo")).toBeNull(); }); + + it("parses a bare distro-root path to linuxPath '/'", () => { + expect(parseWslUncPath("\\\\wsl.localhost\\Ubuntu")).toEqual({ + distro: "Ubuntu", + linuxPath: "/", + }); + }); + + it("parses a distro-root path with a trailing separator to linuxPath '/'", () => { + expect(parseWslUncPath("\\\\wsl.localhost\\Ubuntu\\")).toEqual({ + distro: "Ubuntu", + linuxPath: "/", + }); + }); }); diff --git a/src/shared/wsl.ts b/src/shared/wsl.ts index 31efce9f..e3712b21 100644 --- a/src/shared/wsl.ts +++ b/src/shared/wsl.ts @@ -17,10 +17,14 @@ export function toWslUncPath(distro: string, linuxPath: string): string { } export function parseWslUncPath(uncPath: string): { distro: string; linuxPath: string } | null { - const match = /^\\\\wsl(?:\.localhost|\$)\\([^\\]+)\\(.+)$/i.exec(uncPath); + // The subpath after the distro is optional so a bare distro root + // (`\\wsl.localhost\Ubuntu`, with or without a trailing separator) parses to + // linuxPath "/" rather than failing and being misread as a Windows path. + const match = /^\\\\wsl(?:\.localhost|\$)\\([^\\]+)(?:\\(.*))?$/i.exec(uncPath); if (!match) return null; const distro = match[1]!; - const linuxPath = "/" + match[2]!.replace(/\\/g, "/"); + const rest = match[2]; + const linuxPath = rest ? "/" + rest.replace(/\\/g, "/") : "/"; return { distro, linuxPath }; }