refactor(tui): migrate diff viewer to V2 theme (#37999)

This commit is contained in:
James Long 2026-07-20 17:29:43 -04:00 committed by GitHub
commit a2c4c7a64b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 86 additions and 101 deletions

View file

@ -1,31 +1,19 @@
/** @jsxImportSource @opentui/solid */
import type { ColorInput, RGBA, ScrollBoxRenderable } from "@opentui/core"
import type { ScrollBoxRenderable } from "@opentui/core"
import { Locale } from "../../util/locale"
import { tint } from "../../theme/color"
import { createEffect, createMemo, For, Match, Switch } from "solid-js"
import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils"
import { Panel } from "./diff-viewer-ui"
import { useTheme } from "../../context/theme"
const FILE_TREE_STATUS_WIDTH = 2
export type DiffViewerFileTreeTheme = {
readonly background: RGBA
readonly backgroundPanel: ColorInput
readonly backgroundElement: ColorInput
readonly primary: ColorInput
readonly secondary: ColorInput
readonly selectedListItemText: ColorInput
readonly text: RGBA
readonly textMuted: RGBA
readonly error: ColorInput
}
export type DiffViewerFileTreeProps = {
readonly width: number
readonly files: readonly FileTreeItem[]
readonly loading: boolean
readonly error: unknown
readonly theme: DiffViewerFileTreeTheme
readonly focused?: boolean
readonly highlightedNode?: number
readonly selectedFileIndex?: number
@ -35,6 +23,7 @@ export type DiffViewerFileTreeProps = {
}
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
const { themeV2 } = useTheme()
const tree = createMemo(() => buildFileTree(props.files))
const rows = createMemo(() => flattenFileTree(tree(), props.expandedNodes))
let scroll: ScrollBoxRenderable | undefined
@ -49,7 +38,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
requestAnimationFrame(scrollSelectedIntoView)
})
const fadedColor = () => tint(props.theme.text, props.theme.background, 0.75)
const fadedColor = () => tint(themeV2.text.default, themeV2.background.default, 0.75)
return (
<Panel border="both" width={props.width}>
@ -63,7 +52,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
<text />
</Match>
<Match when={props.files.length === 0}>
<text fg={props.theme.text}>No files</text>
<text fg={themeV2.text.default}>No files</text>
</Match>
<Match when={props.files.length > 0}>
<For each={rows()}>
@ -82,22 +71,26 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
<box
flexDirection="row"
width="100%"
backgroundColor={highlighted() ? props.theme.primary : undefined}
backgroundColor={highlighted() ? themeV2.background.action.primary.focused : undefined}
onMouseUp={() => props.onRowClick?.(row)}
>
<text fg={highlighted() ? props.theme.background : fadedColor()} wrapMode="none" flexShrink={0}>
<text
fg={highlighted() ? themeV2.text.action.primary.focused : fadedColor()}
wrapMode="none"
flexShrink={0}
>
{prefix()}
</text>
<box flexGrow={1} minWidth={0}>
<text
fg={
highlighted()
? props.theme.background
? themeV2.text.action.primary.focused
: selected()
? props.theme.primary
? themeV2.text.formfield.selected
: reviewed() || row.kind === "directory"
? props.theme.textMuted
: props.theme.text
? themeV2.text.subdued
: themeV2.text.default
}
wrapMode="none"
>
@ -105,7 +98,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
</text>
</box>
<text
fg={highlighted() ? props.theme.background : props.theme.textMuted}
fg={highlighted() ? themeV2.text.action.primary.focused : themeV2.text.subdued}
wrapMode="none"
flexShrink={0}
>

View file

@ -85,7 +85,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
const config = useConfig()
const dialog = useDialog()
const themeState = useTheme()
const theme = () => themeState.theme
const themeV2 = themeState.themeV2
const params = () => {
const route = props.context.ui.router.current()
return (route.type === "plugin" ? route.data : undefined) as
@ -741,11 +741,11 @@ function DiffViewer(props: { context: Plugin.Context }) {
<box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
<PanelGroup axis="y" width="100%" height="100%">
<Panel border="none" flexShrink={0} padding={0} paddingLeft={1}>
<text fg={theme().text}>Diff </text>
<text fg={theme().textMuted}>{diffSourceLabel(mode())}</text>
<text fg={themeV2.text.default}>Diff </text>
<text fg={themeV2.text.subdued}>{diffSourceLabel(mode())}</text>
<box flexGrow={1} />
<Show when={!diff.loading && !diff.error}>
<text fg={theme().textMuted}>
<text fg={themeV2.text.subdued}>
{files().length} {files().length === 1 ? "file" : "files"}
</text>
</Show>
@ -756,19 +756,21 @@ function DiffViewer(props: { context: Plugin.Context }) {
<Match when={diff.loading}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={theme().textMuted}>Loading diff</text>
<text fg={themeV2.text.subdued}>Loading diff</text>
</box>
</Match>
<Match when={!diff.loading && diff.error}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={theme().error}>Could not load diff. Reopen the diff viewer to try again.</text>
<text fg={themeV2.text.feedback.error.default}>
Could not load diff. Reopen the diff viewer to try again.
</text>
</box>
</Match>
<Match when={!diff.loading && files().length === 0}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={theme().textMuted}>No changes to show</text>
<text fg={themeV2.text.subdued}>No changes to show</text>
</box>
</Match>
<Match when={!diff.loading}>
@ -778,7 +780,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
files={files()}
loading={diff.loading}
error={diff.error}
theme={theme()}
focused={focus() === "files"}
width={FILE_TREE_WIDTH}
highlightedNode={highlightedFileNode()}
@ -812,24 +813,26 @@ function DiffViewer(props: { context: Plugin.Context }) {
paddingLeft={1}
paddingRight={1}
border={patchLeftBorder()}
borderColor={theme().border}
borderColor={themeV2.border.default}
>
<text fg={reviewed() ? theme().textMuted : theme().text}>{entry.file.file}</text>
<text fg={reviewed() ? themeV2.text.subdued : themeV2.text.default}>
{entry.file.file}
</text>
<box flexGrow={1} />
<text fg={reviewed() ? theme().textMuted : theme().diffAdded}>
<text fg={reviewed() ? themeV2.text.subdued : themeV2.diff.text.added}>
+{entry.file.additions}
</text>
<text fg={reviewed() ? theme().textMuted : theme().diffRemoved}>
<text fg={reviewed() ? themeV2.text.subdued : themeV2.diff.text.removed}>
-{entry.file.deletions}
</text>
</box>
<Separator axis="x" start={showFileTree() ? "edge" : undefined} />
<Show
when={entry.file.patch}
fallback={<text fg={theme().textMuted}>No patch available for this file.</text>}
fallback={<text fg={themeV2.text.subdued}>No patch available for this file.</text>}
>
{(patch) => (
<box border={patchLeftBorder()} borderColor={theme().border}>
<box border={patchLeftBorder()} borderColor={themeV2.border.default}>
<diff
ref={(element: DiffRenderable) => diffNodeByFileIndex.set(entry.fileIndex, element)}
diff={patch()}
@ -839,17 +842,27 @@ function DiffViewer(props: { context: Plugin.Context }) {
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}
fg={reviewed() ? themeV2.text.subdued : themeV2.text.default}
addedBg={
reviewed() ? themeV2.background.surface.overlay : themeV2.diff.background.added
}
removedBg={
reviewed() ? themeV2.background.surface.overlay : themeV2.diff.background.removed
}
addedSignColor={reviewed() ? themeV2.text.subdued : themeV2.diff.highlight.added}
removedSignColor={
reviewed() ? themeV2.text.subdued : themeV2.diff.highlight.removed
}
lineNumberFg={themeV2.diff.lineNumber.text}
addedLineNumberBg={
reviewed() ? theme().backgroundElement : theme().diffAddedLineNumberBg
reviewed()
? themeV2.background.surface.overlay
: themeV2.diff.lineNumber.background.added
}
removedLineNumberBg={
reviewed() ? theme().backgroundElement : theme().diffRemovedLineNumberBg
reviewed()
? themeV2.background.surface.overlay
: themeV2.diff.lineNumber.background.removed
}
/>
</box>
@ -860,7 +873,11 @@ function DiffViewer(props: { context: Plugin.Context }) {
}}
</For>
<Show when={patchFillerHeight() > 0}>
<box height={patchFillerHeight()} border={patchLeftBorder()} borderColor={theme().border} />
<box
height={patchFillerHeight()}
border={patchLeftBorder()}
borderColor={themeV2.border.default}
/>
</Show>
</scrollbox>
<Separator axis="x" start={showFileTree() ? "edge-in" : undefined} />
@ -873,57 +890,57 @@ function DiffViewer(props: { context: Plugin.Context }) {
<Panel flexShrink={0} gap={2} paddingLeft={1} border="none">
<Show when={switchFocusShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>focus file tree</span>
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>focus file tree</span>
</text>
)}
</Show>
<Show when={nextFileShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>next file</span>
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>next file</span>
</text>
)}
</Show>
<Show when={nextHunkShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>next hunk</span>
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>next hunk</span>
</text>
)}
</Show>
<Show when={previousHunkShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>previous hunk</span>
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>previous hunk</span>
</text>
)}
</Show>
<Show when={previousFileShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>previous file</span>
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>previous file</span>
</text>
)}
</Show>
<Show when={switchSourceShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>switch source</span>
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>switch source</span>
</text>
)}
</Show>
<Show when={markReviewedShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>mark reviewed</span>
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>mark reviewed</span>
</text>
)}
</Show>
<Show when={helpShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>all</span>
<text fg={themeV2.text.default}>
{shortcut()} <span style={{ fg: themeV2.text.subdued }}>all</span>
</text>
)}
</Show>
@ -934,7 +951,7 @@ function DiffViewer(props: { context: Plugin.Context }) {
}
function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
const { theme } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
const shortcut = (id: string) => () => props.context.keymap.shortcut(id)
const rows = [
{
@ -1002,30 +1019,30 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
return (
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
<text attributes={TextAttributes.BOLD} fg={themeV2.text.default}>
Diff shortcuts
</text>
<text fg={theme.textMuted}>esc</text>
<text fg={themeV2.text.subdued}>esc</text>
</box>
<box flexDirection="row">
<text fg={theme.textMuted} width={5} wrapMode="none">
<text fg={themeV2.text.subdued} width={5} wrapMode="none">
Key
</text>
<text fg={theme.textMuted} width={22} wrapMode="none">
<text fg={themeV2.text.subdued} width={22} wrapMode="none">
Action
</text>
<text fg={theme.textMuted}>Description</text>
<text fg={themeV2.text.subdued}>Description</text>
</box>
<For each={rows}>
{(row) => (
<box flexDirection="row">
<text fg={theme.text} width={5} wrapMode="none">
<text fg={themeV2.text.default} width={5} wrapMode="none">
{row.shortcut() || "-"}
</text>
<text fg={theme.text} width={22} wrapMode="none">
<text fg={themeV2.text.default} width={22} wrapMode="none">
{row.action}
</text>
<text fg={theme.textMuted}>{row.description}</text>
<text fg={themeV2.text.subdued}>{row.description}</text>
</box>
)}
</For>

View file

@ -1,6 +1,5 @@
/** @jsxImportSource @opentui/solid */
import { describe, expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { testRender } from "@opentui/solid"
import type { JSX } from "solid-js"
import { onMount, type ParentProps } from "solid-js"
@ -14,18 +13,6 @@ import {
buildFileTree,
} from "../../../src/feature-plugins/system/diff-viewer-file-tree-utils"
const theme = {
background: RGBA.fromHex("#000000"),
backgroundPanel: RGBA.fromHex("#111111"),
backgroundElement: RGBA.fromHex("#333333"),
primary: RGBA.fromHex("#00ffff"),
secondary: RGBA.fromHex("#0088ff"),
selectedListItemText: RGBA.fromHex("#ffffff"),
text: RGBA.fromHex("#ffffff"),
textMuted: RGBA.fromHex("#888888"),
error: RGBA.fromHex("#ff0000"),
}
describe("DiffViewerFileTree", () => {
test.skip("renders sorted hierarchical file rows", async () => {
const lines = visibleLines(
@ -41,7 +28,6 @@ describe("DiffViewerFileTree", () => {
]}
loading={false}
error={undefined}
theme={theme}
focused={true}
/>
)),
@ -59,13 +45,13 @@ describe("DiffViewerFileTree", () => {
test("keeps loading and error quiet while rendering an empty settled state", async () => {
const loading = await renderFrame(() => (
<DiffViewerFileTree width={32} files={[]} loading={true} error={undefined} theme={theme} />
<DiffViewerFileTree width={32} files={[]} loading={true} error={undefined} />
))
const failed = await renderFrame(() => (
<DiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} theme={theme} />
<DiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} />
))
const empty = await renderFrame(() => (
<DiffViewerFileTree width={32} files={[]} loading={false} error={undefined} theme={theme} />
<DiffViewerFileTree width={32} files={[]} loading={false} error={undefined} />
))
expect(loading).not.toContain("Loading diff...")
@ -86,16 +72,13 @@ describe("DiffViewerFileTree", () => {
files={files}
loading={false}
error={undefined}
theme={theme}
focused
highlightedNode={src.id}
/>
)),
)
const unfocused = visibleLines(
await renderFrame(() => (
<DiffViewerFileTree width={32} files={files} loading={false} error={undefined} theme={theme} />
)),
await renderFrame(() => <DiffViewerFileTree width={32} files={files} loading={false} error={undefined} />),
)
expect(focused).toContain("▾ src/config")
@ -114,14 +97,7 @@ describe("DiffViewerFileTree", () => {
expect(
visibleLines(
await renderFrame(() => (
<DiffViewerFileTree
width={32}
files={files}
loading={false}
error={undefined}
theme={theme}
expandedNodes={collapsed}
/>
<DiffViewerFileTree width={32} files={files} loading={false} error={undefined} expandedNodes={collapsed} />
)),
),
).toEqual(["▸ src/config"])
@ -134,7 +110,6 @@ describe("DiffViewerFileTree", () => {
width={32}
loading={false}
error={undefined}
theme={theme}
expandedNodes={allExpandedFileTreeDirectories(tree)}
/>
)),