diff --git a/docs/non-git-projects/intent.md b/docs/non-git-projects/intent.md new file mode 100644 index 0000000..e0e684f --- /dev/null +++ b/docs/non-git-projects/intent.md @@ -0,0 +1,11 @@ +# Intent + +SpireCode currently treats every Project as a Git repository. Opening a directory runs Git discovery immediately, watcher setup requires Git metadata, and the Renderer always exposes Changes and Worktree actions. This prevents users from using SpireCode for an existing source folder that has not been initialized as a repository. + +SpireCode should support both Git repositories and ordinary directories as first-class Projects. A directory Project must retain the useful workspace capabilities—file browsing and editing, terminals, Chat, project-local settings and durable restoration—without pretending that Git exists. Git status, diff, commit-related surfaces, and managed Worktree operations must be absent from the UI and rejected by Main when invoked for a directory Project. + +The implementation must preserve the existing security boundary: Renderer operations continue to use opaque IDs and relative paths. Renderer must never receive generic filesystem or arbitrary-path IPC. SpireCode only opens existing directories and does not expose a New Project or directory-creation flow. + +## Risk + +High risk. This changes the durable Project schema, path handling, watcher semantics, and cross-process capability checks. Implementation requires explicit approval after review of this intent, specification, and plan. diff --git a/docs/non-git-projects/plan.md b/docs/non-git-projects/plan.md new file mode 100644 index 0000000..f26b158 --- /dev/null +++ b/docs/non-git-projects/plan.md @@ -0,0 +1,88 @@ +# Plan + +## 1. Establish red tests for the Project schema and directory semantics + +Update `electron/domains/projects/projects.test.ts` before production code: + +- Git open returns `kind: "git"` and preserves subdirectory-to-repository-root behavior. +- Existing empty and non-empty ordinary directories open as `kind: "directory"` with one primary workspace. +- canonical aliases deduplicate; repeated opens preserve IDs. +- ordinary files and missing/inaccessible paths fail without catalog mutation. +- a directory Project upgraded by a later `git init` preserves IDs. +- version 2 state migrates to version 3 as Git. +- malformed version 3 kind/ownership data is rejected. +- Renderer exposes no New Project or directory-creation action. + +## 2. Implement durable Project capabilities + +Change `electron/domains/projects/index.ts`: + +- add `ProjectKind` and `ProjectSummary.kind`; +- bump `PROJECT_STATE_VERSION` to 3 and add a pure v2 migration; +- separate canonical selected-directory validation from Git repository discovery; +- add directory-root registration and existing-directory-to-Git upgrade; +- add Main-owned capability lookup/assertion helpers by Project/workspace ID; +- preserve atomic catalog writes and opaque workspace IDs. + +Update the shared Renderer DTO in `src/bindings/generated.ts` and affected fixtures. + +## 3. Make watcher behavior capability-aware + +First add tests in `electron/domains/filesystem/watcher.test.ts` proving that a directory watcher emits filesystem events but never Git events. + +Then change: + +- `electron/domains/filesystem/watcher.ts` to accept optional Git metadata and gate Git invalidation; +- `electron/appState.ts` startup/open paths to register filesystem-only watchers for directory Projects and Git-aware watchers for Git Projects. + +## 4. Enforce Main-side Git and Worktree gates + +Add regression tests to: + +- `electron/domains/git/git.test.ts`; +- `electron/domains/worktrees/worktrees.test.ts`; +- relevant IPC tests. + +Then: + +- give `GitService` a Main-derived capability resolver and reject directory workspaces before Git execution; +- make `WorktreeService` reject directory Projects before origin lookup, validation, managed-root creation, or Git mutation; +- retain select/list/reveal for the synthetic primary workspace. + +## 5. Implement capability-aware Renderer UX + +Update tests first, then production code: + +- `src/features/projects/ProjectRail.test.tsx` / `ProjectRail.tsx`: Open Folder remains the only Project-add action; use a folder icon and direct selection for directory Projects; hide Worktree UI. +- `src/features/workbench/Workbench.test.tsx` / `Workbench.tsx`: no Git refresh, no Changes tab, and Files fallback for directory Projects. +- `src/features/workbench/BreadcrumbSwitcher.test.tsx` / `BreadcrumbSwitcher.tsx`: Project-only breadcrumb for directory Projects. +- `src/features/editor/editorStore.test.ts` and `EditorPane.test.tsx`: clear or ignore directory diff tabs without affecting file, terminal, or Chat tabs. +- `src/i18n/en.ts` and `src/i18n/zh-CN.ts`: neutral folder/open labels. + +## 6. Prove retained non-Git capabilities + +Add focused integration/regression coverage where missing: + +- `electron/domains/filesystem/filesystem.test.ts`: list/read/write ordinary roots and preserve containment. +- `electron/domains/terminal/service.test.ts`: the ordinary directory is passed as PTY `cwd`. +- `electron/domains/chat/chatService.test.ts`: Chat adapter receives the ordinary directory and ownership isolation remains intact. +- `src/features/projects/projectsStore.test.ts`: directory primary workspace hydration, selection, and close fallback. + +## 7. Verification + +Run focused red-green suites during implementation, then: + +```bash +pnpm test -- electron/domains/projects/projects.test.ts +pnpm test -- electron/domains/filesystem/watcher.test.ts +pnpm test -- electron/domains/git/git.test.ts +pnpm test -- electron/domains/worktrees/worktrees.test.ts +pnpm test -- electron/ipc.test.ts electron/contracts.test.ts +pnpm test -- src/features/projects/ProjectRail.test.tsx +pnpm test -- src/features/workbench/Workbench.test.tsx +pnpm test -- src/features/workbench/BreadcrumbSwitcher.test.tsx +pnpm test -- src/features/editor/EditorPane.test.tsx +pnpm check +``` + +Finally inspect `git diff --check`, the complete diff, schema migration behavior, and all new IPC fields. No implementation is complete until the full gate passes. diff --git a/docs/non-git-projects/spec.md b/docs/non-git-projects/spec.md new file mode 100644 index 0000000..59f57bd --- /dev/null +++ b/docs/non-git-projects/spec.md @@ -0,0 +1,111 @@ +# Spec + +## 1. Project model + +Add an explicit Project capability discriminator: + +```ts +type ProjectKind = "git" | "directory"; + +interface ProjectSummary { + kind: ProjectKind; + // existing fields remain +} +``` + +The durable Project catalog moves from version 2 to version 3. + +- Existing version 2 Projects migrate deterministically to `kind: "git"` without probing the filesystem or changing IDs. +- A directory Project has exactly one synthetic primary workspace represented by the existing `WorktreeSummary` shape. +- The synthetic workspace uses `kind: "main"`, the canonical Project path, the Project display name, and empty `branch` / `baseRef` strings. Project capability is determined only by `ProjectSummary.kind`, never inferred from these placeholder fields. +- `activeWorktreeId` remains the active workspace identity. This intentionally avoids a broad rename of filesystem, terminal, Chat, editor, event, and settings contracts. +- Parsed catalogs reject unknown Project kinds and inconsistent workspace ownership. + +## 2. Open an existing folder + +The existing Open Project action accepts: + +- a Git repository root; +- a subdirectory of a Git worktree, normalized to its repository top level; +- an empty or non-empty ordinary directory, retained at the selected canonical root; +- a directory symlink, normalized with `realpath` so aliases do not create duplicate Projects. + +It rejects missing paths, regular files, inaccessible paths, and paths that cannot be canonicalized. Such failures do not modify the catalog. + +Git discovery must distinguish an ordinary directory from operational Git failures. A missing Git executable, timeout, permission failure, unsafe repository condition, or other unexpected failure must not silently downgrade a repository to a directory Project. + +Opening the same canonical root reuses the existing Project and primary workspace IDs. If an existing directory Project later becomes a Git repository and is reopened, its existing IDs are retained while its kind and primary workspace branch metadata are upgraded to Git. + +## 3. Runtime capabilities + +Directory Projects retain: + +- file tree and UTF-8 file editing; +- filesystem change refresh; +- terminal creation with the directory as `cwd`; +- Chat sessions with the directory as `cwd`; +- project-local settings, skills, and extensions resolved from that root; +- reveal, close, durable restore, and Project switching. + +Directory Projects do not support: + +- Git status; +- staged, unstaged, or untracked diff; +- Changes or commit-related UI; +- origin branch listing; +- managed Worktree create, rename, inspect-delete, or delete. + +Main is authoritative. Git and managed Worktree domain methods reject directory Projects with `NOT_A_GIT_REPOSITORY` even if Renderer IPC is called directly. Renderer capability flags are never trusted for authorization. + +## 4. Watchers and events + +Watcher registration accepts optional Git metadata. + +- Git Projects watch the workspace root and external Git directory when needed, preserving existing filesystem and `git://changed` events. +- Directory Projects watch only their root and emit only `filesystem://changed`. +- Directory file changes never trigger Git refresh. +- Startup restoration must register directory watchers without executing Git. + +## 5. Renderer behavior + +### Project rail + +- The add control opens an existing folder. SpireCode does not expose a New Project or directory-creation action. +- Git Projects retain repository icon, expand/collapse, Worktree rows, and Create Worktree. +- Directory Projects use a folder icon, hide all Worktree controls and rows, and select their synthetic primary workspace when the Project row is clicked. +- Reveal and Close remain available for both kinds. + +### Workbench + +- Directory Projects never call Git status refresh. +- The Changes tab is hidden for directory Projects. +- If the current in-memory right panel preference is Changes, a directory Project renders Files without destroying the preference; switching back to Git restores normal behavior. +- Files, editor, terminal, and Chat remain available. + +### Breadcrumb + +- Git Projects retain Project > Worktree > Branch. +- Directory Projects display only the Project-level switcher and no synthetic Worktree or branch metadata. + +### Diff tabs + +A restored or stale Git diff tab must not execute `git_diff_file` for a directory Project. Directory workspaces remove or ignore diff tabs while preserving file, terminal, and Chat tabs. Chat tool output and patches remain visible because they are Chat content, not the Git Changes feature. + +## 6. Compatibility and security + +- Existing Git Project behavior and IDs remain stable after migration. +- Renderer continues to use `worktreeId + relativePath`; no raw filesystem or shell IPC is added. +- Filesystem containment, symlink escape rejection, `.git` exclusion, hardened Git execution, and atomic catalog persistence remain unchanged. +- Git capability checks are derived from Main's durable catalog. + +## 7. Acceptance criteria + +1. Opening an existing empty or non-empty non-Git directory creates or reuses a directory Project. +2. SpireCode exposes no New Project or directory-creation action. +3. Directory Projects support Files, editing, Terminal, and Chat before and after restart. +4. Directory Projects show no Changes, diff, commit-related, or Worktree UI. +5. Direct Git and managed Worktree calls for directory Projects return `NOT_A_GIT_REPOSITORY` before running Git or mutating disk. +6. Directory watcher events do not produce Git invalidations. +7. Existing Git Projects, linked worktrees, managed Worktrees, Changes, and diffs continue to work. +8. Version 2 catalogs migrate to version 3 without changing existing IDs. +9. `pnpm check` passes. diff --git a/electron/appState.ts b/electron/appState.ts index f9655ca..179e487 100644 --- a/electron/appState.ts +++ b/electron/appState.ts @@ -49,7 +49,7 @@ export class AppState { this.diagnostics = new DiagnosticsService(dataDirectory, "0.1.0"); const root = (id: string) => projects.root(id); this.filesystem = new FilesystemService(root); - this.git = new GitService(root); + this.git = new GitService(root, (id) => projects.assertGitWorktree(id)); this.memory = new MemoryService(); this.models = new ModelCatalogService(); this.terminals = new TerminalService(root, (subscriptionId, payload) => { @@ -75,7 +75,7 @@ export class AppState { for (const project of await projects.list()) { for (const worktree of project.worktrees) { try { - await state.ensureWatcher(worktree.id, worktree.path); + await state.ensureWatcher(project.kind, worktree.id, worktree.path); } catch (error) { console.warn( `Unable to watch persisted worktree ${worktree.id}`, @@ -156,7 +156,7 @@ export class AppState { async openProject(selectedPath: string) { const project = await this.projects.openPath(selectedPath); for (const worktree of project.worktrees) - await this.ensureWatcher(worktree.id, worktree.path); + await this.ensureWatcher(project.kind, worktree.id, worktree.path); return project; } @@ -201,7 +201,7 @@ export class AppState { async createWorktree(projectId: string, name: string, baseRef: string) { const worktree = await this.worktrees.create(projectId, name, baseRef); try { - await this.ensureWatcher(worktree.id, worktree.path); + await this.ensureWatcher("git", worktree.id, worktree.path); return worktree; } catch (error) { await this.worktrees.rollbackCreated(worktree.id); @@ -214,7 +214,7 @@ export class AppState { await this.watchers.close(worktreeId); try { const renamed = await this.worktrees.rename(worktreeId, name); - await this.ensureWatcher(renamed.id, renamed.path); + await this.ensureWatcher("git", renamed.id, renamed.path); return renamed; } catch (error) { try { @@ -222,7 +222,7 @@ export class AppState { if (current.name !== original.name) await this.worktrees.rename(worktreeId, original.name); const restored = await this.projects.worktree(worktreeId); - await this.ensureWatcher(restored.id, restored.path); + await this.ensureWatcher("git", restored.id, restored.path); } catch { // Preserve the primary failure; recovery is best effort here. } @@ -248,7 +248,7 @@ export class AppState { } catch (error) { try { const current = await this.projects.worktree(worktreeId); - await this.ensureWatcher(current.id, current.path); + await this.ensureWatcher("git", current.id, current.path); } catch { // The worktree may already be gone. } @@ -267,7 +267,17 @@ export class AppState { console.warn("Application cleanup was incomplete", result.reason); } - private async ensureWatcher(worktreeId: string, root: string): Promise { + private async ensureWatcher( + kind: "git" | "directory", + worktreeId: string, + root: string, + ): Promise { + if (kind === "directory") { + await this.watchers.ensure(worktreeId, root, (event) => + this.emitWatchEvent(event), + ); + return; + } const gitDir = ( await gitText(root, ["rev-parse", "--path-format=absolute", "--git-dir"]) ).trim(); diff --git a/electron/domains/filesystem/watcher.test.ts b/electron/domains/filesystem/watcher.test.ts index 8510c20..6dd36ba 100644 --- a/electron/domains/filesystem/watcher.test.ts +++ b/electron/domains/filesystem/watcher.test.ts @@ -32,6 +32,70 @@ describe("WatcherRegistry", () => { expect(watcherBackendForPlatform("win32")).toBe("chokidar"); }); + it.each([ + ["omitted", undefined], + ["null", null], + ] as const)( + "emits only filesystem changes when Git metadata is %s", + async (_label, gitDirectory) => { + const root = await temporaryDirectory(); + const events: WatchEvent[] = []; + const registry = new WatcherRegistry(); + const sink = (event: WatchEvent) => events.push(event); + + if (gitDirectory === undefined) { + await registry.ensure("directory-1", root, sink); + } else { + await registry.ensure("directory-1", root, gitDirectory, sink); + } + await writeFile(path.join(root, "file.ts"), "export {};\n"); + await vi.waitFor( + () => { + expect( + events.some( + (event) => + event.topic === "filesystem://changed" && + event.payload.paths.includes("file.ts"), + ), + ).toBe(true); + }, + { timeout: 5_000 }, + ); + await new Promise((resolve) => setTimeout(resolve, 250)); + + expect(events.some((event) => event.topic === "git://changed")).toBe( + false, + ); + await registry.dispose(); + }, + ); + + it("preserves filesystem and Git invalidations for Git watchers", async () => { + const root = await temporaryDirectory(); + const gitDirectory = path.join(root, ".git"); + await mkdir(gitDirectory); + const events: WatchEvent[] = []; + const registry = new WatcherRegistry(); + + await registry.ensure("git-1", root, gitDirectory, (event) => + events.push(event), + ); + await writeFile(path.join(root, "file.ts"), "export {};\n"); + await vi.waitFor( + () => { + expect( + events.some((event) => event.topic === "filesystem://changed"), + ).toBe(true); + expect(events.some((event) => event.topic === "git://changed")).toBe( + true, + ); + }, + { timeout: 5_000 }, + ); + + await registry.dispose(); + }); + it.runIf(process.platform === "darwin")( "observes nested files with the native recursive backend and releases it", async () => { diff --git a/electron/domains/filesystem/watcher.ts b/electron/domains/filesystem/watcher.ts index 85b32d4..a42880b 100644 --- a/electron/domains/filesystem/watcher.ts +++ b/electron/domains/filesystem/watcher.ts @@ -37,17 +37,50 @@ export class WatcherRegistry { rootPath: string, gitDirectory: string, sink: (event: WatchEvent) => void, + ): Promise; + async ensure( + worktreeId: string, + rootPath: string, + sink: (event: WatchEvent) => void, + ): Promise; + async ensure( + worktreeId: string, + rootPath: string, + gitDirectory: string | null | undefined, + sink: (event: WatchEvent) => void, + ): Promise; + async ensure( + worktreeId: string, + rootPath: string, + gitDirectoryOrSink: + string | null | undefined | ((event: WatchEvent) => void), + optionalSink?: (event: WatchEvent) => void, ): Promise { if (this.sessions.has(worktreeId)) return; + const gitDirectory = + typeof gitDirectoryOrSink === "function" + ? null + : (gitDirectoryOrSink ?? null); + const sink = + typeof gitDirectoryOrSink === "function" + ? gitDirectoryOrSink + : optionalSink; + if (!sink) { + throw new CommandError("INVALID_ARGUMENT", "watcher sink is required"); + } const root = await realpath(rootPath); let gitDir = gitDirectory; - try { - gitDir = await realpath(gitDirectory); - } catch { - // Missing external git metadata is not fatal during watcher setup. + if (gitDir !== null) { + try { + gitDir = await realpath(gitDir); + } catch { + // Missing external git metadata is not fatal during watcher setup. + } } const watchPaths = [root]; - if (!isWithin(root, gitDir) && gitDir !== root) watchPaths.push(gitDir); + if (gitDir !== null && !isWithin(root, gitDir) && gitDir !== root) { + watchPaths.push(gitDir); + } let timer: NodeJS.Timeout | undefined; const paths = new Set(); @@ -68,7 +101,9 @@ export class WatcherRegistry { }, }); } - if (gitChanged) sink({ topic: "git://changed", payload: { worktreeId } }); + if (gitDirectory !== null && gitChanged) { + sink({ topic: "git://changed", payload: { worktreeId } }); + } paths.clear(); truncated = false; gitChanged = false; @@ -92,8 +127,8 @@ export class WatcherRegistry { } if (paths.size >= MAX_PATHS) truncated = true; else paths.add(rel); - gitChanged = true; - } else { + if (gitDirectory !== null) gitChanged = true; + } else if (gitDirectory !== null) { gitChanged = true; } schedule(); @@ -102,7 +137,7 @@ export class WatcherRegistry { let stopWatcher: (() => Promise) | undefined; const handleError = () => { truncated = true; - gitChanged = true; + if (gitDirectory !== null) gitChanged = true; schedule(); }; try { diff --git a/electron/domains/git/git.test.ts b/electron/domains/git/git.test.ts index 3171ede..ada4ae6 100644 --- a/electron/domains/git/git.test.ts +++ b/electron/domains/git/git.test.ts @@ -212,6 +212,29 @@ describe("safe Git runner", () => { }); describe("GitService", () => { + it("rejects Git operations before resolving a directory workspace root", async () => { + let resolved = false; + const service = new GitService( + () => { + resolved = true; + return "/unused"; + }, + () => { + throw new CommandError( + "NOT_A_GIT_REPOSITORY", + "workspace does not support Git operations", + ); + }, + ); + + await expectCode(service.status("directory-1"), "NOT_A_GIT_REPOSITORY"); + await expectCode( + service.diffFile("directory-1", "file.txt", "unstaged"), + "NOT_A_GIT_REPOSITORY", + ); + expect(resolved).toBe(false); + }); + it("returns status with combined staged and unstaged stats", async () => { const root = await temporaryRepository(); await writeFile(path.join(root, "tracked.txt"), "index\n"); diff --git a/electron/domains/git/service.ts b/electron/domains/git/service.ts index 9c7202e..1641012 100644 --- a/electron/domains/git/service.ts +++ b/electron/domains/git/service.ts @@ -8,14 +8,21 @@ import type { DiffScope, GitDiff, GitStatus } from "./types.js"; const MAX_TEXT_BYTES = 5 * 1024 * 1024; export type RootResolver = (worktreeId: string) => string | Promise; +export type GitCapabilityResolver = ( + worktreeId: string, +) => void | Promise; export class GitService { private readonly queues = new Map>(); - constructor(private readonly rootResolver: RootResolver) {} + constructor( + private readonly rootResolver: RootResolver, + private readonly assertGit: GitCapabilityResolver = () => undefined, + ) {} status(worktreeId: string): Promise { return this.serial(worktreeId, async () => { + await this.assertGit(worktreeId); const root = await this.rootResolver(worktreeId); const output = await safeGitText(root, [ "status", @@ -52,6 +59,7 @@ export class GitService { scope: DiffScope | string, ): Promise { return this.serial(worktreeId, async () => { + await this.assertGit(worktreeId); if (scope !== "staged" && scope !== "unstaged" && scope !== "untracked") { throw new CommandError( "INVALID_ARGUMENT", diff --git a/electron/domains/projects/index.ts b/electron/domains/projects/index.ts index dfb10ac..d4a200e 100644 --- a/electron/domains/projects/index.ts +++ b/electron/domains/projects/index.ts @@ -1,4 +1,4 @@ -import { realpath, readFile, rename } from "node:fs/promises"; +import { realpath, readFile, rename, stat } from "node:fs/promises"; import path from "node:path"; import { randomUUID } from "node:crypto"; import { AsyncQueue } from "../../core/asyncQueue.js"; @@ -6,8 +6,9 @@ import { CommandError, toCommandError } from "../../core/errors.js"; import { gitText } from "../../core/gitProcess.js"; import { corruptBackupPath, saveAtomic } from "../persistence/index.js"; -export const PROJECT_STATE_VERSION = 2; +export const PROJECT_STATE_VERSION = 3; +export type ProjectKind = "git" | "directory"; export type WorktreeKind = "main" | "managed" | "external"; export interface WorktreeSummary { @@ -23,6 +24,7 @@ export interface WorktreeSummary { export interface ProjectSummary { id: string; + kind: ProjectKind; name: string; path: string; lastOpenedAt: number; @@ -38,6 +40,14 @@ export interface ProjectCatalog { type Catalog = ProjectCatalog; +type ProjectV2 = Omit; + +interface CatalogV2 { + version: number; + projects: ProjectV2[]; + activeWorktreeId: string | null; +} + interface ProjectV1 { id: string; name: string; @@ -84,8 +94,12 @@ export class ProjectService { } async openPath(selected: string): Promise { - const root = await gitAbsolute(selected, ["rev-parse", "--show-toplevel"]); - return this.openRoot(root.trim()); + const canonical = await canonicalDirectory(selected); + const repositoryRoot = await discoverRepositoryRoot(canonical); + return this.openRoot( + repositoryRoot ?? canonical, + repositoryRoot ? "git" : "directory", + ); } async close(id: string): Promise { @@ -137,6 +151,35 @@ export class ProjectService { return structuredClone((await this.project(projectId)).worktrees); } + async projectKind(projectId: string): Promise { + return (await this.project(projectId)).kind; + } + + async worktreeProjectKind(worktreeId: string): Promise { + return this.queue.run(async () => { + const project = findProjectByWorktree(this.catalogValue, worktreeId); + return project.kind; + }); + } + + async assertGitProject(projectId: string): Promise { + if ((await this.projectKind(projectId)) !== "git") { + throw new CommandError( + "NOT_A_GIT_REPOSITORY", + "project does not support Git operations", + ); + } + } + + async assertGitWorktree(worktreeId: string): Promise { + if ((await this.worktreeProjectKind(worktreeId)) !== "git") { + throw new CommandError( + "NOT_A_GIT_REPOSITORY", + "workspace does not support Git operations", + ); + } + } + async select(worktreeId: string): Promise { const openedAt = now(); return this.transact((catalog) => { @@ -197,13 +240,10 @@ export class ProjectService { }); } - private async openRoot(root: string): Promise { - let canonical: string; - try { - canonical = await realpath(root); - } catch (error) { - throw toCommandError(error); - } + private async openRoot( + canonical: string, + kind: ProjectKind, + ): Promise { const name = path.basename(canonical) || canonical; const openedAt = now(); @@ -224,17 +264,25 @@ export class ProjectService { "INVALID_ARGUMENT", "project has no main worktree", ); + if (existing.kind === "directory" && kind === "git") { + const branch = (await currentBranch(canonical)) ?? "HEAD"; + existing.kind = "git"; + main.name = branch; + main.branch = branch; + main.baseRef = branch; + } main.lastOpenedAt = openedAt; candidate.activeWorktreeId = main.id; result = structuredClone(existing); } else { - const branch = (await currentBranch(canonical)) ?? "HEAD"; + const branch = + kind === "git" ? ((await currentBranch(canonical)) ?? "HEAD") : ""; const projectId = randomUUID(); const worktreeId = randomUUID(); const main: WorktreeSummary = { id: worktreeId, projectId, - name: branch, + name: kind === "git" ? branch : name, path: canonical, branch, baseRef: branch, @@ -243,6 +291,7 @@ export class ProjectService { }; const project: ProjectSummary = { id: projectId, + kind, name, path: canonical, lastOpenedAt: openedAt, @@ -308,6 +357,9 @@ async function loadCatalog( if (version === 1) { return { catalog: await migrateV1(parseCatalogV1(record)), migrated: true }; } + if (version === 2) { + return { catalog: migrateV2(parseCatalogV2(record)), migrated: true }; + } throw new CommandError("INVALID_ARGUMENT", "unsupported state version"); } @@ -369,6 +421,7 @@ async function migrateV1(old: CatalogV1): Promise { } projects.push({ id: projectId, + kind: "git", name: main.name, path: main.path, lastOpenedAt: main.lastOpenedAt, @@ -379,6 +432,55 @@ async function migrateV1(old: CatalogV1): Promise { return { version: PROJECT_STATE_VERSION, projects, activeWorktreeId }; } +function migrateV2(old: CatalogV2): Catalog { + return { + version: PROJECT_STATE_VERSION, + projects: old.projects.map((project) => ({ ...project, kind: "git" })), + activeWorktreeId: old.activeWorktreeId, + }; +} + +async function canonicalDirectory(selected: string): Promise { + try { + const canonical = await realpath(selected); + if (!(await stat(canonical)).isDirectory()) { + throw new CommandError( + "INVALID_ARGUMENT", + "project path is not a directory", + ); + } + return canonical; + } catch (error) { + throw toCommandError(error); + } +} + +async function discoverRepositoryRoot(root: string): Promise { + try { + return (await gitAbsolute(root, ["rev-parse", "--show-toplevel"])).trim(); + } catch (error) { + if (!(error instanceof CommandError) || error.code !== "GIT_FAILED") + throw error; + if (await hasGitMarker(root)) throw error; + return null; + } +} + +async function hasGitMarker(root: string): Promise { + let candidate = root; + for (;;) { + try { + await stat(path.join(candidate, ".git")); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") return true; + } + const parent = path.dirname(candidate); + if (parent === candidate) return false; + candidate = parent; + } +} + async function isMainCheckout(root: string): Promise { try { const [gitDirectory, commonDirectory] = await Promise.all([ @@ -425,11 +527,16 @@ async function gitAbsolute( } function findWorktree(catalog: Catalog, id: string): WorktreeSummary { - for (const project of catalog.projects) { - const worktree = project.worktrees.find((value) => value.id === id); - if (worktree) return worktree; - } - throw new CommandError("NOT_FOUND", "worktree not found"); + const project = findProjectByWorktree(catalog, id); + return project.worktrees.find((value) => value.id === id)!; +} + +function findProjectByWorktree(catalog: Catalog, id: string): ProjectSummary { + const project = catalog.projects.find((value) => + value.worktrees.some((worktree) => worktree.id === id), + ); + if (!project) throw new CommandError("NOT_FOUND", "worktree not found"); + return project; } function emptyCatalog(): Catalog { @@ -450,12 +557,27 @@ function parseCatalog(value: Record): Catalog { function parseProject(value: unknown): ProjectSummary { const record = asRecord(value); + const id = uuid(record.id, "project.id"); + const kind = projectKind(record.kind); + const worktrees = array(record.worktrees, "project.worktrees").map( + parseWorktree, + ); + if (worktrees.some((worktree) => worktree.projectId !== id)) { + throw invalid("worktree.projectId"); + } + if ( + kind === "directory" && + (worktrees.length !== 1 || worktrees[0]?.kind !== "main") + ) { + throw invalid("project.worktrees"); + } return { - id: uuid(record.id, "project.id"), + id, + kind, name: string(record.name, "project.name"), path: string(record.path, "project.path"), lastOpenedAt: unsignedInteger(record.lastOpenedAt, "project.lastOpenedAt"), - worktrees: array(record.worktrees, "project.worktrees").map(parseWorktree), + worktrees, nextWorktreeSequence: unsignedInteger( record.nextWorktreeSequence, "project.nextWorktreeSequence", @@ -481,6 +603,37 @@ function parseWorktree(value: unknown): WorktreeSummary { }; } +function parseCatalogV2(value: Record): CatalogV2 { + return { + version: unsignedInteger(value.version, "version"), + projects: array(value.projects, "projects").map((item) => { + const record = asRecord(item); + const id = uuid(record.id, "project.id"); + const worktrees = array(record.worktrees, "project.worktrees").map( + parseWorktree, + ); + if (worktrees.some((worktree) => worktree.projectId !== id)) { + throw invalid("worktree.projectId"); + } + return { + id, + name: string(record.name, "project.name"), + path: string(record.path, "project.path"), + lastOpenedAt: unsignedInteger( + record.lastOpenedAt, + "project.lastOpenedAt", + ), + worktrees, + nextWorktreeSequence: unsignedInteger( + record.nextWorktreeSequence, + "project.nextWorktreeSequence", + ), + }; + }), + activeWorktreeId: nullableUuid(value.activeWorktreeId, "activeWorktreeId"), + }; +} + function parseCatalogV1(value: Record): CatalogV1 { return { version: unsignedInteger(value.version, "version"), @@ -511,6 +664,14 @@ function array(value: unknown, field: string): unknown[] { return value; } +function projectKind(value: unknown): ProjectKind { + const result = string(value, "project.kind"); + if (result !== "git" && result !== "directory") { + throw invalid("project.kind"); + } + return result; +} + function string(value: unknown, field: string): string { if (typeof value !== "string") throw invalid(field); return value; diff --git a/electron/domains/projects/projects.test.ts b/electron/domains/projects/projects.test.ts index 351a625..92fbc24 100644 --- a/electron/domains/projects/projects.test.ts +++ b/electron/domains/projects/projects.test.ts @@ -1,5 +1,12 @@ import { execFile } from "node:child_process"; -import { mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises"; +import { + mkdtemp, + readFile, + rm, + writeFile, + mkdir, + symlink, +} from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -52,26 +59,122 @@ afterEach(async () => { }); describe("ProjectService", () => { - it("opens a v2 project with distinct repository and main worktree ids", async () => { + it("opens a Git subdirectory with distinct repository and main workspace ids", async () => { const directory = await tempDir(); await init(directory); + const nested = path.join(directory, "src", "nested"); + await mkdir(nested, { recursive: true }); const statePath = path.join(directory, "app", "state.json"); const service = await ProjectService.load(statePath); - const first = await service.openPath(directory); + const first = await service.openPath(nested); const second = await service.openPath(directory); - expect(second.id).toBe(first.id); - expect(first.id).not.toBe(first.worktrees[0]?.id); - expect(await service.root(first.worktrees[0]!.id)).toBe( + expect(first.kind).toBe("git"); + expect(first.path).toBe( await import("node:fs/promises").then((fs) => fs.realpath(directory)), ); + expect(second.id).toBe(first.id); + expect(first.id).not.toBe(first.worktrees[0]?.id); + expect(await service.root(first.worktrees[0]!.id)).toBe(first.path); await expect( (await ProjectService.load(statePath)).list(), ).resolves.toHaveLength(1); expect((await service.catalog()).version).toBe(PROJECT_STATE_VERSION); }); + it("opens ordinary directories with one synthetic workspace and deduplicates canonical aliases", async () => { + const root = await tempDir(); + const directory = path.join(root, "ordinary"); + const alias = path.join(root, "alias"); + await mkdir(directory); + await writeFile(path.join(directory, "notes.txt"), "not a repository\n"); + await symlink(directory, alias, "dir"); + const service = await ProjectService.load(path.join(root, "state.json")); + + const canonical = await import("node:fs/promises").then((fs) => + fs.realpath(directory), + ); + const first = await service.openPath(alias); + const second = await service.openPath(directory); + + expect(first).toMatchObject({ + kind: "directory", + name: "ordinary", + path: canonical, + nextWorktreeSequence: 1, + }); + expect(first.worktrees).toEqual([ + expect.objectContaining({ + projectId: first.id, + name: "ordinary", + path: canonical, + branch: "", + baseRef: "", + kind: "main", + }), + ]); + expect(second.id).toBe(first.id); + expect(second.worktrees[0]?.id).toBe(first.worktrees[0]?.id); + expect(await service.list()).toHaveLength(1); + }); + + it("rejects files and missing paths without mutating the catalog", async () => { + const root = await tempDir(); + const file = path.join(root, "file.txt"); + await writeFile(file, "content"); + const service = await ProjectService.load(path.join(root, "state.json")); + + await expect(service.openPath(file)).rejects.toMatchObject({ + code: "INVALID_ARGUMENT", + }); + await expect( + service.openPath(path.join(root, "missing")), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + expect((await service.catalog()).projects).toEqual([]); + }); + + it("upgrades a directory project to Git without changing its ids", async () => { + const directory = await tempDir(); + const service = await ProjectService.load( + path.join(directory, "app", "state.json"), + ); + const ordinary = await service.openPath(directory); + + await init(directory); + const repository = await service.openPath(directory); + + expect(repository.kind).toBe("git"); + expect(repository.id).toBe(ordinary.id); + expect(repository.worktrees[0]?.id).toBe(ordinary.worktrees[0]?.id); + expect(repository.worktrees[0]?.branch).not.toBe(""); + expect(repository.worktrees[0]?.baseRef).toBe( + repository.worktrees[0]?.branch, + ); + }); + + it("exposes authoritative Git capabilities by project and workspace id", async () => { + const root = await tempDir(); + const gitRoot = path.join(root, "repo"); + const directoryRoot = path.join(root, "folder"); + await init(gitRoot); + await mkdir(directoryRoot); + const service = await ProjectService.load(path.join(root, "state.json")); + const gitProject = await service.openPath(gitRoot); + const directoryProject = await service.openPath(directoryRoot); + + await expect(service.projectKind(gitProject.id)).resolves.toBe("git"); + await expect( + service.worktreeProjectKind(directoryProject.worktrees[0]!.id), + ).resolves.toBe("directory"); + await expect( + service.assertGitProject(gitProject.id), + ).resolves.toBeUndefined(); + await expect( + service.assertGitWorktree(directoryProject.worktrees[0]!.id), + ).rejects.toMatchObject({ code: "NOT_A_GIT_REPOSITORY" }); + }); + it("sorts projects by last-opened time and closes the active project", async () => { const root = await tempDir(); const firstRoot = path.join(root, "first"); @@ -152,14 +255,63 @@ describe("ProjectService", () => { const catalog = await (await ProjectService.load(statePath)).catalog(); - expect(catalog.version).toBe(2); + expect(catalog.version).toBe(3); expect(catalog.projects[0]?.worktrees[0]?.id).toBe(oldId); expect(catalog.activeWorktreeId).toBe(oldId); await expect(readFile(corruptBackupPath(statePath))).rejects.toMatchObject({ code: "ENOENT", }); expect(JSON.parse(await readFile(statePath, "utf8"))).toMatchObject({ - version: 2, + version: 3, + }); + }); + + it("migrates v2 to v3 as Git without changing ids", async () => { + const directory = await tempDir(); + await init(directory); + const statePath = path.join(directory, "state.json"); + const projectId = randomUUID(); + const worktreeId = randomUUID(); + await writeFile( + statePath, + JSON.stringify({ + version: 2, + projects: [ + { + id: projectId, + name: "repo", + path: directory, + lastOpenedAt: 7, + worktrees: [ + { + id: worktreeId, + projectId, + name: "main", + path: directory, + branch: "main", + baseRef: "main", + kind: "main", + lastOpenedAt: 7, + }, + ], + nextWorktreeSequence: 2, + }, + ], + activeWorktreeId: worktreeId, + }), + ); + + const catalog = await (await ProjectService.load(statePath)).catalog(); + + expect(catalog).toMatchObject({ + version: 3, + activeWorktreeId: worktreeId, + projects: [{ id: projectId, kind: "git" }], + }); + expect(catalog.projects[0]?.worktrees[0]?.id).toBe(worktreeId); + expect(JSON.parse(await readFile(statePath, "utf8"))).toMatchObject({ + version: 3, + projects: [{ kind: "git" }], }); }); @@ -214,7 +366,7 @@ describe("ProjectService", () => { const unsupportedPath = path.join(directory, "unsupported.json"); await writeFile( unsupportedPath, - JSON.stringify({ version: 3, projects: [], activeWorktreeId: null }), + JSON.stringify({ version: 4, projects: [], activeWorktreeId: null }), ); await expect(ProjectService.load(unsupportedPath)).rejects.toMatchObject({ code: "INVALID_ARGUMENT", @@ -222,7 +374,54 @@ describe("ProjectService", () => { }); const invalidPath = path.join(directory, "invalid.json"); - await writeFile(invalidPath, JSON.stringify({ version: 2, projects: [] })); + await writeFile( + invalidPath, + JSON.stringify({ version: 3, projects: [], activeWorktreeId: null }), + ); + await expect(ProjectService.load(invalidPath)).resolves.toBeDefined(); + + const projectId = randomUUID(); + const worktreeId = randomUUID(); + const invalidProject = { + id: projectId, + kind: "unknown", + name: "bad", + path: directory, + lastOpenedAt: 1, + worktrees: [ + { + id: worktreeId, + projectId: randomUUID(), + name: "bad", + path: directory, + branch: "", + baseRef: "", + kind: "main", + lastOpenedAt: 1, + }, + ], + nextWorktreeSequence: 1, + }; + await writeFile( + invalidPath, + JSON.stringify({ + version: 3, + projects: [invalidProject], + activeWorktreeId: worktreeId, + }), + ); + await expect(ProjectService.load(invalidPath)).rejects.toMatchObject({ + code: "INVALID_ARGUMENT", + }); + invalidProject.kind = "directory"; + await writeFile( + invalidPath, + JSON.stringify({ + version: 3, + projects: [invalidProject], + activeWorktreeId: worktreeId, + }), + ); await expect(ProjectService.load(invalidPath)).rejects.toMatchObject({ code: "INVALID_ARGUMENT", }); diff --git a/electron/domains/worktrees/index.ts b/electron/domains/worktrees/index.ts index f08d30c..94a96ad 100644 --- a/electron/domains/worktrees/index.ts +++ b/electron/domains/worktrees/index.ts @@ -79,6 +79,7 @@ export class WorktreeService { async listOriginBranches(projectId: string): Promise { const project = await this.projects.project(projectId); + gitOnly(project); const [originConfigured, branches] = await Promise.all([ this.originConfigured(project.path), this.originBranches(project.path), @@ -98,6 +99,7 @@ export class WorktreeService { ): Promise { return this.mutate(projectId, async () => { const project = await this.projects.project(projectId); + gitOnly(project); validateName(name); await this.ensureUnique(project, name); const branches = await this.originBranches(project.path); @@ -173,6 +175,8 @@ export class WorktreeService { async rollbackCreated(worktreeId: string): Promise { const initial = await this.projects.worktree(worktreeId); + const initialProject = await this.projects.project(initial.projectId); + gitOnly(initialProject); managedOnly(initial); await this.mutate(initial.projectId, async () => { const current = await this.projects.worktree(worktreeId); @@ -220,6 +224,7 @@ export class WorktreeService { async rename(worktreeId: string, name: string): Promise { const initial = await this.projects.worktree(worktreeId); + gitOnly(await this.projects.project(initial.projectId)); managedOnly(initial); return this.mutate(initial.projectId, async () => { const current = await this.projects.worktree(worktreeId); @@ -277,6 +282,7 @@ export class WorktreeService { async inspectDelete(worktreeId: string): Promise { const worktree = await this.projects.worktree(worktreeId); + gitOnly(await this.projects.project(worktree.projectId)); managedOnly(worktree); const output = await runGit(worktree.path, [ "status", @@ -293,6 +299,7 @@ export class WorktreeService { async delete(worktreeId: string, force: boolean): Promise { const initial = await this.projects.worktree(worktreeId); + gitOnly(await this.projects.project(initial.projectId)); managedOnly(initial); return this.mutate(initial.projectId, async () => { const current = await this.projects.worktree(worktreeId); @@ -536,6 +543,15 @@ function validateName(name: string): void { } } +function gitOnly(project: ProjectSummary): void { + if (project.kind !== "git") { + throw new CommandError( + "NOT_A_GIT_REPOSITORY", + "project does not support Git worktrees", + ); + } +} + function managedOnly(worktree: WorktreeSummary): void { if (worktree.kind !== "managed") { throw new CommandError( diff --git a/electron/domains/worktrees/worktrees.test.ts b/electron/domains/worktrees/worktrees.test.ts index 513925c..86b2417 100644 --- a/electron/domains/worktrees/worktrees.test.ts +++ b/electron/domains/worktrees/worktrees.test.ts @@ -60,6 +60,7 @@ async function fixture(): Promise<{ const project: ProjectSummary = { id: "11111111-1111-4111-8111-111111111111", + kind: "git", name: "repo", path: repo, lastOpenedAt: 1, @@ -133,6 +134,18 @@ describe("WorktreeService", () => { ); }); + it("rejects directory projects before running Git or validating worktree input", async () => { + const value = await fixture(); + value.project.kind = "directory"; + + await expect( + value.service.listOriginBranches(value.project.id), + ).rejects.toMatchObject({ code: "NOT_A_GIT_REPOSITORY" }); + await expect( + value.service.create(value.project.id, "bad/name", "origin/main"), + ).rejects.toMatchObject({ code: "NOT_A_GIT_REPOSITORY" }); + }); + it("lists real origin branches, default branch, and the next available name", async () => { const value = await fixture(); diff --git a/src/bindings/generated.ts b/src/bindings/generated.ts index bee1c96..1283b59 100644 --- a/src/bindings/generated.ts +++ b/src/bindings/generated.ts @@ -33,6 +33,7 @@ export interface ProjectSummary { id: string; name: string; path: string; + kind: "git" | "directory"; lastOpenedAt: number; worktrees: WorktreeSummary[]; nextWorktreeSequence?: number; diff --git a/src/features/editor/editorStore.test.ts b/src/features/editor/editorStore.test.ts index 5f4e4d7..6c5a219 100644 --- a/src/features/editor/editorStore.test.ts +++ b/src/features/editor/editorStore.test.ts @@ -216,6 +216,29 @@ describe("preview and keep tabs", () => { expect(restored.p1.activeTabId).toBe("chat:p1:s1"); }); + it("clears only diff tabs when Git capabilities are unavailable", () => { + useEditorStore.getState().open(tab("a.ts"), true); + useEditorStore.getState().open( + { + id: "diff:p1:unstaged:b.ts", + worktreeId: "p1", + type: "diff", + relativePath: "b.ts", + scope: "unstaged", + preview: false, + }, + true, + ); + useEditorStore.getState().openChat("p1", "session-a", "Chat A"); + + useEditorStore.getState().clearDiffTabs("p1"); + + expect( + useEditorStore.getState().views.p1.tabs.map((item) => item.type), + ).toEqual(["file", "chat"]); + expect(localStorage.getItem(STORAGE_KEY)).not.toContain('"type":"diff"'); + }); + it("clears a worktree from memory and persistence", () => { useEditorStore.getState().open(tab("a.ts"), true); expect(localStorage.getItem(STORAGE_KEY)).toContain("a.ts"); diff --git a/src/features/editor/editorStore.ts b/src/features/editor/editorStore.ts index bf519e5..c8a7f07 100644 --- a/src/features/editor/editorStore.ts +++ b/src/features/editor/editorStore.ts @@ -206,6 +206,7 @@ interface EditorState { beginNavigation: () => number; invalidateFiles: (worktreeId: string) => void; invalidateDiffs: (worktreeId: string) => void; + clearDiffTabs: (worktreeId: string) => void; clearWorktree: (worktreeId: string) => void; } const emptyView = (): WorktreeEditorView => ({ tabs: [], activeTabId: null }); @@ -390,6 +391,22 @@ export const useEditorStore = create((set, get) => ({ [worktreeId]: (state.diffGenerationByWorktree[worktreeId] ?? 0) + 1, }, })), + clearDiffTabs: (worktreeId) => + set((state) => { + const view = state.views[worktreeId] ?? emptyView(); + const tabs = view.tabs.filter((tab) => tab.type !== "diff"); + const views = { + ...state.views, + [worktreeId]: { + tabs, + activeTabId: tabs.some((tab) => tab.id === view.activeTabId) + ? view.activeTabId + : (tabs[tabs.length - 1]?.id ?? null), + }, + }; + persistEditorViews(views); + return { views }; + }), clearWorktree: (worktreeId) => set((state) => { const views = { ...state.views }; diff --git a/src/features/files/FileTree.test.tsx b/src/features/files/FileTree.test.tsx index e20dc01..6ec1ec8 100644 --- a/src/features/files/FileTree.test.tsx +++ b/src/features/files/FileTree.test.tsx @@ -97,6 +97,36 @@ describe("FileTree accessibility", () => { ); }); + it("keeps the previous entries visible while watcher invalidation refreshes in the background", async () => { + let resolveRead!: ( + entries: Awaited>, + ) => void; + vi.mocked(commands.fsReadDir).mockReturnValueOnce( + new Promise((resolve) => { + resolveRead = resolve; + }), + ); + useFileTreeStore.setState({ + expandedByWorktree: { w1: [] }, + }); + render(); + + useFileTreeStore.getState().invalidateWorktree("w1"); + + await waitFor(() => + expect(commands.fsReadDir).toHaveBeenCalledWith("w1", ""), + ); + expect(screen.getByRole("treeitem", { name: "README.md" })).toBeVisible(); + expect(screen.queryByText("Loading…")).not.toBeInTheDocument(); + + resolveRead([ + { name: "updated.txt", relativePath: "updated.txt", kind: "file" }, + ]); + expect( + await screen.findByRole("treeitem", { name: "updated.txt" }), + ).toBeVisible(); + }); + it("falls back to the first visible item when the focused item disappears", async () => { const { rerender } = render(); const readme = screen.getByRole("treeitem", { name: "README.md" }); diff --git a/src/features/files/FileTree.tsx b/src/features/files/FileTree.tsx index 10b9777..274224f 100644 --- a/src/features/files/FileTree.tsx +++ b/src/features/files/FileTree.tsx @@ -49,10 +49,11 @@ function Directory({ ); useEffect(() => { const key = directoryKey(worktreeId, path); - if (state) return; + if (state && state.status !== "idle") return; + const previousEntries = state?.entries ?? []; tree.setDirectory(worktreeId, key, generation, { - status: "loading", - entries: [], + status: state ? "refreshing" : "loading", + entries: previousEntries, }); void commands.fsReadDir(worktreeId, path).then( (entries) => @@ -63,7 +64,7 @@ function Directory({ (error) => tree.setDirectory(worktreeId, key, generation, { status: "error", - entries: [], + entries: previousEntries, error: commandError(error), }), ); diff --git a/src/features/files/fileTreeStore.ts b/src/features/files/fileTreeStore.ts index 1c8f493..72bb6bd 100644 --- a/src/features/files/fileTreeStore.ts +++ b/src/features/files/fileTreeStore.ts @@ -2,7 +2,7 @@ import { create } from "zustand"; import type { CommandError, FileEntry } from "../../bindings"; export interface DirectoryState { - status: "idle" | "loading" | "ready" | "error"; + status: "idle" | "loading" | "refreshing" | "ready" | "error"; entries: FileEntry[]; error?: CommandError; } @@ -47,9 +47,12 @@ export const useFileTreeStore = create((set) => ({ invalidateWorktree: (worktreeId) => set((state) => ({ directories: Object.fromEntries( - Object.entries(state.directories).filter( - ([key]) => !key.startsWith(`${worktreeId}:`), - ), + Object.entries(state.directories).map(([key, directory]) => [ + key, + key.startsWith(`${worktreeId}:`) + ? { ...directory, status: "idle" as const } + : directory, + ]), ), generationByWorktree: { ...state.generationByWorktree, diff --git a/src/features/files/filesystemEvents.test.ts b/src/features/files/filesystemEvents.test.ts index 84d2023..09b49e5 100644 --- a/src/features/files/filesystemEvents.test.ts +++ b/src/features/files/filesystemEvents.test.ts @@ -31,7 +31,7 @@ describe("filesystem invalidation", () => { }); expect( useFileTreeStore.getState().directories[directoryKey("current", "")], - ).toBeUndefined(); + ).toMatchObject({ status: "idle", entries: [] }); expect( useFileTreeStore.getState().directories[directoryKey("other", "")], ).toBeDefined(); @@ -64,6 +64,6 @@ describe("filesystem invalidation", () => { }); expect( useFileTreeStore.getState().directories[directoryKey("current", "")], - ).toBeUndefined(); + ).toMatchObject({ status: "idle", entries: [] }); }); }); diff --git a/src/features/projects/ProjectRail.test.tsx b/src/features/projects/ProjectRail.test.tsx index 30d3412..97ad84c 100644 --- a/src/features/projects/ProjectRail.test.tsx +++ b/src/features/projects/ProjectRail.test.tsx @@ -19,12 +19,14 @@ vi.mock("./projectsApi", () => ({ })); beforeEach(() => { + vi.clearAllMocks(); useProjectsStore.setState({ projects: [ { id: "project-1", name: "spirecode-client", path: "/repo", + kind: "git", lastOpenedAt: 1, worktrees: [ { @@ -58,6 +60,18 @@ beforeEach(() => { }); describe("ProjectRail", () => { + it("opens an existing folder", async () => { + vi.mocked(projectsApi.openDialog).mockResolvedValueOnce(null); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Open folder" })); + + await vi.waitFor(() => expect(projectsApi.openDialog).toHaveBeenCalled()); + expect( + screen.queryByRole("button", { name: "New project" }), + ).not.toBeInTheDocument(); + }); + it("renders the shared SpireCode brand mark", () => { render(); expect(screen.getByTestId("spirecode-mark")).toBeInTheDocument(); @@ -100,7 +114,49 @@ describe("ProjectRail", () => { ).not.toBeInTheDocument(); }); - it("manages a project with reveal, copy path, and close actions", async () => { + it("selects a directory project's primary workspace without exposing worktree controls", async () => { + useProjectsStore.setState({ + projects: [ + { + id: "directory-1", + name: "notes", + path: "/notes", + kind: "directory", + lastOpenedAt: 1, + worktrees: [ + { + id: "directory-main", + projectId: "directory-1", + name: "notes", + path: "/notes", + branch: "", + baseRef: "", + kind: "main", + lastOpenedAt: 1, + }, + ], + }, + ], + activeWorktreeId: null, + }); + + render(); + + expect( + screen.queryByRole("button", { name: "Expand notes" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Create worktree for notes" }), + ).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Open notes" })); + + await vi.waitFor(() => + expect(projectsApi.selectWorktree).toHaveBeenCalledWith("directory-main"), + ); + expect(useProjectsStore.getState().activeWorktreeId).toBe("directory-main"); + }); + + it("manages a project with reveal and close actions", async () => { render(); fireEvent.click( screen.getByRole("button", { name: "Manage spirecode-client" }), diff --git a/src/features/projects/ProjectRail.tsx b/src/features/projects/ProjectRail.tsx index 2bd2f30..25e219e 100644 --- a/src/features/projects/ProjectRail.tsx +++ b/src/features/projects/ProjectRail.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { RiAddLine, RiArrowRightSLine, + RiFolderLine, RiFolderOpenLine, RiGitBranchLine, RiGitRepositoryLine, @@ -135,13 +136,17 @@ export function ProjectRail() { {store.loading ? ( ) : ( - + )}
{store.projects.map((project) => { - const expanded = expandedProjectIds.has(project.id); + const directory = project.kind === "directory"; + const expanded = !directory && expandedProjectIds.has(project.id); + const primaryWorkspace = + project.worktrees.find(({ kind }) => kind === "main") ?? + project.worktrees[0]; return (
@@ -149,38 +154,56 @@ export function ProjectRail() { className="project-name" title={project.path} aria-label={t( - expanded ? "projects.collapse" : "projects.expand", + directory + ? "projects.open" + : expanded + ? "projects.collapse" + : "projects.expand", { name: project.name }, )} - aria-expanded={expanded} - onClick={() => toggleProject(project.id)} + aria-expanded={directory ? undefined : expanded} + onClick={() => { + if (directory) { + if (primaryWorkspace) void select(primaryWorkspace.id); + return; + } + toggleProject(project.id); + }} > - - + {!directory && ( + + )} + {directory ? ( + + ) : ( + + )} {project.name} - + {!directory && ( + + )}
{ projects: [ { id: "project-id", + kind: "git", name: "Project", path: "/repo", lastOpenedAt: 1, diff --git a/src/features/projects/projectsStore.test.ts b/src/features/projects/projectsStore.test.ts index 0e82fa9..230e83f 100644 --- a/src/features/projects/projectsStore.test.ts +++ b/src/features/projects/projectsStore.test.ts @@ -21,6 +21,7 @@ const project = ( ...worktrees: WorktreeSummary[] ): ProjectSummary => ({ id, + kind: "git", name: id, path: `/tmp/${id}`, lastOpenedAt: 1, diff --git a/src/features/workbench/BreadcrumbSwitcher.test.tsx b/src/features/workbench/BreadcrumbSwitcher.test.tsx index a437012..d42ba77 100644 --- a/src/features/workbench/BreadcrumbSwitcher.test.tsx +++ b/src/features/workbench/BreadcrumbSwitcher.test.tsx @@ -44,6 +44,7 @@ const projects: ProjectSummary[] = [ id: "p-one", name: "Alpha Project", path: "/one", + kind: "git", lastOpenedAt: 1, worktrees: [featureWorktree, mainWorktree], }, @@ -51,6 +52,7 @@ const projects: ProjectSummary[] = [ id: "p-two", name: "Beta Project", path: "/two", + kind: "git", lastOpenedAt: 1, worktrees: [fallbackWorktree], }, @@ -58,6 +60,7 @@ const projects: ProjectSummary[] = [ id: "p-empty", name: "Empty Project", path: "/empty", + kind: "git", lastOpenedAt: 1, worktrees: [], }, @@ -116,6 +119,43 @@ describe("BreadcrumbSwitcher", () => { ).not.toBeInTheDocument(); }); + it("renders only the project switcher for a directory project", () => { + const directoryWorktree: WorktreeSummary = { + ...mainWorktree, + id: "w-directory", + projectId: "p-directory", + name: "Notes", + path: "/notes", + branch: "", + baseRef: "", + }; + const directoryProject: ProjectSummary = { + id: "p-directory", + name: "Notes", + path: "/notes", + kind: "directory", + lastOpenedAt: 1, + worktrees: [directoryWorktree], + }; + + render( + , + ); + + expect( + screen.getByRole("button", { name: "Project: Notes" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Worktree: Notes" }), + ).not.toBeInTheDocument(); + expect(document.querySelector(".breadcrumb-separator")).toBeNull(); + expect(document.querySelector(".breadcrumb-branch")).toBeNull(); + }); + it("selects a project's main worktree, falls back to its first worktree, and disables empty projects", async () => { renderSwitcher(); fireEvent.click( diff --git a/src/features/workbench/BreadcrumbSwitcher.tsx b/src/features/workbench/BreadcrumbSwitcher.tsx index ebfe6ff..0edf514 100644 --- a/src/features/workbench/BreadcrumbSwitcher.tsx +++ b/src/features/workbench/BreadcrumbSwitcher.tsx @@ -371,14 +371,22 @@ export function BreadcrumbSwitcher({ "breadcrumb.project.trigger", "project", )} - - {trigger("worktree", activeWorktree.name, "breadcrumb.worktree.trigger")} - - {branch} + {activeProject.kind === "git" && ( + <> + + {trigger( + "worktree", + activeWorktree.name, + "breadcrumb.worktree.trigger", + )} + + {branch} + + )}
); } diff --git a/src/features/workbench/Workbench.test.tsx b/src/features/workbench/Workbench.test.tsx index 1528b2c..e3fd615 100644 --- a/src/features/workbench/Workbench.test.tsx +++ b/src/features/workbench/Workbench.test.tsx @@ -6,7 +6,7 @@ import { refreshChanges } from "../changes/changesRefresh"; import { useProjectsStore } from "../projects/projectsStore"; import { Workbench } from "./Workbench"; import { settingsApi } from "../settings/settingsApi"; -import { resetWorkbenchStore } from "./workbenchStore"; +import { resetWorkbenchStore, useWorkbenchStore } from "./workbenchStore"; vi.mock("../projects/ProjectRail", () => ({ ProjectRail: () => , @@ -27,16 +27,20 @@ vi.mock("./BreadcrumbSwitcher", () => ({ activeWorktree, branch, }: { - activeProject: { name: string }; + activeProject: { name: string; kind: "git" | "directory" }; activeWorktree: { name: string }; branch: string; }) => (
- {" > "} - - {" > "} - {branch} + {activeProject.kind === "git" && ( + <> + {" > "} + + {" > "} + {branch} + + )}
), })); @@ -77,6 +81,7 @@ beforeEach(() => { id: "p1", name: "Project", path: "/repo", + kind: "git", lastOpenedAt: 1, worktrees: [ { @@ -133,6 +138,83 @@ describe("Workbench panel handles", () => { expect(refreshChanges).toHaveBeenCalledWith("w1"); }); + it("uses Files without mutating the Changes preference for a directory project", () => { + useProjectsStore.setState({ + projects: [ + { + id: "directory", + name: "Notes", + path: "/notes", + kind: "directory", + lastOpenedAt: 1, + worktrees: [ + { + id: "directory-main", + projectId: "directory", + name: "Notes", + path: "/notes", + branch: "", + baseRef: "", + kind: "main", + lastOpenedAt: 1, + }, + ], + }, + ], + activeWorktreeId: "directory-main", + }); + useWorkbenchStore.getState().setRightView("changes"); + + render(); + + expect(refreshChanges).not.toHaveBeenCalled(); + expect( + screen.queryByRole("button", { name: "CHANGES" }), + ).not.toBeInTheDocument(); + expect(screen.getByText("Files")).toBeInTheDocument(); + expect(screen.queryByText("Changes")).not.toBeInTheDocument(); + expect(useWorkbenchStore.getState().rightView).toBe("changes"); + expect( + screen.getByRole("navigation", { name: "Project context" }), + ).toHaveTextContent("Notes"); + }); + + it("restores the Changes preference when switching back to a Git project", () => { + useWorkbenchStore.getState().setRightView("changes"); + const directoryProject = { + id: "directory", + name: "Notes", + path: "/notes", + kind: "directory" as const, + lastOpenedAt: 1, + worktrees: [ + { + id: "directory-main", + projectId: "directory", + name: "Notes", + path: "/notes", + branch: "", + baseRef: "", + kind: "main" as const, + lastOpenedAt: 1, + }, + ], + }; + const gitProject = useProjectsStore.getState().projects[0]; + useProjectsStore.setState({ + projects: [directoryProject, gitProject], + activeWorktreeId: "directory-main", + }); + const view = render(); + expect(screen.getByText("Files")).toBeInTheDocument(); + + act(() => useProjectsStore.getState().selectWorktree("w1")); + view.rerender(); + + expect(screen.getByText("Changes")).toBeInTheDocument(); + expect(refreshChanges).toHaveBeenCalledWith("w1"); + }); + it("shows the SpireCode identity and slogan when no project is active", () => { useProjectsStore.setState({ projects: [], activeWorktreeId: null }); @@ -162,7 +244,7 @@ describe("Workbench panel handles", () => { ).toBeInTheDocument(); expect(screen.getByText("快速轻量的图形化代码智能体")).toBeInTheDocument(); expect( - screen.getByRole("button", { name: "打开项目" }), + screen.getByRole("button", { name: "打开目录" }), ).toBeInTheDocument(); }); diff --git a/src/features/workbench/Workbench.tsx b/src/features/workbench/Workbench.tsx index f41c200..f6b2719 100644 --- a/src/features/workbench/Workbench.tsx +++ b/src/features/workbench/Workbench.tsx @@ -12,6 +12,7 @@ import { ChangesPanel } from "../changes/ChangesPanel"; import { refreshChanges } from "../changes/changesRefresh"; import { useChangesStore } from "../changes/changesStore"; import { EditorPane } from "../editor/EditorPane"; +import { useEditorStore } from "../editor/editorStore"; import { FileTree } from "../files/FileTree"; import { ProjectRail } from "../projects/ProjectRail"; import { useProjectsStore } from "../projects/projectsStore"; @@ -91,10 +92,20 @@ export function Workbench() { : null, ); useEffect(() => { - if (activeWorktreeId) void refreshChanges(activeWorktreeId); - }, [activeWorktreeId]); + if (!active) return; + if (active.project.kind === "git") { + void refreshChanges(active.worktree.id); + return; + } + useEditorStore.getState().clearDiffTabs(active.worktree.id); + }, [active]); const error = useProjectsStore((state) => state.error); const workbench = useWorkbenchStore(); + const supportsGit = active?.project.kind === "git"; + const visibleRightView = + !supportsGit && workbench.rightView === "changes" + ? "files" + : workbench.rightView; const resizeProjects = (width: number) => { const right = workbench.rightCollapsed ? 0 : workbench.rightPanelWidth; const available = viewportWidth - right - MIN_EDITOR_WIDTH - PANEL_GAP; @@ -218,24 +229,26 @@ export function Workbench() {