feat(tui): design revamp of diff viewer (#28728)
This commit is contained in:
parent
bbbef0da1c
commit
ee008923f3
6 changed files with 516 additions and 257 deletions
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
export type FileTreeItem = {
|
export type FileTreeItem = {
|
||||||
readonly file: string
|
readonly file: string
|
||||||
|
readonly status?: "added" | "deleted" | "modified"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FileTreeNode = {
|
export type FileTreeNode = {
|
||||||
|
|
@ -125,6 +126,21 @@ export function moveFileTreeSelection(rows: readonly FileTreeRow[], selected: nu
|
||||||
return rows[Math.max(0, Math.min(rows.length - 1, index + offset))]!.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 moveFileTreeSelectionToFile(
|
export function moveFileTreeSelectionToFile(
|
||||||
rows: readonly FileTreeRow[],
|
rows: readonly FileTreeRow[],
|
||||||
selected: number | undefined,
|
selected: number | undefined,
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,36 @@
|
||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import type { ColorInput, ScrollBoxRenderable } from "@opentui/core"
|
import type { ColorInput, RGBA, ScrollBoxRenderable } from "@opentui/core"
|
||||||
import { Locale } from "@/util/locale"
|
import { Locale } from "@/util/locale"
|
||||||
|
import { tint } from "@tui/context/theme"
|
||||||
import { createEffect, createMemo, For, Match, Switch } from "solid-js"
|
import { createEffect, createMemo, For, Match, Switch } from "solid-js"
|
||||||
import { buildFileTree, flattenFileTree, type FileTreeItem } from "./diff-viewer-file-tree-utils"
|
import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils"
|
||||||
|
import { Panel } from "./diff-viewer-ui"
|
||||||
|
|
||||||
const FILE_TREE_WIDTH = 32
|
|
||||||
const FILE_TREE_HORIZONTAL_PADDING = 2
|
const FILE_TREE_HORIZONTAL_PADDING = 2
|
||||||
|
const FILE_TREE_STATUS_WIDTH = 2
|
||||||
|
|
||||||
export type DiffViewerFileTreeTheme = {
|
export type DiffViewerFileTreeTheme = {
|
||||||
readonly background: ColorInput
|
readonly background: RGBA
|
||||||
readonly backgroundPanel: ColorInput
|
readonly backgroundPanel: ColorInput
|
||||||
readonly backgroundElement: ColorInput
|
readonly backgroundElement: ColorInput
|
||||||
readonly primary: ColorInput
|
readonly primary: ColorInput
|
||||||
|
readonly secondary: ColorInput
|
||||||
readonly selectedListItemText: ColorInput
|
readonly selectedListItemText: ColorInput
|
||||||
readonly text: ColorInput
|
readonly text: RGBA
|
||||||
readonly textMuted: ColorInput
|
readonly textMuted: RGBA
|
||||||
readonly error: ColorInput
|
readonly error: ColorInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DiffViewerFileTreeProps = {
|
export type DiffViewerFileTreeProps = {
|
||||||
|
readonly width: number
|
||||||
readonly files: readonly FileTreeItem[]
|
readonly files: readonly FileTreeItem[]
|
||||||
readonly loading: boolean
|
readonly loading: boolean
|
||||||
readonly error: unknown
|
readonly error: unknown
|
||||||
readonly theme: DiffViewerFileTreeTheme
|
readonly theme: DiffViewerFileTreeTheme
|
||||||
readonly focused?: boolean
|
readonly focused?: boolean
|
||||||
readonly highlightedNode?: number
|
readonly highlightedNode?: number
|
||||||
|
readonly selectedFileIndex?: number
|
||||||
|
readonly reviewedFileNames?: ReadonlySet<string>
|
||||||
readonly expandedNodes?: ReadonlySet<number>
|
readonly expandedNodes?: ReadonlySet<number>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,21 +49,12 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||||
requestAnimationFrame(scrollSelectedIntoView)
|
requestAnimationFrame(scrollSelectedIntoView)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const fadedColor = () => tint(props.theme.text, props.theme.background, 0.75)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<Panel border="both" width={props.width}>
|
||||||
width={FILE_TREE_WIDTH}
|
|
||||||
flexShrink={0}
|
|
||||||
backgroundColor={props.theme.backgroundPanel}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
paddingTop={1}
|
|
||||||
gap={1}
|
|
||||||
minHeight={0}
|
|
||||||
>
|
|
||||||
<scrollbox
|
<scrollbox
|
||||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||||
flexGrow={1}
|
|
||||||
minHeight={0}
|
|
||||||
verticalScrollbarOptions={{ visible: false }}
|
verticalScrollbarOptions={{ visible: false }}
|
||||||
horizontalScrollbarOptions={{ visible: false }}
|
horizontalScrollbarOptions={{ visible: false }}
|
||||||
>
|
>
|
||||||
|
|
@ -70,26 +67,24 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.files.length > 0}>
|
<Match when={props.files.length > 0}>
|
||||||
<For each={rows()}>
|
<For each={rows()}>
|
||||||
{(row) => {
|
{(row, index) => {
|
||||||
const highlighted = () => props.focused && props.highlightedNode === row.id
|
const highlighted = () => props.focused && props.highlightedNode === row.id
|
||||||
const prefix = () =>
|
const selected = () => row.fileIndex !== undefined && props.selectedFileIndex === row.fileIndex
|
||||||
`${" ".repeat(row.depth)}${row.kind === "directory" ? (props.expandedNodes && !props.expandedNodes.has(row.id) ? "▸ " : "▾ ") : " "}`
|
const reviewed = () => {
|
||||||
|
const file = row.fileIndex === undefined ? undefined : props.files[row.fileIndex]?.file
|
||||||
|
return file !== undefined && props.reviewedFileNames?.has(file)
|
||||||
|
}
|
||||||
|
const prefix = () => fileTreeRowPrefix(rows(), index(), row, props.expandedNodes)
|
||||||
|
const status = () => fileTreeRowStatus(row, props.files)
|
||||||
const name = () =>
|
const name = () =>
|
||||||
Locale.truncate(
|
Locale.truncate(
|
||||||
row.name,
|
row.name,
|
||||||
Math.max(1, FILE_TREE_WIDTH - FILE_TREE_HORIZONTAL_PADDING - prefix().length),
|
Math.max(1, props.width - FILE_TREE_HORIZONTAL_PADDING - prefix().length - status().length),
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" width="100%">
|
<box flexDirection="row" width="100%" backgroundColor={highlighted() ? props.theme.primary : undefined}>
|
||||||
<text
|
<text
|
||||||
fg={
|
fg={highlighted() ? props.theme.background : fadedColor()}
|
||||||
highlighted()
|
|
||||||
? props.theme.background
|
|
||||||
: row.kind === "directory"
|
|
||||||
? props.theme.textMuted
|
|
||||||
: props.theme.text
|
|
||||||
}
|
|
||||||
bg={highlighted() ? props.theme.primary : undefined}
|
|
||||||
wrapMode="none"
|
wrapMode="none"
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
>
|
>
|
||||||
|
|
@ -100,16 +95,22 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||||
fg={
|
fg={
|
||||||
highlighted()
|
highlighted()
|
||||||
? props.theme.background
|
? props.theme.background
|
||||||
: row.kind === "directory"
|
: reviewed()
|
||||||
? props.theme.textMuted
|
? props.theme.textMuted
|
||||||
: props.theme.text
|
: selected()
|
||||||
|
? props.theme.primary
|
||||||
|
: row.kind === "directory"
|
||||||
|
? tint(props.theme.text, props.theme.background, 0.35)
|
||||||
|
: props.theme.text
|
||||||
}
|
}
|
||||||
bg={highlighted() ? props.theme.primary : undefined}
|
|
||||||
wrapMode="none"
|
wrapMode="none"
|
||||||
>
|
>
|
||||||
{name()}
|
{name()}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
<text fg={highlighted() ? props.theme.background : props.theme.textMuted} wrapMode="none" flexShrink={0}>
|
||||||
|
{status()}
|
||||||
|
</text>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|
@ -117,7 +118,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
</box>
|
</Panel>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,3 +132,33 @@ function scrollFileTreeRowIntoView(scroll: ScrollBoxRenderable | undefined, inde
|
||||||
scroll.scrollTo(index - scroll.viewport.height + 1)
|
scroll.scrollTo(index - scroll.viewport.height + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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[]) {
|
||||||
|
if (row.fileIndex === undefined) return ""
|
||||||
|
const status = files[row.fileIndex]?.status
|
||||||
|
if (status === "modified") return "M".padStart(FILE_TREE_STATUS_WIDTH)
|
||||||
|
if (status === "added") return "A".padStart(FILE_TREE_STATUS_WIDTH)
|
||||||
|
if (status === "deleted") return "D".padStart(FILE_TREE_STATUS_WIDTH)
|
||||||
|
return "?".padStart(FILE_TREE_STATUS_WIDTH)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
import type { BorderSides, ColorInput } from "@opentui/core"
|
||||||
|
import type { JSX } from "@opentui/solid"
|
||||||
|
import { useTheme } from "@tui/context/theme"
|
||||||
|
import { createContext, 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 }>()
|
||||||
|
|
||||||
|
function crossAxis(axis: Axis) {
|
||||||
|
return axis === "x" ? "y" : "x"
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePanelGroup() {
|
||||||
|
return useContext(PanelGroupContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis }) {
|
||||||
|
const [local, boxProps] = splitProps(props, ["axis", "children"])
|
||||||
|
return (
|
||||||
|
<PanelGroupContext.Provider value={{ axis: local.axis }}>
|
||||||
|
<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 }) {
|
||||||
|
const group = usePanelGroup()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const [local, boxProps] = splitProps(props, ["border"])
|
||||||
|
const border = local.border ?? "start"
|
||||||
|
const borderProps = border === "none"
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
border: panelBorderSides(group?.axis ?? "y", border),
|
||||||
|
borderColor: theme.border,
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const color = () => props.color ?? theme.border
|
||||||
|
const axis = () => props.axis ?? crossAxis(group?.axis ?? "y")
|
||||||
|
if (axis() === "y") {
|
||||||
|
if (!props.start && !props.end) return <box width={1} flexShrink={0} border={["left"]} borderColor={color()} />
|
||||||
|
return (
|
||||||
|
<box width={1} flexShrink={0} flexDirection="column">
|
||||||
|
{props.start && <text fg={color()}>{verticalEdge(props.start, "start")}</text>}
|
||||||
|
<box flexGrow={1} border={["left"]} borderColor={color()} />
|
||||||
|
{props.end && <text fg={color()}>{verticalEdge(props.end, "end")}</text>}
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!props.start && !props.end) return <box height={1} flexShrink={0} border={["top"]} borderColor={color()} />
|
||||||
|
return (
|
||||||
|
<box height={1} flexShrink={0} flexDirection="row">
|
||||||
|
{props.start && <text fg={color()}>{horizontalEdge(props.start, "start")}</text>}
|
||||||
|
<box flexGrow={1} border={["top"]} borderColor={color()} />
|
||||||
|
{props.end && <text fg={color()}>{horizontalEdge(props.end, "end")}</text>}
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "├"
|
||||||
|
}
|
||||||
|
|
@ -9,19 +9,24 @@ import { useTerminalDimensions } from "@opentui/solid"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
|
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||||
import { DiffViewerFileTree } from "./diff-viewer-file-tree"
|
import { DiffViewerFileTree } from "./diff-viewer-file-tree"
|
||||||
|
import { Panel, PanelGroup, Separator } from "./diff-viewer-ui"
|
||||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||||
import {
|
import {
|
||||||
allExpandedFileTreeDirectories,
|
allExpandedFileTreeDirectories,
|
||||||
buildFileTree,
|
buildFileTree,
|
||||||
flattenFileTree,
|
flattenFileTree,
|
||||||
moveFileTreeSelection,
|
moveFileTreeSelection,
|
||||||
|
moveFileTreeSelectionToFirstChild,
|
||||||
moveFileTreeSelectionToFile,
|
moveFileTreeSelectionToFile,
|
||||||
|
moveFileTreeSelectionToParent,
|
||||||
setFileTreeDirectoryExpanded,
|
setFileTreeDirectoryExpanded,
|
||||||
toggleFileTreeDirectory,
|
toggleFileTreeDirectory,
|
||||||
} from "./diff-viewer-file-tree-utils"
|
} from "./diff-viewer-file-tree-utils"
|
||||||
|
|
||||||
const ROUTE = "diff"
|
const ROUTE = "diff"
|
||||||
const MIN_SPLIT_WIDTH = 100
|
const MIN_SPLIT_WIDTH = 100
|
||||||
|
const FILE_TREE_WIDTH = 32
|
||||||
|
const PLAIN_TEXT_FILETYPE = "opencode-plain-text"
|
||||||
type DiffMode = "git" | "last-turn"
|
type DiffMode = "git" | "last-turn"
|
||||||
type DiffViewerFocus = "patches" | "files"
|
type DiffViewerFocus = "patches" | "files"
|
||||||
|
|
||||||
|
|
@ -100,6 +105,8 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||||
const [highlightedFileNode, setHighlightedFileNode] = createSignal<number | undefined>()
|
const [highlightedFileNode, setHighlightedFileNode] = createSignal<number | undefined>()
|
||||||
const [lastHighlightedFileNode, setLastHighlightedFileNode] = createSignal<number | undefined>()
|
const [lastHighlightedFileNode, setLastHighlightedFileNode] = createSignal<number | undefined>()
|
||||||
const [activePatchFileIndex, setActivePatchFileIndex] = createSignal<number | undefined>()
|
const [activePatchFileIndex, setActivePatchFileIndex] = createSignal<number | undefined>()
|
||||||
|
const [selectedFileIndex, setSelectedFileIndex] = createSignal<number | undefined>()
|
||||||
|
const [reviewedFileNames, setReviewedFileNames] = createSignal<ReadonlySet<string>>(new Set())
|
||||||
const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes()))
|
const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes()))
|
||||||
const focusRunner = (input: Record<DiffViewerFocus, () => void>) => () => input[focus()]()
|
const focusRunner = (input: Record<DiffViewerFocus, () => void>) => () => input[focus()]()
|
||||||
const switchFocusShortcut = useCommandShortcut("diff.switch_focus")
|
const switchFocusShortcut = useCommandShortcut("diff.switch_focus")
|
||||||
|
|
@ -109,6 +116,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||||
const singlePatchShortcut = useCommandShortcut("diff.single_patch")
|
const singlePatchShortcut = useCommandShortcut("diff.single_patch")
|
||||||
const switchDiffShortcut = useCommandShortcut("diff.switch_diff")
|
const switchDiffShortcut = useCommandShortcut("diff.switch_diff")
|
||||||
const toggleViewShortcut = useCommandShortcut("diff.toggle_view")
|
const toggleViewShortcut = useCommandShortcut("diff.toggle_view")
|
||||||
|
const markReviewedShortcut = useCommandShortcut("diff.mark_reviewed")
|
||||||
let scroll: ScrollBoxRenderable | undefined
|
let scroll: ScrollBoxRenderable | undefined
|
||||||
const patchNodeByFileIndex = new Map<number, BoxRenderable>()
|
const patchNodeByFileIndex = new Map<number, BoxRenderable>()
|
||||||
const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
|
const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
|
||||||
|
|
@ -118,6 +126,8 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||||
setHighlightedFileNode(undefined)
|
setHighlightedFileNode(undefined)
|
||||||
setLastHighlightedFileNode(undefined)
|
setLastHighlightedFileNode(undefined)
|
||||||
setActivePatchFileIndex(undefined)
|
setActivePatchFileIndex(undefined)
|
||||||
|
setSelectedFileIndex(undefined)
|
||||||
|
setReviewedFileNames(new Set<string>())
|
||||||
})
|
})
|
||||||
|
|
||||||
const ensureHighlightedFileNode = () => {
|
const ensureHighlightedFileNode = () => {
|
||||||
|
|
@ -144,11 +154,12 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||||
setActivePatchFileIndex(undefined)
|
setActivePatchFileIndex(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollPatchNodeToTop = (patchNode: BoxRenderable) => {
|
const scrollPatchNodeToTop = (patchNode: BoxRenderable, fileIndex: number) => {
|
||||||
if (!scroll) return
|
if (!scroll) return
|
||||||
scroll.scrollBy(patchNode.y - scroll.viewport.y)
|
const offset = fileIndex === 0 ? 0 : 1
|
||||||
|
scroll.scrollBy(patchNode.y - scroll.viewport.y + offset)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (scroll) scroll.scrollBy(patchNode.y - scroll.viewport.y)
|
if (scroll) scroll.scrollBy(patchNode.y - scroll.viewport.y + offset)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,8 +179,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||||
const scrollToFileIndex = (fileIndex: number | undefined) => {
|
const scrollToFileIndex = (fileIndex: number | undefined) => {
|
||||||
if (fileIndex === undefined) return
|
if (fileIndex === undefined) return
|
||||||
setActivePatchFileIndex(fileIndex)
|
setActivePatchFileIndex(fileIndex)
|
||||||
|
setSelectedFileIndex(fileIndex)
|
||||||
const patchNode = patchNodeByFileIndex.get(fileIndex)
|
const patchNode = patchNodeByFileIndex.get(fileIndex)
|
||||||
if (patchNode) scrollPatchNodeToTop(patchNode)
|
if (patchNode) scrollPatchNodeToTop(patchNode, fileIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
const jumpToFileIndex = (fileIndex: number | undefined) => {
|
const jumpToFileIndex = (fileIndex: number | undefined) => {
|
||||||
|
|
@ -227,9 +239,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||||
patchNodeByFileIndex.set(fileIndex, element)
|
patchNodeByFileIndex.set(fileIndex, element)
|
||||||
if (pendingPatchScrollFileIndex() !== fileIndex) return
|
if (pendingPatchScrollFileIndex() !== fileIndex) return
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
scrollPatchNodeToTop(element)
|
scrollPatchNodeToTop(element, fileIndex)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
scrollPatchNodeToTop(element)
|
scrollPatchNodeToTop(element, fileIndex)
|
||||||
setPendingPatchScrollFileIndex(undefined)
|
setPendingPatchScrollFileIndex(undefined)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -244,6 +256,21 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||||
setExpandedFileNodes((expanded) => toggleFileTreeDirectory(fileTree(), expanded, highlightedFileNode()))
|
setExpandedFileNodes((expanded) => toggleFileTreeDirectory(fileTree(), expanded, highlightedFileNode()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleSelectedFileReviewed = () => {
|
||||||
|
const fileIndex =
|
||||||
|
focus() === "files"
|
||||||
|
? fileRows().find((row) => row.id === highlightedFileNode())?.fileIndex
|
||||||
|
: (selectedFileIndex() ?? activePatchFileIndex() ?? currentPatchFileIndex())
|
||||||
|
const file = fileIndex === undefined ? undefined : files()[fileIndex]?.file
|
||||||
|
if (!file) return
|
||||||
|
setReviewedFileNames((reviewed) => {
|
||||||
|
const next = new Set(reviewed)
|
||||||
|
if (next.has(file)) next.delete(file)
|
||||||
|
else next.add(file)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const commands = [
|
const commands = [
|
||||||
{
|
{
|
||||||
name: "diff.close",
|
name: "diff.close",
|
||||||
|
|
@ -326,6 +353,11 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||||
category: "VCS",
|
category: "VCS",
|
||||||
run: focusRunner({
|
run: focusRunner({
|
||||||
files() {
|
files() {
|
||||||
|
const highlighted = highlightedFileNode()
|
||||||
|
if (highlighted !== undefined && expandedFileNodes().has(highlighted)) {
|
||||||
|
setHighlighted(moveFileTreeSelectionToFirstChild(fileRows(), highlighted))
|
||||||
|
return
|
||||||
|
}
|
||||||
setExpandedFileNodes((expanded) =>
|
setExpandedFileNodes((expanded) =>
|
||||||
setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), true),
|
setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), true),
|
||||||
)
|
)
|
||||||
|
|
@ -339,6 +371,12 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||||
category: "VCS",
|
category: "VCS",
|
||||||
run: focusRunner({
|
run: focusRunner({
|
||||||
files() {
|
files() {
|
||||||
|
const highlighted = highlightedFileNode()
|
||||||
|
const node = highlighted === undefined ? undefined : fileTree().nodes[highlighted]
|
||||||
|
if (node?.kind !== "directory" || !expandedFileNodes().has(node.id)) {
|
||||||
|
setHighlighted(moveFileTreeSelectionToParent(fileRows(), highlighted))
|
||||||
|
return
|
||||||
|
}
|
||||||
setExpandedFileNodes((expanded) =>
|
setExpandedFileNodes((expanded) =>
|
||||||
setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), false),
|
setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), false),
|
||||||
)
|
)
|
||||||
|
|
@ -362,6 +400,14 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||||
jumpRelativePatchFile(-1)
|
jumpRelativePatchFile(-1)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "diff.mark_reviewed",
|
||||||
|
title: "Toggle selected diff file reviewed",
|
||||||
|
category: "VCS",
|
||||||
|
run() {
|
||||||
|
toggleSelectedFileReviewed()
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "diff.switch_focus",
|
name: "diff.switch_focus",
|
||||||
title: "Switch diff viewer focus",
|
title: "Switch diff viewer focus",
|
||||||
|
|
@ -460,6 +506,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||||
{ key: "k,up", cmd: "diff.up", desc: "Move diff viewer up" },
|
{ key: "k,up", cmd: "diff.up", desc: "Move diff viewer up" },
|
||||||
{ key: "pagedown,ctrl+f", cmd: "diff.page.down", desc: "Page diff viewer down" },
|
{ key: "pagedown,ctrl+f", cmd: "diff.page.down", desc: "Page diff viewer down" },
|
||||||
{ key: "pageup,ctrl+b", cmd: "diff.page.up", desc: "Page diff viewer up" },
|
{ key: "pageup,ctrl+b", cmd: "diff.page.up", desc: "Page diff viewer up" },
|
||||||
|
{ key: "m", cmd: "diff.mark_reviewed", desc: "Mark selected file reviewed" },
|
||||||
...props.api.tuiConfig.keybinds.gather(
|
...props.api.tuiConfig.keybinds.gather(
|
||||||
"diff",
|
"diff",
|
||||||
commands.map((command) => command.name),
|
commands.map((command) => command.name),
|
||||||
|
|
@ -468,186 +515,193 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
|
||||||
position="absolute"
|
<PanelGroup axis="y" width="100%" height="100%">
|
||||||
zIndex={2500}
|
<Panel border="none" flexShrink={0} padding={0} paddingLeft={1}>
|
||||||
left={0}
|
<text fg={theme().text}>Diff </text>
|
||||||
top={0}
|
|
||||||
width={dimensions().width}
|
|
||||||
height={dimensions().height}
|
|
||||||
backgroundColor={theme().background}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
paddingTop={1}
|
|
||||||
paddingBottom={1}
|
|
||||||
gap={1}
|
|
||||||
>
|
|
||||||
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={theme().text}>Diff</text>
|
|
||||||
<text fg={theme().textMuted}>{mode() === "last-turn" ? "last turn" : "working tree"}</text>
|
<text fg={theme().textMuted}>{mode() === "last-turn" ? "last turn" : "working tree"}</text>
|
||||||
|
<box flexGrow={1} />
|
||||||
|
<text fg={theme().textMuted}>
|
||||||
|
{files().length} {files().length === 1 ? "file" : "files"}
|
||||||
|
</text>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<box flexGrow={1} minHeight={0}>
|
||||||
|
<Switch>
|
||||||
|
<Match when={diff.loading}>
|
||||||
|
<box flexGrow={1} alignItems="center" justifyContent="center">
|
||||||
|
<text fg={theme().textMuted}>Loading diff...</text>
|
||||||
|
</box>
|
||||||
|
</Match>
|
||||||
|
<Match when={!diff.loading}>
|
||||||
|
<PanelGroup axis="x">
|
||||||
|
<Show when={showFileTree()}>
|
||||||
|
<DiffViewerFileTree
|
||||||
|
files={files()}
|
||||||
|
loading={diff.loading}
|
||||||
|
error={diff.error}
|
||||||
|
theme={theme()}
|
||||||
|
focused={focus() === "files"}
|
||||||
|
width={FILE_TREE_WIDTH}
|
||||||
|
highlightedNode={highlightedFileNode()}
|
||||||
|
selectedFileIndex={selectedFileIndex()}
|
||||||
|
reviewedFileNames={reviewedFileNames()}
|
||||||
|
expandedNodes={expandedFileNodes()}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Panel flexGrow={1} minHeight={0} border="none">
|
||||||
|
<Separator axis="x" start="edge-out" />
|
||||||
|
<Switch>
|
||||||
|
<Match when={diff.error}>
|
||||||
|
<box paddingTop={1}>
|
||||||
|
<text fg={theme().error}>Failed to load diff</text>
|
||||||
|
</box>
|
||||||
|
</Match>
|
||||||
|
<Match when={files().length === 0}>
|
||||||
|
<box paddingTop={1}>
|
||||||
|
<text fg={theme().textMuted}>No diff to show</text>
|
||||||
|
</box>
|
||||||
|
</Match>
|
||||||
|
<Match when={files().length > 0}>
|
||||||
|
<scrollbox
|
||||||
|
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||||
|
flexGrow={1}
|
||||||
|
minHeight={0}
|
||||||
|
verticalScrollbarOptions={{ visible: false }}
|
||||||
|
horizontalScrollbarOptions={{ visible: false }}
|
||||||
|
>
|
||||||
|
<For each={visiblePatchFiles()}>
|
||||||
|
{(entry, index) => {
|
||||||
|
const reviewed = () => reviewedFileNames().has(entry.file.file)
|
||||||
|
return (
|
||||||
|
<box ref={(element: BoxRenderable) => registerPatchNode(entry.fileIndex, element)}>
|
||||||
|
{index() !== 0 ? <Separator axis="x" start="edge" /> : null}
|
||||||
|
<box
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
flexShrink={0}
|
||||||
|
paddingLeft={2}
|
||||||
|
paddingRight={1}
|
||||||
|
border={["left"]}
|
||||||
|
borderColor={theme().border}
|
||||||
|
>
|
||||||
|
<text fg={reviewed() ? theme().textMuted : theme().text}>{entry.file.file}</text>
|
||||||
|
<box flexGrow={1} />
|
||||||
|
<text fg={reviewed() ? theme().textMuted : theme().diffAdded}>
|
||||||
|
+{entry.file.additions}
|
||||||
|
</text>
|
||||||
|
<text fg={reviewed() ? theme().textMuted : theme().diffRemoved}>
|
||||||
|
-{entry.file.deletions}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<Separator axis="x" start="edge" />
|
||||||
|
<Show
|
||||||
|
when={entry.file.patch}
|
||||||
|
fallback={<text fg={theme().textMuted}>No patch available for this file.</text>}
|
||||||
|
>
|
||||||
|
{(patch) => (
|
||||||
|
<box border={["left"]} borderColor={theme().border}>
|
||||||
|
<diff
|
||||||
|
diff={patch()}
|
||||||
|
view={view()}
|
||||||
|
filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
|
||||||
|
syntaxStyle={themeState.syntax()}
|
||||||
|
showLineNumbers={true}
|
||||||
|
width="100%"
|
||||||
|
wrapMode="char"
|
||||||
|
fg={reviewed() ? theme().textMuted : theme().text}
|
||||||
|
addedBg={reviewed() ? theme().backgroundElement : theme().diffAddedBg}
|
||||||
|
removedBg={reviewed() ? theme().backgroundElement : theme().diffRemovedBg}
|
||||||
|
addedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightAdded}
|
||||||
|
removedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightRemoved}
|
||||||
|
lineNumberFg={theme().diffLineNumber}
|
||||||
|
addedLineNumberBg={
|
||||||
|
reviewed() ? theme().backgroundElement : theme().diffAddedLineNumberBg
|
||||||
|
}
|
||||||
|
removedLineNumberBg={
|
||||||
|
reviewed() ? theme().backgroundElement : theme().diffRemovedLineNumberBg
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</scrollbox>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
<Separator axis="x" start="edge-in" />
|
||||||
|
</Panel>
|
||||||
|
</PanelGroup>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
|
||||||
|
|
||||||
<Switch>
|
<Panel flexShrink={0} gap={2} paddingLeft={1} border="none">
|
||||||
<Match when={diff.loading}>
|
<Show when={switchFocusShortcut()}>
|
||||||
<box flexGrow={1} alignItems="center" justifyContent="center">
|
{(shortcut) => (
|
||||||
<text fg={theme().textMuted}>Loading diff...</text>
|
<text fg={theme().text}>
|
||||||
</box>
|
{shortcut()} <span style={{ fg: theme().textMuted }}>focus file tree</span>
|
||||||
</Match>
|
</text>
|
||||||
<Match when={!diff.loading}>
|
)}
|
||||||
<box flexDirection="row" flexGrow={1} minHeight={0} gap={1}>
|
</Show>
|
||||||
<Show when={showFileTree()}>
|
<Show when={nextFileShortcut()}>
|
||||||
<DiffViewerFileTree
|
{(shortcut) => (
|
||||||
files={files()}
|
<text fg={theme().text}>
|
||||||
loading={diff.loading}
|
{shortcut()} <span style={{ fg: theme().textMuted }}>next file</span>
|
||||||
error={diff.error}
|
</text>
|
||||||
theme={theme()}
|
)}
|
||||||
focused={focus() === "files"}
|
</Show>
|
||||||
highlightedNode={highlightedFileNode()}
|
<Show when={previousFileShortcut()}>
|
||||||
expandedNodes={expandedFileNodes()}
|
{(shortcut) => (
|
||||||
/>
|
<text fg={theme().text}>
|
||||||
</Show>
|
{shortcut()} <span style={{ fg: theme().textMuted }}>previous file</span>
|
||||||
|
</text>
|
||||||
<box
|
)}
|
||||||
flexGrow={1}
|
</Show>
|
||||||
minWidth={0}
|
<Show when={toggleFileTreeShortcut()}>
|
||||||
backgroundColor={theme().background}
|
{(shortcut) => (
|
||||||
paddingLeft={0}
|
<text fg={theme().text}>
|
||||||
paddingRight={2}
|
{shortcut()}{" "}
|
||||||
gap={1}
|
<span style={{ fg: theme().textMuted }}>{showFileTree() ? "hide file tree" : "show file tree"}</span>
|
||||||
>
|
</text>
|
||||||
<Switch>
|
)}
|
||||||
<Match when={diff.error}>
|
</Show>
|
||||||
<box paddingTop={1}>
|
<Show when={singlePatchShortcut()}>
|
||||||
<text fg={theme().error}>Failed to load diff</text>
|
{(shortcut) => (
|
||||||
</box>
|
<text fg={theme().text}>
|
||||||
</Match>
|
{shortcut()}{" "}
|
||||||
<Match when={files().length === 0}>
|
<span style={{ fg: theme().textMuted }}>{singlePatch() ? "all patches" : "single patch"}</span>
|
||||||
<box paddingTop={1}>
|
</text>
|
||||||
<text fg={theme().textMuted}>No diff to show</text>
|
)}
|
||||||
</box>
|
</Show>
|
||||||
</Match>
|
<Show when={switchDiffShortcut()}>
|
||||||
<Match when={files().length > 0}>
|
{(shortcut) => (
|
||||||
<scrollbox
|
<text fg={theme().text}>
|
||||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
{shortcut()} <span style={{ fg: theme().textMuted }}>switch diff</span>
|
||||||
flexGrow={1}
|
</text>
|
||||||
minHeight={0}
|
)}
|
||||||
verticalScrollbarOptions={{ visible: false }}
|
</Show>
|
||||||
horizontalScrollbarOptions={{ visible: false }}
|
<Show when={toggleViewShortcut()}>
|
||||||
>
|
{(shortcut) => (
|
||||||
<For each={visiblePatchFiles()}>
|
<text fg={theme().text}>
|
||||||
{(entry) => (
|
{shortcut()}{" "}
|
||||||
<box
|
<span style={{ fg: theme().textMuted }}>{view() === "split" ? "unified view" : "split view"}</span>
|
||||||
ref={(element: BoxRenderable) => registerPatchNode(entry.fileIndex, element)}
|
</text>
|
||||||
marginBottom={1}
|
)}
|
||||||
backgroundColor={theme().backgroundPanel}
|
</Show>
|
||||||
>
|
<Show when={markReviewedShortcut()}>
|
||||||
<box
|
{(shortcut) => (
|
||||||
flexDirection="row"
|
<text fg={theme().text}>
|
||||||
gap={2}
|
{shortcut()} <span style={{ fg: theme().textMuted }}>mark reviewed</span>
|
||||||
flexShrink={0}
|
</text>
|
||||||
paddingTop={1}
|
)}
|
||||||
paddingBottom={1}
|
</Show>
|
||||||
paddingLeft={2}
|
</Panel>
|
||||||
paddingRight={1}
|
</PanelGroup>
|
||||||
backgroundColor={theme().backgroundPanel}
|
|
||||||
>
|
|
||||||
<text fg={theme().text}>{entry.file.file}</text>
|
|
||||||
<text fg={theme().diffAdded}>+{entry.file.additions}</text>
|
|
||||||
<text fg={theme().diffRemoved}>-{entry.file.deletions}</text>
|
|
||||||
</box>
|
|
||||||
<Show
|
|
||||||
when={entry.file.patch}
|
|
||||||
fallback={<text fg={theme().textMuted}>No patch available for this file.</text>}
|
|
||||||
>
|
|
||||||
{(patch) => (
|
|
||||||
<diff
|
|
||||||
diff={patch()}
|
|
||||||
view={view()}
|
|
||||||
filetype={filetype(entry.file.file)}
|
|
||||||
syntaxStyle={themeState.syntax()}
|
|
||||||
showLineNumbers={true}
|
|
||||||
width="100%"
|
|
||||||
wrapMode="word"
|
|
||||||
fg={theme().text}
|
|
||||||
addedBg={theme().diffAddedBg}
|
|
||||||
removedBg={theme().diffRemovedBg}
|
|
||||||
contextBg={theme().diffContextBg}
|
|
||||||
addedSignColor={theme().diffHighlightAdded}
|
|
||||||
removedSignColor={theme().diffHighlightRemoved}
|
|
||||||
lineNumberFg={theme().diffLineNumber}
|
|
||||||
lineNumberBg={theme().diffContextBg}
|
|
||||||
addedLineNumberBg={theme().diffAddedLineNumberBg}
|
|
||||||
removedLineNumberBg={theme().diffRemovedLineNumberBg}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</scrollbox>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={2} flexShrink={0}>
|
|
||||||
<Show when={switchFocusShortcut()}>
|
|
||||||
{(shortcut) => (
|
|
||||||
<text fg={theme().text}>
|
|
||||||
{shortcut()} <span style={{ fg: theme().textMuted }}>focus file tree</span>
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<Show when={nextFileShortcut()}>
|
|
||||||
{(shortcut) => (
|
|
||||||
<text fg={theme().text}>
|
|
||||||
{shortcut()} <span style={{ fg: theme().textMuted }}>next file</span>
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<Show when={previousFileShortcut()}>
|
|
||||||
{(shortcut) => (
|
|
||||||
<text fg={theme().text}>
|
|
||||||
{shortcut()} <span style={{ fg: theme().textMuted }}>previous file</span>
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<Show when={toggleFileTreeShortcut()}>
|
|
||||||
{(shortcut) => (
|
|
||||||
<text fg={theme().text}>
|
|
||||||
{shortcut()}{" "}
|
|
||||||
<span style={{ fg: theme().textMuted }}>{showFileTree() ? "hide file tree" : "show file tree"}</span>
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<Show when={singlePatchShortcut()}>
|
|
||||||
{(shortcut) => (
|
|
||||||
<text fg={theme().text}>
|
|
||||||
{shortcut()}{" "}
|
|
||||||
<span style={{ fg: theme().textMuted }}>{singlePatch() ? "all patches" : "single patch"}</span>
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<Show when={switchDiffShortcut()}>
|
|
||||||
{(shortcut) => (
|
|
||||||
<text fg={theme().text}>
|
|
||||||
{shortcut()} <span style={{ fg: theme().textMuted }}>switch diff</span>
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<Show when={toggleViewShortcut()}>
|
|
||||||
{(shortcut) => (
|
|
||||||
<text fg={theme().text}>
|
|
||||||
{shortcut()}{" "}
|
|
||||||
<span style={{ fg: theme().textMuted }}>{view() === "split" ? "unified view" : "split view"}</span>
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ import {
|
||||||
buildFileTree,
|
buildFileTree,
|
||||||
flattenFileTree,
|
flattenFileTree,
|
||||||
moveFileTreeSelection,
|
moveFileTreeSelection,
|
||||||
|
moveFileTreeSelectionToFirstChild,
|
||||||
moveFileTreeSelectionToFile,
|
moveFileTreeSelectionToFile,
|
||||||
|
moveFileTreeSelectionToParent,
|
||||||
setFileTreeDirectoryExpanded,
|
setFileTreeDirectoryExpanded,
|
||||||
toggleFileTreeDirectory,
|
toggleFileTreeDirectory,
|
||||||
} from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils"
|
} from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils"
|
||||||
|
|
@ -177,6 +179,47 @@ describe("diff viewer file tree utilities", () => {
|
||||||
expect(moveFileTreeSelection([], undefined, 1)).toBeUndefined()
|
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("moves file selection relative to the highlighted row", () => {
|
test("moves file selection relative to the highlighted row", () => {
|
||||||
const rows = flattenFileTree(
|
const rows = flattenFileTree(
|
||||||
buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }]),
|
buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }]),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@ import { describe, expect, test } from "bun:test"
|
||||||
import { RGBA } from "@opentui/core"
|
import { RGBA } from "@opentui/core"
|
||||||
import { testRender } from "@opentui/solid"
|
import { testRender } from "@opentui/solid"
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from "solid-js"
|
||||||
|
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||||
|
import { KVProvider } from "../../../src/cli/cmd/tui/context/kv"
|
||||||
|
import { ThemeProvider } from "../../../src/cli/cmd/tui/context/theme"
|
||||||
|
import { TuiConfigProvider } from "../../../src/cli/cmd/tui/context/tui-config"
|
||||||
import { DiffViewerFileTree } from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree"
|
import { DiffViewerFileTree } from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree"
|
||||||
import {
|
import {
|
||||||
allExpandedFileTreeDirectories,
|
allExpandedFileTreeDirectories,
|
||||||
|
|
@ -14,6 +18,7 @@ const theme = {
|
||||||
backgroundPanel: RGBA.fromHex("#111111"),
|
backgroundPanel: RGBA.fromHex("#111111"),
|
||||||
backgroundElement: RGBA.fromHex("#333333"),
|
backgroundElement: RGBA.fromHex("#333333"),
|
||||||
primary: RGBA.fromHex("#00ffff"),
|
primary: RGBA.fromHex("#00ffff"),
|
||||||
|
secondary: RGBA.fromHex("#0088ff"),
|
||||||
selectedListItemText: RGBA.fromHex("#ffffff"),
|
selectedListItemText: RGBA.fromHex("#ffffff"),
|
||||||
text: RGBA.fromHex("#ffffff"),
|
text: RGBA.fromHex("#ffffff"),
|
||||||
textMuted: RGBA.fromHex("#888888"),
|
textMuted: RGBA.fromHex("#888888"),
|
||||||
|
|
@ -23,44 +28,50 @@ const theme = {
|
||||||
describe("DiffViewerFileTree", () => {
|
describe("DiffViewerFileTree", () => {
|
||||||
test("renders sorted hierarchical file rows", async () => {
|
test("renders sorted hierarchical file rows", async () => {
|
||||||
const app = await testRender(
|
const app = await testRender(
|
||||||
() => (
|
() => withTheme(
|
||||||
<DiffViewerFileTree
|
() => (
|
||||||
files={[
|
<DiffViewerFileTree
|
||||||
{ file: "z-file.ts" },
|
width={32}
|
||||||
{ file: "b/file.ts" },
|
files={[
|
||||||
{ file: "a/zeta.ts" },
|
{ file: "z-file.ts" },
|
||||||
{ file: "b/alpha.ts" },
|
{ file: "b/file.ts" },
|
||||||
{ file: "a/alpha.ts" },
|
{ file: "a/zeta.ts" },
|
||||||
]}
|
{ file: "b/alpha.ts" },
|
||||||
loading={false}
|
{ file: "a/alpha.ts" },
|
||||||
error={undefined}
|
]}
|
||||||
theme={theme}
|
loading={false}
|
||||||
focused={true}
|
error={undefined}
|
||||||
/>
|
theme={theme}
|
||||||
|
focused={true}
|
||||||
|
/>
|
||||||
|
),
|
||||||
),
|
),
|
||||||
{ width: 40, height: 20 },
|
{ width: 40, height: 20 },
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await app.renderOnce()
|
await renderOnceSettled(app)
|
||||||
const lines = visibleLines(app.captureCharFrame())
|
const lines = visibleLines(app.captureCharFrame())
|
||||||
|
|
||||||
expect(lines).toEqual(["▾ a", " alpha.ts", " zeta.ts", "▾ b", " alpha.ts", " file.ts", " z-file.ts"])
|
expect(lines).toEqual([
|
||||||
|
"▾ a",
|
||||||
|
"│ ├─ alpha.ts ?",
|
||||||
|
"│ └─ zeta.ts ?",
|
||||||
|
"├─ ▾ b",
|
||||||
|
"│ ├─ alpha.ts ?",
|
||||||
|
"│ └─ file.ts ?",
|
||||||
|
])
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps loading and error quiet while rendering an empty settled state", async () => {
|
test("keeps loading and error quiet while rendering an empty settled state", async () => {
|
||||||
const loading = await renderFrame(() => (
|
const loading = await renderFrame(() => <DiffViewerFileTree width={32} files={[]} loading={true} error={undefined} theme={theme} />)
|
||||||
<DiffViewerFileTree files={[]} loading={true} error={undefined} theme={theme} />
|
|
||||||
))
|
|
||||||
const failed = await renderFrame(() => (
|
const failed = await renderFrame(() => (
|
||||||
<DiffViewerFileTree files={[]} loading={false} error={new Error("nope")} theme={theme} />
|
<DiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} theme={theme} />
|
||||||
))
|
|
||||||
const empty = await renderFrame(() => (
|
|
||||||
<DiffViewerFileTree files={[]} loading={false} error={undefined} theme={theme} />
|
|
||||||
))
|
))
|
||||||
|
const empty = await renderFrame(() => <DiffViewerFileTree width={32} files={[]} loading={false} error={undefined} theme={theme} />)
|
||||||
|
|
||||||
expect(loading).not.toContain("Loading diff...")
|
expect(loading).not.toContain("Loading diff...")
|
||||||
expect(loading).not.toContain("No files")
|
expect(loading).not.toContain("No files")
|
||||||
|
|
@ -75,18 +86,11 @@ describe("DiffViewerFileTree", () => {
|
||||||
|
|
||||||
const focused = visibleLines(
|
const focused = visibleLines(
|
||||||
await renderFrame(() => (
|
await renderFrame(() => (
|
||||||
<DiffViewerFileTree
|
<DiffViewerFileTree width={32} files={files} loading={false} error={undefined} theme={theme} focused highlightedNode={src.id} />
|
||||||
files={files}
|
|
||||||
loading={false}
|
|
||||||
error={undefined}
|
|
||||||
theme={theme}
|
|
||||||
focused
|
|
||||||
highlightedNode={src.id}
|
|
||||||
/>
|
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
const unfocused = visibleLines(
|
const unfocused = visibleLines(
|
||||||
await renderFrame(() => <DiffViewerFileTree files={files} loading={false} error={undefined} theme={theme} />),
|
await renderFrame(() => <DiffViewerFileTree width={32} files={files} loading={false} error={undefined} theme={theme} />),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(focused).toContain("▾ src/config")
|
expect(focused).toContain("▾ src/config")
|
||||||
|
|
@ -105,16 +109,17 @@ describe("DiffViewerFileTree", () => {
|
||||||
expect(
|
expect(
|
||||||
visibleLines(
|
visibleLines(
|
||||||
await renderFrame(() => (
|
await renderFrame(() => (
|
||||||
<DiffViewerFileTree files={files} loading={false} error={undefined} theme={theme} expandedNodes={collapsed} />
|
<DiffViewerFileTree width={32} files={files} loading={false} error={undefined} theme={theme} expandedNodes={collapsed} />
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
).toEqual(["▸ src/config", " README.md"])
|
).toEqual(["▸ src/config"])
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
visibleLines(
|
visibleLines(
|
||||||
await renderFrame(() => (
|
await renderFrame(() => (
|
||||||
<DiffViewerFileTree
|
<DiffViewerFileTree
|
||||||
files={files}
|
files={files}
|
||||||
|
width={32}
|
||||||
loading={false}
|
loading={false}
|
||||||
error={undefined}
|
error={undefined}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
|
@ -122,25 +127,41 @@ describe("DiffViewerFileTree", () => {
|
||||||
/>
|
/>
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
).toEqual(["▾ src/config", " tui.ts", " README.md"])
|
).toEqual(["▾ src/config", "│ └─ tui.ts ?"])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
async function renderFrame(component: () => JSX.Element) {
|
async function renderFrame(component: () => JSX.Element) {
|
||||||
const app = await testRender(component, { width: 40, height: 10 })
|
const app = await testRender(() => withTheme(component), { width: 40, height: 10 })
|
||||||
try {
|
try {
|
||||||
await app.renderOnce()
|
await renderOnceSettled(app)
|
||||||
return app.captureCharFrame()
|
return app.captureCharFrame()
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renderOnceSettled(app: Awaited<ReturnType<typeof testRender>>) {
|
||||||
|
await app.renderOnce()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||||
|
await app.renderOnce()
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTheme(component: () => JSX.Element) {
|
||||||
|
return (
|
||||||
|
<TuiConfigProvider config={createTuiResolvedConfig()}>
|
||||||
|
<KVProvider>
|
||||||
|
<ThemeProvider mode="dark">{component()}</ThemeProvider>
|
||||||
|
</KVProvider>
|
||||||
|
</TuiConfigProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function visibleLines(frame: string) {
|
function visibleLines(frame: string) {
|
||||||
return frame
|
return frame
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((line) => line.trimEnd())
|
.map((line) => line.trimEnd())
|
||||||
.map((line) => line.replace(/^ ?│ ?/, "").replace(/[ │]*$/, ""))
|
.map((line) => line.replace(/^ ?│ ?/, "").replace(/[ │]*$/, ""))
|
||||||
.map((line) => (line.startsWith(" ") ? line.slice(1) : line))
|
.map((line) => (line.startsWith(" ") ? line.slice(1) : line))
|
||||||
.filter((line) => line.length > 0 && !/^┌|^└/.test(line))
|
.filter((line) => line.length > 0 && !/^┌|^└|^─+$/.test(line))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue