Compare commits

...
6 changed files with 176 additions and 9 deletions
@@ -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
View File
@@ -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
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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", () => {