mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-30 01:11:55 +00:00
feat(tui): streamline diff review workflow (#45817)
This commit is contained in:
parent
074413a96d
commit
92b9eebab2
17 changed files with 2454 additions and 1067 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { RGBA, ScrollBoxRenderable, TextAttributes, type MouseEvent } from "@opentui/core"
|
||||
import { BoxRenderable, RGBA, ScrollBoxRenderable, TextAttributes, type MouseEvent } from "@opentui/core"
|
||||
import {
|
||||
For,
|
||||
Index,
|
||||
|
|
@ -398,7 +398,16 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
|
|||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Portal
|
||||
ref={(container) => {
|
||||
if (!(container instanceof BoxRenderable)) return
|
||||
// Portal's wrapper otherwise follows the full-height app in root layout.
|
||||
container.position = "absolute"
|
||||
container.left = 0
|
||||
container.top = 0
|
||||
container.zIndex = 2500
|
||||
}}
|
||||
>
|
||||
<box
|
||||
position="absolute"
|
||||
left={0}
|
||||
|
|
|
|||
|
|
@ -66,21 +66,26 @@ export const Definitions = {
|
|||
"diff.up": keybind("k,up", "Move diff viewer up"),
|
||||
"diff.page.down": keybind("pagedown,ctrl+f", "Page diff viewer down"),
|
||||
"diff.page.up": keybind("pageup,ctrl+b", "Page diff viewer up"),
|
||||
"diff.toggle": keybind("enter,space", "Toggle diff viewer item"),
|
||||
"diff.expand": keybind("right", "Expand diff viewer item"),
|
||||
"diff.expand_all": keybind("E", "Expand all diff viewer folders"),
|
||||
"diff.collapse": keybind("left", "Collapse diff viewer item"),
|
||||
"diff.switch_focus": keybind("tab", "Switch diff viewer focus"),
|
||||
"diff.half_page.down": keybind("ctrl+d", "Scroll diff viewer down half a page"),
|
||||
"diff.half_page.up": keybind("ctrl+u", "Scroll diff viewer up half a page"),
|
||||
"diff.first": keybind("gg,home", "Go to the start of the diff"),
|
||||
"diff.last": keybind("shift+g,end", "Go to the end of the diff"),
|
||||
// Retain shipped configuration names without registering the removed tree navigation commands.
|
||||
"diff.toggle": keybind("none", "Deprecated: file tree is mouse-controlled"),
|
||||
"diff.expand": keybind("none", "Deprecated: file tree is mouse-controlled"),
|
||||
"diff.expand_all": keybind("none", "Deprecated: file tree is mouse-controlled"),
|
||||
"diff.collapse": keybind("none", "Deprecated: file tree is mouse-controlled"),
|
||||
"diff.switch_focus": keybind("none", "Deprecated: keyboard navigation always controls the diff"),
|
||||
"diff.next_hunk": keybind("]", "Jump to next diff hunk"),
|
||||
"diff.previous_hunk": keybind("[", "Jump to previous diff hunk"),
|
||||
"diff.next_file": keybind("n", "Jump to next diff file"),
|
||||
"diff.previous_file": keybind("p", "Jump to previous diff file"),
|
||||
"diff.next_file": keybind("n,alt+down", "Jump to next diff file"),
|
||||
"diff.previous_file": keybind("p,alt+up", "Jump to previous diff file"),
|
||||
"diff.toggle_file_tree": keybind("b", "Toggle diff viewer file tree"),
|
||||
"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.mark_reviewed": keybind("m", "Toggle selected diff file reviewed"),
|
||||
"diff.help": keybind("?", "Show more diff viewer shortcuts"),
|
||||
"diff.help": keybind("?,shift+?,shift+/", "Show more diff viewer shortcuts"),
|
||||
|
||||
"prompt.editor": keybind("<leader>e", "Open external editor"),
|
||||
"theme.switch": keybind("<leader>t", "List available themes"),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
// Paths branch softly through the screen,
|
||||
// A quiet tree of changed designs;
|
||||
// Each leaf remembers what has been,
|
||||
// And waits where careful light aligns.
|
||||
|
||||
export type FileTreeItem = {
|
||||
readonly file: string
|
||||
readonly status?: "added" | "deleted" | "modified"
|
||||
|
|
@ -119,44 +114,20 @@ export function compareFileTreeNodes(tree: FileTree, left: number, right: number
|
|||
return left - right
|
||||
}
|
||||
|
||||
export function moveFileTreeSelection(rows: readonly FileTreeRow[], selected: number | undefined, offset: number) {
|
||||
if (rows.length === 0) return undefined
|
||||
const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
|
||||
if (index === -1) return rows[0]!.id
|
||||
return rows[Math.max(0, Math.min(rows.length - 1, index + offset))]!.id
|
||||
}
|
||||
|
||||
export function moveFileTreeSelectionToFirstChild(rows: readonly FileTreeRow[], selected: number | undefined) {
|
||||
const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
|
||||
const row = index === -1 ? undefined : rows[index]
|
||||
if (row?.kind !== "directory") return selected
|
||||
const child = rows[index + 1]
|
||||
return child && child.depth > row.depth ? child.id : selected
|
||||
}
|
||||
|
||||
export function moveFileTreeSelectionToParent(rows: readonly FileTreeRow[], selected: number | undefined) {
|
||||
const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
|
||||
const row = index === -1 ? undefined : rows[index]
|
||||
if (!row || row.depth === 0) return selected
|
||||
return rows.findLast((item, itemIndex) => itemIndex < index && item.depth < row.depth)?.id ?? selected
|
||||
}
|
||||
|
||||
export function fileTreeFileSelection(tree: FileTree, fileIndex: number) {
|
||||
const node = tree.nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex)
|
||||
if (!node) return undefined
|
||||
return {
|
||||
highlightedNode: node.id,
|
||||
expandedNodes: fileTreeParentDirectories(tree, node.id),
|
||||
}
|
||||
}
|
||||
|
||||
export function singlePatchFileIndex(
|
||||
selected: number | undefined,
|
||||
active: number | undefined,
|
||||
current: number | undefined,
|
||||
first: number | undefined,
|
||||
) {
|
||||
return selected ?? active ?? current ?? first
|
||||
return selected ?? current ?? first
|
||||
}
|
||||
|
||||
export function orderedPatchFileIndexes(rows: readonly FileTreeRow[]) {
|
||||
|
|
@ -186,19 +157,6 @@ export function toggleFileTreeDirectory(tree: FileTree, expanded: ReadonlySet<nu
|
|||
return next
|
||||
}
|
||||
|
||||
export function setFileTreeDirectoryExpanded(
|
||||
tree: FileTree,
|
||||
expanded: ReadonlySet<number>,
|
||||
selected: number | undefined,
|
||||
value: boolean,
|
||||
) {
|
||||
if (selected === undefined || tree.nodes[selected]?.kind !== "directory") return expanded
|
||||
const next = new Set(expanded)
|
||||
if (value) next.add(selected)
|
||||
else next.delete(selected)
|
||||
return next
|
||||
}
|
||||
|
||||
function addFileTreeNode(nodes: FileTreeNode[], roots: number[], input: Omit<FileTreeNode, "id" | "children">) {
|
||||
const id = nodes.length
|
||||
nodes.push({ ...input, id, children: [] })
|
||||
|
|
|
|||
|
|
@ -1,156 +1,259 @@
|
|||
/** @jsxImportSource @opentui/solid */
|
||||
import type { ScrollBoxRenderable } from "@opentui/core"
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { MouseButton, TextAttributes, type MouseEvent, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { truncateFilePath } from "../../ui/file-path"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { tint } from "../../theme/color"
|
||||
import { createEffect, createMemo, For, Match, Switch } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
|
||||
import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils"
|
||||
import { Panel } from "./diff-viewer-ui"
|
||||
|
||||
const FILE_TREE_STATUS_WIDTH = 2
|
||||
const FILE_TREE_STATUS_WIDTH = 1
|
||||
|
||||
export type DiffViewerFileTreeProps = {
|
||||
readonly context: Plugin.Context
|
||||
readonly width: number
|
||||
readonly files: readonly FileTreeItem[]
|
||||
readonly loading: boolean
|
||||
readonly error: unknown
|
||||
readonly focused?: boolean
|
||||
readonly highlightedNode?: number
|
||||
readonly layout?: "tree" | "list"
|
||||
readonly selectedFileIndex?: number
|
||||
readonly reviewedFileNames?: ReadonlySet<string>
|
||||
readonly expandedNodes?: ReadonlySet<number>
|
||||
readonly onRowClick?: (row: FileTreeRow) => void
|
||||
readonly onFileContextMenu?: (fileIndex: number, event: MouseEvent) => void
|
||||
readonly source?: string
|
||||
readonly onSwitchSource?: () => void
|
||||
readonly footer?: JSX.Element
|
||||
}
|
||||
|
||||
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
const theme = props.context.theme
|
||||
const theme = useTheme("elevated")
|
||||
const [sourceHovered, setSourceHovered] = createSignal(false)
|
||||
const list = () => props.layout === "list"
|
||||
const tree = createMemo(() => buildFileTree(props.files))
|
||||
const rows = createMemo(() => flattenFileTree(tree(), props.expandedNodes))
|
||||
const rows = createMemo(() =>
|
||||
list()
|
||||
? flattenFileTree(tree()).filter((row) => row.fileIndex !== undefined)
|
||||
: flattenFileTree(tree(), props.expandedNodes),
|
||||
)
|
||||
// Quieter than subdued text: markers are affordances, not content.
|
||||
const faint = createMemo(() => tint(theme.text.subdued, theme.background.default, 0.45))
|
||||
// Rails are pure texture; keep them barely above the surface.
|
||||
const rail = createMemo(() => tint(theme.text.subdued, theme.background.default, 0.7))
|
||||
const reviewedCount = createMemo(() => props.files.filter((file) => props.reviewedFileNames?.has(file.file)).length)
|
||||
const contentWidth = () => Math.max(0, props.width - 4 - FILE_TREE_STATUS_WIDTH - 1)
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const node = props.highlightedNode
|
||||
if (node === undefined) return
|
||||
const selectedIndex = rows().findIndex((row) => row.id === node)
|
||||
if (selectedIndex === -1) return
|
||||
const scrollSelectedIntoView = () => scrollFileTreeRowIntoView(scroll, selectedIndex)
|
||||
const index = rows().findIndex((row) => row.fileIndex !== undefined && row.fileIndex === props.selectedFileIndex)
|
||||
if (index === -1) return
|
||||
const top = index * (list() ? 3 : 1)
|
||||
const height = list() ? 2 : 1
|
||||
const scrollSelectedIntoView = () => scrollFileTreeRowIntoView(scroll, top, height)
|
||||
scrollSelectedIntoView()
|
||||
requestAnimationFrame(scrollSelectedIntoView)
|
||||
})
|
||||
|
||||
const fadedColor = () => tint(theme.text.default, theme.background.default, 0.75)
|
||||
|
||||
return (
|
||||
<Panel border="both" width={props.width} context={props.context}>
|
||||
<scrollbox
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
verticalScrollbarOptions={{ visible: false }}
|
||||
horizontalScrollbarOptions={{ visible: false }}
|
||||
<box width={props.width} height="100%" minWidth={0} minHeight={0} flexShrink={0} flexDirection="column">
|
||||
<box id="diff-tree-top-edge" height={1} flexShrink={0} backgroundColor={theme.background.default} />
|
||||
<box
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={theme.background.default}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={props.loading || props.error}>
|
||||
<text />
|
||||
</Match>
|
||||
<Match when={props.files.length === 0}>
|
||||
<text fg={theme.text.default}>No files</text>
|
||||
</Match>
|
||||
<Match when={props.files.length > 0}>
|
||||
<For each={rows()}>
|
||||
{(row, index) => {
|
||||
const highlighted = () => props.focused && props.highlightedNode === row.id
|
||||
const selected = () => row.fileIndex !== undefined && props.selectedFileIndex === row.fileIndex
|
||||
const reviewed = () => {
|
||||
const file = row.fileIndex === undefined ? undefined : props.files[row.fileIndex]?.file
|
||||
return file !== undefined && (props.reviewedFileNames?.has(file) ?? false)
|
||||
}
|
||||
const prefix = () => fileTreeRowPrefix(rows(), index(), row, props.expandedNodes)
|
||||
const status = () => fileTreeRowStatus(row, props.files, reviewed())
|
||||
const name = () =>
|
||||
Locale.truncate(row.name, Math.max(1, props.width - FILE_TREE_STATUS_WIDTH - prefix().length))
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
width="100%"
|
||||
backgroundColor={highlighted() ? theme.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => props.onRowClick?.(row)}
|
||||
>
|
||||
<text
|
||||
fg={highlighted() ? theme.text.action.primary.focused : fadedColor()}
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
>
|
||||
{prefix()}
|
||||
</text>
|
||||
<box flexGrow={1} minWidth={0}>
|
||||
<text
|
||||
fg={
|
||||
highlighted()
|
||||
? theme.text.action.primary.focused
|
||||
: selected()
|
||||
? theme.text.formfield.selected
|
||||
: reviewed() || row.kind === "directory"
|
||||
? theme.text.subdued
|
||||
: theme.text.default
|
||||
<box height={1} flexShrink={0} flexDirection="row" marginBottom={1} gap={1}>
|
||||
<text
|
||||
id="diff-source-switch"
|
||||
fg={
|
||||
props.onSwitchSource
|
||||
? sourceHovered()
|
||||
? theme.text.action.secondary.hovered
|
||||
: theme.text.action.secondary.default
|
||||
: theme.text.default
|
||||
}
|
||||
attributes={TextAttributes.BOLD}
|
||||
flexGrow={1}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
selectable={false}
|
||||
onMouseOver={() => setSourceHovered(true)}
|
||||
onMouseOut={() => setSourceHovered(false)}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button !== MouseButton.LEFT) return
|
||||
event.stopPropagation()
|
||||
props.onSwitchSource?.()
|
||||
}}
|
||||
>
|
||||
{props.source ?? "Files"}
|
||||
</text>
|
||||
<text fg={theme.text.subdued} wrapMode="none" flexShrink={0}>
|
||||
{reviewedCount()}/{props.files.length} reviewed
|
||||
</text>
|
||||
</box>
|
||||
<scrollbox
|
||||
id="diff-files"
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
flexGrow={1}
|
||||
minHeight={0}
|
||||
verticalScrollbarOptions={{ visible: false }}
|
||||
horizontalScrollbarOptions={{ visible: false }}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={props.loading || props.error}>
|
||||
<text />
|
||||
</Match>
|
||||
<Match when={props.files.length === 0}>
|
||||
<text fg={theme.text.subdued}>No files</text>
|
||||
</Match>
|
||||
<Match when={props.files.length > 0}>
|
||||
<box flexShrink={0} gap={list() ? 1 : 0}>
|
||||
<For each={rows()}>
|
||||
{(row) => {
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
const selected = () => row.fileIndex !== undefined && props.selectedFileIndex === row.fileIndex
|
||||
const reviewed = () => {
|
||||
const file = row.fileIndex === undefined ? undefined : props.files[row.fileIndex]?.file
|
||||
return file !== undefined && (props.reviewedFileNames?.has(file) ?? false)
|
||||
}
|
||||
const foreground = () => {
|
||||
if (row.kind === "directory") return theme.text.subdued
|
||||
return reviewed() ? theme.text.subdued : theme.text.default
|
||||
}
|
||||
const background = () => {
|
||||
// Elevated context maps this to a quiet neutral surface step, not the loud accent.
|
||||
if (hovered()) return theme.background.action.primary.hovered
|
||||
return theme.background.default
|
||||
}
|
||||
const marker = () => {
|
||||
if (row.kind !== "directory") return "≡ "
|
||||
return props.expandedNodes && !props.expandedNodes.has(row.id) ? "▸ " : "▾ "
|
||||
}
|
||||
// Rails run straight down from each ancestor folder; no end hooks.
|
||||
const indent = createMemo(() => {
|
||||
if (list()) return ""
|
||||
return "│ ".repeat(Math.max(0, Math.min(row.depth, Math.floor((contentWidth() - 3) / 2))))
|
||||
})
|
||||
const status = () => fileTreeRowStatus(row, props.files, reviewed())
|
||||
const statusColor = () => {
|
||||
if (reviewed()) return theme.text.subdued
|
||||
const status = row.fileIndex === undefined ? undefined : props.files[row.fileIndex]?.status
|
||||
if (status === "added") return theme.diff.text.added
|
||||
if (status === "deleted") return theme.diff.text.removed
|
||||
return theme.text.subdued
|
||||
}
|
||||
const name = () => {
|
||||
const width = contentWidth() - stringWidth(indent()) - stringWidth(marker())
|
||||
if (row.kind === "directory") return truncateDirectoryChain(row.name, width)
|
||||
return truncateFilePath(row.name, width)
|
||||
}
|
||||
const parent = () => {
|
||||
const file = row.fileIndex === undefined ? "" : (props.files[row.fileIndex]?.file ?? "")
|
||||
const directory = file.slice(0, Math.max(0, file.lastIndexOf("/")))
|
||||
return directory ? truncateDirectoryChain(directory, contentWidth() - stringWidth(marker())) : ""
|
||||
}
|
||||
return (
|
||||
<box
|
||||
id={
|
||||
row.fileIndex === undefined ? `diff-folder-row-${row.id}` : `diff-file-row-${row.fileIndex}`
|
||||
}
|
||||
wrapMode="none"
|
||||
flexDirection="column"
|
||||
width="100%"
|
||||
height={list() ? 2 : 1}
|
||||
flexShrink={0}
|
||||
backgroundColor={background()}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseOut={() => setHovered(false)}
|
||||
onMouseDown={(event) => {
|
||||
if (row.fileIndex !== undefined) props.onFileContextMenu?.(row.fileIndex, event)
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button !== MouseButton.LEFT) return
|
||||
event.stopPropagation()
|
||||
props.onRowClick?.(row)
|
||||
}}
|
||||
>
|
||||
{name()}
|
||||
</text>
|
||||
</box>
|
||||
<text
|
||||
fg={highlighted() ? theme.text.action.primary.focused : theme.text.subdued}
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
>
|
||||
{status()}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Match>
|
||||
</Switch>
|
||||
</scrollbox>
|
||||
</Panel>
|
||||
<box flexDirection="row" height={1}>
|
||||
<text wrapMode="none" flexShrink={0}>
|
||||
<span style={{ fg: rail() }}>{indent()}</span>
|
||||
<span style={{ fg: faint() }}>{marker()}</span>
|
||||
</text>
|
||||
<box flexGrow={1} minWidth={0} marginRight={1}>
|
||||
<text
|
||||
fg={foreground()}
|
||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
>
|
||||
{name()}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={statusColor()} wrapMode="none" width={FILE_TREE_STATUS_WIDTH} flexShrink={0}>
|
||||
{status()}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={list()}>
|
||||
<text
|
||||
fg={foreground()}
|
||||
attributes={selected() || hovered() ? TextAttributes.DIM : undefined}
|
||||
marginLeft={stringWidth(marker())}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
>
|
||||
{parent()}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</scrollbox>
|
||||
<Show when={props.footer}>
|
||||
<box flexShrink={0} paddingTop={1} paddingBottom={1}>
|
||||
{props.footer}
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function scrollFileTreeRowIntoView(scroll: ScrollBoxRenderable | undefined, index: number) {
|
||||
if (!scroll) return
|
||||
if (index < scroll.scrollTop) {
|
||||
scroll.scrollTo(index)
|
||||
function scrollFileTreeRowIntoView(scroll: ScrollBoxRenderable | undefined, top: number, height: number) {
|
||||
if (!scroll || scroll.isDestroyed) return
|
||||
if (top < scroll.scrollTop) {
|
||||
scroll.scrollTo(top)
|
||||
return
|
||||
}
|
||||
if (index >= scroll.scrollTop + scroll.viewport.height) {
|
||||
scroll.scrollTo(index - scroll.viewport.height + 1)
|
||||
if (top + height > scroll.scrollTop + scroll.viewport.height) {
|
||||
scroll.scrollTo(top + height - scroll.viewport.height)
|
||||
}
|
||||
}
|
||||
|
||||
function fileTreeRowPrefix(
|
||||
rows: readonly FileTreeRow[],
|
||||
index: number,
|
||||
row: FileTreeRow,
|
||||
expandedNodes: ReadonlySet<number> | undefined,
|
||||
) {
|
||||
const indentation = Array.from({ length: row.depth }, (_, depth) => {
|
||||
if (depth === 0 && !hasLaterSibling(rows, 0, 0)) return " "
|
||||
return hasLaterSibling(rows, index, depth) ? "│ " : " "
|
||||
}).join("")
|
||||
const topRoot = index === 0 && row.depth === 0
|
||||
const branch = topRoot ? " " : hasLaterSibling(rows, index, row.depth) ? "├─ " : "└─ "
|
||||
const marker = row.kind === "directory" ? (expandedNodes && !expandedNodes.has(row.id) ? "▸ " : "▾ ") : ""
|
||||
|
||||
return `${indentation}${branch}${marker}`
|
||||
}
|
||||
|
||||
function hasLaterSibling(rows: readonly FileTreeRow[], index: number, depth: number) {
|
||||
return rows.slice(index + 1).find((row) => row.depth <= depth)?.depth === depth
|
||||
}
|
||||
|
||||
function fileTreeRowStatus(row: FileTreeRow, files: readonly FileTreeItem[], reviewed: boolean) {
|
||||
if (row.fileIndex === undefined) return ""
|
||||
if (reviewed) return "✓"
|
||||
const status = files[row.fileIndex]?.status
|
||||
const marker = status === "modified" ? "M" : status === "added" ? "A" : status === "deleted" ? "D" : "?"
|
||||
return `${reviewed ? "✓" : " "}${marker}`.padStart(FILE_TREE_STATUS_WIDTH)
|
||||
return status === "modified" ? "M" : status === "added" ? "A" : status === "deleted" ? "D" : "?"
|
||||
}
|
||||
|
||||
// Collapsed chains drop whole leading segments instead of squeezing
|
||||
// mid-segment, so "a/b/c/d" narrows to "…/c/d" rather than "…/b…/c/d".
|
||||
function truncateDirectoryChain(name: string, maxWidth: number) {
|
||||
if (stringWidth(name) <= maxWidth) return name
|
||||
const kept: string[] = []
|
||||
let width = stringWidth("…/")
|
||||
for (const segment of name.split("/").toReversed()) {
|
||||
const next = stringWidth(segment) + (kept.length ? 1 : 0)
|
||||
if (width + next > maxWidth) break
|
||||
kept.unshift(segment)
|
||||
width += next
|
||||
}
|
||||
if (kept.length === 0) return truncateFilePath(name, maxWidth)
|
||||
return `…/${kept.join("/")}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
/** @jsxImportSource @opentui/solid */
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import type { MouseEvent } from "@opentui/core"
|
||||
import { createResource, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { DialogImagePreview } from "../../component/dialog-image-preview"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
|
||||
export function isDiffImageFile(file: string) {
|
||||
return /\.(png|jpe?g|webp|gif)$/i.test(file)
|
||||
}
|
||||
|
||||
export function DiffViewerImage(props: {
|
||||
file: string
|
||||
load: (file: string, signal: AbortSignal) => Promise<Uint8Array>
|
||||
label?: string
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const dialog = useDialog()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const height = () => Math.max(3, Math.min(8, Math.floor(dimensions().height / 4)))
|
||||
const [image] = createResource(
|
||||
() => {
|
||||
const controller = new AbortController()
|
||||
onCleanup(() => controller.abort())
|
||||
return { file: props.file, signal: controller.signal }
|
||||
},
|
||||
(input) => props.load(input.file, input.signal),
|
||||
)
|
||||
|
||||
return (
|
||||
<box width="100%" flexShrink={0} gap={1} paddingLeft={1} paddingRight={1} paddingBottom={1}>
|
||||
<text fg={theme.text.subdued}>{props.label ?? "Working tree preview"}</text>
|
||||
<box height={height() + 2} flexShrink={0} gap={1}>
|
||||
<Switch>
|
||||
<Match when={image.error}>
|
||||
<text fg={theme.text.feedback.error.default}>Could not load image</text>
|
||||
</Match>
|
||||
<Match when={image.loading}>
|
||||
<text fg={theme.text.subdued}>Loading image...</text>
|
||||
</Match>
|
||||
<Match when={!image.error && image()} keyed>
|
||||
{(bytes) => {
|
||||
const [failed, setFailed] = createSignal(false)
|
||||
const [size, setSize] = createSignal<string>()
|
||||
const open = (event: MouseEvent) => {
|
||||
if (event.button !== 0 || !size() || failed()) return
|
||||
event.stopPropagation()
|
||||
dialog.replace(() => (
|
||||
<DialogImagePreview
|
||||
images={[
|
||||
{
|
||||
uri: `data:application/octet-stream;base64,${Buffer.from(bytes).toString("base64")}`,
|
||||
mention: { text: props.file },
|
||||
},
|
||||
]}
|
||||
initial={0}
|
||||
/>
|
||||
))
|
||||
}
|
||||
return (
|
||||
<Show
|
||||
when={!failed()}
|
||||
fallback={<text fg={theme.text.feedback.error.default}>Could not decode image</text>}
|
||||
>
|
||||
<box width="100%" height={height()} onMouseUp={open}>
|
||||
<image
|
||||
id={`diff-image-${props.file}`}
|
||||
source={bytes}
|
||||
fit="fit"
|
||||
protocol="auto"
|
||||
width="100%"
|
||||
height="100%"
|
||||
onLoad={(loaded) => setSize(`${loaded.width} x ${loaded.height}`)}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
</box>
|
||||
<Show when={size()}>
|
||||
{(value) => (
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.text.subdued}>{value()}</text>
|
||||
<text fg={theme.text.action.secondary.default} onMouseUp={open}>
|
||||
Click to enlarge
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
import type { BorderSides, ColorInput } from "@opentui/core"
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import { createContext, Show, splitProps, useContext } from "solid-js"
|
||||
|
||||
export type Axis = "x" | "y"
|
||||
export type SeparatorEdge = "edge" | "edge-in" | "edge-out"
|
||||
export type PanelBorder = "start" | "end" | "both" | "none"
|
||||
|
||||
const PanelGroupContext = createContext<{ axis: Axis; context: Plugin.Context }>()
|
||||
|
||||
function crossAxis(axis: Axis) {
|
||||
return axis === "x" ? "y" : "x"
|
||||
}
|
||||
|
||||
function usePanelGroup() {
|
||||
return useContext(PanelGroupContext)
|
||||
}
|
||||
|
||||
export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis; context: Plugin.Context }) {
|
||||
const [local, boxProps] = splitProps(props, ["axis", "context", "children"])
|
||||
return (
|
||||
<PanelGroupContext.Provider value={{ axis: local.axis, context: local.context }}>
|
||||
<box minWidth={0} minHeight={0} padding={0} flexDirection={local.axis === "x" ? "row" : "column"} {...boxProps}>
|
||||
{local.children}
|
||||
</box>
|
||||
</PanelGroupContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Panel(
|
||||
props: Omit<JSX.IntrinsicElements["box"], "border"> & { border?: PanelBorder; context?: Plugin.Context },
|
||||
) {
|
||||
const group = usePanelGroup()
|
||||
const [local, boxProps] = splitProps(props, ["border", "context"])
|
||||
const context = local.context ?? group?.context
|
||||
if (!context) throw new Error("Panel context is missing")
|
||||
const theme = context.theme
|
||||
const border = local.border ?? "start"
|
||||
const borderProps =
|
||||
border === "none"
|
||||
? {}
|
||||
: {
|
||||
border: panelBorderSides(group?.axis ?? "y", border),
|
||||
borderColor: theme.border.default,
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
flexDirection={crossAxis(group?.axis ?? "y") === "x" ? "row" : "column"}
|
||||
{...borderProps}
|
||||
{...boxProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function panelBorderSides(axis: Axis, border: Exclude<PanelBorder, "none">): BorderSides[] {
|
||||
if (axis === "x") return border === "both" ? ["top", "bottom"] : [border === "start" ? "top" : "bottom"]
|
||||
return border === "both" ? ["left", "right"] : [border === "start" ? "left" : "right"]
|
||||
}
|
||||
|
||||
export function Separator(props: { axis?: Axis; color?: ColorInput; start?: SeparatorEdge; end?: SeparatorEdge }) {
|
||||
const group = usePanelGroup()
|
||||
if (!group) throw new Error("PanelGroup is missing")
|
||||
const theme = group.context.theme
|
||||
const color = () => props.color ?? theme.border.default
|
||||
const axis = () => props.axis ?? crossAxis(group.axis)
|
||||
if (axis() === "y") {
|
||||
return (
|
||||
<Show
|
||||
when={props.start || props.end}
|
||||
fallback={<box width={1} flexShrink={0} border={["left"]} borderColor={color()} />}
|
||||
>
|
||||
<box width={1} flexShrink={0} flexDirection="column">
|
||||
<Show when={props.start}>{(edge) => <text fg={color()}>{verticalEdge(edge(), "start")}</text>}</Show>
|
||||
<box flexGrow={1} border={["left"]} borderColor={color()} />
|
||||
<Show when={props.end}>{(edge) => <text fg={color()}>{verticalEdge(edge(), "end")}</text>}</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Show
|
||||
when={props.start || props.end}
|
||||
fallback={<box height={1} flexShrink={0} border={["top"]} borderColor={color()} />}
|
||||
>
|
||||
<box height={1} flexShrink={0} flexDirection="row">
|
||||
<Show when={props.start}>{(edge) => <text fg={color()}>{horizontalEdge(edge(), "start")}</text>}</Show>
|
||||
<box flexGrow={1} border={["top"]} borderColor={color()} />
|
||||
<Show when={props.end}>{(edge) => <text fg={color()}>{horizontalEdge(edge(), "end")}</text>}</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function horizontalEdge(edge: SeparatorEdge, side: "start" | "end") {
|
||||
if (edge === "edge") return side === "start" ? "├" : "┤"
|
||||
if (edge === "edge-in") return "┴"
|
||||
return "┬"
|
||||
}
|
||||
|
||||
function verticalEdge(edge: SeparatorEdge, side: "start" | "end") {
|
||||
if (edge === "edge") return side === "start" ? "┬" : "┴"
|
||||
if (edge === "edge-in") return "┤"
|
||||
return "├"
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -236,7 +236,10 @@ function settle<T>(resolve: (value: T) => void) {
|
|||
}
|
||||
}
|
||||
|
||||
function createDialogApi(dialog: ReturnType<typeof useDialog>, provide: (render: () => JSX.Element) => JSX.Element) {
|
||||
export function createDialogApi(
|
||||
dialog: ReturnType<typeof useDialog>,
|
||||
provide: (render: () => JSX.Element) => JSX.Element,
|
||||
) {
|
||||
const api: Dialog = {
|
||||
show(render, onClose) {
|
||||
dialog.replace(() => provide(render), onClose)
|
||||
|
|
|
|||
|
|
@ -2,16 +2,11 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { JSX } from "solid-js"
|
||||
import { onMount, type ParentProps } from "solid-js"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { emptyThemeSource } from "../../fixture/fixture"
|
||||
import { ThemeProvider, useThemes } from "../../../src/context/theme"
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import {
|
||||
DiffViewerFileTree,
|
||||
type DiffViewerFileTreeProps,
|
||||
} from "../../../src/feature-plugins/system/diff-viewer-file-tree"
|
||||
import { DiffViewerFileTree } from "../../../src/feature-plugins/system/diff-viewer-file-tree"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import {
|
||||
allExpandedFileTreeDirectories,
|
||||
|
|
@ -19,44 +14,44 @@ import {
|
|||
} from "../../../src/feature-plugins/system/diff-viewer-file-tree-utils"
|
||||
|
||||
describe("DiffViewerFileTree", () => {
|
||||
test.skip("renders sorted hierarchical file rows", async () => {
|
||||
const lines = visibleLines(
|
||||
await renderFrame(() => (
|
||||
<ThemedDiffViewerFileTree
|
||||
width={32}
|
||||
files={[
|
||||
{ file: "z-file.ts" },
|
||||
{ file: "b/file.ts" },
|
||||
{ file: "a/zeta.ts" },
|
||||
{ file: "b/alpha.ts" },
|
||||
{ file: "a/alpha.ts" },
|
||||
]}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
focused={true}
|
||||
/>
|
||||
)),
|
||||
)
|
||||
test("defaults to text-line file icons and triangle folders with straight rails", async () => {
|
||||
const frame = await renderFrame(() => (
|
||||
<DiffViewerFileTree
|
||||
width={32}
|
||||
files={[
|
||||
{ file: "z-file.ts" },
|
||||
{ file: "b/file.ts" },
|
||||
{ file: "a/zeta.ts" },
|
||||
{ file: "b/alpha.ts" },
|
||||
{ file: "a/alpha.ts" },
|
||||
]}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
/>
|
||||
))
|
||||
|
||||
expect(lines).toEqual([
|
||||
expect(visibleLines(frame)).toEqual([
|
||||
"Files 0/5 reviewed",
|
||||
"▾ a",
|
||||
"│ ├─ alpha.ts ?",
|
||||
"│ └─ zeta.ts ?",
|
||||
"├─ ▾ b",
|
||||
"│ ├─ alpha.ts ?",
|
||||
"│ └─ file.ts ?",
|
||||
"│ ≡ alpha.ts ?",
|
||||
"│ ≡ zeta.ts ?",
|
||||
"▾ b",
|
||||
"│ ≡ alpha.ts ?",
|
||||
"│ ≡ file.ts ?",
|
||||
"≡ z-file.ts ?",
|
||||
])
|
||||
expect(frame).not.toMatch(/[├└─]/)
|
||||
})
|
||||
|
||||
test("keeps loading and error quiet while rendering an empty settled state", async () => {
|
||||
const loading = await renderFrame(() => (
|
||||
<ThemedDiffViewerFileTree width={32} files={[]} loading={true} error={undefined} />
|
||||
<DiffViewerFileTree width={32} files={[]} loading={true} error={undefined} />
|
||||
))
|
||||
const failed = await renderFrame(() => (
|
||||
<ThemedDiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} />
|
||||
<DiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} />
|
||||
))
|
||||
const empty = await renderFrame(() => (
|
||||
<ThemedDiffViewerFileTree width={32} files={[]} loading={false} error={undefined} />
|
||||
<DiffViewerFileTree width={32} files={[]} loading={false} error={undefined} />
|
||||
))
|
||||
|
||||
expect(loading).not.toContain("Loading diff…")
|
||||
|
|
@ -64,32 +59,64 @@ describe("DiffViewerFileTree", () => {
|
|||
expect(failed).not.toContain("Failed to load diff")
|
||||
expect(failed).not.toContain("No files")
|
||||
expect(empty).toContain("No files")
|
||||
expect(
|
||||
empty
|
||||
.split("\n")
|
||||
.find((line) => line.includes("No files"))
|
||||
?.indexOf("No files"),
|
||||
).toBe(2)
|
||||
})
|
||||
|
||||
test("does not render text markers for highlighted rows", async () => {
|
||||
const files = [{ file: "src/config/tui.ts" }, { file: "README.md" }]
|
||||
const src = buildFileTree(files).nodes.find((node) => node.kind === "directory" && node.name === "src")!
|
||||
test.each(["tree", "list"] as const)("%s layout uses two-cell horizontal sidebar padding", async (layout) => {
|
||||
const frame = await renderFrame(() => (
|
||||
<DiffViewerFileTree
|
||||
width={32}
|
||||
layout={layout}
|
||||
files={[
|
||||
{ file: "src/a.ts", status: "added" },
|
||||
{ file: "README.md", status: "modified" },
|
||||
]}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
/>
|
||||
))
|
||||
const lines = frame
|
||||
.split("\n")
|
||||
.slice(1)
|
||||
.filter((line) => line.trim())
|
||||
expect(lines.find((line) => line.includes("Files"))?.indexOf("Files")).toBe(2)
|
||||
const file = lines.find((line) => line.includes("README.md"))!
|
||||
expect(file.indexOf("≡")).toBe(2)
|
||||
expect(file.slice(29, 32)).toBe("M ")
|
||||
expect(lines.every((line) => line.startsWith(" ") && line.slice(30, 32) === " ")).toBe(true)
|
||||
})
|
||||
|
||||
const focused = visibleLines(
|
||||
test.each(["dark", "light"] as const)("full top padding keeps the heading one row down in %s mode", async (mode) => {
|
||||
const frame = await renderFrame(
|
||||
() => <DiffViewerFileTree width={32} files={[{ file: "README.md" }]} loading={false} error={undefined} />,
|
||||
mode,
|
||||
)
|
||||
const lines = frame.split("\n")
|
||||
expect(lines[0].trim()).toBe("")
|
||||
expect(lines[1].indexOf("Files")).toBe(2)
|
||||
expect(lines.find((line) => line.includes("README.md"))?.indexOf("≡")).toBe(2)
|
||||
})
|
||||
|
||||
test("does not render text markers for selected files", async () => {
|
||||
const files = [{ file: "src/config/tui.ts" }, { file: "README.md" }]
|
||||
const selected = visibleLines(
|
||||
await renderFrame(() => (
|
||||
<ThemedDiffViewerFileTree
|
||||
width={32}
|
||||
files={files}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
focused
|
||||
highlightedNode={src.id}
|
||||
/>
|
||||
<DiffViewerFileTree width={32} files={files} loading={false} error={undefined} selectedFileIndex={0} />
|
||||
)),
|
||||
)
|
||||
const unfocused = visibleLines(
|
||||
await renderFrame(() => <ThemedDiffViewerFileTree width={32} files={files} loading={false} error={undefined} />),
|
||||
const unselected = visibleLines(
|
||||
await renderFrame(() => <DiffViewerFileTree width={32} files={files} loading={false} error={undefined} />),
|
||||
)
|
||||
|
||||
expect(focused).toContain("▾ src/config")
|
||||
expect(unfocused).toContain("▾ src/config")
|
||||
expect(focused.some((line) => line.includes("*"))).toBe(false)
|
||||
expect(unfocused.some((line) => line.includes("*"))).toBe(false)
|
||||
expect(selected).toContain("▾ src/config")
|
||||
expect(unselected).toContain("▾ src/config")
|
||||
expect(selected.some((line) => line.includes("*"))).toBe(false)
|
||||
expect(unselected.some((line) => line.includes("*"))).toBe(false)
|
||||
})
|
||||
|
||||
test("renders collapsed and expanded directory rows", async () => {
|
||||
|
|
@ -102,21 +129,15 @@ describe("DiffViewerFileTree", () => {
|
|||
expect(
|
||||
visibleLines(
|
||||
await renderFrame(() => (
|
||||
<ThemedDiffViewerFileTree
|
||||
width={32}
|
||||
files={files}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
expandedNodes={collapsed}
|
||||
/>
|
||||
<DiffViewerFileTree width={32} files={files} loading={false} error={undefined} expandedNodes={collapsed} />
|
||||
)),
|
||||
),
|
||||
).toEqual(["▸ src/config"])
|
||||
).toEqual(["Files 0/2 reviewed", "▸ src/config", "≡ README.md ?"])
|
||||
|
||||
expect(
|
||||
visibleLines(
|
||||
await renderFrame(() => (
|
||||
<ThemedDiffViewerFileTree
|
||||
<DiffViewerFileTree
|
||||
files={files}
|
||||
width={32}
|
||||
loading={false}
|
||||
|
|
@ -125,49 +146,111 @@ describe("DiffViewerFileTree", () => {
|
|||
/>
|
||||
)),
|
||||
),
|
||||
).toEqual(["▾ src/config", "│ └─ tui.ts ?"])
|
||||
).toEqual(["Files 0/2 reviewed", "▾ src/config", "│ ≡ tui.ts ?", "≡ README.md ?"])
|
||||
})
|
||||
|
||||
test.each(["dark", "light"] as const)(
|
||||
"file tabs distinguish duplicate basenames and review state in %s",
|
||||
async (mode) => {
|
||||
const frame = await renderFrame(
|
||||
() => (
|
||||
<DiffViewerFileTree
|
||||
width={32}
|
||||
layout="list"
|
||||
files={[
|
||||
{ file: "src/sidebar.tsx", status: "added" },
|
||||
{ file: "test/sidebar.tsx", status: "modified" },
|
||||
]}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
selectedFileIndex={0}
|
||||
reviewedFileNames={new Set(["src/sidebar.tsx"])}
|
||||
/>
|
||||
),
|
||||
mode,
|
||||
)
|
||||
expect(visibleLines(frame)).toEqual(["Files 1/2 reviewed", "≡ sidebar.tsx ✓", "src", "≡ sidebar.tsx M", "test"])
|
||||
expect(frame).not.toMatch(/[│├└─]/)
|
||||
},
|
||||
)
|
||||
|
||||
test("keeps rows quiet: straight rails, single status letters, no hooks or dots", async () => {
|
||||
const frame = await renderFrame(() => (
|
||||
<DiffViewerFileTree
|
||||
width={32}
|
||||
files={[
|
||||
{ file: "src/a.ts", status: "added" },
|
||||
{ file: "src/b.ts", status: "modified" },
|
||||
{ file: "test/a.ts", status: "deleted" },
|
||||
]}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
/>
|
||||
))
|
||||
const lines = visibleLines(frame)
|
||||
expect(lines).toEqual(["Files 0/3 reviewed", "▾ src", "│ ≡ a.ts A", "│ ≡ b.ts M", "▾ test", "│ ≡ a.ts D"])
|
||||
expect(frame).not.toMatch(/[├└─·]/)
|
||||
expect(frame).not.toMatch(/[\uE000-\uF8FF]/)
|
||||
})
|
||||
|
||||
test("file tabs align parent paths beneath marked filenames", async () => {
|
||||
const frame = await renderFrame(() => (
|
||||
<DiffViewerFileTree
|
||||
width={32}
|
||||
layout="list"
|
||||
files={[{ file: "src/sidebar.tsx", status: "modified" }]}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
/>
|
||||
))
|
||||
expect(visibleLines(frame)).toEqual(["Files 0/1 reviewed", "≡ sidebar.tsx M", "src"])
|
||||
const lines = frame.split("\n")
|
||||
expect(lines.find((line) => line.includes("src"))?.indexOf("src")).toBe(
|
||||
lines.find((line) => line.includes("sidebar.tsx"))?.indexOf("sidebar.tsx"),
|
||||
)
|
||||
})
|
||||
|
||||
test("narrow collapsed chains drop whole leading segments", async () => {
|
||||
const frame = await renderFrame(() => (
|
||||
<DiffViewerFileTree
|
||||
width={26}
|
||||
files={[
|
||||
{ file: "packages/tui/src/feature-plugins/system/deeply/nested/selection.ts" },
|
||||
{ file: "packages/tui/src/feature-plugins/system/other/index.ts" },
|
||||
]}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
/>
|
||||
))
|
||||
const lines = visibleLines(frame)
|
||||
expect(lines).toContain("▾ …/system")
|
||||
expect(frame).not.toMatch(/\S+…\//)
|
||||
})
|
||||
})
|
||||
|
||||
function ThemedDiffViewerFileTree(props: Omit<DiffViewerFileTreeProps, "context">) {
|
||||
return <DiffViewerFileTree {...props} context={{ theme: useThemes().currentTokens() } as Plugin.Context} />
|
||||
}
|
||||
|
||||
async function renderFrame(component: () => JSX.Element) {
|
||||
const mounted = Promise.withResolvers<void>()
|
||||
const app = await testRender(() => withTheme(component, mounted.resolve), { width: 40, height: 10 })
|
||||
async function renderFrame(component: () => JSX.Element, mode: "dark" | "light" = "dark") {
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts>
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<ThemeProvider mode={mode} source={emptyThemeSource}>
|
||||
{component()}
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 40, height: 20 },
|
||||
)
|
||||
try {
|
||||
await mounted.promise
|
||||
await app.renderOnce()
|
||||
await app.renderOnce()
|
||||
return app.captureCharFrame()
|
||||
return await app.waitForFrame((frame) => frame.includes("Files"))
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
function withTheme(component: () => JSX.Element, onReady = () => {}) {
|
||||
return (
|
||||
<TestTuiContexts>
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<Ready onReady={onReady}>{component()}</Ready>
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
)
|
||||
}
|
||||
|
||||
function Ready(props: ParentProps<{ onReady: () => void }>) {
|
||||
onMount(props.onReady)
|
||||
return props.children
|
||||
}
|
||||
|
||||
function visibleLines(frame: string) {
|
||||
return frame
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.map((line) => line.replace(/^ ?│ ?/, "").replace(/[ │]*$/, ""))
|
||||
.map((line) => (line.startsWith(" ") ? line.slice(1) : line))
|
||||
.filter((line) => line.length > 0 && !/^┌|^└|^─+$/.test(line))
|
||||
.map((line) => line.trim().replace(/\s+/g, " "))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
|
|
|||
172
packages/tui/test/cli/tui/diff-viewer-image.test.tsx
Normal file
172
packages/tui/test/cli/tui/diff-viewer-image.test.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { ImageRenderable, type Renderable } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { createSignal, Show, type JSX } from "solid-js"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { DialogProvider } from "../../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { DiffViewerImage, isDiffImageFile } from "../../../src/feature-plugins/system/diff-viewer-image"
|
||||
import { diffImageFixture } from "../../fixture/diff-image"
|
||||
import { emptyThemeSource } from "../../fixture/fixture"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
test("recognizes only supported image file extensions", () => {
|
||||
for (const extension of ["png", "jpg", "jpeg", "webp", "gif", "PNG", "JPEG"]) {
|
||||
expect(isDiffImageFile(`assets/preview.${extension}`)).toBe(true)
|
||||
}
|
||||
for (const file of ["assets/preview.svg", "preview.avif", "image.png.ts", "png", "image.png/file"]) {
|
||||
expect(isDiffImageFile(file)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ width: 40, height: 16, mode: "dark" as const },
|
||||
{ width: 120, height: 40, mode: "light" as const },
|
||||
])("renders a real image at $width columns in $mode mode", async (options) => {
|
||||
const pending = Promise.withResolvers<Uint8Array>()
|
||||
const requested: string[] = []
|
||||
const app = await renderImage(
|
||||
() => (
|
||||
<DiffViewerImage
|
||||
file="assets/landscape.png"
|
||||
load={(file) => {
|
||||
requested.push(file)
|
||||
return pending.promise
|
||||
}}
|
||||
/>
|
||||
),
|
||||
options,
|
||||
)
|
||||
try {
|
||||
await app.waitForFrame((frame) => frame.includes("Loading image..."))
|
||||
pending.resolve(diffImageFixture)
|
||||
await app.waitForFrame((frame) => frame.includes("96 x 48"))
|
||||
expect(requested).toEqual(["assets/landscape.png"])
|
||||
expect(app.captureCharFrame()).toContain("Working tree preview")
|
||||
const image = findImage(app.renderer.root)!
|
||||
expect(image.image?.width).toBe(96)
|
||||
expect(image.image?.height).toBe(48)
|
||||
expect(image.width).toBe(options.width - 2)
|
||||
expect(image.height).toBe(Math.min(8, options.height / 4))
|
||||
expect(image.fit).toBe("fit")
|
||||
expect(image.protocol).toBe("auto")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("clicking a thumbnail opens the shared image modal and escape returns to the preview", async () => {
|
||||
const app = await renderImage(() => <DiffViewerImage file="landscape.png" load={async () => diffImageFixture} />)
|
||||
try {
|
||||
await app.waitForFrame((frame) => frame.includes("Click to enlarge"))
|
||||
const thumbnail = findImage(app.renderer.root)!
|
||||
await app.mockMouse.click(thumbnail.x + Math.floor(thumbnail.width / 2), thumbnail.y + 1)
|
||||
await app.waitForFrame((frame) => frame.includes("Image 1 of 1"))
|
||||
const modal = app.renderer.root.findDescendantById("prompt-image-viewer-image")
|
||||
expect(modal).toBeInstanceOf(ImageRenderable)
|
||||
if (!(modal instanceof ImageRenderable)) throw new Error("Missing image modal")
|
||||
await app.waitFor(() => modal.image?.width === 96)
|
||||
expect(modal.image?.height).toBe(48)
|
||||
expect(modal.height).toBeGreaterThan(thumbnail.height)
|
||||
expect(app.captureCharFrame()).toContain("landscape.png")
|
||||
|
||||
app.mockInput.pressEscape()
|
||||
await app.waitForFrame((frame) => !frame.includes("Image 1 of 1"))
|
||||
expect(findImage(app.renderer.root)).toBe(thumbnail)
|
||||
expect(app.captureCharFrame()).toContain("Click to enlarge")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("pending image reads are aborted when the source changes or the preview closes", async () => {
|
||||
const pending = Promise.withResolvers<Uint8Array>()
|
||||
const [file, setFile] = createSignal("a.png")
|
||||
const [visible, setVisible] = createSignal(true)
|
||||
const signals: AbortSignal[] = []
|
||||
const app = await renderImage(() => (
|
||||
<Show when={visible()}>
|
||||
<DiffViewerImage
|
||||
file={file()}
|
||||
load={(_, signal) => {
|
||||
signals.push(signal)
|
||||
return pending.promise
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
))
|
||||
try {
|
||||
await app.waitForFrame((frame) => frame.includes("Loading image..."))
|
||||
expect(signals[0].aborted).toBe(false)
|
||||
setFile("b.png")
|
||||
await app.flush()
|
||||
expect(signals).toHaveLength(2)
|
||||
expect(signals[0].aborted).toBe(true)
|
||||
expect(signals[1].aborted).toBe(false)
|
||||
setVisible(false)
|
||||
await app.flush()
|
||||
expect(signals[1].aborted).toBe(true)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
pending.resolve(diffImageFixture)
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["fetch", "decode"])("recovers from a %s error when the file changes", async (failure) => {
|
||||
const [file, setFile] = createSignal("broken.png")
|
||||
const app = await renderImage(() => (
|
||||
<DiffViewerImage
|
||||
file={file()}
|
||||
label="Story fixture preview"
|
||||
load={async (file) => {
|
||||
if (file !== "broken.png") return diffImageFixture
|
||||
if (failure === "fetch") throw new Error("Unavailable")
|
||||
return new Uint8Array([1, 2, 3])
|
||||
}}
|
||||
/>
|
||||
))
|
||||
try {
|
||||
await app.waitForFrame((frame) =>
|
||||
frame.includes(failure === "fetch" ? "Could not load image" : "Could not decode image"),
|
||||
)
|
||||
expect(findImage(app.renderer.root)).toBeUndefined()
|
||||
setFile("landscape.png")
|
||||
await app.waitForFrame((frame) => frame.includes("96 x 48"))
|
||||
expect(app.captureCharFrame()).toContain("Story fixture preview")
|
||||
expect(app.captureCharFrame()).not.toContain("Could not")
|
||||
expect(findImage(app.renderer.root)?.image?.width).toBe(96)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
function renderImage(
|
||||
component: () => JSX.Element,
|
||||
options = { width: 80, height: 24, mode: "dark" as "dark" | "light" },
|
||||
) {
|
||||
return testRender(
|
||||
() => (
|
||||
<TestTuiContexts>
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<Keymap.Provider>
|
||||
<ThemeProvider mode={options.mode} source={emptyThemeSource}>
|
||||
<ToastProvider>
|
||||
<DialogProvider>{component()}</DialogProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ ...options, kittyKeyboard: true },
|
||||
)
|
||||
}
|
||||
|
||||
function findImage(root: Renderable): ImageRenderable | undefined {
|
||||
if (root instanceof ImageRenderable) return root
|
||||
return root.getChildren().map(findImage).find(Boolean)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -65,7 +65,7 @@ test("releasing a transcript selection over tab controls does not activate them"
|
|||
}
|
||||
})
|
||||
|
||||
test("the tab context menu keeps preview tabs open without offering promotion for permanent tabs", async () => {
|
||||
test("the horizontal tab context menu keeps preview tabs open without selecting them", async () => {
|
||||
const [active, setActive] = createSignal("first")
|
||||
const promoted: string[] = []
|
||||
const controller = {
|
||||
|
|
@ -104,18 +104,31 @@ test("the tab context menu keeps preview tabs open without offering promotion fo
|
|||
app.renderer.start()
|
||||
await app.waitForFrame((frame) => frame.includes("Second"))
|
||||
|
||||
await app.mockMouse.click(5, 0, MouseButton.RIGHT)
|
||||
const first = app
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.findIndex((line) => line.includes("First"))
|
||||
await app.mockMouse.click(app.captureCharFrame().split("\n")[first]!.indexOf("First"), first, MouseButton.RIGHT)
|
||||
await app.waitForFrame((frame) => frame.includes("Rename"))
|
||||
expect(app.captureCharFrame()).toContain("Close")
|
||||
expect(app.captureCharFrame()).not.toContain("Keep open")
|
||||
|
||||
app.mockInput.pressKey("c", { ctrl: true })
|
||||
await app.waitForFrame((frame) => !frame.includes("Rename"))
|
||||
|
||||
await app.mockMouse.click(40, 0, MouseButton.RIGHT)
|
||||
const second = app
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.findIndex((line) => line.includes("Second"))
|
||||
await app.mockMouse.click(app.captureCharFrame().split("\n")[second]!.indexOf("Second"), second, MouseButton.RIGHT)
|
||||
await app.waitForFrame((frame) => frame.includes("Keep open"))
|
||||
expect(app.captureCharFrame()).toContain("Rename")
|
||||
expect(app.captureCharFrame()).toContain("Close")
|
||||
expect(active()).toBe("first")
|
||||
const frame = app.captureCharFrame().split("\n")
|
||||
const row = frame.findIndex((line) => line.includes("Keep open"))
|
||||
await app.mockMouse.click(frame[row]!.indexOf("Keep open"), row)
|
||||
await app.waitForFrame((frame) => !frame.includes("Rename"))
|
||||
|
||||
expect(promoted).toEqual(["second"])
|
||||
expect(active()).toBe("first")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { MouseButton } from "@opentui/core"
|
||||
import { expect, test } from "bun:test"
|
||||
import { batch, createSignal } from "solid-js"
|
||||
import { ConfigProvider, useConfig, type Info } from "../../src/config"
|
||||
|
|
@ -13,23 +14,27 @@ import { SPINNER_FRAMES } from "../../src/component/spinner-frames"
|
|||
import { ClientProvider } from "../../src/context/client"
|
||||
import { DataProvider } from "../../src/context/data"
|
||||
import { LocationProvider } from "../../src/context/location"
|
||||
import { Keymap } from "../../src/context/keymap"
|
||||
import { RouteProvider } from "../../src/context/route"
|
||||
import { TuiAppProvider } from "../../src/context/runtime"
|
||||
import { SessionTabsProvider } from "../../src/context/session-tabs"
|
||||
import { StorageProvider } from "../../src/context/storage"
|
||||
import { ThemeProvider, useTheme } from "../../src/context/theme"
|
||||
import { DialogProvider } from "../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../src/ui/toast"
|
||||
import { emptyThemeSource, tmpdir } from "../fixture/fixture"
|
||||
import { createApi, createEventStream, createFetch } from "../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||
|
||||
for (const orientation of ["horizontal", "vertical"] as const) {
|
||||
test(`${orientation} tabs replace ordinals with status without moving titles`, async () => {
|
||||
test(`${orientation} tabs replace ordinals with status without moving titles and keep context menu actions`, async () => {
|
||||
await using temporary = await tmpdir()
|
||||
const [status, setStatus] = createSignal<SessionTabsStatus>(EMPTY_SESSION_TAB_STATUS)
|
||||
const [active, setActive] = createSignal("second")
|
||||
const [animations, setAnimations] = createSignal(false)
|
||||
const [newTab, setNewTab] = createSignal(false)
|
||||
const [preview, setPreview] = createSignal(false)
|
||||
const settings: Info = { tabs: { enabled: true } }
|
||||
let config!: ReturnType<typeof useConfig>
|
||||
let theme!: ReturnType<typeof useTheme>
|
||||
|
|
@ -53,6 +58,10 @@ for (const orientation of ["horizontal", "vertical"] as const) {
|
|||
},
|
||||
close() {},
|
||||
move() {},
|
||||
isPreview: (sessionID: string) => sessionID === "first" && preview(),
|
||||
promote(sessionID: string) {
|
||||
if (sessionID === "first") setPreview(false)
|
||||
},
|
||||
detail: () => "project",
|
||||
status: (sessionID: string) => (sessionID === "first" ? status() : EMPTY_SESSION_TAB_STATUS),
|
||||
} satisfies SessionTabsController
|
||||
|
|
@ -78,7 +87,19 @@ for (const orientation of ["horizontal", "vertical"] as const) {
|
|||
<SessionTabsProvider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<Colors />
|
||||
<SessionTabs controller={controller} orientation={orientation} animations={animations()} />
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<box width="100%" height="100%">
|
||||
<SessionTabs
|
||||
controller={controller}
|
||||
orientation={orientation}
|
||||
animations={animations()}
|
||||
/>
|
||||
</box>
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ThemeProvider>
|
||||
</SessionTabsProvider>
|
||||
</LocationProvider>
|
||||
|
|
@ -227,6 +248,46 @@ for (const orientation of ["horizontal", "vertical"] as const) {
|
|||
})
|
||||
await app.waitForFrame((frame) => SPINNER_FRAMES.some((glyph) => frame.includes(`${glyph} First`)))
|
||||
|
||||
setAnimations(false)
|
||||
setStatus(EMPTY_SESSION_TAB_STATUS)
|
||||
setActive("second")
|
||||
await app.renderOnce()
|
||||
const rows = app.captureCharFrame().split("\n")
|
||||
const row = rows.findIndex((line) => line.includes("First"))
|
||||
const column = rows[row]!.indexOf("First")
|
||||
await app.mockMouse.click(column, row, MouseButton.RIGHT)
|
||||
await app.waitForFrame((frame) => frame.includes("Rename"))
|
||||
expect(app.captureCharFrame().split("\n")[row + 1]!.indexOf("Rename")).toBe(column + 1)
|
||||
expect(app.captureCharFrame()).toContain("Close")
|
||||
expect(app.captureCharFrame()).not.toContain("Keep open")
|
||||
expect(active()).toBe("second")
|
||||
app.mockInput.pressKey("c", { ctrl: true })
|
||||
await app.waitForFrame((frame) => !frame.includes("Rename"))
|
||||
|
||||
setPreview(true)
|
||||
await app.mockMouse.click(column, row, MouseButton.RIGHT)
|
||||
await app.waitForFrame((frame) => frame.includes("Keep open"))
|
||||
expect(app.captureCharFrame()).toContain("Rename")
|
||||
expect(app.captureCharFrame()).toContain("Close")
|
||||
expect(active()).toBe("second")
|
||||
for (const size of [
|
||||
{ width: 18, height: 4, row: 1, column: 3 },
|
||||
{ width: 60, height: 10, row: row + 1, column: column + 1 },
|
||||
]) {
|
||||
app.resize(size.width, size.height)
|
||||
await app.waitForFrame((frame) => frame.split("\n")[0]?.length === size.width && frame.includes("Keep open"))
|
||||
const rows = app.captureCharFrame().split("\n")
|
||||
expect(rows[size.row]?.indexOf("Keep open")).toBe(size.column)
|
||||
expect(rows[size.row + 1]?.indexOf("Rename")).toBe(size.column)
|
||||
expect(rows[size.row + 2]?.indexOf("Close")).toBe(size.column)
|
||||
}
|
||||
const menu = app.captureCharFrame().split("\n")
|
||||
const keepOpen = menu.findIndex((line) => line.includes("Keep open"))
|
||||
await app.mockMouse.click(menu[keepOpen]!.indexOf("Keep open"), keepOpen)
|
||||
await app.waitForFrame((frame) => !frame.includes("Rename"))
|
||||
expect(preview()).toBe(false)
|
||||
expect(active()).toBe("second")
|
||||
|
||||
setNewTab(true)
|
||||
await app.waitForFrame((frame) => frame.includes("+ New session"))
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -179,6 +179,10 @@ test("accepts every v2-only named command ID", () => {
|
|||
"diff.up",
|
||||
"diff.page.down",
|
||||
"diff.page.up",
|
||||
"diff.half_page.down",
|
||||
"diff.half_page.up",
|
||||
"diff.first",
|
||||
"diff.last",
|
||||
"diff.mark_reviewed",
|
||||
"opencode.settings",
|
||||
"service.restart",
|
||||
|
|
@ -207,7 +211,16 @@ test("centralizes named command defaults and resolves explicit none", () => {
|
|||
"diff.up": "k,up",
|
||||
"diff.page.down": "pagedown,ctrl+f",
|
||||
"diff.page.up": "pageup,ctrl+b",
|
||||
"diff.half_page.down": "ctrl+d",
|
||||
"diff.half_page.up": "ctrl+u",
|
||||
"diff.first": "gg,home",
|
||||
"diff.last": "shift+g,end",
|
||||
"diff.next_file": "n,alt+down",
|
||||
"diff.previous_file": "p,alt+up",
|
||||
"diff.next_hunk": "]",
|
||||
"diff.previous_hunk": "[",
|
||||
"diff.mark_reviewed": "m",
|
||||
"diff.help": "?,shift+?,shift+/",
|
||||
}
|
||||
const config = resolve({}, { terminalSuspend: true })
|
||||
Object.entries(defaults).forEach(([command, key]) => expect(config.keybinds.get(command)).toMatchObject([{ key }]))
|
||||
|
|
@ -219,6 +232,14 @@ test("centralizes named command defaults and resolves explicit none", () => {
|
|||
Object.keys(defaults).forEach((command) => expect(disabled.keybinds.get(command)).toEqual([]))
|
||||
})
|
||||
|
||||
test("retired diff tree keybinds remain accepted but have no default bindings", () => {
|
||||
const ids = ["diff.toggle", "diff.expand", "diff.expand_all", "diff.collapse", "diff.switch_focus"]
|
||||
const defaults = resolve({}, { terminalSuspend: true })
|
||||
const overrides = Object.fromEntries(ids.map((id) => [id, "ctrl+alt+x"]))
|
||||
expect(decodeInfo({ keybinds: overrides }).keybinds).toEqual(overrides)
|
||||
ids.forEach((id) => expect(defaults.keybinds.get(id)).toEqual([]))
|
||||
})
|
||||
|
||||
test("rejects orphaned keybind definitions", () => {
|
||||
expect(decodeInfo({ keybinds: { "app.heap_snapshot": "ctrl+h" } })).toEqual({ keybinds: {} })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,12 +4,8 @@ import {
|
|||
buildFileTree,
|
||||
fileTreeFileSelection,
|
||||
flattenFileTree,
|
||||
moveFileTreeSelection,
|
||||
moveFileTreeSelectionToFirstChild,
|
||||
moveFileTreeSelectionToParent,
|
||||
movePatchFileIndex,
|
||||
orderedPatchFileIndexes,
|
||||
setFileTreeDirectoryExpanded,
|
||||
showDiffViewerFileTree,
|
||||
singlePatchFileIndex,
|
||||
toggleFileTreeDirectory,
|
||||
|
|
@ -173,67 +169,18 @@ describe("diff viewer file tree utilities", () => {
|
|||
])
|
||||
})
|
||||
|
||||
test("moves selection across visible rows and clamps to bounds", () => {
|
||||
const rows = flattenFileTree(buildFileTree([{ file: "src/config/tui.ts" }, { file: "README.md" }]))
|
||||
|
||||
expect(moveFileTreeSelection(rows, undefined, 1)).toBe(rows[0]!.id)
|
||||
expect(moveFileTreeSelection(rows, rows[0]!.id, 1)).toBe(rows[1]!.id)
|
||||
expect(moveFileTreeSelection(rows, rows[1]!.id, 99)).toBe(rows[rows.length - 1]!.id)
|
||||
expect(moveFileTreeSelection(rows, rows[1]!.id, -99)).toBe(rows[0]!.id)
|
||||
expect(moveFileTreeSelection([], undefined, 1)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("moves directory selection to first visible child", () => {
|
||||
const rows = flattenFileTree(buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }]))
|
||||
const src = rows.find((row) => row.kind === "directory" && row.name === "src")!
|
||||
const config = rows.find((row) => row.kind === "directory" && row.name === "config")!
|
||||
const tui = rows.find((row) => row.name === "tui.ts")!
|
||||
|
||||
expect(moveFileTreeSelectionToFirstChild(rows, src.id)).toBe(config.id)
|
||||
expect(moveFileTreeSelectionToFirstChild(rows, tui.id)).toBe(tui.id)
|
||||
expect(moveFileTreeSelectionToFirstChild(rows, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("moves collapsed chain selection to first visible child", () => {
|
||||
const rows = flattenFileTree(
|
||||
buildFileTree([{ file: "packages/opencode/src/cli/app.ts" }, { file: "packages/opencode/src/server/server.ts" }]),
|
||||
)
|
||||
const packages = rows.find((row) => row.kind === "directory" && row.name === "packages/opencode/src")!
|
||||
const cli = rows.find((row) => row.kind === "directory" && row.name === "cli")!
|
||||
|
||||
expect(moveFileTreeSelectionToFirstChild(rows, packages.id)).toBe(cli.id)
|
||||
})
|
||||
|
||||
test("moves file and collapsed directory selection to visible parent", () => {
|
||||
const rows = flattenFileTree(
|
||||
buildFileTree([{ file: "packages/opencode/src/cli/app.ts" }, { file: "packages/opencode/src/server/server.ts" }]),
|
||||
)
|
||||
const root = rows.find((row) => row.kind === "directory" && row.name === "packages/opencode/src")!
|
||||
const cli = rows.find((row) => row.kind === "directory" && row.name === "cli")!
|
||||
const app = rows.find((row) => row.name === "app.ts")!
|
||||
|
||||
expect(moveFileTreeSelectionToParent(rows, app.id)).toBe(cli.id)
|
||||
expect(moveFileTreeSelectionToParent(rows, cli.id)).toBe(root.id)
|
||||
expect(moveFileTreeSelectionToParent(rows, root.id)).toBe(root.id)
|
||||
expect(moveFileTreeSelectionToParent(rows, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("selects a file tree node and expands its parents for a patch file", () => {
|
||||
test("finds the parent directories to expand for a patch file", () => {
|
||||
const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }])
|
||||
const selection = fileTreeFileSelection(tree, 1)
|
||||
|
||||
expect(selection?.highlightedNode).toBe(
|
||||
tree.nodes.find((node) => node.kind === "file" && node.name === "index.ts")?.id,
|
||||
)
|
||||
expect([...selection!.expandedNodes].map((id) => tree.nodes[id]!.name)).toEqual(["session", "src"])
|
||||
expect(fileTreeFileSelection(tree, 99)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("prefers the selected file when choosing the single patch file", () => {
|
||||
expect(singlePatchFileIndex(2, 1, 0, 3)).toBe(2)
|
||||
expect(singlePatchFileIndex(undefined, 1, 0, 3)).toBe(1)
|
||||
expect(singlePatchFileIndex(undefined, undefined, 0, 3)).toBe(0)
|
||||
expect(singlePatchFileIndex(undefined, undefined, undefined, 3)).toBe(3)
|
||||
expect(singlePatchFileIndex(2, 0, 3)).toBe(2)
|
||||
expect(singlePatchFileIndex(undefined, 0, 3)).toBe(0)
|
||||
expect(singlePatchFileIndex(undefined, undefined, 3)).toBe(3)
|
||||
})
|
||||
|
||||
test("orders patches by the flattened file tree order", () => {
|
||||
|
|
@ -284,20 +231,4 @@ describe("diff viewer file tree utilities", () => {
|
|||
expect(toggleFileTreeDirectory(tree, reopened, readme.id)).toBe(reopened)
|
||||
expect(toggleFileTreeDirectory(tree, reopened, undefined)).toBe(reopened)
|
||||
})
|
||||
|
||||
test("sets only selected directory expansion", () => {
|
||||
const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "README.md" }])
|
||||
const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
|
||||
const readme = tree.nodes.find((node) => node.kind === "file" && node.name === "README.md")!
|
||||
const expanded = allExpandedFileTreeDirectories(tree)
|
||||
|
||||
const collapsed = setFileTreeDirectoryExpanded(tree, expanded, src.id, false)
|
||||
expect(collapsed.has(src.id)).toBe(false)
|
||||
|
||||
const reopened = setFileTreeDirectoryExpanded(tree, collapsed, src.id, true)
|
||||
expect(reopened.has(src.id)).toBe(true)
|
||||
|
||||
expect(setFileTreeDirectoryExpanded(tree, reopened, readme.id, false)).toBe(reopened)
|
||||
expect(setFileTreeDirectoryExpanded(tree, reopened, undefined, false)).toBe(reopened)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
7
packages/tui/test/fixture/diff-image.ts
Normal file
7
packages/tui/test/fixture/diff-image.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// A 96 x 48 PNG fixture: a golden sun over blue mountains and a lake.
|
||||
export const diffImageFixture = Uint8Array.from(
|
||||
atob(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAGAAAAAwCAIAAABhdOiYAAABX0lEQVR42u3XwQkCMRCF4eljwVoET3r1YAGCbCcebMMCrMEW7MWrLiysi+4ms5PJJDEP/lsu4WMGEtpf78gRgQBAAAIQgCSttse5o9ej/a0uoE6nj6kjNqKidX6NHDoyIypdZ2zk1REY6QPtDhdjncGoAKBOp89Ypy9roIFmnKUO3ygB0KSOohFTh2lkCuSg0WJapMMxsgNi6oQYCXS8RkZAi3RkRmIdh5HFO0hAI2AK1Jk0snhJB+owjVR0voyi/8VUaLxMijRz35EoQOo6k0aRdEKMKKHOl1FUHbERJaQZZ6AjM6KqdARGlJbGXmepEVWos8iI6tThG1ESmhx0mEZUsw7HiCrX8RoRdPrmLkxNe0aOAAQgAE32vJ3GAQgTBKBIyxW4aNSsN58wMh4gSHGBipVy7Jdsy3xA1Y8VgBSB8hb0Lpds0VSB/nESUwAVhZgrUDaOhQPFR6wAKMzxDTghptXEGbWAAAAAAElFTkSuQmCC",
|
||||
),
|
||||
(character) => character.charCodeAt(0),
|
||||
)
|
||||
|
|
@ -101,29 +101,40 @@ Unknown command IDs are rejected.
|
|||
|
||||
## Diff Viewer
|
||||
|
||||
| ID | Default | Description |
|
||||
| ----------------------- | ----------------- | ---------------------------------------- |
|
||||
| `diff.open` | `none` | Open diff viewer |
|
||||
| `diff.close` | `escape,q` | Close diff viewer |
|
||||
| `diff.down` | `j,down` | Move diff viewer down |
|
||||
| `diff.up` | `k,up` | Move diff viewer up |
|
||||
| `diff.page.down` | `pagedown,ctrl+f` | Page diff viewer down |
|
||||
| `diff.page.up` | `pageup,ctrl+b` | Page diff viewer up |
|
||||
| `diff.toggle` | `enter,space` | Toggle diff viewer item |
|
||||
| `diff.expand` | `right` | Expand diff viewer item |
|
||||
| `diff.expand_all` | `E` | Expand all diff viewer folders |
|
||||
| `diff.collapse` | `left` | Collapse diff viewer item |
|
||||
| `diff.switch_focus` | `tab` | Switch diff viewer focus |
|
||||
| `diff.next_hunk` | `]` | Jump to next diff hunk |
|
||||
| `diff.previous_hunk` | `[` | Jump to previous diff hunk |
|
||||
| `diff.next_file` | `n` | Jump to next diff file |
|
||||
| `diff.previous_file` | `p` | Jump to previous diff file |
|
||||
| `diff.toggle_file_tree` | `b` | Toggle diff viewer file tree |
|
||||
| `diff.single_patch` | `s` | Toggle single patch view |
|
||||
| `diff.switch_source` | `d` | Switch diff viewer source |
|
||||
| `diff.toggle_view` | `v` | Toggle diff viewer split or unified view |
|
||||
| `diff.mark_reviewed` | `m` | Toggle selected diff file reviewed |
|
||||
| `diff.help` | `?` | Show more diff viewer shortcuts |
|
||||
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.
|
||||
|
||||
Use `n` / `p` or `alt+down` / `alt+up` (Option on macOS) to move between files. Inside the diff viewer, the Alt shortcuts navigate files rather than session tabs.
|
||||
|
||||
Press `m` to mark a file reviewed and collapse its patch body. In all-files view, press `m` again to unmark it and reopen the patch. Single-file view automatically advances to the next file when marking reviewed, but stays on the last file rather than wrapping. Unmarking a file never advances.
|
||||
|
||||
Right-click a file in the sidebar or its diff heading for **Mark complete** (or **Mark incomplete**). This changes the same reviewed state as `m`. Completing a different file from its menu does not select it or advance the file currently being read.
|
||||
|
||||
The shifted `diff.help` alternatives support terminals that report the Shift modifier when you press `?`.
|
||||
|
||||
| ID | Default | Description |
|
||||
| ----------------------- | ------------------- | ---------------------------------------- |
|
||||
| `diff.open` | `none` | Open diff viewer |
|
||||
| `diff.close` | `escape,q` | Close diff viewer |
|
||||
| `diff.down` | `j,down` | Move diff viewer down |
|
||||
| `diff.up` | `k,up` | Move diff viewer up |
|
||||
| `diff.page.down` | `pagedown,ctrl+f` | Page diff viewer down |
|
||||
| `diff.page.up` | `pageup,ctrl+b` | Page diff viewer up |
|
||||
| `diff.half_page.down` | `ctrl+d` | Scroll diff viewer down half a page |
|
||||
| `diff.half_page.up` | `ctrl+u` | Scroll diff viewer up half a page |
|
||||
| `diff.first` | `gg,home` | Go to the start of the diff |
|
||||
| `diff.last` | `shift+g,end` | Go to the end of the diff |
|
||||
| `diff.next_hunk` | `]` | Jump to next diff hunk |
|
||||
| `diff.previous_hunk` | `[` | Jump to previous diff hunk |
|
||||
| `diff.next_file` | `n,alt+down` | Jump to next diff file |
|
||||
| `diff.previous_file` | `p,alt+up` | Jump to previous diff file |
|
||||
| `diff.toggle_file_tree` | `b` | Toggle diff viewer file tree |
|
||||
| `diff.single_patch` | `s` | Toggle single patch view |
|
||||
| `diff.switch_source` | `d` | Switch diff viewer source |
|
||||
| `diff.toggle_view` | `v` | Toggle diff viewer split or unified view |
|
||||
| `diff.mark_reviewed` | `m` | Toggle selected diff file reviewed |
|
||||
| `diff.help` | `?,shift+?,shift+/` | Show more diff viewer shortcuts |
|
||||
|
||||
The retired `diff.toggle`, `diff.expand`, `diff.expand_all`, `diff.collapse`, and `diff.switch_focus` configuration entries are still accepted, but no longer register shortcuts.
|
||||
|
||||
## Appearance And Navigation
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue