mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-17 12:56:41 +02:00
wip
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { parsePatch } from "diff"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||
import { useBindings } from "../keymap"
|
||||
|
||||
type DiffFile = {
|
||||
readonly file: string
|
||||
readonly patch: string
|
||||
readonly additions: number
|
||||
readonly deletions: number
|
||||
readonly status: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
const stripPrefix = (file: string | undefined) => {
|
||||
if (!file || file === "/dev/null") return undefined
|
||||
if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2)
|
||||
return file
|
||||
}
|
||||
|
||||
const splitRawDiff = (text: string) => {
|
||||
const starts = [...text.matchAll(/(?:^|\n)diff --git /g)].map((match) =>
|
||||
match[0].startsWith("\n") ? match.index + 1 : match.index,
|
||||
)
|
||||
if (starts.length === 0) return text.trim() ? [text] : []
|
||||
return starts.map((start, index) => text.slice(start, starts[index + 1] ?? text.length))
|
||||
}
|
||||
|
||||
const parseRawDiff = (text: string): DiffFile[] => {
|
||||
const chunks = splitRawDiff(text)
|
||||
return chunks.flatMap((chunk) => {
|
||||
const parsed = parsePatch(chunk)[0]
|
||||
const file = stripPrefix(parsed?.newFileName) ?? stripPrefix(parsed?.oldFileName)
|
||||
if (!parsed || !file) return []
|
||||
|
||||
const counts = parsed.hunks.flatMap((hunk) => hunk.lines).reduce(
|
||||
(acc, line) => ({
|
||||
additions: acc.additions + (line.startsWith("+") ? 1 : 0),
|
||||
deletions: acc.deletions + (line.startsWith("-") ? 1 : 0),
|
||||
}),
|
||||
{ additions: 0, deletions: 0 },
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
file,
|
||||
patch: chunk,
|
||||
additions: counts.additions,
|
||||
deletions: counts.deletions,
|
||||
status: parsed.oldFileName === "/dev/null" ? "added" : parsed.newFileName === "/dev/null" ? "deleted" : "modified",
|
||||
} satisfies DiffFile,
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const lineKind = (line: string) => {
|
||||
if (line.startsWith("+")) return "added"
|
||||
if (line.startsWith("-")) return "deleted"
|
||||
if (line.startsWith("@@")) return "hunk"
|
||||
if (line.startsWith("diff --git") || line.startsWith("index ")) return "meta"
|
||||
return "context"
|
||||
}
|
||||
|
||||
export function DiffViewer(props: { onClose: () => void }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [raw] = createResource(async () => {
|
||||
const result = await sdk.client.vcs.diff2.raw(undefined, { throwOnError: true })
|
||||
return result.data ?? ""
|
||||
})
|
||||
const files = createMemo(() => parseRawDiff(raw() ?? ""))
|
||||
const current = createMemo(() => files()[selected()])
|
||||
const lines = createMemo(() => current()?.patch.trimEnd().split(/\r?\n/) ?? [])
|
||||
|
||||
const move = (delta: number) => {
|
||||
const total = files().length
|
||||
if (total === 0) return
|
||||
setSelected((selected() + delta + total) % total)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (selected() >= files().length) setSelected(Math.max(0, files().length - 1))
|
||||
})
|
||||
|
||||
useBindings(() => ({
|
||||
priority: 2000,
|
||||
bindings: [
|
||||
{
|
||||
key: "up",
|
||||
desc: "Previous file",
|
||||
group: "Diff",
|
||||
cmd: () => move(-1),
|
||||
},
|
||||
{
|
||||
key: "k",
|
||||
desc: "Previous file",
|
||||
group: "Diff",
|
||||
cmd: () => move(-1),
|
||||
},
|
||||
{
|
||||
key: "down",
|
||||
desc: "Next file",
|
||||
group: "Diff",
|
||||
cmd: () => move(1),
|
||||
},
|
||||
{
|
||||
key: "j",
|
||||
desc: "Next file",
|
||||
group: "Diff",
|
||||
cmd: () => move(1),
|
||||
},
|
||||
{
|
||||
key: "escape",
|
||||
desc: "Close diff viewer",
|
||||
group: "Diff",
|
||||
cmd: props.onClose,
|
||||
},
|
||||
{
|
||||
key: "q",
|
||||
desc: "Close diff viewer",
|
||||
group: "Diff",
|
||||
cmd: props.onClose,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box
|
||||
position="absolute"
|
||||
zIndex={2500}
|
||||
left={0}
|
||||
top={0}
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
backgroundColor={theme.background}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
gap={1}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.text}>Diff</text>
|
||||
<text fg={theme.textMuted}>working tree</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>j/k select · q/esc close</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" flexGrow={1} minHeight={0} gap={2}>
|
||||
<box
|
||||
width={32}
|
||||
flexShrink={0}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
border={["left", "right"]}
|
||||
borderColor={theme.border}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
gap={1}
|
||||
>
|
||||
<text fg={theme.textMuted}>Files</text>
|
||||
<Switch>
|
||||
<Match when={raw.loading}>
|
||||
<text fg={theme.textMuted}>Loading diff...</text>
|
||||
</Match>
|
||||
<Match when={raw.error}>
|
||||
<text fg={theme.error}>Failed to load diff</text>
|
||||
</Match>
|
||||
<Match when={files().length === 0}>
|
||||
<text fg={theme.text}>No changes</text>
|
||||
</Match>
|
||||
<Match when={files().length > 0}>
|
||||
<For each={files()}>
|
||||
{(file, index) => (
|
||||
<box flexDirection="row" gap={1} backgroundColor={index() === selected() ? theme.backgroundElement : undefined}>
|
||||
<text fg={index() === selected() ? theme.accent : theme.text}>{index() === selected() ? "›" : " "}</text>
|
||||
<text fg={theme.text} wrapMode="none">
|
||||
{file.file}
|
||||
</text>
|
||||
<text fg={theme.diffAdded}>+{file.additions}</text>
|
||||
<text fg={theme.diffRemoved}>-{file.deletions}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
|
||||
<box
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
border={["left", "right"]}
|
||||
borderColor={theme.borderActive}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
gap={1}
|
||||
>
|
||||
<Show
|
||||
when={current()}
|
||||
fallback={<text fg={theme.textMuted}>{raw.loading ? "Loading diff..." : raw.error ? "Failed to load diff" : "No diff to show"}</text>}
|
||||
>
|
||||
{(file) => (
|
||||
<>
|
||||
<box flexDirection="row" gap={2} flexShrink={0}>
|
||||
<text fg={theme.text}>{file().file}</text>
|
||||
<text fg={theme.textMuted}>{file().status}</text>
|
||||
<text fg={theme.diffAdded}>+{file().additions}</text>
|
||||
<text fg={theme.diffRemoved}>-{file().deletions}</text>
|
||||
</box>
|
||||
<scrollbox flexGrow={1} minHeight={0}>
|
||||
<For each={lines()}>
|
||||
{(line) => {
|
||||
const kind = lineKind(line)
|
||||
return (
|
||||
<text
|
||||
fg={
|
||||
kind === "added"
|
||||
? theme.diffAdded
|
||||
: kind === "deleted"
|
||||
? theme.diffRemoved
|
||||
: kind === "hunk"
|
||||
? theme.accent
|
||||
: kind === "meta"
|
||||
? theme.textMuted
|
||||
: theme.text
|
||||
}
|
||||
wrapMode="none"
|
||||
>
|
||||
{line || " "}
|
||||
</text>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Installation } from "@/installation"
|
||||
import { Server } from "@/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { upgrade } from "@/cli/upgrade"
|
||||
@@ -19,6 +20,15 @@ import fs from "fs/promises"
|
||||
ensureProcessMetadata("worker")
|
||||
if (process.env.OPENCODE_SIMULATION_CWD) {
|
||||
process.env.PWD = process.env.OPENCODE_SIMULATION_CWD
|
||||
process.env.OPENCODE_TEST_HOME = process.env.OPENCODE_SIMULATION_CWD
|
||||
Global.Path.data = `${process.env.OPENCODE_SIMULATION_CWD}/.local/share/opencode`
|
||||
Global.Path.cache = `${process.env.OPENCODE_SIMULATION_CWD}/.cache/opencode`
|
||||
Global.Path.config = `${process.env.OPENCODE_SIMULATION_CWD}/.config/opencode`
|
||||
Global.Path.state = `${process.env.OPENCODE_SIMULATION_CWD}/.local/state/opencode`
|
||||
Global.Path.tmp = `${process.env.OPENCODE_SIMULATION_CWD}/tmp/opencode`
|
||||
Global.Path.bin = `${Global.Path.cache}/bin`
|
||||
Global.Path.log = `${Global.Path.data}/log`
|
||||
Global.Path.repos = `${Global.Path.data}/repos`
|
||||
Object.defineProperty(process, "cwd", {
|
||||
value: () => process.env.OPENCODE_SIMULATION_CWD!,
|
||||
configurable: true,
|
||||
|
||||
@@ -199,8 +199,10 @@ export const layer: Layer.Layer<
|
||||
const data: DiscoveryResult = yield* Effect.gen(function* () {
|
||||
const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
|
||||
const dotgit = dotgitMatches[0]
|
||||
log.info("fromDirectory dotgit discovery", { directory, dotgit, matches: dotgitMatches })
|
||||
|
||||
if (!dotgit) {
|
||||
log.info("fromDirectory no dotgit", { directory, fakeVcs })
|
||||
return {
|
||||
id: ProjectID.global,
|
||||
worktree: "/",
|
||||
@@ -212,8 +214,10 @@ export const layer: Layer.Layer<
|
||||
let sandbox = pathSvc.dirname(dotgit)
|
||||
const gitBinary = yield* Effect.sync(() => which("git"))
|
||||
let id = yield* readCachedProjectId(dotgit)
|
||||
log.info("fromDirectory dotgit found", { directory, dotgit, sandbox, gitBinary, cachedProjectId: id })
|
||||
|
||||
if (!gitBinary) {
|
||||
log.info("fromDirectory no git binary", { directory, sandbox, fakeVcs })
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
worktree: sandbox,
|
||||
@@ -223,7 +227,9 @@ export const layer: Layer.Layer<
|
||||
}
|
||||
|
||||
const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox })
|
||||
log.info("fromDirectory git common-dir", { directory, sandbox, code: commonDir.code, text: commonDir.text, stderr: commonDir.stderr })
|
||||
if (commonDir.code !== 0) {
|
||||
log.info("fromDirectory git common-dir failed", { directory, sandbox, fakeVcs })
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
worktree: sandbox,
|
||||
@@ -235,13 +241,24 @@ export const layer: Layer.Layer<
|
||||
const bareCheck = yield* git(["config", "--bool", "core.bare"], { cwd: sandbox })
|
||||
const isBareRepo = bareCheck.code === 0 && bareCheck.text.trim() === "true"
|
||||
const worktree = common === sandbox ? sandbox : isBareRepo ? common : pathSvc.dirname(common)
|
||||
log.info("fromDirectory git repository metadata", {
|
||||
directory,
|
||||
sandbox,
|
||||
common,
|
||||
bareCheckCode: bareCheck.code,
|
||||
bareCheckText: bareCheck.text,
|
||||
isBareRepo,
|
||||
worktree,
|
||||
})
|
||||
|
||||
if (id == null) {
|
||||
id = yield* readCachedProjectId(common)
|
||||
log.info("fromDirectory common cached project id", { directory, common, cachedProjectId: id })
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox })
|
||||
log.info("fromDirectory git rev-list roots", { directory, sandbox, code: revList.code, text: revList.text, stderr: revList.stderr })
|
||||
const roots = revList.text
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
@@ -255,11 +272,14 @@ export const layer: Layer.Layer<
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
log.info("fromDirectory no project id", { directory, sandbox, worktree })
|
||||
return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const }
|
||||
}
|
||||
|
||||
const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox })
|
||||
log.info("fromDirectory git top-level", { directory, sandbox, code: topLevel.code, text: topLevel.text, stderr: topLevel.stderr })
|
||||
if (topLevel.code !== 0) {
|
||||
log.info("fromDirectory git top-level failed", { directory, sandbox, fakeVcs })
|
||||
return {
|
||||
id,
|
||||
worktree: sandbox,
|
||||
@@ -269,8 +289,10 @@ export const layer: Layer.Layer<
|
||||
}
|
||||
sandbox = resolveGitPath(sandbox, topLevel.text.trim())
|
||||
|
||||
log.info("fromDirectory discovered git project", { directory, id, sandbox, worktree })
|
||||
return { id, sandbox, worktree, vcs: "git" as const }
|
||||
})
|
||||
log.info("fromDirectory discovery result", data)
|
||||
|
||||
// Phase 2: upsert
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
|
||||
@@ -292,6 +314,7 @@ export const layer: Layer.Layer<
|
||||
vcs: data.vcs,
|
||||
time: { ...existing.time, updated: Date.now() },
|
||||
}
|
||||
log.info("fromDirectory existing project row", { directory, hasExisting: Boolean(row), existing })
|
||||
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
|
||||
result.sandboxes.push(data.sandbox)
|
||||
result.sandboxes = yield* Effect.forEach(
|
||||
|
||||
@@ -53,6 +53,11 @@ export function make(options: Options) {
|
||||
return Effect.fail(normalized)
|
||||
}
|
||||
|
||||
const normalizeSearchStart = (file: string) => {
|
||||
const resolved = path.resolve(root, file)
|
||||
if (resolved === root || AppFileSystem.contains(root, resolved)) return resolved
|
||||
}
|
||||
|
||||
const normalizePair = (method: string, fromPath: string, toPath: string) =>
|
||||
Effect.all([normalizeEffect(method, fromPath), normalizeEffect(method, toPath)] as const)
|
||||
|
||||
@@ -68,7 +73,10 @@ export function make(options: Options) {
|
||||
fs.mkdirSync(root, { recursive: true })
|
||||
for (const [file, content] of Object.entries(options.files ?? {})) {
|
||||
const normalized = normalize("seed", file)
|
||||
if (typeof normalized === "string") fs.writeFileSync(normalized, content, { encoding: "utf8" })
|
||||
if (typeof normalized === "string") {
|
||||
fs.mkdirSync(path.dirname(normalized), { recursive: true })
|
||||
fs.writeFileSync(normalized, content, { encoding: "utf8" })
|
||||
}
|
||||
}
|
||||
|
||||
const base = FileSystem.make({
|
||||
@@ -272,8 +280,9 @@ export function make(options: Options) {
|
||||
up: (methodOptions) =>
|
||||
Effect.gen(function* () {
|
||||
const result: string[] = []
|
||||
let current = yield* normalizeEffect("up", methodOptions.start)
|
||||
const normalizedStop = methodOptions.stop ? yield* normalizeEffect("up", methodOptions.stop) : undefined
|
||||
let current = normalizeSearchStart(methodOptions.start)
|
||||
if (!current) return result
|
||||
const normalizedStop = methodOptions.stop ? normalizeSearchStart(methodOptions.stop) : undefined
|
||||
while (true) {
|
||||
for (const target of methodOptions.targets) {
|
||||
const file = path.join(current, target)
|
||||
@@ -289,8 +298,9 @@ export function make(options: Options) {
|
||||
globUp: (pattern, start, stop) =>
|
||||
Effect.gen(function* () {
|
||||
const result: string[] = []
|
||||
let current = yield* normalizeEffect("globUp", start)
|
||||
const normalizedStop = stop ? yield* normalizeEffect("globUp", stop) : undefined
|
||||
let current = normalizeSearchStart(start)
|
||||
if (!current) return result
|
||||
const normalizedStop = stop ? normalizeSearchStart(stop) : undefined
|
||||
while (true) {
|
||||
result.push(...(yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true })))
|
||||
if (normalizedStop === current) break
|
||||
|
||||
@@ -46,6 +46,24 @@ function output(value: string) {
|
||||
return Stream.make(encoder.encode(value))
|
||||
}
|
||||
|
||||
function handle(result: { stdout?: string; stderr?: string; exitCode?: number }) {
|
||||
const stdoutText = result.stdout ?? ""
|
||||
const stderrText = result.stderr ?? ""
|
||||
return makeHandle({
|
||||
pid: ProcessId(0),
|
||||
stdin: Sink.drain,
|
||||
stdout: output(stdoutText),
|
||||
stderr: output(stderrText),
|
||||
all: output(stdoutText + stderrText),
|
||||
getInputFd: () => Sink.drain,
|
||||
getOutputFd: () => Stream.empty,
|
||||
isRunning: Effect.succeed(false),
|
||||
exitCode: Effect.succeed(ExitCode(result.exitCode ?? 0)),
|
||||
kill: () => Effect.void,
|
||||
unref: Effect.succeed(Effect.void),
|
||||
})
|
||||
}
|
||||
|
||||
function cwd(options: Options, command: ChildProcess.StandardCommand) {
|
||||
const root = path.resolve(options.root)
|
||||
const resolved = path.resolve(root, command.options.cwd ?? root)
|
||||
@@ -56,33 +74,25 @@ function cwd(options: Options, command: ChildProcess.StandardCommand) {
|
||||
export function make(options: Options) {
|
||||
const spawn = Effect.fn("SimulationSpawner.spawn")(function* (command: ChildProcess.Command) {
|
||||
if (command._tag !== "StandardCommand") return yield* error("spawn", command, "Piped commands are not supported")
|
||||
const workingDirectory = yield* cwd(options, command)
|
||||
if (Shell.name(command.command) === "git") {
|
||||
if (command.args[0] === "rev-parse" && command.args.includes("--git-common-dir")) return handle({ stdout: ".git\n" })
|
||||
if (command.args[0] === "rev-parse" && command.args.includes("--show-toplevel")) return handle({ stdout: `${workingDirectory}\n` })
|
||||
if (command.args[0] === "rev-parse") return handle({ stdout: "0000000000000000000000000000000000000000\n" })
|
||||
if (command.args[0] === "rev-list") return handle({ stdout: "0000000000000000000000000000000000000000\n" })
|
||||
if (command.args[0] === "config" && command.args.includes("core.bare")) return handle({ stdout: "false\n" })
|
||||
}
|
||||
if (!isShell(command)) return yield* error("spawn", command, "Only shell commands are supported in simulation")
|
||||
|
||||
const text = commandText(command)
|
||||
if (!text) return yield* error("spawn", command, "Shell command did not include command text")
|
||||
|
||||
const workingDirectory = yield* cwd(options, command)
|
||||
const result = yield* Effect.promise(() =>
|
||||
new Bash({ fs: options.fs, cwd: workingDirectory }).exec(text, {
|
||||
env: Object.fromEntries(Object.entries(command.options.env ?? {}).filter((entry): entry is [string, string] => typeof entry[1] === "string")),
|
||||
}),
|
||||
)
|
||||
const stdout = output(result.stdout)
|
||||
const stderr = output(result.stderr)
|
||||
|
||||
return makeHandle({
|
||||
pid: ProcessId(0),
|
||||
stdin: Sink.drain,
|
||||
stdout,
|
||||
stderr,
|
||||
all: output(result.stdout + result.stderr),
|
||||
getInputFd: () => Sink.drain,
|
||||
getOutputFd: () => Stream.empty,
|
||||
isRunning: Effect.succeed(false),
|
||||
exitCode: Effect.succeed(ExitCode(result.exitCode)),
|
||||
kill: () => Effect.void,
|
||||
unref: Effect.succeed(Effect.void),
|
||||
})
|
||||
return handle(result)
|
||||
})
|
||||
|
||||
return makeSpawner(spawn)
|
||||
|
||||
@@ -25,6 +25,8 @@ describe("SimulationFileSystem", () => {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
expect(yield* fs.readFileString(path.join(root, "README.md"))).toBe("hello")
|
||||
expect(yield* fs.readFileString(path.join(root, "src/index.ts"))).toBe("export const value = 1\n")
|
||||
expect(yield* fs.isDir(path.join(root, "src"))).toBe(true)
|
||||
yield* fs.writeWithDirs(path.join(root, "tmp", "result.txt"), "done")
|
||||
|
||||
expect(yield* fs.readFileString(path.join(root, "tmp", "result.txt"))).toBe("done")
|
||||
@@ -55,6 +57,15 @@ describe("SimulationFileSystem", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns no upward matches when search starts outside the simulated root", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
expect(yield* fs.up({ targets: [".opencode"], start: "/Users/james", stop: "/Users/james" })).toEqual([])
|
||||
expect(yield* fs.globUp("*.json", "/Users/james", "/Users/james")).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
const shared = new InMemoryFs()
|
||||
const sharedIt = testEffect(SimulationFileSystem.layer({ root, fs: shared }))
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"actions": [
|
||||
{ "type": "writeFile", "path": ".git/HEAD", "content": "ref: refs/heads/main\n" },
|
||||
{ "type": "writeFile", "path": ".git/config", "content": "[core]\n\trepositoryformatversion = 0\n\tbare = false\n[branch \"main\"]\n" },
|
||||
{ "type": "writeFile", "path": "src/delta-23.txt", "content": "delta_23 note 1: alpha-41 glade-90.\ndelta_23 note 2: iris-73 juniper-33.\ndelta_23 note 3: ember-41 cedar-12." },
|
||||
{ "type": "writeFile", "path": "src/cedar-20.txt", "content": "cedar_20 note 1: cedar-94 field-8.\ncedar_20 note 2: delta-21 juniper-19.\ncedar_20 note 3: bravo-42 juniper-35.\ncedar_20 note 4: cedar-25 delta-87." },
|
||||
{ "type": "writeFile", "path": "src/bravo-86.txt", "content": "bravo_86 note 1: bravo-42 harbor-20.\nbravo_86 note 2: delta-92 cedar-97.\nbravo_86 note 3: juniper-72 juniper-90.\nbravo_86 note 4: cedar-15 field-91." },
|
||||
{ "type": "writeFile", "path": "src/bravo-11.ts", "content": "const bravo_11Defaults = {\n retries: 1,\n timeout: 220,\n label: \"iris-21\",\n} as const\n\nexport async function loadbravo_11(input: Partial<typeof bravo_11Defaults> = {}) {\n const config = { ...bravo_11Defaults, ...input }\n await Promise.resolve()\n return {\n ...config,\n ready: config.retries > 0 && config.timeout > 0,\n }\n}\n" },
|
||||
{ "type": "writeFile", "path": "src/iris-53.ts", "content": "export class iris_53Store {\n #items = new Map<string, number>()\n\n add(key: string, value: number) {\n this.#items.set(key, (this.#items.get(key) ?? 0) + value)\n return this\n }\n\n snapshot() {\n return Object.fromEntries(this.#items.entries())\n }\n}\n\nexport const iris_53StoreInstance = new iris_53Store().add(\"harbor-38\", 4)\n" },
|
||||
{ "type": "writeFile", "path": "src/dir-2/delta-75.ts", "content": "export type delta_75Event =\n | { readonly type: \"created\"; readonly id: string; readonly count: number }\n | { readonly type: \"updated\"; readonly id: string; readonly fields: readonly string[] }\n | { readonly type: \"deleted\"; readonly id: string }\n\nexport function delta_75Label(event: delta_75Event) {\n if (event.type === \"created\") return `created:${event.id}:${event.count}`\n if (event.type === \"updated\") return `updated:${event.id}:${event.fields.length}`\n return `deleted:${event.id}`\n}\n\nexport const delta_75Sample: delta_75Event = { type: \"created\", id: \"ember-76\", count: 11 }\n" },
|
||||
{ "type": "writeFile", "path": "src/dir-4/harbor-79.ts", "content": "export type harbor_79Event =\n | { readonly type: \"created\"; readonly id: string; readonly count: number }\n | { readonly type: \"updated\"; readonly id: string; readonly fields: readonly string[] }\n | { readonly type: \"deleted\"; readonly id: string }\n\nexport function harbor_79Label(event: harbor_79Event) {\n if (event.type === \"created\") return `created:${event.id}:${event.count}`\n if (event.type === \"updated\") return `updated:${event.id}:${event.fields.length}`\n return `deleted:${event.id}`\n}\n\nexport const harbor_79Sample: harbor_79Event = { type: \"created\", id: \"bravo-6\", count: 12 }\n" },
|
||||
{ "type": "writeFile", "path": "src/dir-4/alpha-56.txt", "content": "alpha_56 note 1: iris-27 ember-92.\nalpha_56 note 2: iris-35 cedar-50.\nalpha_56 note 3: iris-71 glade-35.\nalpha_56 note 4: field-83 harbor-24.\nalpha_56 note 5: field-9 cedar-86.\nalpha_56 note 6: harbor-11 juniper-49." },
|
||||
{ "type": "writeFile", "path": "_patches/001-delta-75.ts.patch", "content": "diff --git a/src/dir-2/delta-75.ts b/src/dir-2/delta-75.ts\nindex 9ed8735..df7ce67 100644\n--- a/src/dir-2/delta-75.ts\n+++ b/src/dir-2/delta-75.ts\n@@ -1,12 +1,8 @@\n export type delta_75Event =\n | { readonly type: \"created\"; readonly id: string; readonly count: number }\n- | { readonly type: \"updated\"; readonly id: string; readonly fields: readonly string[] }\n- | { readonly type: \"deleted\"; readonly id: string }\n \n export function delta_75Label(event: delta_75Event) {\n if (event.type === \"created\") return `created:${event.id}:${event.count}`\n- if (event.type === \"updated\") return `updated:${event.id}:${event.fields.length}`\n- return `deleted:${event.id}`\n }\n \n-export const delta_75Sample: delta_75Event = { type: \"created\", id: \"ember-76\", count: 11 }\n+\n" },
|
||||
{ "type": "writeFile", "path": "_patches/002-harbor-79.ts.patch", "content": "diff --git a/src/dir-4/harbor-79.ts b/src/dir-4/harbor-79.ts\nindex d7ebf78..5e2b8bf 100644\n--- a/src/dir-4/harbor-79.ts\n+++ b/src/dir-4/harbor-79.ts\n@@ -1,12 +1,9 @@\n+\n export type harbor_79Event =\n- | { readonly type: \"created\"; readonly id: string; readonly count: number }\n- | { readonly type: \"updated\"; readonly id: string; readonly fields: readonly string[] }\n- | { readonly type: \"deleted\"; readonly id: string }\n \n export function harbor_79Label(event: harbor_79Event) {\n if (event.type === \"created\") return `created:${event.id}:${event.count}`\n if (event.type === \"updated\") return `updated:${event.id}:${event.fields.length}`\n- return `deleted:${event.id}`\n }\n \n export const harbor_79Sample: harbor_79Event = { type: \"created\", id: \"bravo-6\", count: 12 }\n" },
|
||||
{ "type": "writeFile", "path": "_patches/003-bravo-11.ts.patch", "content": "diff --git a/src/bravo-11.ts b/src/bravo-11.ts\nindex eaf26e4..58519e4 100644\n--- a/src/bravo-11.ts\n+++ b/src/bravo-11.ts\n@@ -1,5 +1,4 @@\n const bravo_11Defaults = {\n- retries: 1,\n timeout: 220,\n label: \"iris-21\",\n } as const\n@@ -12,3 +11,20 @@ export async function loadbravo_11(input: Partial<typeof bravo_11Defaults> = {})\n ready: config.retries > 0 && config.timeout > 0,\n }\n }\n+\n+export function generated370Normalize(input: readonly string[]) {\n+ return input.map((item) => item.trim()).filter(Boolean).join(\"|\")\n+}\n+\n+export const generated160Config = {\n+ id: \"alpha-85\",\n+ retries: 5,\n+ flags: [\"iris-67\", \"ember-55\"],\n+} as const\n+\n+export const generated595Config = {\n+ id: \"alpha-55\",\n+ retries: 3,\n+ flags: [\"harbor-26\", \"harbor-77\"],\n+} as const\n+\n" },
|
||||
{ "type": "writeFile", "path": "_patches/004-iris-53.ts.patch", "content": "diff --git a/src/iris-53.ts b/src/iris-53.ts\nindex 9497460..b8281a7 100644\n--- a/src/iris-53.ts\n+++ b/src/iris-53.ts\n@@ -1,9 +1,39 @@\n+\n+export function generated284Normalize(input: readonly string[]) {\n+ return input.map((item) => item.trim()).filter(Boolean).join(\",\")\n+}\n+\n+export type generated458State =\n+ | { readonly ok: true; readonly value: \"harbor-83\" }\n+ | { readonly ok: false; readonly reason: \"iris-97\" }\n+\n+export type generated838State =\n+ | { readonly ok: true; readonly value: \"harbor-34\" }\n+ | { readonly ok: false; readonly reason: \"delta-29\" }\n+\n+export const generated467Items = [\n+ { key: \"glade-76\", value: 11 },\n+ { key: \"alpha-80\", value: 9 },\n+ { key: \"field-83\", value: 16 },\n+] as const\n+\n+export const generated560Config = {\n+ id: \"alpha-44\",\n+ retries: 1,\n+ flags: [\"harbor-11\", \"field-37\"],\n+} as const\n+\n+export const generated713Items = [\n+ { key: \"juniper-98\", value: 2 },\n+ { key: \"juniper-83\", value: 47 },\n+ { key: \"bravo-78\", value: 33 },\n+] as const\n+\n export class iris_53Store {\n #items = new Map<string, number>()\n \n add(key: string, value: number) {\n this.#items.set(key, (this.#items.get(key) ?? 0) + value)\n- return this\n }\n \n snapshot() {\n" },
|
||||
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "Read the fake git diff from _patches and summarize the generated changes." }, { "type": "text", "content": "The generated project contains eight seeded files under src and four fake git patches under _patches. The patches remove several event variants from delta_75 and harbor_79, remove retries from bravo_11 while adding generated config helpers, and expand iris_53 with generated normalize/state/item exports while changing add() to stop returning this. Main risks: type errors from removed union variants, bravo_11 still referencing config.retries after deletion, and fluent chaining breakage in iris_53StoreInstance." }]], "usage": { "inputTokens": 520, "outputTokens": 96, "totalTokens": 616 }, "finish": "stop" }] },
|
||||
{ "type": "typeText", "text": "Review the generated project changes. Use the git diff from _patches and summarize the changed files, likely risks, and a recommended next step." },
|
||||
{ "type": "pressEnter" },
|
||||
{ "type": "wait", "ms": 1200 }
|
||||
]
|
||||
}
|
||||
@@ -37,7 +37,25 @@ describe("SimulationSpawner", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects non-shell commands", () =>
|
||||
it.effect("fakes git discovery commands", () =>
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
const [common, topLevel, revision] = yield* Effect.all(
|
||||
[
|
||||
spawner.spawn(ChildProcess.make("git", ["rev-parse", "--git-common-dir"], { cwd: root })).pipe(Effect.scoped),
|
||||
spawner.spawn(ChildProcess.make("git", ["rev-parse", "--show-toplevel"], { cwd: root })).pipe(Effect.scoped),
|
||||
spawner.spawn(ChildProcess.make("git", ["rev-list", "--max-parents=0", "HEAD"], { cwd: root })).pipe(Effect.scoped),
|
||||
],
|
||||
{ concurrency: 3 },
|
||||
)
|
||||
|
||||
expect(yield* Stream.mkString(Stream.decodeText(common.stdout))).toBe(".git\n")
|
||||
expect(yield* Stream.mkString(Stream.decodeText(topLevel.stdout))).toBe("/opencode\n")
|
||||
expect(yield* Stream.mkString(Stream.decodeText(revision.stdout))).toBe("0000000000000000000000000000000000000000\n")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unsupported non-shell commands", () =>
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
const exit = yield* spawner.spawn(ChildProcess.make("git", ["status"], { cwd: root })).pipe(Effect.scoped, Effect.exit)
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# Diff Viewer
|
||||
|
||||
## Goal
|
||||
|
||||
Add a full-screen TUI diff viewer opened by `/diff` and by a keybinding. The first version should be intentionally small: open the surface, fetch a raw VCS patch, parse it with `@pierre/diffs`, render a basic readable diff, and keep the architecture ready for tree navigation, split/unified layout, and larger patches.
|
||||
|
||||
## Reference Insights
|
||||
|
||||
The useful lesson from `hunk` is not its full architecture, but its separation between data, render rows, geometry, and navigation. It normalizes patches into file models, derives stable hunk and row cursors, caches expensive measurements, and preserves viewport position through stable anchors when layout changes.
|
||||
|
||||
The useful lesson from `ghui` is that a TUI diff viewer should treat scroll math as a first-class data problem. It precomputes per-file stacked offsets, uses binary search for sticky headers, keeps selected anchors visible, and avoids quadratic whitespace or wrapping work by capping expensive algorithms.
|
||||
|
||||
The useful lesson from `@pierre/diffs` is that we should not parse Git patches ourselves. It already provides `parsePatchFiles`, `parseDiffFromFile`, `FileDiffMetadata`, hunk metadata, line counts for unified and split views, cache keys, syntax highlighting helpers, and windowed iteration utilities like `iterateOverDiff`.
|
||||
|
||||
## Existing opencode Shape
|
||||
|
||||
The backend already has most of the raw data path:
|
||||
|
||||
- `Vcs.diffRaw()` returns current uncommitted changes as a raw patch from `Git.patchAll()` plus untracked files.
|
||||
- `GET /vcs/diff/raw` exposes that as `text/x-diff`.
|
||||
- `GET /vcs/diff?mode=git|branch` returns structured `Vcs.FileDiff[]`, but for the viewer we should start with the raw patch because it maps directly to `@pierre/diffs` parsing.
|
||||
- Existing inline tool diffs use the OpenTUI `<diff>` renderable for edit and patch tool output.
|
||||
- Slash commands are keymap commands with `slashName`, registered through `useBindings()` and surfaced by the command palette.
|
||||
- Keybindings are declared in `src/cli/cmd/tui/config/keybind.ts`, then mapped to command names through `CommandMap`.
|
||||
|
||||
## Proposed Minimal Architecture
|
||||
|
||||
### Backend
|
||||
|
||||
Start with the existing endpoint:
|
||||
|
||||
- `sdk.client.instance.vcsDiffRaw()` or the generated equivalent for `GET /vcs/diff/raw`.
|
||||
- Keep `Vcs.diffRaw()` unchanged at first.
|
||||
- Later add query support for `mode=git|branch`, staged-only, pathspecs, context lines, and max bytes only when the UI needs them.
|
||||
|
||||
This keeps the first pass limited to uncommitted working-tree diffs and avoids introducing a new service before there is a concrete need.
|
||||
|
||||
### Frontend State
|
||||
|
||||
Add a small route-local state holder for the viewer, likely in a new `routes/diff` area or a top-level overlay component:
|
||||
|
||||
```ts
|
||||
type DiffViewerState = {
|
||||
open: boolean
|
||||
loading: boolean
|
||||
error?: string
|
||||
raw: string
|
||||
files: DiffFileModel[]
|
||||
layout: "split" | "unified"
|
||||
selectedFile: number
|
||||
selectedHunk: number
|
||||
scrollTop: number
|
||||
}
|
||||
|
||||
type DiffFileModel = {
|
||||
id: string
|
||||
path: string
|
||||
previousPath?: string
|
||||
patch: string
|
||||
metadata: FileDiffMetadata
|
||||
additions: number
|
||||
deletions: number
|
||||
}
|
||||
```
|
||||
|
||||
Do not add global persistence or cross-session sharing in the first version. If the viewer can be reopened in the same TUI process with fresh state, that is enough.
|
||||
|
||||
### Parsing
|
||||
|
||||
Create a tiny adapter around `@pierre/diffs`:
|
||||
|
||||
```ts
|
||||
parsePatchFiles(raw, `vcs:${hash}`)
|
||||
.flatMap((patch) => patch.files)
|
||||
.map((metadata, index) => buildDiffFileModel(metadata, rawChunk, index))
|
||||
```
|
||||
|
||||
The adapter should own only opencode-specific concerns:
|
||||
|
||||
- normalize display paths from `a/` and `b/` prefixes if the parsed metadata keeps them.
|
||||
- compute additions and deletions from `metadata.hunks[*].hunkContent`.
|
||||
- preserve each file patch chunk so the current OpenTUI `<diff>` renderable can render the first basic version.
|
||||
- assign stable IDs from path plus index, not from array position alone.
|
||||
|
||||
Avoid building a custom parser, whitespace minimizer, or line-level diff engine initially.
|
||||
|
||||
### Rendering V1
|
||||
|
||||
Use the existing OpenTUI `<diff>` renderable for the first visible version. It already supports unified/split, line numbers, wrapping, colors, and syntax style.
|
||||
|
||||
The full-screen surface should contain:
|
||||
|
||||
- header: `Diff`, file count, additions/deletions, current layout, refresh hint.
|
||||
- left pane: flat file list first, nested tree later.
|
||||
- right pane: stacked file sections, each with a file header and `<diff>` body.
|
||||
- footer: core key hints.
|
||||
|
||||
The nested tree is important, but the first implementation can render a flat file list with indentation-ready data. Build the tree data model in the adapter only after the flat list works.
|
||||
|
||||
### Navigation
|
||||
|
||||
Start with simple indexes:
|
||||
|
||||
- `j`/`down`: next hunk or next file when at the final hunk.
|
||||
- `k`/`up`: previous hunk or previous file.
|
||||
- `J`: next file.
|
||||
- `K`: previous file.
|
||||
- `tab`: toggle focus between file list and diff pane.
|
||||
- `s`: toggle split/unified.
|
||||
- `r`: refresh raw patch.
|
||||
- `escape`/`q`: close.
|
||||
|
||||
The first version can scroll selected files into view. Hunk-perfect scrolling can come after a row geometry layer exists.
|
||||
|
||||
### Geometry Layer
|
||||
|
||||
Add this only after V1 rendering works.
|
||||
|
||||
```ts
|
||||
type DiffSectionGeometry = {
|
||||
fileId: string
|
||||
top: number
|
||||
headerHeight: number
|
||||
bodyHeight: number
|
||||
bottom: number
|
||||
hunkAnchors: readonly { hunkIndex: number; top: number; height: number }[]
|
||||
}
|
||||
```
|
||||
|
||||
Use `metadata.unifiedLineCount`, `metadata.splitLineCount`, and the current `<diff>` measured height where available. Initially estimate body height from `layout === "split" ? metadata.splitLineCount : metadata.unifiedLineCount`. Refine later for wrapping and custom row rendering.
|
||||
|
||||
This layer enables:
|
||||
|
||||
- sticky file headers.
|
||||
- fast file lookup by scroll offset with binary search.
|
||||
- jump-to-hunk without scanning DOM/renderables.
|
||||
- preserving viewport anchors when toggling layout.
|
||||
|
||||
### Performance Rules
|
||||
|
||||
- Parse raw patch once per fetched patch string.
|
||||
- Do not hold both many transformed row formats and raw patches until a performance measurement justifies it.
|
||||
- Cache by patch hash plus layout plus width for derived geometry.
|
||||
- Prefer windowing before custom rendering large diffs.
|
||||
- Do not syntax-highlight lines outside the visible window if we move away from OpenTUI `<diff>` into custom row rendering.
|
||||
- Cap raw patch response size before attempting to render huge diffs; show skipped files rather than blocking the TUI.
|
||||
- Keep tree building linear in path count.
|
||||
- Keep navigation data as arrays and maps, not recursive lookup at keypress time.
|
||||
|
||||
## Future Rendering Direction
|
||||
|
||||
The existing `<diff>` renderable is the pragmatic starting point. If we hit limitations for tree-aware navigation, sticky headers, inline annotations, or partial rendering, move to a custom terminal row renderer backed by `@pierre/diffs` metadata and utilities:
|
||||
|
||||
- use `iterateOverDiff` to produce only visible rows.
|
||||
- use `renderDiffWithHighlighter` or lower-level highlighting utilities for styled spans.
|
||||
- maintain stable row anchors for layout toggles.
|
||||
- virtualize by file section and visible row window.
|
||||
|
||||
Do not start here. The project should earn this complexity through measured needs.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] Add a `diff.open` keymap command with `slashName: "diff"` that opens a full-screen placeholder.
|
||||
- [x] Add a default keybinding for `diff.open` after choosing the key sequence.
|
||||
- [x] Build the full-screen shell with header, left pane placeholder, right pane placeholder, and footer hints.
|
||||
- [ ] Fetch raw patch data from `GET /vcs/diff/raw` when the viewer opens.
|
||||
- [ ] Show loading, empty, and error states.
|
||||
- [ ] Add a small `parseDiffPatch(raw)` adapter using `@pierre/diffs.parsePatchFiles`.
|
||||
- [ ] Split the raw patch into per-file chunks or derive enough patch text for `<diff>` rendering.
|
||||
- [ ] Render stacked file sections with the existing OpenTUI `<diff>` renderable.
|
||||
- [ ] Add split/unified layout toggle using existing `diff_style` behavior as guidance, but keep viewer-local state.
|
||||
- [ ] Add basic file navigation and selected-file highlighting.
|
||||
- [ ] Add basic hunk navigation based on `FileDiffMetadata.hunks`.
|
||||
- [ ] Add refresh and close commands.
|
||||
- [ ] Add tests for the patch parser adapter with added, deleted, modified, renamed, binary, and untracked file patches.
|
||||
- [ ] Add simulation coverage that `/diff` opens the full-screen viewer and renders an empty state.
|
||||
- [ ] Add simulation coverage for a generated patch rendering at least one file and hunk.
|
||||
- [ ] Convert the flat file list into a nested tree model.
|
||||
- [ ] Add tree expand/collapse state and keyboard navigation.
|
||||
- [ ] Add section geometry estimates from `metadata.unifiedLineCount` and `metadata.splitLineCount`.
|
||||
- [ ] Preserve scroll anchor when toggling split/unified.
|
||||
- [ ] Add sticky file header in the diff pane.
|
||||
- [ ] Add patch byte limits and skipped-file UI for huge diffs.
|
||||
- [ ] Profile a large patch before introducing custom row virtualization.
|
||||
- [ ] If needed, replace `<diff>` bodies with a windowed custom renderer using `@pierre/diffs.iterateOverDiff`.
|
||||
|
||||
## Open Decisions
|
||||
|
||||
- Default keybinding for opening the viewer.
|
||||
- Whether `/diff` should default to uncommitted changes only or allow immediate mode selection for branch diff.
|
||||
- Whether branch diff should use `Vcs.diffRaw(mode=branch)` or a separate raw patch endpoint when we add it.
|
||||
- Whether staged-only diffs are required in the first useful release.
|
||||
- Whether tree selection should follow scroll position immediately or only after explicit file navigation.
|
||||
Reference in New Issue
Block a user