kajji 0.1.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/LICENSE +21 -0
- package/README.md +128 -0
- package/bin/kajji.js +2 -0
- package/package.json +56 -0
- package/src/App.tsx +229 -0
- package/src/commander/bookmarks.ts +129 -0
- package/src/commander/diff.ts +186 -0
- package/src/commander/executor.ts +285 -0
- package/src/commander/files.ts +87 -0
- package/src/commander/log.ts +99 -0
- package/src/commander/operations.ts +313 -0
- package/src/commander/types.ts +21 -0
- package/src/components/AnsiText.tsx +77 -0
- package/src/components/BorderBox.tsx +124 -0
- package/src/components/FileTreeList.tsx +105 -0
- package/src/components/Layout.tsx +48 -0
- package/src/components/Panel.tsx +143 -0
- package/src/components/RevisionPicker.tsx +165 -0
- package/src/components/StatusBar.tsx +158 -0
- package/src/components/modals/BookmarkNameModal.tsx +170 -0
- package/src/components/modals/DescribeModal.tsx +124 -0
- package/src/components/modals/HelpModal.tsx +372 -0
- package/src/components/modals/RevisionPickerModal.tsx +70 -0
- package/src/components/modals/UndoModal.tsx +75 -0
- package/src/components/panels/BookmarksPanel.tsx +768 -0
- package/src/components/panels/CommandLogPanel.tsx +40 -0
- package/src/components/panels/LogPanel.tsx +774 -0
- package/src/components/panels/MainArea.tsx +354 -0
- package/src/context/command.tsx +106 -0
- package/src/context/commandlog.tsx +45 -0
- package/src/context/dialog.tsx +217 -0
- package/src/context/focus.tsx +63 -0
- package/src/context/helper.tsx +24 -0
- package/src/context/keybind.tsx +51 -0
- package/src/context/loading.tsx +68 -0
- package/src/context/sync.tsx +868 -0
- package/src/context/theme.tsx +90 -0
- package/src/context/types.ts +51 -0
- package/src/index.tsx +15 -0
- package/src/keybind/index.ts +2 -0
- package/src/keybind/parser.ts +88 -0
- package/src/keybind/types.ts +83 -0
- package/src/theme/index.ts +3 -0
- package/src/theme/presets/lazygit.ts +45 -0
- package/src/theme/presets/opencode.ts +45 -0
- package/src/theme/types.ts +47 -0
- package/src/utils/double-click.ts +59 -0
- package/src/utils/file-tree.ts +154 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
2
|
+
import {
|
|
3
|
+
For,
|
|
4
|
+
Show,
|
|
5
|
+
createEffect,
|
|
6
|
+
createMemo,
|
|
7
|
+
createSignal,
|
|
8
|
+
onCleanup,
|
|
9
|
+
onMount,
|
|
10
|
+
} from "solid-js"
|
|
11
|
+
import type { DiffStats } from "../../commander/operations"
|
|
12
|
+
import type { Commit } from "../../commander/types"
|
|
13
|
+
import { useCommand } from "../../context/command"
|
|
14
|
+
import { useFocus } from "../../context/focus"
|
|
15
|
+
import { type CommitDetails, useSync } from "../../context/sync"
|
|
16
|
+
import { useTheme } from "../../context/theme"
|
|
17
|
+
import { AnsiText } from "../AnsiText"
|
|
18
|
+
import { Panel } from "../Panel"
|
|
19
|
+
|
|
20
|
+
const INITIAL_LIMIT = 1000
|
|
21
|
+
const LIMIT_INCREMENT = 200
|
|
22
|
+
const LOAD_THRESHOLD = 200
|
|
23
|
+
|
|
24
|
+
function formatTimestamp(timestamp: string): string {
|
|
25
|
+
// Input: "2026-01-02 14:30:45 -0800"
|
|
26
|
+
// Output: "Thu Jan 2 14:30:45 2026 -0800"
|
|
27
|
+
const match = timestamp.match(
|
|
28
|
+
/(\d{4})-(\d{2})-(\d{2}) (\d{2}:\d{2}:\d{2}) (.+)/,
|
|
29
|
+
)
|
|
30
|
+
if (!match) return timestamp
|
|
31
|
+
|
|
32
|
+
const [, year, month, day, time, tz] = match
|
|
33
|
+
const date = new Date(`${year}-${month}-${day}T${time}`)
|
|
34
|
+
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
|
35
|
+
const monthNames = [
|
|
36
|
+
"Jan",
|
|
37
|
+
"Feb",
|
|
38
|
+
"Mar",
|
|
39
|
+
"Apr",
|
|
40
|
+
"May",
|
|
41
|
+
"Jun",
|
|
42
|
+
"Jul",
|
|
43
|
+
"Aug",
|
|
44
|
+
"Sep",
|
|
45
|
+
"Oct",
|
|
46
|
+
"Nov",
|
|
47
|
+
"Dec",
|
|
48
|
+
]
|
|
49
|
+
const dayName = dayNames[date.getDay()]
|
|
50
|
+
const monthName = monthNames[date.getMonth()]
|
|
51
|
+
const dayNum = date.getDate()
|
|
52
|
+
|
|
53
|
+
return `${dayName} ${monthName} ${dayNum} ${time} ${year} ${tz}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function FileStats(props: { stats: DiffStats; maxWidth: number }) {
|
|
57
|
+
const { colors } = useTheme()
|
|
58
|
+
const s = () => props.stats
|
|
59
|
+
|
|
60
|
+
const separatorWidth = 3 // " | "
|
|
61
|
+
const barMargin = 2 // margin on right side
|
|
62
|
+
|
|
63
|
+
// Scale +/- counts to fit within available width while preserving ratio
|
|
64
|
+
const scaleBar = (
|
|
65
|
+
insertions: number,
|
|
66
|
+
deletions: number,
|
|
67
|
+
availableWidth: number,
|
|
68
|
+
) => {
|
|
69
|
+
const total = insertions + deletions
|
|
70
|
+
if (total === 0) return { plus: 0, minus: 0 }
|
|
71
|
+
if (total <= availableWidth) return { plus: insertions, minus: deletions }
|
|
72
|
+
|
|
73
|
+
// Scale down proportionally
|
|
74
|
+
const scale = availableWidth / total
|
|
75
|
+
const scaledPlus = Math.round(insertions * scale)
|
|
76
|
+
const scaledMinus = Math.round(deletions * scale)
|
|
77
|
+
|
|
78
|
+
// Ensure at least 1 char if there were any changes
|
|
79
|
+
const plus = insertions > 0 ? Math.max(1, scaledPlus) : 0
|
|
80
|
+
const minus = deletions > 0 ? Math.max(1, scaledMinus) : 0
|
|
81
|
+
|
|
82
|
+
return { plus, minus }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<>
|
|
87
|
+
<text> </text>
|
|
88
|
+
<For each={s().files}>
|
|
89
|
+
{(file) => {
|
|
90
|
+
// Calculate available width for bar based on actual path length
|
|
91
|
+
const pathLen = file.path.length
|
|
92
|
+
const availableBarWidth = Math.max(
|
|
93
|
+
1,
|
|
94
|
+
props.maxWidth - pathLen - separatorWidth - barMargin,
|
|
95
|
+
)
|
|
96
|
+
const bar = scaleBar(
|
|
97
|
+
file.insertions,
|
|
98
|
+
file.deletions,
|
|
99
|
+
availableBarWidth,
|
|
100
|
+
)
|
|
101
|
+
return (
|
|
102
|
+
<text wrapMode="none">
|
|
103
|
+
{file.path}
|
|
104
|
+
{" | "}
|
|
105
|
+
<span style={{ fg: colors().success }}>
|
|
106
|
+
{"+".repeat(bar.plus)}
|
|
107
|
+
</span>
|
|
108
|
+
<span style={{ fg: colors().error }}>
|
|
109
|
+
{"-".repeat(bar.minus)}
|
|
110
|
+
</span>
|
|
111
|
+
</text>
|
|
112
|
+
)
|
|
113
|
+
}}
|
|
114
|
+
</For>
|
|
115
|
+
<text>
|
|
116
|
+
{s().totalFiles} file{s().totalFiles !== 1 ? "s" : ""} changed
|
|
117
|
+
<Show when={s().totalInsertions > 0}>
|
|
118
|
+
{", "}
|
|
119
|
+
<span style={{ fg: colors().success }}>
|
|
120
|
+
{s().totalInsertions} insertion
|
|
121
|
+
{s().totalInsertions !== 1 ? "s" : ""}(+)
|
|
122
|
+
</span>
|
|
123
|
+
</Show>
|
|
124
|
+
<Show when={s().totalDeletions > 0}>
|
|
125
|
+
{", "}
|
|
126
|
+
<span style={{ fg: colors().error }}>
|
|
127
|
+
{s().totalDeletions} deletion
|
|
128
|
+
{s().totalDeletions !== 1 ? "s" : ""}(-)
|
|
129
|
+
</span>
|
|
130
|
+
</Show>
|
|
131
|
+
</text>
|
|
132
|
+
<text fg={colors().textMuted}>{"─".repeat(props.maxWidth)}</text>
|
|
133
|
+
</>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function CommitHeader(props: {
|
|
138
|
+
commit: Commit
|
|
139
|
+
details: CommitDetails | null
|
|
140
|
+
maxWidth: number
|
|
141
|
+
}) {
|
|
142
|
+
const { colors } = useTheme()
|
|
143
|
+
|
|
144
|
+
// Stale-while-revalidate: show details as-is (may be stale from previous commit)
|
|
145
|
+
// until new details arrive. This prevents flash during navigation.
|
|
146
|
+
const subject = () => props.details?.subject || props.commit.description
|
|
147
|
+
const stats = () => props.details?.stats
|
|
148
|
+
|
|
149
|
+
const bodyLines = createMemo(() => {
|
|
150
|
+
const b = props.details?.body
|
|
151
|
+
return b ? b.split("\n") : null
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<box flexDirection="column" flexShrink={0}>
|
|
156
|
+
<text>
|
|
157
|
+
<span style={{ fg: colors().warning }}>{props.commit.changeId}</span>{" "}
|
|
158
|
+
<span style={{ fg: colors().textMuted }}>{props.commit.commitId}</span>
|
|
159
|
+
</text>
|
|
160
|
+
<text>
|
|
161
|
+
{"Author: "}
|
|
162
|
+
<span style={{ fg: colors().secondary }}>
|
|
163
|
+
{`${props.commit.author} <${props.commit.authorEmail}>`}
|
|
164
|
+
</span>
|
|
165
|
+
</text>
|
|
166
|
+
<text>
|
|
167
|
+
{"Date: "}
|
|
168
|
+
<span style={{ fg: colors().secondary }}>
|
|
169
|
+
{formatTimestamp(props.commit.timestamp)}
|
|
170
|
+
</span>
|
|
171
|
+
</text>
|
|
172
|
+
<text> </text>
|
|
173
|
+
<box flexDirection="row">
|
|
174
|
+
<text>{" "}</text>
|
|
175
|
+
<AnsiText content={subject()} wrapMode="none" />
|
|
176
|
+
</box>
|
|
177
|
+
<Show when={bodyLines()}>
|
|
178
|
+
{(lines: () => string[]) => (
|
|
179
|
+
<box flexDirection="column">
|
|
180
|
+
<text> </text>
|
|
181
|
+
<For each={lines()}>
|
|
182
|
+
{(line) => (
|
|
183
|
+
<text>
|
|
184
|
+
{" "}
|
|
185
|
+
{line}
|
|
186
|
+
</text>
|
|
187
|
+
)}
|
|
188
|
+
</For>
|
|
189
|
+
</box>
|
|
190
|
+
)}
|
|
191
|
+
</Show>
|
|
192
|
+
<Show when={stats()?.totalFiles ? stats() : undefined}>
|
|
193
|
+
{(s: () => DiffStats) => (
|
|
194
|
+
<box flexDirection="column">
|
|
195
|
+
<FileStats stats={s()} maxWidth={props.maxWidth} />
|
|
196
|
+
</box>
|
|
197
|
+
)}
|
|
198
|
+
</Show>
|
|
199
|
+
<text> </text>
|
|
200
|
+
</box>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function MainArea() {
|
|
205
|
+
const {
|
|
206
|
+
activeCommit,
|
|
207
|
+
commitDetails,
|
|
208
|
+
diff,
|
|
209
|
+
diffLoading,
|
|
210
|
+
diffError,
|
|
211
|
+
diffLineCount,
|
|
212
|
+
mainAreaWidth,
|
|
213
|
+
} = useSync()
|
|
214
|
+
const { colors } = useTheme()
|
|
215
|
+
const focus = useFocus()
|
|
216
|
+
const command = useCommand()
|
|
217
|
+
|
|
218
|
+
let scrollRef: ScrollBoxRenderable | undefined
|
|
219
|
+
|
|
220
|
+
const [scrollTop, setScrollTop] = createSignal(0)
|
|
221
|
+
const [limit, setLimit] = createSignal(INITIAL_LIMIT)
|
|
222
|
+
const [currentCommitId, setCurrentCommitId] = createSignal<string | null>(
|
|
223
|
+
null,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
createEffect(() => {
|
|
227
|
+
const commit = activeCommit()
|
|
228
|
+
if (commit && commit.changeId !== currentCommitId()) {
|
|
229
|
+
setCurrentCommitId(commit.changeId)
|
|
230
|
+
setScrollTop(0)
|
|
231
|
+
setLimit(INITIAL_LIMIT)
|
|
232
|
+
scrollRef?.scrollTo(0)
|
|
233
|
+
}
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
const loadMoreIfNeeded = () => {
|
|
237
|
+
if (!scrollRef) return
|
|
238
|
+
const viewportHeight = scrollRef.viewport?.height ?? 30
|
|
239
|
+
const scrollHeight = scrollRef.scrollHeight ?? 0
|
|
240
|
+
const currentScroll = scrollRef.scrollTop ?? 0
|
|
241
|
+
|
|
242
|
+
const distanceFromBottom = scrollHeight - (currentScroll + viewportHeight)
|
|
243
|
+
if (distanceFromBottom < LOAD_THRESHOLD && limit() < diffLineCount()) {
|
|
244
|
+
setLimit((l) => Math.min(l + LIMIT_INCREMENT, diffLineCount()))
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
onMount(() => {
|
|
249
|
+
const pollInterval = setInterval(() => {
|
|
250
|
+
if (scrollRef) {
|
|
251
|
+
const currentScroll = scrollRef.scrollTop ?? 0
|
|
252
|
+
if (currentScroll !== scrollTop()) {
|
|
253
|
+
setScrollTop(currentScroll)
|
|
254
|
+
loadMoreIfNeeded()
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}, 100)
|
|
258
|
+
onCleanup(() => clearInterval(pollInterval))
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
const isFocused = () => focus.isPanel("detail")
|
|
262
|
+
|
|
263
|
+
command.register(() => [
|
|
264
|
+
{
|
|
265
|
+
id: "detail.page_up",
|
|
266
|
+
title: "Page up",
|
|
267
|
+
keybind: "nav_page_up",
|
|
268
|
+
context: "detail",
|
|
269
|
+
type: "navigation",
|
|
270
|
+
onSelect: () => scrollRef?.scrollBy(-0.5, "viewport"),
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
id: "detail.page_down",
|
|
274
|
+
title: "Page down",
|
|
275
|
+
keybind: "nav_page_down",
|
|
276
|
+
context: "detail",
|
|
277
|
+
type: "navigation",
|
|
278
|
+
onSelect: () => {
|
|
279
|
+
scrollRef?.scrollBy(0.5, "viewport")
|
|
280
|
+
loadMoreIfNeeded()
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
id: "detail.scroll_down",
|
|
285
|
+
title: "scroll down",
|
|
286
|
+
keybind: "nav_down",
|
|
287
|
+
context: "detail",
|
|
288
|
+
type: "navigation",
|
|
289
|
+
visibility: "help-only",
|
|
290
|
+
onSelect: () => {
|
|
291
|
+
scrollRef?.scrollTo((scrollTop() || 0) + 1)
|
|
292
|
+
setScrollTop((scrollTop() || 0) + 1)
|
|
293
|
+
loadMoreIfNeeded()
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
id: "detail.scroll_up",
|
|
298
|
+
title: "scroll up",
|
|
299
|
+
keybind: "nav_up",
|
|
300
|
+
context: "detail",
|
|
301
|
+
type: "navigation",
|
|
302
|
+
visibility: "help-only",
|
|
303
|
+
onSelect: () => {
|
|
304
|
+
const newPos = Math.max(0, (scrollTop() || 0) - 1)
|
|
305
|
+
scrollRef?.scrollTo(newPos)
|
|
306
|
+
setScrollTop(newPos)
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
])
|
|
310
|
+
|
|
311
|
+
return (
|
|
312
|
+
<Panel title="Detail" hotkey="3" panelId="detail" focused={isFocused()}>
|
|
313
|
+
<Show when={diffLoading() && !diff()}>
|
|
314
|
+
<text>Loading diff...</text>
|
|
315
|
+
</Show>
|
|
316
|
+
<Show when={diffError()}>
|
|
317
|
+
<text>Error: {diffError()}</text>
|
|
318
|
+
</Show>
|
|
319
|
+
<Show when={diff() || (!diffLoading() && !diffError())}>
|
|
320
|
+
<scrollbox
|
|
321
|
+
ref={scrollRef}
|
|
322
|
+
focused={isFocused()}
|
|
323
|
+
flexGrow={1}
|
|
324
|
+
scrollbarOptions={{
|
|
325
|
+
trackOptions: {
|
|
326
|
+
backgroundColor: colors().scrollbarTrack,
|
|
327
|
+
foregroundColor: colors().scrollbarThumb,
|
|
328
|
+
},
|
|
329
|
+
}}
|
|
330
|
+
>
|
|
331
|
+
<Show when={activeCommit()}>
|
|
332
|
+
{(commit: () => Commit) => (
|
|
333
|
+
<CommitHeader
|
|
334
|
+
commit={commit()}
|
|
335
|
+
details={commitDetails()}
|
|
336
|
+
maxWidth={mainAreaWidth()}
|
|
337
|
+
/>
|
|
338
|
+
)}
|
|
339
|
+
</Show>
|
|
340
|
+
<Show when={diff()}>
|
|
341
|
+
<ghostty-terminal
|
|
342
|
+
ansi={diff() ?? ""}
|
|
343
|
+
cols={mainAreaWidth()}
|
|
344
|
+
limit={limit()}
|
|
345
|
+
/>
|
|
346
|
+
</Show>
|
|
347
|
+
<Show when={!diff()}>
|
|
348
|
+
<text>No changes in this commit.</text>
|
|
349
|
+
</Show>
|
|
350
|
+
</scrollbox>
|
|
351
|
+
</Show>
|
|
352
|
+
</Panel>
|
|
353
|
+
)
|
|
354
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { useKeyboard } from "@opentui/solid"
|
|
2
|
+
import { type Accessor, createMemo, createSignal, onCleanup } from "solid-js"
|
|
3
|
+
import type { KeybindConfigKey } from "../keybind"
|
|
4
|
+
import { useDialog } from "./dialog"
|
|
5
|
+
import { useFocus } from "./focus"
|
|
6
|
+
import { createSimpleContext } from "./helper"
|
|
7
|
+
import { useKeybind } from "./keybind"
|
|
8
|
+
import type { CommandType, CommandVisibility, Context, Panel } from "./types"
|
|
9
|
+
|
|
10
|
+
export type { CommandType, CommandVisibility, Context }
|
|
11
|
+
|
|
12
|
+
export type CommandOption = {
|
|
13
|
+
id: string
|
|
14
|
+
title: string
|
|
15
|
+
keybind?: KeybindConfigKey
|
|
16
|
+
context: Context
|
|
17
|
+
type: CommandType
|
|
18
|
+
panel?: Panel
|
|
19
|
+
visibility?: CommandVisibility
|
|
20
|
+
onSelect: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function contextMatches(
|
|
24
|
+
commandContext: Context,
|
|
25
|
+
activeContext: Context,
|
|
26
|
+
): boolean {
|
|
27
|
+
if (commandContext === "global") return true
|
|
28
|
+
if (commandContext === activeContext) return true
|
|
29
|
+
return activeContext.startsWith(`${commandContext}.`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const { use: useCommand, provider: CommandProvider } =
|
|
33
|
+
createSimpleContext({
|
|
34
|
+
name: "Command",
|
|
35
|
+
init: () => {
|
|
36
|
+
const [registrations, setRegistrations] = createSignal<
|
|
37
|
+
Accessor<CommandOption[]>[]
|
|
38
|
+
>([])
|
|
39
|
+
const keybind = useKeybind()
|
|
40
|
+
const focus = useFocus()
|
|
41
|
+
const dialog = useDialog()
|
|
42
|
+
|
|
43
|
+
const allCommands = createMemo(() => {
|
|
44
|
+
return registrations().flatMap((r) => r())
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
useKeyboard((evt) => {
|
|
48
|
+
if (evt.defaultPrevented) return
|
|
49
|
+
|
|
50
|
+
const dialogOpen = dialog.isOpen()
|
|
51
|
+
const activeCtx = focus.activeContext()
|
|
52
|
+
const activePanel = focus.panel()
|
|
53
|
+
|
|
54
|
+
let mostSpecificMatch: CommandOption | null = null
|
|
55
|
+
let highestContextSpecificity = -1
|
|
56
|
+
|
|
57
|
+
for (const cmd of allCommands()) {
|
|
58
|
+
if (dialogOpen && cmd.keybind !== "help") {
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!dialogOpen) {
|
|
63
|
+
if (!contextMatches(cmd.context, activeCtx)) {
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
if (cmd.panel && cmd.panel !== activePanel) {
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (cmd.keybind && keybind.match(cmd.keybind, evt)) {
|
|
72
|
+
const contextSpecificity =
|
|
73
|
+
cmd.context === activeCtx
|
|
74
|
+
? Number.MAX_SAFE_INTEGER
|
|
75
|
+
: cmd.context.length
|
|
76
|
+
if (contextSpecificity > highestContextSpecificity) {
|
|
77
|
+
mostSpecificMatch = cmd
|
|
78
|
+
highestContextSpecificity = contextSpecificity
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (mostSpecificMatch) {
|
|
84
|
+
evt.preventDefault()
|
|
85
|
+
mostSpecificMatch.onSelect()
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
register: (cb: () => CommandOption[]) => {
|
|
91
|
+
const accessor = createMemo(cb)
|
|
92
|
+
setRegistrations((arr) => [...arr, accessor])
|
|
93
|
+
onCleanup(() => {
|
|
94
|
+
setRegistrations((arr) => arr.filter((r) => r !== accessor))
|
|
95
|
+
})
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
trigger: (id: string) => {
|
|
99
|
+
const cmd = allCommands().find((c) => c.id === id)
|
|
100
|
+
cmd?.onSelect()
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
all: allCommands,
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createSignal } from "solid-js"
|
|
2
|
+
import type { OperationResult } from "../commander/operations"
|
|
3
|
+
import { createSimpleContext } from "./helper"
|
|
4
|
+
|
|
5
|
+
export interface CommandLogEntry {
|
|
6
|
+
id: string
|
|
7
|
+
command: string
|
|
8
|
+
output: string
|
|
9
|
+
success: boolean
|
|
10
|
+
timestamp: Date
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const { use: useCommandLog, provider: CommandLogProvider } =
|
|
14
|
+
createSimpleContext({
|
|
15
|
+
name: "CommandLog",
|
|
16
|
+
init: () => {
|
|
17
|
+
const [entries, setEntries] = createSignal<CommandLogEntry[]>([])
|
|
18
|
+
|
|
19
|
+
const addEntry = (result: OperationResult) => {
|
|
20
|
+
const entry: CommandLogEntry = {
|
|
21
|
+
id: crypto.randomUUID(),
|
|
22
|
+
command: result.command,
|
|
23
|
+
output: result.success
|
|
24
|
+
? result.stdout.trim() || "Done"
|
|
25
|
+
: result.stderr.trim() || result.stdout.trim() || "Failed",
|
|
26
|
+
success: result.success,
|
|
27
|
+
timestamp: new Date(),
|
|
28
|
+
}
|
|
29
|
+
setEntries((prev) => [...prev, entry])
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const clear = () => {
|
|
33
|
+
setEntries([])
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const latest = () => entries().at(-1)
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
entries,
|
|
40
|
+
addEntry,
|
|
41
|
+
clear,
|
|
42
|
+
latest,
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
})
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { RGBA } from "@opentui/core"
|
|
2
|
+
import { useKeyboard, useRenderer } from "@opentui/solid"
|
|
3
|
+
import {
|
|
4
|
+
type JSX,
|
|
5
|
+
type ParentProps,
|
|
6
|
+
Show,
|
|
7
|
+
createSignal,
|
|
8
|
+
onCleanup,
|
|
9
|
+
onMount,
|
|
10
|
+
} from "solid-js"
|
|
11
|
+
import { createSimpleContext } from "./helper"
|
|
12
|
+
import { useTheme } from "./theme"
|
|
13
|
+
|
|
14
|
+
export interface DialogHint {
|
|
15
|
+
key: string
|
|
16
|
+
label: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface DialogState {
|
|
20
|
+
id?: string
|
|
21
|
+
render: () => JSX.Element
|
|
22
|
+
onClose?: () => void
|
|
23
|
+
hints?: DialogHint[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ConfirmOptions {
|
|
27
|
+
message: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ConfirmDialogContent(props: {
|
|
31
|
+
message: string
|
|
32
|
+
onResolve: (confirmed: boolean) => void
|
|
33
|
+
}) {
|
|
34
|
+
const { colors, style } = useTheme()
|
|
35
|
+
|
|
36
|
+
useKeyboard((evt) => {
|
|
37
|
+
if (evt.name === "y" || evt.name === "return") {
|
|
38
|
+
evt.preventDefault()
|
|
39
|
+
props.onResolve(true)
|
|
40
|
+
} else if (evt.name === "n" || evt.name === "escape") {
|
|
41
|
+
evt.preventDefault()
|
|
42
|
+
props.onResolve(false)
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<box
|
|
48
|
+
flexDirection="column"
|
|
49
|
+
border
|
|
50
|
+
borderStyle={style().panel.borderStyle}
|
|
51
|
+
borderColor={colors().borderFocused}
|
|
52
|
+
backgroundColor={colors().background}
|
|
53
|
+
paddingLeft={2}
|
|
54
|
+
width="50%"
|
|
55
|
+
>
|
|
56
|
+
<text fg={colors().text}>{props.message}</text>
|
|
57
|
+
</box>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const { use: useDialog, provider: DialogProvider } = createSimpleContext(
|
|
62
|
+
{
|
|
63
|
+
name: "Dialog",
|
|
64
|
+
init: () => {
|
|
65
|
+
const [stack, setStack] = createSignal<DialogState[]>([])
|
|
66
|
+
|
|
67
|
+
const close = () => {
|
|
68
|
+
const current = stack().at(-1)
|
|
69
|
+
current?.onClose?.()
|
|
70
|
+
setStack((s) => s.slice(0, -1))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
useKeyboard((evt) => {
|
|
74
|
+
if (stack().length > 0 && evt.name === "escape") {
|
|
75
|
+
evt.preventDefault()
|
|
76
|
+
close()
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const open = (
|
|
81
|
+
render: () => JSX.Element,
|
|
82
|
+
options?: { id?: string; onClose?: () => void; hints?: DialogHint[] },
|
|
83
|
+
) => {
|
|
84
|
+
setStack((s) => [
|
|
85
|
+
...s,
|
|
86
|
+
{
|
|
87
|
+
id: options?.id,
|
|
88
|
+
render,
|
|
89
|
+
onClose: options?.onClose,
|
|
90
|
+
hints: options?.hints,
|
|
91
|
+
},
|
|
92
|
+
])
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const toggle = (
|
|
96
|
+
id: string,
|
|
97
|
+
render: () => JSX.Element,
|
|
98
|
+
options?: { onClose?: () => void; hints?: DialogHint[] },
|
|
99
|
+
) => {
|
|
100
|
+
const current = stack().at(-1)
|
|
101
|
+
if (current?.id === id) {
|
|
102
|
+
close()
|
|
103
|
+
} else {
|
|
104
|
+
open(render, { id, onClose: options?.onClose, hints: options?.hints })
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const confirm = (options: ConfirmOptions): Promise<boolean> => {
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
let resolved = false
|
|
111
|
+
const handleResolve = (confirmed: boolean) => {
|
|
112
|
+
if (resolved) return
|
|
113
|
+
resolved = true
|
|
114
|
+
close()
|
|
115
|
+
resolve(confirmed)
|
|
116
|
+
}
|
|
117
|
+
open(
|
|
118
|
+
() => (
|
|
119
|
+
<ConfirmDialogContent
|
|
120
|
+
message={options.message}
|
|
121
|
+
onResolve={handleResolve}
|
|
122
|
+
/>
|
|
123
|
+
),
|
|
124
|
+
{
|
|
125
|
+
id: "confirm-dialog",
|
|
126
|
+
hints: [
|
|
127
|
+
{ key: "y", label: "confirm" },
|
|
128
|
+
{ key: "n", label: "cancel" },
|
|
129
|
+
],
|
|
130
|
+
onClose: () => {
|
|
131
|
+
if (!resolved) {
|
|
132
|
+
resolved = true
|
|
133
|
+
resolve(false)
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
isOpen: () => stack().length > 0,
|
|
143
|
+
current: () => stack().at(-1),
|
|
144
|
+
hints: () => stack().at(-1)?.hints ?? [],
|
|
145
|
+
setHints: (hints: DialogHint[]) => {
|
|
146
|
+
setStack((s) => {
|
|
147
|
+
if (s.length === 0) return s
|
|
148
|
+
const last = s[s.length - 1]
|
|
149
|
+
if (!last) return s
|
|
150
|
+
return [...s.slice(0, -1), { ...last, hints }]
|
|
151
|
+
})
|
|
152
|
+
},
|
|
153
|
+
open,
|
|
154
|
+
toggle,
|
|
155
|
+
close,
|
|
156
|
+
confirm,
|
|
157
|
+
clear: () => {
|
|
158
|
+
for (const item of stack()) {
|
|
159
|
+
item.onClose?.()
|
|
160
|
+
}
|
|
161
|
+
setStack([])
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
function DialogBackdrop(props: { onClose: () => void; children: JSX.Element }) {
|
|
169
|
+
const renderer = useRenderer()
|
|
170
|
+
const { style } = useTheme()
|
|
171
|
+
const [dimensions, setDimensions] = createSignal({
|
|
172
|
+
width: renderer.width,
|
|
173
|
+
height: renderer.height,
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
onMount(() => {
|
|
177
|
+
const handleResize = (width: number, height: number) => {
|
|
178
|
+
setDimensions({ width, height })
|
|
179
|
+
}
|
|
180
|
+
renderer.on("resize", handleResize)
|
|
181
|
+
onCleanup(() => renderer.off("resize", handleResize))
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
const overlayColor = () =>
|
|
185
|
+
RGBA.fromInts(0, 0, 0, style().dialog.overlayOpacity)
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<box
|
|
189
|
+
position="absolute"
|
|
190
|
+
left={0}
|
|
191
|
+
top={0}
|
|
192
|
+
width={dimensions().width}
|
|
193
|
+
height={dimensions().height}
|
|
194
|
+
backgroundColor={overlayColor()}
|
|
195
|
+
flexDirection="column"
|
|
196
|
+
justifyContent="center"
|
|
197
|
+
alignItems="center"
|
|
198
|
+
>
|
|
199
|
+
{props.children}
|
|
200
|
+
</box>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function DialogContainer(props: ParentProps) {
|
|
205
|
+
const dialog = useDialog()
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<box flexGrow={1} width="100%" height="100%">
|
|
209
|
+
{props.children}
|
|
210
|
+
<Show when={dialog.isOpen()}>
|
|
211
|
+
<DialogBackdrop onClose={dialog.close}>
|
|
212
|
+
{dialog.current()?.render()}
|
|
213
|
+
</DialogBackdrop>
|
|
214
|
+
</Show>
|
|
215
|
+
</box>
|
|
216
|
+
)
|
|
217
|
+
}
|