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,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
|
+
}
|