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,186 @@
1
+ import {
2
+ type PTYStreamingOptions,
3
+ execute,
4
+ executePTYStreaming,
5
+ executeStreaming,
6
+ } from "./executor"
7
+
8
+ const PROFILE = process.env.KAJJI_PROFILE === "1"
9
+
10
+ function profile(label: string) {
11
+ if (!PROFILE) return () => {}
12
+ const start = performance.now()
13
+ return (extra?: string) => {
14
+ const ms = (performance.now() - start).toFixed(2)
15
+ console.error(`[PROFILE] ${label}: ${ms}ms${extra ? ` (${extra})` : ""}`)
16
+ }
17
+ }
18
+
19
+ export interface FetchDiffOptions {
20
+ cwd?: string
21
+ columns?: number
22
+ paths?: string[]
23
+ }
24
+
25
+ export async function fetchDiff(
26
+ changeId: string,
27
+ options: FetchDiffOptions = {},
28
+ ): Promise<string> {
29
+ const endTotal = profile(`fetchDiff(${changeId.slice(0, 8)})`)
30
+
31
+ const env: Record<string, string> = {}
32
+
33
+ if (options.columns) {
34
+ env.COLUMNS = String(options.columns)
35
+ }
36
+
37
+ const args = [
38
+ "diff",
39
+ "-r",
40
+ changeId,
41
+ "--color",
42
+ "always",
43
+ "--ignore-working-copy",
44
+ ]
45
+
46
+ if (options.paths && options.paths.length > 0) {
47
+ args.push(...options.paths)
48
+ }
49
+
50
+ const result = await execute(args, { cwd: options.cwd, env })
51
+
52
+ if (!result.success) {
53
+ throw new Error(`jj diff failed: ${result.stderr}`)
54
+ }
55
+
56
+ endTotal()
57
+ return result.stdout
58
+ }
59
+
60
+ export interface StreamDiffCallbacks {
61
+ onUpdate: (content: string, lineCount: number, complete: boolean) => void
62
+ onError: (error: Error) => void
63
+ }
64
+
65
+ export function streamDiff(
66
+ changeId: string,
67
+ options: FetchDiffOptions,
68
+ callbacks: StreamDiffCallbacks,
69
+ ): { cancel: () => void } {
70
+ const endTotal = profile(`streamDiff(${changeId.slice(0, 8)})`)
71
+
72
+ const env: Record<string, string> = {}
73
+
74
+ if (options.columns) {
75
+ env.COLUMNS = String(options.columns)
76
+ }
77
+
78
+ const args = [
79
+ "diff",
80
+ "-r",
81
+ changeId,
82
+ "--color",
83
+ "always",
84
+ "--ignore-working-copy",
85
+ ]
86
+
87
+ if (options.paths && options.paths.length > 0) {
88
+ args.push(...options.paths)
89
+ }
90
+
91
+ let firstUpdate = true
92
+ const endFirstChunk = profile(" first chunk")
93
+
94
+ return executeStreaming(
95
+ args,
96
+ { cwd: options.cwd, env },
97
+ {
98
+ onChunk: (content, lineCount) => {
99
+ if (firstUpdate) {
100
+ endFirstChunk(`${lineCount} lines`)
101
+ firstUpdate = false
102
+ }
103
+ callbacks.onUpdate(content, lineCount, false)
104
+ },
105
+ onComplete: (result) => {
106
+ endTotal()
107
+ if (result.success) {
108
+ callbacks.onUpdate(
109
+ result.stdout,
110
+ result.stdout.split("\n").length,
111
+ true,
112
+ )
113
+ } else {
114
+ callbacks.onError(new Error(`jj diff failed: ${result.stderr}`))
115
+ }
116
+ },
117
+ onError: callbacks.onError,
118
+ },
119
+ )
120
+ }
121
+
122
+ export interface StreamDiffPTYOptions extends FetchDiffOptions {
123
+ cols?: number
124
+ rows?: number
125
+ }
126
+
127
+ export function streamDiffPTY(
128
+ changeId: string,
129
+ options: StreamDiffPTYOptions,
130
+ callbacks: StreamDiffCallbacks,
131
+ ): { cancel: () => void } {
132
+ const endTotal = profile(`streamDiffPTY(${changeId.slice(0, 8)})`)
133
+
134
+ const env: Record<string, string> = {}
135
+
136
+ if (options.columns) {
137
+ env.COLUMNS = String(options.columns)
138
+ }
139
+
140
+ const args = [
141
+ "diff",
142
+ "-r",
143
+ changeId,
144
+ "--color",
145
+ "always",
146
+ "--no-pager",
147
+ "--ignore-working-copy",
148
+ ]
149
+
150
+ if (options.paths && options.paths.length > 0) {
151
+ args.push(...options.paths)
152
+ }
153
+
154
+ let firstUpdate = true
155
+ const endFirstChunk = profile(" PTY first chunk")
156
+
157
+ const ptyOptions: PTYStreamingOptions = {
158
+ cwd: options.cwd,
159
+ env,
160
+ cols: options.cols,
161
+ rows: options.rows,
162
+ }
163
+
164
+ return executePTYStreaming(args, ptyOptions, {
165
+ onChunk: (content, lineCount) => {
166
+ if (firstUpdate) {
167
+ endFirstChunk(`${lineCount} lines`)
168
+ firstUpdate = false
169
+ }
170
+ callbacks.onUpdate(content, lineCount, false)
171
+ },
172
+ onComplete: (result) => {
173
+ endTotal()
174
+ if (result.success) {
175
+ callbacks.onUpdate(
176
+ result.stdout,
177
+ result.stdout.split("\n").length,
178
+ true,
179
+ )
180
+ } else {
181
+ callbacks.onError(new Error(`jj diff failed: ${result.stderr}`))
182
+ }
183
+ },
184
+ onError: callbacks.onError,
185
+ })
186
+ }
@@ -0,0 +1,285 @@
1
+ export interface ExecuteResult {
2
+ stdout: string
3
+ stderr: string
4
+ exitCode: number
5
+ success: boolean
6
+ }
7
+
8
+ export interface ExecuteOptions {
9
+ cwd?: string
10
+ env?: Record<string, string>
11
+ timeout?: number
12
+ }
13
+
14
+ const PROFILE = process.env.KAJJI_PROFILE === "1"
15
+
16
+ function profile(label: string) {
17
+ if (!PROFILE) return () => {}
18
+ const start = performance.now()
19
+ return (extra?: string) => {
20
+ const ms = (performance.now() - start).toFixed(2)
21
+ console.error(`[PROFILE] ${label}: ${ms}ms${extra ? ` (${extra})` : ""}`)
22
+ }
23
+ }
24
+
25
+ export async function execute(
26
+ args: string[],
27
+ options: ExecuteOptions = {},
28
+ ): Promise<ExecuteResult> {
29
+ const endTotal = profile(`execute [jj ${args[0]}]`)
30
+ const endSpawn = profile(" spawn")
31
+
32
+ const proc = Bun.spawn(["jj", ...args], {
33
+ cwd: options.cwd,
34
+ env: {
35
+ ...process.env,
36
+ // Prevent jj from opening editors
37
+ JJ_EDITOR: "true",
38
+ EDITOR: "true",
39
+ VISUAL: "true",
40
+ ...options.env,
41
+ },
42
+ stdin: "ignore",
43
+ stdout: "pipe",
44
+ stderr: "pipe",
45
+ })
46
+ endSpawn()
47
+
48
+ const endRead = profile(" read stdout/stderr")
49
+ const [stdout, stderr] = await Promise.all([
50
+ new Response(proc.stdout).text(),
51
+ new Response(proc.stderr).text(),
52
+ ])
53
+ endRead(`${stdout.length} chars, ${stdout.split("\n").length} lines`)
54
+
55
+ const endWait = profile(" wait for exit")
56
+ const exitCode = await proc.exited
57
+ endWait()
58
+
59
+ endTotal()
60
+
61
+ return {
62
+ stdout,
63
+ stderr,
64
+ exitCode,
65
+ success: exitCode === 0,
66
+ }
67
+ }
68
+
69
+ export async function executeWithColor(
70
+ args: string[],
71
+ options: ExecuteOptions = {},
72
+ ): Promise<ExecuteResult> {
73
+ return execute(["--color", "always", ...args], options)
74
+ }
75
+
76
+ export interface StreamingExecuteCallbacks {
77
+ onChunk: (content: string, lineCount: number) => void
78
+ onComplete: (result: ExecuteResult) => void
79
+ onError: (error: Error) => void
80
+ }
81
+
82
+ export function executeStreaming(
83
+ args: string[],
84
+ options: ExecuteOptions,
85
+ callbacks: StreamingExecuteCallbacks,
86
+ ): { cancel: () => void } {
87
+ const endTotal = profile(`executeStreaming [jj ${args[0]}]`)
88
+
89
+ const proc = Bun.spawn(["jj", ...args], {
90
+ cwd: options.cwd,
91
+ env: {
92
+ ...process.env,
93
+ JJ_EDITOR: "true",
94
+ EDITOR: "true",
95
+ VISUAL: "true",
96
+ ...options.env,
97
+ },
98
+ stdin: "ignore",
99
+ stdout: "pipe",
100
+ stderr: "pipe",
101
+ })
102
+
103
+ let cancelled = false
104
+ let stdout = ""
105
+ let lineCount = 0
106
+
107
+ const readStream = async () => {
108
+ try {
109
+ const reader = proc.stdout.getReader()
110
+ const decoder = new TextDecoder()
111
+ let chunkCount = 0
112
+
113
+ while (!cancelled) {
114
+ const { done, value } = await reader.read()
115
+ if (done) break
116
+
117
+ chunkCount++
118
+ const chunk = decoder.decode(value, { stream: true })
119
+ stdout += chunk
120
+
121
+ const newLines = chunk.split("\n").length - 1
122
+ lineCount += newLines
123
+
124
+ if (PROFILE) {
125
+ console.error(
126
+ `[PROFILE] chunk #${chunkCount}: ${chunk.length} bytes, ${newLines} lines, total ${lineCount} lines`,
127
+ )
128
+ }
129
+
130
+ callbacks.onChunk(stdout, lineCount)
131
+
132
+ await new Promise((r) => setImmediate(r))
133
+ }
134
+
135
+ const stderr = await new Response(proc.stderr).text()
136
+ const exitCode = await proc.exited
137
+
138
+ endTotal()
139
+
140
+ if (!cancelled) {
141
+ callbacks.onComplete({
142
+ stdout,
143
+ stderr,
144
+ exitCode,
145
+ success: exitCode === 0,
146
+ })
147
+ }
148
+ } catch (error) {
149
+ if (!cancelled) {
150
+ callbacks.onError(
151
+ error instanceof Error ? error : new Error(String(error)),
152
+ )
153
+ }
154
+ }
155
+ }
156
+
157
+ readStream()
158
+
159
+ return {
160
+ cancel: () => {
161
+ cancelled = true
162
+ proc.kill()
163
+ },
164
+ }
165
+ }
166
+
167
+ export interface PTYStreamingOptions extends ExecuteOptions {
168
+ cols?: number
169
+ rows?: number
170
+ }
171
+
172
+ /**
173
+ * Execute jj command via PTY for true streaming output.
174
+ * Uses Bun.spawn with terminal option (requires Bun 1.3.5+).
175
+ * Falls back to regular executeStreaming on Windows or if PTY fails.
176
+ */
177
+ export function executePTYStreaming(
178
+ args: string[],
179
+ options: PTYStreamingOptions,
180
+ callbacks: StreamingExecuteCallbacks,
181
+ ): { cancel: () => void } {
182
+ const endTotal = profile(`executePTYStreaming [jj ${args[0]}]`)
183
+
184
+ const cols = options.cols || 120
185
+ const rows = options.rows || 50
186
+ const cwd = options.cwd || process.cwd()
187
+
188
+ let cancelled = false
189
+ let stdout = ""
190
+ let lineCount = 0
191
+ let chunkCount = 0
192
+ let pendingUpdate = false
193
+ let lastUpdateTime = 0
194
+ const UPDATE_INTERVAL = 60
195
+ const startTime = performance.now()
196
+
197
+ try {
198
+ const proc = Bun.spawn(["jj", ...args], {
199
+ cwd,
200
+ env: {
201
+ ...process.env,
202
+ JJ_EDITOR: "true",
203
+ EDITOR: "true",
204
+ VISUAL: "true",
205
+ ...options.env,
206
+ },
207
+ terminal: {
208
+ cols,
209
+ rows,
210
+ data(_terminal, data) {
211
+ if (cancelled) return
212
+
213
+ chunkCount++
214
+ const chunk = data.toString()
215
+ stdout += chunk
216
+
217
+ if (chunkCount === 1 && PROFILE) {
218
+ const firstChunkTime = performance.now() - startTime
219
+ console.error(
220
+ `[PROFILE] PTY first chunk: ${firstChunkTime.toFixed(0)}ms, ${chunk.length} bytes`,
221
+ )
222
+ }
223
+
224
+ let newLines = 0
225
+ for (let i = 0; i < chunk.length; i++) {
226
+ if (chunk[i] === "\n") newLines++
227
+ }
228
+ lineCount += newLines
229
+
230
+ if (PROFILE && chunkCount <= 5) {
231
+ console.error(
232
+ `[PROFILE] PTY chunk #${chunkCount}: ${chunk.length} bytes, ${newLines} lines, total ${lineCount} lines`,
233
+ )
234
+ }
235
+
236
+ const now = performance.now()
237
+ const shouldUpdate =
238
+ chunkCount === 1 || now - lastUpdateTime >= UPDATE_INTERVAL
239
+ if (shouldUpdate) {
240
+ lastUpdateTime = now
241
+ callbacks.onChunk(stdout, lineCount)
242
+ } else if (!pendingUpdate) {
243
+ pendingUpdate = true
244
+ setTimeout(() => {
245
+ pendingUpdate = false
246
+ if (!cancelled) {
247
+ lastUpdateTime = performance.now()
248
+ callbacks.onChunk(stdout, lineCount)
249
+ }
250
+ }, UPDATE_INTERVAL)
251
+ }
252
+ },
253
+ },
254
+ })
255
+
256
+ proc.exited.then((exitCode) => {
257
+ if (cancelled) return
258
+
259
+ proc.terminal?.close()
260
+ endTotal()
261
+
262
+ callbacks.onComplete({
263
+ stdout,
264
+ stderr: "",
265
+ exitCode,
266
+ success: exitCode === 0,
267
+ })
268
+ })
269
+
270
+ return {
271
+ cancel: () => {
272
+ cancelled = true
273
+ proc.terminal?.close()
274
+ proc.kill()
275
+ },
276
+ }
277
+ } catch (error) {
278
+ if (PROFILE) {
279
+ console.error(
280
+ `[PROFILE] PTY error: ${error}, falling back to pipe streaming`,
281
+ )
282
+ }
283
+ return executeStreaming(args, options, callbacks)
284
+ }
285
+ }
@@ -0,0 +1,87 @@
1
+ import { execute } from "./executor"
2
+ import type { FileChange, FileStatus } from "./types"
3
+
4
+ const STATUS_MAP: Record<string, FileStatus> = {
5
+ A: "added",
6
+ M: "modified",
7
+ D: "deleted",
8
+ R: "renamed",
9
+ C: "copied",
10
+ }
11
+
12
+ const BRACED_RENAME_REGEX = /^(.*)\{(.+) => (.+)\}(.*)$/
13
+
14
+ function parseRenamedPath(rawPath: string): {
15
+ oldPath: string
16
+ newPath: string
17
+ } {
18
+ const match = rawPath.match(BRACED_RENAME_REGEX)
19
+ if (match?.[2] && match[3]) {
20
+ const prefix = match[1] ?? ""
21
+ const oldPart = match[2]
22
+ const newPart = match[3]
23
+ const suffix = match[4] ?? ""
24
+ return {
25
+ oldPath: prefix + oldPart + suffix,
26
+ newPath: prefix + newPart + suffix,
27
+ }
28
+ }
29
+
30
+ const arrowIndex = rawPath.indexOf(" => ")
31
+ if (arrowIndex !== -1) {
32
+ return {
33
+ oldPath: rawPath.slice(0, arrowIndex),
34
+ newPath: rawPath.slice(arrowIndex + 4),
35
+ }
36
+ }
37
+
38
+ return { oldPath: rawPath, newPath: rawPath }
39
+ }
40
+
41
+ export function parseFileSummary(output: string): FileChange[] {
42
+ const files: FileChange[] = []
43
+
44
+ for (const line of output.split("\n")) {
45
+ const trimmed = line.trim()
46
+ if (!trimmed) continue
47
+
48
+ const statusChar = trimmed[0]
49
+ if (!statusChar) continue
50
+ const status = STATUS_MAP[statusChar]
51
+ if (!status) continue
52
+
53
+ const rawPath = trimmed.slice(2)
54
+
55
+ if (status === "renamed" || status === "copied") {
56
+ const { oldPath, newPath } = parseRenamedPath(rawPath)
57
+ files.push({ path: newPath, status, oldPath })
58
+ } else {
59
+ files.push({ path: rawPath, status })
60
+ }
61
+ }
62
+
63
+ return files
64
+ }
65
+
66
+ export interface FetchFilesOptions {
67
+ ignoreWorkingCopy?: boolean
68
+ }
69
+
70
+ export async function fetchFiles(
71
+ changeId: string,
72
+ options: FetchFilesOptions = {},
73
+ ): Promise<FileChange[]> {
74
+ const args = ["diff", "--summary", "-r", changeId]
75
+
76
+ if (options.ignoreWorkingCopy) {
77
+ args.push("--ignore-working-copy")
78
+ }
79
+
80
+ const result = await execute(args)
81
+
82
+ if (!result.success) {
83
+ throw new Error(`Failed to fetch files: ${result.stderr}`)
84
+ }
85
+
86
+ return parseFileSummary(result.stdout)
87
+ }
@@ -0,0 +1,99 @@
1
+ import { execute } from "./executor"
2
+ import type { Commit } from "./types"
3
+
4
+ const MARKER = "__LJ__"
5
+
6
+ // Strip ANSI escape codes from a string (for extracting clean metadata)
7
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI escape sequence
8
+ const stripAnsi = (str: string) => str.replace(/\x1b\[[0-9;]*m/g, "")
9
+
10
+ function buildTemplate(): string {
11
+ const styledDescription = `if(empty, label("empty", "(empty) "), "") ++ if(description.first_line(), description.first_line(), label("description placeholder", "(no description set)"))`
12
+
13
+ const prefix = [
14
+ `"${MARKER}"`,
15
+ "change_id.short()",
16
+ `"${MARKER}"`,
17
+ "commit_id.short()",
18
+ `"${MARKER}"`,
19
+ "immutable",
20
+ `"${MARKER}"`,
21
+ "empty",
22
+ `"${MARKER}"`,
23
+ styledDescription,
24
+ `"${MARKER}"`,
25
+ "author.name()",
26
+ `"${MARKER}"`,
27
+ "author.email()",
28
+ `"${MARKER}"`,
29
+ 'author.timestamp().local().format("%Y-%m-%d %H:%M:%S %:z")',
30
+ `"${MARKER}"`,
31
+ ].join(" ++ ")
32
+
33
+ return `${prefix} ++ builtin_log_compact`
34
+ }
35
+
36
+ export function parseLogOutput(output: string): Commit[] {
37
+ const commits: Commit[] = []
38
+ let current: Commit | null = null
39
+
40
+ for (const line of output.split("\n")) {
41
+ if (line.includes(MARKER)) {
42
+ const parts = line.split(MARKER)
43
+ if (parts.length >= 10) {
44
+ if (current) {
45
+ commits.push(current)
46
+ }
47
+
48
+ const gutter = parts[0] ?? ""
49
+ current = {
50
+ changeId: stripAnsi(parts[1] ?? ""),
51
+ commitId: stripAnsi(parts[2] ?? ""),
52
+ immutable: stripAnsi(parts[3] ?? "") === "true",
53
+ empty: stripAnsi(parts[4] ?? "") === "true",
54
+ description: stripAnsi(parts[5] ?? ""),
55
+ author: stripAnsi(parts[6] ?? ""),
56
+ authorEmail: stripAnsi(parts[7] ?? ""),
57
+ timestamp: stripAnsi(parts[8] ?? ""),
58
+ isWorkingCopy: gutter.includes("@"),
59
+ lines: [gutter + (parts[9] ?? "")],
60
+ }
61
+ continue
62
+ }
63
+ }
64
+
65
+ if (current && line.trim() !== "") {
66
+ current.lines.push(line)
67
+ }
68
+ }
69
+
70
+ if (current) {
71
+ commits.push(current)
72
+ }
73
+
74
+ return commits
75
+ }
76
+
77
+ export interface FetchLogOptions {
78
+ cwd?: string
79
+ revset?: string
80
+ }
81
+
82
+ export async function fetchLog(options?: FetchLogOptions): Promise<Commit[]> {
83
+ const template = buildTemplate()
84
+ const args = ["log", "--color", "always", "--template", template]
85
+
86
+ if (options?.revset) {
87
+ args.push("-r", options.revset)
88
+ }
89
+
90
+ const result = await execute(args, {
91
+ cwd: options?.cwd,
92
+ })
93
+
94
+ if (!result.success) {
95
+ throw new Error(`jj log failed: ${result.stderr}`)
96
+ }
97
+
98
+ return parseLogOutput(result.stdout)
99
+ }