@neurocode-ai/tui 1.18.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bunfig.toml +4 -0
- package/package.json +72 -0
- package/src/app.tsx +1134 -0
- package/src/attention.ts +260 -0
- package/src/audio.d.ts +9 -0
- package/src/audio.ts +53 -0
- package/src/clipboard.ts +124 -0
- package/src/component/bg-pulse-render.ts +436 -0
- package/src/component/bg-pulse.tsx +99 -0
- package/src/component/command-palette.tsx +79 -0
- package/src/component/dialog-agent.tsx +31 -0
- package/src/component/dialog-console-org.tsx +135 -0
- package/src/component/dialog-debug.tsx +90 -0
- package/src/component/dialog-mcp.tsx +85 -0
- package/src/component/dialog-model.tsx +197 -0
- package/src/component/dialog-move-session.tsx +353 -0
- package/src/component/dialog-provider.tsx +469 -0
- package/src/component/dialog-retry-action.tsx +160 -0
- package/src/component/dialog-session-delete-failed.tsx +99 -0
- package/src/component/dialog-session-list.tsx +364 -0
- package/src/component/dialog-session-rename.tsx +31 -0
- package/src/component/dialog-skill.tsx +70 -0
- package/src/component/dialog-stash.tsx +87 -0
- package/src/component/dialog-status.tsx +168 -0
- package/src/component/dialog-tag.tsx +47 -0
- package/src/component/dialog-theme-list.tsx +50 -0
- package/src/component/dialog-variant.tsx +39 -0
- package/src/component/dialog-workspace-create.tsx +308 -0
- package/src/component/dialog-workspace-file-changes.tsx +144 -0
- package/src/component/dialog-workspace-list.tsx +112 -0
- package/src/component/dialog-workspace-unavailable.tsx +69 -0
- package/src/component/error-component.tsx +240 -0
- package/src/component/logo.tsx +61 -0
- package/src/component/plugin-route-missing.tsx +14 -0
- package/src/component/prompt/autocomplete.tsx +781 -0
- package/src/component/prompt/cwd.ts +0 -0
- package/src/component/prompt/frecency.tsx +1 -0
- package/src/component/prompt/history.tsx +1 -0
- package/src/component/prompt/index.tsx +1713 -0
- package/src/component/prompt/local-attachment.ts +48 -0
- package/src/component/prompt/move.tsx +205 -0
- package/src/component/prompt/stash.tsx +1 -0
- package/src/component/prompt/workspace.tsx +137 -0
- package/src/component/register-spinner.ts +6 -0
- package/src/component/spinner.tsx +26 -0
- package/src/component/startup-loading.tsx +63 -0
- package/src/component/todo-item.tsx +32 -0
- package/src/component/use-connected.tsx +12 -0
- package/src/component/workspace-label.tsx +19 -0
- package/src/config/index.tsx +129 -0
- package/src/config/keybind.ts +471 -0
- package/src/context/args.tsx +16 -0
- package/src/context/clipboard.tsx +18 -0
- package/src/context/data.tsx +569 -0
- package/src/context/directory.ts +17 -0
- package/src/context/editor.ts +408 -0
- package/src/context/epilogue.tsx +6 -0
- package/src/context/event.ts +36 -0
- package/src/context/exit.tsx +8 -0
- package/src/context/helper.tsx +26 -0
- package/src/context/kv.tsx +66 -0
- package/src/context/local.tsx +542 -0
- package/src/context/location.tsx +14 -0
- package/src/context/path-format.tsx +24 -0
- package/src/context/permission.tsx +26 -0
- package/src/context/project.tsx +115 -0
- package/src/context/prompt.tsx +18 -0
- package/src/context/route.tsx +60 -0
- package/src/context/runtime.tsx +62 -0
- package/src/context/sdk.tsx +151 -0
- package/src/context/sync.tsx +666 -0
- package/src/context/theme.tsx +332 -0
- package/src/context/thinking.ts +67 -0
- package/src/editor-zed.ts +286 -0
- package/src/editor.ts +101 -0
- package/src/feature-plugins/builtins.ts +36 -0
- package/src/feature-plugins/home/footer.tsx +100 -0
- package/src/feature-plugins/home/tips-view.tsx +287 -0
- package/src/feature-plugins/home/tips.tsx +59 -0
- package/src/feature-plugins/sidebar/context.tsx +65 -0
- package/src/feature-plugins/sidebar/files.tsx +70 -0
- package/src/feature-plugins/sidebar/footer.tsx +98 -0
- package/src/feature-plugins/sidebar/lsp.tsx +65 -0
- package/src/feature-plugins/sidebar/mcp.tsx +97 -0
- package/src/feature-plugins/sidebar/todo.tsx +49 -0
- package/src/feature-plugins/system/diff-viewer-file-tree-utils.ts +232 -0
- package/src/feature-plugins/system/diff-viewer-file-tree.tsx +162 -0
- package/src/feature-plugins/system/diff-viewer-ui.tsx +103 -0
- package/src/feature-plugins/system/diff-viewer.tsx +1077 -0
- package/src/feature-plugins/system/notifications.ts +94 -0
- package/src/feature-plugins/system/plugins.tsx +269 -0
- package/src/feature-plugins/system/which-key.tsx +608 -0
- package/src/index.tsx +1 -0
- package/src/keymap.tsx +290 -0
- package/src/logo.ts +11 -0
- package/src/parsers-config.ts +386 -0
- package/src/plugin/adapters.tsx +355 -0
- package/src/plugin/api.ts +52 -0
- package/src/plugin/command-shim.ts +109 -0
- package/src/plugin/runtime.tsx +81 -0
- package/src/plugin/slots.tsx +65 -0
- package/src/prompt/display.ts +48 -0
- package/src/prompt/frecency.tsx +80 -0
- package/src/prompt/history.tsx +111 -0
- package/src/prompt/part.ts +29 -0
- package/src/prompt/stash.tsx +89 -0
- package/src/prompt/traits.ts +29 -0
- package/src/routes/home/session-destination.tsx +41 -0
- package/src/routes/home.tsx +95 -0
- package/src/routes/session/dialog-fork-from-timeline.tsx +76 -0
- package/src/routes/session/dialog-message.tsx +109 -0
- package/src/routes/session/dialog-subagent.tsx +26 -0
- package/src/routes/session/dialog-timeline.tsx +47 -0
- package/src/routes/session/footer.tsx +91 -0
- package/src/routes/session/index.tsx +2710 -0
- package/src/routes/session/permission.tsx +718 -0
- package/src/routes/session/question.tsx +514 -0
- package/src/routes/session/sidebar.tsx +103 -0
- package/src/routes/session/subagent-footer.tsx +132 -0
- package/src/runtime.tsx +9 -0
- package/src/terminal-win32.ts +130 -0
- package/src/theme/assets/aura.json +69 -0
- package/src/theme/assets/ayu.json +80 -0
- package/src/theme/assets/carbonfox.json +248 -0
- package/src/theme/assets/catppuccin-frappe.json +230 -0
- package/src/theme/assets/catppuccin-macchiato.json +230 -0
- package/src/theme/assets/catppuccin.json +112 -0
- package/src/theme/assets/cobalt2.json +225 -0
- package/src/theme/assets/cursor.json +249 -0
- package/src/theme/assets/dracula.json +219 -0
- package/src/theme/assets/everforest.json +241 -0
- package/src/theme/assets/flexoki.json +237 -0
- package/src/theme/assets/github.json +233 -0
- package/src/theme/assets/gruvbox.json +242 -0
- package/src/theme/assets/kanagawa.json +77 -0
- package/src/theme/assets/lucent-orng.json +234 -0
- package/src/theme/assets/material.json +235 -0
- package/src/theme/assets/matrix.json +77 -0
- package/src/theme/assets/mercury.json +252 -0
- package/src/theme/assets/monokai.json +221 -0
- package/src/theme/assets/nightowl.json +221 -0
- package/src/theme/assets/nord.json +223 -0
- package/src/theme/assets/one-dark.json +84 -0
- package/src/theme/assets/opencode.json +245 -0
- package/src/theme/assets/orng.json +249 -0
- package/src/theme/assets/osaka-jade.json +93 -0
- package/src/theme/assets/palenight.json +222 -0
- package/src/theme/assets/rosepine.json +234 -0
- package/src/theme/assets/solarized.json +223 -0
- package/src/theme/assets/synthwave84.json +226 -0
- package/src/theme/assets/tokyonight.json +243 -0
- package/src/theme/assets/vercel.json +245 -0
- package/src/theme/assets/vesper.json +218 -0
- package/src/theme/assets/zenburn.json +223 -0
- package/src/theme/index.ts +1089 -0
- package/src/ui/border.ts +21 -0
- package/src/ui/dialog-alert.tsx +66 -0
- package/src/ui/dialog-confirm.tsx +108 -0
- package/src/ui/dialog-export-options.tsx +217 -0
- package/src/ui/dialog-help.tsx +40 -0
- package/src/ui/dialog-prompt.tsx +126 -0
- package/src/ui/dialog-select.tsx +790 -0
- package/src/ui/dialog.tsx +231 -0
- package/src/ui/link.tsx +34 -0
- package/src/ui/spinner.ts +368 -0
- package/src/ui/toast.tsx +102 -0
- package/src/util/collapse-tool-output.ts +19 -0
- package/src/util/error.ts +182 -0
- package/src/util/filetype.ts +130 -0
- package/src/util/format.ts +20 -0
- package/src/util/layout.ts +25 -0
- package/src/util/locale.ts +86 -0
- package/src/util/model.ts +28 -0
- package/src/util/path.ts +12 -0
- package/src/util/persistence.ts +33 -0
- package/src/util/presentation.ts +38 -0
- package/src/util/provider-origin.ts +7 -0
- package/src/util/record.ts +3 -0
- package/src/util/renderer.ts +7 -0
- package/src/util/revert-diff.ts +18 -0
- package/src/util/scroll.ts +27 -0
- package/src/util/selection.ts +79 -0
- package/src/util/session.ts +3 -0
- package/src/util/signal.ts +51 -0
- package/src/util/system.ts +20 -0
- package/src/util/tool-display.ts +13 -0
- package/src/util/transcript.ts +112 -0
- package/sst-env.d.ts +10 -0
- package/test/app-lifecycle.test.tsx +128 -0
- package/test/cli/cmd/tui/dialog-workspace-create.test.ts +28 -0
- package/test/cli/cmd/tui/model-options.test.ts +32 -0
- package/test/cli/cmd/tui/notifications.test.ts +267 -0
- package/test/cli/cmd/tui/provider-options.test.ts +41 -0
- package/test/cli/cmd/tui/sync-fixture.tsx +70 -0
- package/test/cli/cmd/tui/sync-live-hydration.test.tsx +262 -0
- package/test/cli/cmd/tui/sync-undefined-messages.test.tsx +43 -0
- package/test/cli/cmd/tui/sync.test.tsx +65 -0
- package/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap +92 -0
- package/test/cli/tui/data.test.tsx +486 -0
- package/test/cli/tui/dialog-prompt.test.tsx +147 -0
- package/test/cli/tui/diff-viewer-file-tree.test.tsx +200 -0
- package/test/cli/tui/diff-viewer.test.tsx +268 -0
- package/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +351 -0
- package/test/cli/tui/prompt-submit-race.test.ts +98 -0
- package/test/cli/tui/thinking.test.ts +36 -0
- package/test/cli/tui/use-event.test.tsx +148 -0
- package/test/clipboard.test.ts +19 -0
- package/test/component/dialog-session-list.test.ts +46 -0
- package/test/config.test.tsx +127 -0
- package/test/context/local.test.ts +22 -0
- package/test/editor.test.ts +32 -0
- package/test/feature-plugins/diff-viewer-file-tree-utils.test.ts +323 -0
- package/test/fixture/fixture.ts +13 -0
- package/test/fixture/tui-environment.tsx +32 -0
- package/test/fixture/tui-plugin.ts +36 -0
- package/test/fixture/tui-runtime.ts +12 -0
- package/test/fixture/tui-sdk.ts +109 -0
- package/test/index.test.tsx +6 -0
- package/test/keymap.test.tsx +141 -0
- package/test/plugin/runtime.test.ts +50 -0
- package/test/plugin/slots.test.tsx +38 -0
- package/test/prompt/display.test.ts +33 -0
- package/test/prompt/history.test.ts +39 -0
- package/test/prompt/jsonl.test.ts +24 -0
- package/test/prompt/local-attachment.test.ts +43 -0
- package/test/prompt/part.test.ts +53 -0
- package/test/prompt/persistence.test.ts +23 -0
- package/test/prompt/traits.test.ts +25 -0
- package/test/runtime.test.tsx +37 -0
- package/test/theme.test.ts +81 -0
- package/test/util/error.test.ts +49 -0
- package/test/util/filetype.test.ts +16 -0
- package/test/util/format.test.ts +59 -0
- package/test/util/model.test.ts +9 -0
- package/test/util/presentation.test.ts +8 -0
- package/test/util/renderer.test.ts +30 -0
- package/test/util/revert-diff.test.ts +35 -0
- package/test/util/session.test.ts +10 -0
- package/test/util/tool-display.test.ts +40 -0
- package/test/util/transcript.test.ts +421 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,1077 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import type { TuiPlugin, TuiPluginApi, TuiRouteCurrent } from "@neurocode-ai/plugin/tui"
|
|
3
|
+
import type { SnapshotFileDiff, VcsFileDiff } from "@neurocode-ai/sdk/v2"
|
|
4
|
+
import {
|
|
5
|
+
TextAttributes,
|
|
6
|
+
type BorderSides,
|
|
7
|
+
type BoxRenderable,
|
|
8
|
+
type DiffRenderable,
|
|
9
|
+
type ScrollBoxRenderable,
|
|
10
|
+
} from "@opentui/core"
|
|
11
|
+
import { LANGUAGE_EXTENSIONS } from "../../util/filetype"
|
|
12
|
+
import { useBindings, useCommandShortcut } from "../../keymap"
|
|
13
|
+
import { useTheme } from "../../context/theme"
|
|
14
|
+
import { useTerminalDimensions } from "@opentui/solid"
|
|
15
|
+
import path from "path"
|
|
16
|
+
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
|
|
17
|
+
import { DiffViewerFileTree } from "./diff-viewer-file-tree"
|
|
18
|
+
import { Panel, PanelGroup, Separator } from "./diff-viewer-ui"
|
|
19
|
+
import { DialogSelect } from "../../ui/dialog-select"
|
|
20
|
+
import { getScrollAcceleration } from "../../util/scroll"
|
|
21
|
+
import {
|
|
22
|
+
allExpandedFileTreeDirectories,
|
|
23
|
+
buildFileTree,
|
|
24
|
+
fileTreeFileSelection,
|
|
25
|
+
type FileTreeRow,
|
|
26
|
+
flattenFileTree,
|
|
27
|
+
moveFileTreeSelection,
|
|
28
|
+
moveFileTreeSelectionToFirstChild,
|
|
29
|
+
moveFileTreeSelectionToParent,
|
|
30
|
+
movePatchFileIndex,
|
|
31
|
+
orderedPatchFileIndexes,
|
|
32
|
+
setFileTreeDirectoryExpanded,
|
|
33
|
+
showDiffViewerFileTree,
|
|
34
|
+
singlePatchFileIndex,
|
|
35
|
+
toggleFileTreeDirectory,
|
|
36
|
+
} from "./diff-viewer-file-tree-utils"
|
|
37
|
+
|
|
38
|
+
const ROUTE = "diff"
|
|
39
|
+
const MIN_SPLIT_WIDTH = 100
|
|
40
|
+
const FILE_TREE_WIDTH = 32
|
|
41
|
+
const PLAIN_TEXT_FILETYPE = "opencode-plain-text"
|
|
42
|
+
const VCS_DIFF_CONTEXT_LINES = 12
|
|
43
|
+
const KV_SHOW_FILE_TREE = "diff_viewer_show_file_tree"
|
|
44
|
+
const KV_SINGLE_PATCH = "diff_viewer_single_patch"
|
|
45
|
+
const KV_VIEW = "diff_viewer_view"
|
|
46
|
+
type DiffMode = "git" | "branch" | "last-turn"
|
|
47
|
+
type DiffViewerFocus = "patches" | "files"
|
|
48
|
+
type DiffView = "split" | "unified"
|
|
49
|
+
type SelectedHunk = { readonly fileIndex: number; readonly hunkIndex: number; readonly scrollTop: number }
|
|
50
|
+
|
|
51
|
+
type DiffFile = {
|
|
52
|
+
readonly file: string
|
|
53
|
+
readonly patch?: string
|
|
54
|
+
readonly additions: number
|
|
55
|
+
readonly deletions: number
|
|
56
|
+
readonly status: "added" | "deleted" | "modified"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const normalizeDiffs = (diffs: readonly (VcsFileDiff | SnapshotFileDiff)[]): DiffFile[] =>
|
|
60
|
+
diffs.flatMap((item) =>
|
|
61
|
+
item.file
|
|
62
|
+
? [
|
|
63
|
+
{
|
|
64
|
+
file: item.file,
|
|
65
|
+
patch: item.patch,
|
|
66
|
+
additions: item.additions,
|
|
67
|
+
deletions: item.deletions,
|
|
68
|
+
status: item.status ?? "modified",
|
|
69
|
+
} satisfies DiffFile,
|
|
70
|
+
]
|
|
71
|
+
: [],
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
function filetype(input?: string) {
|
|
75
|
+
if (!input) return "none"
|
|
76
|
+
const language = LANGUAGE_EXTENSIONS[path.extname(input)]
|
|
77
|
+
if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
|
|
78
|
+
return language
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function storedView(value: unknown): DiffView | undefined {
|
|
82
|
+
if (value === "split" || value === "unified") return value
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function diffSourceLabel(mode: DiffMode) {
|
|
86
|
+
if (mode === "last-turn") return "last turn"
|
|
87
|
+
if (mode === "branch") return "main branch"
|
|
88
|
+
return "working tree"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function DiffViewer(props: { api: TuiPluginApi }) {
|
|
92
|
+
const dimensions = useTerminalDimensions()
|
|
93
|
+
const themeState = useTheme()
|
|
94
|
+
const theme = () => props.api.theme.current
|
|
95
|
+
const params = () =>
|
|
96
|
+
("params" in props.api.route.current ? props.api.route.current.params : undefined) as
|
|
97
|
+
| {
|
|
98
|
+
mode?: DiffMode
|
|
99
|
+
sessionID?: string
|
|
100
|
+
messageID?: string
|
|
101
|
+
returnRoute?: TuiRouteCurrent
|
|
102
|
+
}
|
|
103
|
+
| undefined
|
|
104
|
+
const mode = () => params()?.mode ?? "git"
|
|
105
|
+
const diffInput = createMemo(() => {
|
|
106
|
+
const sessionID = params()?.sessionID
|
|
107
|
+
return {
|
|
108
|
+
mode: mode(),
|
|
109
|
+
sessionID,
|
|
110
|
+
messageID: params()?.messageID,
|
|
111
|
+
directory: sessionID ? props.api.state.session.get(sessionID)?.directory : undefined,
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
const [diff] = createResource(diffInput, async (input) => {
|
|
115
|
+
if (input.mode === "last-turn") {
|
|
116
|
+
const sessionID = input.sessionID
|
|
117
|
+
if (!sessionID) return []
|
|
118
|
+
const result = await props.api.client.session.diff(
|
|
119
|
+
{ sessionID, messageID: input.messageID },
|
|
120
|
+
{ throwOnError: true },
|
|
121
|
+
)
|
|
122
|
+
return normalizeDiffs(result.data ?? [])
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const result = await props.api.client.vcs.diff(
|
|
126
|
+
{ directory: input.directory, mode: input.mode, context: VCS_DIFF_CONTEXT_LINES },
|
|
127
|
+
{ throwOnError: true },
|
|
128
|
+
)
|
|
129
|
+
return normalizeDiffs(result.data ?? [])
|
|
130
|
+
})
|
|
131
|
+
const files = createMemo(() => diff() ?? [])
|
|
132
|
+
const [focus, setFocus] = createSignal<DiffViewerFocus>("patches")
|
|
133
|
+
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(
|
|
134
|
+
props.api.kv.get<boolean>(KV_SHOW_FILE_TREE, true) !== false,
|
|
135
|
+
)
|
|
136
|
+
const showFileTree = createMemo(() => showDiffViewerFileTree(fileTreeEnabled(), files().length))
|
|
137
|
+
const [singlePatch, setSinglePatch] = createSignal(props.api.kv.get<boolean>(KV_SINGLE_PATCH, false) === true)
|
|
138
|
+
const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? 33 : 0) - 4)
|
|
139
|
+
const patchLeftBorder = createMemo<BorderSides[]>(() => (showFileTree() ? ["left"] : []))
|
|
140
|
+
const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
|
|
141
|
+
const defaultView = createMemo(() => {
|
|
142
|
+
if (props.api.tuiConfig.diff_style === "stacked") return "unified"
|
|
143
|
+
return splitAvailable() ? "split" : "unified"
|
|
144
|
+
})
|
|
145
|
+
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.api.kv.get(KV_VIEW)))
|
|
146
|
+
const view = createMemo(() => (splitAvailable() ? (viewOverride() ?? defaultView()) : "unified"))
|
|
147
|
+
const fileTree = createMemo(() => buildFileTree(files()))
|
|
148
|
+
const [expandedFileNodes, setExpandedFileNodes] = createSignal<ReadonlySet<number>>(new Set())
|
|
149
|
+
const [highlightedFileNode, setHighlightedFileNode] = createSignal<number | undefined>()
|
|
150
|
+
const [lastHighlightedFileNode, setLastHighlightedFileNode] = createSignal<number | undefined>()
|
|
151
|
+
const [activePatchFileIndex, setActivePatchFileIndex] = createSignal<number | undefined>()
|
|
152
|
+
const [selectedFileIndex, setSelectedFileIndex] = createSignal<number | undefined>()
|
|
153
|
+
const [reviewedFileNames, setReviewedFileNames] = createSignal<ReadonlySet<string>>(new Set())
|
|
154
|
+
const patchScrollAcceleration = createMemo(() => getScrollAcceleration(props.api.tuiConfig))
|
|
155
|
+
const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes()))
|
|
156
|
+
const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree())))
|
|
157
|
+
const focusRunner = (input: Record<DiffViewerFocus, () => void>) => () => input[focus()]()
|
|
158
|
+
const switchFocusShortcut = useCommandShortcut("diff.switch_focus")
|
|
159
|
+
const nextHunkShortcut = useCommandShortcut("diff.next_hunk")
|
|
160
|
+
const previousHunkShortcut = useCommandShortcut("diff.previous_hunk")
|
|
161
|
+
const nextFileShortcut = useCommandShortcut("diff.next_file")
|
|
162
|
+
const previousFileShortcut = useCommandShortcut("diff.previous_file")
|
|
163
|
+
const toggleFileTreeShortcut = useCommandShortcut("diff.toggle_file_tree")
|
|
164
|
+
const singlePatchShortcut = useCommandShortcut("diff.single_patch")
|
|
165
|
+
const switchSourceShortcut = useCommandShortcut("diff.switch_source")
|
|
166
|
+
const toggleViewShortcut = useCommandShortcut("diff.toggle_view")
|
|
167
|
+
const markReviewedShortcut = useCommandShortcut("diff.mark_reviewed")
|
|
168
|
+
const helpShortcut = useCommandShortcut("diff.help")
|
|
169
|
+
let scroll: ScrollBoxRenderable | undefined
|
|
170
|
+
const patchNodeByFileIndex = new Map<number, BoxRenderable>()
|
|
171
|
+
const diffNodeByFileIndex = new Map<number, DiffRenderable>()
|
|
172
|
+
const [selectedHunk, setSelectedHunk] = createSignal<SelectedHunk | undefined>()
|
|
173
|
+
const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
|
|
174
|
+
const [patchFillerHeight, setPatchFillerHeight] = createSignal(0)
|
|
175
|
+
|
|
176
|
+
onCleanup(() => props.api.ui.dialog.clear())
|
|
177
|
+
|
|
178
|
+
createEffect(() => {
|
|
179
|
+
setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree()))
|
|
180
|
+
setHighlightedFileNode(undefined)
|
|
181
|
+
setLastHighlightedFileNode(undefined)
|
|
182
|
+
setActivePatchFileIndex(undefined)
|
|
183
|
+
setSelectedFileIndex(undefined)
|
|
184
|
+
setSelectedHunk(undefined)
|
|
185
|
+
setReviewedFileNames(new Set<string>())
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
const ensureHighlightedFileNode = () => {
|
|
189
|
+
const highlighted = highlightedFileNode()
|
|
190
|
+
if (highlighted !== undefined && fileRows().some((row) => row.id === highlighted)) return
|
|
191
|
+
const lastHighlighted = lastHighlightedFileNode()
|
|
192
|
+
const next =
|
|
193
|
+
lastHighlighted !== undefined && fileRows().some((row) => row.id === lastHighlighted)
|
|
194
|
+
? lastHighlighted
|
|
195
|
+
: fileRows().find((row) => row.fileIndex !== undefined)?.id
|
|
196
|
+
setHighlightedFileNode(next)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const setHighlighted = (node: number | undefined) => {
|
|
200
|
+
setHighlightedFileNode(node)
|
|
201
|
+
if (node !== undefined) setLastHighlightedFileNode(node)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const moveFileSelection = (offset: number) =>
|
|
205
|
+
setHighlighted(moveFileTreeSelection(fileRows(), highlightedFileNode(), offset))
|
|
206
|
+
|
|
207
|
+
const clearFileTreePatchState = () => {
|
|
208
|
+
setHighlightedFileNode(undefined)
|
|
209
|
+
setActivePatchFileIndex(undefined)
|
|
210
|
+
setSelectedHunk(undefined)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const scrollPatchNodeToTop = (patchNode: BoxRenderable) => {
|
|
214
|
+
requestAnimationFrame(() => {
|
|
215
|
+
if (!scroll) return
|
|
216
|
+
const scrollDelta = patchNode.y - scroll.viewport.y
|
|
217
|
+
const contentY = scroll.scrollTop + scrollDelta
|
|
218
|
+
const offset = contentY === 0 ? 0 : 1
|
|
219
|
+
scroll.scrollBy(scrollDelta + offset)
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const revealFileTreeFile = (fileIndex: number) => {
|
|
224
|
+
const selection = fileTreeFileSelection(fileTree(), fileIndex)
|
|
225
|
+
if (!selection) return
|
|
226
|
+
setExpandedFileNodes((expanded) => {
|
|
227
|
+
const next = new Set(expanded)
|
|
228
|
+
selection.expandedNodes.forEach((node) => next.add(node))
|
|
229
|
+
return next
|
|
230
|
+
})
|
|
231
|
+
setHighlighted(selection.highlightedNode)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const selectPatchFile = (fileIndex: number) => {
|
|
235
|
+
revealFileTreeFile(fileIndex)
|
|
236
|
+
setActivePatchFileIndex(fileIndex)
|
|
237
|
+
setSelectedFileIndex(fileIndex)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const scrollToFileIndex = (fileIndex: number | undefined) => {
|
|
241
|
+
if (fileIndex === undefined) return
|
|
242
|
+
selectPatchFile(fileIndex)
|
|
243
|
+
const patchNode = patchNodeByFileIndex.get(fileIndex)
|
|
244
|
+
if (patchNode) scrollPatchNodeToTop(patchNode)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const jumpToFileIndex = (fileIndex: number | undefined) => {
|
|
248
|
+
if (fileIndex === undefined) return
|
|
249
|
+
setSelectedHunk(undefined)
|
|
250
|
+
scrollToFileIndex(fileIndex)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const currentPatchFileIndex = () => {
|
|
254
|
+
if (!scroll) return undefined
|
|
255
|
+
const viewportContentY = scroll.scrollTop + 1
|
|
256
|
+
const entries = patchFileIndexes()
|
|
257
|
+
.map((fileIndex) => ({
|
|
258
|
+
fileIndex,
|
|
259
|
+
node: patchNodeByFileIndex.get(fileIndex),
|
|
260
|
+
}))
|
|
261
|
+
.filter((entry): entry is { fileIndex: number; node: BoxRenderable } => Boolean(entry.node))
|
|
262
|
+
.map((entry) => ({
|
|
263
|
+
...entry,
|
|
264
|
+
contentY: scroll!.scrollTop + entry.node.y - scroll!.viewport.y,
|
|
265
|
+
}))
|
|
266
|
+
.sort((left, right) => left.contentY - right.contentY)
|
|
267
|
+
return entries.findLast((entry) => entry.contentY <= viewportContentY)?.fileIndex ?? entries[0]?.fileIndex
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const jumpRelativePatchFile = (offset: number) => {
|
|
271
|
+
setSelectedHunk(undefined)
|
|
272
|
+
const next = movePatchFileIndex(patchFileIndexes(), selectedFileIndex() ?? activePatchFileIndex(), offset)
|
|
273
|
+
if (singlePatch()) {
|
|
274
|
+
if (next === undefined) return
|
|
275
|
+
selectPatchFile(next)
|
|
276
|
+
scrollSinglePatchToTop()
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
scrollToFileIndex(next)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const jumpRelativeHunk = (offset: -1 | 1) => {
|
|
283
|
+
const patchScroll = scroll
|
|
284
|
+
if (!patchScroll) return
|
|
285
|
+
const hunks = visiblePatchFiles()
|
|
286
|
+
.flatMap((entry) => {
|
|
287
|
+
const node = diffNodeByFileIndex.get(entry.fileIndex)
|
|
288
|
+
if (!node || node.isDestroyed) return []
|
|
289
|
+
const contentY = patchScroll.scrollTop + node.y - patchScroll.viewport.y
|
|
290
|
+
return node.diff
|
|
291
|
+
.split("\n")
|
|
292
|
+
.flatMap((line, row) => (line.startsWith("@@") ? [row] : []))
|
|
293
|
+
.map((row, hunkIndex) => ({
|
|
294
|
+
fileIndex: entry.fileIndex,
|
|
295
|
+
hunkIndex,
|
|
296
|
+
contentY: contentY + row,
|
|
297
|
+
}))
|
|
298
|
+
})
|
|
299
|
+
.sort((left, right) => left.contentY - right.contentY)
|
|
300
|
+
const selected = selectedHunk()
|
|
301
|
+
const selectedIndex =
|
|
302
|
+
selected?.scrollTop === patchScroll.scrollTop
|
|
303
|
+
? hunks.findIndex((hunk) => hunk.fileIndex === selected.fileIndex && hunk.hunkIndex === selected.hunkIndex)
|
|
304
|
+
: -1
|
|
305
|
+
const next =
|
|
306
|
+
selectedIndex !== -1
|
|
307
|
+
? hunks[selectedIndex + offset]
|
|
308
|
+
: offset === 1
|
|
309
|
+
? hunks.find((hunk) => hunk.contentY > patchScroll.scrollTop)
|
|
310
|
+
: hunks.findLast((hunk) => hunk.contentY < patchScroll.scrollTop)
|
|
311
|
+
if (!next) return
|
|
312
|
+
selectPatchFile(next.fileIndex)
|
|
313
|
+
patchScroll.scrollTo(next.contentY)
|
|
314
|
+
setSelectedHunk({ fileIndex: next.fileIndex, hunkIndex: next.hunkIndex, scrollTop: patchScroll.scrollTop })
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const highlightedPatchFileIndex = () => fileRows().find((row) => row.id === highlightedFileNode())?.fileIndex
|
|
318
|
+
const firstPatchFileIndex = () => fileRows().find((row) => row.fileIndex !== undefined)?.fileIndex
|
|
319
|
+
const visiblePatchFiles = createMemo(() => {
|
|
320
|
+
if (!singlePatch()) {
|
|
321
|
+
return patchFileIndexes().flatMap((fileIndex) => {
|
|
322
|
+
const file = files()[fileIndex]
|
|
323
|
+
return file ? [{ file, fileIndex }] : []
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
const fileIndex = singlePatchFileIndex(
|
|
327
|
+
selectedFileIndex(),
|
|
328
|
+
activePatchFileIndex(),
|
|
329
|
+
currentPatchFileIndex(),
|
|
330
|
+
firstPatchFileIndex(),
|
|
331
|
+
)
|
|
332
|
+
const file = fileIndex === undefined ? undefined : files()[fileIndex]
|
|
333
|
+
return file && fileIndex !== undefined ? [{ file, fileIndex }] : []
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
const ensureHighlightedPatchFile = () => {
|
|
337
|
+
const fileIndex = currentPatchFileIndex() ?? activePatchFileIndex() ?? firstPatchFileIndex()
|
|
338
|
+
if (fileIndex === undefined) return
|
|
339
|
+
selectPatchFile(fileIndex)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const scrollToPatchFileIndexAfterRender = (fileIndex: number) => {
|
|
343
|
+
setPendingPatchScrollFileIndex(fileIndex)
|
|
344
|
+
requestAnimationFrame(() => {
|
|
345
|
+
const patchNode = patchNodeByFileIndex.get(fileIndex)
|
|
346
|
+
if (patchNode) scrollPatchNodeToTop(patchNode)
|
|
347
|
+
requestAnimationFrame(() => {
|
|
348
|
+
const patchNode = patchNodeByFileIndex.get(fileIndex)
|
|
349
|
+
if (patchNode) scrollPatchNodeToTop(patchNode)
|
|
350
|
+
setPendingPatchScrollFileIndex(undefined)
|
|
351
|
+
})
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const scrollSinglePatchToTop = () => {
|
|
356
|
+
requestAnimationFrame(() => {
|
|
357
|
+
scroll?.scrollTo(0)
|
|
358
|
+
requestAnimationFrame(() => scroll?.scrollTo(0))
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const measurePatchFiller = () => {
|
|
363
|
+
requestAnimationFrame(() => {
|
|
364
|
+
if (!scroll) return
|
|
365
|
+
const entries = visiblePatchFiles()
|
|
366
|
+
.map((entry) => patchNodeByFileIndex.get(entry.fileIndex))
|
|
367
|
+
.filter((node): node is BoxRenderable => Boolean(node))
|
|
368
|
+
if (entries.length === 0) {
|
|
369
|
+
setPatchFillerHeight(0)
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
const contentHeight = Math.max(
|
|
373
|
+
...entries.map((node) => scroll!.scrollTop + node.y - scroll!.viewport.y + node.height),
|
|
374
|
+
)
|
|
375
|
+
setPatchFillerHeight(Math.max(0, scroll.viewport.height - contentHeight))
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const registerPatchNode = (fileIndex: number, element: BoxRenderable) => {
|
|
380
|
+
patchNodeByFileIndex.set(fileIndex, element)
|
|
381
|
+
measurePatchFiller()
|
|
382
|
+
if (pendingPatchScrollFileIndex() !== fileIndex) return
|
|
383
|
+
requestAnimationFrame(() => {
|
|
384
|
+
scrollPatchNodeToTop(element)
|
|
385
|
+
requestAnimationFrame(() => {
|
|
386
|
+
scrollPatchNodeToTop(element)
|
|
387
|
+
setPendingPatchScrollFileIndex(undefined)
|
|
388
|
+
})
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
createEffect(() => {
|
|
393
|
+
visiblePatchFiles()
|
|
394
|
+
dimensions()
|
|
395
|
+
view()
|
|
396
|
+
measurePatchFiller()
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
const toggleSelectedFileTreeRow = () => {
|
|
400
|
+
const highlighted = fileRows().find((row) => row.id === highlightedFileNode())
|
|
401
|
+
if (highlighted?.fileIndex !== undefined) {
|
|
402
|
+
jumpToFileIndex(highlighted.fileIndex)
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
setExpandedFileNodes((expanded) => toggleFileTreeDirectory(fileTree(), expanded, highlightedFileNode()))
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const clickFileTreeRow = (row: FileTreeRow) => {
|
|
409
|
+
setFocus("files")
|
|
410
|
+
setHighlighted(row.id)
|
|
411
|
+
if (row.fileIndex !== undefined) {
|
|
412
|
+
jumpToFileIndex(row.fileIndex)
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
setExpandedFileNodes((expanded) => toggleFileTreeDirectory(fileTree(), expanded, row.id))
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const toggleSelectedFileReviewed = () => {
|
|
419
|
+
const fileIndex =
|
|
420
|
+
focus() === "files"
|
|
421
|
+
? fileRows().find((row) => row.id === highlightedFileNode())?.fileIndex
|
|
422
|
+
: (selectedFileIndex() ?? activePatchFileIndex() ?? currentPatchFileIndex())
|
|
423
|
+
const file = fileIndex === undefined ? undefined : files()[fileIndex]?.file
|
|
424
|
+
if (!file) return
|
|
425
|
+
setReviewedFileNames((reviewed) => {
|
|
426
|
+
const next = new Set(reviewed)
|
|
427
|
+
if (next.has(file)) next.delete(file)
|
|
428
|
+
else next.add(file)
|
|
429
|
+
return next
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const commands = [
|
|
434
|
+
{
|
|
435
|
+
name: "diff.close",
|
|
436
|
+
title: "Close diff viewer",
|
|
437
|
+
category: "VCS",
|
|
438
|
+
run() {
|
|
439
|
+
const returnRoute = params()?.returnRoute
|
|
440
|
+
props.api.ui.dialog.clear()
|
|
441
|
+
|
|
442
|
+
props.api.route.navigate(
|
|
443
|
+
returnRoute?.name ?? "home",
|
|
444
|
+
returnRoute && "params" in returnRoute ? returnRoute.params : undefined,
|
|
445
|
+
)
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
name: "diff.down",
|
|
450
|
+
title: "Move diff viewer down",
|
|
451
|
+
category: "VCS",
|
|
452
|
+
run: focusRunner({
|
|
453
|
+
files() {
|
|
454
|
+
moveFileSelection(1)
|
|
455
|
+
},
|
|
456
|
+
patches() {
|
|
457
|
+
clearFileTreePatchState()
|
|
458
|
+
scroll?.scrollBy(1)
|
|
459
|
+
},
|
|
460
|
+
}),
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
name: "diff.up",
|
|
464
|
+
title: "Move diff viewer up",
|
|
465
|
+
category: "VCS",
|
|
466
|
+
run: focusRunner({
|
|
467
|
+
files() {
|
|
468
|
+
moveFileSelection(-1)
|
|
469
|
+
},
|
|
470
|
+
patches() {
|
|
471
|
+
clearFileTreePatchState()
|
|
472
|
+
scroll?.scrollBy(-1)
|
|
473
|
+
},
|
|
474
|
+
}),
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
name: "diff.page.down",
|
|
478
|
+
title: "Page diff viewer down",
|
|
479
|
+
category: "VCS",
|
|
480
|
+
run: focusRunner({
|
|
481
|
+
files() {
|
|
482
|
+
moveFileSelection(8)
|
|
483
|
+
},
|
|
484
|
+
patches() {
|
|
485
|
+
clearFileTreePatchState()
|
|
486
|
+
if (scroll) scroll.scrollBy(scroll.height)
|
|
487
|
+
},
|
|
488
|
+
}),
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
name: "diff.page.up",
|
|
492
|
+
title: "Page diff viewer up",
|
|
493
|
+
category: "VCS",
|
|
494
|
+
run: focusRunner({
|
|
495
|
+
files() {
|
|
496
|
+
moveFileSelection(-8)
|
|
497
|
+
},
|
|
498
|
+
patches() {
|
|
499
|
+
clearFileTreePatchState()
|
|
500
|
+
if (scroll) scroll.scrollBy(-scroll.height)
|
|
501
|
+
},
|
|
502
|
+
}),
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
name: "diff.toggle",
|
|
506
|
+
title: "Toggle diff viewer item",
|
|
507
|
+
category: "VCS",
|
|
508
|
+
run: focusRunner({
|
|
509
|
+
files() {
|
|
510
|
+
toggleSelectedFileTreeRow()
|
|
511
|
+
},
|
|
512
|
+
patches() {},
|
|
513
|
+
}),
|
|
514
|
+
},
|
|
515
|
+
{
|
|
516
|
+
name: "diff.expand",
|
|
517
|
+
title: "Expand diff viewer item",
|
|
518
|
+
category: "VCS",
|
|
519
|
+
run: focusRunner({
|
|
520
|
+
files() {
|
|
521
|
+
const highlighted = highlightedFileNode()
|
|
522
|
+
if (highlighted !== undefined && expandedFileNodes().has(highlighted)) {
|
|
523
|
+
setHighlighted(moveFileTreeSelectionToFirstChild(fileRows(), highlighted))
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
setExpandedFileNodes((expanded) =>
|
|
527
|
+
setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), true),
|
|
528
|
+
)
|
|
529
|
+
},
|
|
530
|
+
patches() {},
|
|
531
|
+
}),
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
name: "diff.expand_all",
|
|
535
|
+
title: "Expand all diff viewer folders",
|
|
536
|
+
category: "VCS",
|
|
537
|
+
run: focusRunner({
|
|
538
|
+
files() {
|
|
539
|
+
setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree()))
|
|
540
|
+
},
|
|
541
|
+
patches() {},
|
|
542
|
+
}),
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
name: "diff.collapse",
|
|
546
|
+
title: "Collapse diff viewer item",
|
|
547
|
+
category: "VCS",
|
|
548
|
+
run: focusRunner({
|
|
549
|
+
files() {
|
|
550
|
+
const highlighted = highlightedFileNode()
|
|
551
|
+
const node = highlighted === undefined ? undefined : fileTree().nodes[highlighted]
|
|
552
|
+
if (node?.kind !== "directory" || !expandedFileNodes().has(node.id)) {
|
|
553
|
+
setHighlighted(moveFileTreeSelectionToParent(fileRows(), highlighted))
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
setExpandedFileNodes((expanded) =>
|
|
557
|
+
setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), false),
|
|
558
|
+
)
|
|
559
|
+
},
|
|
560
|
+
patches() {},
|
|
561
|
+
}),
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
name: "diff.next_hunk",
|
|
565
|
+
title: "Jump to next diff hunk",
|
|
566
|
+
category: "VCS",
|
|
567
|
+
run() {
|
|
568
|
+
jumpRelativeHunk(1)
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
name: "diff.previous_hunk",
|
|
573
|
+
title: "Jump to previous diff hunk",
|
|
574
|
+
category: "VCS",
|
|
575
|
+
run() {
|
|
576
|
+
jumpRelativeHunk(-1)
|
|
577
|
+
},
|
|
578
|
+
},
|
|
579
|
+
{
|
|
580
|
+
name: "diff.next_file",
|
|
581
|
+
title: "Jump to next diff file",
|
|
582
|
+
category: "VCS",
|
|
583
|
+
run() {
|
|
584
|
+
jumpRelativePatchFile(1)
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
name: "diff.previous_file",
|
|
589
|
+
title: "Jump to previous diff file",
|
|
590
|
+
category: "VCS",
|
|
591
|
+
run() {
|
|
592
|
+
jumpRelativePatchFile(-1)
|
|
593
|
+
},
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
name: "diff.mark_reviewed",
|
|
597
|
+
title: "Toggle selected diff file reviewed",
|
|
598
|
+
category: "VCS",
|
|
599
|
+
run() {
|
|
600
|
+
toggleSelectedFileReviewed()
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
name: "diff.switch_focus",
|
|
605
|
+
title: "Switch diff viewer focus",
|
|
606
|
+
category: "VCS",
|
|
607
|
+
run() {
|
|
608
|
+
if (!showFileTree()) return
|
|
609
|
+
setFocus((current) => {
|
|
610
|
+
if (current === "files") return "patches"
|
|
611
|
+
ensureHighlightedFileNode()
|
|
612
|
+
return "files"
|
|
613
|
+
})
|
|
614
|
+
},
|
|
615
|
+
},
|
|
616
|
+
{
|
|
617
|
+
name: "diff.toggle_file_tree",
|
|
618
|
+
title: "Toggle diff viewer file tree",
|
|
619
|
+
category: "VCS",
|
|
620
|
+
run() {
|
|
621
|
+
const next = !fileTreeEnabled()
|
|
622
|
+
if (!next) setFocus("patches")
|
|
623
|
+
setFileTreeEnabled(next)
|
|
624
|
+
props.api.kv.set(KV_SHOW_FILE_TREE, next)
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
{
|
|
628
|
+
name: "diff.single_patch",
|
|
629
|
+
title: "Toggle single patch view",
|
|
630
|
+
category: "VCS",
|
|
631
|
+
run() {
|
|
632
|
+
setSelectedHunk(undefined)
|
|
633
|
+
if (!singlePatch()) {
|
|
634
|
+
ensureHighlightedPatchFile()
|
|
635
|
+
setSinglePatch(true)
|
|
636
|
+
props.api.kv.set(KV_SINGLE_PATCH, true)
|
|
637
|
+
scrollSinglePatchToTop()
|
|
638
|
+
return
|
|
639
|
+
}
|
|
640
|
+
const fileIndex =
|
|
641
|
+
visiblePatchFiles()[0]?.fileIndex ??
|
|
642
|
+
singlePatchFileIndex(
|
|
643
|
+
selectedFileIndex(),
|
|
644
|
+
activePatchFileIndex(),
|
|
645
|
+
currentPatchFileIndex(),
|
|
646
|
+
firstPatchFileIndex(),
|
|
647
|
+
)
|
|
648
|
+
if (fileIndex !== undefined) selectPatchFile(fileIndex)
|
|
649
|
+
setSinglePatch(false)
|
|
650
|
+
props.api.kv.set(KV_SINGLE_PATCH, false)
|
|
651
|
+
if (fileIndex !== undefined) scrollToPatchFileIndexAfterRender(fileIndex)
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
name: "diff.switch_source",
|
|
656
|
+
title: "Switch diff viewer source",
|
|
657
|
+
category: "VCS",
|
|
658
|
+
run() {
|
|
659
|
+
openSwitchDiffDialog()
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
name: "diff.toggle_view",
|
|
664
|
+
title: "Toggle diff viewer split or unified view",
|
|
665
|
+
category: "VCS",
|
|
666
|
+
run() {
|
|
667
|
+
if (!splitAvailable()) return
|
|
668
|
+
setSelectedHunk(undefined)
|
|
669
|
+
const next = view() === "split" ? "unified" : "split"
|
|
670
|
+
setViewOverride(next)
|
|
671
|
+
props.api.kv.set(KV_VIEW, next)
|
|
672
|
+
},
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
name: "diff.help",
|
|
676
|
+
title: "Show more diff viewer shortcuts",
|
|
677
|
+
category: "VCS",
|
|
678
|
+
run() {
|
|
679
|
+
openHelpDialog()
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
]
|
|
683
|
+
|
|
684
|
+
const switchDiffOptions = createMemo(() => {
|
|
685
|
+
const vcs = props.api.state.vcs
|
|
686
|
+
return [
|
|
687
|
+
{
|
|
688
|
+
title: "Working tree",
|
|
689
|
+
value: "git" as const,
|
|
690
|
+
description: "Show current git changes",
|
|
691
|
+
},
|
|
692
|
+
...(vcs?.branch && vcs.default_branch && vcs.branch !== vcs.default_branch
|
|
693
|
+
? [
|
|
694
|
+
{
|
|
695
|
+
title: "Main branch",
|
|
696
|
+
value: "branch" as const,
|
|
697
|
+
description: "Show changes compared to main branch",
|
|
698
|
+
},
|
|
699
|
+
]
|
|
700
|
+
: []),
|
|
701
|
+
{
|
|
702
|
+
title: "Last turn",
|
|
703
|
+
value: "last-turn" as const,
|
|
704
|
+
description: "Show changes from the last assistant turn",
|
|
705
|
+
},
|
|
706
|
+
]
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
const openSwitchDiffDialog = () => {
|
|
710
|
+
props.api.ui.dialog.replace(() => (
|
|
711
|
+
<DialogSelect
|
|
712
|
+
title="Switch source"
|
|
713
|
+
skipFilter={true}
|
|
714
|
+
renderFilter={false}
|
|
715
|
+
current={mode()}
|
|
716
|
+
options={switchDiffOptions().map((option) => ({
|
|
717
|
+
...option,
|
|
718
|
+
onSelect(dialog) {
|
|
719
|
+
dialog.clear()
|
|
720
|
+
props.api.route.navigate(ROUTE, {
|
|
721
|
+
mode: option.value,
|
|
722
|
+
sessionID: params()?.sessionID,
|
|
723
|
+
messageID: params()?.messageID,
|
|
724
|
+
returnRoute: params()?.returnRoute,
|
|
725
|
+
})
|
|
726
|
+
},
|
|
727
|
+
}))}
|
|
728
|
+
/>
|
|
729
|
+
))
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const openHelpDialog = () => {
|
|
733
|
+
props.api.ui.dialog.replace(() => <DiffViewerHelpDialog />)
|
|
734
|
+
props.api.ui.dialog.setSize("large")
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
useBindings(() => ({
|
|
738
|
+
commands,
|
|
739
|
+
bindings: [
|
|
740
|
+
{ key: "j,down", cmd: "diff.down", desc: "Move diff viewer down" },
|
|
741
|
+
{ key: "k,up", cmd: "diff.up", desc: "Move diff viewer up" },
|
|
742
|
+
{ key: "pagedown,ctrl+f", cmd: "diff.page.down", desc: "Page diff viewer down" },
|
|
743
|
+
{ key: "pageup,ctrl+b", cmd: "diff.page.up", desc: "Page diff viewer up" },
|
|
744
|
+
{ key: "m", cmd: "diff.mark_reviewed", desc: "Mark selected file reviewed" },
|
|
745
|
+
...props.api.tuiConfig.keybinds.gather(
|
|
746
|
+
"diff",
|
|
747
|
+
commands.map((command) => command.name),
|
|
748
|
+
),
|
|
749
|
+
],
|
|
750
|
+
}))
|
|
751
|
+
|
|
752
|
+
return (
|
|
753
|
+
<box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
|
|
754
|
+
<PanelGroup axis="y" width="100%" height="100%">
|
|
755
|
+
<Panel border="none" flexShrink={0} padding={0} paddingLeft={1}>
|
|
756
|
+
<text fg={theme().text}>Diff </text>
|
|
757
|
+
<text fg={theme().textMuted}>{diffSourceLabel(mode())}</text>
|
|
758
|
+
<box flexGrow={1} />
|
|
759
|
+
<text fg={theme().textMuted}>
|
|
760
|
+
{files().length} {files().length === 1 ? "file" : "files"}
|
|
761
|
+
</text>
|
|
762
|
+
</Panel>
|
|
763
|
+
|
|
764
|
+
<box flexGrow={1} minHeight={0}>
|
|
765
|
+
<Switch>
|
|
766
|
+
<Match when={diff.loading}>
|
|
767
|
+
<Separator axis="x" />
|
|
768
|
+
<box flexGrow={1} paddingLeft={1}>
|
|
769
|
+
<text fg={theme().textMuted}>Loading diff...</text>
|
|
770
|
+
</box>
|
|
771
|
+
</Match>
|
|
772
|
+
<Match when={!diff.loading && files().length === 0}>
|
|
773
|
+
<Separator axis="x" />
|
|
774
|
+
<box flexGrow={1} paddingLeft={1}>
|
|
775
|
+
<text fg={theme().textMuted}>No diff!</text>
|
|
776
|
+
</box>
|
|
777
|
+
</Match>
|
|
778
|
+
<Match when={!diff.loading && diff.error}>
|
|
779
|
+
<Separator axis="x" />
|
|
780
|
+
<box flexGrow={1} paddingLeft={1}>
|
|
781
|
+
<text fg={theme().error}>Failed to load diff</text>
|
|
782
|
+
</box>
|
|
783
|
+
</Match>
|
|
784
|
+
<Match when={!diff.loading}>
|
|
785
|
+
<PanelGroup axis="x">
|
|
786
|
+
<Show when={showFileTree()}>
|
|
787
|
+
<DiffViewerFileTree
|
|
788
|
+
files={files()}
|
|
789
|
+
loading={diff.loading}
|
|
790
|
+
error={diff.error}
|
|
791
|
+
theme={theme()}
|
|
792
|
+
focused={focus() === "files"}
|
|
793
|
+
width={FILE_TREE_WIDTH}
|
|
794
|
+
highlightedNode={highlightedFileNode()}
|
|
795
|
+
selectedFileIndex={selectedFileIndex()}
|
|
796
|
+
reviewedFileNames={reviewedFileNames()}
|
|
797
|
+
expandedNodes={expandedFileNodes()}
|
|
798
|
+
onRowClick={clickFileTreeRow}
|
|
799
|
+
/>
|
|
800
|
+
</Show>
|
|
801
|
+
|
|
802
|
+
<Panel flexGrow={1} minHeight={0} border="none">
|
|
803
|
+
<Separator axis="x" start={showFileTree() ? "edge-out" : undefined} />
|
|
804
|
+
<scrollbox
|
|
805
|
+
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
|
806
|
+
flexGrow={1}
|
|
807
|
+
minHeight={0}
|
|
808
|
+
scrollAcceleration={patchScrollAcceleration()}
|
|
809
|
+
verticalScrollbarOptions={{ visible: false }}
|
|
810
|
+
horizontalScrollbarOptions={{ visible: false }}
|
|
811
|
+
>
|
|
812
|
+
<For each={visiblePatchFiles()}>
|
|
813
|
+
{(entry, index) => {
|
|
814
|
+
const reviewed = () => reviewedFileNames().has(entry.file.file)
|
|
815
|
+
return (
|
|
816
|
+
<box ref={(element: BoxRenderable) => registerPatchNode(entry.fileIndex, element)}>
|
|
817
|
+
{index() !== 0 ? <Separator axis="x" start={showFileTree() ? "edge" : undefined} /> : null}
|
|
818
|
+
<box
|
|
819
|
+
flexDirection="row"
|
|
820
|
+
gap={1}
|
|
821
|
+
flexShrink={0}
|
|
822
|
+
paddingLeft={1}
|
|
823
|
+
paddingRight={1}
|
|
824
|
+
border={patchLeftBorder()}
|
|
825
|
+
borderColor={theme().border}
|
|
826
|
+
>
|
|
827
|
+
<text fg={reviewed() ? theme().textMuted : theme().text}>{entry.file.file}</text>
|
|
828
|
+
<box flexGrow={1} />
|
|
829
|
+
<text fg={reviewed() ? theme().textMuted : theme().diffAdded}>
|
|
830
|
+
+{entry.file.additions}
|
|
831
|
+
</text>
|
|
832
|
+
<text fg={reviewed() ? theme().textMuted : theme().diffRemoved}>
|
|
833
|
+
-{entry.file.deletions}
|
|
834
|
+
</text>
|
|
835
|
+
</box>
|
|
836
|
+
<Separator axis="x" start={showFileTree() ? "edge" : undefined} />
|
|
837
|
+
<Show
|
|
838
|
+
when={entry.file.patch}
|
|
839
|
+
fallback={<text fg={theme().textMuted}>No patch available for this file.</text>}
|
|
840
|
+
>
|
|
841
|
+
{(patch) => (
|
|
842
|
+
<box border={patchLeftBorder()} borderColor={theme().border}>
|
|
843
|
+
<diff
|
|
844
|
+
ref={(element: DiffRenderable) => diffNodeByFileIndex.set(entry.fileIndex, element)}
|
|
845
|
+
diff={patch()}
|
|
846
|
+
view={view()}
|
|
847
|
+
filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
|
|
848
|
+
syntaxStyle={themeState.syntax()}
|
|
849
|
+
showLineNumbers={true}
|
|
850
|
+
width="100%"
|
|
851
|
+
wrapMode="char"
|
|
852
|
+
fg={reviewed() ? theme().textMuted : theme().text}
|
|
853
|
+
addedBg={reviewed() ? theme().backgroundElement : theme().diffAddedBg}
|
|
854
|
+
removedBg={reviewed() ? theme().backgroundElement : theme().diffRemovedBg}
|
|
855
|
+
addedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightAdded}
|
|
856
|
+
removedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightRemoved}
|
|
857
|
+
lineNumberFg={theme().diffLineNumber}
|
|
858
|
+
addedLineNumberBg={
|
|
859
|
+
reviewed() ? theme().backgroundElement : theme().diffAddedLineNumberBg
|
|
860
|
+
}
|
|
861
|
+
removedLineNumberBg={
|
|
862
|
+
reviewed() ? theme().backgroundElement : theme().diffRemovedLineNumberBg
|
|
863
|
+
}
|
|
864
|
+
/>
|
|
865
|
+
</box>
|
|
866
|
+
)}
|
|
867
|
+
</Show>
|
|
868
|
+
</box>
|
|
869
|
+
)
|
|
870
|
+
}}
|
|
871
|
+
</For>
|
|
872
|
+
<Show when={patchFillerHeight() > 0}>
|
|
873
|
+
<box height={patchFillerHeight()} border={patchLeftBorder()} borderColor={theme().border} />
|
|
874
|
+
</Show>
|
|
875
|
+
</scrollbox>
|
|
876
|
+
<Separator axis="x" start={showFileTree() ? "edge-in" : undefined} />
|
|
877
|
+
</Panel>
|
|
878
|
+
</PanelGroup>
|
|
879
|
+
</Match>
|
|
880
|
+
</Switch>
|
|
881
|
+
</box>
|
|
882
|
+
|
|
883
|
+
<Panel flexShrink={0} gap={2} paddingLeft={1} border="none">
|
|
884
|
+
<Show when={switchFocusShortcut()}>
|
|
885
|
+
{(shortcut) => (
|
|
886
|
+
<text fg={theme().text}>
|
|
887
|
+
{shortcut()} <span style={{ fg: theme().textMuted }}>focus file tree</span>
|
|
888
|
+
</text>
|
|
889
|
+
)}
|
|
890
|
+
</Show>
|
|
891
|
+
<Show when={nextFileShortcut()}>
|
|
892
|
+
{(shortcut) => (
|
|
893
|
+
<text fg={theme().text}>
|
|
894
|
+
{shortcut()} <span style={{ fg: theme().textMuted }}>next file</span>
|
|
895
|
+
</text>
|
|
896
|
+
)}
|
|
897
|
+
</Show>
|
|
898
|
+
<Show when={nextHunkShortcut()}>
|
|
899
|
+
{(shortcut) => (
|
|
900
|
+
<text fg={theme().text}>
|
|
901
|
+
{shortcut()} <span style={{ fg: theme().textMuted }}>next hunk</span>
|
|
902
|
+
</text>
|
|
903
|
+
)}
|
|
904
|
+
</Show>
|
|
905
|
+
<Show when={previousHunkShortcut()}>
|
|
906
|
+
{(shortcut) => (
|
|
907
|
+
<text fg={theme().text}>
|
|
908
|
+
{shortcut()} <span style={{ fg: theme().textMuted }}>previous hunk</span>
|
|
909
|
+
</text>
|
|
910
|
+
)}
|
|
911
|
+
</Show>
|
|
912
|
+
<Show when={previousFileShortcut()}>
|
|
913
|
+
{(shortcut) => (
|
|
914
|
+
<text fg={theme().text}>
|
|
915
|
+
{shortcut()} <span style={{ fg: theme().textMuted }}>previous file</span>
|
|
916
|
+
</text>
|
|
917
|
+
)}
|
|
918
|
+
</Show>
|
|
919
|
+
<Show when={switchSourceShortcut()}>
|
|
920
|
+
{(shortcut) => (
|
|
921
|
+
<text fg={theme().text}>
|
|
922
|
+
{shortcut()} <span style={{ fg: theme().textMuted }}>switch source</span>
|
|
923
|
+
</text>
|
|
924
|
+
)}
|
|
925
|
+
</Show>
|
|
926
|
+
<Show when={markReviewedShortcut()}>
|
|
927
|
+
{(shortcut) => (
|
|
928
|
+
<text fg={theme().text}>
|
|
929
|
+
{shortcut()} <span style={{ fg: theme().textMuted }}>mark reviewed</span>
|
|
930
|
+
</text>
|
|
931
|
+
)}
|
|
932
|
+
</Show>
|
|
933
|
+
<Show when={helpShortcut()}>
|
|
934
|
+
{(shortcut) => (
|
|
935
|
+
<text fg={theme().text}>
|
|
936
|
+
{shortcut()} <span style={{ fg: theme().textMuted }}>all</span>
|
|
937
|
+
</text>
|
|
938
|
+
)}
|
|
939
|
+
</Show>
|
|
940
|
+
</Panel>
|
|
941
|
+
</PanelGroup>
|
|
942
|
+
</box>
|
|
943
|
+
)
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function DiffViewerHelpDialog() {
|
|
947
|
+
const { theme } = useTheme()
|
|
948
|
+
const rows = [
|
|
949
|
+
{
|
|
950
|
+
shortcut: () => "q",
|
|
951
|
+
action: "Close viewer",
|
|
952
|
+
description: "Quit the diff viewer",
|
|
953
|
+
},
|
|
954
|
+
{
|
|
955
|
+
shortcut: useCommandShortcut("diff.switch_focus"),
|
|
956
|
+
action: "Focus file tree",
|
|
957
|
+
description: "Move keyboard focus between the file tree and patch pane",
|
|
958
|
+
},
|
|
959
|
+
{
|
|
960
|
+
shortcut: useCommandShortcut("diff.next_hunk"),
|
|
961
|
+
action: "Next hunk",
|
|
962
|
+
description: "Jump to the next diff hunk",
|
|
963
|
+
},
|
|
964
|
+
{
|
|
965
|
+
shortcut: useCommandShortcut("diff.previous_hunk"),
|
|
966
|
+
action: "Previous hunk",
|
|
967
|
+
description: "Jump to the previous diff hunk",
|
|
968
|
+
},
|
|
969
|
+
{
|
|
970
|
+
shortcut: useCommandShortcut("diff.next_file"),
|
|
971
|
+
action: "Next file",
|
|
972
|
+
description: "Select the next changed file in file-tree order",
|
|
973
|
+
},
|
|
974
|
+
{
|
|
975
|
+
shortcut: useCommandShortcut("diff.previous_file"),
|
|
976
|
+
action: "Previous file",
|
|
977
|
+
description: "Select the previous changed file in file-tree order",
|
|
978
|
+
},
|
|
979
|
+
{
|
|
980
|
+
shortcut: useCommandShortcut("diff.toggle_file_tree"),
|
|
981
|
+
action: "Toggle file tree",
|
|
982
|
+
description: "Show or hide the file tree sidebar",
|
|
983
|
+
},
|
|
984
|
+
{
|
|
985
|
+
shortcut: useCommandShortcut("diff.single_patch"),
|
|
986
|
+
action: "Toggle patches",
|
|
987
|
+
description: "Switch between one selected patch and all patches",
|
|
988
|
+
},
|
|
989
|
+
{
|
|
990
|
+
shortcut: useCommandShortcut("diff.switch_source"),
|
|
991
|
+
action: "Switch source",
|
|
992
|
+
description: "Choose working tree, main branch, or last-turn changes",
|
|
993
|
+
},
|
|
994
|
+
{
|
|
995
|
+
shortcut: useCommandShortcut("diff.toggle_view"),
|
|
996
|
+
action: "Toggle view",
|
|
997
|
+
description: "Switch between split and unified diff layout",
|
|
998
|
+
},
|
|
999
|
+
{
|
|
1000
|
+
shortcut: useCommandShortcut("diff.expand_all"),
|
|
1001
|
+
action: "Expand all folders",
|
|
1002
|
+
description: "Open every folder in the file tree",
|
|
1003
|
+
},
|
|
1004
|
+
{
|
|
1005
|
+
shortcut: useCommandShortcut("diff.mark_reviewed"),
|
|
1006
|
+
action: "Mark reviewed",
|
|
1007
|
+
description: "Toggle reviewed state for the selected file",
|
|
1008
|
+
},
|
|
1009
|
+
]
|
|
1010
|
+
|
|
1011
|
+
return (
|
|
1012
|
+
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
|
|
1013
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
1014
|
+
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
|
1015
|
+
Diff shortcuts
|
|
1016
|
+
</text>
|
|
1017
|
+
<text fg={theme.textMuted}>esc</text>
|
|
1018
|
+
</box>
|
|
1019
|
+
<box flexDirection="row">
|
|
1020
|
+
<text fg={theme.textMuted} width={5} wrapMode="none">
|
|
1021
|
+
Key
|
|
1022
|
+
</text>
|
|
1023
|
+
<text fg={theme.textMuted} width={22} wrapMode="none">
|
|
1024
|
+
Action
|
|
1025
|
+
</text>
|
|
1026
|
+
<text fg={theme.textMuted}>Description</text>
|
|
1027
|
+
</box>
|
|
1028
|
+
<For each={rows}>
|
|
1029
|
+
{(row) => (
|
|
1030
|
+
<box flexDirection="row">
|
|
1031
|
+
<text fg={theme.text} width={5} wrapMode="none">
|
|
1032
|
+
{row.shortcut() || "-"}
|
|
1033
|
+
</text>
|
|
1034
|
+
<text fg={theme.text} width={22} wrapMode="none">
|
|
1035
|
+
{row.action}
|
|
1036
|
+
</text>
|
|
1037
|
+
<text fg={theme.textMuted}>{row.description}</text>
|
|
1038
|
+
</box>
|
|
1039
|
+
)}
|
|
1040
|
+
</For>
|
|
1041
|
+
</box>
|
|
1042
|
+
)
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const tui: TuiPlugin = async (api) => {
|
|
1046
|
+
api.route.register([
|
|
1047
|
+
{
|
|
1048
|
+
name: ROUTE,
|
|
1049
|
+
render: () => <DiffViewer api={api} />,
|
|
1050
|
+
},
|
|
1051
|
+
])
|
|
1052
|
+
|
|
1053
|
+
api.keymap.registerLayer({
|
|
1054
|
+
commands: [
|
|
1055
|
+
{
|
|
1056
|
+
name: "diff.open",
|
|
1057
|
+
title: "Open diff viewer",
|
|
1058
|
+
slashName: "diff",
|
|
1059
|
+
category: "VCS",
|
|
1060
|
+
namespace: "palette",
|
|
1061
|
+
run() {
|
|
1062
|
+
api.route.navigate(ROUTE, {
|
|
1063
|
+
mode: "git",
|
|
1064
|
+
sessionID: "params" in api.route.current ? api.route.current.params?.sessionID : undefined,
|
|
1065
|
+
returnRoute: api.route.current,
|
|
1066
|
+
})
|
|
1067
|
+
api.ui.dialog.clear()
|
|
1068
|
+
},
|
|
1069
|
+
},
|
|
1070
|
+
],
|
|
1071
|
+
})
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
export default {
|
|
1075
|
+
id: "diff-viewer",
|
|
1076
|
+
tui,
|
|
1077
|
+
}
|