diff --git a/packages/app/src/components/file-tree.test.ts b/packages/app/src/components/file-tree.test.ts index 20bffc41a3..7064787924 100644 --- a/packages/app/src/components/file-tree.test.ts +++ b/packages/app/src/components/file-tree.test.ts @@ -40,6 +40,7 @@ beforeAll(async () => { describe("file tree fetch discipline", () => { test("root lists on mount unless already loaded or loading", () => { expect(shouldListRoot({ level: 0 })).toBe(true) + expect(shouldListRoot({ level: 0, filtered: true })).toBe(false) expect(shouldListRoot({ level: 0, dir: { loaded: true } })).toBe(false) expect(shouldListRoot({ level: 0, dir: { loading: true } })).toBe(false) expect(shouldListRoot({ level: 1 })).toBe(false) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 211ce05ef0..ff69b1a722 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -32,7 +32,12 @@ type Filter = { dirs: Set } -export function shouldListRoot(input: { level: number; dir?: { loaded?: boolean; loading?: boolean } }) { +export function shouldListRoot(input: { + level: number + filtered?: boolean + dir?: { loaded?: boolean; loading?: boolean } +}) { + if (input.filtered) return false if (input.level !== 0) return false if (input.dir?.loaded) return false if (input.dir?.loading) return false @@ -309,7 +314,7 @@ export default function FileTree(props: { filter: current, expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false, }) - for (const dir of dirs) file.tree.expand(dir) + for (const dir of dirs) file.tree.expand(dir, { load: false }) }) createEffect( @@ -317,7 +322,7 @@ export default function FileTree(props: { () => props.path, (path) => { const dir = untrack(() => file.tree.state(path)) - if (!shouldListRoot({ level, dir })) return + if (!shouldListRoot({ level, filtered: !!filter(), dir })) return void file.tree.list(path) }, { defer: false }, @@ -401,7 +406,9 @@ export default function FileTree(props: { data-scope="filetree" forceMount={false} open={expanded()} - onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} + onOpenChange={(open) => + open ? file.tree.expand(node.path, { load: !filter() }) : file.tree.collapse(node.path) + } > { + test("expands synthetic directories without listing them", () => { + const listed: string[] = [] + const value = createRoot((dispose) => ({ + dispose, + tree: createFileTreeStore({ + scope: () => "/project", + normalizeDir: (input) => input, + list: (input) => { + listed.push(input) + return Promise.resolve([]) + }, + onError: () => undefined, + }), + })) + + value.tree.expandDir("deleted/parent", { load: false }) + + expect(value.tree.dirState("deleted/parent")?.expanded).toBe(true) + expect(listed).toEqual([]) + + value.dispose() + }) +}) diff --git a/packages/app/src/context/file/tree-store.ts b/packages/app/src/context/file/tree-store.ts index a86051d286..f10d41acc4 100644 --- a/packages/app/src/context/file/tree-store.ts +++ b/packages/app/src/context/file/tree-store.ts @@ -127,10 +127,11 @@ export function createFileTreeStore(options: TreeStoreOptions) { return promise } - const expandDir = (input: string) => { + const expandDir = (input: string, opts?: { load?: boolean }) => { const dir = options.normalizeDir(input) ensureDir(dir) setTree("dir", dir, "expanded", true) + if (opts?.load === false) return void listDir(dir) } diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index ab8f1663dd..5bf4730ece 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -22,6 +22,8 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2 } from "@opencode-ai/core/event" import { Project } from "@opencode-ai/schema/project" +import { SessionContextEpoch } from "@opencode-ai/core/session/context-epoch" +import path from "path" export const Info = Project.Info export type Info = Types.DeepMutable> @@ -229,12 +231,19 @@ export const layer = Layer.effect( sandboxes: [] as string[], time: { created: Date.now(), updated: Date.now() }, } + const previousWorktree = + row && + projectID !== ProjectV2.ID.global && + existing.worktree !== worktree && + !(yield* fs.isDir(existing.worktree)) + ? existing.worktree + : undefined if (flags.experimentalIconDiscovery) yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope)) const result: Info = { ...existing, - worktree: projectID === ProjectV2.ID.global ? worktree : existing.worktree, + worktree: projectID === ProjectV2.ID.global || previousWorktree ? worktree : existing.worktree, vcs: data.vcs?.type ?? fakeVcs, time: { ...existing.time, updated: Date.now() }, } @@ -252,7 +261,11 @@ export const layer = Layer.effect( Effect.map((exists) => (exists ? s : undefined)), ), { concurrency: "unbounded" }, - ).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined))) + ).pipe( + Effect.map((arr) => + arr.filter((x): x is string => x !== undefined && x !== result.worktree && x !== previousWorktree), + ), + ) yield* db .insert(ProjectTable) @@ -288,6 +301,29 @@ export const layer = Layer.effect( .run() .pipe(Effect.orDie) + if (previousWorktree) { + const sessions = yield* db + .select({ id: SessionTable.id, directory: SessionTable.directory }) + .from(SessionTable) + .where(eq(SessionTable.project_id, projectID)) + .all() + .pipe(Effect.orDie) + yield* Effect.forEach( + sessions.filter((session) => FSUtil.contains(previousWorktree, session.directory)), + (session) => + Effect.gen(function* () { + yield* db + .update(SessionTable) + .set({ directory: path.join(result.worktree, path.relative(previousWorktree, session.directory)) }) + .where(eq(SessionTable.id, session.id)) + .run() + .pipe(Effect.orDie) + yield* SessionContextEpoch.reset(db, session.id) + }), + { concurrency: 1, discard: true }, + ) + } + if (projectID !== ProjectV2.ID.global) { yield* db .update(SessionTable) @@ -301,6 +337,9 @@ export const layer = Layer.effect( projectID, directory: data.directory, }) + if (previousWorktree) { + yield* projectDirectories.remove({ projectID, directory: AbsolutePath.make(previousWorktree) }) + } yield* emitUpdated(result) if (projectID !== ProjectV2.ID.global && data.vcs?.type === "git") { @@ -334,7 +373,16 @@ export const layer = Layer.effect( }) const list = Effect.fn("Project.list")(function* () { - return (yield* db.select().from(ProjectTable).all().pipe(Effect.orDie)).map(fromRow) + const projects = (yield* db.select().from(ProjectTable).all().pipe(Effect.orDie)).map(fromRow) + return (yield* Effect.forEach( + projects, + Effect.fnUntraced(function* (project) { + if (project.id === ProjectV2.ID.global) return project + if (yield* fs.isDir(project.worktree)) return project + return undefined + }), + { concurrency: "unbounded" }, + )).filter((project): project is Info => project !== undefined) }) const get = Effect.fn("Project.get")(function* (id: ProjectV2.ID) { diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 05e205cd87..f63c9de545 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -6,7 +6,7 @@ import path from "path" import { tmpdirScoped } from "../fixture/fixture" import { GlobalBus } from "../../src/bus/global" import { Database } from "@opencode-ai/core/database/database" -import { ProjectTable } from "@opencode-ai/core/project/sql" +import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql" import { SessionTable } from "@opencode-ai/core/session/sql" import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" import { eq } from "drizzle-orm" @@ -23,6 +23,7 @@ import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" import { RuntimeFlags } from "@/effect/runtime-flags" +import { AbsolutePath } from "@opencode-ai/core/schema" const encoder = new TextEncoder() @@ -403,6 +404,87 @@ describe("Project.fromDirectory with worktrees", () => { expect(result.project.sandboxes).not.toContain(tmp) }), ) + + it.live("relocates a project and its sessions when the primary checkout moved", () => + Effect.gen(function* () { + const { db } = yield* Database.Service + const project = yield* Project.Service + const tmp = yield* tmpdirScoped({ git: true }) + const original = yield* project.fromDirectory(tmp) + const moved = `${tmp}-moved` + const rootSession = SessionID.make(`ses_${crypto.randomUUID()}`) + const nestedSession = SessionID.make(`ses_${crypto.randomUUID()}`) + yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${moved}`.quiet().nothrow()).pipe(Effect.ignore)) + yield* db + .insert(SessionTable) + .values([ + { + id: rootSession, + project_id: original.project.id, + slug: rootSession, + directory: tmp, + title: "root", + version: "test", + time_created: 1, + time_updated: 1, + }, + { + id: nestedSession, + project_id: original.project.id, + slug: nestedSession, + directory: path.join(tmp, "packages", "app"), + path: "packages/app", + title: "nested", + version: "test", + time_created: 2, + time_updated: 2, + }, + ]) + .run() + .pipe(Effect.orDie) + yield* Effect.promise(() => $`mv ${tmp} ${moved}`.quiet()) + + const result = yield* project.fromDirectory(moved) + const sessions = yield* db + .select({ id: SessionTable.id, directory: SessionTable.directory, path: SessionTable.path }) + .from(SessionTable) + .where(eq(SessionTable.project_id, original.project.id)) + .all() + .pipe(Effect.orDie) + const directories = yield* db + .select({ directory: ProjectDirectoryTable.directory }) + .from(ProjectDirectoryTable) + .where(eq(ProjectDirectoryTable.project_id, original.project.id)) + .all() + .pipe(Effect.orDie) + + expect(result.project.worktree).toBe(moved) + expect(result.project.sandboxes).not.toContain(tmp) + expect(result.project.sandboxes).not.toContain(moved) + expect(directories.map((item) => item.directory)).toEqual([AbsolutePath.make(moved)]) + expect(sessions).toContainEqual({ id: rootSession, directory: moved, path: null }) + expect(sessions).toContainEqual({ + id: nestedSession, + directory: path.join(moved, "packages", "app"), + path: "packages/app", + }) + }), + ) + + it.live("omits projects whose primary checkout no longer exists", () => + Effect.gen(function* () { + const project = yield* Project.Service + const tmp = yield* tmpdirScoped({ git: true }) + const original = yield* project.fromDirectory(tmp) + const moved = `${tmp}-moved` + yield* Effect.addFinalizer(() => Effect.promise(() => $`rm -rf ${moved}`.quiet().nothrow()).pipe(Effect.ignore)) + yield* Effect.promise(() => $`mv ${tmp} ${moved}`.quiet()) + + const result = (yield* project.list()).find((item) => item.id === original.project.id) + + expect(result).toBeUndefined() + }), + ) }) describe("Project.discover", () => {