@kitlangton/ghui 0.1.21 → 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.
@@ -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 toggleDiffCommentMode: () => void
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 diffCommentMode: boolean
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
- diffCommentMode,
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-mode",
255
- title: diffCommentMode ? "Exit diff comment mode" : "Enter diff comment mode",
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: diffCommentMode ? "Return to diff scrolling" : "Choose a line to comment on",
258
- shortcut: "c",
259
- disabledReason: diffFullView && diffReady ? null : diffOpenReadyReason,
260
- keywords: ["review", "comment", "line"],
261
- run: actions.toggleDiffCommentMode,
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: diffCommentMode && selectedDiffCommentAnchorLabel ? null : "Enter diff comment mode and select a line first.",
311
+ disabledReason: selectedDiffLineReason,
270
312
  keywords: ["review", "reply"],
271
313
  run: actions.openDiffCommentModal,
272
314
  }),
package/src/domain.ts CHANGED
@@ -62,6 +62,8 @@ export interface CreatePullRequestCommentInput {
62
62
  readonly path: string
63
63
  readonly line: number
64
64
  readonly side: DiffCommentSide
65
+ readonly startLine?: number
66
+ readonly startSide?: DiffCommentSide
65
67
  readonly body: string
66
68
  }
67
69
 
package/src/index.tsx CHANGED
@@ -3,8 +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 { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
7
6
  import { KeymapProvider } from "@opentui/keymap/react"
7
+ import { createKeymap } from "./keyboard/createKeymap.js"
8
8
 
9
9
  process.env.OTUI_USE_ALTERNATE_SCREEN = "true"
10
10
 
@@ -45,7 +45,7 @@ const renderer = await createCliRenderer({
45
45
 
46
46
  process.stdout.write(FOCUS_REPORTING_ENABLE)
47
47
 
48
- const keymap = createDefaultOpenTuiKeymap(renderer)
48
+ const keymap = createKeymap(renderer)
49
49
 
50
50
  createRoot(renderer).render(
51
51
  <RegistryProvider>
@@ -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
  })
@@ -80,8 +80,9 @@ export const CommandPalette = ({
80
80
  const visibleRows = rows.slice(scrollTop, scrollTop + listHeight)
81
81
  const bottomPaddingRows = Math.max(0, listHeight - visibleRows.length)
82
82
  const countText = commands.length === 1 ? "1 command" : `${commands.length} commands`
83
- const queryText = query.length > 0 ? query : "type a command, state, or shortcut"
84
- const queryWidth = Math.max(1, contentWidth - 2)
83
+ const queryWidth = Math.max(1, contentWidth)
84
+ const placeholder = "Search word"
85
+ const queryText = trimCell(query, Math.max(0, queryWidth - 1))
85
86
  const emptyTopRows = Math.max(0, Math.floor((listHeight - 1) / 2))
86
87
  const emptyBottomRows = Math.max(0, listHeight - emptyTopRows - 1)
87
88
  useEffect(() => {
@@ -94,12 +95,21 @@ export const CommandPalette = ({
94
95
  top={offsetTop}
95
96
  width={modalWidth}
96
97
  height={modalHeight}
97
- title="Command Palette"
98
+ title="Commands"
98
99
  headerRight={{ text: countText }}
99
100
  subtitle={
100
101
  <TextLine>
101
- <span fg={colors.count}>› </span>
102
- <span fg={query.length > 0 ? colors.text : colors.muted}>{fitCell(queryText, queryWidth)}</span>
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
+ )}
103
113
  </TextLine>
104
114
  }
105
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" }]} />}
@@ -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 line = rawLine.trim()
106
- if (line.startsWith("```")) {
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 colWidth = Math.floor((contentWidth - 1) / 2)
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 / 2)
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
- {left ? (
222
- <>
223
- <span fg={checkColor(left)}>{checkIcon(left)} </span>
224
- <span fg={colors.text}>{fitCell(left.name, nameCol)}</span>
225
- </>
226
- ) : null}
227
- {right ? (
228
- <>
229
- <span fg={colors.muted}> </span>
230
- <span fg={checkColor(right)}>{checkIcon(right)} </span>
231
- <span fg={colors.text}>{right.name}</span>
232
- </>
233
- ) : null}
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
  })}
@@ -15,7 +15,7 @@ interface HintsContext {
15
15
  readonly showFilterClear: boolean
16
16
  readonly detailFullView: boolean
17
17
  readonly diffFullView: boolean
18
- readonly diffCommentMode: boolean
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 diffCommentModeHints: readonly HintItem[] = [
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: "v", label: "view" },
50
- { key: "w", label: "wrap" },
51
- { key: "c", label: "comment" },
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.diffCommentMode ? diffCommentModeHints : diffViewHints
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 commentPeek = commentMode && selectedCommentAnchor && selectedCommentThread.length > 0
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?.body.split("\n")[0]?.trim() || "(empty comment)"
147
+ const commentPeekBody = commentPeek ? firstCommentBodyLine(commentPeek.body) : "(empty comment)"
142
148
  const commentPeekMeta = commentPeek && selectedCommentAnchor
143
- ? `${selectedSideLabel ?? "line"} ${selectedCommentAnchor.side === "RIGHT" ? "+" : "-"}${selectedCommentAnchor.line} ${commentPeek.author} ${commentPeekCount} enter thread a comment`
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 (!commentMode) return ""
153
- if (!selectedCommentAnchor) return " c no lines"
158
+ if (!selectedCommentAnchor) return " no lines"
154
159
  if (selectedCommentAnchor.fileIndex !== stackedFile?.index) return ""
155
- return ` ${selectedCommentAnchor.side === "RIGHT" ? "right" : "left"} ${selectedCommentAnchor.side === "RIGHT" ? "+" : "-"}${selectedCommentAnchor.line}`
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={!commentMode} flexGrow={1} scrollY scrollX={false} onMouseDown={handleDiffMouseDown}>
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}
@@ -69,6 +69,7 @@ export const buildPullRequestListRows = ({
69
69
  loadedCount,
70
70
  hasMore,
71
71
  isLoadingMore,
72
+ loadingIndicator = "-",
72
73
  }: {
73
74
  readonly groups: PullRequestGroups
74
75
  readonly status: LoadStatus
@@ -78,6 +79,7 @@ export const buildPullRequestListRows = ({
78
79
  readonly loadedCount: number
79
80
  readonly hasMore: boolean
80
81
  readonly isLoadingMore: boolean
82
+ readonly loadingIndicator?: string
81
83
  }): readonly PullRequestListRow[] => {
82
84
  const itemCount = groups.reduce((count, [, pullRequests]) => count + pullRequests.length, 0)
83
85
  const rows: PullRequestListRow[] = [{ _tag: "title" }]
@@ -90,7 +92,7 @@ export const buildPullRequestListRows = ({
90
92
  for (const pullRequest of pullRequests) rows.push({ _tag: "pull-request", pullRequest, groupPullRequests: pullRequests })
91
93
  }
92
94
  if (status === "ready" && itemCount > 0 && (hasMore || isLoadingMore)) {
93
- rows.push({ _tag: "load-more", text: isLoadingMore ? `- Loading more pull requests... (${loadedCount} loaded)` : `- ${loadedCount} loaded, more available` })
95
+ rows.push({ _tag: "load-more", text: isLoadingMore ? `${loadingIndicator} Loading more pull requests... (${loadedCount} loaded)` : `- ${loadedCount} loaded, more available` })
94
96
  }
95
97
  return rows
96
98
  }
@@ -152,6 +154,7 @@ export const PullRequestList = ({
152
154
  loadedCount,
153
155
  hasMore,
154
156
  isLoadingMore,
157
+ loadingIndicator,
155
158
  onSelectPullRequest,
156
159
  }: {
157
160
  groups: PullRequestGroups
@@ -165,9 +168,10 @@ export const PullRequestList = ({
165
168
  loadedCount: number
166
169
  hasMore: boolean
167
170
  isLoadingMore: boolean
171
+ loadingIndicator: string
168
172
  onSelectPullRequest: (url: string) => void
169
173
  }) => {
170
- const rows = buildPullRequestListRows({ groups, status, error, filterText, showFilterBar, loadedCount, hasMore, isLoadingMore })
174
+ const rows = buildPullRequestListRows({ groups, status, error, filterText, showFilterBar, loadedCount, hasMore, isLoadingMore, loadingIndicator })
171
175
 
172
176
  return (
173
177
  <box width={contentWidth} flexDirection="column">
package/src/ui/diff.ts CHANGED
@@ -214,6 +214,12 @@ export const diffCommentLocationKey = (location: Pick<PullRequestReviewComment,
214
214
 
215
215
  export const diffCommentAnchorKey = diffCommentLocationKey
216
216
 
217
+ export const diffCommentSideLabel = (anchor: Pick<DiffCommentAnchor, "side">) => anchor.side === "RIGHT" ? "right" : "left"
218
+
219
+ export const diffCommentLineLabel = (anchor: Pick<DiffCommentAnchor, "side" | "line">) => `${anchor.side === "RIGHT" ? "+" : "-"}${anchor.line}`
220
+
221
+ export const diffCommentAnchorLabel = (anchor: Pick<DiffCommentAnchor, "side" | "line">) => `${diffCommentSideLabel(anchor)} ${diffCommentLineLabel(anchor)}`
222
+
217
223
  type PendingDiffCommentAnchor = Omit<DiffCommentAnchor, "renderLine">
218
224
 
219
225
  const diffContentWidth = (lines: readonly string[], view: DiffView, width: number) => {