Prechádzať zdrojové kódy

fix(desktop): handle moved projects and deleted paths

𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 1 mesiac pred
rodič
commit
c9e1aedd42

+ 1 - 0
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)

+ 11 - 4
packages/app/src/components/file-tree.tsx

@@ -32,7 +32,12 @@ type Filter = {
   dirs: Set<string>
 }
 
-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)
+                  }
                 >
                   <Collapsible.Trigger>
                     <FileTreeNode

+ 28 - 0
packages/app/src/context/file/tree-store.test.ts

@@ -0,0 +1,28 @@
+import { describe, expect, test } from "bun:test"
+import { createRoot } from "solid-js"
+import { createFileTreeStore } from "./tree-store"
+
+describe("file tree store", () => {
+  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()
+  })
+})

+ 2 - 1
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)
   }
 

+ 51 - 3
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<Schema.Schema.Type<typeof Info>>
@@ -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) {

+ 83 - 1
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", () => {