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