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,868 @@
|
|
|
1
|
+
import { useRenderer } from "@opentui/solid"
|
|
2
|
+
import {
|
|
3
|
+
type JSX,
|
|
4
|
+
createContext,
|
|
5
|
+
createEffect,
|
|
6
|
+
createMemo,
|
|
7
|
+
createSignal,
|
|
8
|
+
onCleanup,
|
|
9
|
+
onMount,
|
|
10
|
+
useContext,
|
|
11
|
+
} from "solid-js"
|
|
12
|
+
import { type Bookmark, fetchBookmarks } from "../commander/bookmarks"
|
|
13
|
+
import { streamDiffPTY } from "../commander/diff"
|
|
14
|
+
import { fetchFiles } from "../commander/files"
|
|
15
|
+
import { fetchLog } from "../commander/log"
|
|
16
|
+
import {
|
|
17
|
+
type DiffStats,
|
|
18
|
+
fetchOpLogId,
|
|
19
|
+
jjAbandon,
|
|
20
|
+
jjDescribe,
|
|
21
|
+
jjDiffStats,
|
|
22
|
+
jjEdit,
|
|
23
|
+
jjNew,
|
|
24
|
+
jjShowDescriptionStyled,
|
|
25
|
+
jjSquash,
|
|
26
|
+
} from "../commander/operations"
|
|
27
|
+
import type { Commit, FileChange } from "../commander/types"
|
|
28
|
+
import {
|
|
29
|
+
type FileTreeNode,
|
|
30
|
+
type FlatFileNode,
|
|
31
|
+
buildFileTree,
|
|
32
|
+
flattenTree,
|
|
33
|
+
getFilePaths,
|
|
34
|
+
} from "../utils/file-tree"
|
|
35
|
+
import { useFocus } from "./focus"
|
|
36
|
+
import { useLoading } from "./loading"
|
|
37
|
+
|
|
38
|
+
const PROFILE = process.env.KAJJI_PROFILE === "1"
|
|
39
|
+
|
|
40
|
+
function profile(label: string) {
|
|
41
|
+
if (!PROFILE) return () => {}
|
|
42
|
+
const start = performance.now()
|
|
43
|
+
return (extra?: string) => {
|
|
44
|
+
const ms = (performance.now() - start).toFixed(2)
|
|
45
|
+
console.error(`[PROFILE] ${label}: ${ms}ms${extra ? ` (${extra})` : ""}`)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type ViewMode = "log" | "files"
|
|
50
|
+
export type BookmarkViewMode = "list" | "commits" | "files"
|
|
51
|
+
|
|
52
|
+
export interface CommitDetails {
|
|
53
|
+
changeId: string
|
|
54
|
+
subject: string
|
|
55
|
+
body: string
|
|
56
|
+
stats: DiffStats
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface SyncContextValue {
|
|
60
|
+
commits: () => Commit[]
|
|
61
|
+
selectedIndex: () => number
|
|
62
|
+
setSelectedIndex: (index: number) => void
|
|
63
|
+
selectPrev: () => void
|
|
64
|
+
selectNext: () => void
|
|
65
|
+
selectFirst: () => void
|
|
66
|
+
selectLast: () => void
|
|
67
|
+
selectedCommit: () => Commit | undefined
|
|
68
|
+
activeCommit: () => Commit | undefined
|
|
69
|
+
commitDetails: () => CommitDetails | null
|
|
70
|
+
loadLog: () => Promise<void>
|
|
71
|
+
loading: () => boolean
|
|
72
|
+
error: () => string | null
|
|
73
|
+
diff: () => string | null
|
|
74
|
+
diffLoading: () => boolean
|
|
75
|
+
diffError: () => string | null
|
|
76
|
+
diffLineCount: () => number
|
|
77
|
+
terminalWidth: () => number
|
|
78
|
+
terminalHeight: () => number
|
|
79
|
+
mainAreaWidth: () => number
|
|
80
|
+
|
|
81
|
+
viewMode: () => ViewMode
|
|
82
|
+
fileTree: () => FileTreeNode | null
|
|
83
|
+
flatFiles: () => FlatFileNode[]
|
|
84
|
+
selectedFileIndex: () => number
|
|
85
|
+
setSelectedFileIndex: (index: number) => void
|
|
86
|
+
collapsedPaths: () => Set<string>
|
|
87
|
+
filesLoading: () => boolean
|
|
88
|
+
filesError: () => string | null
|
|
89
|
+
selectedFile: () => FlatFileNode | undefined
|
|
90
|
+
|
|
91
|
+
enterFilesView: () => Promise<void>
|
|
92
|
+
exitFilesView: () => void
|
|
93
|
+
toggleFolder: (path: string) => void
|
|
94
|
+
selectPrevFile: () => void
|
|
95
|
+
selectNextFile: () => void
|
|
96
|
+
selectFirstFile: () => void
|
|
97
|
+
selectLastFile: () => void
|
|
98
|
+
|
|
99
|
+
bookmarks: () => Bookmark[]
|
|
100
|
+
selectedBookmarkIndex: () => number
|
|
101
|
+
setSelectedBookmarkIndex: (index: number) => void
|
|
102
|
+
bookmarksLoading: () => boolean
|
|
103
|
+
bookmarksError: () => string | null
|
|
104
|
+
selectedBookmark: () => Bookmark | undefined
|
|
105
|
+
loadBookmarks: () => Promise<void>
|
|
106
|
+
selectPrevBookmark: () => void
|
|
107
|
+
selectNextBookmark: () => void
|
|
108
|
+
selectFirstBookmark: () => void
|
|
109
|
+
selectLastBookmark: () => void
|
|
110
|
+
jumpToBookmarkCommit: () => number | null
|
|
111
|
+
|
|
112
|
+
bookmarkViewMode: () => BookmarkViewMode
|
|
113
|
+
bookmarkCommits: () => Commit[]
|
|
114
|
+
selectedBookmarkCommitIndex: () => number
|
|
115
|
+
setSelectedBookmarkCommitIndex: (index: number) => void
|
|
116
|
+
bookmarkCommitsLoading: () => boolean
|
|
117
|
+
selectedBookmarkCommit: () => Commit | undefined
|
|
118
|
+
bookmarkFileTree: () => FileTreeNode | null
|
|
119
|
+
bookmarkFlatFiles: () => FlatFileNode[]
|
|
120
|
+
selectedBookmarkFileIndex: () => number
|
|
121
|
+
setSelectedBookmarkFileIndex: (index: number) => void
|
|
122
|
+
bookmarkFilesLoading: () => boolean
|
|
123
|
+
selectedBookmarkFile: () => FlatFileNode | undefined
|
|
124
|
+
bookmarkCollapsedPaths: () => Set<string>
|
|
125
|
+
activeBookmarkName: () => string | null
|
|
126
|
+
|
|
127
|
+
enterBookmarkCommitsView: () => Promise<void>
|
|
128
|
+
enterBookmarkFilesView: () => Promise<void>
|
|
129
|
+
exitBookmarkView: () => void
|
|
130
|
+
selectPrevBookmarkCommit: () => void
|
|
131
|
+
selectNextBookmarkCommit: () => void
|
|
132
|
+
selectFirstBookmarkCommit: () => void
|
|
133
|
+
selectLastBookmarkCommit: () => void
|
|
134
|
+
selectPrevBookmarkFile: () => void
|
|
135
|
+
selectNextBookmarkFile: () => void
|
|
136
|
+
selectFirstBookmarkFile: () => void
|
|
137
|
+
selectLastBookmarkFile: () => void
|
|
138
|
+
toggleBookmarkFolder: (path: string) => void
|
|
139
|
+
refresh: () => Promise<void>
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const SyncContext = createContext<SyncContextValue>()
|
|
143
|
+
|
|
144
|
+
export function SyncProvider(props: { children: JSX.Element }) {
|
|
145
|
+
const renderer = useRenderer()
|
|
146
|
+
const focus = useFocus()
|
|
147
|
+
const globalLoading = useLoading()
|
|
148
|
+
const [commits, setCommits] = createSignal<Commit[]>([])
|
|
149
|
+
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
|
150
|
+
const [loading, setLoading] = createSignal(false)
|
|
151
|
+
const [error, setError] = createSignal<string | null>(null)
|
|
152
|
+
const [diff, setDiff] = createSignal<string | null>(null)
|
|
153
|
+
const [diffLoading, setDiffLoading] = createSignal(false)
|
|
154
|
+
const [diffError, setDiffError] = createSignal<string | null>(null)
|
|
155
|
+
const [diffLineCount, setDiffLineCount] = createSignal(0)
|
|
156
|
+
const [terminalWidth, setTerminalWidth] = createSignal(renderer.width)
|
|
157
|
+
const [terminalHeight, setTerminalHeight] = createSignal(renderer.height)
|
|
158
|
+
|
|
159
|
+
const [viewMode, setViewMode] = createSignal<ViewMode>("log")
|
|
160
|
+
const [files, setFiles] = createSignal<FileChange[]>([])
|
|
161
|
+
const [fileTree, setFileTree] = createSignal<FileTreeNode | null>(null)
|
|
162
|
+
const [selectedFileIndex, setSelectedFileIndex] = createSignal(0)
|
|
163
|
+
const [collapsedPaths, setCollapsedPaths] = createSignal<Set<string>>(
|
|
164
|
+
new Set(),
|
|
165
|
+
)
|
|
166
|
+
const [filesLoading, setFilesLoading] = createSignal(false)
|
|
167
|
+
const [filesError, setFilesError] = createSignal<string | null>(null)
|
|
168
|
+
|
|
169
|
+
const [bookmarks, setBookmarks] = createSignal<Bookmark[]>([])
|
|
170
|
+
const [selectedBookmarkIndex, setSelectedBookmarkIndex] = createSignal(0)
|
|
171
|
+
const [bookmarksLoading, setBookmarksLoading] = createSignal(false)
|
|
172
|
+
const [bookmarksError, setBookmarksError] = createSignal<string | null>(null)
|
|
173
|
+
|
|
174
|
+
const [bookmarkViewMode, setBookmarkViewMode] =
|
|
175
|
+
createSignal<BookmarkViewMode>("list")
|
|
176
|
+
const [bookmarkCommits, setBookmarkCommits] = createSignal<Commit[]>([])
|
|
177
|
+
const [selectedBookmarkCommitIndex, setSelectedBookmarkCommitIndex] =
|
|
178
|
+
createSignal(0)
|
|
179
|
+
const [bookmarkCommitsLoading, setBookmarkCommitsLoading] =
|
|
180
|
+
createSignal(false)
|
|
181
|
+
const [activeBookmarkName, setActiveBookmarkName] = createSignal<
|
|
182
|
+
string | null
|
|
183
|
+
>(null)
|
|
184
|
+
const [bookmarkFiles, setBookmarkFiles] = createSignal<FileChange[]>([])
|
|
185
|
+
const [bookmarkFileTree, setBookmarkFileTree] =
|
|
186
|
+
createSignal<FileTreeNode | null>(null)
|
|
187
|
+
const [selectedBookmarkFileIndex, setSelectedBookmarkFileIndex] =
|
|
188
|
+
createSignal(0)
|
|
189
|
+
const [bookmarkCollapsedPaths, setBookmarkCollapsedPaths] = createSignal<
|
|
190
|
+
Set<string>
|
|
191
|
+
>(new Set())
|
|
192
|
+
const [bookmarkFilesLoading, setBookmarkFilesLoading] = createSignal(false)
|
|
193
|
+
|
|
194
|
+
const [commitDetails, setCommitDetails] = createSignal<CommitDetails | null>(
|
|
195
|
+
null,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
const flatFiles = createMemo(() => {
|
|
199
|
+
const tree = fileTree()
|
|
200
|
+
if (!tree) return []
|
|
201
|
+
return flattenTree(tree, collapsedPaths())
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
const selectedFile = () => flatFiles()[selectedFileIndex()]
|
|
205
|
+
|
|
206
|
+
const bookmarkFlatFiles = createMemo(() => {
|
|
207
|
+
const tree = bookmarkFileTree()
|
|
208
|
+
if (!tree) return []
|
|
209
|
+
return flattenTree(tree, bookmarkCollapsedPaths())
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
const selectedBookmarkCommit = () =>
|
|
213
|
+
bookmarkCommits()[selectedBookmarkCommitIndex()]
|
|
214
|
+
const selectedBookmarkFile = () =>
|
|
215
|
+
bookmarkFlatFiles()[selectedBookmarkFileIndex()]
|
|
216
|
+
|
|
217
|
+
const mainAreaWidth = () => {
|
|
218
|
+
const width = terminalWidth()
|
|
219
|
+
const mainAreaRatio = 2 / 3
|
|
220
|
+
const borderWidth = 2
|
|
221
|
+
return Math.floor(width * mainAreaRatio) - borderWidth
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
onMount(() => {
|
|
225
|
+
const handleResize = (width: number, height: number) => {
|
|
226
|
+
setTerminalWidth(width)
|
|
227
|
+
setTerminalHeight(height)
|
|
228
|
+
}
|
|
229
|
+
renderer.on("resize", handleResize)
|
|
230
|
+
onCleanup(() => renderer.off("resize", handleResize))
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
let lastOpLogId: string | null = null
|
|
234
|
+
let isRefreshing = false
|
|
235
|
+
|
|
236
|
+
const doFullRefresh = async () => {
|
|
237
|
+
if (isRefreshing) return
|
|
238
|
+
isRefreshing = true
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
await Promise.all([loadLog(), loadBookmarks()])
|
|
242
|
+
|
|
243
|
+
if (viewMode() === "files") {
|
|
244
|
+
const commit = selectedCommit()
|
|
245
|
+
if (commit) {
|
|
246
|
+
const result = await fetchFiles(commit.changeId, {
|
|
247
|
+
ignoreWorkingCopy: true,
|
|
248
|
+
})
|
|
249
|
+
setFiles(result)
|
|
250
|
+
setFileTree(buildFileTree(result))
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const bmMode = bookmarkViewMode()
|
|
255
|
+
if (bmMode === "commits" || bmMode === "files") {
|
|
256
|
+
const bookmarkName = activeBookmarkName()
|
|
257
|
+
if (bookmarkName) {
|
|
258
|
+
const result = await fetchLog({ revset: `::${bookmarkName}` })
|
|
259
|
+
setBookmarkCommits(result)
|
|
260
|
+
|
|
261
|
+
if (bmMode === "files") {
|
|
262
|
+
const commit = selectedBookmarkCommit()
|
|
263
|
+
if (commit) {
|
|
264
|
+
const fileResult = await fetchFiles(commit.changeId, {
|
|
265
|
+
ignoreWorkingCopy: true,
|
|
266
|
+
})
|
|
267
|
+
setBookmarkFiles(fileResult)
|
|
268
|
+
setBookmarkFileTree(buildFileTree(fileResult))
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} finally {
|
|
274
|
+
isRefreshing = false
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Auto-refresh: focus-based + adaptive polling (2s focused, 30s unfocused)
|
|
279
|
+
onMount(() => {
|
|
280
|
+
let focusDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
281
|
+
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
|
282
|
+
let isChecking = false
|
|
283
|
+
let isFocused = true
|
|
284
|
+
|
|
285
|
+
const POLL_INTERVAL_FOCUSED = 2000
|
|
286
|
+
const POLL_INTERVAL_UNFOCUSED = 30000
|
|
287
|
+
const FOCUS_DEBOUNCE = 100
|
|
288
|
+
|
|
289
|
+
const checkAndRefresh = async () => {
|
|
290
|
+
if (isChecking) return
|
|
291
|
+
isChecking = true
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const currentId = await fetchOpLogId()
|
|
295
|
+
if (!currentId) return
|
|
296
|
+
|
|
297
|
+
if (lastOpLogId !== null && currentId !== lastOpLogId) {
|
|
298
|
+
lastOpLogId = currentId
|
|
299
|
+
await doFullRefresh()
|
|
300
|
+
} else {
|
|
301
|
+
lastOpLogId = currentId
|
|
302
|
+
}
|
|
303
|
+
} finally {
|
|
304
|
+
isChecking = false
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const schedulePoll = () => {
|
|
309
|
+
if (pollTimer) {
|
|
310
|
+
clearTimeout(pollTimer)
|
|
311
|
+
}
|
|
312
|
+
const interval = isFocused
|
|
313
|
+
? POLL_INTERVAL_FOCUSED
|
|
314
|
+
: POLL_INTERVAL_UNFOCUSED
|
|
315
|
+
pollTimer = setTimeout(() => {
|
|
316
|
+
checkAndRefresh()
|
|
317
|
+
schedulePoll()
|
|
318
|
+
}, interval)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const handleFocus = () => {
|
|
322
|
+
isFocused = true
|
|
323
|
+
if (focusDebounceTimer) {
|
|
324
|
+
clearTimeout(focusDebounceTimer)
|
|
325
|
+
}
|
|
326
|
+
focusDebounceTimer = setTimeout(() => {
|
|
327
|
+
focusDebounceTimer = null
|
|
328
|
+
checkAndRefresh()
|
|
329
|
+
}, FOCUS_DEBOUNCE)
|
|
330
|
+
schedulePoll()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const handleBlur = () => {
|
|
334
|
+
isFocused = false
|
|
335
|
+
schedulePoll()
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
renderer.on("focus", handleFocus)
|
|
339
|
+
renderer.on("blur", handleBlur)
|
|
340
|
+
|
|
341
|
+
fetchOpLogId()
|
|
342
|
+
.then((id) => {
|
|
343
|
+
lastOpLogId = id
|
|
344
|
+
})
|
|
345
|
+
.catch(() => {})
|
|
346
|
+
|
|
347
|
+
schedulePoll()
|
|
348
|
+
|
|
349
|
+
onCleanup(() => {
|
|
350
|
+
renderer.off("focus", handleFocus)
|
|
351
|
+
renderer.off("blur", handleBlur)
|
|
352
|
+
if (pollTimer) {
|
|
353
|
+
clearTimeout(pollTimer)
|
|
354
|
+
}
|
|
355
|
+
if (focusDebounceTimer) {
|
|
356
|
+
clearTimeout(focusDebounceTimer)
|
|
357
|
+
}
|
|
358
|
+
})
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
createEffect(() => {
|
|
362
|
+
const currentPanel = focus.panel()
|
|
363
|
+
if (currentPanel === "log") {
|
|
364
|
+
const mode = viewMode()
|
|
365
|
+
focus.setActiveContext(mode === "files" ? "log.files" : "log.revisions")
|
|
366
|
+
} else if (currentPanel === "refs") {
|
|
367
|
+
const mode = bookmarkViewMode()
|
|
368
|
+
if (mode === "files") {
|
|
369
|
+
focus.setActiveContext("refs.files")
|
|
370
|
+
} else if (mode === "commits") {
|
|
371
|
+
focus.setActiveContext("refs.revisions")
|
|
372
|
+
} else {
|
|
373
|
+
focus.setActiveContext("refs.bookmarks")
|
|
374
|
+
}
|
|
375
|
+
} else if (currentPanel === "detail") {
|
|
376
|
+
focus.setActiveContext("detail")
|
|
377
|
+
}
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
// Determine which commit to show based on context (main log vs bookmark commits)
|
|
381
|
+
const activeCommit = () => {
|
|
382
|
+
const focusedPanel = focus.panel()
|
|
383
|
+
const bmMode = bookmarkViewMode()
|
|
384
|
+
if (
|
|
385
|
+
focusedPanel === "refs" &&
|
|
386
|
+
(bmMode === "commits" || bmMode === "files")
|
|
387
|
+
) {
|
|
388
|
+
return selectedBookmarkCommit()
|
|
389
|
+
}
|
|
390
|
+
return selectedCommit()
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Fetch full commit details when selection changes
|
|
394
|
+
// Works for both main log and bookmark commits views
|
|
395
|
+
let currentDetailsChangeId: string | null = null
|
|
396
|
+
createEffect(() => {
|
|
397
|
+
const commit = activeCommit()
|
|
398
|
+
|
|
399
|
+
if (!commit) {
|
|
400
|
+
setCommitDetails(null)
|
|
401
|
+
currentDetailsChangeId = null
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (commit.changeId === currentDetailsChangeId) return
|
|
406
|
+
currentDetailsChangeId = commit.changeId
|
|
407
|
+
|
|
408
|
+
// Fetch both in parallel (keep stale data until new arrives)
|
|
409
|
+
const changeId = commit.changeId
|
|
410
|
+
Promise.all([
|
|
411
|
+
jjShowDescriptionStyled(changeId),
|
|
412
|
+
jjDiffStats(changeId),
|
|
413
|
+
]).then(([desc, stats]) => {
|
|
414
|
+
// Only update if still the current commit
|
|
415
|
+
if (currentDetailsChangeId === changeId) {
|
|
416
|
+
setCommitDetails({
|
|
417
|
+
changeId,
|
|
418
|
+
subject: desc.subject,
|
|
419
|
+
body: desc.body,
|
|
420
|
+
stats,
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
})
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
const selectPrev = () => {
|
|
427
|
+
setSelectedIndex((i) => Math.max(0, i - 1))
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const selectNext = () => {
|
|
431
|
+
setSelectedIndex((i) => Math.min(commits().length - 1, i + 1))
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const selectFirst = () => {
|
|
435
|
+
setSelectedIndex(0)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const selectLast = () => {
|
|
439
|
+
setSelectedIndex(Math.max(0, commits().length - 1))
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const selectedCommit = () => commits()[selectedIndex()]
|
|
443
|
+
|
|
444
|
+
const selectPrevFile = () => {
|
|
445
|
+
setSelectedFileIndex((i) => Math.max(0, i - 1))
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const selectNextFile = () => {
|
|
449
|
+
setSelectedFileIndex((i) => Math.min(flatFiles().length - 1, i + 1))
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const selectFirstFile = () => {
|
|
453
|
+
setSelectedFileIndex(0)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const selectLastFile = () => {
|
|
457
|
+
setSelectedFileIndex(Math.max(0, flatFiles().length - 1))
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const localBookmarks = () => bookmarks().filter((b) => b.isLocal)
|
|
461
|
+
const selectedBookmark = () => localBookmarks()[selectedBookmarkIndex()]
|
|
462
|
+
|
|
463
|
+
const selectPrevBookmark = () => {
|
|
464
|
+
setSelectedBookmarkIndex((i) => Math.max(0, i - 1))
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const selectNextBookmark = () => {
|
|
468
|
+
setSelectedBookmarkIndex((i) =>
|
|
469
|
+
Math.min(localBookmarks().length - 1, i + 1),
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const selectFirstBookmark = () => {
|
|
474
|
+
setSelectedBookmarkIndex(0)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const selectLastBookmark = () => {
|
|
478
|
+
setSelectedBookmarkIndex(Math.max(0, localBookmarks().length - 1))
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const selectPrevBookmarkCommit = () => {
|
|
482
|
+
setSelectedBookmarkCommitIndex((i) => Math.max(0, i - 1))
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const selectNextBookmarkCommit = () => {
|
|
486
|
+
setSelectedBookmarkCommitIndex((i) =>
|
|
487
|
+
Math.min(bookmarkCommits().length - 1, i + 1),
|
|
488
|
+
)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const selectFirstBookmarkCommit = () => {
|
|
492
|
+
setSelectedBookmarkCommitIndex(0)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const selectLastBookmarkCommit = () => {
|
|
496
|
+
setSelectedBookmarkCommitIndex(Math.max(0, bookmarkCommits().length - 1))
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const selectPrevBookmarkFile = () => {
|
|
500
|
+
setSelectedBookmarkFileIndex((i) => Math.max(0, i - 1))
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const selectNextBookmarkFile = () => {
|
|
504
|
+
setSelectedBookmarkFileIndex((i) =>
|
|
505
|
+
Math.min(bookmarkFlatFiles().length - 1, i + 1),
|
|
506
|
+
)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const selectFirstBookmarkFile = () => {
|
|
510
|
+
setSelectedBookmarkFileIndex(0)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const selectLastBookmarkFile = () => {
|
|
514
|
+
setSelectedBookmarkFileIndex(Math.max(0, bookmarkFlatFiles().length - 1))
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const toggleBookmarkFolder = (path: string) => {
|
|
518
|
+
setBookmarkCollapsedPaths((prev) => {
|
|
519
|
+
const next = new Set(prev)
|
|
520
|
+
if (next.has(path)) {
|
|
521
|
+
next.delete(path)
|
|
522
|
+
} else {
|
|
523
|
+
next.add(path)
|
|
524
|
+
}
|
|
525
|
+
return next
|
|
526
|
+
})
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const enterBookmarkCommitsView = async () => {
|
|
530
|
+
const bookmark = selectedBookmark()
|
|
531
|
+
if (!bookmark) return
|
|
532
|
+
|
|
533
|
+
setBookmarkCommitsLoading(true)
|
|
534
|
+
try {
|
|
535
|
+
const result = await fetchLog({ revset: `::${bookmark.name}` })
|
|
536
|
+
setBookmarkCommits(result)
|
|
537
|
+
setSelectedBookmarkCommitIndex(0)
|
|
538
|
+
setActiveBookmarkName(bookmark.name)
|
|
539
|
+
setBookmarkViewMode("commits")
|
|
540
|
+
focus.setActiveContext("refs.revisions")
|
|
541
|
+
} catch (e) {
|
|
542
|
+
console.error("Failed to load bookmark commits:", e)
|
|
543
|
+
} finally {
|
|
544
|
+
setBookmarkCommitsLoading(false)
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const enterBookmarkFilesView = async () => {
|
|
549
|
+
const commit = selectedBookmarkCommit()
|
|
550
|
+
if (!commit) return
|
|
551
|
+
|
|
552
|
+
setBookmarkFilesLoading(true)
|
|
553
|
+
try {
|
|
554
|
+
const result = await fetchFiles(commit.changeId, {
|
|
555
|
+
ignoreWorkingCopy: true,
|
|
556
|
+
})
|
|
557
|
+
setBookmarkFiles(result)
|
|
558
|
+
setBookmarkFileTree(buildFileTree(result))
|
|
559
|
+
setSelectedBookmarkFileIndex(0)
|
|
560
|
+
setBookmarkCollapsedPaths(new Set<string>())
|
|
561
|
+
setBookmarkViewMode("files")
|
|
562
|
+
focus.setActiveContext("refs.files")
|
|
563
|
+
} catch (e) {
|
|
564
|
+
console.error("Failed to load bookmark files:", e)
|
|
565
|
+
} finally {
|
|
566
|
+
setBookmarkFilesLoading(false)
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const exitBookmarkView = () => {
|
|
571
|
+
const mode = bookmarkViewMode()
|
|
572
|
+
if (mode === "files") {
|
|
573
|
+
setBookmarkViewMode("commits")
|
|
574
|
+
setBookmarkFiles([])
|
|
575
|
+
setBookmarkFileTree(null)
|
|
576
|
+
setSelectedBookmarkFileIndex(0)
|
|
577
|
+
setBookmarkCollapsedPaths(new Set<string>())
|
|
578
|
+
focus.setActiveContext("refs.revisions")
|
|
579
|
+
} else if (mode === "commits") {
|
|
580
|
+
setBookmarkViewMode("list")
|
|
581
|
+
setBookmarkCommits([])
|
|
582
|
+
setSelectedBookmarkCommitIndex(0)
|
|
583
|
+
setActiveBookmarkName(null)
|
|
584
|
+
focus.setActiveContext("refs.bookmarks")
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const loadBookmarks = async () => {
|
|
589
|
+
const isInitialLoad = bookmarks().length === 0
|
|
590
|
+
if (isInitialLoad) setBookmarksLoading(true)
|
|
591
|
+
setBookmarksError(null)
|
|
592
|
+
try {
|
|
593
|
+
await globalLoading.run("Fetching...", async () => {
|
|
594
|
+
const result = await fetchBookmarks({ allRemotes: true })
|
|
595
|
+
setBookmarks(result)
|
|
596
|
+
if (isInitialLoad) setSelectedBookmarkIndex(0)
|
|
597
|
+
})
|
|
598
|
+
} catch (e) {
|
|
599
|
+
setBookmarksError(
|
|
600
|
+
e instanceof Error ? e.message : "Failed to load bookmarks",
|
|
601
|
+
)
|
|
602
|
+
} finally {
|
|
603
|
+
if (isInitialLoad) setBookmarksLoading(false)
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const jumpToBookmarkCommit = (): number | null => {
|
|
608
|
+
const bookmark = selectedBookmark()
|
|
609
|
+
if (!bookmark) return null
|
|
610
|
+
|
|
611
|
+
const index = commits().findIndex((c) => c.changeId === bookmark.changeId)
|
|
612
|
+
if (index !== -1) {
|
|
613
|
+
setSelectedIndex(index)
|
|
614
|
+
return index
|
|
615
|
+
}
|
|
616
|
+
return null
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
let currentDiffKey: string | null = null
|
|
620
|
+
let currentDiffStream: { cancel: () => void } | null = null
|
|
621
|
+
|
|
622
|
+
const loadDiff = (changeId: string, columns: number, paths?: string[]) => {
|
|
623
|
+
const endTotal = profile(`loadDiff(${changeId.slice(0, 8)})`)
|
|
624
|
+
const requestKey = currentDiffKey
|
|
625
|
+
|
|
626
|
+
if (currentDiffStream) {
|
|
627
|
+
currentDiffStream.cancel()
|
|
628
|
+
currentDiffStream = null
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
setDiffLoading(true)
|
|
632
|
+
setDiffError(null)
|
|
633
|
+
|
|
634
|
+
let firstUpdate = true
|
|
635
|
+
|
|
636
|
+
currentDiffStream = streamDiffPTY(
|
|
637
|
+
changeId,
|
|
638
|
+
{
|
|
639
|
+
columns,
|
|
640
|
+
paths,
|
|
641
|
+
cols: columns,
|
|
642
|
+
rows: terminalHeight(),
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
onUpdate: (content: string, lineCount: number, complete: boolean) => {
|
|
646
|
+
if (currentDiffKey !== requestKey) return
|
|
647
|
+
|
|
648
|
+
if (firstUpdate) {
|
|
649
|
+
profile(" first render trigger")()
|
|
650
|
+
firstUpdate = false
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
setDiff(content)
|
|
654
|
+
setDiffLineCount(lineCount)
|
|
655
|
+
|
|
656
|
+
if (complete) {
|
|
657
|
+
setDiffLoading(false)
|
|
658
|
+
endTotal()
|
|
659
|
+
}
|
|
660
|
+
},
|
|
661
|
+
onError: (error: Error) => {
|
|
662
|
+
if (currentDiffKey !== requestKey) return
|
|
663
|
+
setDiffError(error.message)
|
|
664
|
+
setDiff(null)
|
|
665
|
+
setDiffLoading(false)
|
|
666
|
+
endTotal()
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const computeDiffKey = (changeId: string, paths?: string[]) =>
|
|
673
|
+
paths?.length ? `${changeId}:${paths.join(",")}` : changeId
|
|
674
|
+
|
|
675
|
+
createEffect(() => {
|
|
676
|
+
const columns = mainAreaWidth()
|
|
677
|
+
const mode = viewMode()
|
|
678
|
+
const bmMode = bookmarkViewMode()
|
|
679
|
+
const focusedPanel = focus.panel()
|
|
680
|
+
|
|
681
|
+
let changeId: string
|
|
682
|
+
let paths: string[] | undefined
|
|
683
|
+
|
|
684
|
+
if (focusedPanel === "refs" && bmMode === "commits") {
|
|
685
|
+
const commit = selectedBookmarkCommit()
|
|
686
|
+
if (!commit) return
|
|
687
|
+
changeId = commit.changeId
|
|
688
|
+
} else if (focusedPanel === "refs" && bmMode === "files") {
|
|
689
|
+
const commit = selectedBookmarkCommit()
|
|
690
|
+
const file = selectedBookmarkFile()
|
|
691
|
+
if (!commit || !file) return
|
|
692
|
+
changeId = commit.changeId
|
|
693
|
+
paths = file.node.isDirectory ? getFilePaths(file.node) : [file.node.path]
|
|
694
|
+
} else if (mode === "files") {
|
|
695
|
+
const commit = selectedCommit()
|
|
696
|
+
const file = selectedFile()
|
|
697
|
+
if (!commit || !file) return
|
|
698
|
+
changeId = commit.changeId
|
|
699
|
+
paths = file.node.isDirectory ? getFilePaths(file.node) : [file.node.path]
|
|
700
|
+
} else {
|
|
701
|
+
const commit = selectedCommit()
|
|
702
|
+
if (!commit) return
|
|
703
|
+
changeId = commit.changeId
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const newKey = computeDiffKey(changeId, paths)
|
|
707
|
+
if (newKey === currentDiffKey) return
|
|
708
|
+
|
|
709
|
+
currentDiffKey = newKey // Update key BEFORE loading to prevent duplicate loads
|
|
710
|
+
loadDiff(changeId, columns, paths)
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
const loadLog = async () => {
|
|
714
|
+
const isInitialLoad = commits().length === 0
|
|
715
|
+
if (isInitialLoad) setLoading(true)
|
|
716
|
+
setError(null)
|
|
717
|
+
try {
|
|
718
|
+
await globalLoading.run("Fetching...", async () => {
|
|
719
|
+
const result = await fetchLog()
|
|
720
|
+
setCommits(result)
|
|
721
|
+
if (isInitialLoad) setSelectedIndex(0)
|
|
722
|
+
})
|
|
723
|
+
} catch (e) {
|
|
724
|
+
setError(e instanceof Error ? e.message : "Failed to load log")
|
|
725
|
+
} finally {
|
|
726
|
+
if (isInitialLoad) setLoading(false)
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const enterFilesView = async () => {
|
|
731
|
+
const commit = selectedCommit()
|
|
732
|
+
if (!commit) return
|
|
733
|
+
|
|
734
|
+
setFilesLoading(true)
|
|
735
|
+
setFilesError(null)
|
|
736
|
+
try {
|
|
737
|
+
const result = await fetchFiles(commit.changeId, {
|
|
738
|
+
ignoreWorkingCopy: true,
|
|
739
|
+
})
|
|
740
|
+
setFiles(result)
|
|
741
|
+
setFileTree(buildFileTree(result))
|
|
742
|
+
setSelectedFileIndex(0)
|
|
743
|
+
setCollapsedPaths(new Set<string>())
|
|
744
|
+
setViewMode("files")
|
|
745
|
+
focus.setActiveContext("log.files")
|
|
746
|
+
} catch (e) {
|
|
747
|
+
setFilesError(e instanceof Error ? e.message : "Failed to load files")
|
|
748
|
+
} finally {
|
|
749
|
+
setFilesLoading(false)
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const exitFilesView = () => {
|
|
754
|
+
setViewMode("log")
|
|
755
|
+
setFiles([])
|
|
756
|
+
setFileTree(null)
|
|
757
|
+
setSelectedFileIndex(0)
|
|
758
|
+
setCollapsedPaths(new Set<string>())
|
|
759
|
+
focus.setActiveContext("log.revisions")
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const toggleFolder = (path: string) => {
|
|
763
|
+
setCollapsedPaths((prev) => {
|
|
764
|
+
const next = new Set(prev)
|
|
765
|
+
if (next.has(path)) {
|
|
766
|
+
next.delete(path)
|
|
767
|
+
} else {
|
|
768
|
+
next.add(path)
|
|
769
|
+
}
|
|
770
|
+
return next
|
|
771
|
+
})
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const value: SyncContextValue = {
|
|
775
|
+
commits,
|
|
776
|
+
selectedIndex,
|
|
777
|
+
setSelectedIndex,
|
|
778
|
+
selectPrev,
|
|
779
|
+
selectNext,
|
|
780
|
+
selectFirst,
|
|
781
|
+
selectLast,
|
|
782
|
+
selectedCommit,
|
|
783
|
+
activeCommit,
|
|
784
|
+
commitDetails,
|
|
785
|
+
loadLog,
|
|
786
|
+
loading,
|
|
787
|
+
error,
|
|
788
|
+
diff,
|
|
789
|
+
diffLoading,
|
|
790
|
+
diffError,
|
|
791
|
+
diffLineCount,
|
|
792
|
+
terminalWidth,
|
|
793
|
+
terminalHeight,
|
|
794
|
+
mainAreaWidth,
|
|
795
|
+
|
|
796
|
+
viewMode,
|
|
797
|
+
fileTree,
|
|
798
|
+
flatFiles,
|
|
799
|
+
selectedFileIndex,
|
|
800
|
+
setSelectedFileIndex,
|
|
801
|
+
collapsedPaths,
|
|
802
|
+
filesLoading,
|
|
803
|
+
filesError,
|
|
804
|
+
selectedFile,
|
|
805
|
+
|
|
806
|
+
enterFilesView,
|
|
807
|
+
exitFilesView,
|
|
808
|
+
toggleFolder,
|
|
809
|
+
selectPrevFile,
|
|
810
|
+
selectNextFile,
|
|
811
|
+
selectFirstFile,
|
|
812
|
+
selectLastFile,
|
|
813
|
+
|
|
814
|
+
bookmarks,
|
|
815
|
+
selectedBookmarkIndex,
|
|
816
|
+
setSelectedBookmarkIndex,
|
|
817
|
+
bookmarksLoading,
|
|
818
|
+
bookmarksError,
|
|
819
|
+
selectedBookmark,
|
|
820
|
+
loadBookmarks,
|
|
821
|
+
selectPrevBookmark,
|
|
822
|
+
selectNextBookmark,
|
|
823
|
+
selectFirstBookmark,
|
|
824
|
+
selectLastBookmark,
|
|
825
|
+
jumpToBookmarkCommit,
|
|
826
|
+
|
|
827
|
+
bookmarkViewMode,
|
|
828
|
+
bookmarkCommits,
|
|
829
|
+
selectedBookmarkCommitIndex,
|
|
830
|
+
setSelectedBookmarkCommitIndex,
|
|
831
|
+
bookmarkCommitsLoading,
|
|
832
|
+
selectedBookmarkCommit,
|
|
833
|
+
bookmarkFileTree,
|
|
834
|
+
bookmarkFlatFiles,
|
|
835
|
+
selectedBookmarkFileIndex,
|
|
836
|
+
setSelectedBookmarkFileIndex,
|
|
837
|
+
bookmarkFilesLoading,
|
|
838
|
+
selectedBookmarkFile,
|
|
839
|
+
bookmarkCollapsedPaths,
|
|
840
|
+
activeBookmarkName,
|
|
841
|
+
|
|
842
|
+
enterBookmarkCommitsView,
|
|
843
|
+
enterBookmarkFilesView,
|
|
844
|
+
exitBookmarkView,
|
|
845
|
+
selectPrevBookmarkCommit,
|
|
846
|
+
selectNextBookmarkCommit,
|
|
847
|
+
selectFirstBookmarkCommit,
|
|
848
|
+
selectLastBookmarkCommit,
|
|
849
|
+
selectPrevBookmarkFile,
|
|
850
|
+
selectNextBookmarkFile,
|
|
851
|
+
selectFirstBookmarkFile,
|
|
852
|
+
selectLastBookmarkFile,
|
|
853
|
+
toggleBookmarkFolder,
|
|
854
|
+
refresh: doFullRefresh,
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
return (
|
|
858
|
+
<SyncContext.Provider value={value}>{props.children}</SyncContext.Provider>
|
|
859
|
+
)
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
export function useSync(): SyncContextValue {
|
|
863
|
+
const ctx = useContext(SyncContext)
|
|
864
|
+
if (!ctx) {
|
|
865
|
+
throw new Error("useSync must be used within SyncProvider")
|
|
866
|
+
}
|
|
867
|
+
return ctx
|
|
868
|
+
}
|