Compare commits

..
1 Commits
Author SHA1 Message Date
Shoubhit Dash 7212af12ee feat(tui): add last turn source to diff viewer 2026-09-27 15:06:01 +05:30
6 changed files with 183 additions and 74 deletions
+6 -2
View File
@@ -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 />}
+100 -15
View File
@@ -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,
+2 -2
View File
@@ -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: {} })
+9 -8
View File
@@ -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.