@kitlangton/ghui 0.1.21 → 0.2.0
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 +9 -4
- package/src/App.tsx +620 -792
- package/src/appCommands.ts +70 -16
- package/src/domain.ts +13 -0
- package/src/index.tsx +1 -7
- package/src/keyboard/opentuiAdapter.ts +43 -0
- package/src/keymap/all.ts +103 -0
- package/src/keymap/closeModal.ts +13 -0
- package/src/keymap/commandPalette.ts +16 -0
- package/src/keymap/commentModal.ts +73 -0
- package/src/keymap/commentThreadModal.ts +24 -0
- package/src/keymap/detailView.ts +30 -0
- package/src/keymap/diffView.ts +91 -0
- package/src/keymap/filterMode.ts +13 -0
- package/src/keymap/helpers.ts +24 -0
- package/src/keymap/labelModal.ts +16 -0
- package/src/keymap/listNav.ts +119 -0
- package/src/keymap/mergeModal.ts +23 -0
- package/src/keymap/openRepositoryModal.ts +13 -0
- package/src/keymap/themeModal.ts +49 -0
- package/src/mergeActions.ts +4 -1
- package/src/services/GitHubService.ts +42 -6
- package/src/services/MockGitHubService.ts +37 -2
- package/src/themeStore.ts +28 -11
- package/src/ui/CommandPalette.tsx +123 -63
- package/src/ui/DetailsPane.tsx +192 -54
- package/src/ui/FooterHints.tsx +9 -18
- package/src/ui/LoadingLogo.tsx +75 -0
- package/src/ui/PullRequestDiffPane.tsx +25 -18
- package/src/ui/PullRequestList.tsx +6 -2
- package/src/ui/comments.tsx +143 -0
- package/src/ui/diff.ts +198 -6
- package/src/ui/modals.tsx +4 -39
- package/src/ui/primitives.tsx +5 -1
- package/src/ui/singleLineInput.ts +2 -1
- package/src/ui/spinner.ts +1 -0
package/src/appCommands.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { AppCommand } from "./commands.js"
|
|
2
2
|
import { defineCommand } from "./commands.js"
|
|
3
3
|
import type { LoadStatus, PullRequestItem } from "./domain.js"
|
|
4
|
-
import type { DiffView, DiffWrapMode } from "./ui/diff.js"
|
|
4
|
+
import type { DiffView, DiffWhitespaceMode, DiffWrapMode } from "./ui/diff.js"
|
|
5
5
|
import { type PullRequestView, viewEquals, viewLabel, viewMode } from "./pullRequestViews.js"
|
|
6
6
|
|
|
7
7
|
interface AppCommandActions {
|
|
8
8
|
readonly openCommandPalette: () => void
|
|
9
|
-
readonly refreshPullRequests: (message?: string) => void
|
|
9
|
+
readonly refreshPullRequests: (message?: string, options?: { readonly resetTransientState?: boolean }) => void
|
|
10
10
|
readonly openFilter: () => void
|
|
11
11
|
readonly clearFilter: () => void
|
|
12
12
|
readonly openThemeModal: () => void
|
|
@@ -20,8 +20,11 @@ interface AppCommandActions {
|
|
|
20
20
|
readonly reloadDiff: () => void
|
|
21
21
|
readonly toggleDiffRenderView: () => void
|
|
22
22
|
readonly toggleDiffWrapMode: () => void
|
|
23
|
+
readonly toggleDiffWhitespaceMode: () => void
|
|
23
24
|
readonly jumpDiffFile: (delta: 1 | -1) => void
|
|
24
|
-
readonly
|
|
25
|
+
readonly openSelectedDiffComment: () => void
|
|
26
|
+
readonly toggleDiffCommentRange: () => void
|
|
27
|
+
readonly moveDiffCommentThread: (delta: 1 | -1) => void
|
|
25
28
|
readonly openDiffCommentModal: () => void
|
|
26
29
|
readonly togglePullRequestDraftStatus: () => void
|
|
27
30
|
readonly openLabelModal: () => void
|
|
@@ -48,10 +51,13 @@ interface BuildAppCommandsInput {
|
|
|
48
51
|
readonly diffReady: boolean
|
|
49
52
|
readonly effectiveDiffRenderView: DiffView
|
|
50
53
|
readonly diffWrapMode: DiffWrapMode
|
|
54
|
+
readonly diffWhitespaceMode: DiffWhitespaceMode
|
|
51
55
|
readonly readyDiffFileCount: number
|
|
52
56
|
readonly diffFileIndex: number
|
|
53
|
-
readonly
|
|
57
|
+
readonly diffRangeActive: boolean
|
|
54
58
|
readonly selectedDiffCommentAnchorLabel: string | null
|
|
59
|
+
readonly selectedDiffCommentThreadCount: number
|
|
60
|
+
readonly hasDiffCommentThreads: boolean
|
|
55
61
|
readonly actions: AppCommandActions
|
|
56
62
|
}
|
|
57
63
|
|
|
@@ -71,10 +77,13 @@ export const buildAppCommands = ({
|
|
|
71
77
|
diffReady,
|
|
72
78
|
effectiveDiffRenderView,
|
|
73
79
|
diffWrapMode,
|
|
80
|
+
diffWhitespaceMode,
|
|
74
81
|
readyDiffFileCount,
|
|
75
82
|
diffFileIndex,
|
|
76
|
-
|
|
83
|
+
diffRangeActive,
|
|
77
84
|
selectedDiffCommentAnchorLabel,
|
|
85
|
+
selectedDiffCommentThreadCount,
|
|
86
|
+
hasDiffCommentThreads,
|
|
78
87
|
actions,
|
|
79
88
|
}: BuildAppCommandsInput): readonly AppCommand[] => {
|
|
80
89
|
const selectedPullRequestLabel = selectedPullRequest ? `#${selectedPullRequest.number} ${selectedPullRequest.repository}` : "No pull request selected"
|
|
@@ -84,6 +93,12 @@ export const buildAppCommands = ({
|
|
|
84
93
|
? diffReady ? null : "Load the diff before running this command."
|
|
85
94
|
: noPullRequestReason
|
|
86
95
|
const diffOpenReadyReason = diffFullView ? diffReadyReason : "Open a diff first."
|
|
96
|
+
const selectedDiffLineReason = diffFullView && diffReady
|
|
97
|
+
? selectedDiffCommentAnchorLabel ? null : "No diff line selected."
|
|
98
|
+
: diffOpenReadyReason
|
|
99
|
+
const diffThreadReason = diffFullView && diffReady
|
|
100
|
+
? hasDiffCommentThreads ? null : "No diff comments loaded."
|
|
101
|
+
: diffOpenReadyReason
|
|
87
102
|
const loadMoreDisabledReason = isLoadingMorePullRequests
|
|
88
103
|
? "Already loading more pull requests."
|
|
89
104
|
: hasMorePullRequests ? null : "No more pull requests loaded by this view."
|
|
@@ -116,7 +131,7 @@ export const buildAppCommands = ({
|
|
|
116
131
|
subtitle: "Fetch the latest queue from GitHub",
|
|
117
132
|
shortcut: "r",
|
|
118
133
|
keywords: ["reload", "sync"],
|
|
119
|
-
run: () => actions.refreshPullRequests("Refreshed"),
|
|
134
|
+
run: () => actions.refreshPullRequests("Refreshed", { resetTransientState: true }),
|
|
120
135
|
}),
|
|
121
136
|
defineCommand({
|
|
122
137
|
id: "filter.open",
|
|
@@ -189,7 +204,7 @@ export const buildAppCommands = ({
|
|
|
189
204
|
}),
|
|
190
205
|
forSelected({
|
|
191
206
|
id: "diff.open",
|
|
192
|
-
title: "Open
|
|
207
|
+
title: "Open diff",
|
|
193
208
|
scope: "Diff",
|
|
194
209
|
shortcut: "d",
|
|
195
210
|
keywords: ["files", "patch"],
|
|
@@ -219,7 +234,7 @@ export const buildAppCommands = ({
|
|
|
219
234
|
title: "Toggle diff split/unified view",
|
|
220
235
|
scope: "Diff",
|
|
221
236
|
subtitle: effectiveDiffRenderView === "split" ? "Switch to unified view" : "Switch to split view",
|
|
222
|
-
shortcut: "v",
|
|
237
|
+
shortcut: "shift-v",
|
|
223
238
|
disabledReason: diffFullView ? null : "Open a diff first.",
|
|
224
239
|
run: actions.toggleDiffRenderView,
|
|
225
240
|
}),
|
|
@@ -232,6 +247,15 @@ export const buildAppCommands = ({
|
|
|
232
247
|
disabledReason: diffFullView ? null : "Open a diff first.",
|
|
233
248
|
run: actions.toggleDiffWrapMode,
|
|
234
249
|
}),
|
|
250
|
+
defineCommand({
|
|
251
|
+
id: "diff.toggle-whitespace",
|
|
252
|
+
title: diffWhitespaceMode === "ignore" ? "Show whitespace changes" : "Ignore whitespace changes",
|
|
253
|
+
scope: "Diff",
|
|
254
|
+
subtitle: diffWhitespaceMode === "ignore" ? "Display the original GitHub patch" : "Hide whitespace-only line changes",
|
|
255
|
+
disabledReason: diffFullView ? null : "Open a diff first.",
|
|
256
|
+
keywords: ["whitespace", "spacing", "ignore", "show"],
|
|
257
|
+
run: actions.toggleDiffWhitespaceMode,
|
|
258
|
+
}),
|
|
235
259
|
defineCommand({
|
|
236
260
|
id: "diff.next-file",
|
|
237
261
|
title: "Next diff file",
|
|
@@ -251,14 +275,44 @@ export const buildAppCommands = ({
|
|
|
251
275
|
run: () => actions.jumpDiffFile(-1),
|
|
252
276
|
}),
|
|
253
277
|
defineCommand({
|
|
254
|
-
id: "diff.comment-
|
|
255
|
-
title:
|
|
278
|
+
id: "diff.open-comment-target",
|
|
279
|
+
title: selectedDiffCommentThreadCount > 0 ? "Open selected diff thread" : "Comment on selected diff line",
|
|
280
|
+
scope: "Diff",
|
|
281
|
+
subtitle: selectedDiffCommentAnchorLabel ?? "No diff line selected",
|
|
282
|
+
shortcut: "enter",
|
|
283
|
+
disabledReason: selectedDiffLineReason,
|
|
284
|
+
keywords: ["review", "comment", "thread", "line"],
|
|
285
|
+
run: actions.openSelectedDiffComment,
|
|
286
|
+
}),
|
|
287
|
+
defineCommand({
|
|
288
|
+
id: "diff.toggle-range",
|
|
289
|
+
title: diffRangeActive ? "Clear diff comment range" : "Start diff comment range",
|
|
290
|
+
scope: "Diff",
|
|
291
|
+
subtitle: selectedDiffCommentAnchorLabel ?? "No diff line selected",
|
|
292
|
+
shortcut: "v",
|
|
293
|
+
disabledReason: selectedDiffLineReason,
|
|
294
|
+
keywords: ["review", "comment", "range", "visual"],
|
|
295
|
+
run: actions.toggleDiffCommentRange,
|
|
296
|
+
}),
|
|
297
|
+
defineCommand({
|
|
298
|
+
id: "diff.next-thread",
|
|
299
|
+
title: "Next diff thread",
|
|
300
|
+
scope: "Diff",
|
|
301
|
+
subtitle: hasDiffCommentThreads ? "Jump to the next commented line" : "No diff comments loaded",
|
|
302
|
+
shortcut: "n",
|
|
303
|
+
disabledReason: diffThreadReason,
|
|
304
|
+
keywords: ["review", "comment", "thread"],
|
|
305
|
+
run: () => actions.moveDiffCommentThread(1),
|
|
306
|
+
}),
|
|
307
|
+
defineCommand({
|
|
308
|
+
id: "diff.previous-thread",
|
|
309
|
+
title: "Previous diff thread",
|
|
256
310
|
scope: "Diff",
|
|
257
|
-
subtitle:
|
|
258
|
-
shortcut: "
|
|
259
|
-
disabledReason:
|
|
260
|
-
keywords: ["review", "comment", "
|
|
261
|
-
run: actions.
|
|
311
|
+
subtitle: hasDiffCommentThreads ? "Jump to the previous commented line" : "No diff comments loaded",
|
|
312
|
+
shortcut: "p",
|
|
313
|
+
disabledReason: diffThreadReason,
|
|
314
|
+
keywords: ["review", "comment", "thread"],
|
|
315
|
+
run: () => actions.moveDiffCommentThread(-1),
|
|
262
316
|
}),
|
|
263
317
|
defineCommand({
|
|
264
318
|
id: "diff.add-comment",
|
|
@@ -266,7 +320,7 @@ export const buildAppCommands = ({
|
|
|
266
320
|
scope: "Diff",
|
|
267
321
|
subtitle: selectedDiffCommentAnchorLabel ?? "No diff line selected",
|
|
268
322
|
shortcut: "a",
|
|
269
|
-
disabledReason:
|
|
323
|
+
disabledReason: selectedDiffLineReason,
|
|
270
324
|
keywords: ["review", "reply"],
|
|
271
325
|
run: actions.openDiffCommentModal,
|
|
272
326
|
}),
|
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
|
|
|
@@ -76,6 +78,17 @@ export interface PullRequestReviewComment {
|
|
|
76
78
|
readonly url: string | null
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
export type PullRequestConversationItem =
|
|
82
|
+
| {
|
|
83
|
+
readonly _tag: "comment"
|
|
84
|
+
readonly id: string
|
|
85
|
+
readonly author: string
|
|
86
|
+
readonly body: string
|
|
87
|
+
readonly createdAt: Date | null
|
|
88
|
+
readonly url: string | null
|
|
89
|
+
}
|
|
90
|
+
| ({ readonly _tag: "review-comment" } & PullRequestReviewComment)
|
|
91
|
+
|
|
79
92
|
export interface PullRequestItem {
|
|
80
93
|
readonly repository: string
|
|
81
94
|
readonly author: string
|
package/src/index.tsx
CHANGED
|
@@ -3,8 +3,6 @@
|
|
|
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
|
-
import { KeymapProvider } from "@opentui/keymap/react"
|
|
8
6
|
|
|
9
7
|
process.env.OTUI_USE_ALTERNATE_SCREEN = "true"
|
|
10
8
|
|
|
@@ -45,12 +43,8 @@ const renderer = await createCliRenderer({
|
|
|
45
43
|
|
|
46
44
|
process.stdout.write(FOCUS_REPORTING_ENABLE)
|
|
47
45
|
|
|
48
|
-
const keymap = createDefaultOpenTuiKeymap(renderer)
|
|
49
|
-
|
|
50
46
|
createRoot(renderer).render(
|
|
51
47
|
<RegistryProvider>
|
|
52
|
-
<
|
|
53
|
-
<App />
|
|
54
|
-
</KeymapProvider>
|
|
48
|
+
<App />
|
|
55
49
|
</RegistryProvider>,
|
|
56
50
|
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { KeyEvent } from "@opentui/core"
|
|
2
|
+
import { useKeyboard } from "@opentui/react"
|
|
3
|
+
import type { ParsedStroke } from "@ghui/keymap"
|
|
4
|
+
import type { KeySubscribe } from "@ghui/keymap/react"
|
|
5
|
+
import { useMemo, useRef } from "react"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Map an opentui KeyEvent into @ghui/keymap's ParsedStroke.
|
|
9
|
+
* `option` (alt key on Linux/Windows) is folded into `meta` to keep one
|
|
10
|
+
* cross-platform modifier surface.
|
|
11
|
+
*/
|
|
12
|
+
export const normalizeOpenTuiKey = (event: KeyEvent): ParsedStroke => ({
|
|
13
|
+
key: event.name.toLowerCase(),
|
|
14
|
+
ctrl: event.ctrl,
|
|
15
|
+
shift: event.shift,
|
|
16
|
+
meta: event.meta || event.option,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* React hook that returns a stable `KeySubscribe` driven by opentui's keyboard.
|
|
21
|
+
*
|
|
22
|
+
* Multiple subscribers (e.g. the `@ghui/keymap` dispatcher and any text-input
|
|
23
|
+
* fallback) can each register their own handler. Internally we attach a single
|
|
24
|
+
* `useKeyboard` and fan out to every registered handler, so we don't accidentally
|
|
25
|
+
* stack two `useKeyboard` listeners on the same component.
|
|
26
|
+
*/
|
|
27
|
+
export const useOpenTuiSubscribe = (): KeySubscribe => {
|
|
28
|
+
const handlersRef = useRef<Set<(stroke: ParsedStroke) => void>>(new Set())
|
|
29
|
+
|
|
30
|
+
useKeyboard((event) => {
|
|
31
|
+
const keyEvent = event as KeyEvent
|
|
32
|
+
if (keyEvent.defaultPrevented) return
|
|
33
|
+
const stroke = normalizeOpenTuiKey(keyEvent)
|
|
34
|
+
for (const handler of handlersRef.current) handler(stroke)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
return useMemo<KeySubscribe>(() => (handler) => {
|
|
38
|
+
handlersRef.current.add(handler)
|
|
39
|
+
return () => {
|
|
40
|
+
handlersRef.current.delete(handler)
|
|
41
|
+
}
|
|
42
|
+
}, [])
|
|
43
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { context } from "@ghui/keymap"
|
|
2
|
+
import { closeModalKeymap, type CloseModalCtx } from "./closeModal.ts"
|
|
3
|
+
import { commandPaletteKeymap, type CommandPaletteCtx } from "./commandPalette.ts"
|
|
4
|
+
import { commentModalKeymap, type CommentModalCtx } from "./commentModal.ts"
|
|
5
|
+
import { commentThreadModalKeymap, type CommentThreadModalCtx } from "./commentThreadModal.ts"
|
|
6
|
+
import { detailViewKeymap, type DetailViewCtx } from "./detailView.ts"
|
|
7
|
+
import { diffViewKeymap, type DiffViewCtx } from "./diffView.ts"
|
|
8
|
+
import { filterModeKeymap, type FilterModeCtx } from "./filterMode.ts"
|
|
9
|
+
import { labelModalKeymap, type LabelModalCtx } from "./labelModal.ts"
|
|
10
|
+
import { listNavKeymap, type ListNavCtx } from "./listNav.ts"
|
|
11
|
+
import { mergeModalKeymap, type MergeModalCtx } from "./mergeModal.ts"
|
|
12
|
+
import { openRepositoryModalKeymap, type OpenRepositoryModalCtx } from "./openRepositoryModal.ts"
|
|
13
|
+
import { themeModalKeymap, type ThemeModalCtx } from "./themeModal.ts"
|
|
14
|
+
|
|
15
|
+
export interface AppCtx {
|
|
16
|
+
// Active flags
|
|
17
|
+
readonly closeModalActive: boolean
|
|
18
|
+
readonly mergeModalActive: boolean
|
|
19
|
+
readonly commentThreadModalActive: boolean
|
|
20
|
+
readonly labelModalActive: boolean
|
|
21
|
+
readonly themeModalActive: boolean
|
|
22
|
+
readonly openRepositoryModalActive: boolean
|
|
23
|
+
readonly commentModalActive: boolean
|
|
24
|
+
readonly commandPaletteActive: boolean
|
|
25
|
+
readonly filterMode: boolean
|
|
26
|
+
readonly diffFullView: boolean
|
|
27
|
+
readonly detailFullView: boolean
|
|
28
|
+
|
|
29
|
+
// True whenever a modal/mode swallows raw text input (so q-quit, etc. are
|
|
30
|
+
// disabled inside text-editing contexts).
|
|
31
|
+
readonly textInputActive: boolean
|
|
32
|
+
|
|
33
|
+
// Per-layer narrow contexts
|
|
34
|
+
readonly closeModal: CloseModalCtx
|
|
35
|
+
readonly mergeModal: MergeModalCtx
|
|
36
|
+
readonly commentThreadModal: CommentThreadModalCtx
|
|
37
|
+
readonly labelModal: LabelModalCtx
|
|
38
|
+
readonly themeModal: ThemeModalCtx
|
|
39
|
+
readonly openRepositoryModal: OpenRepositoryModalCtx
|
|
40
|
+
readonly commentModal: CommentModalCtx
|
|
41
|
+
readonly commandPalette: CommandPaletteCtx
|
|
42
|
+
readonly filterModeCtx: FilterModeCtx
|
|
43
|
+
readonly diff: DiffViewCtx
|
|
44
|
+
readonly detail: DetailViewCtx
|
|
45
|
+
readonly listNav: ListNavCtx
|
|
46
|
+
|
|
47
|
+
// Always-on / app-level
|
|
48
|
+
readonly openCommandPalette: () => void
|
|
49
|
+
readonly handleQuitOrClose: () => void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const App = context<AppCtx>()
|
|
53
|
+
|
|
54
|
+
const inListMode = (a: AppCtx): boolean =>
|
|
55
|
+
!a.closeModalActive
|
|
56
|
+
&& !a.mergeModalActive
|
|
57
|
+
&& !a.commentThreadModalActive
|
|
58
|
+
&& !a.labelModalActive
|
|
59
|
+
&& !a.themeModalActive
|
|
60
|
+
&& !a.openRepositoryModalActive
|
|
61
|
+
&& !a.commentModalActive
|
|
62
|
+
&& !a.commandPaletteActive
|
|
63
|
+
&& !a.filterMode
|
|
64
|
+
&& !a.diffFullView
|
|
65
|
+
&& !a.detailFullView
|
|
66
|
+
|
|
67
|
+
export const appKeymap = App(
|
|
68
|
+
// Always-on: command palette opener
|
|
69
|
+
{ id: "command.open", title: "Open command palette", keys: ["ctrl+p", "meta+k"], run: (s) => s.openCommandPalette() },
|
|
70
|
+
|
|
71
|
+
// Quit / close-active-modal — gated to "not editing text"
|
|
72
|
+
{
|
|
73
|
+
id: "app.quit-or-close",
|
|
74
|
+
title: "Quit / close modal",
|
|
75
|
+
keys: ["ctrl+c"],
|
|
76
|
+
run: (s) => s.handleQuitOrClose(),
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: "app.quit-or-close-q",
|
|
80
|
+
title: "Quit / close modal",
|
|
81
|
+
keys: ["q"],
|
|
82
|
+
when: (s) => !s.textInputActive,
|
|
83
|
+
run: (s) => s.handleQuitOrClose(),
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
// Modal layers
|
|
87
|
+
closeModalKeymap.scope((a) => a.closeModalActive && a.closeModal),
|
|
88
|
+
mergeModalKeymap.scope((a) => a.mergeModalActive && a.mergeModal),
|
|
89
|
+
commentThreadModalKeymap.scope((a) => a.commentThreadModalActive && a.commentThreadModal),
|
|
90
|
+
labelModalKeymap.scope((a) => a.labelModalActive && a.labelModal),
|
|
91
|
+
themeModalKeymap.scope((a) => a.themeModalActive && a.themeModal),
|
|
92
|
+
openRepositoryModalKeymap.scope((a) => a.openRepositoryModalActive && a.openRepositoryModal),
|
|
93
|
+
commentModalKeymap.scope((a) => a.commentModalActive && a.commentModal),
|
|
94
|
+
commandPaletteKeymap.scope((a) => a.commandPaletteActive && a.commandPalette),
|
|
95
|
+
filterModeKeymap.scope((a) => a.filterMode && a.filterModeCtx),
|
|
96
|
+
|
|
97
|
+
// Full-view layers (only when no modal is on top)
|
|
98
|
+
diffViewKeymap.scope((a) => a.diffFullView && !a.commandPaletteActive && a.diff),
|
|
99
|
+
detailViewKeymap.scope((a) => a.detailFullView && !a.commandPaletteActive && a.detail),
|
|
100
|
+
|
|
101
|
+
// PR list nav
|
|
102
|
+
listNavKeymap.scope((a) => inListMode(a) && a.listNav),
|
|
103
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { context } from "@ghui/keymap"
|
|
2
|
+
|
|
3
|
+
export interface CloseModalCtx {
|
|
4
|
+
readonly closeModal: () => void
|
|
5
|
+
readonly confirmClose: () => void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const Close = context<CloseModalCtx>()
|
|
9
|
+
|
|
10
|
+
export const closeModalKeymap = Close(
|
|
11
|
+
{ id: "close-modal.cancel", title: "Cancel", keys: ["escape"], run: (s) => s.closeModal() },
|
|
12
|
+
{ id: "close-modal.confirm", title: "Close pull request", keys: ["return"], run: (s) => s.confirmClose() },
|
|
13
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { context } from "@ghui/keymap"
|
|
2
|
+
|
|
3
|
+
export interface CommandPaletteCtx {
|
|
4
|
+
readonly closeModal: () => void
|
|
5
|
+
readonly runSelected: () => void
|
|
6
|
+
readonly moveSelection: (delta: -1 | 1) => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const Palette = context<CommandPaletteCtx>()
|
|
10
|
+
|
|
11
|
+
export const commandPaletteKeymap = Palette(
|
|
12
|
+
{ id: "palette.close", title: "Close palette", keys: ["escape", "ctrl+c"], run: (s) => s.closeModal() },
|
|
13
|
+
{ id: "palette.run", title: "Run command", keys: ["return"], run: (s) => s.runSelected() },
|
|
14
|
+
{ id: "palette.up", title: "Up", keys: ["up"], run: (s) => s.moveSelection(-1) },
|
|
15
|
+
{ id: "palette.down", title: "Down", keys: ["down"], run: (s) => s.moveSelection(1) },
|
|
16
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { context } from "@ghui/keymap"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Single-method-per-binding interface. Each `move*` / `delete*` is a discrete
|
|
5
|
+
* editor command; the App constructs the context so each method applies the
|
|
6
|
+
* appropriate `editorState → editorState` transform via the existing
|
|
7
|
+
* `editComment` helper.
|
|
8
|
+
*/
|
|
9
|
+
export interface CommentModalCtx {
|
|
10
|
+
readonly closeModal: () => void
|
|
11
|
+
readonly submit: () => void
|
|
12
|
+
readonly insertNewline: () => void
|
|
13
|
+
readonly moveLeft: () => void
|
|
14
|
+
readonly moveRight: () => void
|
|
15
|
+
readonly moveUp: () => void
|
|
16
|
+
readonly moveDown: () => void
|
|
17
|
+
readonly moveLineStart: () => void
|
|
18
|
+
readonly moveLineEnd: () => void
|
|
19
|
+
readonly moveWordBackward: () => void
|
|
20
|
+
readonly moveWordForward: () => void
|
|
21
|
+
readonly backspace: () => void
|
|
22
|
+
readonly deleteForward: () => void
|
|
23
|
+
readonly deleteWordBackward: () => void
|
|
24
|
+
readonly deleteWordForward: () => void
|
|
25
|
+
readonly deleteToLineStart: () => void
|
|
26
|
+
readonly deleteToLineEnd: () => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const Comment = context<CommentModalCtx>()
|
|
30
|
+
|
|
31
|
+
export const commentModalKeymap = Comment(
|
|
32
|
+
{ id: "comment.escape", title: "Cancel", keys: ["escape"], run: (s) => s.closeModal() },
|
|
33
|
+
{ id: "comment.submit", title: "Submit", keys: ["ctrl+s", "return"], run: (s) => s.submit() },
|
|
34
|
+
{ id: "comment.newline", title: "Insert newline", keys: ["shift+return"], run: (s) => s.insertNewline() },
|
|
35
|
+
|
|
36
|
+
// Cursor movement
|
|
37
|
+
{ id: "comment.move-left", title: "Cursor left", keys: ["left", "ctrl+b"], run: (s) => s.moveLeft() },
|
|
38
|
+
{ id: "comment.move-right", title: "Cursor right", keys: ["right", "ctrl+f"], run: (s) => s.moveRight() },
|
|
39
|
+
{ id: "comment.move-up", title: "Cursor up", keys: ["up"], run: (s) => s.moveUp() },
|
|
40
|
+
{ id: "comment.move-down", title: "Cursor down", keys: ["down"], run: (s) => s.moveDown() },
|
|
41
|
+
{ id: "comment.line-start", title: "Line start", keys: ["home", "ctrl+a"], run: (s) => s.moveLineStart() },
|
|
42
|
+
{ id: "comment.line-end", title: "Line end", keys: ["end", "ctrl+e"], run: (s) => s.moveLineEnd() },
|
|
43
|
+
{
|
|
44
|
+
id: "comment.word-back",
|
|
45
|
+
title: "Word backward",
|
|
46
|
+
keys: ["meta+b", "meta+left"],
|
|
47
|
+
run: (s) => s.moveWordBackward(),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "comment.word-forward",
|
|
51
|
+
title: "Word forward",
|
|
52
|
+
keys: ["meta+f", "meta+right"],
|
|
53
|
+
run: (s) => s.moveWordForward(),
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
// Deletion
|
|
57
|
+
{ id: "comment.backspace", title: "Backspace", keys: ["backspace"], run: (s) => s.backspace() },
|
|
58
|
+
{ id: "comment.delete", title: "Delete", keys: ["delete", "ctrl+d"], run: (s) => s.deleteForward() },
|
|
59
|
+
{
|
|
60
|
+
id: "comment.delete-word-back",
|
|
61
|
+
title: "Delete word backward",
|
|
62
|
+
keys: ["ctrl+w", "meta+backspace"],
|
|
63
|
+
run: (s) => s.deleteWordBackward(),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "comment.delete-word-forward",
|
|
67
|
+
title: "Delete word forward",
|
|
68
|
+
keys: ["meta+delete"],
|
|
69
|
+
run: (s) => s.deleteWordForward(),
|
|
70
|
+
},
|
|
71
|
+
{ id: "comment.delete-to-line-start", title: "Delete to line start", keys: ["ctrl+u"], run: (s) => s.deleteToLineStart() },
|
|
72
|
+
{ id: "comment.delete-to-line-end", title: "Delete to line end", keys: ["ctrl+k"], run: (s) => s.deleteToLineEnd() },
|
|
73
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { context } from "@ghui/keymap"
|
|
2
|
+
|
|
3
|
+
export interface CommentThreadModalCtx {
|
|
4
|
+
readonly halfPage: number
|
|
5
|
+
readonly closeModal: () => void
|
|
6
|
+
readonly openInlineComment: () => void
|
|
7
|
+
readonly scrollBy: (delta: number) => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const Thread = context<CommentThreadModalCtx>()
|
|
11
|
+
|
|
12
|
+
export const commentThreadModalKeymap = Thread(
|
|
13
|
+
{ id: "comment-thread.close", title: "Close", keys: ["escape"], run: (s) => s.closeModal() },
|
|
14
|
+
{ id: "comment-thread.reply", title: "Reply", keys: ["return", "a", "c"], run: (s) => s.openInlineComment() },
|
|
15
|
+
{ id: "comment-thread.up", title: "Up", keys: ["k", "up"], run: (s) => s.scrollBy(-1) },
|
|
16
|
+
{ id: "comment-thread.down", title: "Down", keys: ["j", "down"], run: (s) => s.scrollBy(1) },
|
|
17
|
+
{ id: "comment-thread.half-up", title: "Half page up", keys: ["pageup", "ctrl+u"], run: (s) => s.scrollBy(-s.halfPage) },
|
|
18
|
+
{
|
|
19
|
+
id: "comment-thread.half-down",
|
|
20
|
+
title: "Half page down",
|
|
21
|
+
keys: ["pagedown", "ctrl+d", "ctrl+v"],
|
|
22
|
+
run: (s) => s.scrollBy(s.halfPage),
|
|
23
|
+
},
|
|
24
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { context, type Scrollable, scrollCommands } from "@ghui/keymap"
|
|
2
|
+
|
|
3
|
+
export interface DetailViewCtx extends Scrollable {
|
|
4
|
+
readonly closeDetail: () => void
|
|
5
|
+
readonly openTheme: () => void
|
|
6
|
+
readonly openDiff: () => void
|
|
7
|
+
readonly closePullRequest: () => void
|
|
8
|
+
readonly openLabels: () => void
|
|
9
|
+
readonly openMerge: () => void
|
|
10
|
+
readonly toggleDraft: () => void
|
|
11
|
+
readonly refresh: () => void
|
|
12
|
+
readonly openInBrowser: () => void
|
|
13
|
+
readonly copyMetadata: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const Detail = context<DetailViewCtx>()
|
|
17
|
+
|
|
18
|
+
export const detailViewKeymap = Detail(
|
|
19
|
+
scrollCommands<DetailViewCtx>(),
|
|
20
|
+
{ id: "detail.close", title: "Close detail", keys: ["escape", "return"], run: (s) => s.closeDetail() },
|
|
21
|
+
{ id: "detail.theme", title: "Open theme", keys: ["t"], run: (s) => s.openTheme() },
|
|
22
|
+
{ id: "detail.diff", title: "Open diff", keys: ["d"], run: (s) => s.openDiff() },
|
|
23
|
+
{ id: "detail.close-pr", title: "Close pull request", keys: ["x"], run: (s) => s.closePullRequest() },
|
|
24
|
+
{ id: "detail.labels", title: "Manage labels", keys: ["l"], run: (s) => s.openLabels() },
|
|
25
|
+
{ id: "detail.merge", title: "Merge", keys: ["m", "shift+m"], run: (s) => s.openMerge() },
|
|
26
|
+
{ id: "detail.toggle-draft", title: "Toggle draft", keys: ["s", "shift+s"], run: (s) => s.toggleDraft() },
|
|
27
|
+
{ id: "detail.refresh", title: "Refresh", keys: ["r"], run: (s) => s.refresh() },
|
|
28
|
+
{ id: "detail.open-browser", title: "Open in browser", keys: ["o"], run: (s) => s.openInBrowser() },
|
|
29
|
+
{ id: "detail.copy", title: "Copy metadata", keys: ["y"], run: (s) => s.copyMetadata() },
|
|
30
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { context } from "@ghui/keymap"
|
|
2
|
+
import { countedVerticalBindings } from "./helpers.ts"
|
|
3
|
+
|
|
4
|
+
export type DiffSide = "LEFT" | "RIGHT"
|
|
5
|
+
export type DiffAlign = "center" | "top" | "bottom"
|
|
6
|
+
|
|
7
|
+
export interface DiffViewCtx {
|
|
8
|
+
readonly halfPage: number
|
|
9
|
+
readonly handleEscape: () => void // closes diff, or clears comment range if active
|
|
10
|
+
readonly openSelectedComment: () => void
|
|
11
|
+
readonly addComment: () => void
|
|
12
|
+
readonly toggleRange: () => void
|
|
13
|
+
readonly toggleView: () => void
|
|
14
|
+
readonly toggleWrap: () => void
|
|
15
|
+
readonly reload: () => void
|
|
16
|
+
readonly nextThread: () => void
|
|
17
|
+
readonly previousThread: () => void
|
|
18
|
+
readonly moveAnchor: (delta: number, opts?: { preserveViewportRow?: boolean }) => void
|
|
19
|
+
readonly moveAnchorToBoundary: (boundary: "first" | "last") => void
|
|
20
|
+
readonly alignAnchor: (align: DiffAlign) => void
|
|
21
|
+
readonly selectSide: (side: DiffSide) => void
|
|
22
|
+
readonly nextFile: () => void
|
|
23
|
+
readonly previousFile: () => void
|
|
24
|
+
readonly openInBrowser: () => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const Diff = context<DiffViewCtx>()
|
|
28
|
+
|
|
29
|
+
export const diffViewKeymap = Diff(
|
|
30
|
+
{ id: "diff.escape", title: "Close diff / clear range", keys: ["escape"], run: (s) => s.handleEscape() },
|
|
31
|
+
{ id: "diff.open-comment", title: "Open / add comment", keys: ["return"], run: (s) => s.openSelectedComment() },
|
|
32
|
+
{ id: "diff.add-comment", title: "Add comment on line", keys: ["a"], run: (s) => s.addComment() },
|
|
33
|
+
{ id: "diff.toggle-range", title: "Toggle comment range", keys: ["v"], run: (s) => s.toggleRange() },
|
|
34
|
+
{ id: "diff.toggle-view", title: "Toggle split/unified", keys: ["shift+v"], run: (s) => s.toggleView() },
|
|
35
|
+
{ id: "diff.toggle-wrap", title: "Toggle wrap", keys: ["w"], run: (s) => s.toggleWrap() },
|
|
36
|
+
{ id: "diff.reload", title: "Reload diff", keys: ["r"], run: (s) => s.reload() },
|
|
37
|
+
{ id: "diff.next-thread", title: "Next thread", keys: ["n"], run: (s) => s.nextThread() },
|
|
38
|
+
{ id: "diff.previous-thread", title: "Previous thread", keys: ["p"], run: (s) => s.previousThread() },
|
|
39
|
+
|
|
40
|
+
// Half-page anchor moves preserve viewport row (true vim semantics)
|
|
41
|
+
{
|
|
42
|
+
id: "diff.half-up",
|
|
43
|
+
title: "Half page up",
|
|
44
|
+
keys: ["pageup", "ctrl+u"],
|
|
45
|
+
run: (s) => s.moveAnchor(-s.halfPage, { preserveViewportRow: true }),
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "diff.half-down",
|
|
49
|
+
title: "Half page down",
|
|
50
|
+
keys: ["pagedown", "ctrl+d", "ctrl+v"],
|
|
51
|
+
run: (s) => s.moveAnchor(s.halfPage, { preserveViewportRow: true }),
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
// Jump-by-8 (shift+arrow / meta+arrow / shift+letter / meta+letter)
|
|
55
|
+
{
|
|
56
|
+
id: "diff.jump-up",
|
|
57
|
+
title: "Jump up",
|
|
58
|
+
keys: ["shift+up", "shift+k", "meta+up", "meta+k"],
|
|
59
|
+
run: (s) => s.moveAnchor(-8),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "diff.jump-down",
|
|
63
|
+
title: "Jump down",
|
|
64
|
+
keys: ["shift+down", "shift+j", "meta+down", "meta+j"],
|
|
65
|
+
run: (s) => s.moveAnchor(8),
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
// Vim count prefixes ("2 k", "15 j", etc.)
|
|
69
|
+
...countedVerticalBindings<DiffViewCtx>((s, delta) => s.moveAnchor(delta)),
|
|
70
|
+
|
|
71
|
+
// Single-step
|
|
72
|
+
{ id: "diff.up", title: "Up", keys: ["up", "k"], run: (s) => s.moveAnchor(-1) },
|
|
73
|
+
{ id: "diff.down", title: "Down", keys: ["down", "j"], run: (s) => s.moveAnchor(1) },
|
|
74
|
+
|
|
75
|
+
// Side selection
|
|
76
|
+
{ id: "diff.side-left", title: "Old side", keys: ["left", "h"], run: (s) => s.selectSide("LEFT") },
|
|
77
|
+
{ id: "diff.side-right", title: "New side", keys: ["right", "l"], run: (s) => s.selectSide("RIGHT") },
|
|
78
|
+
|
|
79
|
+
// File nav
|
|
80
|
+
{ id: "diff.next-file", title: "Next file", keys: ["]"], run: (s) => s.nextFile() },
|
|
81
|
+
{ id: "diff.previous-file", title: "Previous file", keys: ["["], run: (s) => s.previousFile() },
|
|
82
|
+
|
|
83
|
+
// Boundary jumps + align
|
|
84
|
+
{ id: "diff.first", title: "First comment", keys: ["g g"], run: (s) => s.moveAnchorToBoundary("first") },
|
|
85
|
+
{ id: "diff.last", title: "Last comment", keys: ["shift+g"], run: (s) => s.moveAnchorToBoundary("last") },
|
|
86
|
+
{ id: "diff.align-center", title: "Align center", keys: ["z z"], run: (s) => s.alignAnchor("center") },
|
|
87
|
+
{ id: "diff.align-top", title: "Align top", keys: ["z t"], run: (s) => s.alignAnchor("top") },
|
|
88
|
+
{ id: "diff.align-bottom", title: "Align bottom", keys: ["z b"], run: (s) => s.alignAnchor("bottom") },
|
|
89
|
+
|
|
90
|
+
{ id: "diff.open-browser", title: "Open in browser", keys: ["o"], run: (s) => s.openInBrowser() },
|
|
91
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { context } from "@ghui/keymap"
|
|
2
|
+
|
|
3
|
+
export interface FilterModeCtx {
|
|
4
|
+
readonly cancel: () => void
|
|
5
|
+
readonly commit: () => void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const Filter = context<FilterModeCtx>()
|
|
9
|
+
|
|
10
|
+
export const filterModeKeymap = Filter(
|
|
11
|
+
{ id: "filter-mode.cancel", title: "Cancel filter", keys: ["escape"], run: (s) => s.cancel() },
|
|
12
|
+
{ id: "filter-mode.commit", title: "Apply filter", keys: ["return"], run: (s) => s.commit() },
|
|
13
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CommandConfig } from "@ghui/keymap"
|
|
2
|
+
|
|
3
|
+
const MAX_COUNT_PREFIX = 99
|
|
4
|
+
|
|
5
|
+
const countSequence = (count: number, key: string) =>
|
|
6
|
+
`${String(count).split("").join(" ")} ${key}`
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Vim-style "count prefix" navigation: `2 k` moves by 2, `15 j` by 15, etc.
|
|
10
|
+
* Generates bindings for `count` × {k, up, j, down} for count ∈ [1, 99].
|
|
11
|
+
*
|
|
12
|
+
* Each generated binding is anonymous (no meta) — they don't belong in the
|
|
13
|
+
* palette. Only `moveBy` semantics matter.
|
|
14
|
+
*/
|
|
15
|
+
export const countedVerticalBindings = <C>(
|
|
16
|
+
moveBy: (ctx: C, delta: number) => void,
|
|
17
|
+
): readonly CommandConfig<C>[] => {
|
|
18
|
+
const out: CommandConfig<C>[] = []
|
|
19
|
+
for (let count = 1; count <= MAX_COUNT_PREFIX; count++) {
|
|
20
|
+
out.push({ keys: [countSequence(count, "k"), countSequence(count, "up")], run: (s) => moveBy(s, -count) })
|
|
21
|
+
out.push({ keys: [countSequence(count, "j"), countSequence(count, "down")], run: (s) => moveBy(s, count) })
|
|
22
|
+
}
|
|
23
|
+
return out
|
|
24
|
+
}
|