mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-27 11:07:34 +00:00
Compare commits
1
Commits
v2
..
tui-turn-diff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7212af12ee |
@@ -54,6 +54,9 @@ export const Plugin = Schema.Union([
|
||||
}),
|
||||
])
|
||||
|
||||
export const DiffSource = Schema.Union([Vcs.Mode, Schema.Literal("turn")])
|
||||
export type DiffSource = Schema.Schema.Type<typeof DiffSource>
|
||||
|
||||
export const Cursor = Schema.Struct({
|
||||
style: Schema.optional(Schema.Literals(["block", "underline", "line", "default"])).annotate({
|
||||
description: "Cursor shape. Use 'default' to preserve the terminal setting",
|
||||
@@ -108,8 +111,9 @@ export const Info = Schema.Struct({
|
||||
).annotate({ description: "System notification and sound settings" }),
|
||||
diffs: Schema.optional(
|
||||
Schema.Struct({
|
||||
source: Schema.optional(Vcs.Mode).annotate({
|
||||
description: "Initial diff source; defaults to 'branch' (branch and uncommitted changes)",
|
||||
source: Schema.optional(DiffSource).annotate({
|
||||
description:
|
||||
"Initial diff source; defaults to 'branch' (branch and uncommitted changes). 'turn' shows the session's last turn and falls back to 'branch' outside a session",
|
||||
}),
|
||||
wrap: Schema.optional(Schema.Literals(["word", "none"])).annotate({
|
||||
description: "Line wrapping behavior in diff output",
|
||||
|
||||
@@ -21,7 +21,7 @@ import { EmptyBorder } from "../../ui/border"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { createDebouncedSignal } from "../../util/signal"
|
||||
import { useConfig } from "../../config"
|
||||
import { type DiffSource, useConfig } from "../../config"
|
||||
import { locationKey } from "../../context/data"
|
||||
import { useThemes } from "../../context/theme"
|
||||
import { PatchDiff, type PatchDiffRef } from "../../component/patch-diff"
|
||||
@@ -44,7 +44,7 @@ const FILE_TREE_MIN_WIDTH = 30
|
||||
const FILE_TREE_MAX_WIDTH = 40
|
||||
const FILE_HEADER_HEIGHT = 2
|
||||
const VCS_DIFF_CONTEXT_LINES = 12
|
||||
type DiffMode = Vcs.Mode
|
||||
type DiffMode = DiffSource
|
||||
type DiffView = "split" | "unified"
|
||||
type SelectedHunk = { readonly fileIndex: number; readonly hunkIndex: number; readonly scrollTop: number }
|
||||
type FileMenuState = { readonly fileIndex: number; readonly x: number; readonly y: number }
|
||||
@@ -70,11 +70,16 @@ function storedView(value: unknown): DiffView | undefined {
|
||||
if (value === "split" || value === "unified") return value
|
||||
}
|
||||
|
||||
function diffSourceLabel(mode: DiffMode) {
|
||||
if (mode === "branch") return "All"
|
||||
if (mode === "committed") return "Committed"
|
||||
return "Uncommitted"
|
||||
}
|
||||
const DIFF_SOURCES = {
|
||||
branch: { label: "All", description: "Branch + local changes" },
|
||||
committed: { label: "Committed", description: "Branch commits only" },
|
||||
working: { label: "Uncommitted", description: "Local changes only" },
|
||||
turn: { label: "Last turn", description: "Latest session turn" },
|
||||
} satisfies Record<DiffMode, { label: string; description: string }>
|
||||
|
||||
const VCS_SOURCES = ["branch", "committed", "working"] as const
|
||||
|
||||
const needsBase = (mode: DiffMode) => mode === "branch" || mode === "committed"
|
||||
|
||||
function DiffViewer(props: { context: Plugin.Context }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
@@ -93,12 +98,15 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
const [mode, setMode] = createSignal(params()?.mode ?? memory.source ?? config.data.diffs?.source ?? "branch")
|
||||
const sessionID = () => params()?.sessionID
|
||||
const sources = (): readonly DiffMode[] => (sessionID() ? [...VCS_SOURCES, "turn"] : VCS_SOURCES)
|
||||
const initialMode = params()?.mode ?? memory.source ?? config.data.diffs?.source ?? "branch"
|
||||
const [mode, setMode] = createSignal(sources().includes(initialMode) ? initialMode : "branch")
|
||||
const location = createMemo(
|
||||
() => {
|
||||
const sessionID = params()?.sessionID
|
||||
return sessionID
|
||||
? (props.context.data.session.get(sessionID)?.location ?? props.context.data.location.default())
|
||||
const id = sessionID()
|
||||
return id
|
||||
? (props.context.data.session.get(id)?.location ?? props.context.data.location.default())
|
||||
: props.context.data.location.default()
|
||||
},
|
||||
undefined,
|
||||
@@ -121,19 +129,32 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
bases.set(key, pending)
|
||||
return pending
|
||||
}
|
||||
const diffInput = createMemo(() => ({
|
||||
mode: mode(),
|
||||
location: location(),
|
||||
key: baseKey(),
|
||||
selected: mode() === "working" ? undefined : selectedBase(),
|
||||
}))
|
||||
const [diff] = createResource(diffInput, async (input) => {
|
||||
const base =
|
||||
input.mode === "working"
|
||||
? undefined
|
||||
: input.selected
|
||||
? { name: input.selected, ref: input.selected }
|
||||
: (await loadBase(input.location, input.key)).data
|
||||
const diffInput = createMemo(() => {
|
||||
const current = mode()
|
||||
const id = sessionID()
|
||||
if (current === "turn" && id) return { mode: current, sessionID: id }
|
||||
const vcs = current === "turn" ? "branch" : current
|
||||
return {
|
||||
mode: vcs,
|
||||
location: location(),
|
||||
key: baseKey(),
|
||||
selected: needsBase(vcs) ? selectedBase() : undefined,
|
||||
}
|
||||
})
|
||||
const [diff, { refetch }] = createResource(diffInput, async (input) => {
|
||||
if (input.mode === "turn") {
|
||||
return {
|
||||
base: null,
|
||||
files: normalizeDiffs(
|
||||
await props.context.client.session.diff({ sessionID: input.sessionID, context: VCS_DIFF_CONTEXT_LINES }),
|
||||
),
|
||||
}
|
||||
}
|
||||
const base = !needsBase(input.mode)
|
||||
? undefined
|
||||
: input.selected
|
||||
? { name: input.selected, ref: input.selected }
|
||||
: (await loadBase(input.location, input.key)).data
|
||||
if (input !== diffInput() || (input.mode === "committed" && !base)) {
|
||||
return { base: null, files: [] }
|
||||
}
|
||||
@@ -145,12 +166,21 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
})
|
||||
return { base, files: normalizeDiffs(result.data ?? []) }
|
||||
})
|
||||
// Each completed turn replaces the last one.
|
||||
createEffect((previous) => {
|
||||
if (mode() !== "turn") return undefined
|
||||
const id = sessionID()
|
||||
const status = id ? props.context.data.session.status(id) : undefined
|
||||
if (previous === "running" && status === "idle") void refetch()
|
||||
return status
|
||||
})
|
||||
const sourceBase = () => {
|
||||
const ref = selectedBase()
|
||||
return ref ? { name: ref, ref } : reportedBases().get(baseKey())
|
||||
}
|
||||
const result = () => (diff.error || diff.loading ? undefined : diff())
|
||||
const sourceDetail = () => {
|
||||
if (mode() === "turn") return diff.error ? "Diff unavailable" : undefined
|
||||
if (mode() === "working") return "vs HEAD"
|
||||
if (diff.error) return "Base or diff unavailable"
|
||||
if (!result()) return "Resolving diff…"
|
||||
@@ -167,6 +197,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
loading={diff.loading}
|
||||
error={diff.error}
|
||||
mode={mode()}
|
||||
sources={sources()}
|
||||
sourceDetail={sourceDetail()}
|
||||
sourceBase={sourceBase()}
|
||||
unavailable={mode() === "committed" && !!result() && !result()?.base}
|
||||
@@ -261,6 +292,7 @@ export function DiffViewerContent(props: {
|
||||
loading?: boolean
|
||||
error?: unknown
|
||||
mode: DiffMode
|
||||
sources: readonly DiffMode[]
|
||||
sourceDetail?: string
|
||||
sourceBase?: Pick<Vcs.Base, "name" | "ref"> | null
|
||||
unavailable?: boolean
|
||||
@@ -702,20 +734,6 @@ export function DiffViewerContent(props: {
|
||||
]
|
||||
|
||||
const openSwitchDiffDialog = () => {
|
||||
const options = [
|
||||
{
|
||||
value: "branch" as const,
|
||||
description: "Branch + local changes",
|
||||
},
|
||||
{
|
||||
value: "committed" as const,
|
||||
description: "Branch commits only",
|
||||
},
|
||||
{
|
||||
value: "working" as const,
|
||||
description: "Local changes only",
|
||||
},
|
||||
]
|
||||
dialog.show(() => (
|
||||
<DialogSelect<DiffMode | "base">
|
||||
title="Diff source"
|
||||
@@ -723,13 +741,14 @@ export function DiffViewerContent(props: {
|
||||
renderFilter={false}
|
||||
current={mode()}
|
||||
options={[
|
||||
...options.map((option) => ({
|
||||
...option,
|
||||
title: diffSourceLabel(option.value),
|
||||
titleView: diffSourceLabel(option.value).padEnd(11),
|
||||
...props.sources.map((source) => ({
|
||||
value: source,
|
||||
title: DIFF_SOURCES[source].label,
|
||||
titleView: DIFF_SOURCES[source].label.padEnd(11),
|
||||
description: DIFF_SOURCES[source].description,
|
||||
onSelect() {
|
||||
dialog.clear()
|
||||
props.onSwitchSource(option.value)
|
||||
props.onSwitchSource(source)
|
||||
},
|
||||
})),
|
||||
...(props.onChooseBase
|
||||
@@ -811,7 +830,7 @@ export function DiffViewerContent(props: {
|
||||
flexShrink={0}
|
||||
wrapMode="none"
|
||||
>
|
||||
{diffSourceLabel(mode())}
|
||||
{DIFF_SOURCES[mode()].label}
|
||||
</text>
|
||||
<Show when={props.sourceDetail}>
|
||||
<text fg={theme.text.muted} selectable={false} flexGrow={1} minWidth={0} wrapMode="none" truncate>
|
||||
@@ -834,7 +853,7 @@ export function DiffViewerContent(props: {
|
||||
<Match when={!props.loading && props.error}>
|
||||
<box flexGrow={1} padding={2}>
|
||||
<text fg={theme.text.feedback.error.base}>
|
||||
{!props.sourceBase && mode() !== "working"
|
||||
{!props.sourceBase && needsBase(mode())
|
||||
? "Could not load diff. Choose a base branch from Diff source, or select Uncommitted."
|
||||
: "Could not load diff. Reopen the diff viewer to try again."}
|
||||
</text>
|
||||
@@ -868,7 +887,7 @@ export function DiffViewerContent(props: {
|
||||
expandedNodes={expandedFileNodes()}
|
||||
onRowClick={clickFileTreeRow}
|
||||
onFileContextMenu={openFileMenu}
|
||||
source={diffSourceLabel(mode())}
|
||||
source={DIFF_SOURCES[mode()].label}
|
||||
sourceDetail={props.sourceDetail}
|
||||
onSwitchSource={openSwitchDiffDialog}
|
||||
footer={<HelpShortcut />}
|
||||
|
||||
@@ -209,6 +209,80 @@ test("explicit route source overrides the configured default", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("the turn source diffs the session's last turn without resolving a base", async () => {
|
||||
const viewer = await renderDiffViewer(hunkDiff, { height: 30, kittyKeyboard: true })
|
||||
try {
|
||||
await chooseSource(viewer, 3)
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("Last turn") && frame.includes("const first"))
|
||||
expect(viewer.app.captureCharFrame()).not.toContain("Base not reported")
|
||||
expect(viewer.turnDiffRequests).toHaveLength(1)
|
||||
expect(viewer.turnDiffRequests[0].searchParams.get("context")).toBe("12")
|
||||
expect(viewer.turnDiffRequests[0].searchParams.has("from")).toBe(false)
|
||||
expect(viewer.diffRequests).toHaveLength(1)
|
||||
expect(viewer.baseRequests).toHaveLength(1)
|
||||
await chooseSource(viewer, 2)
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("Uncommitted · vs HEAD") && frame.includes("const first"))
|
||||
expect(viewer.vcsDiffInput()).toEqual({ location: session.location, mode: "working", context: "12" })
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("the turn source refreshes only when the session finishes a turn", async () => {
|
||||
const viewer = await renderDiffViewer(hunkDiff, { source: "turn" })
|
||||
try {
|
||||
expect(viewer.turnDiffRequests).toHaveLength(1)
|
||||
viewer.setSessionStatus("running")
|
||||
await viewer.app.flush()
|
||||
expect(viewer.turnDiffRequests).toHaveLength(1)
|
||||
viewer.setSessionStatus("idle")
|
||||
await viewer.app.waitFor(() => viewer.turnDiffRequests.length === 2)
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("const first"))
|
||||
expect(viewer.diffRequests).toHaveLength(0)
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("an empty or failing turn diff uses the viewer's empty and error states", async () => {
|
||||
const empty = await renderDiffViewer([], { source: "turn" })
|
||||
try {
|
||||
await empty.app.waitForFrame((frame) => frame.includes("No changes to show"))
|
||||
expect(empty.baseRequests).toHaveLength(0)
|
||||
expect(empty.diffRequests).toHaveLength(0)
|
||||
expect(empty.turnDiffRequests).toHaveLength(1)
|
||||
} finally {
|
||||
empty.app.renderer.destroy()
|
||||
}
|
||||
const failing = await renderDiffViewer([], { source: "turn", fail: true })
|
||||
try {
|
||||
await failing.app.waitForFrame((frame) => frame.includes("Could not load diff. Reopen the diff viewer"))
|
||||
expect(failing.app.captureCharFrame()).toContain("Last turn · Diff unavailable")
|
||||
expect(failing.app.captureCharFrame()).not.toContain("Choose a base branch")
|
||||
} finally {
|
||||
failing.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("the turn source is unavailable outside a session and falls back to the branch scope", async () => {
|
||||
const viewer = await renderDiffViewer(hunkDiff, { source: "turn", height: 30, initialRoute: { type: "home" } })
|
||||
try {
|
||||
expect(viewer.vcsDiffInput()).toEqual({
|
||||
location: { directory: "/repo/default" },
|
||||
mode: "branch",
|
||||
base: "refs/heads/v2",
|
||||
context: "12",
|
||||
})
|
||||
expect(viewer.turnDiffRequests).toHaveLength(0)
|
||||
viewer.app.mockInput.pressKey("d")
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("Diff source"))
|
||||
expect(viewer.app.captureCharFrame()).not.toContain("Last turn")
|
||||
expect(viewer.app.captureCharFrame()).toMatch(/Base\s+v2/)
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([50, 80, 160])(
|
||||
"keeps scope, base, and review count on one row with a selectable base at %i columns",
|
||||
async (width) => {
|
||||
@@ -240,9 +314,11 @@ test.each([50, 80, 160])(
|
||||
expect(rows[first]).toMatch(/All\s+Branch \+ local changes/)
|
||||
expect(rows[first + 1]).toMatch(/Committed\s+Branch commits only/)
|
||||
expect(rows[first + 2]).toMatch(/Uncommitted\s+Local changes only/)
|
||||
expect(rows[first + 3]).toMatch(/Base\s+release/)
|
||||
expect(rows[first + 3]).toMatch(/Last turn\s+Latest session turn/)
|
||||
expect(rows[first + 4]).toMatch(/Base\s+release/)
|
||||
expect(rows[first + 1].indexOf("Branch commits only")).toBe(rows[first].indexOf("Branch + local changes"))
|
||||
expect(rows[first + 2].indexOf("Local changes only")).toBe(rows[first].indexOf("Branch + local changes"))
|
||||
expect(rows[first + 3].indexOf("Latest session turn")).toBe(rows[first].indexOf("Branch + local changes"))
|
||||
viewer.app.mockInput.pressEscape()
|
||||
await viewer.app.waitForFrame((frame) => !frame.includes("Diff source"))
|
||||
viewer.commands.get("diff.mark_reviewed")!.run()
|
||||
@@ -292,7 +368,7 @@ test.each([50, 80, 100, 160])(
|
||||
)
|
||||
|
||||
test("opening the source chooser from initial Uncommitted does not resolve a branch base", async () => {
|
||||
const viewer = await renderDiffViewer(hunkDiff, { source: "working" })
|
||||
const viewer = await renderDiffViewer(hunkDiff, { source: "working", height: 30 })
|
||||
try {
|
||||
viewer.app.mockInput.pressKey("d")
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("Diff source"))
|
||||
@@ -313,7 +389,7 @@ test.each(["branch", "committed", "working"] as const)(
|
||||
viewer.commands.get("diff.mark_reviewed")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("1/1")
|
||||
await chooseSource(viewer, 3)
|
||||
await chooseSource(viewer, 4)
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("Base branch") && frame.includes("origin/release"))
|
||||
expect(viewer.app.captureCharFrame()).toMatch(/●\s+v2/)
|
||||
expect(viewer.branchesRequests[0].searchParams.get("location[directory]")).toBe("/repo/session")
|
||||
@@ -347,7 +423,7 @@ test.each(["branch", "committed", "working"] as const)(
|
||||
expect(viewer.app.captureCharFrame()).toContain("0/1")
|
||||
expect(viewer.diffRequests).toHaveLength(source === "working" ? 2 : 3)
|
||||
if (source !== "working") expect(viewer.vcsDiffInput()).toMatchObject({ base: "origin/release" })
|
||||
await chooseSource(viewer, 3)
|
||||
await chooseSource(viewer, 4)
|
||||
await viewer.app.waitForFrame((frame) => /●\s+origin\/release/.test(frame))
|
||||
expect(viewer.baseRequests).toHaveLength(1)
|
||||
} finally {
|
||||
@@ -371,7 +447,7 @@ test.each(["branch", "committed"] as const)("an ambiguous base never requests a
|
||||
expect(viewer.app.captureCharFrame()).toContain("Choose a base branch")
|
||||
expect(viewer.app.captureCharFrame()).not.toContain("No changes to show")
|
||||
expect(viewer.diffRequests).toHaveLength(0)
|
||||
await chooseSource(viewer, 3)
|
||||
await chooseSource(viewer, 4)
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("Base branch") && frame.includes("origin/release"))
|
||||
viewer.app.mockInput.pressKey("HOME")
|
||||
viewer.app.mockInput.pressArrow("down")
|
||||
@@ -388,7 +464,7 @@ test.each(["branch", "committed"] as const)("an ambiguous base never requests a
|
||||
test("base and scope choices survive reopening but not a new TUI instance", async () => {
|
||||
const viewer = await renderDiffViewer(hunkDiff, { source: "working", height: 30, kittyKeyboard: true })
|
||||
try {
|
||||
await chooseSource(viewer, 3)
|
||||
await chooseSource(viewer, 4)
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("origin/release"))
|
||||
viewer.app.mockInput.pressArrow("down")
|
||||
viewer.app.mockInput.pressEnter()
|
||||
@@ -421,7 +497,7 @@ test("base and scope choices survive reopening but not a new TUI instance", asyn
|
||||
test("base choices are isolated by branch within the same location", async () => {
|
||||
const viewer = await renderDiffViewer(hunkDiff, { height: 30, kittyKeyboard: true })
|
||||
try {
|
||||
await chooseSource(viewer, 3)
|
||||
await chooseSource(viewer, 4)
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("origin/release"))
|
||||
viewer.app.mockInput.pressArrow("down")
|
||||
viewer.app.mockInput.pressEnter()
|
||||
@@ -449,13 +525,13 @@ test("an invalid comparison reports an error and allows another base choice", as
|
||||
: json({ location: session.location, data: hunkDiff }),
|
||||
})
|
||||
try {
|
||||
await chooseSource(viewer, 3)
|
||||
await chooseSource(viewer, 4)
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("origin/release"))
|
||||
viewer.app.mockInput.pressArrow("down")
|
||||
viewer.app.mockInput.pressEnter()
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("Base or diff unavailable"))
|
||||
expect(viewer.app.captureCharFrame()).not.toContain("No changes to show")
|
||||
await chooseSource(viewer, 3)
|
||||
await chooseSource(viewer, 4)
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("origin/release"))
|
||||
viewer.app.mockInput.pressKey("HOME")
|
||||
viewer.app.mockInput.pressEnter()
|
||||
@@ -474,7 +550,7 @@ test("base search failures are visible without changing the diff", async () => {
|
||||
branchesResponse: async () => json({ message: "branches unavailable" }, { status: 503 }),
|
||||
})
|
||||
try {
|
||||
await chooseSource(viewer, 3)
|
||||
await chooseSource(viewer, 4)
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("Could not load branches"))
|
||||
expect(viewer.app.captureCharFrame()).toContain("All · vs v2")
|
||||
expect(viewer.mutationRequests).toHaveLength(0)
|
||||
@@ -493,7 +569,7 @@ test("a late base lookup cannot overwrite an in-memory base choice", async () =>
|
||||
baseResponse: () => pending.promise,
|
||||
})
|
||||
try {
|
||||
await chooseSource(viewer, 3)
|
||||
await chooseSource(viewer, 4)
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("origin/release"))
|
||||
viewer.app.mockInput.pressArrow("down")
|
||||
viewer.app.mockInput.pressEnter()
|
||||
@@ -522,7 +598,7 @@ test("dismissing the base picker leaves the comparison unchanged", async () => {
|
||||
kittyKeyboard: true,
|
||||
})
|
||||
try {
|
||||
await chooseSource(viewer, 3)
|
||||
await chooseSource(viewer, 4)
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("origin/release"))
|
||||
viewer.app.mockInput.pressArrow("down")
|
||||
viewer.app.mockInput.pressEscape()
|
||||
@@ -540,7 +616,7 @@ test("dismissing the base picker leaves the comparison unchanged", async () => {
|
||||
test("the base picker remembers its captured location without refreshing a moved session", async () => {
|
||||
const viewer = await renderDiffViewer(hunkDiff, { height: 30, kittyKeyboard: true })
|
||||
try {
|
||||
await chooseSource(viewer, 3)
|
||||
await chooseSource(viewer, 4)
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("origin/release"))
|
||||
viewer.setSessionLocation({ directory: "/repo/moved" })
|
||||
await viewer.app.flush()
|
||||
@@ -1800,7 +1876,7 @@ async function renderDiffViewer(
|
||||
onSessionTab?: () => void
|
||||
keybinds?: TuiKeybind.KeybindOverrides
|
||||
kittyKeyboard?: boolean
|
||||
source?: "branch" | "committed" | "working"
|
||||
source?: "branch" | "committed" | "working" | "turn"
|
||||
base?: typeof baseFixture | null
|
||||
open?: boolean
|
||||
pending?: boolean
|
||||
@@ -1814,6 +1890,7 @@ async function renderDiffViewer(
|
||||
const [current, setCurrent] = createSignal<Route>(options.initialRoute ?? startRoute)
|
||||
const [sessionLocation, setSessionLocation] = createSignal(session.location)
|
||||
const [branch, setBranch] = createSignal("feature")
|
||||
const [sessionStatus, setSessionStatus] = createSignal<"idle" | "running">("idle")
|
||||
const state = options.state ?? path.join(temporary.path, crypto.randomUUID())
|
||||
let renderDiff: Page["render"] | undefined
|
||||
let renderCommands: SlotClaim<"app">["render"] | undefined
|
||||
@@ -1826,6 +1903,7 @@ async function renderDiffViewer(
|
||||
const writes: Info[] = []
|
||||
const baseRequests: URL[] = []
|
||||
const diffRequests: URL[] = []
|
||||
const turnDiffRequests: URL[] = []
|
||||
const branchesRequests: URL[] = []
|
||||
const mutationRequests: URL[] = []
|
||||
const config = createTuiResolvedConfig(stored.info)
|
||||
@@ -1858,6 +1936,11 @@ async function renderDiffViewer(
|
||||
),
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/api/session/session-1/diff") {
|
||||
turnDiffRequests.push(url)
|
||||
if (options.fail) return json({ message: "boom" }, { status: 500 })
|
||||
return json({ data: vcsDiff })
|
||||
}
|
||||
if (url.pathname !== "/api/vcs/diff") return
|
||||
diffRequests.push(url)
|
||||
vcsDiffInput = {
|
||||
@@ -1896,7 +1979,7 @@ async function renderDiffViewer(
|
||||
storage: useStorage(),
|
||||
client: createApi(transport.fetch),
|
||||
data: {
|
||||
session: { get: () => ({ ...session, location: sessionLocation() }) },
|
||||
session: { get: () => ({ ...session, location: sessionLocation() }), status: sessionStatus },
|
||||
location: {
|
||||
default: () => ({ directory: "/repo/default" }),
|
||||
vcs: { info: () => ({ branch: { current: branch() } }) },
|
||||
@@ -2003,9 +2086,11 @@ async function renderDiffViewer(
|
||||
imageReadInput: () => imageReadInput,
|
||||
baseRequests,
|
||||
diffRequests,
|
||||
turnDiffRequests,
|
||||
branchesRequests,
|
||||
mutationRequests,
|
||||
setSessionLocation,
|
||||
setSessionStatus,
|
||||
setBranch,
|
||||
state,
|
||||
writes,
|
||||
|
||||
@@ -9,8 +9,8 @@ import { CommandMap, Definitions } from "../src/config/v1/keybind"
|
||||
|
||||
const decodeInfo = Schema.decodeUnknownSync(Info)
|
||||
|
||||
test("validates the three explicit diff source defaults", () => {
|
||||
for (const source of ["branch", "committed", "working"] as const) {
|
||||
test("validates the explicit diff source defaults", () => {
|
||||
for (const source of ["branch", "committed", "working", "turn"] as const) {
|
||||
expect(decodeInfo({ diffs: { source } })).toEqual({ diffs: { source } })
|
||||
}
|
||||
expect(decodeInfo({ diffs: {} })).toEqual({ diffs: {} })
|
||||
|
||||
@@ -194,18 +194,19 @@ Configure the initial diff scope and presentation:
|
||||
|
||||
<div class="docs-table-scroll" role="region" aria-label="Diff settings" tabIndex={0}>
|
||||
|
||||
| Setting | Values | Description |
|
||||
| -------------- | ----------------------------------- | ------------------------------------------------------------- |
|
||||
| `diffs.source` | `branch`, `committed`, or `working` | Sets the initial review scope. |
|
||||
| `diffs.wrap` | `word` or `none` | Wraps long lines at words or leaves them unwrapped. |
|
||||
| `diffs.tree` | boolean | Shows the diff file tree. |
|
||||
| `diffs.single` | boolean | Shows only the selected file patch. |
|
||||
| `diffs.view` | `auto`, `split`, or `unified` | Sets the layout. `auto` chooses from the available width. |
|
||||
| Setting | Values | Description |
|
||||
| -------------- | ------------------------------------------- | --------------------------------------------------------- |
|
||||
| `diffs.source` | `branch`, `committed`, `working`, or `turn` | Sets the initial review scope. |
|
||||
| `diffs.wrap` | `word` or `none` | Wraps long lines at words or leaves them unwrapped. |
|
||||
| `diffs.tree` | boolean | Shows the diff file tree. |
|
||||
| `diffs.single` | boolean | Shows only the selected file patch. |
|
||||
| `diffs.view` | `auto`, `split`, or `unified` | Sets the layout. `auto` chooses from the available width. |
|
||||
|
||||
</div>
|
||||
|
||||
`branch` shows **All** branch and local changes, `committed` shows branch commits only, and `working` shows staged,
|
||||
unstaged, and untracked changes.
|
||||
unstaged, and untracked changes. `turn` shows **Last turn**, the files the session changed in its latest turn; it
|
||||
refreshes when the session finishes a turn and falls back to `branch` when `/diff` is not opened from a session.
|
||||
|
||||
In `/diff`, press `d` to change the scope or choose a comparison branch from **Base**. These in-view choices last until
|
||||
the TUI exits and do not change `cli.json`.
|
||||
|
||||
@@ -117,7 +117,7 @@ The `app.clear` command works only in [`opencode mini`](/cli). Press `ctrl+l` to
|
||||
|
||||
## Diff Viewer
|
||||
|
||||
Press `d` (`diff.switch_source`) to choose **All**, **Committed**, or **Uncommitted**, or select **Base** to change the comparison branch. Choices are remembered until the TUI exits. Set the initial scope with [`diffs.source` in `cli.json`](/cli/config#diffs).
|
||||
Press `d` (`diff.switch_source`) to choose **All**, **Committed**, **Uncommitted**, or **Last turn** (inside a session), or select **Base** to change the comparison branch. Choices are remembered until the TUI exits. Set the initial scope with [`diffs.source` in `cli.json`](/cli/config#diffs).
|
||||
|
||||
Scrolling, paging, and start/end shortcuts always control the diff. There is no keyboard focus switch: click files to open them, click folders to expand or collapse them, and use the mouse wheel to scroll the file tree.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user