Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/app/src/context/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useServerSync } from "./server-sync"
import { useServerSDK } from "./server-sdk"
import { trackProjectMoves } from "./project-moves"
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2"
Expand Down Expand Up @@ -512,6 +513,34 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
})
})

// A moved or renamed project folder keeps its identity through the
// repo-local id, and the server adopts the new path the next time the
// project is opened from its new location. Open-project entries are
// keyed by path, so follow the move: relocate the old entry instead of
// leaving it at the dead location while the new path shows up as a
// separate project.
const seenWorktrees = new Map<string, string>()
createEffect(() => {
const moves = trackProjectMoves(seenWorktrees, serverSync().data.project)
if (moves.length === 0) return

batch(() => {
for (const move of moves) {
const open = server.projects.list()
const entry = open.find((project) => project.worktree === move.from)
if (!entry) continue
const index = open.indexOf(entry)

server.projects.remove(move.from)
server.projects.open(move.to)
server.projects.move(move.to, index)
if (entry.expanded) server.projects.expand(move.to)
else server.projects.collapse(move.to)
if (server.projects.last() === move.from) server.projects.touch(move.to)
}
})
})

const enriched = createMemo(() => server.projects.list().map(enrich))
const list = createMemo(() => {
const projects = enriched()
Expand Down Expand Up @@ -630,6 +659,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}),
open(directory: string) {
const root = rootFor(directory)
// Always touch the server, even when an entry for this path is
// already listed: the entry may be stale (its folder moved away and
// back, or another checkout re-homed here). Touching re-identifies
// the directory server-side and emits the project.updated event
// that reconciles the sidebar.
void serverSync().project.touch(root)
if (server.projects.list().find((x) => x.worktree === root)) return
void serverSync().project.loadSessions(root)
server.projects.open(root)
Expand Down
61 changes: 61 additions & 0 deletions packages/app/src/context/project-moves.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, test } from "bun:test"
import { trackProjectMoves } from "./project-moves"

describe("trackProjectMoves", () => {
test("reports nothing on first sight of a project", () => {
const seen = new Map<string, string>()
const moves = trackProjectMoves(seen, [{ id: "a", worktree: "/repo" }])
expect(moves).toEqual([])
expect(seen.get("a")).toBe("/repo")
})

test("reports a move when a known project changes worktree", () => {
const seen = new Map<string, string>()
trackProjectMoves(seen, [{ id: "a", worktree: "/repo.old" }])
const moves = trackProjectMoves(seen, [{ id: "a", worktree: "/repo.new" }])
expect(moves).toEqual([{ id: "a", from: "/repo.old", to: "/repo.new" }])
expect(seen.get("a")).toBe("/repo.new")
})

test("does not report unchanged worktrees", () => {
const seen = new Map<string, string>()
trackProjectMoves(seen, [{ id: "a", worktree: "/repo" }])
expect(trackProjectMoves(seen, [{ id: "a", worktree: "/repo" }])).toEqual([])
})

test("reports each transition exactly once", () => {
const seen = new Map<string, string>()
trackProjectMoves(seen, [{ id: "a", worktree: "/repo.old" }])
trackProjectMoves(seen, [{ id: "a", worktree: "/repo.new" }])
expect(trackProjectMoves(seen, [{ id: "a", worktree: "/repo.new" }])).toEqual([])
})

test("tracks multiple projects independently", () => {
const seen = new Map<string, string>()
trackProjectMoves(seen, [
{ id: "a", worktree: "/one" },
{ id: "b", worktree: "/two" },
])
const moves = trackProjectMoves(seen, [
{ id: "a", worktree: "/one.moved" },
{ id: "b", worktree: "/two" },
])
expect(moves).toEqual([{ id: "a", from: "/one", to: "/one.moved" }])
})

test("two projects swapping does not lose either transition", () => {
const seen = new Map<string, string>()
trackProjectMoves(seen, [
{ id: "a", worktree: "/one" },
{ id: "b", worktree: "/two" },
])
const moves = trackProjectMoves(seen, [
{ id: "a", worktree: "/two" },
{ id: "b", worktree: "/one" },
])
expect(moves).toEqual([
{ id: "a", from: "/one", to: "/two" },
{ id: "b", from: "/two", to: "/one" },
])
})
})
32 changes: 32 additions & 0 deletions packages/app/src/context/project-moves.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export interface ProjectWorktree {
readonly id: string
readonly worktree: string
}

export interface ProjectMove {
readonly id: string
readonly from: string
readonly to: string
}

/**
* Tracks the last seen worktree per project id and reports transitions.
*
* A moved or renamed project folder keeps its identity through the repo-local
* id file, and the server adopts the new path the next time the project is
* opened from its new location. Observing the worktree change here lets the
* client relocate path-keyed state (open-project entries, last-project) so
* the old entry does not linger at the dead location while the new path
* shows up as a separate project.
*/
export function trackProjectMoves(seen: Map<string, string>, projects: readonly ProjectWorktree[]): ProjectMove[] {
const moves: ProjectMove[] = []
for (const project of projects) {
const previous = seen.get(project.id)
seen.set(project.id, project.worktree)
if (previous && previous !== project.worktree) {
moves.push({ id: project.id, from: previous, to: project.worktree })
}
}
return moves
}
9 changes: 9 additions & 0 deletions packages/app/src/context/server-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {

const projectApi = {
loadSessions,
// Ask the server to (re)identify a directory. Instance-scoped, so a stale
// cached instance revalidates and a moved checkout re-homes its project,
// emitting the project.updated event that reconciles the sidebar.
touch(directory: string) {
return sdkFor(directory)
.project.current()
.then(() => undefined)
.catch(() => undefined)
},
meta(directory: string, patch: ProjectMeta) {
children.projectMeta(directory, patch)
},
Expand Down
61 changes: 51 additions & 10 deletions packages/core/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ export type ID = ProjectSchema.ID
export const Vcs = ProjectSchema.Vcs
export type Vcs = ProjectSchema.Vcs

const STABLE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i

/**
* Whether an id is a stable minted repo identity (uuid) rather than a legacy
* derived id (remote hash, root commit, cached value) or the global sentinel.
*/
export function isStableID(id: string) {
return STABLE_ID_PATTERN.test(id)
}

export class Info extends Schema.Class<Info>("Project.Info")({
id: ID,
}) {}
Expand All @@ -38,15 +48,17 @@ export interface Interface {
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
/**
* Temporary bridge method for writing the resolved project ID to the repo-local cache.
* Temporary bridge method for writing a project's minted identity to the
* repo-local cache (`<commonDir>/opencode`) as versioned JSON.
*
* This exists while the old opencode project service and this core project
* service work together: core resolves the ID, while the old service still owns
* database migration and persistence. The old service should call this after it
* finishes migrating from `resolve().previous` to `resolve().id`; once project
* persistence moves into core, this separate bridge method can go away.
* minting, database migration, and persistence. Returns whether the write
* landed so callers only adopt a minted identity that is durably stored;
* once project persistence moves into core, this separate bridge method can
* go away.
*/
readonly commit: (input: { store: AbsolutePath; id: ID }) => Effect.Effect<void>
readonly commit: (input: { store: AbsolutePath; id: ID }) => Effect.Effect<boolean>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectV2") {}
Expand All @@ -62,12 +74,28 @@ const layer = Layer.effect(
return yield* projectDirectories.list(input.projectID)
})

const parse = (content: string): { repoID?: ID; legacy?: ID } => {
try {
const parsed: unknown = JSON.parse(content)
if (parsed && typeof parsed === "object") {
const repoID = "repoID" in parsed ? parsed.repoID : undefined
// Forward-compatible read: honor the repoID of any structured
// version, ignore structured content we do not understand.
if (typeof repoID === "string" && isStableID(repoID)) return { repoID: ID.make(repoID) }
return {}
}
} catch {}
// Bare string contents predate the versioned format.
return { legacy: ID.make(content) }
}

const cached = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
const content = yield* fs.readFileString(path.join(dir, "opencode")).pipe(
Effect.map((value) => value.trim()),
Effect.map((value) => (value ? ID.make(value) : undefined)),
Effect.catch(() => Effect.succeed(undefined)),
)
if (!content) return { repoID: undefined, legacy: undefined }
return parse(content)
})

const remote = Effect.fnUntraced(function* (repo: Git.Repository) {
Expand Down Expand Up @@ -111,18 +139,31 @@ const layer = Layer.effect(
const repo = yield* git.repo.discover(input)
if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }

const previous = yield* cached(repo.commonDirectory)
const vcs = { type: "git" as const, store: repo.commonDirectory }
const stored = yield* cached(repo.commonDirectory)
// A minted identity persisted in the versioned cache file is
// authoritative: it is what keeps independent clones of the same
// remote distinct while linked worktrees (shared common dir) and
// renamed checkouts keep resolving to the same project.
if (stored.repoID) return { id: stored.repoID, directory: repo.worktree, vcs }

const previous = stored.legacy
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
return {
previous,
id: id ?? ID.global,
directory: repo.worktree,
vcs: { type: "git" as const, store: repo.commonDirectory },
vcs,
}
})

const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore)
return yield* fs
.writeFileString(path.join(input.store, "opencode"), JSON.stringify({ version: 1, repoID: input.id }) + "\n")
.pipe(
Effect.map(() => true),
Effect.catch(() => Effect.succeed(false)),
)
})

return Service.of({ directories, resolve, commit })
Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/effect/layer-node/node-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ describe("node build", () => {
return Project.Service.of({
directories: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }),
commit: () => Effect.void,
commit: () => Effect.succeed(true),
})
}),
)
Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/location.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const projectLayer = Layer.succeed(
directory: AbsolutePath.make("/repo"),
vcs: { type: "git", store: AbsolutePath.make("/repo/.git") },
}),
commit: () => Effect.void,
commit: () => Effect.succeed(true),
}),
)
const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [[Project.node, projectLayer]]))
Expand Down
Loading
Loading