mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-24 19:56:14 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9e1aedd42 |
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user