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,313 @@
|
|
|
1
|
+
import { type ExecuteResult, execute } from "./executor"
|
|
2
|
+
|
|
3
|
+
export interface OperationResult extends ExecuteResult {
|
|
4
|
+
command: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface OpLogEntry {
|
|
8
|
+
operationId: string
|
|
9
|
+
lines: string[]
|
|
10
|
+
isCurrent: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function stripAnsi(str: string): string {
|
|
14
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape sequence
|
|
15
|
+
return str.replace(/\x1b\[[0-9;]*m/g, "")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseOpLog(lines: string[]): OpLogEntry[] {
|
|
19
|
+
const operations: OpLogEntry[] = []
|
|
20
|
+
let current: OpLogEntry | null = null
|
|
21
|
+
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
const stripped = stripAnsi(line)
|
|
24
|
+
const isHeader = stripped.startsWith("@") || stripped.startsWith("○")
|
|
25
|
+
|
|
26
|
+
if (isHeader) {
|
|
27
|
+
if (current) {
|
|
28
|
+
operations.push(current)
|
|
29
|
+
}
|
|
30
|
+
const parts = stripped.split(/\s+/)
|
|
31
|
+
const operationId = parts[1] || ""
|
|
32
|
+
current = {
|
|
33
|
+
operationId,
|
|
34
|
+
lines: [line],
|
|
35
|
+
isCurrent: stripped.startsWith("@"),
|
|
36
|
+
}
|
|
37
|
+
} else if (current && stripped.trim()) {
|
|
38
|
+
current.lines.push(line)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (current) {
|
|
43
|
+
operations.push(current)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return operations
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function jjNew(revision: string): Promise<OperationResult> {
|
|
50
|
+
const args = ["new", revision]
|
|
51
|
+
const result = await execute(args)
|
|
52
|
+
return {
|
|
53
|
+
...result,
|
|
54
|
+
command: `jj ${args.join(" ")}`,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function jjEdit(revision: string): Promise<OperationResult> {
|
|
59
|
+
const args = ["edit", revision]
|
|
60
|
+
const result = await execute(args)
|
|
61
|
+
return {
|
|
62
|
+
...result,
|
|
63
|
+
command: `jj ${args.join(" ")}`,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function jjSquash(
|
|
68
|
+
revision?: string,
|
|
69
|
+
options?: { ignoreImmutable?: boolean },
|
|
70
|
+
): Promise<OperationResult> {
|
|
71
|
+
const args = revision ? ["squash", "-r", revision] : ["squash"]
|
|
72
|
+
if (options?.ignoreImmutable) {
|
|
73
|
+
args.push("--ignore-immutable")
|
|
74
|
+
}
|
|
75
|
+
const result = await execute(args)
|
|
76
|
+
return {
|
|
77
|
+
...result,
|
|
78
|
+
command: `jj ${args.join(" ")}`,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function isImmutableError(result: OperationResult): boolean {
|
|
83
|
+
return (
|
|
84
|
+
!result.success &&
|
|
85
|
+
(result.stderr.includes("immutable") || result.stderr.includes("Immutable"))
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function jjDescribe(
|
|
90
|
+
revision: string,
|
|
91
|
+
message: string,
|
|
92
|
+
options?: { ignoreImmutable?: boolean },
|
|
93
|
+
): Promise<OperationResult> {
|
|
94
|
+
const args = ["describe", revision, "-m", message]
|
|
95
|
+
if (options?.ignoreImmutable) {
|
|
96
|
+
args.push("--ignore-immutable")
|
|
97
|
+
}
|
|
98
|
+
const result = await execute(args)
|
|
99
|
+
return {
|
|
100
|
+
...result,
|
|
101
|
+
command: `jj describe ${revision} -m "..."`,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function jjShowDescription(
|
|
106
|
+
revision: string,
|
|
107
|
+
): Promise<{ subject: string; body: string }> {
|
|
108
|
+
const result = await execute([
|
|
109
|
+
"log",
|
|
110
|
+
"-r",
|
|
111
|
+
revision,
|
|
112
|
+
"--no-graph",
|
|
113
|
+
"-T",
|
|
114
|
+
"description",
|
|
115
|
+
])
|
|
116
|
+
|
|
117
|
+
if (!result.success) {
|
|
118
|
+
return { subject: "", body: "" }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const description = result.stdout.trim()
|
|
122
|
+
const lines = description.split("\n")
|
|
123
|
+
const subject = lines[0] ?? ""
|
|
124
|
+
const body = lines.slice(1).join("\n").trim()
|
|
125
|
+
|
|
126
|
+
return { subject, body }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function jjShowDescriptionStyled(
|
|
130
|
+
revision: string,
|
|
131
|
+
): Promise<{ subject: string; body: string }> {
|
|
132
|
+
const styledTemplate = `if(empty, label("empty", "(empty) "), "") ++ if(description.first_line(), description.first_line(), label("description placeholder", "(no description set)"))`
|
|
133
|
+
const subjectResult = await execute([
|
|
134
|
+
"log",
|
|
135
|
+
"-r",
|
|
136
|
+
revision,
|
|
137
|
+
"--no-graph",
|
|
138
|
+
"--color",
|
|
139
|
+
"always",
|
|
140
|
+
"-T",
|
|
141
|
+
styledTemplate,
|
|
142
|
+
])
|
|
143
|
+
|
|
144
|
+
const bodyResult = await execute([
|
|
145
|
+
"log",
|
|
146
|
+
"-r",
|
|
147
|
+
revision,
|
|
148
|
+
"--no-graph",
|
|
149
|
+
"-T",
|
|
150
|
+
'if(description.first_line(), description.rest(), "")',
|
|
151
|
+
])
|
|
152
|
+
|
|
153
|
+
const subject = subjectResult.success ? subjectResult.stdout.trim() : ""
|
|
154
|
+
const body = bodyResult.success ? bodyResult.stdout.trim() : ""
|
|
155
|
+
|
|
156
|
+
return { subject, body }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function jjAbandon(revision: string): Promise<OperationResult> {
|
|
160
|
+
const args = ["abandon", revision]
|
|
161
|
+
const result = await execute(args)
|
|
162
|
+
return {
|
|
163
|
+
...result,
|
|
164
|
+
command: `jj ${args.join(" ")}`,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function fetchOpLog(limit?: number): Promise<string[]> {
|
|
169
|
+
const args = ["op", "log", "--color", "always"]
|
|
170
|
+
if (limit) {
|
|
171
|
+
args.push("--limit", String(limit))
|
|
172
|
+
}
|
|
173
|
+
const result = await execute(args)
|
|
174
|
+
if (!result.success) {
|
|
175
|
+
throw new Error(`jj op log failed: ${result.stderr}`)
|
|
176
|
+
}
|
|
177
|
+
return result.stdout.split("\n")
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function fetchOpLogId(): Promise<string> {
|
|
181
|
+
const result = await execute([
|
|
182
|
+
"op",
|
|
183
|
+
"log",
|
|
184
|
+
"--limit",
|
|
185
|
+
"1",
|
|
186
|
+
"--no-graph",
|
|
187
|
+
"-T",
|
|
188
|
+
"self.id()",
|
|
189
|
+
])
|
|
190
|
+
return result.success ? result.stdout.trim() : ""
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function jjUndo(): Promise<OperationResult> {
|
|
194
|
+
const args = ["undo"]
|
|
195
|
+
const result = await execute(args)
|
|
196
|
+
return {
|
|
197
|
+
...result,
|
|
198
|
+
command: "jj undo",
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function jjRedo(): Promise<OperationResult> {
|
|
203
|
+
const args = ["redo"]
|
|
204
|
+
const result = await execute(args)
|
|
205
|
+
return {
|
|
206
|
+
...result,
|
|
207
|
+
command: "jj redo",
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function jjOpRestore(
|
|
212
|
+
operationId: string,
|
|
213
|
+
): Promise<OperationResult> {
|
|
214
|
+
const args = ["op", "restore", operationId]
|
|
215
|
+
const result = await execute(args)
|
|
216
|
+
return {
|
|
217
|
+
...result,
|
|
218
|
+
command: `jj op restore ${operationId}`,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function jjGitFetch(options?: {
|
|
223
|
+
allRemotes?: boolean
|
|
224
|
+
}): Promise<OperationResult> {
|
|
225
|
+
const args = ["git", "fetch"]
|
|
226
|
+
if (options?.allRemotes) {
|
|
227
|
+
args.push("--all-remotes")
|
|
228
|
+
}
|
|
229
|
+
const result = await execute(args)
|
|
230
|
+
return {
|
|
231
|
+
...result,
|
|
232
|
+
command: `jj ${args.join(" ")}`,
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function jjGitPush(options?: {
|
|
237
|
+
all?: boolean
|
|
238
|
+
}): Promise<OperationResult> {
|
|
239
|
+
const args = ["git", "push"]
|
|
240
|
+
if (options?.all) {
|
|
241
|
+
args.push("--all")
|
|
242
|
+
}
|
|
243
|
+
const result = await execute(args)
|
|
244
|
+
return {
|
|
245
|
+
...result,
|
|
246
|
+
command: `jj ${args.join(" ")}`,
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function jjRestore(
|
|
251
|
+
paths: string[],
|
|
252
|
+
revision?: string,
|
|
253
|
+
): Promise<OperationResult> {
|
|
254
|
+
const args = ["restore"]
|
|
255
|
+
if (revision) {
|
|
256
|
+
args.push("-r", revision)
|
|
257
|
+
}
|
|
258
|
+
args.push(...paths)
|
|
259
|
+
const result = await execute(args)
|
|
260
|
+
return {
|
|
261
|
+
...result,
|
|
262
|
+
command: `jj ${args.join(" ")}`,
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export interface DiffStats {
|
|
267
|
+
files: { path: string; insertions: number; deletions: number }[]
|
|
268
|
+
totalFiles: number
|
|
269
|
+
totalInsertions: number
|
|
270
|
+
totalDeletions: number
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function jjDiffStats(revision: string): Promise<DiffStats> {
|
|
274
|
+
const result = await execute(["diff", "--stat", "-r", revision])
|
|
275
|
+
|
|
276
|
+
if (!result.success) {
|
|
277
|
+
return { files: [], totalFiles: 0, totalInsertions: 0, totalDeletions: 0 }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const lines = result.stdout.trim().split("\n")
|
|
281
|
+
const files: DiffStats["files"] = []
|
|
282
|
+
let totalFiles = 0
|
|
283
|
+
let totalInsertions = 0
|
|
284
|
+
let totalDeletions = 0
|
|
285
|
+
|
|
286
|
+
for (const line of lines) {
|
|
287
|
+
// Summary line: "14 files changed, 1448 insertions(+), 56 deletions(-)"
|
|
288
|
+
const summaryMatch = line.match(
|
|
289
|
+
/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/,
|
|
290
|
+
)
|
|
291
|
+
if (summaryMatch) {
|
|
292
|
+
totalFiles = Number.parseInt(summaryMatch[1] ?? "0", 10)
|
|
293
|
+
totalInsertions = summaryMatch[2]
|
|
294
|
+
? Number.parseInt(summaryMatch[2], 10)
|
|
295
|
+
: 0
|
|
296
|
+
totalDeletions = summaryMatch[3]
|
|
297
|
+
? Number.parseInt(summaryMatch[3], 10)
|
|
298
|
+
: 0
|
|
299
|
+
continue
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// File line: "src/foo.ts | 12 ++++----"
|
|
303
|
+
const fileMatch = line.match(/^(.+?)\s+\|\s+(\d+)\s+([+-]*)/)
|
|
304
|
+
if (fileMatch) {
|
|
305
|
+
const path = (fileMatch[1] ?? "").trim()
|
|
306
|
+
const plusCount = (fileMatch[3]?.match(/\+/g) || []).length
|
|
307
|
+
const minusCount = (fileMatch[3]?.match(/-/g) || []).length
|
|
308
|
+
files.push({ path, insertions: plusCount, deletions: minusCount })
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return { files, totalFiles, totalInsertions, totalDeletions }
|
|
313
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface Commit {
|
|
2
|
+
changeId: string
|
|
3
|
+
commitId: string
|
|
4
|
+
description: string
|
|
5
|
+
author: string
|
|
6
|
+
authorEmail: string
|
|
7
|
+
timestamp: string
|
|
8
|
+
lines: string[]
|
|
9
|
+
isWorkingCopy: boolean
|
|
10
|
+
immutable: boolean
|
|
11
|
+
empty: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type FileStatus = "added" | "modified" | "deleted" | "renamed" | "copied"
|
|
15
|
+
|
|
16
|
+
export interface FileChange {
|
|
17
|
+
path: string
|
|
18
|
+
status: FileStatus
|
|
19
|
+
/** Original path for renamed/copied files */
|
|
20
|
+
oldPath?: string
|
|
21
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { TerminalLine } from "ghostty-opentui"
|
|
2
|
+
import { ptyToJson } from "ghostty-opentui"
|
|
3
|
+
import { For, Show, createEffect, createMemo } from "solid-js"
|
|
4
|
+
|
|
5
|
+
const PROFILE = process.env.KAJJI_PROFILE === "1"
|
|
6
|
+
|
|
7
|
+
function profile(label: string) {
|
|
8
|
+
if (!PROFILE) return () => {}
|
|
9
|
+
const start = performance.now()
|
|
10
|
+
return (extra?: string) => {
|
|
11
|
+
const ms = (performance.now() - start).toFixed(2)
|
|
12
|
+
console.error(`[PROFILE] ${label}: ${ms}ms${extra ? ` (${extra})` : ""}`)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface AnsiTextProps {
|
|
17
|
+
content: string
|
|
18
|
+
cols?: number
|
|
19
|
+
bold?: boolean
|
|
20
|
+
wrapMode?: "none" | "char" | "word"
|
|
21
|
+
maxLines?: number
|
|
22
|
+
onTotalLines?: (total: number) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function AnsiText(props: AnsiTextProps) {
|
|
26
|
+
const allLines = createMemo(() => {
|
|
27
|
+
if (!props.content) return [] as TerminalLine[]
|
|
28
|
+
const endParse = profile("ptyToJson parse")
|
|
29
|
+
const result = ptyToJson(props.content, {
|
|
30
|
+
cols: props.cols ?? 9999,
|
|
31
|
+
rows: 1,
|
|
32
|
+
})
|
|
33
|
+
endParse(`${result.lines.length} lines from ${props.content.length} chars`)
|
|
34
|
+
return result.lines
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
createEffect(() => {
|
|
38
|
+
const total = allLines().length
|
|
39
|
+
props.onTotalLines?.(total)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const visibleLines = createMemo(() => {
|
|
43
|
+
const lines = allLines()
|
|
44
|
+
const limit = props.maxLines
|
|
45
|
+
if (limit !== undefined && lines.length > limit) {
|
|
46
|
+
return lines.slice(0, limit)
|
|
47
|
+
}
|
|
48
|
+
return lines
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const renderSpans = (line: TerminalLine) => (
|
|
52
|
+
<For each={line.spans}>
|
|
53
|
+
{(span) => (
|
|
54
|
+
<span
|
|
55
|
+
style={{
|
|
56
|
+
fg: span.fg ?? undefined,
|
|
57
|
+
bg: span.bg ?? undefined,
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
{span.text}
|
|
61
|
+
</span>
|
|
62
|
+
)}
|
|
63
|
+
</For>
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<For each={visibleLines()}>
|
|
68
|
+
{(line) => (
|
|
69
|
+
<text wrapMode={props.wrapMode ?? "word"}>
|
|
70
|
+
<Show when={props.bold} fallback={renderSpans(line)}>
|
|
71
|
+
<b>{renderSpans(line)}</b>
|
|
72
|
+
</Show>
|
|
73
|
+
</text>
|
|
74
|
+
)}
|
|
75
|
+
</For>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { BorderStyle } from "@opentui/core"
|
|
2
|
+
import type { JSX } from "solid-js"
|
|
3
|
+
import { Show, children as resolveChildren } from "solid-js"
|
|
4
|
+
|
|
5
|
+
type Dimension = number | "auto" | `${number}%`
|
|
6
|
+
type CornerContent = JSX.Element | string | (() => JSX.Element | string)
|
|
7
|
+
|
|
8
|
+
interface BorderBoxProps {
|
|
9
|
+
topLeft?: CornerContent
|
|
10
|
+
topRight?: CornerContent
|
|
11
|
+
bottomLeft?: CornerContent
|
|
12
|
+
bottomRight?: CornerContent
|
|
13
|
+
|
|
14
|
+
border?: boolean
|
|
15
|
+
borderStyle?: BorderStyle
|
|
16
|
+
borderColor?: string
|
|
17
|
+
backgroundColor?: string
|
|
18
|
+
flexGrow?: number
|
|
19
|
+
flexDirection?: "row" | "column"
|
|
20
|
+
width?: Dimension
|
|
21
|
+
height?: Dimension
|
|
22
|
+
padding?: number
|
|
23
|
+
paddingLeft?: number
|
|
24
|
+
paddingRight?: number
|
|
25
|
+
paddingTop?: number
|
|
26
|
+
paddingBottom?: number
|
|
27
|
+
gap?: number
|
|
28
|
+
overflow?: "hidden" | "visible"
|
|
29
|
+
onMouseDown?: () => void
|
|
30
|
+
|
|
31
|
+
children: JSX.Element
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function BorderBox(props: BorderBoxProps) {
|
|
35
|
+
const resolved = resolveChildren(() => props.children)
|
|
36
|
+
|
|
37
|
+
const hasOverlays = () =>
|
|
38
|
+
props.topLeft || props.topRight || props.bottomLeft || props.bottomRight
|
|
39
|
+
|
|
40
|
+
const resolveCorner = (content: CornerContent | undefined) =>
|
|
41
|
+
typeof content === "function" ? content() : content
|
|
42
|
+
|
|
43
|
+
const renderCorner = (
|
|
44
|
+
position: "topLeft" | "topRight" | "bottomLeft" | "bottomRight",
|
|
45
|
+
) => {
|
|
46
|
+
const content = resolveCorner(props[position])
|
|
47
|
+
if (!content) return null
|
|
48
|
+
|
|
49
|
+
const isTop = position.startsWith("top")
|
|
50
|
+
const isLeft = position.endsWith("Left")
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<box
|
|
54
|
+
position="absolute"
|
|
55
|
+
top={isTop ? 0 : undefined}
|
|
56
|
+
bottom={!isTop ? 0 : undefined}
|
|
57
|
+
left={isLeft ? 1 : undefined}
|
|
58
|
+
right={!isLeft ? 1 : undefined}
|
|
59
|
+
zIndex={1}
|
|
60
|
+
>
|
|
61
|
+
{typeof content === "string" ? <text>{content}</text> : content}
|
|
62
|
+
</box>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!hasOverlays()) {
|
|
67
|
+
return (
|
|
68
|
+
<box
|
|
69
|
+
flexDirection={props.flexDirection ?? "column"}
|
|
70
|
+
flexGrow={props.flexGrow}
|
|
71
|
+
width={props.width}
|
|
72
|
+
height={props.height}
|
|
73
|
+
border={props.border}
|
|
74
|
+
borderStyle={props.borderStyle}
|
|
75
|
+
borderColor={props.borderColor}
|
|
76
|
+
backgroundColor={props.backgroundColor}
|
|
77
|
+
padding={props.padding}
|
|
78
|
+
paddingLeft={props.paddingLeft}
|
|
79
|
+
paddingRight={props.paddingRight}
|
|
80
|
+
paddingTop={props.paddingTop}
|
|
81
|
+
paddingBottom={props.paddingBottom}
|
|
82
|
+
gap={props.gap}
|
|
83
|
+
overflow={props.overflow}
|
|
84
|
+
onMouseDown={props.onMouseDown}
|
|
85
|
+
>
|
|
86
|
+
{resolved()}
|
|
87
|
+
</box>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<box
|
|
93
|
+
position="relative"
|
|
94
|
+
flexDirection="column"
|
|
95
|
+
flexGrow={props.flexGrow}
|
|
96
|
+
width={props.width}
|
|
97
|
+
height={props.height}
|
|
98
|
+
onMouseDown={props.onMouseDown}
|
|
99
|
+
>
|
|
100
|
+
<Show when={props.topLeft}>{() => renderCorner("topLeft")}</Show>
|
|
101
|
+
<Show when={props.topRight}>{() => renderCorner("topRight")}</Show>
|
|
102
|
+
<Show when={props.bottomLeft}>{() => renderCorner("bottomLeft")}</Show>
|
|
103
|
+
<Show when={props.bottomRight}>{() => renderCorner("bottomRight")}</Show>
|
|
104
|
+
|
|
105
|
+
<box
|
|
106
|
+
flexDirection={props.flexDirection ?? "column"}
|
|
107
|
+
flexGrow={props.flexGrow}
|
|
108
|
+
border={props.border}
|
|
109
|
+
borderStyle={props.borderStyle}
|
|
110
|
+
borderColor={props.borderColor}
|
|
111
|
+
backgroundColor={props.backgroundColor}
|
|
112
|
+
padding={props.padding}
|
|
113
|
+
paddingLeft={props.paddingLeft}
|
|
114
|
+
paddingRight={props.paddingRight}
|
|
115
|
+
paddingTop={props.paddingTop}
|
|
116
|
+
paddingBottom={props.paddingBottom}
|
|
117
|
+
gap={props.gap}
|
|
118
|
+
overflow={props.overflow}
|
|
119
|
+
>
|
|
120
|
+
{resolved()}
|
|
121
|
+
</box>
|
|
122
|
+
</box>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { For, Show } from "solid-js"
|
|
2
|
+
import { useFocus } from "../context/focus"
|
|
3
|
+
import { useTheme } from "../context/theme"
|
|
4
|
+
import { createDoubleClickDetector } from "../utils/double-click"
|
|
5
|
+
import type { FlatFileNode } from "../utils/file-tree"
|
|
6
|
+
|
|
7
|
+
const STATUS_CHARS: Record<string, string> = {
|
|
8
|
+
added: "A",
|
|
9
|
+
modified: "M",
|
|
10
|
+
deleted: "D",
|
|
11
|
+
renamed: "R",
|
|
12
|
+
copied: "C",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FileTreeListProps {
|
|
16
|
+
files: () => FlatFileNode[]
|
|
17
|
+
selectedIndex: () => number
|
|
18
|
+
setSelectedIndex: (index: number) => void
|
|
19
|
+
collapsedPaths: () => Set<string>
|
|
20
|
+
toggleFolder: (path: string) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function FileTreeList(props: FileTreeListProps) {
|
|
24
|
+
const focus = useFocus()
|
|
25
|
+
const { colors } = useTheme()
|
|
26
|
+
|
|
27
|
+
const statusColors = () => ({
|
|
28
|
+
added: colors().success,
|
|
29
|
+
modified: colors().warning,
|
|
30
|
+
deleted: colors().error,
|
|
31
|
+
renamed: colors().info,
|
|
32
|
+
copied: colors().info,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<For each={props.files()}>
|
|
37
|
+
{(item, index) => {
|
|
38
|
+
const isSelected = () => index() === props.selectedIndex()
|
|
39
|
+
const node = item.node
|
|
40
|
+
const indent = " ".repeat(item.visualDepth)
|
|
41
|
+
const isCollapsed = props.collapsedPaths().has(node.path)
|
|
42
|
+
|
|
43
|
+
const icon = node.isDirectory ? (isCollapsed ? "▶" : "▼") : " "
|
|
44
|
+
|
|
45
|
+
const statusChar = node.status
|
|
46
|
+
? (STATUS_CHARS[node.status] ?? " ")
|
|
47
|
+
: " "
|
|
48
|
+
const statusColor = node.status
|
|
49
|
+
? (statusColors()[
|
|
50
|
+
node.status as keyof ReturnType<typeof statusColors>
|
|
51
|
+
] ?? colors().text)
|
|
52
|
+
: colors().text
|
|
53
|
+
|
|
54
|
+
const handleDoubleClick = createDoubleClickDetector(() => {
|
|
55
|
+
if (node.isDirectory) {
|
|
56
|
+
props.toggleFolder(node.path)
|
|
57
|
+
} else {
|
|
58
|
+
focus.setPanel("detail")
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const handleMouseDown = (e: { stopPropagation: () => void }) => {
|
|
63
|
+
e.stopPropagation()
|
|
64
|
+
props.setSelectedIndex(index())
|
|
65
|
+
if (node.isDirectory) {
|
|
66
|
+
props.toggleFolder(node.path)
|
|
67
|
+
} else {
|
|
68
|
+
handleDoubleClick()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<box
|
|
74
|
+
backgroundColor={
|
|
75
|
+
isSelected() ? colors().selectionBackground : undefined
|
|
76
|
+
}
|
|
77
|
+
overflow="hidden"
|
|
78
|
+
onMouseDown={handleMouseDown}
|
|
79
|
+
>
|
|
80
|
+
<text>
|
|
81
|
+
<span style={{ fg: colors().textMuted }}>{indent}</span>
|
|
82
|
+
<span
|
|
83
|
+
style={{
|
|
84
|
+
fg: node.isDirectory ? colors().info : colors().textMuted,
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
{icon}{" "}
|
|
88
|
+
</span>
|
|
89
|
+
<Show when={!node.isDirectory}>
|
|
90
|
+
<span style={{ fg: statusColor }}>{statusChar} </span>
|
|
91
|
+
</Show>
|
|
92
|
+
<span
|
|
93
|
+
style={{
|
|
94
|
+
fg: node.isDirectory ? colors().info : colors().text,
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
{node.name}
|
|
98
|
+
</span>
|
|
99
|
+
</text>
|
|
100
|
+
</box>
|
|
101
|
+
)
|
|
102
|
+
}}
|
|
103
|
+
</For>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { JSX } from "solid-js"
|
|
2
|
+
import { useTheme } from "../context/theme"
|
|
3
|
+
import { StatusBar } from "./StatusBar"
|
|
4
|
+
import { CommandLogPanel } from "./panels/CommandLogPanel"
|
|
5
|
+
|
|
6
|
+
interface LayoutProps {
|
|
7
|
+
top: JSX.Element
|
|
8
|
+
bottom: JSX.Element
|
|
9
|
+
right: JSX.Element
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Layout(props: LayoutProps) {
|
|
13
|
+
const { colors, style } = useTheme()
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<box
|
|
17
|
+
flexGrow={1}
|
|
18
|
+
flexDirection="column"
|
|
19
|
+
width="100%"
|
|
20
|
+
height="100%"
|
|
21
|
+
backgroundColor={colors().background}
|
|
22
|
+
padding={style().adaptToTerminal ? 0 : 1}
|
|
23
|
+
gap={0}
|
|
24
|
+
>
|
|
25
|
+
<box flexGrow={1} flexDirection="row" width="100%" gap={0}>
|
|
26
|
+
<box
|
|
27
|
+
flexGrow={1}
|
|
28
|
+
flexBasis={0}
|
|
29
|
+
height="100%"
|
|
30
|
+
flexDirection="column"
|
|
31
|
+
gap={0}
|
|
32
|
+
>
|
|
33
|
+
<box flexGrow={3} flexBasis={0}>
|
|
34
|
+
{props.top}
|
|
35
|
+
</box>
|
|
36
|
+
<box flexGrow={1} flexBasis={0}>
|
|
37
|
+
{props.bottom}
|
|
38
|
+
</box>
|
|
39
|
+
</box>
|
|
40
|
+
<box flexGrow={2} flexBasis={0} height="100%" flexDirection="column">
|
|
41
|
+
<box flexGrow={1}>{props.right}</box>
|
|
42
|
+
<CommandLogPanel />
|
|
43
|
+
</box>
|
|
44
|
+
</box>
|
|
45
|
+
<StatusBar />
|
|
46
|
+
</box>
|
|
47
|
+
)
|
|
48
|
+
}
|