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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +128 -0
  3. package/bin/kajji.js +2 -0
  4. package/package.json +56 -0
  5. package/src/App.tsx +229 -0
  6. package/src/commander/bookmarks.ts +129 -0
  7. package/src/commander/diff.ts +186 -0
  8. package/src/commander/executor.ts +285 -0
  9. package/src/commander/files.ts +87 -0
  10. package/src/commander/log.ts +99 -0
  11. package/src/commander/operations.ts +313 -0
  12. package/src/commander/types.ts +21 -0
  13. package/src/components/AnsiText.tsx +77 -0
  14. package/src/components/BorderBox.tsx +124 -0
  15. package/src/components/FileTreeList.tsx +105 -0
  16. package/src/components/Layout.tsx +48 -0
  17. package/src/components/Panel.tsx +143 -0
  18. package/src/components/RevisionPicker.tsx +165 -0
  19. package/src/components/StatusBar.tsx +158 -0
  20. package/src/components/modals/BookmarkNameModal.tsx +170 -0
  21. package/src/components/modals/DescribeModal.tsx +124 -0
  22. package/src/components/modals/HelpModal.tsx +372 -0
  23. package/src/components/modals/RevisionPickerModal.tsx +70 -0
  24. package/src/components/modals/UndoModal.tsx +75 -0
  25. package/src/components/panels/BookmarksPanel.tsx +768 -0
  26. package/src/components/panels/CommandLogPanel.tsx +40 -0
  27. package/src/components/panels/LogPanel.tsx +774 -0
  28. package/src/components/panels/MainArea.tsx +354 -0
  29. package/src/context/command.tsx +106 -0
  30. package/src/context/commandlog.tsx +45 -0
  31. package/src/context/dialog.tsx +217 -0
  32. package/src/context/focus.tsx +63 -0
  33. package/src/context/helper.tsx +24 -0
  34. package/src/context/keybind.tsx +51 -0
  35. package/src/context/loading.tsx +68 -0
  36. package/src/context/sync.tsx +868 -0
  37. package/src/context/theme.tsx +90 -0
  38. package/src/context/types.ts +51 -0
  39. package/src/index.tsx +15 -0
  40. package/src/keybind/index.ts +2 -0
  41. package/src/keybind/parser.ts +88 -0
  42. package/src/keybind/types.ts +83 -0
  43. package/src/theme/index.ts +3 -0
  44. package/src/theme/presets/lazygit.ts +45 -0
  45. package/src/theme/presets/opencode.ts +45 -0
  46. package/src/theme/types.ts +47 -0
  47. package/src/utils/double-click.ts +59 -0
  48. package/src/utils/file-tree.ts +154 -0
@@ -0,0 +1,124 @@
1
+ import {
2
+ type InputRenderable,
3
+ RGBA,
4
+ type TextareaRenderable,
5
+ } from "@opentui/core"
6
+ import { useKeyboard } from "@opentui/solid"
7
+ import { createSignal, onMount } from "solid-js"
8
+ import { useDialog } from "../../context/dialog"
9
+ import { useTheme } from "../../context/theme"
10
+
11
+ interface DescribeModalProps {
12
+ initialSubject: string
13
+ initialBody: string
14
+ onSave: (subject: string, body: string) => void
15
+ }
16
+
17
+ export function DescribeModal(props: DescribeModalProps) {
18
+ const dialog = useDialog()
19
+ const { colors, style } = useTheme()
20
+
21
+ const [subject, setSubject] = createSignal(props.initialSubject)
22
+ const [body, setBody] = createSignal(props.initialBody)
23
+ const [focusedField, setFocusedField] = createSignal<"subject" | "body">(
24
+ "subject",
25
+ )
26
+
27
+ let subjectRef: InputRenderable | undefined
28
+ let bodyRef: TextareaRenderable | undefined
29
+
30
+ const focusInputAtEnd = (ref: InputRenderable | undefined) => {
31
+ if (!ref) return
32
+ ref.focus()
33
+ ref.cursorPosition = ref.value.length
34
+ }
35
+
36
+ const focusTextareaAtEnd = (ref: TextareaRenderable | undefined) => {
37
+ if (!ref) return
38
+ ref.focus()
39
+ ref.gotoBufferEnd()
40
+ }
41
+
42
+ onMount(() => {
43
+ setTimeout(() => focusInputAtEnd(subjectRef), 1)
44
+ })
45
+
46
+ const handleSave = () => {
47
+ dialog.close()
48
+ props.onSave(subject(), body())
49
+ }
50
+
51
+ useKeyboard((evt) => {
52
+ if (evt.name === "tab") {
53
+ evt.preventDefault()
54
+ if (focusedField() === "subject") {
55
+ setFocusedField("body")
56
+ focusTextareaAtEnd(bodyRef)
57
+ } else {
58
+ setFocusedField("subject")
59
+ focusInputAtEnd(subjectRef)
60
+ }
61
+ }
62
+ })
63
+
64
+ const charCount = () => subject().length
65
+
66
+ return (
67
+ <box flexDirection="column" width="60%" gap={0}>
68
+ <box
69
+ flexDirection="column"
70
+ border
71
+ borderStyle={style().panel.borderStyle}
72
+ borderColor={
73
+ focusedField() === "subject"
74
+ ? colors().borderFocused
75
+ : colors().border
76
+ }
77
+ backgroundColor={colors().background}
78
+ height={3}
79
+ padding={0}
80
+ title={`Subject─[${charCount()}]`}
81
+ >
82
+ <input
83
+ ref={subjectRef}
84
+ value={props.initialSubject}
85
+ onInput={(value) => setSubject(value)}
86
+ onSubmit={handleSave}
87
+ cursorColor={colors().primary}
88
+ textColor={colors().text}
89
+ focusedTextColor={colors().text}
90
+ focusedBackgroundColor={RGBA.fromInts(0, 0, 0, 0)}
91
+ flexGrow={1}
92
+ />
93
+ </box>
94
+
95
+ <box
96
+ flexDirection="column"
97
+ border
98
+ borderStyle={style().panel.borderStyle}
99
+ borderColor={
100
+ focusedField() === "body" ? colors().borderFocused : colors().border
101
+ }
102
+ backgroundColor={colors().background}
103
+ height={10}
104
+ padding={0}
105
+ title="Body"
106
+ >
107
+ <textarea
108
+ ref={(r) => {
109
+ bodyRef = r
110
+ }}
111
+ initialValue={props.initialBody}
112
+ onContentChange={() => {
113
+ if (bodyRef) setBody(bodyRef.plainText)
114
+ }}
115
+ cursorColor={colors().primary}
116
+ textColor={colors().text}
117
+ focusedTextColor={colors().text}
118
+ focusedBackgroundColor={RGBA.fromInts(0, 0, 0, 0)}
119
+ flexGrow={1}
120
+ />
121
+ </box>
122
+ </box>
123
+ )
124
+ }
@@ -0,0 +1,372 @@
1
+ import { type InputRenderable, RGBA } from "@opentui/core"
2
+ import { useKeyboard, useRenderer } from "@opentui/solid"
3
+ import fuzzysort from "fuzzysort"
4
+ import {
5
+ type Accessor,
6
+ For,
7
+ Show,
8
+ createEffect,
9
+ createMemo,
10
+ createSignal,
11
+ onCleanup,
12
+ onMount,
13
+ } from "solid-js"
14
+ import {
15
+ type CommandOption,
16
+ type Context,
17
+ useCommand,
18
+ } from "../../context/command"
19
+ import { useDialog } from "../../context/dialog"
20
+ import { useFocus } from "../../context/focus"
21
+ import { useKeybind } from "../../context/keybind"
22
+ import { useTheme } from "../../context/theme"
23
+ import type { KeybindConfigKey } from "../../keybind"
24
+
25
+ type ContextGroup =
26
+ | "navigation"
27
+ | "revisions"
28
+ | "files"
29
+ | "bookmarks"
30
+ | "oplog"
31
+ | "detail"
32
+ | "git"
33
+ | "global"
34
+
35
+ interface ContextGroupData {
36
+ context: ContextGroup
37
+ label: string
38
+ commands: CommandOption[]
39
+ }
40
+
41
+ const GROUP_ORDER: ContextGroup[] = [
42
+ "revisions",
43
+ "files",
44
+ "bookmarks",
45
+ "oplog",
46
+ "detail",
47
+ "navigation",
48
+ "git",
49
+ "global",
50
+ ]
51
+
52
+ const GROUP_LABELS: Record<ContextGroup, string> = {
53
+ navigation: "navigation",
54
+ revisions: "revisions",
55
+ files: "files",
56
+ bookmarks: "bookmarks",
57
+ oplog: "oplog",
58
+ detail: "detail",
59
+ git: "git",
60
+ global: "global",
61
+ }
62
+
63
+ const NAVIGATION_KEYBINDS = new Set([
64
+ "nav_down",
65
+ "nav_up",
66
+ "nav_page_up",
67
+ "nav_page_down",
68
+ "next_tab",
69
+ "prev_tab",
70
+ "focus_next",
71
+ "focus_prev",
72
+ "focus_panel_1",
73
+ "focus_panel_2",
74
+ "focus_panel_3",
75
+ ])
76
+
77
+ function contextToGroup(context: Context): ContextGroup {
78
+ if (context === "log.revisions" || context === "refs.revisions")
79
+ return "revisions"
80
+ if (context === "log.files" || context === "refs.files") return "files"
81
+ if (context === "refs.bookmarks") return "bookmarks"
82
+ if (context === "log.oplog") return "oplog"
83
+ if (context === "detail") return "detail"
84
+ return "global"
85
+ }
86
+
87
+ function contextMatches(
88
+ commandContext: Context,
89
+ activeContext: Context,
90
+ ): boolean {
91
+ if (commandContext === "global") return true
92
+ if (commandContext === activeContext) return true
93
+ return activeContext.startsWith(`${commandContext}.`)
94
+ }
95
+
96
+ const NARROW_THRESHOLD = 100
97
+
98
+ export function HelpModal() {
99
+ const renderer = useRenderer()
100
+ const command = useCommand()
101
+ const keybind = useKeybind()
102
+ const dialog = useDialog()
103
+ const focus = useFocus()
104
+ const { colors, style } = useTheme()
105
+ const [filter, setFilter] = createSignal("")
106
+ const [selectedIndex, setSelectedIndex] = createSignal(-1)
107
+ const [terminalWidth, setTerminalWidth] = createSignal(renderer.width)
108
+
109
+ onMount(() => {
110
+ const handleResize = (width: number) => setTerminalWidth(width)
111
+ renderer.on("resize", handleResize)
112
+ onCleanup(() => renderer.off("resize", handleResize))
113
+ })
114
+
115
+ const columnCount = () => (terminalWidth() < NARROW_THRESHOLD ? 1 : 3)
116
+
117
+ type SearchableCommand = CommandOption & { keybindStr: string }
118
+
119
+ const isVisibleInHelp = (cmd: CommandOption) => {
120
+ const v = cmd.visibility ?? "all"
121
+ return v === "all" || v === "help-only"
122
+ }
123
+
124
+ const allCommands = createMemo((): SearchableCommand[] => {
125
+ return command
126
+ .all()
127
+ .filter(isVisibleInHelp)
128
+ .map((cmd) => ({
129
+ ...cmd,
130
+ keybindStr: cmd.keybind ? keybind.print(cmd.keybind) : "",
131
+ }))
132
+ })
133
+
134
+ const matchedCommands = createMemo(() => {
135
+ const all = allCommands()
136
+ const filterText = filter().trim()
137
+
138
+ if (!filterText) {
139
+ return all
140
+ }
141
+
142
+ const results = fuzzysort.go(filterText, all, {
143
+ keys: ["title", "context", "keybindStr"],
144
+ threshold: -10000,
145
+ })
146
+
147
+ return results.map((r) => r.obj)
148
+ })
149
+
150
+ const matchedIds = createMemo(() => {
151
+ return new Set(matchedCommands().map((cmd) => cmd.id))
152
+ })
153
+
154
+ createEffect(() => {
155
+ const filterText = filter().trim()
156
+ if (filterText) {
157
+ setSelectedIndex(0)
158
+ } else {
159
+ setSelectedIndex(-1)
160
+ }
161
+ })
162
+
163
+ const selectedCommand = createMemo(() => {
164
+ const idx = selectedIndex()
165
+ if (idx < 0) return null
166
+ return matchedInColumnOrder()[idx] ?? null
167
+ })
168
+
169
+ const groupedCommands = createMemo((): ContextGroupData[] => {
170
+ const all = allCommands()
171
+ const seenKeybinds = new Set<string>()
172
+
173
+ const groups = new Map<ContextGroup, CommandOption[]>()
174
+ const navCommands: CommandOption[] = []
175
+
176
+ for (const cmd of all) {
177
+ if (NAVIGATION_KEYBINDS.has(cmd.keybind ?? "")) {
178
+ if (!seenKeybinds.has(cmd.keybind ?? "")) {
179
+ navCommands.push(cmd)
180
+ seenKeybinds.add(cmd.keybind ?? "")
181
+ }
182
+ continue
183
+ }
184
+
185
+ const group = cmd.type === "git" ? "git" : contextToGroup(cmd.context)
186
+ const existing = groups.get(group) || []
187
+ groups.set(group, [...existing, cmd])
188
+ }
189
+
190
+ if (navCommands.length > 0) {
191
+ groups.set("navigation", navCommands)
192
+ }
193
+
194
+ const result: ContextGroupData[] = []
195
+ for (const group of GROUP_ORDER) {
196
+ const commands = groups.get(group)
197
+ if (commands && commands.length > 0) {
198
+ result.push({ context: group, label: GROUP_LABELS[group], commands })
199
+ }
200
+ }
201
+
202
+ return result
203
+ })
204
+
205
+ const columns = createMemo(() => {
206
+ const groups = groupedCommands()
207
+ const numCols = columnCount()
208
+ const cols: ContextGroupData[][] = Array.from({ length: numCols }, () => [])
209
+
210
+ let colIndex = 0
211
+ for (const group of groups) {
212
+ const col = cols[colIndex]
213
+ if (col) col.push(group)
214
+ colIndex = (colIndex + 1) % numCols
215
+ }
216
+
217
+ return cols
218
+ })
219
+
220
+ const commandsInColumnOrder = createMemo(() => {
221
+ const cols = columns()
222
+ const result: CommandOption[] = []
223
+ for (const column of cols) {
224
+ for (const group of column) {
225
+ for (const cmd of group.commands) {
226
+ result.push(cmd)
227
+ }
228
+ }
229
+ }
230
+ return result
231
+ })
232
+
233
+ const matchedInColumnOrder = createMemo(() => {
234
+ const matched = matchedIds()
235
+ return commandsInColumnOrder().filter((cmd) => matched.has(cmd.id))
236
+ })
237
+
238
+ const move = (direction: 1 | -1) => {
239
+ const matched = matchedInColumnOrder()
240
+ if (matched.length === 0) return
241
+
242
+ setSelectedIndex((prev) => {
243
+ if (prev < 0) return 0
244
+ let next = prev + direction
245
+ if (next < 0) next = matched.length - 1
246
+ if (next >= matched.length) next = 0
247
+ return next
248
+ })
249
+ }
250
+
251
+ const executeSelected = () => {
252
+ const cmd = selectedCommand()
253
+ if (cmd) {
254
+ dialog.close()
255
+ cmd.onSelect()
256
+ }
257
+ }
258
+
259
+ useKeyboard((evt) => {
260
+ if (evt.name === "j" || evt.name === "down") {
261
+ evt.preventDefault()
262
+ move(1)
263
+ } else if (evt.name === "k" || evt.name === "up") {
264
+ evt.preventDefault()
265
+ move(-1)
266
+ } else if (evt.name === "return") {
267
+ evt.preventDefault()
268
+ executeSelected()
269
+ }
270
+ })
271
+
272
+ const separator = () => style().statusBar.separator
273
+ const gap = () => (separator() ? 0 : 3)
274
+
275
+ const isMatched = (cmd: CommandOption) => matchedIds().has(cmd.id)
276
+ const isSelected = (cmd: CommandOption) => selectedCommand()?.id === cmd.id
277
+ const isActive = (cmd: CommandOption) => {
278
+ if (!contextMatches(cmd.context, focus.activeContext())) return false
279
+ if (cmd.panel && cmd.panel !== focus.panel()) return false
280
+ return true
281
+ }
282
+
283
+ return (
284
+ <box
285
+ flexDirection="column"
286
+ border
287
+ borderStyle={style().panel.borderStyle}
288
+ borderColor={colors().borderFocused}
289
+ backgroundColor={colors().background}
290
+ padding={1}
291
+ width="80%"
292
+ height="80%"
293
+ title="[esc / ?]─Commands"
294
+ >
295
+ <box flexDirection="row" marginBottom={2} paddingLeft={4}>
296
+ <input
297
+ ref={(r: InputRenderable) => setTimeout(() => r.focus(), 1)}
298
+ onInput={(value) => setFilter(value)}
299
+ onSubmit={() => executeSelected()}
300
+ placeholder="Search"
301
+ flexGrow={1}
302
+ cursorColor={colors().primary}
303
+ textColor={colors().textMuted}
304
+ focusedTextColor={colors().text}
305
+ focusedBackgroundColor={RGBA.fromInts(0, 0, 0, 0)}
306
+ />
307
+ </box>
308
+
309
+ <box flexDirection="row" flexGrow={1} gap={1}>
310
+ <For each={columns()}>
311
+ {(column) => (
312
+ <box flexDirection="column" flexGrow={1} flexBasis={0}>
313
+ <For each={column}>
314
+ {(group) => (
315
+ <box flexDirection="column" marginBottom={1}>
316
+ <box flexDirection="row">
317
+ <box width={10} flexShrink={0} />
318
+ <text fg={colors().primary}> {group.label}</text>
319
+ </box>
320
+ <For each={group.commands}>
321
+ {(cmd) => (
322
+ <box
323
+ flexDirection="row"
324
+ backgroundColor={
325
+ isSelected(cmd)
326
+ ? colors().selectionBackground
327
+ : undefined
328
+ }
329
+ >
330
+ <box width={10} flexShrink={0}>
331
+ <Show when={cmd.keybind}>
332
+ {(kb: Accessor<KeybindConfigKey>) => (
333
+ <text
334
+ fg={
335
+ isSelected(cmd)
336
+ ? colors().selectionText
337
+ : isMatched(cmd) && isActive(cmd)
338
+ ? colors().info
339
+ : colors().textMuted
340
+ }
341
+ wrapMode="none"
342
+ >
343
+ {keybind.print(kb()).padStart(9)}
344
+ </text>
345
+ )}
346
+ </Show>
347
+ </box>
348
+ <text
349
+ fg={
350
+ isSelected(cmd)
351
+ ? colors().selectionText
352
+ : isMatched(cmd) && isActive(cmd)
353
+ ? colors().text
354
+ : colors().textMuted
355
+ }
356
+ >
357
+ {" "}
358
+ {cmd.title}
359
+ </text>
360
+ </box>
361
+ )}
362
+ </For>
363
+ </box>
364
+ )}
365
+ </For>
366
+ </box>
367
+ )}
368
+ </For>
369
+ </box>
370
+ </box>
371
+ )
372
+ }
@@ -0,0 +1,70 @@
1
+ import { useKeyboard } from "@opentui/solid"
2
+ import { createSignal } from "solid-js"
3
+ import type { Commit } from "../../commander/types"
4
+ import { useDialog } from "../../context/dialog"
5
+ import { useTheme } from "../../context/theme"
6
+ import { RevisionPicker } from "../RevisionPicker"
7
+
8
+ interface RevisionPickerModalProps {
9
+ title: string
10
+ commits: Commit[]
11
+ defaultRevision?: string
12
+ width?: number | "auto" | `${number}%`
13
+ height?: number
14
+ onSelect: (revision: string) => void
15
+ }
16
+
17
+ export function RevisionPickerModal(props: RevisionPickerModalProps) {
18
+ const dialog = useDialog()
19
+ const { colors, style } = useTheme()
20
+
21
+ const [selectedRevision, setSelectedRevision] = createSignal(
22
+ props.defaultRevision ?? props.commits[0]?.changeId ?? "",
23
+ )
24
+
25
+ const handleConfirm = () => {
26
+ const rev = selectedRevision()
27
+ if (!rev) return
28
+ dialog.close()
29
+ props.onSelect(rev)
30
+ }
31
+
32
+ useKeyboard((evt) => {
33
+ if (evt.name === "escape") {
34
+ evt.preventDefault()
35
+ dialog.close()
36
+ } else if (evt.name === "return") {
37
+ evt.preventDefault()
38
+ handleConfirm()
39
+ }
40
+ })
41
+
42
+ const handleRevisionSelect = (commit: Commit) => {
43
+ setSelectedRevision(commit.changeId)
44
+ }
45
+
46
+ const pickerHeight = () => props.height ?? 12
47
+
48
+ return (
49
+ <box flexDirection="column" width={props.width ?? "60%"} gap={0}>
50
+ <box
51
+ flexDirection="column"
52
+ border
53
+ borderStyle={style().panel.borderStyle}
54
+ borderColor={colors().borderFocused}
55
+ backgroundColor={colors().background}
56
+ height={pickerHeight()}
57
+ padding={0}
58
+ title={props.title}
59
+ >
60
+ <RevisionPicker
61
+ commits={props.commits}
62
+ defaultRevision={props.defaultRevision}
63
+ focused={true}
64
+ onSelect={handleRevisionSelect}
65
+ height={pickerHeight() - 2}
66
+ />
67
+ </box>
68
+ </box>
69
+ )
70
+ }
@@ -0,0 +1,75 @@
1
+ import { useKeyboard } from "@opentui/solid"
2
+ import { Show, createResource } from "solid-js"
3
+ import { fetchOpLog } from "../../commander/operations"
4
+ import { useTheme } from "../../context/theme"
5
+ import { AnsiText } from "../AnsiText"
6
+ import { BorderBox } from "../BorderBox"
7
+
8
+ interface UndoModalProps {
9
+ type: "undo" | "redo" | "restore"
10
+ operationLines?: string[]
11
+ onConfirm: () => void
12
+ onCancel: () => void
13
+ }
14
+
15
+ export function UndoModal(props: UndoModalProps) {
16
+ const { colors, style } = useTheme()
17
+
18
+ const [fetchedDetails] = createResource(
19
+ () => !props.operationLines,
20
+ async () => {
21
+ const lines = await fetchOpLog(1)
22
+ return lines.join("\n")
23
+ },
24
+ )
25
+
26
+ const opDetails = () =>
27
+ props.operationLines?.join("\n") ?? fetchedDetails() ?? ""
28
+
29
+ useKeyboard((evt) => {
30
+ if (evt.name === "y" || evt.name === "return") {
31
+ evt.preventDefault()
32
+ props.onConfirm()
33
+ } else if (evt.name === "n" || evt.name === "escape") {
34
+ evt.preventDefault()
35
+ props.onCancel()
36
+ }
37
+ })
38
+
39
+ const title = () => {
40
+ if (props.type === "restore") return "Restore to this operation?"
41
+ return props.type === "undo"
42
+ ? "Undo last operation?"
43
+ : "Redo last operation?"
44
+ }
45
+
46
+ const hints = () => (
47
+ <text>
48
+ <span style={{ fg: colors().primary }}>y</span>
49
+ <span style={{ fg: colors().textMuted }}> confirm </span>
50
+ <span style={{ fg: colors().primary }}>n</span>
51
+ <span style={{ fg: colors().textMuted }}> cancel</span>
52
+ </text>
53
+ )
54
+
55
+ return (
56
+ <BorderBox
57
+ border
58
+ borderStyle={style().panel.borderStyle}
59
+ borderColor={colors().borderFocused}
60
+ backgroundColor={colors().background}
61
+ width="60%"
62
+ topLeft={<text fg={colors().text}>{title()}</text>}
63
+ bottomRight={hints()}
64
+ paddingLeft={1}
65
+ paddingRight={1}
66
+ >
67
+ <Show when={fetchedDetails.loading && !props.operationLines}>
68
+ <text fg={colors().textMuted}>Loading...</text>
69
+ </Show>
70
+ <Show when={!fetchedDetails.loading || props.operationLines}>
71
+ <AnsiText content={opDetails()} wrapMode="none" />
72
+ </Show>
73
+ </BorderBox>
74
+ )
75
+ }