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,90 @@
|
|
|
1
|
+
import { useRenderer } from "@opentui/solid"
|
|
2
|
+
import { type Accessor, createSignal, onMount } from "solid-js"
|
|
3
|
+
import { lazygitTheme } from "../theme/presets/lazygit"
|
|
4
|
+
import { opencodeTheme } from "../theme/presets/opencode"
|
|
5
|
+
import type { Theme, ThemeColors, ThemeStyle } from "../theme/types"
|
|
6
|
+
import { createSimpleContext } from "./helper"
|
|
7
|
+
|
|
8
|
+
const ACTIVE_THEME: "lazygit" | "opencode" = "lazygit"
|
|
9
|
+
|
|
10
|
+
const themes = {
|
|
11
|
+
lazygit: lazygitTheme,
|
|
12
|
+
opencode: opencodeTheme,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseHexColor(
|
|
16
|
+
hex: string,
|
|
17
|
+
): { r: number; g: number; b: number } | null {
|
|
18
|
+
if (!hex || !hex.startsWith("#") || hex.length !== 7) return null
|
|
19
|
+
const r = Number.parseInt(hex.slice(1, 3), 16)
|
|
20
|
+
const g = Number.parseInt(hex.slice(3, 5), 16)
|
|
21
|
+
const b = Number.parseInt(hex.slice(5, 7), 16)
|
|
22
|
+
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null
|
|
23
|
+
return { r, g, b }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function calculateLuminance(r: number, g: number, b: number): number {
|
|
27
|
+
return (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function adjustBrightness(hex: string, amount: number): string {
|
|
31
|
+
const rgb = parseHexColor(hex)
|
|
32
|
+
if (!rgb) return hex
|
|
33
|
+
const adjust = (c: number) => Math.max(0, Math.min(255, c + amount))
|
|
34
|
+
const toHex = (c: number) => adjust(c).toString(16).padStart(2, "0")
|
|
35
|
+
return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|
39
|
+
name: "Theme",
|
|
40
|
+
init: () => {
|
|
41
|
+
const renderer = useRenderer()
|
|
42
|
+
const [theme, setTheme] = createSignal<Theme>(themes[ACTIVE_THEME])
|
|
43
|
+
const [terminalBg, setTerminalBg] = createSignal<string | null>(null)
|
|
44
|
+
const [isDark, setIsDark] = createSignal(true)
|
|
45
|
+
|
|
46
|
+
onMount(async () => {
|
|
47
|
+
try {
|
|
48
|
+
const palette = await renderer.getPalette({ timeout: 1000 })
|
|
49
|
+
if (palette.defaultBackground) {
|
|
50
|
+
setTerminalBg(palette.defaultBackground)
|
|
51
|
+
const rgb = parseHexColor(palette.defaultBackground)
|
|
52
|
+
if (rgb) {
|
|
53
|
+
const luminance = calculateLuminance(rgb.r, rgb.g, rgb.b)
|
|
54
|
+
setIsDark(luminance <= 0.5)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch {}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const colors: Accessor<ThemeColors> = () => {
|
|
61
|
+
const base = theme().colors
|
|
62
|
+
const bg = terminalBg()
|
|
63
|
+
|
|
64
|
+
if (!theme().style.adaptToTerminal || !bg) {
|
|
65
|
+
return base
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const brightnessDir = isDark() ? 1 : -1
|
|
69
|
+
return {
|
|
70
|
+
...base,
|
|
71
|
+
background: bg,
|
|
72
|
+
backgroundSecondary: adjustBrightness(bg, 10 * brightnessDir),
|
|
73
|
+
backgroundElement: adjustBrightness(bg, 20 * brightnessDir),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const style: Accessor<ThemeStyle> = () => theme().style
|
|
78
|
+
|
|
79
|
+
const setThemeByName = (name: "lazygit" | "opencode") => {
|
|
80
|
+
setTheme(themes[name])
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
theme,
|
|
85
|
+
colors,
|
|
86
|
+
style,
|
|
87
|
+
setTheme: setThemeByName,
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Panel represents the physical UI region (derived from context's first segment)
|
|
3
|
+
*/
|
|
4
|
+
export type Panel = "log" | "refs" | "detail"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Context represents the current interaction mode (what keys mean right now).
|
|
8
|
+
* Format: panel.mode (e.g., "log.revisions", "log.files")
|
|
9
|
+
*
|
|
10
|
+
* Modes are siblings, not hierarchical. "files" is NOT a child of "revisions".
|
|
11
|
+
* This prevents accidental command inheritance via prefix matching.
|
|
12
|
+
*/
|
|
13
|
+
export type Context =
|
|
14
|
+
// Special contexts
|
|
15
|
+
| "global"
|
|
16
|
+
| "help"
|
|
17
|
+
// Log panel modes
|
|
18
|
+
| "log"
|
|
19
|
+
| "log.revisions"
|
|
20
|
+
| "log.files"
|
|
21
|
+
| "log.oplog"
|
|
22
|
+
// Refs panel modes
|
|
23
|
+
| "refs"
|
|
24
|
+
| "refs.bookmarks"
|
|
25
|
+
| "refs.revisions"
|
|
26
|
+
| "refs.files"
|
|
27
|
+
// Detail panel
|
|
28
|
+
| "detail"
|
|
29
|
+
|
|
30
|
+
export type CommandType = "action" | "navigation" | "view" | "git"
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Controls where a command appears:
|
|
34
|
+
* - "all": show in both status bar and help modal (default)
|
|
35
|
+
* - "help-only": show only in help modal (navigation, git ops, refresh)
|
|
36
|
+
* - "status-only": show only in status bar (modal hints)
|
|
37
|
+
* - "none": hidden everywhere (internal commands)
|
|
38
|
+
*/
|
|
39
|
+
export type CommandVisibility = "all" | "help-only" | "status-only" | "none"
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Extract the panel from a hierarchical context
|
|
43
|
+
*/
|
|
44
|
+
export function panelFromContext(context: Context): Panel | null {
|
|
45
|
+
if (context === "global" || context === "help") return null
|
|
46
|
+
const panel = context.split(".")[0]
|
|
47
|
+
if (panel === "log" || panel === "refs" || panel === "detail") {
|
|
48
|
+
return panel
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ConsolePosition } from "@opentui/core"
|
|
2
|
+
import { extend, render } from "@opentui/solid"
|
|
3
|
+
import { GhosttyTerminalRenderable } from "ghostty-opentui/terminal-buffer"
|
|
4
|
+
import { App } from "./App"
|
|
5
|
+
|
|
6
|
+
// Register ghostty-terminal component for ANSI rendering
|
|
7
|
+
extend({ "ghostty-terminal": GhosttyTerminalRenderable })
|
|
8
|
+
|
|
9
|
+
render(() => <App />, {
|
|
10
|
+
consoleOptions: {
|
|
11
|
+
position: ConsolePosition.BOTTOM,
|
|
12
|
+
maxStoredLogs: 1000,
|
|
13
|
+
sizePercent: 40,
|
|
14
|
+
},
|
|
15
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { KeybindInfo } from "./types"
|
|
2
|
+
|
|
3
|
+
export function parse(key: string): KeybindInfo[] {
|
|
4
|
+
if (key === "none") return []
|
|
5
|
+
|
|
6
|
+
return key.split(",").map((combo) => {
|
|
7
|
+
const parts = combo.split("+")
|
|
8
|
+
const info: KeybindInfo = {
|
|
9
|
+
ctrl: false,
|
|
10
|
+
meta: false,
|
|
11
|
+
shift: false,
|
|
12
|
+
name: "",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
for (const part of parts) {
|
|
16
|
+
const lower = part.toLowerCase()
|
|
17
|
+
switch (lower) {
|
|
18
|
+
case "ctrl":
|
|
19
|
+
info.ctrl = true
|
|
20
|
+
break
|
|
21
|
+
case "alt":
|
|
22
|
+
case "meta":
|
|
23
|
+
case "option":
|
|
24
|
+
info.meta = true
|
|
25
|
+
break
|
|
26
|
+
case "shift":
|
|
27
|
+
info.shift = true
|
|
28
|
+
break
|
|
29
|
+
case "esc":
|
|
30
|
+
info.name = "escape"
|
|
31
|
+
break
|
|
32
|
+
default:
|
|
33
|
+
if (part.length === 1 && part !== lower) {
|
|
34
|
+
info.shift = true
|
|
35
|
+
}
|
|
36
|
+
info.name = lower
|
|
37
|
+
break
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return info
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function match(a: KeybindInfo, b: KeybindInfo): boolean {
|
|
46
|
+
return (
|
|
47
|
+
a.name === b.name &&
|
|
48
|
+
a.ctrl === b.ctrl &&
|
|
49
|
+
a.meta === b.meta &&
|
|
50
|
+
a.shift === b.shift
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function keybindToString(info: KeybindInfo): string {
|
|
55
|
+
const parts: string[] = []
|
|
56
|
+
|
|
57
|
+
if (info.ctrl) parts.push("ctrl")
|
|
58
|
+
if (info.meta) parts.push("alt")
|
|
59
|
+
|
|
60
|
+
const isSingleLetter = info.name.length === 1 && /[a-z]/.test(info.name)
|
|
61
|
+
if (info.shift && !isSingleLetter) parts.push("shift")
|
|
62
|
+
|
|
63
|
+
if (info.name) {
|
|
64
|
+
if (info.name === "delete") parts.push("del")
|
|
65
|
+
else if (info.name === "escape") parts.push("esc")
|
|
66
|
+
else if (info.shift && isSingleLetter) parts.push(info.name.toUpperCase())
|
|
67
|
+
else parts.push(info.name)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return parts.join("+")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function fromParsedKey(evt: {
|
|
74
|
+
name?: string
|
|
75
|
+
ctrl?: boolean
|
|
76
|
+
meta?: boolean
|
|
77
|
+
shift?: boolean
|
|
78
|
+
}): KeybindInfo {
|
|
79
|
+
let name = evt.name ?? ""
|
|
80
|
+
if (name === "return") name = "enter"
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
name,
|
|
84
|
+
ctrl: evt.ctrl ?? false,
|
|
85
|
+
meta: evt.meta ?? false,
|
|
86
|
+
shift: evt.shift ?? false,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export interface KeybindInfo {
|
|
2
|
+
name: string
|
|
3
|
+
ctrl: boolean
|
|
4
|
+
meta: boolean
|
|
5
|
+
shift: boolean
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type KeybindConfigKey =
|
|
9
|
+
| "quit"
|
|
10
|
+
| "toggle_console"
|
|
11
|
+
| "focus_next"
|
|
12
|
+
| "focus_prev"
|
|
13
|
+
| "focus_panel_1"
|
|
14
|
+
| "focus_panel_2"
|
|
15
|
+
| "focus_panel_3"
|
|
16
|
+
| "nav_down"
|
|
17
|
+
| "nav_up"
|
|
18
|
+
| "nav_page_up"
|
|
19
|
+
| "nav_page_down"
|
|
20
|
+
| "help"
|
|
21
|
+
| "refresh"
|
|
22
|
+
| "enter"
|
|
23
|
+
| "escape"
|
|
24
|
+
| "prev_tab"
|
|
25
|
+
| "next_tab"
|
|
26
|
+
| "jj_new"
|
|
27
|
+
| "jj_edit"
|
|
28
|
+
| "jj_describe"
|
|
29
|
+
| "jj_squash"
|
|
30
|
+
| "jj_abandon"
|
|
31
|
+
| "jj_undo"
|
|
32
|
+
| "jj_redo"
|
|
33
|
+
| "jj_restore"
|
|
34
|
+
| "jj_git_fetch"
|
|
35
|
+
| "jj_git_fetch_all"
|
|
36
|
+
| "jj_git_push"
|
|
37
|
+
| "jj_git_push_all"
|
|
38
|
+
| "bookmark_create"
|
|
39
|
+
| "bookmark_delete"
|
|
40
|
+
| "bookmark_rename"
|
|
41
|
+
| "bookmark_forget"
|
|
42
|
+
| "bookmark_set"
|
|
43
|
+
| "bookmark_move"
|
|
44
|
+
|
|
45
|
+
export type KeybindConfig = Record<KeybindConfigKey, string>
|
|
46
|
+
|
|
47
|
+
export const DEFAULT_KEYBINDS: KeybindConfig = {
|
|
48
|
+
quit: "q",
|
|
49
|
+
toggle_console: "§",
|
|
50
|
+
focus_next: "tab",
|
|
51
|
+
focus_prev: "shift+tab",
|
|
52
|
+
focus_panel_1: "1",
|
|
53
|
+
focus_panel_2: "2",
|
|
54
|
+
focus_panel_3: "3",
|
|
55
|
+
nav_down: "j,down",
|
|
56
|
+
nav_up: "k,up",
|
|
57
|
+
nav_page_up: "ctrl+u",
|
|
58
|
+
nav_page_down: "ctrl+d",
|
|
59
|
+
help: "?",
|
|
60
|
+
refresh: "ctrl+r",
|
|
61
|
+
enter: "enter",
|
|
62
|
+
escape: "escape",
|
|
63
|
+
prev_tab: "[",
|
|
64
|
+
next_tab: "]",
|
|
65
|
+
jj_new: "n",
|
|
66
|
+
jj_edit: "e",
|
|
67
|
+
jj_describe: "d",
|
|
68
|
+
jj_squash: "s",
|
|
69
|
+
jj_abandon: "a",
|
|
70
|
+
jj_undo: "u",
|
|
71
|
+
jj_redo: "U",
|
|
72
|
+
jj_restore: "r",
|
|
73
|
+
jj_git_fetch: "f",
|
|
74
|
+
jj_git_fetch_all: "F",
|
|
75
|
+
jj_git_push: "p",
|
|
76
|
+
jj_git_push_all: "P",
|
|
77
|
+
bookmark_create: "c",
|
|
78
|
+
bookmark_delete: "d",
|
|
79
|
+
bookmark_rename: "r",
|
|
80
|
+
bookmark_forget: "x",
|
|
81
|
+
bookmark_set: "b",
|
|
82
|
+
bookmark_move: "m",
|
|
83
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Theme } from "../types"
|
|
2
|
+
|
|
3
|
+
export const lazygitTheme: Theme = {
|
|
4
|
+
name: "lazygit",
|
|
5
|
+
colors: {
|
|
6
|
+
primary: "#7FD962",
|
|
7
|
+
secondary: "#56b6c2",
|
|
8
|
+
background: "#0a0a0a",
|
|
9
|
+
backgroundSecondary: "#141414",
|
|
10
|
+
backgroundElement: "#1e1e1e",
|
|
11
|
+
|
|
12
|
+
text: "#eeeeee",
|
|
13
|
+
textMuted: "#808080",
|
|
14
|
+
|
|
15
|
+
border: "#606060",
|
|
16
|
+
borderFocused: "#7FD962",
|
|
17
|
+
|
|
18
|
+
selectionBackground: "#323264",
|
|
19
|
+
selectionText: "#eeeeee",
|
|
20
|
+
|
|
21
|
+
success: "#7FD962",
|
|
22
|
+
warning: "#e5c07b",
|
|
23
|
+
error: "#e06c75",
|
|
24
|
+
info: "#56b6c2",
|
|
25
|
+
|
|
26
|
+
purple: "#c678dd",
|
|
27
|
+
orange: "#d19a66",
|
|
28
|
+
green: "#7FD962",
|
|
29
|
+
|
|
30
|
+
scrollbarTrack: "#303030",
|
|
31
|
+
scrollbarThumb: "#606060",
|
|
32
|
+
},
|
|
33
|
+
style: {
|
|
34
|
+
panel: {
|
|
35
|
+
borderStyle: "rounded",
|
|
36
|
+
},
|
|
37
|
+
statusBar: {
|
|
38
|
+
separator: "•",
|
|
39
|
+
},
|
|
40
|
+
dialog: {
|
|
41
|
+
overlayOpacity: 0,
|
|
42
|
+
},
|
|
43
|
+
adaptToTerminal: true,
|
|
44
|
+
},
|
|
45
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Theme } from "../types"
|
|
2
|
+
|
|
3
|
+
export const opencodeTheme: Theme = {
|
|
4
|
+
name: "opencode",
|
|
5
|
+
colors: {
|
|
6
|
+
primary: "#fab283",
|
|
7
|
+
secondary: "#5c9cf5",
|
|
8
|
+
background: "#0a0a0a",
|
|
9
|
+
backgroundSecondary: "#141414",
|
|
10
|
+
backgroundElement: "#1e1e1e",
|
|
11
|
+
|
|
12
|
+
text: "#eeeeee",
|
|
13
|
+
textMuted: "#808080",
|
|
14
|
+
|
|
15
|
+
border: "#484848",
|
|
16
|
+
borderFocused: "#eeeeee",
|
|
17
|
+
|
|
18
|
+
selectionBackground: "#1e1e1e",
|
|
19
|
+
selectionText: "#fab283",
|
|
20
|
+
|
|
21
|
+
success: "#12c905",
|
|
22
|
+
warning: "#fcd53a",
|
|
23
|
+
error: "#fc533a",
|
|
24
|
+
info: "#5c9cf5",
|
|
25
|
+
|
|
26
|
+
purple: "#9d7cd8",
|
|
27
|
+
orange: "#f5a742",
|
|
28
|
+
green: "#7fd88f",
|
|
29
|
+
|
|
30
|
+
scrollbarTrack: "#1e1e1e",
|
|
31
|
+
scrollbarThumb: "#484848",
|
|
32
|
+
},
|
|
33
|
+
style: {
|
|
34
|
+
panel: {
|
|
35
|
+
borderStyle: "single",
|
|
36
|
+
},
|
|
37
|
+
statusBar: {
|
|
38
|
+
separator: null,
|
|
39
|
+
},
|
|
40
|
+
dialog: {
|
|
41
|
+
overlayOpacity: 150,
|
|
42
|
+
},
|
|
43
|
+
adaptToTerminal: false,
|
|
44
|
+
},
|
|
45
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface ThemeColors {
|
|
2
|
+
primary: string
|
|
3
|
+
secondary: string
|
|
4
|
+
background: string
|
|
5
|
+
backgroundSecondary: string
|
|
6
|
+
backgroundElement: string
|
|
7
|
+
|
|
8
|
+
text: string
|
|
9
|
+
textMuted: string
|
|
10
|
+
|
|
11
|
+
border: string
|
|
12
|
+
borderFocused: string
|
|
13
|
+
|
|
14
|
+
selectionBackground: string
|
|
15
|
+
selectionText: string
|
|
16
|
+
|
|
17
|
+
success: string
|
|
18
|
+
warning: string
|
|
19
|
+
error: string
|
|
20
|
+
info: string
|
|
21
|
+
|
|
22
|
+
purple: string
|
|
23
|
+
orange: string
|
|
24
|
+
green: string
|
|
25
|
+
|
|
26
|
+
scrollbarTrack: string
|
|
27
|
+
scrollbarThumb: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ThemeStyle {
|
|
31
|
+
panel: {
|
|
32
|
+
borderStyle: "rounded" | "single"
|
|
33
|
+
}
|
|
34
|
+
statusBar: {
|
|
35
|
+
separator: string | null
|
|
36
|
+
}
|
|
37
|
+
dialog: {
|
|
38
|
+
overlayOpacity: number
|
|
39
|
+
}
|
|
40
|
+
adaptToTerminal: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface Theme {
|
|
44
|
+
name: string
|
|
45
|
+
colors: ThemeColors
|
|
46
|
+
style: ThemeStyle
|
|
47
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a double-click detector that distinguishes single vs double clicks.
|
|
3
|
+
*
|
|
4
|
+
* @param onSingleClick - Called on single click (after timeout confirms no second click)
|
|
5
|
+
* @param onDoubleClick - Called on double click
|
|
6
|
+
* @param timeout - Max time between clicks to count as double-click (default: 300ms)
|
|
7
|
+
*/
|
|
8
|
+
export function createDoubleClickHandler(
|
|
9
|
+
onSingleClick?: () => void,
|
|
10
|
+
onDoubleClick?: () => void,
|
|
11
|
+
timeout = 300,
|
|
12
|
+
): () => void {
|
|
13
|
+
let lastClickTime = 0
|
|
14
|
+
let pendingTimeout: ReturnType<typeof setTimeout> | null = null
|
|
15
|
+
|
|
16
|
+
return () => {
|
|
17
|
+
const now = Date.now()
|
|
18
|
+
const timeSinceLastClick = now - lastClickTime
|
|
19
|
+
|
|
20
|
+
if (pendingTimeout) {
|
|
21
|
+
clearTimeout(pendingTimeout)
|
|
22
|
+
pendingTimeout = null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (timeSinceLastClick < timeout) {
|
|
26
|
+
lastClickTime = 0
|
|
27
|
+
onDoubleClick?.()
|
|
28
|
+
} else {
|
|
29
|
+
lastClickTime = now
|
|
30
|
+
if (onSingleClick) {
|
|
31
|
+
pendingTimeout = setTimeout(() => {
|
|
32
|
+
pendingTimeout = null
|
|
33
|
+
onSingleClick()
|
|
34
|
+
}, timeout)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Simpler version that only detects double-clicks (no single-click delay).
|
|
42
|
+
* Use this when you don't need to distinguish single clicks.
|
|
43
|
+
*/
|
|
44
|
+
export function createDoubleClickDetector(
|
|
45
|
+
onDoubleClick: () => void,
|
|
46
|
+
timeout = 300,
|
|
47
|
+
): () => void {
|
|
48
|
+
let lastClickTime = 0
|
|
49
|
+
|
|
50
|
+
return () => {
|
|
51
|
+
const now = Date.now()
|
|
52
|
+
if (now - lastClickTime < timeout) {
|
|
53
|
+
lastClickTime = 0
|
|
54
|
+
onDoubleClick()
|
|
55
|
+
} else {
|
|
56
|
+
lastClickTime = now
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { FileChange, FileStatus } from "../commander/types"
|
|
2
|
+
|
|
3
|
+
export interface FileTreeNode {
|
|
4
|
+
name: string
|
|
5
|
+
path: string
|
|
6
|
+
isDirectory: boolean
|
|
7
|
+
status?: FileStatus
|
|
8
|
+
children: FileTreeNode[]
|
|
9
|
+
depth: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface FlatFileNode {
|
|
13
|
+
node: FileTreeNode
|
|
14
|
+
visualDepth: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function splitPath(path: string): string[] {
|
|
18
|
+
return path.split("/").filter((part) => part.length > 0)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function insertIntoTree(
|
|
22
|
+
root: FileTreeNode,
|
|
23
|
+
file: FileChange,
|
|
24
|
+
parts: string[],
|
|
25
|
+
depth: number,
|
|
26
|
+
): void {
|
|
27
|
+
if (parts.length === 0) return
|
|
28
|
+
|
|
29
|
+
const [current, ...rest] = parts
|
|
30
|
+
if (!current) return
|
|
31
|
+
|
|
32
|
+
let child = root.children.find((c) => c.name === current)
|
|
33
|
+
|
|
34
|
+
if (!child) {
|
|
35
|
+
const isFile = rest.length === 0
|
|
36
|
+
const path = root.path ? `${root.path}/${current}` : current
|
|
37
|
+
|
|
38
|
+
child = {
|
|
39
|
+
name: current,
|
|
40
|
+
path,
|
|
41
|
+
isDirectory: !isFile,
|
|
42
|
+
status: isFile ? file.status : undefined,
|
|
43
|
+
children: [],
|
|
44
|
+
depth: depth,
|
|
45
|
+
}
|
|
46
|
+
root.children.push(child)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (rest.length > 0) {
|
|
50
|
+
insertIntoTree(child, file, rest, depth + 1)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sortTree(node: FileTreeNode): void {
|
|
55
|
+
node.children.sort((a, b) => {
|
|
56
|
+
if (a.isDirectory && !b.isDirectory) return -1
|
|
57
|
+
if (!a.isDirectory && b.isDirectory) return 1
|
|
58
|
+
return a.name.localeCompare(b.name)
|
|
59
|
+
})
|
|
60
|
+
for (const child of node.children) {
|
|
61
|
+
sortTree(child)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function compressNode(node: FileTreeNode): void {
|
|
66
|
+
while (
|
|
67
|
+
node.isDirectory &&
|
|
68
|
+
node.children.length === 1 &&
|
|
69
|
+
node.children[0]?.isDirectory
|
|
70
|
+
) {
|
|
71
|
+
const onlyChild = node.children[0]
|
|
72
|
+
node.name = node.name ? `${node.name}/${onlyChild.name}` : onlyChild.name
|
|
73
|
+
node.path = onlyChild.path
|
|
74
|
+
node.children = onlyChild.children
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function compressTree(root: FileTreeNode): void {
|
|
79
|
+
for (const child of root.children) {
|
|
80
|
+
compressTreeRecursive(child)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function compressTreeRecursive(node: FileTreeNode): void {
|
|
85
|
+
for (const child of node.children) {
|
|
86
|
+
compressTreeRecursive(child)
|
|
87
|
+
}
|
|
88
|
+
compressNode(node)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function buildFileTree(files: FileChange[]): FileTreeNode {
|
|
92
|
+
const root: FileTreeNode = {
|
|
93
|
+
name: "",
|
|
94
|
+
path: "",
|
|
95
|
+
isDirectory: true,
|
|
96
|
+
children: [],
|
|
97
|
+
depth: 0,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const file of files) {
|
|
101
|
+
const parts = splitPath(file.path)
|
|
102
|
+
insertIntoTree(root, file, parts, 1)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
sortTree(root)
|
|
106
|
+
compressTree(root)
|
|
107
|
+
|
|
108
|
+
return root
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function flattenTree(
|
|
112
|
+
root: FileTreeNode,
|
|
113
|
+
collapsedPaths: Set<string>,
|
|
114
|
+
): FlatFileNode[] {
|
|
115
|
+
const result: FlatFileNode[] = []
|
|
116
|
+
|
|
117
|
+
function traverse(node: FileTreeNode, visualDepth: number): void {
|
|
118
|
+
if (node.path) {
|
|
119
|
+
result.push({ node, visualDepth })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (node.isDirectory && !collapsedPaths.has(node.path)) {
|
|
123
|
+
for (const child of node.children) {
|
|
124
|
+
traverse(child, node.path ? visualDepth + 1 : visualDepth)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
traverse(root, 0)
|
|
130
|
+
return result
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function countVisibleNodes(
|
|
134
|
+
root: FileTreeNode,
|
|
135
|
+
collapsedPaths: Set<string>,
|
|
136
|
+
): number {
|
|
137
|
+
return flattenTree(root, collapsedPaths).length
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function getFilePaths(node: FileTreeNode): string[] {
|
|
141
|
+
const paths: string[] = []
|
|
142
|
+
|
|
143
|
+
function collect(n: FileTreeNode): void {
|
|
144
|
+
if (!n.isDirectory) {
|
|
145
|
+
paths.push(n.path)
|
|
146
|
+
}
|
|
147
|
+
for (const child of n.children) {
|
|
148
|
+
collect(child)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
collect(node)
|
|
153
|
+
return paths
|
|
154
|
+
}
|