diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index d43310b19..19f5e9a3b 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -130,10 +130,57 @@ export default function FileTree(props: { const nodes = file.tree.children(props.path) const current = filter() if (!current) return nodes - return nodes.filter((node) => { + + const parent = (path: string) => { + const idx = path.lastIndexOf("/") + if (idx === -1) return "" + return path.slice(0, idx) + } + + const leaf = (path: string) => { + const idx = path.lastIndexOf("/") + return idx === -1 ? path : path.slice(idx + 1) + } + + const out = nodes.filter((node) => { if (node.type === "file") return current.files.has(node.path) return current.dirs.has(node.path) }) + + const seen = new Set(out.map((node) => node.path)) + + for (const dir of current.dirs) { + if (parent(dir) !== props.path) continue + if (seen.has(dir)) continue + out.push({ + name: leaf(dir), + path: dir, + absolute: dir, + type: "directory", + ignored: false, + }) + seen.add(dir) + } + + for (const item of current.files) { + if (parent(item) !== props.path) continue + if (seen.has(item)) continue + out.push({ + name: leaf(item), + path: item, + absolute: item, + type: "file", + ignored: false, + }) + seen.add(item) + } + + return out.toSorted((a, b) => { + if (a.type !== b.type) { + return a.type === "directory" ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) }) const Node = ( diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 772ad063b..540046c09 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -500,9 +500,7 @@ export default function Page() { const out = new Map() for (const diff of diffs()) { const file = normalize(diff.file) - const add = diff.additions > 0 - const del = diff.deletions > 0 - const kind = add && del ? "mix" : add ? "add" : del ? "del" : "mix" + const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" out.set(file, kind) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 1c1539090..b3c8a905c 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -188,6 +188,7 @@ export namespace Snapshot { after: z.string(), additions: z.number(), deletions: z.number(), + status: z.enum(["added", "deleted", "modified"]).optional(), }) .meta({ ref: "FileDiff", @@ -196,6 +197,23 @@ export namespace Snapshot { export async function diffFull(from: string, to: string): Promise { const git = gitdir() const result: FileDiff[] = [] + const status = new Map() + + const statuses = + await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-status --no-renames ${from} ${to} -- .` + .quiet() + .cwd(Instance.directory) + .nothrow() + .text() + + for (const line of statuses.trim().split("\n")) { + if (!line) continue + const [code, file] = line.split("\t") + if (!code || !file) continue + const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified" + status.set(file, kind) + } + for await (const line of $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .` .quiet() .cwd(Instance.directory) @@ -224,6 +242,7 @@ export namespace Snapshot { after, additions: Number.isFinite(added) ? added : 0, deletions: Number.isFinite(deleted) ? deleted : 0, + status: status.get(file) ?? "modified", }) } return result diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index ef6271ed5..091469ec7 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -749,6 +749,52 @@ test("revert preserves file that existed in snapshot when deleted then recreated }) }) +test("diffFull sets status based on git change type", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Bun.write(`${tmp.path}/grow.txt`, "one\n") + await Bun.write(`${tmp.path}/trim.txt`, "line1\nline2\n") + await Bun.write(`${tmp.path}/delete.txt`, "gone") + + const before = await Snapshot.track() + expect(before).toBeTruthy() + + await Bun.write(`${tmp.path}/grow.txt`, "one\ntwo\n") + await Bun.write(`${tmp.path}/trim.txt`, "line1\n") + await $`rm ${tmp.path}/delete.txt`.quiet() + await Bun.write(`${tmp.path}/added.txt`, "new") + + const after = await Snapshot.track() + expect(after).toBeTruthy() + + const diffs = await Snapshot.diffFull(before!, after!) + expect(diffs.length).toBe(4) + + const added = diffs.find((d) => d.file === "added.txt") + expect(added).toBeDefined() + expect(added!.status).toBe("added") + + const deleted = diffs.find((d) => d.file === "delete.txt") + expect(deleted).toBeDefined() + expect(deleted!.status).toBe("deleted") + + const grow = diffs.find((d) => d.file === "grow.txt") + expect(grow).toBeDefined() + expect(grow!.status).toBe("modified") + expect(grow!.additions).toBeGreaterThan(0) + expect(grow!.deletions).toBe(0) + + const trim = diffs.find((d) => d.file === "trim.txt") + expect(trim).toBeDefined() + expect(trim!.status).toBe("modified") + expect(trim!.additions).toBe(0) + expect(trim!.deletions).toBeGreaterThan(0) + }, + }) +}) + test("diffFull with new file additions", async () => { await using tmp = await bootstrap() await Instance.provide({ diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 0556e1ad9..085c9d9c7 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -96,6 +96,7 @@ export type FileDiff = { after: string additions: number deletions: number + status?: "added" | "deleted" | "modified" } export type UserMessage = {