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,143 @@
|
|
|
1
|
+
import { For, type JSX, Show } from "solid-js"
|
|
2
|
+
import { useCommand } from "../context/command"
|
|
3
|
+
import { useFocus } from "../context/focus"
|
|
4
|
+
import { useTheme } from "../context/theme"
|
|
5
|
+
import type { Context, Panel as PanelType } from "../context/types"
|
|
6
|
+
import { BorderBox } from "./BorderBox"
|
|
7
|
+
|
|
8
|
+
interface Tab {
|
|
9
|
+
id: string
|
|
10
|
+
label: string
|
|
11
|
+
context: Context
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface PanelProps {
|
|
15
|
+
title?: string
|
|
16
|
+
tabs?: Tab[]
|
|
17
|
+
activeTab?: string
|
|
18
|
+
onTabChange?: (tabId: string) => void
|
|
19
|
+
panelId?: PanelType
|
|
20
|
+
hotkey: string
|
|
21
|
+
focused: boolean
|
|
22
|
+
children: JSX.Element
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function Panel(props: PanelProps) {
|
|
26
|
+
const { colors, style } = useTheme()
|
|
27
|
+
const command = useCommand()
|
|
28
|
+
const focus = useFocus()
|
|
29
|
+
|
|
30
|
+
const hasTabs = () => props.tabs && props.tabs.length > 1
|
|
31
|
+
|
|
32
|
+
const cycleTab = (direction: 1 | -1) => {
|
|
33
|
+
if (!props.tabs || props.tabs.length <= 1 || !props.onTabChange) return
|
|
34
|
+
const currentIndex = props.tabs.findIndex((t) => t.id === props.activeTab)
|
|
35
|
+
const nextIndex =
|
|
36
|
+
(currentIndex + direction + props.tabs.length) % props.tabs.length
|
|
37
|
+
const nextTab = props.tabs[nextIndex]
|
|
38
|
+
if (nextTab) props.onTabChange(nextTab.id)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
command.register(() => {
|
|
42
|
+
if (!hasTabs() || !props.panelId) return []
|
|
43
|
+
|
|
44
|
+
return [
|
|
45
|
+
{
|
|
46
|
+
id: `${props.panelId}.next_tab`,
|
|
47
|
+
title: "next tab",
|
|
48
|
+
keybind: "next_tab",
|
|
49
|
+
context: props.panelId,
|
|
50
|
+
type: "navigation",
|
|
51
|
+
panel: props.panelId,
|
|
52
|
+
visibility: "help-only" as const,
|
|
53
|
+
onSelect: () => cycleTab(1),
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: `${props.panelId}.prev_tab`,
|
|
57
|
+
title: "previous tab",
|
|
58
|
+
keybind: "prev_tab",
|
|
59
|
+
context: props.panelId,
|
|
60
|
+
type: "navigation",
|
|
61
|
+
panel: props.panelId,
|
|
62
|
+
visibility: "help-only" as const,
|
|
63
|
+
onSelect: () => cycleTab(-1),
|
|
64
|
+
},
|
|
65
|
+
]
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const renderTitle = () => {
|
|
69
|
+
if (hasTabs()) {
|
|
70
|
+
return (
|
|
71
|
+
<text>
|
|
72
|
+
<Show
|
|
73
|
+
when={props.focused}
|
|
74
|
+
fallback={
|
|
75
|
+
<span style={{ fg: colors().textMuted }}>[{props.hotkey}]</span>
|
|
76
|
+
}
|
|
77
|
+
>
|
|
78
|
+
<span style={{ fg: colors().primary }}>[{props.hotkey}]</span>
|
|
79
|
+
</Show>
|
|
80
|
+
<span style={{ fg: colors().textMuted }}>─</span>
|
|
81
|
+
<For each={props.tabs}>
|
|
82
|
+
{(tab, i) => (
|
|
83
|
+
<>
|
|
84
|
+
<Show when={i() > 0}>
|
|
85
|
+
<span style={{ fg: colors().textMuted }}> </span>
|
|
86
|
+
</Show>
|
|
87
|
+
<span
|
|
88
|
+
style={{
|
|
89
|
+
fg:
|
|
90
|
+
tab.id === props.activeTab
|
|
91
|
+
? colors().primary
|
|
92
|
+
: colors().textMuted,
|
|
93
|
+
}}
|
|
94
|
+
>
|
|
95
|
+
{tab.label}
|
|
96
|
+
</span>
|
|
97
|
+
</>
|
|
98
|
+
)}
|
|
99
|
+
</For>
|
|
100
|
+
</text>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<text>
|
|
106
|
+
<Show
|
|
107
|
+
when={props.focused}
|
|
108
|
+
fallback={
|
|
109
|
+
<span style={{ fg: colors().textMuted }}>
|
|
110
|
+
[{props.hotkey}]─{props.title}
|
|
111
|
+
</span>
|
|
112
|
+
}
|
|
113
|
+
>
|
|
114
|
+
<span style={{ fg: colors().primary }}>
|
|
115
|
+
[{props.hotkey}]─{props.title}
|
|
116
|
+
</span>
|
|
117
|
+
</Show>
|
|
118
|
+
</text>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const handleMouseDown = () => {
|
|
123
|
+
if (props.panelId) {
|
|
124
|
+
focus.setPanel(props.panelId)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<BorderBox
|
|
130
|
+
topLeft={renderTitle}
|
|
131
|
+
border
|
|
132
|
+
borderStyle={style().panel.borderStyle}
|
|
133
|
+
borderColor={props.focused ? colors().borderFocused : colors().border}
|
|
134
|
+
flexGrow={1}
|
|
135
|
+
height="100%"
|
|
136
|
+
overflow="hidden"
|
|
137
|
+
gap={0}
|
|
138
|
+
onMouseDown={handleMouseDown}
|
|
139
|
+
>
|
|
140
|
+
{props.children}
|
|
141
|
+
</BorderBox>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
2
|
+
import { useKeyboard } from "@opentui/solid"
|
|
3
|
+
import { For, Show, createEffect, createSignal, onMount } from "solid-js"
|
|
4
|
+
import type { Commit } from "../commander/types"
|
|
5
|
+
import { useTheme } from "../context/theme"
|
|
6
|
+
import { AnsiText } from "./AnsiText"
|
|
7
|
+
|
|
8
|
+
export interface RevisionPickerProps {
|
|
9
|
+
commits: Commit[]
|
|
10
|
+
defaultRevision?: string
|
|
11
|
+
selectedRevision?: string
|
|
12
|
+
onSelect?: (commit: Commit) => void
|
|
13
|
+
focused?: boolean
|
|
14
|
+
height?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function RevisionPicker(props: RevisionPickerProps) {
|
|
18
|
+
const { colors } = useTheme()
|
|
19
|
+
|
|
20
|
+
const findDefaultIndex = () => {
|
|
21
|
+
if (props.defaultRevision) {
|
|
22
|
+
const idx = props.commits.findIndex(
|
|
23
|
+
(c) => c.changeId === props.defaultRevision,
|
|
24
|
+
)
|
|
25
|
+
return idx >= 0 ? idx : 0
|
|
26
|
+
}
|
|
27
|
+
return 0
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const [selectedIndex, setSelectedIndex] = createSignal(findDefaultIndex())
|
|
31
|
+
|
|
32
|
+
let scrollRef: ScrollBoxRenderable | undefined
|
|
33
|
+
const [scrollTop, setScrollTop] = createSignal(0)
|
|
34
|
+
|
|
35
|
+
const scrollToIndex = (index: number, force = false) => {
|
|
36
|
+
const commitList = props.commits
|
|
37
|
+
if (!scrollRef || commitList.length === 0) return
|
|
38
|
+
|
|
39
|
+
let lineOffset = 0
|
|
40
|
+
const clampedIndex = Math.min(index, commitList.length)
|
|
41
|
+
for (const commit of commitList.slice(0, clampedIndex)) {
|
|
42
|
+
lineOffset += commit.lines.length
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const margin = 2
|
|
46
|
+
const refAny = scrollRef as unknown as Record<string, unknown>
|
|
47
|
+
const viewportHeight =
|
|
48
|
+
(typeof refAny.height === "number" ? refAny.height : null) ??
|
|
49
|
+
(typeof refAny.rows === "number" ? refAny.rows : null) ??
|
|
50
|
+
10
|
|
51
|
+
const currentScrollTop = scrollTop()
|
|
52
|
+
|
|
53
|
+
if (force) {
|
|
54
|
+
const targetScroll = Math.max(0, lineOffset - margin)
|
|
55
|
+
scrollRef.scrollTo(targetScroll)
|
|
56
|
+
setScrollTop(targetScroll)
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const visibleStart = currentScrollTop
|
|
61
|
+
const visibleEnd = currentScrollTop + viewportHeight - 1
|
|
62
|
+
const safeStart = visibleStart + margin
|
|
63
|
+
const safeEnd = visibleEnd - margin
|
|
64
|
+
|
|
65
|
+
let newScrollTop = currentScrollTop
|
|
66
|
+
if (lineOffset < safeStart) {
|
|
67
|
+
newScrollTop = Math.max(0, lineOffset - margin)
|
|
68
|
+
} else if (lineOffset > safeEnd) {
|
|
69
|
+
newScrollTop = Math.max(0, lineOffset - viewportHeight + margin + 1)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (newScrollTop !== currentScrollTop) {
|
|
73
|
+
scrollRef.scrollTo(newScrollTop)
|
|
74
|
+
setScrollTop(newScrollTop)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
createEffect(() => {
|
|
79
|
+
const _ = props.commits
|
|
80
|
+
const __ = props.defaultRevision
|
|
81
|
+
setSelectedIndex(findDefaultIndex())
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
onMount(() => {
|
|
85
|
+
setTimeout(() => scrollToIndex(selectedIndex(), true), 1)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
createEffect(() => {
|
|
89
|
+
scrollToIndex(selectedIndex())
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const selectPrev = () => {
|
|
93
|
+
setSelectedIndex((i) => {
|
|
94
|
+
const newIndex = Math.max(0, i - 1)
|
|
95
|
+
const commit = props.commits[newIndex]
|
|
96
|
+
if (commit) props.onSelect?.(commit)
|
|
97
|
+
return newIndex
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const selectNext = () => {
|
|
102
|
+
setSelectedIndex((i) => {
|
|
103
|
+
const newIndex = Math.min(props.commits.length - 1, i + 1)
|
|
104
|
+
const commit = props.commits[newIndex]
|
|
105
|
+
if (commit) props.onSelect?.(commit)
|
|
106
|
+
return newIndex
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
useKeyboard((evt) => {
|
|
111
|
+
if (!props.focused) return
|
|
112
|
+
|
|
113
|
+
if (evt.name === "j" || evt.name === "down") {
|
|
114
|
+
evt.preventDefault()
|
|
115
|
+
selectNext()
|
|
116
|
+
} else if (evt.name === "k" || evt.name === "up") {
|
|
117
|
+
evt.preventDefault()
|
|
118
|
+
selectPrev()
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
createEffect(() => {
|
|
123
|
+
const commit = props.commits[selectedIndex()]
|
|
124
|
+
if (commit) props.onSelect?.(commit)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<Show
|
|
129
|
+
when={props.commits.length > 0}
|
|
130
|
+
fallback={<text fg={colors().textMuted}>No commits</text>}
|
|
131
|
+
>
|
|
132
|
+
<scrollbox
|
|
133
|
+
ref={scrollRef}
|
|
134
|
+
focused={props.focused}
|
|
135
|
+
flexGrow={1}
|
|
136
|
+
height={props.height}
|
|
137
|
+
scrollbarOptions={{ visible: false }}
|
|
138
|
+
>
|
|
139
|
+
<For each={props.commits}>
|
|
140
|
+
{(commit, index) => {
|
|
141
|
+
const isSelected = () => index() === selectedIndex()
|
|
142
|
+
return (
|
|
143
|
+
<For each={commit.lines}>
|
|
144
|
+
{(line) => (
|
|
145
|
+
<box
|
|
146
|
+
backgroundColor={
|
|
147
|
+
isSelected() ? colors().selectionBackground : undefined
|
|
148
|
+
}
|
|
149
|
+
overflow="hidden"
|
|
150
|
+
>
|
|
151
|
+
<AnsiText
|
|
152
|
+
content={line}
|
|
153
|
+
bold={commit.isWorkingCopy}
|
|
154
|
+
wrapMode="none"
|
|
155
|
+
/>
|
|
156
|
+
</box>
|
|
157
|
+
)}
|
|
158
|
+
</For>
|
|
159
|
+
)
|
|
160
|
+
}}
|
|
161
|
+
</For>
|
|
162
|
+
</scrollbox>
|
|
163
|
+
</Show>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { For, Show, createMemo, createSignal, onCleanup } from "solid-js"
|
|
2
|
+
import { useCommand } from "../context/command"
|
|
3
|
+
import { useDialog } from "../context/dialog"
|
|
4
|
+
import { useFocus } from "../context/focus"
|
|
5
|
+
import { useKeybind } from "../context/keybind"
|
|
6
|
+
import { useLoading } from "../context/loading"
|
|
7
|
+
import { useTheme } from "../context/theme"
|
|
8
|
+
import type { Context } from "../context/types"
|
|
9
|
+
|
|
10
|
+
function contextMatches(
|
|
11
|
+
commandContext: Context,
|
|
12
|
+
activeContext: Context,
|
|
13
|
+
): boolean {
|
|
14
|
+
if (commandContext === "global") return true
|
|
15
|
+
return commandContext === activeContext
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
19
|
+
|
|
20
|
+
export function StatusBar() {
|
|
21
|
+
const command = useCommand()
|
|
22
|
+
const dialog = useDialog()
|
|
23
|
+
const focus = useFocus()
|
|
24
|
+
const keybind = useKeybind()
|
|
25
|
+
const loading = useLoading()
|
|
26
|
+
const { colors, style } = useTheme()
|
|
27
|
+
|
|
28
|
+
const [spinnerFrame, setSpinnerFrame] = createSignal(0)
|
|
29
|
+
|
|
30
|
+
let spinnerInterval: ReturnType<typeof setInterval> | null = null
|
|
31
|
+
const startSpinner = () => {
|
|
32
|
+
if (spinnerInterval) return
|
|
33
|
+
spinnerInterval = setInterval(() => {
|
|
34
|
+
setSpinnerFrame((f) => (f + 1) % SPINNER_FRAMES.length)
|
|
35
|
+
}, 80)
|
|
36
|
+
}
|
|
37
|
+
const stopSpinner = () => {
|
|
38
|
+
if (spinnerInterval) {
|
|
39
|
+
clearInterval(spinnerInterval)
|
|
40
|
+
spinnerInterval = null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
createMemo(() => {
|
|
45
|
+
if (loading.isLoading()) {
|
|
46
|
+
startSpinner()
|
|
47
|
+
} else {
|
|
48
|
+
stopSpinner()
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
onCleanup(() => stopSpinner())
|
|
53
|
+
|
|
54
|
+
const relevantCommands = createMemo(() => {
|
|
55
|
+
const all = command.all()
|
|
56
|
+
const activeCtx = focus.activeContext()
|
|
57
|
+
const activePanel = focus.panel()
|
|
58
|
+
|
|
59
|
+
const isRelevant = (cmd: (typeof all)[0]) => {
|
|
60
|
+
if (!cmd.keybind) return false
|
|
61
|
+
if (!contextMatches(cmd.context, activeCtx)) return false
|
|
62
|
+
if (cmd.panel && cmd.panel !== activePanel) return false
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const isVisibleInStatusBar = (cmd: (typeof all)[0]) => {
|
|
67
|
+
const v = cmd.visibility ?? "all"
|
|
68
|
+
return v === "all" || v === "status-only"
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const contextCmds = all.filter(
|
|
72
|
+
(cmd) => isRelevant(cmd) && cmd.context !== "global",
|
|
73
|
+
)
|
|
74
|
+
const globalCmds = all.filter(
|
|
75
|
+
(cmd) => isRelevant(cmd) && cmd.context === "global",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const seen = new Set<string>()
|
|
79
|
+
return [...contextCmds, ...globalCmds].filter((cmd) => {
|
|
80
|
+
if (!isVisibleInStatusBar(cmd)) return false
|
|
81
|
+
if (seen.has(cmd.id)) return false
|
|
82
|
+
seen.add(cmd.id)
|
|
83
|
+
return true
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const separator = () => style().statusBar.separator
|
|
88
|
+
const gap = () => (separator() ? 0 : 3)
|
|
89
|
+
|
|
90
|
+
const dialogHints = createMemo(() => {
|
|
91
|
+
const hints = dialog.hints()
|
|
92
|
+
if (hints.length === 0) return []
|
|
93
|
+
return [
|
|
94
|
+
...hints,
|
|
95
|
+
{ key: "esc", label: "close" },
|
|
96
|
+
{ key: "?", label: "help" },
|
|
97
|
+
]
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<box
|
|
102
|
+
height={1}
|
|
103
|
+
flexShrink={0}
|
|
104
|
+
paddingLeft={1}
|
|
105
|
+
paddingRight={1}
|
|
106
|
+
flexDirection="row"
|
|
107
|
+
gap={gap()}
|
|
108
|
+
>
|
|
109
|
+
<Show when={loading.isLoading()}>
|
|
110
|
+
<text>
|
|
111
|
+
<span style={{ fg: colors().warning }}>
|
|
112
|
+
{SPINNER_FRAMES[spinnerFrame()]}
|
|
113
|
+
</span>{" "}
|
|
114
|
+
<span style={{ fg: colors().text }}>{loading.loadingText()}</span>
|
|
115
|
+
<Show when={separator()}>
|
|
116
|
+
<span style={{ fg: colors().textMuted }}>{` ${separator()} `}</span>
|
|
117
|
+
</Show>
|
|
118
|
+
</text>
|
|
119
|
+
</Show>
|
|
120
|
+
<Show
|
|
121
|
+
when={dialog.isOpen() && dialogHints().length > 0}
|
|
122
|
+
fallback={
|
|
123
|
+
<For each={relevantCommands()}>
|
|
124
|
+
{(cmd, index) => (
|
|
125
|
+
<text>
|
|
126
|
+
<span style={{ fg: colors().primary }}>
|
|
127
|
+
{cmd.keybind ? keybind.print(cmd.keybind) : ""}
|
|
128
|
+
</span>{" "}
|
|
129
|
+
<span style={{ fg: colors().text }}>{cmd.title}</span>
|
|
130
|
+
<Show
|
|
131
|
+
when={separator() && index() < relevantCommands().length - 1}
|
|
132
|
+
>
|
|
133
|
+
<span style={{ fg: colors().textMuted }}>
|
|
134
|
+
{` ${separator()} `}
|
|
135
|
+
</span>
|
|
136
|
+
</Show>
|
|
137
|
+
</text>
|
|
138
|
+
)}
|
|
139
|
+
</For>
|
|
140
|
+
}
|
|
141
|
+
>
|
|
142
|
+
<For each={dialogHints()}>
|
|
143
|
+
{(hint, index) => (
|
|
144
|
+
<text>
|
|
145
|
+
<span style={{ fg: colors().primary }}>{hint.key}</span>{" "}
|
|
146
|
+
<span style={{ fg: colors().text }}>{hint.label}</span>
|
|
147
|
+
<Show when={separator() && index() < dialogHints().length - 1}>
|
|
148
|
+
<span style={{ fg: colors().textMuted }}>
|
|
149
|
+
{` ${separator()} `}
|
|
150
|
+
</span>
|
|
151
|
+
</Show>
|
|
152
|
+
</text>
|
|
153
|
+
)}
|
|
154
|
+
</For>
|
|
155
|
+
</Show>
|
|
156
|
+
</box>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { type InputRenderable, RGBA } from "@opentui/core"
|
|
2
|
+
import { useKeyboard } from "@opentui/solid"
|
|
3
|
+
import { Show, createSignal, onMount } from "solid-js"
|
|
4
|
+
import type { Commit } from "../../commander/types"
|
|
5
|
+
import { useDialog } from "../../context/dialog"
|
|
6
|
+
import { useTheme } from "../../context/theme"
|
|
7
|
+
import { RevisionPicker } from "../RevisionPicker"
|
|
8
|
+
|
|
9
|
+
interface BookmarkNameModalProps {
|
|
10
|
+
title: string
|
|
11
|
+
commits?: Commit[]
|
|
12
|
+
defaultRevision?: string
|
|
13
|
+
initialValue?: string
|
|
14
|
+
placeholder?: string
|
|
15
|
+
width?: number | "auto" | `${number}%`
|
|
16
|
+
height?: number
|
|
17
|
+
onSave: (name: string, revision?: string) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function BookmarkNameModal(props: BookmarkNameModalProps) {
|
|
21
|
+
const dialog = useDialog()
|
|
22
|
+
const { colors, style } = useTheme()
|
|
23
|
+
|
|
24
|
+
const hasRevisionPicker = () => (props.commits?.length ?? 0) > 0
|
|
25
|
+
|
|
26
|
+
const [selectedRevision, setSelectedRevision] = createSignal(
|
|
27
|
+
props.defaultRevision ?? props.commits?.[0]?.changeId ?? "",
|
|
28
|
+
)
|
|
29
|
+
const [name, setName] = createSignal(props.initialValue ?? "")
|
|
30
|
+
const [error, setError] = createSignal<string | null>(null)
|
|
31
|
+
const [focusedField, setFocusedField] = createSignal<"name" | "picker">(
|
|
32
|
+
"name",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
let inputRef: InputRenderable | undefined
|
|
36
|
+
|
|
37
|
+
const focusInputAtEnd = (ref: InputRenderable | undefined) => {
|
|
38
|
+
if (!ref) return
|
|
39
|
+
ref.focus()
|
|
40
|
+
ref.cursorPosition = ref.value.length
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
onMount(() => {
|
|
44
|
+
setTimeout(() => focusInputAtEnd(inputRef), 1)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const generatedName = () => {
|
|
48
|
+
if (!hasRevisionPicker()) return props.placeholder ?? ""
|
|
49
|
+
const rev = selectedRevision()
|
|
50
|
+
return rev ? `push-${rev.slice(0, 8)}` : "push-bookmark"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const handleSave = () => {
|
|
54
|
+
const trimmed = name().trim()
|
|
55
|
+
const finalName = trimmed || generatedName()
|
|
56
|
+
|
|
57
|
+
if (!finalName) {
|
|
58
|
+
setError("Name cannot be empty")
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
if (/\s/.test(finalName)) {
|
|
62
|
+
setError("Name cannot contain spaces")
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
dialog.close()
|
|
67
|
+
if (hasRevisionPicker()) {
|
|
68
|
+
props.onSave(finalName, selectedRevision())
|
|
69
|
+
} else {
|
|
70
|
+
props.onSave(finalName)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
useKeyboard((evt) => {
|
|
75
|
+
if (evt.name === "escape") {
|
|
76
|
+
evt.preventDefault()
|
|
77
|
+
dialog.close()
|
|
78
|
+
} else if (evt.name === "tab" && hasRevisionPicker()) {
|
|
79
|
+
evt.preventDefault()
|
|
80
|
+
if (focusedField() === "name") {
|
|
81
|
+
setFocusedField("picker")
|
|
82
|
+
} else {
|
|
83
|
+
setFocusedField("name")
|
|
84
|
+
focusInputAtEnd(inputRef)
|
|
85
|
+
}
|
|
86
|
+
} else if (evt.name === "return" && focusedField() === "picker") {
|
|
87
|
+
evt.preventDefault()
|
|
88
|
+
handleSave()
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const handleRevisionSelect = (commit: Commit) => {
|
|
93
|
+
setSelectedRevision(commit.changeId)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const pickerHeight = () => props.height ?? 10
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<box flexDirection="column" width={props.width ?? "60%"} gap={0}>
|
|
100
|
+
<box
|
|
101
|
+
flexDirection="column"
|
|
102
|
+
border
|
|
103
|
+
borderStyle={style().panel.borderStyle}
|
|
104
|
+
borderColor={
|
|
105
|
+
focusedField() === "name" || !hasRevisionPicker()
|
|
106
|
+
? colors().borderFocused
|
|
107
|
+
: colors().border
|
|
108
|
+
}
|
|
109
|
+
backgroundColor={colors().background}
|
|
110
|
+
height={3}
|
|
111
|
+
padding={0}
|
|
112
|
+
title={props.title}
|
|
113
|
+
>
|
|
114
|
+
<input
|
|
115
|
+
ref={inputRef}
|
|
116
|
+
value={props.initialValue ?? ""}
|
|
117
|
+
placeholder={generatedName()}
|
|
118
|
+
onInput={(value) => {
|
|
119
|
+
setName(value)
|
|
120
|
+
setError(null)
|
|
121
|
+
}}
|
|
122
|
+
onSubmit={handleSave}
|
|
123
|
+
cursorColor={colors().primary}
|
|
124
|
+
textColor={colors().text}
|
|
125
|
+
focusedTextColor={colors().text}
|
|
126
|
+
focusedBackgroundColor={RGBA.fromInts(0, 0, 0, 0)}
|
|
127
|
+
flexGrow={1}
|
|
128
|
+
/>
|
|
129
|
+
</box>
|
|
130
|
+
|
|
131
|
+
<Show when={error()}>
|
|
132
|
+
<box
|
|
133
|
+
border
|
|
134
|
+
borderStyle={style().panel.borderStyle}
|
|
135
|
+
borderColor={colors().error}
|
|
136
|
+
backgroundColor={colors().background}
|
|
137
|
+
padding={0}
|
|
138
|
+
paddingLeft={1}
|
|
139
|
+
>
|
|
140
|
+
<text fg={colors().error}>{error()}</text>
|
|
141
|
+
</box>
|
|
142
|
+
</Show>
|
|
143
|
+
|
|
144
|
+
<Show when={hasRevisionPicker()}>
|
|
145
|
+
<box
|
|
146
|
+
flexDirection="column"
|
|
147
|
+
border
|
|
148
|
+
borderStyle={style().panel.borderStyle}
|
|
149
|
+
borderColor={
|
|
150
|
+
focusedField() === "picker"
|
|
151
|
+
? colors().borderFocused
|
|
152
|
+
: colors().border
|
|
153
|
+
}
|
|
154
|
+
backgroundColor={colors().background}
|
|
155
|
+
height={pickerHeight()}
|
|
156
|
+
padding={0}
|
|
157
|
+
title="Revision"
|
|
158
|
+
>
|
|
159
|
+
<RevisionPicker
|
|
160
|
+
commits={props.commits ?? []}
|
|
161
|
+
defaultRevision={props.defaultRevision}
|
|
162
|
+
focused={focusedField() === "picker"}
|
|
163
|
+
onSelect={handleRevisionSelect}
|
|
164
|
+
height={pickerHeight() - 2}
|
|
165
|
+
/>
|
|
166
|
+
</box>
|
|
167
|
+
</Show>
|
|
168
|
+
</box>
|
|
169
|
+
)
|
|
170
|
+
}
|