@kitlangton/ghui 0.1.22 → 0.2.1
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 +5 -4
- package/src/App.tsx +381 -357
- package/src/appCommands.ts +16 -4
- package/src/domain.ts +11 -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 +41 -6
- package/src/services/MockGitHubService.ts +37 -2
- package/src/themeStore.ts +28 -11
- package/src/ui/CommandPalette.tsx +115 -65
- package/src/ui/DetailsPane.tsx +152 -32
- package/src/ui/LoadingLogo.tsx +75 -0
- package/src/ui/PullRequestDiffPane.tsx +19 -17
- package/src/ui/comments.tsx +143 -0
- package/src/ui/diff.ts +194 -8
- 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/keyboard/createKeymap.ts +0 -25
- package/src/keyboard/useAppCommandRegistry.ts +0 -30
- package/src/keyboard/useScopedBindings.ts +0 -66
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,6 +20,7 @@ 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
25
|
readonly openSelectedDiffComment: () => void
|
|
25
26
|
readonly toggleDiffCommentRange: () => void
|
|
@@ -50,6 +51,7 @@ interface BuildAppCommandsInput {
|
|
|
50
51
|
readonly diffReady: boolean
|
|
51
52
|
readonly effectiveDiffRenderView: DiffView
|
|
52
53
|
readonly diffWrapMode: DiffWrapMode
|
|
54
|
+
readonly diffWhitespaceMode: DiffWhitespaceMode
|
|
53
55
|
readonly readyDiffFileCount: number
|
|
54
56
|
readonly diffFileIndex: number
|
|
55
57
|
readonly diffRangeActive: boolean
|
|
@@ -75,6 +77,7 @@ export const buildAppCommands = ({
|
|
|
75
77
|
diffReady,
|
|
76
78
|
effectiveDiffRenderView,
|
|
77
79
|
diffWrapMode,
|
|
80
|
+
diffWhitespaceMode,
|
|
78
81
|
readyDiffFileCount,
|
|
79
82
|
diffFileIndex,
|
|
80
83
|
diffRangeActive,
|
|
@@ -128,7 +131,7 @@ export const buildAppCommands = ({
|
|
|
128
131
|
subtitle: "Fetch the latest queue from GitHub",
|
|
129
132
|
shortcut: "r",
|
|
130
133
|
keywords: ["reload", "sync"],
|
|
131
|
-
run: () => actions.refreshPullRequests("Refreshed"),
|
|
134
|
+
run: () => actions.refreshPullRequests("Refreshed", { resetTransientState: true }),
|
|
132
135
|
}),
|
|
133
136
|
defineCommand({
|
|
134
137
|
id: "filter.open",
|
|
@@ -201,7 +204,7 @@ export const buildAppCommands = ({
|
|
|
201
204
|
}),
|
|
202
205
|
forSelected({
|
|
203
206
|
id: "diff.open",
|
|
204
|
-
title: "Open
|
|
207
|
+
title: "Open diff",
|
|
205
208
|
scope: "Diff",
|
|
206
209
|
shortcut: "d",
|
|
207
210
|
keywords: ["files", "patch"],
|
|
@@ -244,6 +247,15 @@ export const buildAppCommands = ({
|
|
|
244
247
|
disabledReason: diffFullView ? null : "Open a diff first.",
|
|
245
248
|
run: actions.toggleDiffWrapMode,
|
|
246
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
|
+
}),
|
|
247
259
|
defineCommand({
|
|
248
260
|
id: "diff.next-file",
|
|
249
261
|
title: "Next diff file",
|
package/src/domain.ts
CHANGED
|
@@ -78,6 +78,17 @@ export interface PullRequestReviewComment {
|
|
|
78
78
|
readonly url: string | null
|
|
79
79
|
}
|
|
80
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
|
+
|
|
81
92
|
export interface PullRequestItem {
|
|
82
93
|
readonly repository: string
|
|
83
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 { KeymapProvider } from "@opentui/keymap/react"
|
|
7
|
-
import { createKeymap } from "./keyboard/createKeymap.js"
|
|
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 = createKeymap(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
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { context } from "@ghui/keymap"
|
|
2
|
+
|
|
3
|
+
export interface LabelModalCtx {
|
|
4
|
+
readonly closeModal: () => void
|
|
5
|
+
readonly toggleSelected: () => void
|
|
6
|
+
readonly moveSelection: (delta: -1 | 1) => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const Label = context<LabelModalCtx>()
|
|
10
|
+
|
|
11
|
+
export const labelModalKeymap = Label(
|
|
12
|
+
{ id: "label-modal.close", title: "Close", keys: ["escape"], run: (s) => s.closeModal() },
|
|
13
|
+
{ id: "label-modal.toggle", title: "Toggle label", keys: ["return"], run: (s) => s.toggleSelected() },
|
|
14
|
+
{ id: "label-modal.up", title: "Up", keys: ["k", "up"], run: (s) => s.moveSelection(-1) },
|
|
15
|
+
{ id: "label-modal.down", title: "Down", keys: ["j", "down"], run: (s) => s.moveSelection(1) },
|
|
16
|
+
)
|