Compare commits

...
Author SHA1 Message Date
James Long dea9125004 feat(tui): add split diff viewer 2026-09-03 20:15:29 +00:00
9 changed files with 483 additions and 124 deletions
+19 -16
View File
@@ -100,6 +100,7 @@ import { cliErrorMessage, errorFormat } from "./util/error"
import { AttentionProvider } from "./context/attention"
import { StorageProvider, useStorage } from "./context/storage"
import { SessionTerminalsProvider } from "./context/session-terminals"
import { SessionPanelProvider } from "./context/session-panel"
import { SessionFrame } from "./component/session-frame"
import { createTuiClipboard } from "./clipboard"
@@ -397,22 +398,24 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<App
updater={input.updater}
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
<SessionPanelProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<App
updater={input.updater}
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</SessionPanelProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
+128 -48
View File
@@ -1,15 +1,27 @@
import { RGBA, MouseEvent, type ScrollBoxRenderable } from "@opentui/core"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createEffect, createMemo, createResource, createSignal, on, Show } from "solid-js"
import {
batch,
createComponent,
createEffect,
createMemo,
createResource,
createSignal,
on,
onCleanup,
Show,
} from "solid-js"
import { useConfig } from "../config"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { useSessionTerminals } from "../context/session-terminals"
import { usePromptRef } from "../context/prompt"
import { useSessionPanel } from "../context/session-panel"
import { useStorage } from "../context/storage"
import { useTheme } from "../context/theme"
import { Session } from "../routes/session"
import { Sidebar } from "../routes/session/sidebar"
import { clampTerminalPaneWidth, SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { clampSessionPaneWidth, SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { createPaneResize } from "../ui/pane-resize"
import { PaneResizeHandle } from "../ui/pane-resize-handle"
import { useToast } from "../ui/toast"
@@ -23,38 +35,44 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
const toast = useToast()
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const panel = useSessionPanel()
const elevated = useTheme("elevated")
const availableWidth = () => Math.max(0, dimensions().width - props.verticalTabsWidth)
const defaultTerminalWidth = () => Math.max(1, Math.floor(dimensions().width / 2))
const [layout, updateLayout] = useStorage().store<{ terminalWidth?: number }>("layout", { initial: {} })
const terminalResize = createPaneResize({
value: () => layout.terminalWidth ?? defaultTerminalWidth(),
defaultValue: defaultTerminalWidth,
clamp: (width) => clampTerminalPaneWidth(width, availableWidth()),
const defaultPaneWidth = () => Math.max(1, Math.floor(dimensions().width / 2))
const [layout, updateLayout] = useStorage().store<{ paneWidth?: number; terminalWidth?: number }>("layout", {
initial: {},
})
const paneResize = createPaneResize({
value: () => layout.paneWidth ?? layout.terminalWidth ?? defaultPaneWidth(),
defaultValue: defaultPaneWidth,
clamp: (width) => clampSessionPaneWidth(width, availableWidth()),
fromMouse: (event) => dimensions().width - event.x - 1,
contains: (event, width) => event.x >= dimensions().width - width - 1 && event.x <= dimensions().width - width,
onCommit: (width) => {
void updateLayout((draft) => {
draft.terminalWidth = width
draft.paneWidth = width
}).catch((error) => console.error("Failed to persist TUI layout", error))
},
})
let resizeRelease = false
const finishTerminalResize = (event: MouseEvent) => {
if (terminalResize.resizing()) {
const finishPaneResize = (event: MouseEvent) => {
if (paneResize.resizing()) {
// A captured drag-end can be followed by mouse-up on the focus overlay.
resizeRelease = true
queueMicrotask(() => {
resizeRelease = false
})
}
terminalResize.onMouseUp(event)
paneResize.onMouseUp(event)
}
const [sidebarOpen, setSidebarOpen] = createSignal(false)
const [sessionWidth, setSessionWidth] = createSignal<number>()
const [terminalFocused, setTerminalFocused] = createSignal(false)
const [panelFocused, setPanelFocused] = createSignal(false)
const [restoreTerminalFocus, setRestoreTerminalFocus] = createSignal(false)
let focusTerminal: (() => void) | undefined
let sessionScroll: ScrollBoxRenderable | undefined
let focusPanel: (() => void) | undefined
createResource(
() => (config.data.session.terminal ? props.sessionID : undefined),
(sessionID) => sessions.refresh(sessionID).catch(() => undefined),
@@ -65,22 +83,43 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
const value = session()
return value.terminals.find((terminal) => terminal.id === value.selectedTerminalID)
}
const activePanel = createMemo(() => {
const current = panel.current()
if (current?.sessionID === props.sessionID) return current
})
createEffect(
on(
() => selectedTerminal()?.id,
(id) => {
if (id) setSidebarOpen(false)
if (!id) return
setSidebarOpen(false)
if (activePanel()) panel.close()
},
{ defer: true },
),
)
const splitAvailable = createMemo(() => dimensions().width > 80)
const wide = createMemo(() => dimensions().width - props.verticalTabsWidth > 120)
createEffect(() => panel.setAvailable(props.sessionID, splitAvailable()))
onCleanup(() => panel.setAvailable(props.sessionID, false))
createEffect(() => {
const current = activePanel()
if (!current || splitAvailable()) return
panel.close()
current.onUnavailable?.()
})
createEffect(() => {
if (!activePanel()) return
setSidebarOpen(false)
if (selectedTerminal()) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
})
const sidebarVisible = createMemo(() => {
if (data.session.get(props.sessionID)?.parentID) return false
if (sidebarOpen()) return true
return (config.data.session?.sidebar ?? "auto") === "auto" && wide()
})
const rightPane = createMemo(() => {
if (activePanel()) return "panel"
if (sidebarOpen() && sidebarVisible()) return "sidebar"
if (selectedTerminal()) return "terminal"
if (sidebarVisible()) return "sidebar"
@@ -94,21 +133,44 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
})
.catch(toast.error)
setSidebarOpen(!visible)
if (!visible && activePanel()) panel.close()
if (!visible && selectedTerminal()) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
})
}
const focusSession = () => {
// Permission prompts replace the input, so returning focus must not depend on it.
if (terminalFocused()) renderer.currentFocusedRenderable?.blur()
if (terminalFocused() || panelFocused()) renderer.currentFocusedRenderable?.blur()
prompt.current?.focus()
}
const focusRightPane = () => {
if (activePanel()) {
focusPanel?.()
return
}
focusTerminal?.()
}
createEffect(
on(
() => activePanel()?.id,
(id) => {
if (!id) {
setPanelFocused(false)
return
}
requestAnimationFrame(() => {
if (activePanel()?.id !== id) return
focusPanel?.()
})
},
),
)
createEffect(() => {
if (!restoreTerminalFocus() || selectedTerminal()) return
setRestoreTerminalFocus(false)
focusSession()
})
Keymap.createLayer(() => ({
enabled: () => config.data.session.terminal === true,
enabled: () => config.data.session.terminal === true || activePanel() !== undefined,
commands: [
{
id: "pane.focus.left",
@@ -117,10 +179,8 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
},
{
id: "pane.focus.right",
title: "Focus terminal pane",
run: () => {
focusTerminal?.()
},
title: "Focus right pane",
run: focusRightPane,
},
],
}))
@@ -132,9 +192,9 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
minHeight={0}
flexDirection="row"
position="relative"
onMouseDrag={terminalResize.onMouseDrag}
onMouseDragEnd={finishTerminalResize}
onMouseUp={finishTerminalResize}
onMouseDrag={paneResize.onMouseDrag}
onMouseDragEnd={finishPaneResize}
onMouseUp={finishPaneResize}
>
<box
flexGrow={1}
@@ -149,13 +209,13 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
<Session
scrollRef={(value) => (sessionScroll = value)}
verticalTabsWidth={props.verticalTabsWidth}
promptMuted={terminalFocused()}
promptMuted={terminalFocused() || panelFocused()}
sidebarVisible={rightPane() === "sidebar"}
onToggleSidebar={toggleSidebar}
visibleTerminalID={rightPane() === "terminal" ? selectedTerminal()?.id : undefined}
width={sessionWidth()}
/>
<Show when={terminalFocused()}>
<Show when={terminalFocused() || panelFocused()}>
<box
position="absolute"
left={0}
@@ -174,37 +234,61 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
}}
// Consume the release before revealing permission buttons underneath.
onMouseUp={() => {
if (terminalResize.resizing() || resizeRelease) return
if (paneResize.resizing() || resizeRelease) return
focusSession()
}}
/>
</Show>
</box>
<Show when={rightPane() === "terminal" || (rightPane() === "sidebar" && wide())}>
<Show when={rightPane() === "terminal" || rightPane() === "panel" || (rightPane() === "sidebar" && wide())}>
<box
flexShrink={0}
width={rightPane() === "terminal" ? terminalResize.size() : SESSION_SIDEBAR_WIDTH}
width={rightPane() === "terminal" || rightPane() === "panel" ? paneResize.size() : SESSION_SIDEBAR_WIDTH}
minWidth={0}
minHeight={0}
backgroundColor={rightPane() === "panel" ? elevated.background.default : undefined}
>
<Show
when={rightPane() === "sidebar"}
fallback={
<Show keyed when={selectedTerminal()?.id}>
{(ptyID) => (
<TerminalPane
ptyID={ptyID}
resizing={terminalResize.resizing()}
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
onAutoFocus={() => {
sessions.clearFocus(ptyID)
setRestoreTerminalFocus(false)
}}
onFocusChange={setTerminalFocused}
onFocusRequest={(value) => (focusTerminal = value)}
onDisconnect={() => setRestoreTerminalFocus(true)}
/>
)}
<Show
keyed
when={activePanel()}
fallback={
<Show keyed when={selectedTerminal()?.id}>
{(ptyID) => (
<TerminalPane
ptyID={ptyID}
resizing={paneResize.resizing()}
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
onAutoFocus={() => {
sessions.clearFocus(ptyID)
setRestoreTerminalFocus(false)
}}
onFocusChange={setTerminalFocused}
onFocusRequest={(value) => (focusTerminal = value)}
onDisconnect={() => setRestoreTerminalFocus(true)}
/>
)}
</Show>
}
>
{(item) =>
createComponent(item.render, {
get width() {
return paneResize.size()
},
get resizing() {
return paneResize.resizing()
},
get focused() {
return panelFocused()
},
onFocusChange: setPanelFocused,
onFocusRequest: (value) => (focusPanel = value),
close: panel.close,
})
}
</Show>
}
>
@@ -212,12 +296,8 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
</Show>
</box>
</Show>
<Show when={rightPane() === "terminal" && availableWidth() >= 3}>
<PaneResizeHandle
resize={terminalResize}
left={availableWidth() - terminalResize.size() - 1}
highlight="right"
/>
<Show when={(rightPane() === "terminal" || rightPane() === "panel") && availableWidth() >= 3}>
<PaneResizeHandle resize={paneResize} left={availableWidth() - paneResize.size() - 1} highlight="right" />
</Show>
<Show when={rightPane() === "sidebar" && !wide()}>
<box
+2 -1
View File
@@ -84,6 +84,7 @@ export const Definitions = {
"diff.single_patch": keybind("s", "Toggle single patch view"),
"diff.switch_source": keybind("d", "Switch diff viewer source"),
"diff.toggle_view": keybind("v", "Toggle diff viewer split or unified view"),
"diff.toggle_fullscreen": keybind("f", "Toggle diff viewer full screen"),
"diff.mark_reviewed": keybind("m", "Toggle selected diff file reviewed"),
"diff.help": keybind("?,shift+?,shift+/", "Show more diff viewer shortcuts"),
@@ -93,7 +94,7 @@ export const Definitions = {
"theme.mode.lock": keybind("none", "Lock or unlock theme mode"),
"session.sidebar.toggle": keybind("<leader>b", "Toggle sidebar"),
"pane.focus.left": keybind("<leader>left", "Focus session pane"),
"pane.focus.right": keybind("<leader>right", "Focus terminal pane"),
"pane.focus.right": keybind("<leader>right", "Focus right pane"),
"terminal.select": keybind("<leader>down", "Select terminal"),
"terminal.toggle": keybind("<leader>t", "Toggle terminal pane"),
"terminal.close": keybind("<leader>up", "Close terminal pane"),
@@ -0,0 +1,54 @@
import { createContext, createSignal, useContext, type JSX, type ParentProps } from "solid-js"
export type SessionPanelRenderProps = {
readonly width: number
readonly resizing: boolean
readonly focused: boolean
readonly onFocusChange: (focused: boolean) => void
readonly onFocusRequest: (focus: (() => void) | undefined) => void
readonly close: () => void
}
type Panel = {
readonly id: string
readonly sessionID: string
readonly render: (props: SessionPanelRenderProps) => JSX.Element
readonly onUnavailable?: () => void
}
const Context = createContext<{
readonly current: () => Panel | undefined
readonly open: (panel: Panel) => void
readonly close: () => void
readonly available: (sessionID: string) => boolean
readonly setAvailable: (sessionID: string, available: boolean) => void
}>()
export function SessionPanelProvider(props: ParentProps) {
const [current, setCurrent] = createSignal<Panel>()
const [availableSessionID, setAvailableSessionID] = createSignal<string>()
return (
<Context.Provider
value={{
current,
open: setCurrent,
close: () => setCurrent(),
available: (sessionID) => availableSessionID() === sessionID,
setAvailable: (sessionID, available) =>
setAvailableSessionID((current) => (available ? sessionID : current === sessionID ? undefined : current)),
}}
>
{props.children}
</Context.Provider>
)
}
export function useSessionPanel() {
const value = useContext(Context)
if (!value) throw new Error("useSessionPanel must be used within a SessionPanelProvider")
return value
}
export function useOptionalSessionPanel() {
return useContext(Context)
}
@@ -4,6 +4,7 @@ import type { Vcs } from "@opencode-ai/schema/vcs"
import { Plugin } from "@opencode-ai/plugin/tui"
import type { KeymapCommand, Route } from "@opencode-ai/plugin/tui/context"
import {
CliRenderEvents,
MouseButton,
TextAttributes,
type BoxRenderable,
@@ -22,7 +23,8 @@ import { getScrollAcceleration } from "../../util/scroll"
import { createDebouncedSignal } from "../../util/signal"
import { useConfig } from "../../config"
import { locationKey } from "../../context/data"
import { useThemes } from "../../context/theme"
import { useOptionalSessionPanel } from "../../context/session-panel"
import { useTheme, useThemes } from "../../context/theme"
import { PatchDiff, type PatchDiffRef } from "../../component/patch-diff"
import {
allExpandedFileTreeDirectories,
@@ -75,9 +77,55 @@ function diffSourceLabel(mode: DiffMode) {
return "Uncommitted"
}
function DiffViewer(props: { context: Plugin.Context }) {
type PanelController = NonNullable<ReturnType<typeof useOptionalSessionPanel>>
function openDiffPanel(context: Plugin.Context, panel: PanelController, sessionID: string) {
panel.open({
id: ROUTE,
sessionID,
onUnavailable: () => openDiffFullscreen(context, panel, sessionID, false),
render: (input) => (
<DiffViewer
context={context}
sessionID={sessionID}
width={input.width}
embedded
focused={input.focused}
onFocusChange={input.onFocusChange}
onFocusRequest={input.onFocusRequest}
onClose={input.close}
/>
),
})
}
function openDiffFullscreen(context: Plugin.Context, panel: PanelController, sessionID: string, split: boolean) {
panel.close()
context.ui.router.navigate({
type: "plugin",
name: ROUTE,
data: {
sessionID,
returnRoute: { type: "session", sessionID },
...(split ? { split: true } : {}),
},
})
}
function DiffViewer(props: {
context: Plugin.Context
sessionID?: string
width?: number
embedded?: boolean
focused?: boolean
onFocusChange?: (focused: boolean) => void
onFocusRequest?: (focus: (() => void) | undefined) => void
onClose?: () => void
}) {
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
const config = useConfig()
const panel = useOptionalSessionPanel()
const [memory, updateMemory] = props.context.storage.memory<{
source?: DiffMode
bases: Record<string, string>
@@ -89,13 +137,14 @@ function DiffViewer(props: { context: Plugin.Context }) {
mode?: DiffMode
sessionID?: string
returnRoute?: Route
split?: boolean
}
| undefined
}
const [mode, setMode] = createSignal(params()?.mode ?? memory.source ?? config.data.diffs?.source ?? "branch")
const location = createMemo(
() => {
const sessionID = params()?.sessionID
const sessionID = props.sessionID ?? params()?.sessionID
return sessionID
? (props.context.data.session.get(sessionID)?.location ?? props.context.data.location.default())
: props.context.data.location.default()
@@ -157,52 +206,103 @@ function DiffViewer(props: { context: Plugin.Context }) {
if (!base) return "Base not reported"
return `vs ${base.name}`
}
const sessionID = () => props.sessionID ?? params()?.sessionID
const canToggleFullscreen = () =>
panel !== undefined &&
sessionID() !== undefined &&
(props.embedded === true || (params()?.split === true && dimensions().width > 80))
const toggleFullscreen = () => {
const id = sessionID()
if (!panel || !id) return
if (props.embedded) {
openDiffFullscreen(props.context, panel, id, true)
return
}
openDiffPanel(props.context, panel, id)
props.context.ui.router.navigate({ type: "session", sessionID: id })
}
let panelNode: BoxRenderable | undefined
const onFocused = () => props.onFocusChange?.(renderer.currentFocusedRenderable === panelNode)
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
onCleanup(() => {
renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
props.onFocusChange?.(false)
props.onFocusRequest?.(undefined)
})
const content = () => (
<DiffViewerContent
context={props.context}
files={result()?.files ?? []}
loading={diff.loading}
error={diff.error}
mode={mode()}
sourceDetail={sourceDetail()}
sourceBase={sourceBase()}
unavailable={mode() === "committed" && !!result() && !result()?.base}
preferences={props.embedded ? { ...config.data.diffs, tree: false } : config.data.diffs}
width={props.width}
fileTree={!props.embedded}
elevated={props.embedded}
focused={props.embedded ? props.focused === true : true}
onToggleFullscreen={canToggleFullscreen() ? toggleFullscreen : undefined}
loadImage={(file, signal) => props.context.client.file.read({ path: file, location: location() }, { signal })}
onPreferencesChange={(value) => {
void config
.update((draft) => {
draft.diffs = { ...draft.diffs, ...value }
})
.catch(() => {})
}}
onClose={() => {
if (props.onClose) return props.onClose()
props.context.ui.router.navigate(params()?.returnRoute ?? { type: "home" })
}}
onSwitchSource={(mode) => {
updateMemory((draft) => {
draft.source = mode
})
setMode(mode)
}}
onChooseBase={() => {
const target = { ...location() }
const key = baseKey()
if (!memory.bases[key]) void loadBase(target, key).catch(() => {})
props.context.ui.dialog.show(() => (
<DiffBaseDialog
context={props.context}
location={target}
current={memory.bases[key] ?? reportedBases().get(key)?.ref}
onSelect={(ref) =>
updateMemory((draft) => {
draft.bases[key] = ref
})
}
/>
))
}}
/>
)
if (props.embedded)
return (
<box
ref={(node: BoxRenderable) => {
panelNode = node
props.onFocusRequest?.(() => node.focus())
}}
flexGrow={1}
minWidth={0}
minHeight={0}
focusable
onMouseDown={() => panelNode?.focus()}
>
{content()}
</box>
)
return (
<box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
<DiffViewerContent
context={props.context}
files={result()?.files ?? []}
loading={diff.loading}
error={diff.error}
mode={mode()}
sourceDetail={sourceDetail()}
sourceBase={sourceBase()}
unavailable={mode() === "committed" && !!result() && !result()?.base}
preferences={config.data.diffs}
loadImage={(file, signal) => props.context.client.file.read({ path: file, location: location() }, { signal })}
onPreferencesChange={(value) => {
void config
.update((draft) => {
draft.diffs = { ...draft.diffs, ...value }
})
.catch(() => {})
}}
onClose={() => props.context.ui.router.navigate(params()?.returnRoute ?? { type: "home" })}
onSwitchSource={(mode) => {
updateMemory((draft) => {
draft.source = mode
})
setMode(mode)
}}
onChooseBase={() => {
const target = { ...location() }
const key = baseKey()
if (!memory.bases[key]) void loadBase(target, key).catch(() => {})
props.context.ui.dialog.show(() => (
<DiffBaseDialog
context={props.context}
location={target}
current={memory.bases[key] ?? reportedBases().get(key)?.ref}
onSelect={(ref) =>
updateMemory((draft) => {
draft.bases[key] = ref
})
}
/>
))
}}
/>
{content()}
</box>
)
}
@@ -266,28 +366,36 @@ export function DiffViewerContent(props: {
navigation?: "tree" | "list"
loadImage?: (file: string, signal: AbortSignal) => Promise<Uint8Array>
preferences?: DiffPreferences
width?: number
fileTree?: boolean
elevated?: boolean
focused?: boolean
onPreferencesChange?: (value: DiffPreferences) => void
onClose: () => void
onSwitchSource: (mode: DiffMode) => void
onChooseBase?: () => void
onToggleFullscreen?: () => void
}) {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const config = useConfig()
const dialog = props.context.ui.dialog
const theme = useThemes().current
const currentSyntax = useThemes().currentSyntax
const themes = useThemes()
const elevated = useTheme("elevated")
const theme = props.elevated ? elevated : themes.current
const currentSyntax = themes.currentSyntax
const files = () => props.files
const width = () => props.width ?? dimensions().width
const mode = () => props.mode
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(props.preferences?.tree ?? true)
const showFileTree = createMemo(
() => dimensions().width >= 90 && showDiffViewerFileTree(fileTreeEnabled(), files().length),
() => props.fileTree !== false && width() >= 90 && showDiffViewerFileTree(fileTreeEnabled(), files().length),
)
const [singlePatch, setSinglePatch] = createSignal(props.preferences?.single ?? false)
const fileTreeWidth = createMemo(() =>
Math.max(FILE_TREE_MIN_WIDTH, Math.min(FILE_TREE_MAX_WIDTH, Math.floor(dimensions().width / 4))),
Math.max(FILE_TREE_MIN_WIDTH, Math.min(FILE_TREE_MAX_WIDTH, Math.floor(width() / 4))),
)
const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? fileTreeWidth() : 0) - 4)
const patchPaneWidth = createMemo(() => width() - (showFileTree() ? fileTreeWidth() : 0) - (props.elevated ? 2 : 4))
const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.preferences?.view))
const view = createMemo(() =>
@@ -301,6 +409,7 @@ export function DiffViewerContent(props: {
const patchScrollAcceleration = createMemo(() => getScrollAcceleration(config.data))
const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree())))
const helpShortcut = () => props.context.keymap.shortcuts("diff.help")[0]
const firstShortcut = (id: string, fallback: string) => props.context.keymap.shortcuts(id)[0] ?? fallback
let scroll: ScrollBoxRenderable | undefined
const patchNodeByFileIndex = new Map<number, BoxRenderable>()
const patchDiffByFileIndex = new Map<number, PatchDiffRef>()
@@ -683,6 +792,13 @@ export function DiffViewerContent(props: {
props.onPreferencesChange?.({ view: next })
},
},
{
id: "diff.toggle_fullscreen",
title: "Toggle diff viewer full screen",
group: "VCS",
enabled: () => props.onToggleFullscreen !== undefined,
run: () => props.onToggleFullscreen?.(),
},
{
id: "diff.help",
title: "Show more diff viewer shortcuts",
@@ -748,7 +864,13 @@ export function DiffViewerContent(props: {
}
const openHelpDialog = () => {
dialog.show(() => <DiffViewerHelpDialog context={props.context} single={singlePatch()} />)
dialog.show(() => (
<DiffViewerHelpDialog
context={props.context}
single={singlePatch()}
fullscreen={props.onToggleFullscreen !== undefined}
/>
))
dialog.set({ size: "medium", centered: true })
}
@@ -777,6 +899,7 @@ export function DiffViewerContent(props: {
)
props.context.keymap.layer(() => ({
enabled: () => props.focused !== false,
commands,
}))
@@ -874,7 +997,13 @@ export function DiffViewerContent(props: {
/>
</Show>
<box flexGrow={1} minWidth={0} minHeight={0} paddingLeft={2} paddingRight={2}>
<box
flexGrow={1}
minWidth={0}
minHeight={0}
paddingLeft={props.elevated ? 1 : 2}
paddingRight={props.elevated ? 1 : 2}
>
<box
id="diff-patch-top-edge"
ref={(edge: BoxRenderable) => {
@@ -953,7 +1082,7 @@ export function DiffViewerContent(props: {
zIndex={1}
backgroundColor={background()}
paddingLeft={1}
paddingRight={1}
paddingRight={props.elevated ? 0 : 1}
paddingBottom={1}
>
<box flexGrow={1} minWidth={0}>
@@ -1056,7 +1185,45 @@ export function DiffViewerContent(props: {
</Match>
</Switch>
</box>
<Show when={!showFileTree()}>
<Show when={props.elevated}>
<box height={1} flexShrink={0} />
<box height={1} flexShrink={0} paddingLeft={2} paddingRight={2}>
<Show
when={props.focused}
fallback={
<text fg={theme.text.subdued} flexGrow={1} minWidth={0} wrapMode="none" truncate>
<span style={{ fg: theme.text.default }}>{firstShortcut("pane.focus.right", "ctrl+x →")}</span>
{" focus diff"}
</text>
}
>
<text fg={theme.text.subdued} flexGrow={1} minWidth={0} wrapMode="none" truncate>
<span style={{ fg: theme.text.default }}>{firstShortcut("pane.focus.left", "ctrl+x ←")}</span>
{" focus session "}
<span style={{ fg: theme.text.default }}>{firstShortcut("diff.toggle_fullscreen", "f")}</span>
{" full screen "}
<span style={{ fg: theme.text.default }}>
{firstShortcut("diff.down", "j")}/{firstShortcut("diff.up", "k")}
</span>
{" scroll "}
<span style={{ fg: theme.text.default }}>
{firstShortcut("diff.next_file", "n")}/{firstShortcut("diff.previous_file", "p")}
</span>
{" files "}
<span style={{ fg: theme.text.default }}>
{firstShortcut("diff.next_hunk", "]")}/{firstShortcut("diff.previous_hunk", "[")}
</span>
{" hunks "}
<span style={{ fg: theme.text.default }}>{firstShortcut("diff.close", "q")}</span>
{" close "}
<span style={{ fg: theme.text.default }}>{helpShortcut() ?? "?"}</span>
{" see all"}
</text>
</Show>
</box>
<box height={1} flexShrink={0} />
</Show>
<Show when={!showFileTree() && !props.elevated}>
<box position="absolute" top={0} right={0} width={1} height={1}>
<HelpShortcut compact />
</box>
@@ -1146,7 +1313,7 @@ function DiffFileMenu(props: {
)
}
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean }) {
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean; fullscreen: boolean }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.elevated
const shortcut =
@@ -1186,6 +1353,9 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean
{ shortcut: shortcut("diff.single_patch"), label: "All files / single file" },
{ shortcut: shortcut("diff.toggle_file_tree"), label: "Show / hide file tree" },
{ shortcut: shortcut("diff.switch_source"), label: "Switch diff source" },
...(props.fullscreen
? [{ shortcut: shortcut("diff.toggle_fullscreen"), label: "Full screen / split view" }]
: []),
{ shortcut: () => props.context.keymap.shortcuts("diff.close").join(" / "), label: "Close diff viewer" },
],
},
@@ -1243,6 +1413,7 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean
}
function Commands(props: { context: Plugin.Context }) {
const panel = useOptionalSessionPanel()
props.context.keymap.layer(() => ({
mode: "global",
commands: [
@@ -1254,6 +1425,15 @@ function Commands(props: { context: Plugin.Context }) {
palette: true,
run() {
const route = props.context.ui.router.current()
if (route.type === "session" && panel?.available(route.sessionID)) {
if (panel.current()?.id === ROUTE && panel.current()?.sessionID === route.sessionID) {
panel.close()
} else {
openDiffPanel(props.context, panel, route.sessionID)
}
props.context.ui.dialog.clear()
return
}
const returnRoute: Route =
route.type === "home"
? { type: "home" }
+1 -1
View File
@@ -1507,7 +1507,7 @@ export function Session(props: {
<Prompt
visible={true}
ref={bind}
disabled={false}
disabled={props.promptMuted}
muted={props.promptMuted}
onSubmit={() => {
toBottom()
+1 -1
View File
@@ -14,7 +14,7 @@ export function clampSessionTabsWidth(width: number, total: number) {
)
}
export function clampTerminalPaneWidth(width: number, total: number) {
export function clampSessionPaneWidth(width: number, total: number) {
const half = Math.max(1, Math.floor(total / 2))
// Preserve the equal split when there is not enough room for both pane minima.
return Math.max(Math.min(24, half), Math.min(width, Math.max(half, total - SESSION_CONTENT_MIN_WIDTH)))
+41 -1
View File
@@ -37,6 +37,7 @@ import { createApi, createEventStream, createFetch, json } from "../../fixture/t
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
import { createDialogApi } from "../../../src/plugin/api"
import { ToastProvider } from "../../../src/ui/toast"
import { SessionPanelProvider } from "../../../src/context/session-panel"
import { createSignal, Show } from "solid-js"
import { diffImageFixture } from "../../fixture/diff-image"
@@ -69,6 +70,43 @@ test("closing the diff viewer returns to the route it opened from", async () =>
}
})
test("full-screen diff only returns to split view when opened from an eligible panel", async () => {
const narrow = await renderDiffViewer([], {
width: 80,
initialRoute: {
type: "plugin",
id: "opencode.diffs",
name: "diff",
data: { sessionID: "session-1", returnRoute: startRoute },
},
})
try {
const command = narrow.commands.get("diff.toggle_fullscreen")
expect(typeof command?.enabled === "function" ? command.enabled() : command?.enabled).toBe(false)
} finally {
narrow.app.renderer.destroy()
}
const eligible = await renderDiffViewer([], {
width: 160,
initialRoute: {
type: "plugin",
id: "opencode.diffs",
name: "diff",
data: { sessionID: "session-1", returnRoute: startRoute, split: true },
},
})
try {
const command = eligible.commands.get("diff.toggle_fullscreen")
expect(typeof command?.enabled === "function" ? command.enabled() : command?.enabled).toBe(true)
command?.run()
await eligible.app.flush()
expect(eligible.current()).toEqual(startRoute)
} finally {
eligible.app.renderer.destroy()
}
})
test("ctrl+c closes the diff viewer without exiting the application", async () => {
const viewer = await renderDiffViewer([])
@@ -1972,7 +2010,9 @@ async function renderDiffViewer(
<ToastProvider>
<ThemeProvider mode={options.mode ?? "dark"} source={emptyThemeSource}>
<DialogProvider>
<Content />
<SessionPanelProvider>
<Content />
</SessionPanelProvider>
</DialogProvider>
</ThemeProvider>
</ToastProvider>
+1
View File
@@ -209,6 +209,7 @@ test("centralizes named command defaults and resolves explicit none", () => {
"diff.next_hunk": "]",
"diff.previous_hunk": "[",
"diff.mark_reviewed": "m",
"diff.toggle_fullscreen": "f",
"diff.help": "?,shift+?,shift+/",
}
const config = resolve({}, { terminalSuspend: true })