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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/tui/src/config/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
111 changes: 65 additions & 46 deletions packages/tui/src/feature-plugins/system/diff-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 }
Expand All @@ -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()
Expand All @@ -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,
Expand All @@ -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: [] }
}
Expand All @@ -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…"
Expand All @@ -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}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -702,34 +734,21 @@ 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"
skipFilter={true}
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
Expand Down Expand Up @@ -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>
Expand All @@ -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>
Expand Down Expand Up @@ -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 />}
Expand Down
Loading
Loading