@kitlangton/ghui 0.3.0 → 0.3.3
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/README.md +13 -3
- package/bin/ghui.js +6 -1
- package/dist/index.js +9456 -0
- package/package.json +7 -4
- package/src/App.tsx +0 -2779
- package/src/appCommands.ts +0 -409
- package/src/commands.ts +0 -73
- package/src/config.ts +0 -20
- package/src/date.ts +0 -20
- package/src/domain.ts +0 -149
- package/src/errors.ts +0 -10
- package/src/index.tsx +0 -50
- package/src/keyboard/opentuiAdapter.ts +0 -43
- package/src/keymap/all.ts +0 -116
- package/src/keymap/changedFilesModal.ts +0 -23
- package/src/keymap/closeModal.ts +0 -13
- package/src/keymap/commandPalette.ts +0 -16
- package/src/keymap/commentModal.ts +0 -73
- package/src/keymap/commentThreadModal.ts +0 -24
- package/src/keymap/detailView.ts +0 -30
- package/src/keymap/diffView.ts +0 -93
- package/src/keymap/filterMode.ts +0 -13
- package/src/keymap/helpers.ts +0 -24
- package/src/keymap/labelModal.ts +0 -16
- package/src/keymap/listNav.ts +0 -119
- package/src/keymap/mergeModal.ts +0 -23
- package/src/keymap/openRepositoryModal.ts +0 -13
- package/src/keymap/submitReviewModal.ts +0 -48
- package/src/keymap/themeModal.ts +0 -49
- package/src/mergeActions.ts +0 -88
- package/src/observability.ts +0 -46
- package/src/pullRequestCache.ts +0 -19
- package/src/pullRequestViews.ts +0 -43
- package/src/services/BrowserOpener.ts +0 -22
- package/src/services/Clipboard.ts +0 -46
- package/src/services/CommandRunner.ts +0 -91
- package/src/services/GitHubService.ts +0 -756
- package/src/services/MockGitHubService.ts +0 -182
- package/src/themeStore.ts +0 -60
- package/src/ui/CommandPalette.tsx +0 -176
- package/src/ui/DetailsPane.tsx +0 -656
- package/src/ui/FooterHints.tsx +0 -90
- package/src/ui/LoadingLogo.tsx +0 -75
- package/src/ui/PullRequestDiffPane.tsx +0 -249
- package/src/ui/PullRequestList.tsx +0 -194
- package/src/ui/colors.ts +0 -821
- package/src/ui/commentEditor.ts +0 -126
- package/src/ui/comments.tsx +0 -143
- package/src/ui/diff.ts +0 -650
- package/src/ui/diffStats.tsx +0 -25
- package/src/ui/modals.tsx +0 -964
- package/src/ui/primitives.tsx +0 -350
- package/src/ui/pullRequests.ts +0 -106
- package/src/ui/singleLineInput.ts +0 -26
- package/src/ui/spinner.ts +0 -1
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
import { Effect, Layer } from "effect"
|
|
2
|
-
import type { CheckItem, CreatePullRequestCommentInput, Mergeable, PullRequestConversationItem, PullRequestItem, PullRequestLabel, PullRequestMergeInfo, PullRequestPage, PullRequestQueueMode, PullRequestReviewComment, ReviewStatus } from "../domain.js"
|
|
3
|
-
import { GitHubService } from "./GitHubService.js"
|
|
4
|
-
|
|
5
|
-
export interface MockOptions {
|
|
6
|
-
readonly prCount: number
|
|
7
|
-
readonly repoCount?: number
|
|
8
|
-
readonly username?: string
|
|
9
|
-
readonly seed?: number
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const REVIEW_CYCLE: readonly ReviewStatus[] = ["approved", "changes", "review", "none", "draft"]
|
|
13
|
-
const MERGEABLE_CYCLE: readonly Mergeable[] = ["mergeable", "conflicting", "unknown"]
|
|
14
|
-
|
|
15
|
-
const synthCheckSummary = (passed: number, total: number): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> => {
|
|
16
|
-
const checks: readonly CheckItem[] = Array.from({ length: total }, (_, index) => ({
|
|
17
|
-
name: `check-${index}`,
|
|
18
|
-
status: "completed",
|
|
19
|
-
conclusion: index < passed ? "success" : "failure",
|
|
20
|
-
}))
|
|
21
|
-
if (total === 0) return { checkStatus: "none", checkSummary: null, checks: [] }
|
|
22
|
-
if (passed === total) return { checkStatus: "passing", checkSummary: `${passed}/${total}`, checks }
|
|
23
|
-
return { checkStatus: "failing", checkSummary: `${passed}/${total}`, checks }
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const synthLabels = (index: number): readonly PullRequestLabel[] => {
|
|
27
|
-
if (index % 5 === 0) return [{ name: "bug", color: "#d73a4a" }]
|
|
28
|
-
if (index % 7 === 0) return [{ name: "enhancement", color: "#a2eeef" }, { name: "tests", color: "#0e8a16" }]
|
|
29
|
-
return []
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const buildPullRequest = (index: number, options: Required<MockOptions>): PullRequestItem => {
|
|
33
|
-
const repoIndex = index % options.repoCount
|
|
34
|
-
const repository = `mock-org/repo-${repoIndex}`
|
|
35
|
-
const number = 1000 + index
|
|
36
|
-
const total = 8 + (index % 5)
|
|
37
|
-
const passed = total - (index % 3 === 0 ? 1 : 0)
|
|
38
|
-
const review = REVIEW_CYCLE[index % REVIEW_CYCLE.length]!
|
|
39
|
-
const createdAt = new Date(Date.now() - index * 86_400_000)
|
|
40
|
-
|
|
41
|
-
return {
|
|
42
|
-
repository,
|
|
43
|
-
author: options.username,
|
|
44
|
-
headRefOid: `deadbeef${index.toString(16).padStart(8, "0")}`,
|
|
45
|
-
number,
|
|
46
|
-
title: `Mock PR ${number}: example change ${index}`,
|
|
47
|
-
body: `This is mock pull request #${number}.\n\nLine A.\nLine B.`,
|
|
48
|
-
labels: synthLabels(index),
|
|
49
|
-
additions: 10 + index,
|
|
50
|
-
deletions: 5 + (index % 11),
|
|
51
|
-
changedFiles: 1 + (index % 7),
|
|
52
|
-
state: "open",
|
|
53
|
-
reviewStatus: review,
|
|
54
|
-
...synthCheckSummary(passed, total),
|
|
55
|
-
autoMergeEnabled: index % 11 === 0,
|
|
56
|
-
detailLoaded: true,
|
|
57
|
-
createdAt,
|
|
58
|
-
closedAt: null,
|
|
59
|
-
url: `https://github.com/${repository}/pull/${number}`,
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export const buildMockPullRequests = (options: MockOptions): readonly PullRequestItem[] => {
|
|
64
|
-
const resolved: Required<MockOptions> = {
|
|
65
|
-
prCount: options.prCount,
|
|
66
|
-
repoCount: options.repoCount ?? 4,
|
|
67
|
-
username: options.username ?? "mock-user",
|
|
68
|
-
seed: options.seed ?? 0,
|
|
69
|
-
}
|
|
70
|
-
return Array.from({ length: resolved.prCount }, (_, index) => buildPullRequest(index, resolved))
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const filterByView = (mode: PullRequestQueueMode, repository: string | null, source: readonly PullRequestItem[]) => {
|
|
74
|
-
if (mode === "repository") return repository ? source.filter((item) => item.repository === repository) : []
|
|
75
|
-
return source
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const pageItems = (source: readonly PullRequestItem[], cursor: string | null, pageSize: number): PullRequestPage => {
|
|
79
|
-
const start = cursor ? Number.parseInt(cursor, 10) : 0
|
|
80
|
-
const safeStart = Number.isFinite(start) && start >= 0 ? start : 0
|
|
81
|
-
const safePageSize = Math.max(1, Math.min(100, pageSize))
|
|
82
|
-
const end = Math.min(source.length, safeStart + safePageSize)
|
|
83
|
-
return {
|
|
84
|
-
items: source.slice(safeStart, end),
|
|
85
|
-
endCursor: end > safeStart ? String(end) : null,
|
|
86
|
-
hasNextPage: end < source.length,
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const mockDiff = `diff --git a/src/mockDiff.ts b/src/mockDiff.ts
|
|
91
|
-
--- a/src/mockDiff.ts
|
|
92
|
-
+++ b/src/mockDiff.ts
|
|
93
|
-
@@ -1,6 +1,6 @@
|
|
94
|
-
export const before = true
|
|
95
|
-
-const oldOne = 1
|
|
96
|
-
+const newOne = 1
|
|
97
|
-
- sameName()
|
|
98
|
-
+ sameName()
|
|
99
|
-
-const oldTwo = 2
|
|
100
|
-
+const newTwo = 2
|
|
101
|
-
export const after = true`
|
|
102
|
-
|
|
103
|
-
export const MockGitHubService = {
|
|
104
|
-
layer: (options: MockOptions) => {
|
|
105
|
-
const items = buildMockPullRequests(options)
|
|
106
|
-
const username = options.username ?? "mock-user"
|
|
107
|
-
const summaryItems = items.map((item) => ({
|
|
108
|
-
...item,
|
|
109
|
-
body: "",
|
|
110
|
-
labels: [],
|
|
111
|
-
additions: 0,
|
|
112
|
-
deletions: 0,
|
|
113
|
-
changedFiles: 0,
|
|
114
|
-
detailLoaded: false,
|
|
115
|
-
} satisfies PullRequestItem))
|
|
116
|
-
const findPullRequest = (repository: string, number: number) => items.find((item) => item.repository === repository && item.number === number) ?? items[0]!
|
|
117
|
-
const conversationItems = (repository: string, number: number): readonly PullRequestConversationItem[] => [
|
|
118
|
-
{
|
|
119
|
-
_tag: "comment",
|
|
120
|
-
id: `mock-comment:${repository}:${number}:1`,
|
|
121
|
-
author: "mock-reviewer",
|
|
122
|
-
body: `Top-level discussion for #${number}. This should appear after the summary with its own separator.`,
|
|
123
|
-
createdAt: new Date(Date.now() - 3_600_000),
|
|
124
|
-
url: null,
|
|
125
|
-
},
|
|
126
|
-
{
|
|
127
|
-
_tag: "review-comment",
|
|
128
|
-
id: `mock-review:${repository}:${number}:1`,
|
|
129
|
-
author: "mock-reviewer",
|
|
130
|
-
body: "Inline review comment rendered in the same conversation stream.",
|
|
131
|
-
createdAt: new Date(Date.now() - 1_800_000),
|
|
132
|
-
url: null,
|
|
133
|
-
path: "src/App.tsx",
|
|
134
|
-
line: 42,
|
|
135
|
-
side: "RIGHT",
|
|
136
|
-
},
|
|
137
|
-
]
|
|
138
|
-
|
|
139
|
-
return Layer.succeed(
|
|
140
|
-
GitHubService,
|
|
141
|
-
GitHubService.of({
|
|
142
|
-
listOpenPullRequests: (mode: PullRequestQueueMode, repository: string | null) => Effect.succeed(filterByView(mode, repository, summaryItems)),
|
|
143
|
-
listOpenPullRequestPage: (input) => Effect.succeed(pageItems(filterByView(input.mode, input.repository, summaryItems), input.cursor, input.pageSize)),
|
|
144
|
-
listOpenPullRequestDetails: (mode: PullRequestQueueMode, repository: string | null) => Effect.succeed(filterByView(mode, repository, items)),
|
|
145
|
-
getPullRequestDetails: (repository, number) => Effect.succeed(findPullRequest(repository, number)),
|
|
146
|
-
getAuthenticatedUser: () => Effect.succeed(username),
|
|
147
|
-
getPullRequestDiff: (_repo, _number) => Effect.succeed(mockDiff),
|
|
148
|
-
listPullRequestComments: (_repo, _number) => Effect.succeed([] as readonly PullRequestReviewComment[]),
|
|
149
|
-
listPullRequestConversation: (repository, number) => Effect.succeed(conversationItems(repository, number)),
|
|
150
|
-
getPullRequestMergeInfo: (repository, number) => Effect.succeed({
|
|
151
|
-
repository,
|
|
152
|
-
number,
|
|
153
|
-
title: `Mock PR ${number}`,
|
|
154
|
-
state: "open",
|
|
155
|
-
isDraft: false,
|
|
156
|
-
mergeable: MERGEABLE_CYCLE[number % MERGEABLE_CYCLE.length]!,
|
|
157
|
-
reviewStatus: "approved",
|
|
158
|
-
checkStatus: "passing",
|
|
159
|
-
checkSummary: "10/10",
|
|
160
|
-
autoMergeEnabled: false,
|
|
161
|
-
} satisfies PullRequestMergeInfo),
|
|
162
|
-
mergePullRequest: () => Effect.void,
|
|
163
|
-
closePullRequest: () => Effect.void,
|
|
164
|
-
createPullRequestComment: (input: CreatePullRequestCommentInput) => Effect.succeed({
|
|
165
|
-
id: `mock:${Date.now()}`,
|
|
166
|
-
path: input.path,
|
|
167
|
-
line: input.line,
|
|
168
|
-
side: input.side,
|
|
169
|
-
author: username,
|
|
170
|
-
body: input.body,
|
|
171
|
-
createdAt: new Date(),
|
|
172
|
-
url: null,
|
|
173
|
-
} satisfies PullRequestReviewComment),
|
|
174
|
-
submitPullRequestReview: () => Effect.void,
|
|
175
|
-
toggleDraftStatus: () => Effect.void,
|
|
176
|
-
listRepoLabels: () => Effect.succeed([]),
|
|
177
|
-
addPullRequestLabel: () => Effect.void,
|
|
178
|
-
removePullRequestLabel: () => Effect.void,
|
|
179
|
-
}),
|
|
180
|
-
)
|
|
181
|
-
},
|
|
182
|
-
}
|
package/src/themeStore.ts
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { mkdir } from "node:fs/promises"
|
|
2
|
-
import { homedir } from "node:os"
|
|
3
|
-
import { dirname, join } from "node:path"
|
|
4
|
-
import { Effect, Schema } from "effect"
|
|
5
|
-
import { isThemeId, type ThemeId } from "./ui/colors.js"
|
|
6
|
-
import { DiffWhitespaceMode } from "./ui/diff.js"
|
|
7
|
-
|
|
8
|
-
interface StoredConfig {
|
|
9
|
-
readonly theme?: unknown
|
|
10
|
-
readonly diffWhitespaceMode?: unknown
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const configDirectory = () => {
|
|
14
|
-
if (process.env.GHUI_CONFIG_DIR) return process.env.GHUI_CONFIG_DIR
|
|
15
|
-
if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, "ghui")
|
|
16
|
-
if (process.platform === "win32" && process.env.APPDATA) return join(process.env.APPDATA, "ghui")
|
|
17
|
-
return join(homedir(), ".config", "ghui")
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export const configPath = () => join(configDirectory(), "config.json")
|
|
21
|
-
|
|
22
|
-
const parseConfig = (text: string): StoredConfig => {
|
|
23
|
-
const value = JSON.parse(text) as unknown
|
|
24
|
-
return value && typeof value === "object" ? value : {}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const readStoredConfig = async () => {
|
|
28
|
-
const file = Bun.file(configPath())
|
|
29
|
-
return await file.exists() ? parseConfig(await file.text()) : {}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const writeStoredConfig = async (config: StoredConfig) => {
|
|
33
|
-
const path = configPath()
|
|
34
|
-
await mkdir(dirname(path), { recursive: true })
|
|
35
|
-
await Bun.write(path, `${JSON.stringify(config, null, "\t")}\n`)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export const loadStoredThemeId: Effect.Effect<ThemeId> = Effect.catchCause(Effect.tryPromise(async () => {
|
|
39
|
-
const config = await readStoredConfig()
|
|
40
|
-
return isThemeId(config.theme) ? config.theme : "ghui"
|
|
41
|
-
}), () => Effect.succeed("ghui" satisfies ThemeId))
|
|
42
|
-
|
|
43
|
-
export const loadStoredDiffWhitespaceMode: Effect.Effect<DiffWhitespaceMode> = Effect.catchCause(Effect.tryPromise(async () => {
|
|
44
|
-
const config = await readStoredConfig()
|
|
45
|
-
return Schema.is(DiffWhitespaceMode)(config.diffWhitespaceMode) ? config.diffWhitespaceMode : "ignore"
|
|
46
|
-
}), () => Effect.succeed("ignore" satisfies DiffWhitespaceMode))
|
|
47
|
-
|
|
48
|
-
export const saveStoredThemeId = (theme: ThemeId): Effect.Effect<void> => Effect.tryPromise(async () => {
|
|
49
|
-
const config = await readStoredConfig()
|
|
50
|
-
if (config.theme === theme) return
|
|
51
|
-
|
|
52
|
-
await writeStoredConfig({ ...config, theme })
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
export const saveStoredDiffWhitespaceMode = (diffWhitespaceMode: DiffWhitespaceMode): Effect.Effect<void> => Effect.tryPromise(async () => {
|
|
56
|
-
const config = await readStoredConfig()
|
|
57
|
-
if (config.diffWhitespaceMode === diffWhitespaceMode) return
|
|
58
|
-
|
|
59
|
-
await writeStoredConfig({ ...config, diffWhitespaceMode })
|
|
60
|
-
})
|
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
import { TextAttributes, type MouseEvent } from "@opentui/core"
|
|
2
|
-
import { useEffect, useMemo, useState } from "react"
|
|
3
|
-
import type { AppCommand } from "../commands.js"
|
|
4
|
-
import { clampCommandIndex } from "../commands.js"
|
|
5
|
-
import { colors } from "./colors.js"
|
|
6
|
-
import { scrollTopForVisibleLine } from "./diff.js"
|
|
7
|
-
import { centerCell, Filler, fitCell, HintRow, PlainLine, searchModalDims, SearchModalFrame, TextLine, trimCell } from "./primitives.js"
|
|
8
|
-
|
|
9
|
-
const scopeLabels = {
|
|
10
|
-
Global: "App",
|
|
11
|
-
View: "View",
|
|
12
|
-
"Pull request": "Pull Request",
|
|
13
|
-
Diff: "Diff",
|
|
14
|
-
Navigation: "Navigation",
|
|
15
|
-
System: "System",
|
|
16
|
-
} as const satisfies Record<AppCommand["scope"], string>
|
|
17
|
-
|
|
18
|
-
export type CommandPaletteRow =
|
|
19
|
-
| { readonly _tag: "section"; readonly scope: AppCommand["scope"] }
|
|
20
|
-
| { readonly _tag: "spacer" }
|
|
21
|
-
| { readonly _tag: "command"; readonly command: AppCommand; readonly commandIndex: number }
|
|
22
|
-
|
|
23
|
-
export const buildCommandPaletteRows = (commands: readonly AppCommand[]): readonly CommandPaletteRow[] => {
|
|
24
|
-
const rows: CommandPaletteRow[] = []
|
|
25
|
-
let previousScope: AppCommand["scope"] | null = null
|
|
26
|
-
for (let commandIndex = 0; commandIndex < commands.length; commandIndex++) {
|
|
27
|
-
const command = commands[commandIndex]!
|
|
28
|
-
if (command.scope !== previousScope) {
|
|
29
|
-
if (previousScope !== null) rows.push({ _tag: "spacer" })
|
|
30
|
-
rows.push({ _tag: "section", scope: command.scope })
|
|
31
|
-
previousScope = command.scope
|
|
32
|
-
}
|
|
33
|
-
rows.push({ _tag: "command", command, commandIndex })
|
|
34
|
-
}
|
|
35
|
-
return rows
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export const commandPaletteSelectedRowIndex = (rows: readonly CommandPaletteRow[], selectedCommandIndex: number) =>
|
|
39
|
-
Math.max(0, rows.findIndex((row) => row._tag === "command" && row.commandIndex === selectedCommandIndex))
|
|
40
|
-
|
|
41
|
-
export const commandPaletteScrollTop = ({
|
|
42
|
-
current,
|
|
43
|
-
rowsLength,
|
|
44
|
-
listHeight,
|
|
45
|
-
selectedRowIndex,
|
|
46
|
-
}: {
|
|
47
|
-
readonly current: number
|
|
48
|
-
readonly rowsLength: number
|
|
49
|
-
readonly listHeight: number
|
|
50
|
-
readonly selectedRowIndex: number
|
|
51
|
-
}) => {
|
|
52
|
-
if (rowsLength <= listHeight) return 0
|
|
53
|
-
const maxScrollTop = Math.max(0, rowsLength - listHeight)
|
|
54
|
-
const clampScrollTop = (value: number) => Math.max(0, Math.min(value, maxScrollTop))
|
|
55
|
-
return clampScrollTop(scrollTopForVisibleLine(current, listHeight, selectedRowIndex, 0))
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export const commandPaletteClampScrollTop = (rowsLength: number, listHeight: number, value: number) =>
|
|
59
|
-
Math.max(0, Math.min(value, Math.max(0, rowsLength - listHeight)))
|
|
60
|
-
|
|
61
|
-
export const CommandPalette = ({
|
|
62
|
-
commands,
|
|
63
|
-
query,
|
|
64
|
-
selectedIndex,
|
|
65
|
-
modalWidth,
|
|
66
|
-
modalHeight,
|
|
67
|
-
offsetLeft,
|
|
68
|
-
offsetTop,
|
|
69
|
-
onSelectCommandIndex,
|
|
70
|
-
onRunCommand,
|
|
71
|
-
}: {
|
|
72
|
-
commands: readonly AppCommand[]
|
|
73
|
-
query: string
|
|
74
|
-
selectedIndex: number
|
|
75
|
-
modalWidth: number
|
|
76
|
-
modalHeight: number
|
|
77
|
-
offsetLeft: number
|
|
78
|
-
offsetTop: number
|
|
79
|
-
onSelectCommandIndex: (index: number) => void
|
|
80
|
-
onRunCommand: (command: AppCommand) => void
|
|
81
|
-
}) => {
|
|
82
|
-
const { bodyHeight: listHeight, rowWidth } = searchModalDims(modalWidth, modalHeight)
|
|
83
|
-
const clampedIndex = clampCommandIndex(selectedIndex, commands)
|
|
84
|
-
const [scrollTop, setScrollTop] = useState(0)
|
|
85
|
-
const rows = useMemo(() => buildCommandPaletteRows(commands), [commands])
|
|
86
|
-
const selectedRowIndex = commandPaletteSelectedRowIndex(rows, clampedIndex)
|
|
87
|
-
const visibleRows = rows.slice(scrollTop, scrollTop + listHeight)
|
|
88
|
-
const bottomPaddingRows = Math.max(0, listHeight - visibleRows.length)
|
|
89
|
-
const countText = commands.length === 1 ? "1 command" : `${commands.length} commands`
|
|
90
|
-
const emptyTopRows = Math.max(0, Math.floor((listHeight - 1) / 2))
|
|
91
|
-
const emptyBottomRows = Math.max(0, listHeight - emptyTopRows - 1)
|
|
92
|
-
const runCommandOnMouseDown = (command: AppCommand) => (event: MouseEvent) => {
|
|
93
|
-
if (event.button !== 0) return
|
|
94
|
-
event.preventDefault()
|
|
95
|
-
event.stopPropagation()
|
|
96
|
-
onRunCommand(command)
|
|
97
|
-
}
|
|
98
|
-
const selectCommandOnMouse = (commandIndex: number) => (event: MouseEvent) => {
|
|
99
|
-
onSelectCommandIndex(commandIndex)
|
|
100
|
-
event.stopPropagation()
|
|
101
|
-
}
|
|
102
|
-
const handleMouseScroll = (event: MouseEvent) => {
|
|
103
|
-
if (!event.scroll || rows.length <= listHeight) return
|
|
104
|
-
const delta = Math.max(1, Math.ceil(event.scroll.delta))
|
|
105
|
-
const direction = event.scroll.direction === "down" || event.scroll.direction === "right" ? 1 : -1
|
|
106
|
-
setScrollTop((current) => commandPaletteClampScrollTop(rows.length, listHeight, current + direction * delta))
|
|
107
|
-
event.preventDefault()
|
|
108
|
-
event.stopPropagation()
|
|
109
|
-
}
|
|
110
|
-
const content = rows.length === 0 ? (
|
|
111
|
-
<>
|
|
112
|
-
<Filler rows={emptyTopRows} prefix="top" />
|
|
113
|
-
<PlainLine text={centerCell("No matching command", rowWidth)} fg={colors.muted} />
|
|
114
|
-
<Filler rows={emptyBottomRows} prefix="bottom" />
|
|
115
|
-
</>
|
|
116
|
-
) : (
|
|
117
|
-
<>
|
|
118
|
-
{visibleRows.map((row, index) => {
|
|
119
|
-
const rowIndex = scrollTop + index
|
|
120
|
-
if (row._tag === "spacer") {
|
|
121
|
-
return <PlainLine key={`spacer-${rowIndex}`} text="" />
|
|
122
|
-
}
|
|
123
|
-
if (row._tag === "section") {
|
|
124
|
-
return <PlainLine key={`section-${row.scope}-${rowIndex}`} text={fitCell(` ${scopeLabels[row.scope].toUpperCase()}`, rowWidth)} fg={colors.muted} />
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const { command, commandIndex } = row
|
|
128
|
-
const isSelected = commandIndex === clampedIndex
|
|
129
|
-
const shortcut = command.shortcut ? trimCell(command.shortcut, 16) : ""
|
|
130
|
-
const shortcutWidth = shortcut.length === 0 ? 0 : Math.min(18, Math.max(6, shortcut.length + 1))
|
|
131
|
-
const trailingPadding = shortcut.length === 0 ? 0 : 1
|
|
132
|
-
// Layout: "▸ " (2) + title + " " (2) + subtitle + filler + shortcut + " " (1)
|
|
133
|
-
const SELECTOR_WIDTH = 2
|
|
134
|
-
const titleAvailable = Math.max(8, rowWidth - SELECTOR_WIDTH - shortcutWidth - trailingPadding)
|
|
135
|
-
const titleText = trimCell(command.title, Math.min(titleAvailable, 36))
|
|
136
|
-
const subtitleSpace = Math.max(0, titleAvailable - titleText.length - 2)
|
|
137
|
-
const subtitleText = command.subtitle && subtitleSpace > 4 ? trimCell(command.subtitle, subtitleSpace) : ""
|
|
138
|
-
const fillerWidth = Math.max(0, titleAvailable - titleText.length - (subtitleText ? 2 + subtitleText.length : 0))
|
|
139
|
-
|
|
140
|
-
return (
|
|
141
|
-
<box key={command.id} height={1} onMouseDown={runCommandOnMouseDown(command)} onMouseMove={selectCommandOnMouse(commandIndex)} onMouseOver={selectCommandOnMouse(commandIndex)}>
|
|
142
|
-
<TextLine width={rowWidth} bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
|
|
143
|
-
<span fg={isSelected ? colors.accent : colors.muted}>{isSelected ? "▸" : " "}</span>
|
|
144
|
-
<span> </span>
|
|
145
|
-
{isSelected ? <span attributes={TextAttributes.BOLD}>{titleText}</span> : <span>{titleText}</span>}
|
|
146
|
-
{subtitleText ? <span fg={colors.muted}>{` ${subtitleText}`}</span> : null}
|
|
147
|
-
{fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
|
|
148
|
-
{shortcutWidth > 0 ? <span fg={colors.muted}>{fitCell(shortcut, shortcutWidth, "right")}</span> : null}
|
|
149
|
-
{trailingPadding > 0 ? <span> </span> : null}
|
|
150
|
-
</TextLine>
|
|
151
|
-
</box>
|
|
152
|
-
)
|
|
153
|
-
})}
|
|
154
|
-
<Filler rows={bottomPaddingRows} prefix="pad" />
|
|
155
|
-
</>
|
|
156
|
-
)
|
|
157
|
-
useEffect(() => {
|
|
158
|
-
setScrollTop((current) => commandPaletteScrollTop({ current, rowsLength: rows.length, listHeight, selectedRowIndex }))
|
|
159
|
-
}, [listHeight, rows.length, selectedRowIndex])
|
|
160
|
-
return (
|
|
161
|
-
<SearchModalFrame
|
|
162
|
-
left={offsetLeft}
|
|
163
|
-
top={offsetTop}
|
|
164
|
-
width={modalWidth}
|
|
165
|
-
height={modalHeight}
|
|
166
|
-
title="Commands"
|
|
167
|
-
query={query}
|
|
168
|
-
placeholder="Search"
|
|
169
|
-
countText={countText}
|
|
170
|
-
onBodyMouseScroll={handleMouseScroll}
|
|
171
|
-
footer={<HintRow items={[{ key: "↑↓", label: "select" }, { key: "enter", label: "run" }, { key: "esc", label: "close" }]} />}
|
|
172
|
-
>
|
|
173
|
-
{content}
|
|
174
|
-
</SearchModalFrame>
|
|
175
|
-
)
|
|
176
|
-
}
|