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