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,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,2 @@
1
+ export * from "./types"
2
+ export * from "./parser"
@@ -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,3 @@
1
+ export type { Theme, ThemeColors, ThemeStyle } from "./types"
2
+ export { lazygitTheme } from "./presets/lazygit"
3
+ export { opencodeTheme } from "./presets/opencode"
@@ -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
+ }