@kitlangton/ghui 0.1.20 → 0.1.22
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/package.json +17 -6
- package/src/App.tsx +617 -767
- package/src/appCommands.ts +54 -12
- package/src/commands.ts +5 -0
- package/src/domain.ts +2 -0
- package/src/index.tsx +7 -1
- package/src/keyboard/createKeymap.ts +25 -0
- package/src/keyboard/useAppCommandRegistry.ts +30 -0
- package/src/keyboard/useScopedBindings.ts +66 -0
- package/src/services/GitHubService.ts +1 -0
- package/src/ui/CommandPalette.tsx +33 -10
- package/src/ui/DetailsPane.tsx +48 -22
- package/src/ui/FooterHints.tsx +9 -18
- package/src/ui/PullRequestDiffPane.tsx +15 -10
- package/src/ui/PullRequestList.tsx +6 -2
- package/src/ui/diff.ts +6 -0
package/src/appCommands.ts
CHANGED
|
@@ -21,7 +21,9 @@ interface AppCommandActions {
|
|
|
21
21
|
readonly toggleDiffRenderView: () => void
|
|
22
22
|
readonly toggleDiffWrapMode: () => void
|
|
23
23
|
readonly jumpDiffFile: (delta: 1 | -1) => void
|
|
24
|
-
readonly
|
|
24
|
+
readonly openSelectedDiffComment: () => void
|
|
25
|
+
readonly toggleDiffCommentRange: () => void
|
|
26
|
+
readonly moveDiffCommentThread: (delta: 1 | -1) => void
|
|
25
27
|
readonly openDiffCommentModal: () => void
|
|
26
28
|
readonly togglePullRequestDraftStatus: () => void
|
|
27
29
|
readonly openLabelModal: () => void
|
|
@@ -50,8 +52,10 @@ interface BuildAppCommandsInput {
|
|
|
50
52
|
readonly diffWrapMode: DiffWrapMode
|
|
51
53
|
readonly readyDiffFileCount: number
|
|
52
54
|
readonly diffFileIndex: number
|
|
53
|
-
readonly
|
|
55
|
+
readonly diffRangeActive: boolean
|
|
54
56
|
readonly selectedDiffCommentAnchorLabel: string | null
|
|
57
|
+
readonly selectedDiffCommentThreadCount: number
|
|
58
|
+
readonly hasDiffCommentThreads: boolean
|
|
55
59
|
readonly actions: AppCommandActions
|
|
56
60
|
}
|
|
57
61
|
|
|
@@ -73,8 +77,10 @@ export const buildAppCommands = ({
|
|
|
73
77
|
diffWrapMode,
|
|
74
78
|
readyDiffFileCount,
|
|
75
79
|
diffFileIndex,
|
|
76
|
-
|
|
80
|
+
diffRangeActive,
|
|
77
81
|
selectedDiffCommentAnchorLabel,
|
|
82
|
+
selectedDiffCommentThreadCount,
|
|
83
|
+
hasDiffCommentThreads,
|
|
78
84
|
actions,
|
|
79
85
|
}: BuildAppCommandsInput): readonly AppCommand[] => {
|
|
80
86
|
const selectedPullRequestLabel = selectedPullRequest ? `#${selectedPullRequest.number} ${selectedPullRequest.repository}` : "No pull request selected"
|
|
@@ -84,6 +90,12 @@ export const buildAppCommands = ({
|
|
|
84
90
|
? diffReady ? null : "Load the diff before running this command."
|
|
85
91
|
: noPullRequestReason
|
|
86
92
|
const diffOpenReadyReason = diffFullView ? diffReadyReason : "Open a diff first."
|
|
93
|
+
const selectedDiffLineReason = diffFullView && diffReady
|
|
94
|
+
? selectedDiffCommentAnchorLabel ? null : "No diff line selected."
|
|
95
|
+
: diffOpenReadyReason
|
|
96
|
+
const diffThreadReason = diffFullView && diffReady
|
|
97
|
+
? hasDiffCommentThreads ? null : "No diff comments loaded."
|
|
98
|
+
: diffOpenReadyReason
|
|
87
99
|
const loadMoreDisabledReason = isLoadingMorePullRequests
|
|
88
100
|
? "Already loading more pull requests."
|
|
89
101
|
: hasMorePullRequests ? null : "No more pull requests loaded by this view."
|
|
@@ -219,7 +231,7 @@ export const buildAppCommands = ({
|
|
|
219
231
|
title: "Toggle diff split/unified view",
|
|
220
232
|
scope: "Diff",
|
|
221
233
|
subtitle: effectiveDiffRenderView === "split" ? "Switch to unified view" : "Switch to split view",
|
|
222
|
-
shortcut: "v",
|
|
234
|
+
shortcut: "shift-v",
|
|
223
235
|
disabledReason: diffFullView ? null : "Open a diff first.",
|
|
224
236
|
run: actions.toggleDiffRenderView,
|
|
225
237
|
}),
|
|
@@ -251,14 +263,44 @@ export const buildAppCommands = ({
|
|
|
251
263
|
run: () => actions.jumpDiffFile(-1),
|
|
252
264
|
}),
|
|
253
265
|
defineCommand({
|
|
254
|
-
id: "diff.comment-
|
|
255
|
-
title:
|
|
266
|
+
id: "diff.open-comment-target",
|
|
267
|
+
title: selectedDiffCommentThreadCount > 0 ? "Open selected diff thread" : "Comment on selected diff line",
|
|
268
|
+
scope: "Diff",
|
|
269
|
+
subtitle: selectedDiffCommentAnchorLabel ?? "No diff line selected",
|
|
270
|
+
shortcut: "enter",
|
|
271
|
+
disabledReason: selectedDiffLineReason,
|
|
272
|
+
keywords: ["review", "comment", "thread", "line"],
|
|
273
|
+
run: actions.openSelectedDiffComment,
|
|
274
|
+
}),
|
|
275
|
+
defineCommand({
|
|
276
|
+
id: "diff.toggle-range",
|
|
277
|
+
title: diffRangeActive ? "Clear diff comment range" : "Start diff comment range",
|
|
278
|
+
scope: "Diff",
|
|
279
|
+
subtitle: selectedDiffCommentAnchorLabel ?? "No diff line selected",
|
|
280
|
+
shortcut: "v",
|
|
281
|
+
disabledReason: selectedDiffLineReason,
|
|
282
|
+
keywords: ["review", "comment", "range", "visual"],
|
|
283
|
+
run: actions.toggleDiffCommentRange,
|
|
284
|
+
}),
|
|
285
|
+
defineCommand({
|
|
286
|
+
id: "diff.next-thread",
|
|
287
|
+
title: "Next diff thread",
|
|
288
|
+
scope: "Diff",
|
|
289
|
+
subtitle: hasDiffCommentThreads ? "Jump to the next commented line" : "No diff comments loaded",
|
|
290
|
+
shortcut: "n",
|
|
291
|
+
disabledReason: diffThreadReason,
|
|
292
|
+
keywords: ["review", "comment", "thread"],
|
|
293
|
+
run: () => actions.moveDiffCommentThread(1),
|
|
294
|
+
}),
|
|
295
|
+
defineCommand({
|
|
296
|
+
id: "diff.previous-thread",
|
|
297
|
+
title: "Previous diff thread",
|
|
256
298
|
scope: "Diff",
|
|
257
|
-
subtitle:
|
|
258
|
-
shortcut: "
|
|
259
|
-
disabledReason:
|
|
260
|
-
keywords: ["review", "comment", "
|
|
261
|
-
run: actions.
|
|
299
|
+
subtitle: hasDiffCommentThreads ? "Jump to the previous commented line" : "No diff comments loaded",
|
|
300
|
+
shortcut: "p",
|
|
301
|
+
disabledReason: diffThreadReason,
|
|
302
|
+
keywords: ["review", "comment", "thread"],
|
|
303
|
+
run: () => actions.moveDiffCommentThread(-1),
|
|
262
304
|
}),
|
|
263
305
|
defineCommand({
|
|
264
306
|
id: "diff.add-comment",
|
|
@@ -266,7 +308,7 @@ export const buildAppCommands = ({
|
|
|
266
308
|
scope: "Diff",
|
|
267
309
|
subtitle: selectedDiffCommentAnchorLabel ?? "No diff line selected",
|
|
268
310
|
shortcut: "a",
|
|
269
|
-
disabledReason:
|
|
311
|
+
disabledReason: selectedDiffLineReason,
|
|
270
312
|
keywords: ["review", "reply"],
|
|
271
313
|
run: actions.openDiffCommentModal,
|
|
272
314
|
}),
|
package/src/commands.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export type CommandScope = "Global" | "View" | "Pull request" | "Diff" | "Navigation" | "System"
|
|
2
2
|
|
|
3
|
+
const SCOPE_ORDER: readonly CommandScope[] = ["Global", "View", "Pull request", "Diff", "Navigation", "System"]
|
|
4
|
+
|
|
5
|
+
export const sortCommandsByScope = (commands: readonly AppCommand[]) =>
|
|
6
|
+
[...commands].sort((left, right) => SCOPE_ORDER.indexOf(left.scope) - SCOPE_ORDER.indexOf(right.scope))
|
|
7
|
+
|
|
3
8
|
export interface AppCommand {
|
|
4
9
|
readonly id: string
|
|
5
10
|
readonly title: string
|
package/src/domain.ts
CHANGED
package/src/index.tsx
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { addDefaultParsers, createCliRenderer, createTerminalPalette } from "@opentui/core"
|
|
4
4
|
import { RegistryProvider } from "@effect/atom-react"
|
|
5
5
|
import { createRoot } from "@opentui/react"
|
|
6
|
+
import { KeymapProvider } from "@opentui/keymap/react"
|
|
7
|
+
import { createKeymap } from "./keyboard/createKeymap.js"
|
|
6
8
|
|
|
7
9
|
process.env.OTUI_USE_ALTERNATE_SCREEN = "true"
|
|
8
10
|
|
|
@@ -43,8 +45,12 @@ const renderer = await createCliRenderer({
|
|
|
43
45
|
|
|
44
46
|
process.stdout.write(FOCUS_REPORTING_ENABLE)
|
|
45
47
|
|
|
48
|
+
const keymap = createKeymap(renderer)
|
|
49
|
+
|
|
46
50
|
createRoot(renderer).render(
|
|
47
51
|
<RegistryProvider>
|
|
48
|
-
<
|
|
52
|
+
<KeymapProvider keymap={keymap}>
|
|
53
|
+
<App />
|
|
54
|
+
</KeymapProvider>
|
|
49
55
|
</RegistryProvider>,
|
|
50
56
|
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { CliRenderer } from "@opentui/core"
|
|
2
|
+
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates the OpenTUI keymap with ghui's customizations.
|
|
6
|
+
*
|
|
7
|
+
* The default emacs-style parser only treats whitespace-separated input as a
|
|
8
|
+
* sequence when at least one stroke has a "+" modifier. Prepend a parser that
|
|
9
|
+
* handles plain plain-key sequences ("g g") so vim-style multi-stroke bindings
|
|
10
|
+
* can be authored directly.
|
|
11
|
+
*/
|
|
12
|
+
export const createKeymap = (renderer: CliRenderer) => {
|
|
13
|
+
const keymap = createDefaultOpenTuiKeymap(renderer)
|
|
14
|
+
keymap.prependBindingParser(({ input, index, parseObjectKey }) => {
|
|
15
|
+
if (index !== 0) return undefined
|
|
16
|
+
const strokes = input.trim().split(/\s+/).filter(Boolean)
|
|
17
|
+
if (strokes.length <= 1) return undefined
|
|
18
|
+
if (strokes.some((stroke) => stroke.includes("+"))) return undefined
|
|
19
|
+
return {
|
|
20
|
+
parts: strokes.map((stroke) => parseObjectKey({ name: stroke.toLowerCase() })),
|
|
21
|
+
nextIndex: input.length,
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
return keymap
|
|
25
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useBindings } from "@opentui/keymap/react"
|
|
2
|
+
import type { RefObject } from "react"
|
|
3
|
+
import { useRef } from "react"
|
|
4
|
+
import type { AppCommand } from "../commands.js"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Registers each AppCommand under its ID as a named keymap command, so
|
|
8
|
+
* bindings can reference them by ID (`cmd: "pull.refresh"`) and the keymap's
|
|
9
|
+
* introspection (queryCommands, useActiveKeys) sees our commands.
|
|
10
|
+
*
|
|
11
|
+
* The set of IDs is captured at first render — adding to the static list
|
|
12
|
+
* later in the session would not be picked up.
|
|
13
|
+
*/
|
|
14
|
+
export const useAppCommandRegistry = (
|
|
15
|
+
appCommands: readonly AppCommand[],
|
|
16
|
+
runCommandByIdRef: RefObject<(id: string, options?: { readonly notifyDisabled?: boolean }) => boolean>,
|
|
17
|
+
) => {
|
|
18
|
+
const idsRef = useRef(appCommands.map((command) => command.id))
|
|
19
|
+
|
|
20
|
+
useBindings(() => ({
|
|
21
|
+
commands: idsRef.current.map((id) => ({
|
|
22
|
+
name: id,
|
|
23
|
+
run: () => {
|
|
24
|
+
runCommandByIdRef.current(id)
|
|
25
|
+
return true
|
|
26
|
+
},
|
|
27
|
+
})),
|
|
28
|
+
bindings: [],
|
|
29
|
+
}), [])
|
|
30
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useBindings } from "@opentui/keymap/react"
|
|
2
|
+
import { useRef } from "react"
|
|
3
|
+
|
|
4
|
+
export type ScopedBindingAction = (() => void) | string
|
|
5
|
+
|
|
6
|
+
export interface ScopedBindingsOptions {
|
|
7
|
+
readonly when: boolean
|
|
8
|
+
readonly bindings: Readonly<Record<string, ScopedBindingAction>>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Wraps `@opentui/keymap/react`'s `useBindings` so callers don't have to write
|
|
13
|
+
* the ref dance themselves. The layer registers exactly once (deps=[]); the
|
|
14
|
+
* `when` flag and the action callbacks read latest values via refs, so the
|
|
15
|
+
* binding-shape is captured at first render but closures stay fresh.
|
|
16
|
+
*
|
|
17
|
+
* Don't change which keys you bind across renders — only what they do.
|
|
18
|
+
*/
|
|
19
|
+
export const useScopedBindings = ({ when, bindings }: ScopedBindingsOptions): void => {
|
|
20
|
+
const activeRef = useRef(false)
|
|
21
|
+
activeRef.current = when
|
|
22
|
+
|
|
23
|
+
const actionsRef = useRef(bindings)
|
|
24
|
+
actionsRef.current = bindings
|
|
25
|
+
|
|
26
|
+
useBindings(() => ({
|
|
27
|
+
enabled: () => activeRef.current,
|
|
28
|
+
bindings: Object.entries(bindings).map(([key, action]) => ({
|
|
29
|
+
key,
|
|
30
|
+
cmd: typeof action === "string" ? action : () => {
|
|
31
|
+
const current = actionsRef.current[key]
|
|
32
|
+
if (typeof current === "function") current()
|
|
33
|
+
},
|
|
34
|
+
})),
|
|
35
|
+
}), [])
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Vim-style scroll bindings: j/k/up/down for line scroll, ctrl-u/d/v + pageup/down
|
|
40
|
+
* for half-page scroll. When `scrollTo` is given, also adds home/end and gg/G to
|
|
41
|
+
* jump to start/end.
|
|
42
|
+
*/
|
|
43
|
+
export const scrollBindings = (
|
|
44
|
+
scrollBy: (delta: number) => void,
|
|
45
|
+
halfPage: number,
|
|
46
|
+
scrollTo?: (y: number) => void,
|
|
47
|
+
): Record<string, ScopedBindingAction> => {
|
|
48
|
+
const bindings: Record<string, ScopedBindingAction> = {
|
|
49
|
+
up: () => scrollBy(-1),
|
|
50
|
+
k: () => scrollBy(-1),
|
|
51
|
+
down: () => scrollBy(1),
|
|
52
|
+
j: () => scrollBy(1),
|
|
53
|
+
pageup: () => scrollBy(-halfPage),
|
|
54
|
+
pagedown: () => scrollBy(halfPage),
|
|
55
|
+
"ctrl+u": () => scrollBy(-halfPage),
|
|
56
|
+
"ctrl+d": () => scrollBy(halfPage),
|
|
57
|
+
"ctrl+v": () => scrollBy(halfPage),
|
|
58
|
+
}
|
|
59
|
+
if (scrollTo) {
|
|
60
|
+
bindings.home = () => scrollTo(0)
|
|
61
|
+
bindings.end = () => scrollTo(Number.MAX_SAFE_INTEGER)
|
|
62
|
+
bindings["g g"] = () => scrollTo(0)
|
|
63
|
+
bindings["shift+g"] = () => scrollTo(Number.MAX_SAFE_INTEGER)
|
|
64
|
+
}
|
|
65
|
+
return bindings
|
|
66
|
+
}
|
|
@@ -663,6 +663,7 @@ export class GitHubService extends Context.Service<GitHubService, {
|
|
|
663
663
|
"-f", `path=${input.path}`,
|
|
664
664
|
"-F", `line=${input.line}`,
|
|
665
665
|
"-f", `side=${input.side}`,
|
|
666
|
+
...(input.startLine === undefined ? [] : ["-F", `start_line=${input.startLine}`, "-f", `start_side=${input.startSide ?? input.side}`]),
|
|
666
667
|
])
|
|
667
668
|
return parsePullRequestComment(response) ?? fallbackCreatedComment(input)
|
|
668
669
|
})
|
|
@@ -17,6 +17,7 @@ const scopeLabels = {
|
|
|
17
17
|
|
|
18
18
|
export type CommandPaletteRow =
|
|
19
19
|
| { readonly _tag: "section"; readonly scope: AppCommand["scope"] }
|
|
20
|
+
| { readonly _tag: "spacer" }
|
|
20
21
|
| { readonly _tag: "command"; readonly command: AppCommand; readonly commandIndex: number }
|
|
21
22
|
|
|
22
23
|
export const buildCommandPaletteRows = (commands: readonly AppCommand[]): readonly CommandPaletteRow[] => {
|
|
@@ -25,6 +26,7 @@ export const buildCommandPaletteRows = (commands: readonly AppCommand[]): readon
|
|
|
25
26
|
for (let commandIndex = 0; commandIndex < commands.length; commandIndex++) {
|
|
26
27
|
const command = commands[commandIndex]!
|
|
27
28
|
if (command.scope !== previousScope) {
|
|
29
|
+
if (previousScope !== null) rows.push({ _tag: "spacer" })
|
|
28
30
|
rows.push({ _tag: "section", scope: command.scope })
|
|
29
31
|
previousScope = command.scope
|
|
30
32
|
}
|
|
@@ -78,8 +80,9 @@ export const CommandPalette = ({
|
|
|
78
80
|
const visibleRows = rows.slice(scrollTop, scrollTop + listHeight)
|
|
79
81
|
const bottomPaddingRows = Math.max(0, listHeight - visibleRows.length)
|
|
80
82
|
const countText = commands.length === 1 ? "1 command" : `${commands.length} commands`
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
+
const queryWidth = Math.max(1, contentWidth)
|
|
84
|
+
const placeholder = "Search word"
|
|
85
|
+
const queryText = trimCell(query, Math.max(0, queryWidth - 1))
|
|
83
86
|
const emptyTopRows = Math.max(0, Math.floor((listHeight - 1) / 2))
|
|
84
87
|
const emptyBottomRows = Math.max(0, listHeight - emptyTopRows - 1)
|
|
85
88
|
useEffect(() => {
|
|
@@ -92,12 +95,21 @@ export const CommandPalette = ({
|
|
|
92
95
|
top={offsetTop}
|
|
93
96
|
width={modalWidth}
|
|
94
97
|
height={modalHeight}
|
|
95
|
-
title="
|
|
98
|
+
title="Commands"
|
|
96
99
|
headerRight={{ text: countText }}
|
|
97
100
|
subtitle={
|
|
98
101
|
<TextLine>
|
|
99
|
-
|
|
100
|
-
|
|
102
|
+
{query.length > 0 ? (
|
|
103
|
+
<>
|
|
104
|
+
<span fg={colors.text}>{queryText}</span>
|
|
105
|
+
<span bg={colors.accent} fg={colors.background}> </span>
|
|
106
|
+
</>
|
|
107
|
+
) : (
|
|
108
|
+
<>
|
|
109
|
+
<span bg={colors.accent} fg={colors.background}>{placeholder[0]}</span>
|
|
110
|
+
<span fg={colors.muted}>{fitCell(placeholder.slice(1), Math.max(0, queryWidth - 1))}</span>
|
|
111
|
+
</>
|
|
112
|
+
)}
|
|
101
113
|
</TextLine>
|
|
102
114
|
}
|
|
103
115
|
footer={<HintRow items={[{ key: "↑↓", label: "select" }, { key: "enter", label: "run" }, { key: "ctrl-u", label: "clear" }, { key: "ctrl-w", label: "word" }, { key: "esc", label: "close" }]} />}
|
|
@@ -112,24 +124,35 @@ export const CommandPalette = ({
|
|
|
112
124
|
<>
|
|
113
125
|
{visibleRows.map((row, index) => {
|
|
114
126
|
const rowIndex = scrollTop + index
|
|
127
|
+
if (row._tag === "spacer") {
|
|
128
|
+
return <PlainLine key={`spacer-${rowIndex}`} text="" />
|
|
129
|
+
}
|
|
115
130
|
if (row._tag === "section") {
|
|
116
|
-
return <PlainLine key={`section-${row.scope}-${rowIndex}`} text={fitCell(`
|
|
131
|
+
return <PlainLine key={`section-${row.scope}-${rowIndex}`} text={fitCell(` ${scopeLabels[row.scope].toUpperCase()}`, rowWidth)} fg={colors.muted} />
|
|
117
132
|
}
|
|
118
133
|
|
|
119
134
|
const { command, commandIndex } = row
|
|
120
135
|
const isSelected = commandIndex === clampedIndex
|
|
121
136
|
const shortcut = command.shortcut ? trimCell(command.shortcut, 16) : ""
|
|
122
|
-
const
|
|
137
|
+
const shortcutWidth = shortcut.length === 0 ? 0 : Math.min(18, Math.max(6, shortcut.length + 1))
|
|
123
138
|
const trailingPadding = shortcut.length === 0 ? 0 : 1
|
|
124
|
-
|
|
139
|
+
// Layout: "▸ " (2) + title + " " (2) + subtitle + filler + shortcut + " " (1)
|
|
140
|
+
const SELECTOR_WIDTH = 2
|
|
141
|
+
const titleAvailable = Math.max(8, rowWidth - SELECTOR_WIDTH - shortcutWidth - trailingPadding)
|
|
142
|
+
const titleText = trimCell(command.title, Math.min(titleAvailable, 36))
|
|
143
|
+
const subtitleSpace = Math.max(0, titleAvailable - titleText.length - 2)
|
|
144
|
+
const subtitleText = command.subtitle && subtitleSpace > 4 ? trimCell(command.subtitle, subtitleSpace) : ""
|
|
145
|
+
const fillerWidth = Math.max(0, titleAvailable - titleText.length - (subtitleText ? 2 + subtitleText.length : 0))
|
|
125
146
|
|
|
126
147
|
return (
|
|
127
148
|
<box key={command.id} height={1}>
|
|
128
149
|
<TextLine width={rowWidth} bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
|
|
129
150
|
<span fg={isSelected ? colors.accent : colors.muted}>{isSelected ? "▸" : " "}</span>
|
|
130
151
|
<span> </span>
|
|
131
|
-
{isSelected ? <span attributes={TextAttributes.BOLD}>{
|
|
132
|
-
{
|
|
152
|
+
{isSelected ? <span attributes={TextAttributes.BOLD}>{titleText}</span> : <span>{titleText}</span>}
|
|
153
|
+
{subtitleText ? <span fg={colors.muted}>{` ${subtitleText}`}</span> : null}
|
|
154
|
+
{fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
|
|
155
|
+
{shortcutWidth > 0 ? <span fg={colors.muted}>{fitCell(shortcut, shortcutWidth, "right")}</span> : null}
|
|
133
156
|
{trailingPadding > 0 ? <span> </span> : null}
|
|
134
157
|
</TextLine>
|
|
135
158
|
</box>
|
package/src/ui/DetailsPane.tsx
CHANGED
|
@@ -26,6 +26,9 @@ export const DETAIL_PLACEHOLDER_ROWS = 4
|
|
|
26
26
|
export const DETAIL_BODY_SCROLL_LIMIT = 1_000
|
|
27
27
|
|
|
28
28
|
const pullRequestReferencePattern = /(#[0-9]+)/g
|
|
29
|
+
const codeFencePattern = /^```\s*([a-zA-Z0-9_-]+)?/
|
|
30
|
+
const codeTokenPattern = /(\/\/.*|`(?:\\.|[^`])*`|"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\b(?:async|await|break|case|catch|class|const|continue|default|else|export|extends|finally|for|from|function|if|import|interface|let|new|return|switch|throw|try|type|var|while|yield)\b|\b(?:true|false|null|undefined)\b|\b\d+(?:\.\d+)?\b)/g
|
|
31
|
+
const codeFenceLine = (line: string) => line.trim().replace(/\\`/g, "`").match(codeFencePattern)
|
|
29
32
|
|
|
30
33
|
export const wrapText = (text: string, width: number): string[] => {
|
|
31
34
|
if (text.length === 0 || width <= 0) return [""]
|
|
@@ -63,6 +66,29 @@ const parseInlineSegments = (text: string, fg: string, bold = false): PreviewLin
|
|
|
63
66
|
})
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
const parseCodeSegments = (text: string): PreviewLine["segments"] => {
|
|
70
|
+
const segments: Array<PreviewLine["segments"][number]> = []
|
|
71
|
+
let index = 0
|
|
72
|
+
for (const match of text.matchAll(codeTokenPattern)) {
|
|
73
|
+
const start = match.index ?? 0
|
|
74
|
+
if (start > index) segments.push({ text: text.slice(index, start), fg: colors.text })
|
|
75
|
+
const token = match[0]
|
|
76
|
+
const fg = token.startsWith("//")
|
|
77
|
+
? colors.muted
|
|
78
|
+
: token.startsWith("`") || token.startsWith("\"") || token.startsWith("'")
|
|
79
|
+
? colors.inlineCode
|
|
80
|
+
: /^\d/.test(token)
|
|
81
|
+
? colors.status.review
|
|
82
|
+
: token === "true" || token === "false" || token === "null" || token === "undefined"
|
|
83
|
+
? colors.status.review
|
|
84
|
+
: colors.accent
|
|
85
|
+
segments.push({ text: token, fg, bold: fg === colors.accent })
|
|
86
|
+
index = start + token.length
|
|
87
|
+
}
|
|
88
|
+
if (index < text.length) segments.push({ text: text.slice(index), fg: colors.text })
|
|
89
|
+
return segments.length > 0 ? segments : [{ text: "", fg: colors.muted }]
|
|
90
|
+
}
|
|
91
|
+
|
|
66
92
|
const wrapPreviewSegments = (segments: PreviewLine["segments"], width: number, indent = ""): Array<PreviewLine> => {
|
|
67
93
|
const tokens = segments.flatMap((segment) =>
|
|
68
94
|
segment.text.split(/(\s+)/).filter((token) => token.length > 0).map((token) => ({ ...segment, text: token })),
|
|
@@ -102,11 +128,13 @@ const bodyPreview = (body: string, width: number, limit = DETAIL_BODY_LINES): Ar
|
|
|
102
128
|
for (const rawLine of sourceLines) {
|
|
103
129
|
if (preview.length >= limit) break
|
|
104
130
|
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
131
|
+
const fence = codeFenceLine(rawLine)
|
|
132
|
+
if (fence) {
|
|
107
133
|
inCodeBlock = !inCodeBlock
|
|
108
134
|
continue
|
|
109
135
|
}
|
|
136
|
+
|
|
137
|
+
const line = inCodeBlock ? rawLine.replace(/\t/g, " ") : rawLine.trim()
|
|
110
138
|
if (line.length === 0) continue
|
|
111
139
|
|
|
112
140
|
let text = line
|
|
@@ -142,11 +170,9 @@ const bodyPreview = (body: string, width: number, limit = DETAIL_BODY_LINES): Ar
|
|
|
142
170
|
text = `> ${line.replace(/^>\s+/, "")}`
|
|
143
171
|
fg = colors.muted
|
|
144
172
|
indent = " "
|
|
145
|
-
} else if (inCodeBlock) {
|
|
146
|
-
fg = colors.muted
|
|
147
173
|
}
|
|
148
174
|
|
|
149
|
-
const wrapped = wrapPreviewSegments(parseInlineSegments(text, fg, bold), Math.max(16, width), indent)
|
|
175
|
+
const wrapped = wrapPreviewSegments(inCodeBlock ? parseCodeSegments(text) : parseInlineSegments(text, fg, bold), Math.max(16, width), indent)
|
|
150
176
|
for (const wrappedLine of wrapped) {
|
|
151
177
|
preview.push(wrappedLine)
|
|
152
178
|
if (preview.length >= limit) break
|
|
@@ -204,9 +230,10 @@ const ChecksSection = ({ checks, contentWidth }: { checks: readonly CheckItem[];
|
|
|
204
230
|
const unique = deduplicateChecks(checks)
|
|
205
231
|
if (unique.length === 0) return null
|
|
206
232
|
|
|
207
|
-
const
|
|
233
|
+
const columns = 2
|
|
234
|
+
const colWidth = Math.floor((contentWidth - 1) / columns)
|
|
208
235
|
const nameCol = Math.max(4, colWidth - 2)
|
|
209
|
-
const rows = Math.ceil(unique.length /
|
|
236
|
+
const rows = Math.ceil(unique.length / columns)
|
|
210
237
|
|
|
211
238
|
return (
|
|
212
239
|
<box flexDirection="column">
|
|
@@ -214,23 +241,22 @@ const ChecksSection = ({ checks, contentWidth }: { checks: readonly CheckItem[];
|
|
|
214
241
|
<span fg={colors.count} attributes={TextAttributes.BOLD}>Checks</span>
|
|
215
242
|
</TextLine>
|
|
216
243
|
{Array.from({ length: rows }, (_, rowIndex) => {
|
|
217
|
-
const left = unique[rowIndex * 2]
|
|
218
|
-
const right = unique[rowIndex * 2 + 1]
|
|
219
244
|
return (
|
|
220
245
|
<TextLine key={rowIndex}>
|
|
221
|
-
{
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
<
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
246
|
+
{Array.from({ length: columns }, (_, columnIndex) => {
|
|
247
|
+
const check = unique[rowIndex * columns + columnIndex]
|
|
248
|
+
return (
|
|
249
|
+
<Fragment key={columnIndex}>
|
|
250
|
+
{columnIndex > 0 ? <span fg={colors.muted}> </span> : null}
|
|
251
|
+
{check ? (
|
|
252
|
+
<>
|
|
253
|
+
<span fg={checkColor(check)}>{checkIcon(check)} </span>
|
|
254
|
+
<span fg={colors.text}>{fitCell(check.name, nameCol)}</span>
|
|
255
|
+
</>
|
|
256
|
+
) : <span>{" ".repeat(colWidth)}</span>}
|
|
257
|
+
</Fragment>
|
|
258
|
+
)
|
|
259
|
+
})}
|
|
234
260
|
</TextLine>
|
|
235
261
|
)
|
|
236
262
|
})}
|
package/src/ui/FooterHints.tsx
CHANGED
|
@@ -15,7 +15,7 @@ interface HintsContext {
|
|
|
15
15
|
readonly showFilterClear: boolean
|
|
16
16
|
readonly detailFullView: boolean
|
|
17
17
|
readonly diffFullView: boolean
|
|
18
|
-
readonly
|
|
18
|
+
readonly diffRangeActive: boolean
|
|
19
19
|
readonly hasSelection: boolean
|
|
20
20
|
readonly canCloseSelection: boolean
|
|
21
21
|
readonly hasError: boolean
|
|
@@ -33,26 +33,17 @@ const filterEditingHints: readonly HintItem[] = [
|
|
|
33
33
|
{ key: "ctrl-w", label: "word" },
|
|
34
34
|
]
|
|
35
35
|
|
|
36
|
-
const
|
|
37
|
-
{ key: "↑↓", label: "line" },
|
|
38
|
-
{ key: "pgup/pgdn", label: "jump" },
|
|
39
|
-
{ key: "←→", label: "side" },
|
|
40
|
-
{ key: "enter", label: "open" },
|
|
41
|
-
{ key: "a", label: "comment" },
|
|
42
|
-
{ key: "c", label: "done" },
|
|
43
|
-
{ key: "[]", label: "files" },
|
|
44
|
-
{ key: "esc", label: "back" },
|
|
45
|
-
]
|
|
46
|
-
|
|
47
|
-
const diffViewHints: readonly HintItem[] = [
|
|
36
|
+
const diffViewHints = (ctx: HintsContext): readonly HintItem[] => [
|
|
48
37
|
{ key: "esc", label: "back" },
|
|
49
|
-
{ key: "
|
|
50
|
-
{ key: "
|
|
51
|
-
{ key: "
|
|
38
|
+
{ key: "↑↓", label: ctx.diffRangeActive ? "range" : "line" },
|
|
39
|
+
{ key: "enter", label: ctx.diffRangeActive ? "comment" : "open" },
|
|
40
|
+
{ key: "v", label: ctx.diffRangeActive ? "clear" : "range" },
|
|
41
|
+
{ key: "n/p", label: "threads" },
|
|
52
42
|
{ key: "[]", label: "files" },
|
|
43
|
+
{ key: "V", label: "view" },
|
|
44
|
+
{ key: "w", label: "wrap" },
|
|
53
45
|
{ key: "r", label: "reload" },
|
|
54
46
|
{ key: "o", label: "open" },
|
|
55
|
-
{ key: "q", label: "quit" },
|
|
56
47
|
]
|
|
57
48
|
|
|
58
49
|
const detailFullViewHints = (ctx: HintsContext): readonly HintItem[] => [
|
|
@@ -91,7 +82,7 @@ const defaultHints = (ctx: HintsContext): readonly HintItem[] => {
|
|
|
91
82
|
|
|
92
83
|
const footerHints = (ctx: HintsContext): readonly HintItem[] => {
|
|
93
84
|
if (ctx.filterEditing) return filterEditingHints
|
|
94
|
-
if (ctx.diffFullView) return ctx
|
|
85
|
+
if (ctx.diffFullView) return diffViewHints(ctx)
|
|
95
86
|
if (ctx.detailFullView) return detailFullViewHints(ctx)
|
|
96
87
|
return defaultHints(ctx)
|
|
97
88
|
}
|
|
@@ -2,7 +2,7 @@ import type { DiffRenderable, MouseEvent, ScrollBoxRenderable } from "@opentui/c
|
|
|
2
2
|
import { useMemo, type Ref } from "react"
|
|
3
3
|
import type { DiffCommentSide, PullRequestItem, PullRequestReviewComment } from "../domain.js"
|
|
4
4
|
import { colors, type ThemeId } from "./colors.js"
|
|
5
|
-
import { createDiffSyntaxStyle, diffFileStats, diffFileStatsText, diffStatText, stackedDiffFileAtLine, type DiffFileStats, type DiffView, type DiffWrapMode, type PullRequestDiffState, type StackedDiffCommentAnchor, type StackedDiffFilePatch } from "./diff.js"
|
|
5
|
+
import { createDiffSyntaxStyle, diffCommentAnchorLabel, diffFileStats, diffFileStatsText, diffStatText, stackedDiffFileAtLine, type DiffFileStats, type DiffView, type DiffWrapMode, type PullRequestDiffState, type StackedDiffCommentAnchor, type StackedDiffFilePatch } from "./diff.js"
|
|
6
6
|
import { LoadingPane, StatusCard } from "./DetailsPane.js"
|
|
7
7
|
import { DiffStats } from "./diffStats.js"
|
|
8
8
|
import { Divider, fitCell, PaddedRow, PlainLine, TextLine } from "./primitives.js"
|
|
@@ -65,6 +65,11 @@ const FileHeader = ({
|
|
|
65
65
|
)
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
const firstCommentBodyLine = (body: string) => {
|
|
69
|
+
const newlineIndex = body.indexOf("\n")
|
|
70
|
+
return (newlineIndex >= 0 ? body.slice(0, newlineIndex) : body).trim() || "(empty comment)"
|
|
71
|
+
}
|
|
72
|
+
|
|
68
73
|
export const PullRequestDiffPane = ({
|
|
69
74
|
pullRequest,
|
|
70
75
|
diffState,
|
|
@@ -77,8 +82,8 @@ export const PullRequestDiffPane = ({
|
|
|
77
82
|
loadingIndicator,
|
|
78
83
|
scrollRef,
|
|
79
84
|
setDiffRef,
|
|
80
|
-
commentMode,
|
|
81
85
|
selectedCommentAnchor,
|
|
86
|
+
selectedCommentLabel,
|
|
82
87
|
selectedCommentThread,
|
|
83
88
|
onSelectCommentLine,
|
|
84
89
|
themeId,
|
|
@@ -94,8 +99,8 @@ export const PullRequestDiffPane = ({
|
|
|
94
99
|
loadingIndicator: string
|
|
95
100
|
scrollRef: Ref<ScrollBoxRenderable>
|
|
96
101
|
setDiffRef: (index: number, diff: DiffRenderable | null) => void
|
|
97
|
-
commentMode: boolean
|
|
98
102
|
selectedCommentAnchor: StackedDiffCommentAnchor | null
|
|
103
|
+
selectedCommentLabel: string | null
|
|
99
104
|
selectedCommentThread: readonly PullRequestReviewComment[]
|
|
100
105
|
onSelectCommentLine: (renderLine: number, side: DiffCommentSide | null) => void
|
|
101
106
|
themeId: ThemeId
|
|
@@ -134,13 +139,14 @@ export const PullRequestDiffPane = ({
|
|
|
134
139
|
}
|
|
135
140
|
|
|
136
141
|
const selectedSideLabel = selectedCommentAnchor?.side === "RIGHT" ? "right" : selectedCommentAnchor?.side === "LEFT" ? "left" : null
|
|
137
|
-
const
|
|
142
|
+
const hasSelectedCommentAnchor = selectedCommentAnchor !== null
|
|
143
|
+
const commentPeek = hasSelectedCommentAnchor && selectedCommentThread.length > 0
|
|
138
144
|
? selectedCommentThread[selectedCommentThread.length - 1]!
|
|
139
145
|
: null
|
|
140
146
|
const commentPeekCount = selectedCommentThread.length === 1 ? "1 comment" : `${selectedCommentThread.length} comments`
|
|
141
|
-
const commentPeekBody = commentPeek
|
|
147
|
+
const commentPeekBody = commentPeek ? firstCommentBodyLine(commentPeek.body) : "(empty comment)"
|
|
142
148
|
const commentPeekMeta = commentPeek && selectedCommentAnchor
|
|
143
|
-
? `${selectedSideLabel ?? "line"}
|
|
149
|
+
? `${selectedCommentLabel ?? selectedSideLabel ?? "line"} ${commentPeek.author} ${commentPeekCount} enter thread`
|
|
144
150
|
: ""
|
|
145
151
|
const stickyScrollTop = Math.max(0, Math.floor(scrollTop))
|
|
146
152
|
const stickyFile = stackedDiffFileAtLine(stackedFiles, stickyScrollTop) ?? stackedFiles[0]
|
|
@@ -149,10 +155,9 @@ export const PullRequestDiffPane = ({
|
|
|
149
155
|
const incomingHeaderDistance = incomingStickyFile ? incomingStickyFile.headerLine - stickyScrollTop : Number.POSITIVE_INFINITY
|
|
150
156
|
const incomingFile = incomingHeaderDistance === 1 ? incomingStickyFile : undefined
|
|
151
157
|
const stickyCommentLabelFor = (stackedFile: StackedDiffFilePatch | undefined) => {
|
|
152
|
-
if (!
|
|
153
|
-
if (!selectedCommentAnchor) return " c no lines"
|
|
158
|
+
if (!selectedCommentAnchor) return " no lines"
|
|
154
159
|
if (selectedCommentAnchor.fileIndex !== stackedFile?.index) return ""
|
|
155
|
-
return ` ${
|
|
160
|
+
return ` ${selectedCommentLabel ?? diffCommentAnchorLabel(selectedCommentAnchor)}`
|
|
156
161
|
}
|
|
157
162
|
const stickyCommentColor = selectedCommentAnchor?.side === "LEFT" ? colors.status.failing : colors.status.passing
|
|
158
163
|
const handleDiffMouseDown = function (this: ScrollBoxRenderable, event: MouseEvent) {
|
|
@@ -172,7 +177,7 @@ export const PullRequestDiffPane = ({
|
|
|
172
177
|
<box height={height} flexDirection="column">
|
|
173
178
|
<DiffPaneHeader pullRequest={pullRequest} paneWidth={paneWidth} />
|
|
174
179
|
<Divider width={paneWidth} />
|
|
175
|
-
<scrollbox ref={scrollRef} focused
|
|
180
|
+
<scrollbox ref={scrollRef} focused flexGrow={1} scrollY scrollX={false} onMouseDown={handleDiffMouseDown}>
|
|
176
181
|
{stackedFiles.map((stackedFile) => (
|
|
177
182
|
<box key={`${pullRequest.url}-${stackedFile.index}-${view}-${wrapMode}`} flexDirection="column" flexShrink={0}>
|
|
178
183
|
{stackedFile.index > 0 ? <Divider width={paneWidth} /> : null}
|