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