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,774 @@
1
+ import type { ScrollBoxRenderable } from "@opentui/core"
2
+ import {
3
+ For,
4
+ Show,
5
+ createEffect,
6
+ createMemo,
7
+ createSignal,
8
+ onMount,
9
+ } from "solid-js"
10
+ import { jjBookmarkCreate } from "../../commander/bookmarks"
11
+ import {
12
+ type OpLogEntry,
13
+ type OperationResult,
14
+ fetchOpLog,
15
+ isImmutableError,
16
+ jjAbandon,
17
+ jjDescribe,
18
+ jjEdit,
19
+ jjNew,
20
+ jjOpRestore,
21
+ jjRedo,
22
+ jjRestore,
23
+ jjShowDescription,
24
+ jjSquash,
25
+ jjUndo,
26
+ parseOpLog,
27
+ } from "../../commander/operations"
28
+ import { useCommand } from "../../context/command"
29
+ import { useCommandLog } from "../../context/commandlog"
30
+ import { useDialog } from "../../context/dialog"
31
+ import { useFocus } from "../../context/focus"
32
+ import { useLoading } from "../../context/loading"
33
+ import { useSync } from "../../context/sync"
34
+ import { useTheme } from "../../context/theme"
35
+ import type { Context } from "../../context/types"
36
+ import { createDoubleClickDetector } from "../../utils/double-click"
37
+ import { AnsiText } from "../AnsiText"
38
+ import { FileTreeList } from "../FileTreeList"
39
+ import { Panel } from "../Panel"
40
+ import { BookmarkNameModal } from "../modals/BookmarkNameModal"
41
+ import { DescribeModal } from "../modals/DescribeModal"
42
+ import { UndoModal } from "../modals/UndoModal"
43
+
44
+ type LogTab = "revisions" | "oplog"
45
+
46
+ const LOG_TABS: Array<{ id: LogTab; label: string; context: Context }> = [
47
+ { id: "revisions", label: "Revisions", context: "log.revisions" },
48
+ { id: "oplog", label: "Oplog", context: "log.oplog" },
49
+ ]
50
+
51
+ export function LogPanel() {
52
+ const {
53
+ commits,
54
+ selectedIndex,
55
+ setSelectedIndex,
56
+ selectedCommit,
57
+ loading,
58
+ error,
59
+ selectNext,
60
+ selectPrev,
61
+ enterFilesView,
62
+ exitFilesView,
63
+ viewMode,
64
+ refresh,
65
+ flatFiles,
66
+ selectedFileIndex,
67
+ setSelectedFileIndex,
68
+ filesLoading,
69
+ filesError,
70
+ collapsedPaths,
71
+ toggleFolder,
72
+ selectNextFile,
73
+ selectPrevFile,
74
+ } = useSync()
75
+ const focus = useFocus()
76
+ const command = useCommand()
77
+ const commandLog = useCommandLog()
78
+ const dialog = useDialog()
79
+ const globalLoading = useLoading()
80
+ const { colors } = useTheme()
81
+
82
+ const [activeTab, setActiveTab] = createSignal<LogTab>("revisions")
83
+ const [opLogEntries, setOpLogEntries] = createSignal<OpLogEntry[]>([])
84
+ const [opLogLoading, setOpLogLoading] = createSignal(false)
85
+ const [opLogSelectedIndex, setOpLogSelectedIndex] = createSignal(0)
86
+ const [opLogLimit, setOpLogLimit] = createSignal(50)
87
+ const [opLogHasMore, setOpLogHasMore] = createSignal(true)
88
+
89
+ const isFocused = () => focus.isPanel("log")
90
+ const isFilesView = () => viewMode() === "files"
91
+
92
+ const tabs = () => (isFilesView() ? undefined : LOG_TABS)
93
+
94
+ const title = () => (isFilesView() ? "Files" : undefined)
95
+
96
+ const loadOpLog = async (limit?: number) => {
97
+ const effectiveLimit = limit ?? opLogLimit()
98
+ const isInitialLoad = opLogEntries().length === 0
99
+ if (isInitialLoad) setOpLogLoading(true)
100
+ try {
101
+ await globalLoading.run("Loading...", async () => {
102
+ const lines = await fetchOpLog(effectiveLimit)
103
+ const entries = parseOpLog(lines)
104
+ setOpLogEntries(entries)
105
+ setOpLogHasMore(entries.length >= effectiveLimit)
106
+ })
107
+ } catch (e) {
108
+ console.error("Failed to load op log:", e)
109
+ } finally {
110
+ if (isInitialLoad) setOpLogLoading(false)
111
+ }
112
+ }
113
+
114
+ const loadMoreOpLog = async () => {
115
+ if (!opLogHasMore() || opLogLoading()) return
116
+ const newLimit = opLogLimit() + 50
117
+ setOpLogLimit(newLimit)
118
+ await loadOpLog(newLimit)
119
+ }
120
+
121
+ onMount(() => {
122
+ loadOpLog()
123
+ })
124
+
125
+ const switchTab = (tabId: string) => {
126
+ const tab = LOG_TABS.find((t) => t.id === tabId)
127
+ if (!tab) return
128
+ setActiveTab(tab.id)
129
+ focus.setActiveContext(tab.context)
130
+ if (tab.id === "oplog") {
131
+ loadOpLog()
132
+ }
133
+ }
134
+
135
+ const runOperation = async (
136
+ text: string,
137
+ op: () => Promise<OperationResult>,
138
+ ) => {
139
+ const result = await globalLoading.run(text, op)
140
+ commandLog.addEntry(result)
141
+ if (result.success) {
142
+ refresh()
143
+ loadOpLog()
144
+ }
145
+ }
146
+
147
+ let scrollRef: ScrollBoxRenderable | undefined
148
+ const [scrollTop, setScrollTop] = createSignal(0)
149
+
150
+ let opLogScrollRef: ScrollBoxRenderable | undefined
151
+ const [opLogScrollTop, setOpLogScrollTop] = createSignal(0)
152
+
153
+ createEffect(() => {
154
+ const index = selectedIndex()
155
+ const commitList = commits()
156
+ if (!scrollRef || commitList.length === 0) return
157
+
158
+ let lineOffset = 0
159
+ const clampedIndex = Math.min(index, commitList.length)
160
+ for (const commit of commitList.slice(0, clampedIndex)) {
161
+ lineOffset += commit.lines.length
162
+ }
163
+
164
+ const margin = 2
165
+ const refAny = scrollRef as unknown as Record<string, unknown>
166
+ const viewportHeight =
167
+ (typeof refAny.height === "number" ? refAny.height : null) ??
168
+ (typeof refAny.rows === "number" ? refAny.rows : null) ??
169
+ 10
170
+ const currentScrollTop = scrollTop()
171
+
172
+ const visibleStart = currentScrollTop
173
+ const visibleEnd = currentScrollTop + viewportHeight - 1
174
+ const safeStart = visibleStart + margin
175
+ const safeEnd = visibleEnd - margin
176
+
177
+ let newScrollTop = currentScrollTop
178
+ if (lineOffset < safeStart) {
179
+ newScrollTop = Math.max(0, lineOffset - margin)
180
+ } else if (lineOffset > safeEnd) {
181
+ newScrollTop = Math.max(0, lineOffset - viewportHeight + margin + 1)
182
+ }
183
+
184
+ if (newScrollTop !== currentScrollTop) {
185
+ scrollRef.scrollTo(newScrollTop)
186
+ setScrollTop(newScrollTop)
187
+ }
188
+ })
189
+
190
+ createEffect(() => {
191
+ const index = opLogSelectedIndex()
192
+ const entries = opLogEntries()
193
+ if (!opLogScrollRef || entries.length === 0) return
194
+
195
+ let lineOffset = 0
196
+ const clampedIndex = Math.min(index, entries.length)
197
+ for (const entry of entries.slice(0, clampedIndex)) {
198
+ lineOffset += entry.lines.length
199
+ }
200
+ const selectedHeight = entries[clampedIndex]?.lines.length ?? 1
201
+ const lineEnd = lineOffset + selectedHeight - 1
202
+
203
+ const margin = 2
204
+ const refAny = opLogScrollRef as unknown as Record<string, unknown>
205
+ const viewportHeight =
206
+ (typeof refAny.height === "number" ? refAny.height : null) ??
207
+ (typeof refAny.rows === "number" ? refAny.rows : null) ??
208
+ 10
209
+ const currentScrollTop = opLogScrollTop()
210
+
211
+ const visibleStart = currentScrollTop
212
+ const visibleEnd = currentScrollTop + viewportHeight - 1
213
+ const safeStart = visibleStart + margin
214
+ const safeEnd = visibleEnd - margin
215
+
216
+ let newScrollTop = currentScrollTop
217
+ if (lineOffset < safeStart) {
218
+ newScrollTop = Math.max(0, lineOffset - margin)
219
+ } else if (lineEnd > safeEnd) {
220
+ newScrollTop = Math.max(0, lineEnd - viewportHeight + margin + 1)
221
+ }
222
+
223
+ if (newScrollTop !== currentScrollTop) {
224
+ opLogScrollRef.scrollTo(newScrollTop)
225
+ setOpLogScrollTop(newScrollTop)
226
+ }
227
+ })
228
+
229
+ let filesScrollRef: ScrollBoxRenderable | undefined
230
+ const [filesScrollTop, setFilesScrollTop] = createSignal(0)
231
+
232
+ createEffect(() => {
233
+ const index = selectedFileIndex()
234
+ if (!filesScrollRef || flatFiles().length === 0) return
235
+
236
+ const margin = 2
237
+ const refAny = filesScrollRef as unknown as Record<string, unknown>
238
+ const viewportHeight =
239
+ (typeof refAny.height === "number" ? refAny.height : null) ??
240
+ (typeof refAny.rows === "number" ? refAny.rows : null) ??
241
+ 10
242
+ const currentScrollTop = filesScrollTop()
243
+
244
+ const visibleStart = currentScrollTop
245
+ const visibleEnd = currentScrollTop + viewportHeight - 1
246
+ const safeStart = visibleStart + margin
247
+ const safeEnd = visibleEnd - margin
248
+
249
+ let newScrollTop = currentScrollTop
250
+ if (index < safeStart) {
251
+ newScrollTop = Math.max(0, index - margin)
252
+ } else if (index > safeEnd) {
253
+ newScrollTop = Math.max(0, index - viewportHeight + margin + 1)
254
+ }
255
+
256
+ if (newScrollTop !== currentScrollTop) {
257
+ filesScrollRef.scrollTo(newScrollTop)
258
+ setFilesScrollTop(newScrollTop)
259
+ }
260
+ })
261
+
262
+ const handleFileEnter = () => {
263
+ const file = flatFiles()[selectedFileIndex()]
264
+ if (file?.node.isDirectory) {
265
+ toggleFolder(file.node.path)
266
+ }
267
+ }
268
+
269
+ const selectPrevOpLog = () => {
270
+ setOpLogSelectedIndex((i) => Math.max(0, i - 1))
271
+ }
272
+
273
+ const selectNextOpLog = () => {
274
+ const entries = opLogEntries()
275
+ const currentIndex = opLogSelectedIndex()
276
+ const newIndex = Math.min(entries.length - 1, currentIndex + 1)
277
+ setOpLogSelectedIndex(newIndex)
278
+
279
+ if (entries.length - newIndex <= 5 && opLogHasMore()) {
280
+ loadMoreOpLog()
281
+ }
282
+ }
283
+
284
+ const selectedOperation = () => opLogEntries()[opLogSelectedIndex()]
285
+
286
+ const openUndoModal = (type: "undo" | "redo") => {
287
+ dialog.open(
288
+ () => (
289
+ <UndoModal
290
+ type={type}
291
+ onConfirm={async () => {
292
+ dialog.close()
293
+ const op = type === "undo" ? jjUndo : jjRedo
294
+ await runOperation(
295
+ type === "undo" ? "Undoing..." : "Redoing...",
296
+ op,
297
+ )
298
+ }}
299
+ onCancel={() => dialog.close()}
300
+ />
301
+ ),
302
+ {
303
+ id: `${type}-modal`,
304
+ hints: [
305
+ { key: "y", label: "confirm" },
306
+ { key: "n", label: "cancel" },
307
+ ],
308
+ },
309
+ )
310
+ }
311
+
312
+ command.register(() => [
313
+ {
314
+ id: "log.oplog.prev",
315
+ title: "up",
316
+ keybind: "nav_up",
317
+ context: "log.oplog",
318
+ type: "navigation",
319
+ panel: "log",
320
+ visibility: "help-only",
321
+ onSelect: selectPrevOpLog,
322
+ },
323
+ {
324
+ id: "log.oplog.next",
325
+ title: "down",
326
+ keybind: "nav_down",
327
+ context: "log.oplog",
328
+ type: "navigation",
329
+ panel: "log",
330
+ visibility: "help-only",
331
+ onSelect: selectNextOpLog,
332
+ },
333
+ {
334
+ id: "log.revisions.next",
335
+ title: "down",
336
+ keybind: "nav_down",
337
+ context: "log.revisions",
338
+ type: "navigation",
339
+ panel: "log",
340
+ visibility: "help-only",
341
+ onSelect: selectNext,
342
+ },
343
+ {
344
+ id: "log.revisions.prev",
345
+ title: "up",
346
+ keybind: "nav_up",
347
+ context: "log.revisions",
348
+ type: "navigation",
349
+ panel: "log",
350
+ visibility: "help-only",
351
+ onSelect: selectPrev,
352
+ },
353
+ {
354
+ id: "log.revisions.view_files",
355
+ title: "view files",
356
+ keybind: "enter",
357
+ context: "log.revisions",
358
+ type: "view",
359
+ panel: "log",
360
+ visibility: "help-only",
361
+ onSelect: () => enterFilesView(),
362
+ },
363
+ {
364
+ id: "log.revisions.new",
365
+ title: "new",
366
+ keybind: "jj_new",
367
+ context: "log.revisions",
368
+ type: "action",
369
+ panel: "log",
370
+ onSelect: () => {
371
+ const commit = selectedCommit()
372
+ if (commit) runOperation("Creating...", () => jjNew(commit.changeId))
373
+ },
374
+ },
375
+ {
376
+ id: "log.revisions.edit",
377
+ title: "edit",
378
+ keybind: "jj_edit",
379
+ context: "log.revisions",
380
+ type: "action",
381
+ panel: "log",
382
+ onSelect: () => {
383
+ const commit = selectedCommit()
384
+ if (commit) runOperation("Editing...", () => jjEdit(commit.changeId))
385
+ },
386
+ },
387
+ {
388
+ id: "log.revisions.squash",
389
+ title: "squash",
390
+ keybind: "jj_squash",
391
+ context: "log.revisions",
392
+ type: "action",
393
+ panel: "log",
394
+ onSelect: async () => {
395
+ const commit = selectedCommit()
396
+ if (!commit) return
397
+ const result = await jjSquash(commit.changeId)
398
+ if (isImmutableError(result)) {
399
+ const confirmed = await dialog.confirm({
400
+ message: "Parent is immutable. Squash anyway?",
401
+ })
402
+ if (confirmed) {
403
+ await runOperation("Squashing...", () =>
404
+ jjSquash(commit.changeId, { ignoreImmutable: true }),
405
+ )
406
+ }
407
+ } else {
408
+ commandLog.addEntry(result)
409
+ if (result.success) {
410
+ refresh()
411
+ loadOpLog()
412
+ }
413
+ }
414
+ },
415
+ },
416
+ {
417
+ id: "log.revisions.describe",
418
+ title: "describe",
419
+ keybind: "jj_describe",
420
+ context: "log.revisions",
421
+ type: "action",
422
+ panel: "log",
423
+ onSelect: async () => {
424
+ const commit = selectedCommit()
425
+ if (!commit) return
426
+
427
+ let ignoreImmutable = false
428
+ if (commit.immutable) {
429
+ const confirmed = await dialog.confirm({
430
+ message: "Commit is immutable. Describe anyway?",
431
+ })
432
+ if (!confirmed) return
433
+ ignoreImmutable = true
434
+ }
435
+
436
+ const desc = await jjShowDescription(commit.changeId)
437
+ dialog.open(
438
+ () => (
439
+ <DescribeModal
440
+ initialSubject={desc.subject}
441
+ initialBody={desc.body}
442
+ onSave={(subject, body) => {
443
+ const message = body ? `${subject}\n\n${body}` : subject
444
+ runOperation("Describing...", () =>
445
+ jjDescribe(commit.changeId, message, { ignoreImmutable }),
446
+ )
447
+ }}
448
+ />
449
+ ),
450
+ {
451
+ id: "describe",
452
+ hints: [
453
+ { key: "tab", label: "switch field" },
454
+ { key: "enter", label: "save" },
455
+ ],
456
+ },
457
+ )
458
+ },
459
+ },
460
+ {
461
+ id: "log.revisions.abandon",
462
+ title: "abandon",
463
+ keybind: "jj_abandon",
464
+ context: "log.revisions",
465
+ type: "action",
466
+ panel: "log",
467
+ onSelect: async () => {
468
+ const commit = selectedCommit()
469
+ if (!commit) return
470
+ const confirmed = await dialog.confirm({
471
+ message: `Abandon change ${commit.changeId.slice(0, 8)}?`,
472
+ })
473
+ if (confirmed) {
474
+ await runOperation("Abandoning...", () => jjAbandon(commit.changeId))
475
+ }
476
+ },
477
+ },
478
+ {
479
+ id: "log.revisions.bookmark",
480
+ title: "create bookmark",
481
+ keybind: "bookmark_set",
482
+ context: "log.revisions",
483
+ type: "action",
484
+ panel: "log",
485
+ onSelect: () => {
486
+ const commit = selectedCommit()
487
+ if (!commit) return
488
+ dialog.open(
489
+ () => (
490
+ <BookmarkNameModal
491
+ title="Create Bookmark"
492
+ commits={commits()}
493
+ defaultRevision={commit.changeId}
494
+ onSave={(name, revision) => {
495
+ runOperation("Creating bookmark...", () =>
496
+ jjBookmarkCreate(name, { revision }),
497
+ )
498
+ }}
499
+ />
500
+ ),
501
+ {
502
+ id: "bookmark-create",
503
+ hints: [
504
+ { key: "tab", label: "switch field" },
505
+ { key: "enter", label: "save" },
506
+ ],
507
+ },
508
+ )
509
+ },
510
+ },
511
+ {
512
+ id: "log.revisions.undo",
513
+ title: "undo",
514
+ keybind: "jj_undo",
515
+ context: "log.revisions",
516
+ type: "action",
517
+ panel: "log",
518
+ onSelect: () => openUndoModal("undo"),
519
+ },
520
+ {
521
+ id: "log.revisions.redo",
522
+ title: "redo",
523
+ keybind: "jj_redo",
524
+ context: "log.revisions",
525
+ type: "action",
526
+ panel: "log",
527
+ onSelect: () => openUndoModal("redo"),
528
+ },
529
+ {
530
+ id: "log.oplog.restore",
531
+ title: "restore",
532
+ keybind: "jj_restore",
533
+ context: "log.oplog",
534
+ type: "action",
535
+ panel: "log",
536
+ onSelect: () => {
537
+ const op = selectedOperation()
538
+ if (!op) return
539
+ dialog.open(
540
+ () => (
541
+ <UndoModal
542
+ type="restore"
543
+ operationLines={op.lines}
544
+ onConfirm={async () => {
545
+ dialog.close()
546
+ await runOperation("Restoring...", () =>
547
+ jjOpRestore(op.operationId),
548
+ )
549
+ }}
550
+ onCancel={() => dialog.close()}
551
+ />
552
+ ),
553
+ {
554
+ id: "restore-modal",
555
+ hints: [
556
+ { key: "y", label: "confirm" },
557
+ { key: "n", label: "cancel" },
558
+ ],
559
+ },
560
+ )
561
+ },
562
+ },
563
+ {
564
+ id: "log.files.next",
565
+ title: "down",
566
+ keybind: "nav_down",
567
+ context: "log.files",
568
+ type: "navigation",
569
+ panel: "log",
570
+ visibility: "help-only",
571
+ onSelect: selectNextFile,
572
+ },
573
+ {
574
+ id: "log.files.prev",
575
+ title: "up",
576
+ keybind: "nav_up",
577
+ context: "log.files",
578
+ type: "navigation",
579
+ panel: "log",
580
+ visibility: "help-only",
581
+ onSelect: selectPrevFile,
582
+ },
583
+ {
584
+ id: "log.files.toggle",
585
+ title: "toggle folder",
586
+ keybind: "enter",
587
+ context: "log.files",
588
+ type: "action",
589
+ panel: "log",
590
+ visibility: "help-only",
591
+ onSelect: handleFileEnter,
592
+ },
593
+ {
594
+ id: "log.files.back",
595
+ title: "back",
596
+ keybind: "escape",
597
+ context: "log.files",
598
+ type: "view",
599
+ panel: "log",
600
+ visibility: "help-only",
601
+ onSelect: exitFilesView,
602
+ },
603
+ {
604
+ id: "log.files.restore",
605
+ title: "restore",
606
+ keybind: "jj_restore",
607
+ context: "log.files",
608
+ type: "action",
609
+ panel: "log",
610
+ onSelect: async () => {
611
+ const file = flatFiles()[selectedFileIndex()]
612
+ if (!file) return
613
+ const node = file.node
614
+ const pathType = node.isDirectory ? "folder" : "file"
615
+ const confirmed = await dialog.confirm({
616
+ message: `Restore ${pathType} "${node.path}"? This will discard changes.`,
617
+ })
618
+ if (confirmed) {
619
+ await runOperation("Restoring...", () => jjRestore([node.path]))
620
+ }
621
+ },
622
+ },
623
+ ])
624
+
625
+ createEffect(() => {
626
+ if (isFocused() && !isFilesView()) {
627
+ const tab = LOG_TABS.find((t) => t.id === activeTab())
628
+ if (tab) focus.setActiveContext(tab.context)
629
+ }
630
+ })
631
+
632
+ const renderLogContent = () => (
633
+ <>
634
+ <Show when={loading() && commits().length === 0}>
635
+ <text>Loading...</text>
636
+ </Show>
637
+ <Show when={error() && commits().length === 0}>
638
+ <text>Error: {error()}</text>
639
+ </Show>
640
+ <Show when={commits().length > 0}>
641
+ <scrollbox
642
+ ref={scrollRef}
643
+ flexGrow={1}
644
+ scrollbarOptions={{ visible: false }}
645
+ >
646
+ <For each={commits()}>
647
+ {(commit, index) => {
648
+ const isSelected = () => index() === selectedIndex()
649
+ const handleClick = createDoubleClickDetector(() => {
650
+ setSelectedIndex(index())
651
+ enterFilesView()
652
+ })
653
+ const handleMouseDown = () => {
654
+ setSelectedIndex(index())
655
+ handleClick()
656
+ }
657
+ return (
658
+ <box onMouseDown={handleMouseDown}>
659
+ <For each={commit.lines}>
660
+ {(line) => (
661
+ <box
662
+ backgroundColor={
663
+ isSelected()
664
+ ? colors().selectionBackground
665
+ : undefined
666
+ }
667
+ overflow="hidden"
668
+ >
669
+ <AnsiText
670
+ content={line}
671
+ bold={commit.isWorkingCopy}
672
+ wrapMode="none"
673
+ />
674
+ </box>
675
+ )}
676
+ </For>
677
+ </box>
678
+ )
679
+ }}
680
+ </For>
681
+ </scrollbox>
682
+ </Show>
683
+ </>
684
+ )
685
+
686
+ const renderOpLogContent = () => (
687
+ <>
688
+ <Show when={opLogLoading() && opLogEntries().length === 0}>
689
+ <text>Loading...</text>
690
+ </Show>
691
+ <Show when={opLogEntries().length > 0}>
692
+ <scrollbox
693
+ ref={opLogScrollRef}
694
+ flexGrow={1}
695
+ scrollbarOptions={{ visible: false }}
696
+ >
697
+ <For each={opLogEntries()}>
698
+ {(entry, index) => {
699
+ const isSelected = () => index() === opLogSelectedIndex()
700
+ return (
701
+ <For each={entry.lines}>
702
+ {(line) => (
703
+ <box
704
+ backgroundColor={
705
+ isSelected() ? colors().selectionBackground : undefined
706
+ }
707
+ overflow="hidden"
708
+ >
709
+ <AnsiText content={line} wrapMode="none" />
710
+ </box>
711
+ )}
712
+ </For>
713
+ )
714
+ }}
715
+ </For>
716
+ </scrollbox>
717
+ </Show>
718
+ </>
719
+ )
720
+
721
+ const renderFilesContent = () => {
722
+ const commit = selectedCommit()
723
+ return (
724
+ <>
725
+ <Show when={filesLoading()}>
726
+ <text fg={colors().textMuted}>Loading files...</text>
727
+ </Show>
728
+ <Show when={filesError()}>
729
+ <text fg={colors().error}>Error: {filesError()}</text>
730
+ </Show>
731
+ <Show when={!filesLoading() && !filesError()}>
732
+ <scrollbox
733
+ ref={filesScrollRef}
734
+ flexGrow={1}
735
+ scrollbarOptions={{ visible: false }}
736
+ >
737
+ <FileTreeList
738
+ files={flatFiles}
739
+ selectedIndex={selectedFileIndex}
740
+ setSelectedIndex={setSelectedFileIndex}
741
+ collapsedPaths={collapsedPaths}
742
+ toggleFolder={toggleFolder}
743
+ />
744
+ </scrollbox>
745
+ </Show>
746
+ </>
747
+ )
748
+ }
749
+
750
+ const filesTitle = () => {
751
+ const commit = selectedCommit()
752
+ return commit ? `Files (${commit.changeId.slice(0, 8)})` : "Files"
753
+ }
754
+
755
+ return (
756
+ <Panel
757
+ title={isFilesView() ? filesTitle() : title()}
758
+ tabs={tabs()}
759
+ activeTab={activeTab()}
760
+ onTabChange={switchTab}
761
+ panelId="log"
762
+ hotkey="1"
763
+ focused={isFocused()}
764
+ >
765
+ <Show when={isFilesView()}>{renderFilesContent()}</Show>
766
+ <Show when={!isFilesView() && activeTab() === "revisions"}>
767
+ {renderLogContent()}
768
+ </Show>
769
+ <Show when={!isFilesView() && activeTab() === "oplog"}>
770
+ {renderOpLogContent()}
771
+ </Show>
772
+ </Panel>
773
+ )
774
+ }