spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

diff: the header and list stay pinned while the hunks scroll beneath them three rows a tick (the pane answers ui.scroll itself), the list keys and ctrl+x b press Buttons that declare the built-in's actions, rows rest grey, toasts are held, and without the fullscreen layout /diff opens the built-in dialog's shape inline, focused, at any widthBrowse files
poteat authored on Sep 13, 2026 parent 6434626 · commit 325256e17bdc
119 changed files+1629 -1160compared with 6434626
Amods/diff/hooks/git/hunk-after.ts
Mmods/diff/hooks/git/index.ts
Mmods/diff/hooks/host/host.ts
Mmods/diff/hooks/limits/sizes/index.ts
Amods/diff/hooks/limits/sizes/max-visible-files.ts
Amods/diff/hooks/limits/sizes/pane/gutter-chrome.ts
Mmods/diff/hooks/limits/sizes/pane/index.ts
Dmods/diff/hooks/limits/sizes/pane/max-dialog-rows.ts
Amods/diff/hooks/limits/sizes/pane/scroll-margin-rows.ts
Amods/diff/hooks/limits/sizes/pane/wheel-rows.ts
Mmods/diff/hooks/names/texts/index.ts
Amods/diff/hooks/names/texts/more-below-text.ts
Amods/diff/hooks/names/texts/panel-toggle/dialog-dismissed-text.ts
Mmods/diff/hooks/names/texts/panel-toggle/index.ts
Mmods/diff/hooks/pane-state/index.ts
Mmods/diff/hooks/pane-state/initial-model/initial-model.ts
Dmods/diff/hooks/pane-state/listed-of.ts
Mmods/diff/hooks/pane-state/mode-label-of/mode-label-of.ts
Mmods/diff/hooks/pane-state/pane-model/pane-model.ts
Dmods/diff/hooks/pane-state/selection-of/index.ts
Dmods/diff/hooks/pane-state/selection-of/selection-of.ts
Mmods/diff/hooks/register.ts
Amods/diff/hooks/views/body/body-scrolled-by.ts
Amods/diff/hooks/views/body/draw-window.tsx
Amods/diff/hooks/views/body/index.ts
Amods/diff/hooks/views/body/list-block-of.tsx
Amods/diff/hooks/views/body/list-scrolled-by.ts
Amods/diff/hooks/views/body/place-at-file.ts
Amods/diff/hooks/views/body/plan/body-layout-of.ts
Amods/diff/hooks/views/body/plan/dock-plan-of.ts
Amods/diff/hooks/views/body/plan/hunk-code-of.tsx
Amods/diff/hooks/views/body/plan/index.ts
Amods/diff/hooks/views/body/plan/segments/index.ts
Amods/diff/hooks/views/body/plan/segments/list-window-of.ts
Amods/diff/hooks/views/body/plan/segments/segment-rows-of.ts
Amods/diff/hooks/views/body/plan/segments/segments-of.ts
Amods/diff/hooks/views/body/types/body-layout.ts
Amods/diff/hooks/views/body/types/dock-plan.ts
Amods/diff/hooks/views/body/types/index.ts
Amods/diff/hooks/views/body/types/list-window.ts
Amods/diff/hooks/views/body/types/segment/hunk-segment/hunk-segment.ts
Amods/diff/hooks/views/body/types/segment/hunk-segment/index.ts
Amods/diff/hooks/views/body/types/segment/index.ts
Amods/diff/hooks/views/body/types/segment/segment.ts
Dmods/diff/hooks/views/current-pane/current-pane.tsx
Dmods/diff/hooks/views/current-pane/dialog-rows-of/dialog-rows-of.tsx
Dmods/diff/hooks/views/current-pane/dialog-rows-of/index.ts
Dmods/diff/hooks/views/current-pane/file-row-of/file-row-of.ts
Dmods/diff/hooks/views/current-pane/file-row-of/index.ts
Dmods/diff/hooks/views/current-pane/index.ts
Dmods/diff/hooks/views/current-pane/list-body-of/index.ts
Dmods/diff/hooks/views/current-pane/list-body-of/list-body-of.ts
Dmods/diff/hooks/views/current-pane/list-body-of/list-place/index.ts
Dmods/diff/hooks/views/current-pane/list-body-of/list-place/list-place.ts
Dmods/diff/hooks/views/current-pane/message-pane-of/foot-rows/foot-rows.ts
Dmods/diff/hooks/views/current-pane/message-pane-of/foot-rows/index.ts
Dmods/diff/hooks/views/current-pane/message-pane-of/index.ts
Dmods/diff/hooks/views/current-pane/message-pane-of/message-pane/index.ts
Dmods/diff/hooks/views/current-pane/message-pane-of/message-pane/message-pane.ts
Dmods/diff/hooks/views/current-pane/message-pane-of/message-pane-of.tsx
Mmods/diff/hooks/views/detail/detail-view.tsx
Mmods/diff/hooks/views/detail/sub-hunks-of/sub-hunks-of.ts
Dmods/diff/hooks/views/detail/types/detail-draw/detail-draw.ts
Dmods/diff/hooks/views/detail/types/detail-draw/index.ts
Mmods/diff/hooks/views/detail/types/index.ts
Amods/diff/hooks/views/detail/types/named-detail/index.ts
Amods/diff/hooks/views/detail/types/named-detail/named-detail.ts
Amods/diff/hooks/views/dialog-pane/dialog-entries-of/dialog-entries-of.ts
Amods/diff/hooks/views/dialog-pane/dialog-entries-of/index.ts
Amods/diff/hooks/views/dialog-pane/dialog-pane.tsx
Amods/diff/hooks/views/dialog-pane/dialog-window-of/dialog-window-of.ts
Amods/diff/hooks/views/dialog-pane/dialog-window-of/index.ts
Amods/diff/hooks/views/dialog-pane/index.ts
Amods/diff/hooks/views/dialog-pane/list-body-of/index.ts
Amods/diff/hooks/views/dialog-pane/list-body-of/list-body-of.ts
Amods/diff/hooks/views/dialog-pane/list-body-of/list-place/index.ts
Amods/diff/hooks/views/dialog-pane/list-body-of/list-place/list-place.ts
Amods/diff/hooks/views/dialog-pane/message-pane-of/foot-rows/foot-rows.ts
Amods/diff/hooks/views/dialog-pane/message-pane-of/foot-rows/index.ts
Amods/diff/hooks/views/dialog-pane/message-pane-of/index.ts
Amods/diff/hooks/views/dialog-pane/message-pane-of/message-pane/index.ts
Amods/diff/hooks/views/dialog-pane/message-pane-of/message-pane/message-pane.ts
Amods/diff/hooks/views/dialog-pane/message-pane-of/message-pane-of.tsx
Mmods/diff/hooks/views/index.ts
Mmods/diff/hooks/views/pane-actions/pane-actions.ts
Mmods/diff/hooks/views/pane-view.tsx
Mmods/diff/hooks/views/prelude-of/prelude-of.tsx
Dmods/diff/hooks/views/sections/body-key-of/body-key-of.ts
Dmods/diff/hooks/views/sections/body-key-of/index.ts
Dmods/diff/hooks/views/sections/controls-view.tsx
Mmods/diff/hooks/views/sections/dialog-file-row/dialog-file-row.tsx
Mmods/diff/hooks/views/sections/file-key-of/file-key-of.ts
Mmods/diff/hooks/views/sections/file-row/file-row.ts
Mmods/diff/hooks/views/sections/index.ts
Mmods/diff/hooks/views/sections/keyed-pressable/keyed-pressable.ts
Amods/diff/hooks/views/sections/legend-row.tsx
Amods/diff/hooks/views/sections/list-edge-button/index.ts
Amods/diff/hooks/views/sections/list-edge-button/list-edge-button.tsx
Mmods/diff/hooks/views/sections/list-row/list-row.tsx
Amods/diff/hooks/views/sections/name-row/index.ts
Amods/diff/hooks/views/sections/name-row/name-row.tsx
Amods/diff/hooks/views/sections/source-picker.tsx
Mmods/diff/hooks/views/sections/toggle-row.tsx
Dmods/diff/hooks/views/selected-detail-of/index.ts
Dmods/diff/hooks/views/selected-detail-of/selected-detail-of.ts
Dmods/diff/hooks/views/sidebar-pane/body-frame/body-frame.ts
Dmods/diff/hooks/views/sidebar-pane/body-frame/index.ts
Dmods/diff/hooks/views/sidebar-pane/drawn-blocks/drawn-blocks.ts
Dmods/diff/hooks/views/sidebar-pane/drawn-blocks/index.ts
Dmods/diff/hooks/views/sidebar-pane/earlier-section-of/earlier-section-of.tsx
Dmods/diff/hooks/views/sidebar-pane/earlier-section-of/index.ts
Dmods/diff/hooks/views/sidebar-pane/file-blocks-of/file-blocks-of.tsx
Dmods/diff/hooks/views/sidebar-pane/file-blocks-of/index.ts
Mmods/diff/hooks/views/sidebar-pane/index.ts
Dmods/diff/hooks/views/sidebar-pane/list-rows-of/index.ts
Dmods/diff/hooks/views/sidebar-pane/list-rows-of/list-rows-of.ts
Mmods/diff/hooks/views/sidebar-pane/sidebar-pane.tsx
Dmods/diff/hooks/views/turn-pane/index.ts
Dmods/diff/hooks/views/turn-pane/turn-pane.tsx
@@ -1,0 +1,20 @@
1+import { countOf } from '../count-of'
2+import type { Hunk } from './types'
3+
4+/**
5+ * The hunk less its first so many lines, its starts moved past them so
6+ * the numbers of the lines kept stay right; whole at 0.
7+ *
8+ * @param hunk the hunk, its lines body lines only
9+ * @param count how many leading lines to drop
10+ * @returns the rest of the hunk
11+ */
12+export function hunkAfter(hunk: Hunk, count: number): Hunk {
13+ const dropped = hunk.lines.slice(0, count)
14+
15+ return {
16+ oldStart: hunk.oldStart + countOf(dropped, line => !line.startsWith('+')),
17+ newStart: hunk.newStart + countOf(dropped, line => !line.startsWith('-')),
18+ lines: hunk.lines.slice(count),
19+ }
20+}
@@ -8,6 +8,7 @@
88 export * from './git-backend-of.js'
99 export * from './git-base-modes'
1010 export * from './git-words'
11+export * from './hunk-after.js'
1112 export * from './hunk-of'
1213 export * from './large-file-hunks'
1314 export * from './parse'
@@ -9,7 +9,6 @@
99 ProcessRunResult,
1010 SessionMessage,
1111 TimerCall,
12- UiScrollResult,
1312 } from 'claude-code'
1413
1514 /**
@@ -96,12 +95,6 @@
9695 closePane: (pane: PaneCloseArgs) => Promise<void>
9796
9897 /**
99- * `$.ui.scroll` to one of the pane's own keyed elements, its top at the
100- * window's top.
101- */
102- scrollTo: (key: string) => Promise<UiScrollResult>
103-
104- /**
10598 * `$.command.register`; rejects while another `/diff` is listed.
10699 */
107100 registerCommand: (spec: CommandSpec) => Promise<unknown>
@@ -4,6 +4,7 @@
44 export * from './max-lines-per-file.js'
55 export * from './max-listed-directories.js'
66 export * from './max-untracked-probes.js'
7+export * from './max-visible-files.js'
78 export * from './pane'
89 export * from './prompt-context-max-chars.js'
910
@@ -1,0 +1,5 @@
1+/**
2+ * The file rows the dialog lists at once (DiffFileList's MAX_VISIBLE_FILES);
3+ * past them its window moves, `more files` rows counting the rest.
4+ */
5+export const MAX_VISIBLE_FILES = 5
@@ -1,0 +1,5 @@
1+/**
2+ * The cells a diff gutter takes besides its digits: the marker and the
3+ * spaces round the number.
4+ */
5+export const GUTTER_CHROME = 3
@@ -1,7 +1,9 @@
11 export * from './body-fetch-concurrency.js'
2-export * from './max-dialog-rows.js'
2+export * from './gutter-chrome.js'
33 export * from './max-summary-rows.js'
44 export * from './pane-top-pad-rows.js'
55 export * from './pre-session-body-cap.js'
6+export * from './scroll-margin-rows.js'
7+export * from './wheel-rows.js'
68
79 export * as default from '.'
Dmods/diff/hooks/limits/sizes/pane/max-dialog-rows.ts+0 -5
@@ -1,5 +1,0 @@
1-/**
2- * How many file rows the inline pane's list shows at once, around the
3- * selected one (the built-in dialog's window).
4- */
5-export const MAX_DIALOG_ROWS = 5
@@ -1,0 +1,5 @@
1+/**
2+ * The rows the docked window draws past its own height, so a line whose
3+ * wrap was counted short still leaves it full; the pane clips the rest.
4+ */
5+export const SCROLL_MARGIN_ROWS = 8
@@ -1,0 +1,5 @@
1+/**
2+ * The rows one wheel tick moves the docked body, as the built-in panel's
3+ * body moves.
4+ */
5+export const WHEEL_ROWS = 3
@@ -1,5 +1,6 @@
11 export * from './builtin-holds-pattern.js'
22 export * from './git-unanswered-text.js'
3+export * from './more-below-text.js'
34 export * from './not-in-repository-text.js'
45 export * from './panel-toggle'
56 export * from './register-failed-text-of.js'
@@ -1,0 +1,5 @@
1+/**
2+ * What follows a count on the docked list's lower edge row: the words and
3+ * the built-in's advertised list key (its macOS spelling).
4+ */
5+export const MORE_BELOW_TEXT = 'more below (opt+\u2193 to scroll)'
@@ -1,0 +1,4 @@
1+/**
2+ * The transcript line the built-in dialog leaves when the person closes it.
3+ */
4+export const DIALOG_DISMISSED_TEXT = 'Diff dialog dismissed'
@@ -1,3 +1,4 @@
1+export * from './dialog-dismissed-text.js'
12 export * from './panel-hidden-text.js'
23 export * from './panel-shown-text.js'
34
@@ -9,14 +9,12 @@
99 export * from './header-totals'
1010 export * from './header-totals-of.js'
1111 export * from './initial-model'
12-export * from './listed-of.js'
1312 export * from './mode-label-of'
1413 export * from './no-bodies'
1514 export * from './pane-model'
1615 export * from './partition'
1716 export * from './partition-of.js'
1817 export * from './picked-turn-of'
19-export * from './selection-of'
2018 export * from './source'
2119 export * from './turn-title-of'
2220 export * from './turn-totals-of'
@@ -23,4 +23,7 @@
2323 todos: Object.freeze({ done: 0, total: 0 }),
2424 armedPath: null,
2525 placement: 'dock',
26+ isFullscreen: null,
27+ dialogView: 'list',
28+ place: Object.freeze({ top: 0, listStart: 0, columns: 0, rows: 0 }),
2629 })
Dmods/diff/hooks/pane-state/listed-of.ts+0 -21
@@ -1,21 +1,0 @@
1-import type Git from '../git'
2-import type { Partition } from './partition'
3-
4-/**
5- * The rows a person can pick from right now: the session's shown rows,
6- * then the pre-session rows while that section is open.
7- *
8- * @param partition the fetch's rows, grouped
9- * @param preSession whether the pre-session section is open
10- * @returns the pickable rows in drawing order
11- */
12-export function listedOf(
13- partition: Partition,
14- preSession: 'shown' | 'hidden',
15-): readonly Git.FileStat[] {
16- const isOpen = preSession === 'shown'
17-
18- return isOpen
19- ? [...partition.shown, ...partition.preSession]
20- : partition.shown
21-}
@@ -36,7 +36,7 @@
3636 return `branch vs ${source.baseBranch}`
3737 }
3838
39- return isSettledBranch ? `vs ${base} (no base branch)` : 'branch'
39+ return isSettledBranch ? `vs ${base} (no base branch)` : 'branch diff'
4040 }
4141 }
4242
@@ -8,9 +8,9 @@
88 /**
99 * Everything one drawing of the pane reads.
1010 *
11- * The last good fetch, what the person picked, the files' bodies read so
12- * far, the transcript's turns and todos, where the surface seated the pane
13- * last, and the pinned backend's words and base modes (git's until pinned).
11+ * The last good fetch, what the person picked, the bodies read so far, the
12+ * turns and todos, how the surface seated it, whether fullscreen (once
13+ * `/diff` said), the dialog's view, and the docked scroll in its last box.
1414 */
1515 export type PaneModel = {
1616 words: Backend.BackendWords
@@ -29,4 +29,7 @@
2929 todos: Todos.TodoProgress
3030 armedPath: string | null
3131 placement: 'dock' | 'inline'
32+ isFullscreen: boolean | null
33+ dialogView: 'list' | 'detail'
34+ place: { top: number; listStart: number; columns: number; rows: number }
3235 }
Dmods/diff/hooks/pane-state/selection-of/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './selection-of.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/pane-state/selection-of/selection-of.ts+0 -16
@@ -1,16 +1,0 @@
1-import type Git from '../../git'
2-
3-/**
4- * The file whose body the pane shows: the one the person picked while it is
5- * still listed, else the first listed one, else none.
6- *
7- * @param listed the rows on screen, in order (session rows, then the
8- * pre-session rows while that section is open)
9- * @param selectedPath the person's pick, or null
10- * @returns the selected row, or null for an empty list
11- */
12-export const selectionOf = (
13- listed: readonly Git.FileStat[],
14- selectedPath: string | null,
15-): Git.FileStat | null =>
16- listed.find(file => file.path === selectedPath) ?? listed[0] ?? null
@@ -343,8 +343,22 @@
343343 engine: Host,
344344 trigger: (typeof Record.SHOWN_TRIGGERS)[number],
345345 ): Promise<void> {
346- model = { ...model, selectedPath: null }
347- await engine.openPane({ id: Names.PANE_ID, title: Names.PANE_TITLE })
346+ const isDialog = model.isFullscreen === false
347+
348+ model = {
349+ ...model,
350+ selectedPath: null,
351+ dialogView: 'list',
352+ place: { ...model.place, top: 0, listStart: 0 },
353+ }
354+
355+ await engine.openPane({
356+ id: Names.PANE_ID,
357+ title: Names.PANE_TITLE,
358+ holdToasts: true,
359+ ...(isDialog ? ({ focus: true, closeOnEscape: true } as const) : {}),
360+ })
361+
348362 isPaneOpen = true
349363
350364 const sessionId = await engine.sessionId().catch(() => null)
@@ -388,6 +402,7 @@
388402
389403 const isEligible =
390404 preference !== false &&
405+ model.isFullscreen !== false &&
391406 columns !== null &&
392407 columns >= floor &&
393408 backend !== null
@@ -408,14 +423,27 @@
408423
409424 const actionsOf = (engine: Host): Views.PaneActions => ({
410425 selectFile: path => {
411- model = { ...model, selectedPath: path }
426+ const isDocked = model.placement === 'dock'
412427
413- if (model.placement === 'dock') {
414- void engine.scrollTo(Views.bodyKeyOf(path)).catch(() => undefined)
428+ model = {
429+ ...model,
430+ selectedPath: path,
431+ dialogView: isDocked ? model.dialogView : 'detail',
432+ place: isDocked ? Views.placeAtFile(model, path) : model.place,
415433 }
416434
417435 redraw(engine)
418436 },
437+ scrollList: delta => {
438+ const place = Views.listScrolledBy(model, delta)
439+ const isDocked = model.placement === 'dock'
440+
441+ model = isDocked
442+ ? { ...model, place }
443+ : { ...model, place, selectedPath: null }
444+
445+ redraw(engine)
446+ },
419447 toggleNoise: () => {
420448 model = { ...model, isNoiseShown: !model.isNoiseShown }
421449 void loadBodies(engine)
@@ -426,10 +454,14 @@
426454 void loadBodies(engine)
427455 redraw(engine)
428456 },
429- chooseBase: value => {
430- const mode = PaneState.baseModeOf(value)
457+ cycleBase: () => {
458+ const { baseModes, requestedMode } = model
431459
432- if (!mode || mode === model.requestedMode) {
460+ const mode =
461+ baseModes[(baseModes.indexOf(requestedMode) + 1) % baseModes.length] ??
462+ requestedMode
463+
464+ if (mode === requestedMode) {
433465 return
434466 }
435467
@@ -459,7 +491,7 @@
459491 ? { kind: 'turn', index }
460492 : { kind: 'current' }
461493
462- model = { ...model, source, selectedPath: null }
494+ model = { ...model, source, selectedPath: null, dialogView: 'list' }
463495 redraw(engine)
464496 },
465497 toggleAsk: path => {
@@ -529,8 +561,6 @@
529561 uiLog: text => $.ui.log(text),
530562 openPane: pane => $.ui.open(pane),
531563 closePane: pane => $.ui.close(pane),
532- scrollTo: key =>
533- $.ui.scroll({ to: { key }, in: Names.PANE_ID, block: 'start' }),
534564 registerCommand: spec => $.command.register(spec),
535565 sessionId: () => $.session.id(),
536566 mark: entry => $.telemetry.mark(entry),
@@ -556,8 +586,20 @@
556586 const { Box, Text, Button, Select, Code } = await $.ui.resolve(e)
557587
558588 columns = e.viewport?.columns ?? columns
559- model = { ...model, placement: e.props.placement }
560589
590+ model = {
591+ ...model,
592+ placement: e.props.placement,
593+ place: {
594+ ...model.place,
595+ columns: Math.max(
596+ 1,
597+ e.props.bodyColumns - Limits.PANE_RIGHT_PAD_COLUMNS,
598+ ),
599+ rows: e.props.scroll.bodyRows,
600+ },
601+ }
602+
561603 return Views.paneView(
562604 {
563605 ui: { Box, Text, Button, Select, Code },
@@ -585,14 +627,27 @@
585627 }
586628 }
587629
588- const toggle = PaneToggle.paneToggleOf({ isOpen: isPaneOpen, columns })
630+ const { isFullscreen } = e.presentation
589631
632+ columns = e.presentation.columns
633+ model = { ...model, isFullscreen }
634+
635+ const toggle = PaneToggle.paneToggleOf({
636+ isOpen: isPaneOpen,
637+ columns: isFullscreen ? columns : null,
638+ })
639+
590640 if (toggle === 'too-narrow') {
591641 return { text: Names.RESIZE_TERMINAL_TEXT }
592642 }
593643
594644 const isOpening = toggle === 'open'
595645 await (isOpening ? openPane(host, 'manual') : closePane(host))
646+
647+ if (!isFullscreen) {
648+ return isOpening ? {} : { text: Names.DIALOG_DISMISSED_TEXT }
649+ }
650+
596651 markTabSwitch(host, isOpening ? 'diff' : 'convo')
597652 await host.storeSet(Names.STORE_OPEN_KEY, isOpening).catch(() => undefined)
598653
@@ -602,6 +657,28 @@
602657 })
603658
604659 on('ui.close', { id: Names.PANE_ID }, async ($, e, next) => {
660+ const isBack =
661+ e.origin.kind === 'person' &&
662+ model.placement === 'inline' &&
663+ model.dialogView === 'detail'
664+
665+ if (isBack && host) {
666+ model = { ...model, dialogView: 'list' }
667+ redraw(host)
668+
669+ void host
670+ .openPane({
671+ id: Names.PANE_ID,
672+ title: Names.PANE_TITLE,
673+ focus: true,
674+ closeOnEscape: true,
675+ holdToasts: true,
676+ })
677+ .catch(() => undefined)
678+
679+ return { deny: 'back to the file list' }
680+ }
681+
605682 const result = await next(e)
606683 const isClosed = result.deny === undefined
607684 const isPersons = isClosed && e.origin.kind === 'person'
@@ -610,7 +687,13 @@
610687 isPaneOpen = false
611688 }
612689
613- if (isPersons && host) {
690+ const isDialog = model.isFullscreen === false
691+
692+ if (isPersons && host && isDialog) {
693+ host.uiLog(Names.DIALOG_DISMISSED_TEXT)
694+ }
695+
696+ if (isPersons && host && !isDialog) {
614697 markTabSwitch(host, 'convo')
615698 await host.storeSet(Names.STORE_OPEN_KEY, false).catch(() => undefined)
616699 }
@@ -618,6 +701,19 @@
618701 return result
619702 })
620703
704+ on('ui.scroll', { requestId: Names.PANE_ID }, ($, e, next) => {
705+ const isOwnBody = e.origin.kind === 'person' && model.placement === 'dock'
706+
707+ if (!isOwnBody || !host) {
708+ return next(e)
709+ }
710+
711+ model = { ...model, place: Views.bodyScrolledBy(model, e) }
712+ host.invalidate()
713+
714+ return {}
715+ })
716+
621717 on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => {
622718 const result = await next(e)
623719
@@ -1,0 +1,47 @@
1+import type { UiScrollInput } from 'claude-code'
2+
3+import Limits from '../../limits'
4+import type PaneState from '../../pane-state'
5+import Plan from './plan'
6+
7+/**
8+ * The docked pane's place after one of the person's scrolls: a wheel tick
9+ * or arrow three rows, a page key the window's rows, Home and End the ends.
10+ *
11+ * Told apart by the rows the move asks for against the pane's body and
12+ * tree, as the engine sizes each; clamped to the body's extent.
13+ *
14+ * @param model the pane's state
15+ * @param scroll the move: its signed rows, the body's rows, the tree's rows
16+ * @returns the place
17+ */
18+export function bodyScrolledBy(
19+ model: PaneState.PaneModel,
20+ scroll: Pick<UiScrollInput, 'by' | 'bodyRows' | 'contentRows'>,
21+): PaneState.PaneModel['place'] {
22+ const { visibleRows, maxTop } = Plan.bodyLayoutOf(
23+ model,
24+ Plan.dockPlanOf(model),
25+ )
26+
27+ const size = Math.abs(scroll.by)
28+
29+ const isEnd =
30+ size >= scroll.contentRows && scroll.contentRows > scroll.bodyRows
31+
32+ const isPage = !isEnd && size >= scroll.bodyRows
33+
34+ const step = isEnd
35+ ? maxTop
36+ : isPage
37+ ? Math.max(1, visibleRows)
38+ : size * Limits.WHEEL_ROWS
39+
40+ return {
41+ ...model.place,
42+ top: Math.max(
43+ 0,
44+ Math.min(maxTop, model.place.top + Math.sign(scroll.by) * step),
45+ ),
46+ }
47+}
@@ -1,0 +1,85 @@
1+/* @jsxRuntime classic */
2+/* @jsx h */
3+/* @jsxFrag Fragment */
4+import type { RenderElement } from 'claude-code'
5+
6+import Limits from '../../limits'
7+import type { Kit } from '../kit'
8+import Sections from '../sections'
9+import Plan from './plan'
10+import type { BodyLayout, Segment } from './types'
11+
12+/**
13+ * The rows of the docked body its window shows from a row down: each
14+ * segment drawn from the row the window cuts it at, until the window fills.
15+ *
16+ * A hunk cut by the window's top draws from the line holding that row on;
17+ * rows past its foot are the pane's to clip, so a few more are drawn than
18+ * fit (SCROLL_MARGIN_ROWS) lest a miscounted wrap leave it short.
19+ *
20+ * @param kit the elements, the handlers, the width
21+ * @param layout the body laid out
22+ * @param top the body row wanted at the window's top; clamped to the body
23+ * @returns the elements, top to bottom
24+ */
25+export function drawWindow(
26+ kit: Kit,
27+ layout: BodyLayout,
28+ top: number,
29+): RenderElement[] {
30+ const { Box, Text } = kit.ui
31+ const drawn: RenderElement[] = []
32+ const wanted = layout.visibleRows + Limits.SCROLL_MARGIN_ROWS
33+ const at = Math.max(0, Math.min(top, layout.maxTop))
34+ const onPress = kit.actions.togglePreSession
35+
36+ let start = 0
37+ let filled = 0
38+
39+ function elementOf(segment: Segment, skip: number): RenderElement {
40+ switch (segment.kind) {
41+ case 'rule':
42+ return Sections.divider(kit)
43+ case 'blank':
44+ return <Box height={1} />
45+ case 'name':
46+ return Sections.nameRow(kit, segment.name)
47+ case 'hunk':
48+ return Plan.hunkCodeOf(kit, segment, skip)
49+ case 'note':
50+ return (
51+ <Text dimColor italic wrap="wrap">
52+ {segment.text}
53+ </Text>
54+ )
55+ case 'footer':
56+ return (
57+ <Text dimColor italic>
58+ … diff truncated (exceeded 400 line limit)
59+ </Text>
60+ )
61+ case 'text':
62+ return Sections.dimNote(kit, segment.text)
63+ case 'toggle':
64+ return Sections.toggleRow(kit, 'presession', { ...segment, onPress })
65+ case 'legend':
66+ return Sections.legendRow(kit, segment.entry)
67+ }
68+ }
69+
70+ for (const segment of layout.segments) {
71+ const rows = Plan.segmentRowsOf(segment)
72+ const isShown = start + rows > at && filled < wanted
73+
74+ if (isShown) {
75+ const skip = Math.max(0, at - start)
76+
77+ drawn.push(elementOf(segment, skip))
78+ filled += rows - skip
79+ }
80+
81+ start += rows
82+ }
83+
84+ return drawn
85+}
@@ -1,0 +1,9 @@
1+export * from './body-scrolled-by.js'
2+export * from './draw-window.jsx'
3+export * from './list-block-of.jsx'
4+export * from './list-scrolled-by.js'
5+export * from './place-at-file.js'
6+export * from './plan'
7+export * from './types'
8+
9+export * as default from '.'
@@ -1,0 +1,89 @@
1+/* @jsxRuntime classic */
2+/* @jsx h */
3+/* @jsxFrag Fragment */
4+import type { RenderElement } from 'claude-code'
5+
6+import { keptOf } from '../../kept-of'
7+import Names from '../../names'
8+import type PaneState from '../../pane-state'
9+import type Entries from '../entries'
10+import type { Kit } from '../kit'
11+import Layout from '../layout'
12+import Sections from '../sections'
13+import Plan from './plan'
14+import type { DockPlan } from './types'
15+
16+/**
17+ * The docked list block (ReplDiffSidebar's summary rows): the window's rows
18+ * between its edge rows, the untracked note, the noise toggle.
19+ *
20+ * The edge rows count the files out of the window and take the built-in's
21+ * list keys; files past the row cap join the lower one. Empty with no row
22+ * and no noise to toggle.
23+ *
24+ * @param kit the elements, the handlers, the width
25+ * @param model the pane's state
26+ * @param plan the docked pane's decisions over that state
27+ * @returns the block's rows
28+ */
29+export function listBlockOf(
30+ kit: Kit,
31+ model: PaneState.PaneModel,
32+ plan: DockPlan,
33+): RenderElement[] {
34+ const { shown, above, below } = Plan.listWindowOf(model, plan)
35+ const { noiseCount } = plan.partition
36+ const { notShown } = plan.totals
37+ const hasNoise = !plan.turn && noiseCount > 0
38+ const hasMoreAbove = above > 0
39+ const hasMoreBelow = below > 0
40+ const hasNotShown = notShown > 0
41+ const noiseFace = model.isNoiseShown ? 'hide' : 'show'
42+
43+ const lower = keptOf([
44+ hasMoreBelow ? `${below} ${Names.MORE_BELOW_TEXT}` : null,
45+ hasNotShown ? `${notShown} not shown` : null,
46+ ]).join(' · ')
47+
48+ const rowOf = (entry: Entries.BodyEntry) =>
49+ Sections.fileRow(
50+ kit,
51+ {
52+ key: Sections.fileKeyOf(entry),
53+ path: entry.path,
54+ displayPath: entry.displayPath,
55+ added: entry.added,
56+ removed: entry.removed,
57+ note: null,
58+ isSelected: false,
59+ },
60+ () => kit.actions.selectFile(entry.path),
61+ )
62+
63+ const upperEdge = hasMoreAbove
64+ ? Sections.listEdgeButton(kit, 'list-up', `↑ ${above} more above`)
65+ : null
66+
67+ const lowerEdge = hasMoreBelow
68+ ? Sections.listEdgeButton(kit, 'list-down', `↓ ${lower}`)
69+ : hasNotShown
70+ ? Sections.dimNote(kit, `… ${lower}`)
71+ : null
72+
73+ const noiseToggle = hasNoise
74+ ? Sections.toggleRow(kit, 'noise', {
75+ label: `${Layout.plural(noiseCount, 'test')}/generated (${noiseFace})`,
76+ onPress: kit.actions.toggleNoise,
77+ })
78+ : null
79+
80+ return Sections.present([
81+ upperEdge,
82+ ...shown.map(rowOf),
83+ lowerEdge,
84+ plan.isUntrackedNoted
85+ ? Sections.dimNote(kit, Names.untrackedWithheldTextOf(model))
86+ : null,
87+ noiseToggle,
88+ ])
89+}
@@ -1,0 +1,33 @@
1+import Limits from '../../limits'
2+import type PaneState from '../../pane-state'
3+import DialogPane from '../dialog-pane'
4+import Plan from './plan'
5+
6+/**
7+ * The pane's place with its list window moved by so many files, clamped:
8+ * docked, ReplDiffSidebar's scrollSummary; inline, the dialog's window.
9+ *
10+ * @param model the pane's state
11+ * @param delta the files to move by, negative toward the top
12+ * @returns the place
13+ */
14+export function listScrolledBy(
15+ model: PaneState.PaneModel,
16+ delta: number,
17+): PaneState.PaneModel['place'] {
18+ const isDocked = model.placement === 'dock'
19+ const paths = DialogPane.dialogEntriesOf(model).map(entry => entry.path)
20+
21+ const last = isDocked
22+ ? Plan.dockPlanOf(model).rows.length - Limits.MAX_SUMMARY_ROWS
23+ : paths.length - Limits.MAX_VISIBLE_FILES
24+
25+ const start = isDocked
26+ ? Math.min(model.place.listStart, Math.max(0, last))
27+ : DialogPane.dialogWindowOf(model, paths)
28+
29+ return {
30+ ...model.place,
31+ listStart: Math.max(0, Math.min(Math.max(0, last), start + delta)),
32+ }
33+}
@@ -1,0 +1,20 @@
1+import type PaneState from '../../pane-state'
2+import Plan from './plan'
3+
4+/**
5+ * The docked pane's place with the body scrolled so this file's block
6+ * heads the window (the built-in's jump to file); unchanged for no such file.
7+ *
8+ * @param model the pane's state
9+ * @param path the file's path
10+ * @returns the place
11+ */
12+export const placeAtFile = (
13+ model: PaneState.PaneModel,
14+ path: string,
15+): PaneState.PaneModel['place'] => ({
16+ ...model.place,
17+ top:
18+ Plan.bodyLayoutOf(model, Plan.dockPlanOf(model)).tops.get(path) ??
19+ model.place.top,
20+})
@@ -1,0 +1,64 @@
1+import Limits from '../../../limits'
2+import type PaneState from '../../../pane-state'
3+import type { BodyLayout, DockPlan } from '../types'
4+import Segments from './segments'
5+
6+/**
7+ * The docked pane's scrolling body laid out under its pinned rows: the
8+ * segments, their rows, the window's rows, where each file's block starts.
9+ *
10+ * The pinned rows are counted as sidebarPane draws them (header, base or
11+ * turn line, todo bar, list block); read from the box last drawn (`place`),
12+ * so before any draw the window shows nothing and the top stays 0.
13+ *
14+ * @param model the pane's state
15+ * @param plan the docked pane's decisions over that state
16+ * @returns the layout
17+ */
18+export function bodyLayoutOf(
19+ model: PaneState.PaneModel,
20+ plan: DockPlan,
21+): BodyLayout {
22+ const segments = Segments.segmentsOf(model, plan)
23+ const window = Segments.listWindowOf(model, plan)
24+ const hasNoise = !plan.turn && plan.partition.noiseCount > 0
25+ const isListed = plan.message.length === 0
26+ const hasList = isListed && (plan.rows.length > 0 || hasNoise)
27+
28+ const listRows = hasList
29+ ? window.shown.length +
30+ Number(window.above > 0) +
31+ Number(window.below > 0 || plan.totals.notShown > 0) +
32+ Number(plan.isUntrackedNoted) +
33+ Number(hasNoise) +
34+ 2
35+ : Number(isListed)
36+
37+ const headRows =
38+ 1 + Number(plan.line !== null) + (model.todos.total > 0 ? 2 : 0)
39+
40+ const tops = new Map<string, number>()
41+
42+ const visibleRows = Math.max(
43+ 0,
44+ model.place.rows - Limits.PANE_TOP_PAD_ROWS - headRows - listRows,
45+ )
46+
47+ let extent = 0
48+
49+ for (const segment of segments) {
50+ if (segment.kind === 'rule' && segment.path !== null) {
51+ tops.set(segment.path, extent)
52+ }
53+
54+ extent += Segments.segmentRowsOf(segment)
55+ }
56+
57+ return {
58+ segments,
59+ extent,
60+ visibleRows,
61+ maxTop: Math.max(0, extent - visibleRows),
62+ tops,
63+ }
64+}
@@ -1,0 +1,80 @@
1+import PaneState from '../../../pane-state'
2+import DialogPane from '../../dialog-pane'
3+import Entries from '../../entries'
4+import Layout from '../../layout'
5+import type { DockPlan } from '../types'
6+
7+/**
8+ * The docked pane's decisions over a model (ReplDiffSidebarBody's): which
9+ * rows list, what the header counts, what stands in for an empty list.
10+ *
11+ * Over a picked turn the rows are the turn's files and nothing is empty;
12+ * else the fetch's session rows, its pre-session rows apart. Under the
13+ * header: the base line outside settled session mode, or the unborn note.
14+ *
15+ * @param model the pane's state
16+ * @returns the plan
17+ */
18+export function dockPlanOf(model: PaneState.PaneModel): DockPlan {
19+ const { data, requestedMode } = model
20+ const turn = PaneState.pickedTurnOf(model)
21+ const noise = model.isNoiseShown ? 'shown' : 'hidden'
22+ const partition = PaneState.partitionOf(data?.files ?? [], noise)
23+ const face = model.isPreSessionShown ? 'hide' : 'show'
24+
25+ const totals = turn
26+ ? PaneState.turnTotalsOf(turn)
27+ : data
28+ ? PaneState.headerTotalsOf(data, partition)
29+ : PaneState.ZERO_TOTALS
30+
31+ const empty = turn ? null : PaneState.emptyStateOf(model, totals.filesCount)
32+
33+ const listed = turn
34+ ? []
35+ : DialogPane.listBodyOf({ data, partition, totals, empty })
36+
37+ const notes = listed.flatMap(line => (typeof line === 'string' ? [line] : []))
38+ const hasRows = turn ? turn.files.length > 0 : notes.length < listed.length
39+
40+ const entryOf = (file: Parameters<typeof Entries.bodyEntryOf>[0]) =>
41+ Entries.bodyEntryOf(file, model)
42+
43+ const rows = turn
44+ ? turn.files.map(Entries.turnEntryOf)
45+ : hasRows
46+ ? partition.shown.map(entryOf)
47+ : []
48+
49+ const earlier = turn ? [] : partition.preSession.map(entryOf)
50+ const hasEarlier = earlier.length > 0
51+
52+ const isBaseShown =
53+ data !== null &&
54+ !data.isUnborn &&
55+ (requestedMode !== 'session' || data.mode !== 'session')
56+
57+ const line = turn
58+ ? `Turn ${turn.index} "${turn.preview}"`
59+ : isBaseShown
60+ ? PaneState.modeLabelOf(requestedMode, model)
61+ : PaneState.unbornNoteOf(model, totals.filesCount)
62+
63+ return {
64+ turn,
65+ partition,
66+ totals,
67+ empty,
68+ line: line === null ? null : Layout.sanitizeName(line),
69+ rows,
70+ earlier,
71+ earlierLabel: hasEarlier
72+ ? `+${Layout.plural(earlier.length, 'file')} edited before this ` +
73+ `session (${face})`
74+ : null,
75+ notes,
76+ message: empty ? [empty.headline, ...notes] : hasRows ? [] : notes,
77+ isUntrackedNoted:
78+ !turn && data?.isUntrackedWithheld === true && empty === null,
79+ }
80+}
@@ -1,0 +1,47 @@
1+/* @jsxRuntime classic */
2+/* @jsx h */
3+/* @jsxFrag Fragment */
4+import type { RenderElement } from 'claude-code'
5+
6+import Git from '../../../git'
7+import Detail from '../../detail'
8+import type { Kit } from '../../kit'
9+import Layout from '../../layout'
10+import type { HunkSegment } from '../types'
11+
12+/**
13+ * One hunk of the docked body as the engine's diff `Code` block, drawn from
14+ * the line holding a wrapped row of it on (hunkAfter); whole at row 0.
15+ *
16+ * @param kit the elements
17+ * @param segment the hunk, whose file it is, the rows its lines take
18+ * @param skip the wrapped rows the window's top cuts from it
19+ * @returns the block
20+ */
21+export function hunkCodeOf(
22+ kit: Kit,
23+ segment: HunkSegment,
24+ skip: number,
25+): RenderElement {
26+ const { Code } = kit.ui
27+
28+ let passed = 0
29+ let first = 0
30+
31+ for (const rows of segment.lineRows) {
32+ if (passed + rows > skip) {
33+ break
34+ }
35+
36+ passed += rows
37+ first += 1
38+ }
39+
40+ return (
41+ <Code
42+ source={Detail.hunkSourceOf(Git.hunkAfter(segment.hunk, first))}
43+ format="diff"
44+ path={Layout.sanitizeName(segment.path).slice(-Detail.MAX_CODE_CHARS)}
45+ />
46+ )
47+}
@@ -1,0 +1,6 @@
1+export * from './body-layout-of.js'
2+export * from './dock-plan-of.js'
3+export * from './hunk-code-of.jsx'
4+export * from './segments'
5+
6+export * as default from '.'
@@ -1,0 +1,5 @@
1+export * from './list-window-of.js'
2+export * from './segment-rows-of.js'
3+export * from './segments-of.js'
4+
5+export * as default from '.'
@@ -1,0 +1,29 @@
1+import Limits from '../../../../limits'
2+import type PaneState from '../../../../pane-state'
3+import type { DockPlan, ListWindow } from '../../types'
4+
5+/**
6+ * The docked list's MAX_SUMMARY_ROWS-row window starting at the file the
7+ * person scrolled it to, clamped so a shrinking list pulls it back.
8+ *
9+ * @param model where the person put the list window
10+ * @param plan the docked pane's rows
11+ * @returns the window
12+ */
13+export function listWindowOf(
14+ model: Pick<PaneState.PaneModel, 'place'>,
15+ plan: Pick<DockPlan, 'rows'>,
16+): ListWindow {
17+ const first = Math.max(
18+ 0,
19+ Math.min(model.place.listStart, plan.rows.length - Limits.MAX_SUMMARY_ROWS),
20+ )
21+
22+ const shown = plan.rows.slice(first, first + Limits.MAX_SUMMARY_ROWS)
23+
24+ return {
25+ shown,
26+ above: first,
27+ below: plan.rows.length - first - shown.length,
28+ }
29+}
@@ -1,0 +1,11 @@
1+import type { Segment } from '../../types'
2+
3+/**
4+ * The rows a segment takes: a hunk's as counted when it was laid out, one
5+ * for anything else.
6+ *
7+ * @param segment the segment
8+ * @returns the rows
9+ */
10+export const segmentRowsOf = (segment: Segment): number =>
11+ segment.kind === 'hunk' ? segment.rows : 1
@@ -1,0 +1,124 @@
1+import { countOf } from '../../../../count-of'
2+import type Git from '../../../../git'
3+import { keptOf } from '../../../../kept-of'
4+import Limits from '../../../../limits'
5+import type PaneState from '../../../../pane-state'
6+import Detail from '../../../detail'
7+import Entries from '../../../entries'
8+import Layout from '../../../layout'
9+import type { DockPlan, Segment } from '../../types'
10+
11+/**
12+ * The docked pane's scrolling body as segments (ReplDiffSidebar's scroll
13+ * box): each listed file's block, a blank between, the pre-session section.
14+ *
15+ * Under an empty state whose pre-session section is open, the empty lines
16+ * head the body instead. A hunk carries the rows its lines take wrapped
17+ * past the engine's gutter at the body's width; pre-session bodies stack.
18+ *
19+ * @param model the pane's state: which file is armed, which section is open
20+ * @param plan the docked pane's decisions over that state
21+ * @returns the segments, top to bottom
22+ */
23+export function segmentsOf(
24+ model: PaneState.PaneModel,
25+ plan: DockPlan,
26+): Segment[] {
27+ const { earlier } = plan
28+ const { columns } = model.place
29+ const isEmptied = plan.message.length > 0
30+ const isOpen = model.isPreSessionShown && earlier.length > 0
31+ const isCapped = earlier.length > Limits.PRE_SESSION_BODY_CAP
32+
33+ const gutterOf = (hunk: Git.Hunk) =>
34+ String(
35+ Math.max(
36+ 1,
37+ hunk.oldStart + countOf(hunk.lines, line => !line.startsWith('+')) - 1,
38+ hunk.newStart + countOf(hunk.lines, line => !line.startsWith('-')) - 1,
39+ ),
40+ ).length + Limits.GUTTER_CHROME
41+
42+ function hunkOf(entry: Entries.BodyEntry, hunk: Git.Hunk): Segment {
43+ const room = Math.max(1, columns - gutterOf(hunk))
44+
45+ const lineRows = hunk.lines.map(line =>
46+ Math.max(
47+ 1,
48+ Math.ceil(
49+ Layout.cellWidth(
50+ Layout.sanitizeLine(
51+ line.slice(1).replaceAll('\t', ' '.repeat(Layout.TAB_WIDTH)),
52+ ),
53+ ) / room,
54+ ),
55+ ),
56+ )
57+
58+ return {
59+ kind: 'hunk',
60+ path: entry.path,
61+ hunk,
62+ lineRows,
63+ rows: lineRows.reduce((sum, rows) => sum + rows, 0),
64+ }
65+ }
66+
67+ function fileOf(entry: Entries.BodyEntry): Segment[] {
68+ const detail = Entries.detailModelOf(entry, model)
69+ const notes = Detail.placeholderOf(detail)
70+ const isTruncated = entry.body?.isTruncated === true
71+
72+ const body: Segment[] = notes
73+ ? notes.map(text => ({ kind: 'note', text }))
74+ : (entry.body?.hunks ?? []).map(hunk => hunkOf(entry, hunk))
75+
76+ return [
77+ { kind: 'rule', path: entry.path },
78+ {
79+ kind: 'name',
80+ name: { ...detail, isTruncated, isAskable: notes === null },
81+ },
82+ { kind: 'rule', path: null },
83+ ...body,
84+ ...(isTruncated ? [{ kind: 'footer' } as const] : []),
85+ ]
86+ }
87+
88+ const spacedFileOf = (entry: Entries.BodyEntry): Segment[] => [
89+ ...fileOf(entry),
90+ { kind: 'blank' },
91+ ]
92+
93+ const messageOf = (): Segment[] => [
94+ { kind: 'blank' },
95+ ...plan.message.map(text => ({
96+ kind: 'text' as const,
97+ text: Layout.sanitizeName(text),
98+ })),
99+ { kind: 'blank' },
100+ ]
101+
102+ const hiddenNote: Segment = {
103+ kind: 'text',
104+ text: `diffs hidden above ${Limits.PRE_SESSION_BODY_CAP} files`,
105+ }
106+
107+ const bodiesOf = (): Segment[] =>
108+ isCapped ? [hiddenNote] : earlier.flatMap(fileOf)
109+
110+ const openedOf = (): Segment[] => [
111+ { kind: 'blank' },
112+ ...earlier.map(entry => ({ kind: 'legend' as const, entry })),
113+ ...bodiesOf(),
114+ ]
115+
116+ return [
117+ ...(isEmptied ? messageOf() : plan.rows.flatMap(spacedFileOf)),
118+ ...keptOf([plan.earlierLabel]).map(label => ({
119+ kind: 'toggle' as const,
120+ label,
121+ })),
122+ ...(isOpen ? openedOf() : []),
123+ ]
124+}
@@ -1,0 +1,13 @@
1+import type { Segment } from './segment'
2+
3+/**
4+ * The docked pane's scrolling body laid out: its segments, their rows in
5+ * all, the window's rows, its last top, the row each file's block starts.
6+ */
7+export type BodyLayout = {
8+ segments: readonly Segment[]
9+ extent: number
10+ visibleRows: number
11+ maxTop: number
12+ tops: ReadonlyMap<string, number>
13+}
@@ -1,0 +1,25 @@
1+import type PaneState from '../../../pane-state'
2+import type Turns from '../../../turns'
3+import type Entries from '../../entries'
4+
5+/**
6+ * What the docked pane decided before drawing: the turn or fetch it shows,
7+ * its rows and counts, the empty state, the lines round the list.
8+ *
9+ * `message` holds the lines that stand in for the list (an empty state, a
10+ * too-many or only-noise state); `line` the turn's preview, base line or
11+ * no-commits note, printable; `earlierLabel` the pre-session grey line.
12+ */
13+export type DockPlan = {
14+ turn: Turns.TurnDiff | undefined
15+ partition: PaneState.Partition
16+ totals: PaneState.HeaderTotals
17+ empty: PaneState.EmptyState | null
18+ line: string | null
19+ rows: readonly Entries.BodyEntry[]
20+ earlier: readonly Entries.BodyEntry[]
21+ earlierLabel: string | null
22+ notes: readonly string[]
23+ message: readonly string[]
24+ isUntrackedNoted: boolean
25+}
@@ -1,0 +1,6 @@
1+export type * from './body-layout.js'
2+export type * from './dock-plan.js'
3+export type * from './list-window.js'
4+export * from './segment'
5+
6+export * as default from '.'
@@ -1,0 +1,11 @@
1+import type Entries from '../../entries'
2+
3+/**
4+ * The docked list's window over its files: the rows in view, and how many
5+ * files lie above and below it.
6+ */
7+export type ListWindow = {
8+ shown: readonly Entries.BodyEntry[]
9+ above: number
10+ below: number
11+}
@@ -1,0 +1,13 @@
1+import type Git from '../../../../../git'
2+
3+/**
4+ * One hunk of a file in the docked body: whose it is, the hunk, and the
5+ * rows each of its lines takes wrapped at the body's width, summed too.
6+ */
7+export type HunkSegment = {
8+ kind: 'hunk'
9+ path: string
10+ hunk: Git.Hunk
11+ lineRows: readonly number[]
12+ rows: number
13+}
@@ -1,0 +1,3 @@
1+export type * from './hunk-segment.js'
2+
3+export * as default from '.'
@@ -1,0 +1,4 @@
1+export * from './hunk-segment'
2+export type * from './segment.js'
3+
4+export * as default from '.'
@@ -1,0 +1,22 @@
1+import type Detail from '../../../detail'
2+import type Entries from '../../../entries'
3+import type { HunkSegment } from './hunk-segment'
4+
5+/**
6+ * One piece of the docked pane's scrolling body: a hunk some rows tall
7+ * (HunkSegment), or one row of another kind, top to bottom.
8+ *
9+ * A file is a `rule` (its top, what a row's press scrolls to), its `name`,
10+ * a `rule`, then hunks or `note`s and a `footer` when cut; `text` is an
11+ * empty state's line, `toggle` and `legend` the pre-session section's.
12+ */
13+export type Segment =
14+ | HunkSegment
15+ | { kind: 'rule'; path: string | null }
16+ | { kind: 'blank' }
17+ | { kind: 'name'; name: Detail.NamedDetail }
18+ | { kind: 'note'; text: string }
19+ | { kind: 'footer' }
20+ | { kind: 'text'; text: string }
21+ | { kind: 'toggle'; label: string }
22+ | { kind: 'legend'; entry: Entries.BodyEntry }
Dmods/diff/hooks/views/current-pane/current-pane.tsx+0 -154
@@ -1,154 +1,0 @@
1-/* @jsxRuntime classic */
2-/* @jsx h */
3-/* @jsxFrag Fragment */
4-import type { RenderElement } from 'claude-code'
5-
6-import type Git from '../../git'
7-import Names from '../../names'
8-import PaneState from '../../pane-state'
9-import Entries from '../entries'
10-import type { Kit } from '../kit'
11-import Layout from '../layout'
12-import { preludeOf } from '../prelude-of'
13-import Sections from '../sections'
14-import { selectedDetailOf } from '../selected-detail-of'
15-import { dialogRowsOf } from './dialog-rows-of'
16-import { listBodyOf } from './list-body-of'
17-import { messagePaneOf } from './message-pane-of'
18-
19-/**
20- * The inline pane over the repository's diff now (DiffDialog): header,
21- * pickers, todo bar, the rows round the selected file, that file's detail.
22- *
23- * The toggle sits above the rows; the elision count, withheld-untracked
24- * note and pre-session line below. With nothing to list, messagePaneOf
25- * draws the body.
26- *
27- * @param kit the elements, the handlers, the width, the rows
28- * @param model the pane's state
29- * @returns the pane's tree
30- */
31-export function currentPane(
32- kit: Kit,
33- model: PaneState.PaneModel,
34-): RenderElement {
35- const { Box } = kit.ui
36- const { data } = model
37- const prelude = preludeOf(kit, model)
38-
39- if (prelude) {
40- return prelude
41- }
42-
43- const noise = model.isNoiseShown ? 'shown' : 'hidden'
44- const preSession = model.isPreSessionShown ? 'shown' : 'hidden'
45- const partition = PaneState.partitionOf(data?.files ?? [], noise)
46-
47- const totals = data
48- ? PaneState.headerTotalsOf(data, partition)
49- : PaneState.ZERO_TOTALS
50-
51- const empty = PaneState.emptyStateOf(model, totals.filesCount)
52-
53- const selected = PaneState.selectionOf(
54- PaneState.listedOf(partition, preSession),
55- model.selectedPath,
56- )
57-
58- const selectedPath = selected?.path ?? null
59-
60- const noteOf = (text: string | null): RenderElement | null =>
61- text === null ? null : Sections.dimNote(kit, Layout.sanitizeName(text))
62-
63- const noiseFace = model.isNoiseShown ? 'hide' : 'show'
64- const noiseCount = Layout.plural(partition.noiseCount, 'test')
65- const hasNoise = partition.noiseCount > 0
66-
67- const noiseToggle = hasNoise
68- ? Sections.toggleRow(kit, 'noise', {
69- label: `${noiseCount}/generated (${noiseFace})`,
70- onPress: kit.actions.toggleNoise,
71- })
72- : null
73-
74- const earlierFace = model.isPreSessionShown ? 'hide' : 'show'
75- const earlierCount = Layout.plural(partition.preSession.length, 'file')
76- const hasEarlier = partition.preSession.length > 0
77-
78- const earlierToggle = hasEarlier
79- ? Sections.toggleRow(kit, 'presession', {
80- label: `+${earlierCount} edited before this session (${earlierFace})`,
81- onPress: kit.actions.togglePreSession,
82- })
83- : null
84-
85- const earlierFiles = model.isPreSessionShown ? partition.preSession : []
86- const isEarlierListed = earlierFiles.length > 0
87-
88- const earlierRows = isEarlierListed
89- ? [<Box height={1} />, ...dialogRowsOf(kit, earlierFiles, selectedPath)]
90- : []
91-
92- const isUntrackedNoted = data?.isUntrackedWithheld === true && !empty
93- const listed = listBodyOf(kit, { data, partition, totals, empty })
94-
95- const notes = listed.flatMap(line => (typeof line === 'string' ? [line] : []))
96-
97- const rows = listed.filter(
98- (line): line is Git.FileStat => typeof line !== 'string',
99- )
100-
101- const hasRows = rows.length > 0
102- const message = empty ? [empty.headline, ...notes] : hasRows ? [] : notes
103- const header = Sections.headerView(kit, empty ? null : totals, null)
104- const subline = noteOf(PaneState.unbornNoteOf(model, totals.filesCount))
105-
106- const notShownNote = noteOf(
107- totals.notShown > 0 ? `${totals.notShown} not shown` : null,
108- )
109-
110- const untrackedNote = noteOf(
111- isUntrackedNoted ? Names.untrackedWithheldTextOf(model) : null,
112- )
113-
114- const detail = selectedDetailOf(
115- kit,
116- selected ? Entries.bodyEntryOf(selected, model) : null,
117- model,
118- )
119-
120- const top = Sections.present([
121- Sections.titleRow(kit, PaneState.dialogTitleOf(model)),
122- header,
123- subline,
124- Sections.controlsView(kit, model),
125- Sections.todoBar(kit, model),
126- noiseToggle,
127- ])
128-
129- const isMessageShown = message.length > 0
130-
131- return isMessageShown ? (
132- messagePaneOf(kit, {
133- top: Sections.present([...top, notShownNote, untrackedNote]),
134- message,
135- controls: null,
136- earlier: earlierToggle,
137- rest: [...earlierRows, ...detail],
138- })
139- ) : (
140- <Box flexDirection="column">
141- {Sections.present([
142- ...top,
143- ...dialogRowsOf(kit, rows, selectedPath),
144- ...notes.map(noteOf),
145- notShownNote,
146- untrackedNote,
147- hasEarlier ? <Box height={1} /> : null,
148- earlierToggle,
149- ...earlierRows,
150- ...detail,
151- ])}
152- </Box>
153- )
154-}
Dmods/diff/hooks/views/current-pane/dialog-rows-of/dialog-rows-of.tsx+0 -61
@@ -1,61 +1,0 @@
1-/* @jsxRuntime classic */
2-/* @jsx h */
3-/* @jsxFrag Fragment */
4-import type { RenderElement } from 'claude-code'
5-
6-import Git from '../../../git'
7-import Limits from '../../../limits'
8-import type { Kit } from '../../kit'
9-import Layout from '../../layout'
10-import Sections from '../../sections'
11-import { fileRowOf } from '../file-row-of'
12-
13-/**
14- * The inline list (DiffFileList): MAX_DIALOG_ROWS rows round the selected
15- * file, the pointer on it, and over a longer list a dim count each side.
16- *
17- * A side with no file out of the window keeps its row, blank.
18- *
19- * @param kit the elements, the handlers, the width
20- * @param files the listed files, in order
21- * @param selectedPath the selected file's path, or null
22- * @returns the rows
23- */
24-export function dialogRowsOf(
25- kit: Kit,
26- files: readonly Git.FileStat[],
27- selectedPath: string | null,
28-): RenderElement[] {
29- const selected = Math.max(
30- 0,
31- files.findIndex(file => file.path === selectedPath),
32- )
33-
34- const isPaged = files.length > Limits.MAX_DIALOG_ROWS
35-
36- const last = Math.min(
37- files.length,
38- Math.max(0, selected - Math.floor(Limits.MAX_DIALOG_ROWS / 2)) +
39- Limits.MAX_DIALOG_ROWS,
40- )
41-
42- const first = Math.max(0, last - Limits.MAX_DIALOG_ROWS)
43- const above = first
44- const below = files.length - last
45-
46- const edgeOf = (count: number, arrow: string): RenderElement =>
47- Sections.dimNote(
48- kit,
49- count > 0 ? ` ${arrow} ${Layout.plural(count, 'more file')}` : ' ',
50- )
51-
52- const rows = files
53- .slice(first, last)
54- .map(file =>
55- Sections.dialogFileRow(kit, fileRowOf(file, selectedPath), () =>
56- kit.actions.selectFile(file.path),
57- ),
58- )
59-
60- return isPaged ? [edgeOf(above, '↑'), ...rows, edgeOf(below, '↓')] : rows
61-}
Dmods/diff/hooks/views/current-pane/dialog-rows-of/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './dialog-rows-of.jsx'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/current-pane/file-row-of/file-row-of.ts+0 -31
@@ -1,31 +1,0 @@
1-import Git from '../../../git'
2-import Sections from '../../sections'
3-
4-/**
5- * A fetched row as the inline list draws it (DiffFileList FileItem): its
6- * counts or a note for them (`untracked`, `Binary file`), name, selection.
7- *
8- * @param file the row
9- * @param selectedPath the selected file's path, or null
10- * @returns the row model
11- */
12-export function fileRowOf(
13- file: Git.FileStat,
14- selectedPath: string | null,
15-): Sections.FileRowModel {
16- const note = file.isUntracked
17- ? 'untracked'
18- : file.isBinary
19- ? 'Binary file'
20- : null
21-
22- return {
23- key: Sections.fileKeyOf(file.path),
24- path: file.path,
25- displayPath: Git.displayPathOf(file),
26- added: file.added,
27- removed: file.removed,
28- note,
29- isSelected: file.path === selectedPath,
30- }
31-}
Dmods/diff/hooks/views/current-pane/file-row-of/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './file-row-of.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/current-pane/index.ts+0 -7
@@ -1,7 +1,0 @@
1-export * from './current-pane.jsx'
2-export * from './dialog-rows-of'
3-export * from './file-row-of'
4-export * from './list-body-of'
5-export * from './message-pane-of'
6-
7-export * as default from '.'
Dmods/diff/hooks/views/current-pane/list-body-of/index.ts+0 -4
@@ -1,4 +1,0 @@
1-export * from './list-body-of.js'
2-export * from './list-place'
3-
4-export * as default from '.'
Dmods/diff/hooks/views/current-pane/list-body-of/list-body-of.ts+0 -46
@@ -1,46 +1,0 @@
1-import type Git from '../../../git'
2-import type { Kit } from '../../kit'
3-import type { ListPlace } from './list-place'
4-
5-/**
6- * The list's body as ReplDiffSidebarBody decides it: the empty state's
7- * hint, the too-many state, the only-noise state, or the session's rows.
8- *
9- * Strings are dim lines; rows are files to draw.
10- *
11- * @param _kit unused; keeps the part's shape
12- * @param place the fetch, its groups, the counts, the empty state
13- * @returns the lines and rows, in order
14- */
15-export function listBodyOf(
16- _kit: Kit,
17- place: ListPlace,
18-): readonly (string | Git.FileStat)[] {
19- const { data, partition, totals, empty } = place
20-
21- const hint = empty?.hint ?? null
22-
23- if (empty) {
24- return hint === null ? [] : [hint]
25- }
26-
27- const isTooMany =
28- data !== null && data.files.length === 0 && totals.filesCount > 0
29-
30- if (isTooMany) {
31- return [
32- 'Too many changed files to show diff',
33- 'Per-file diff is skipped above 500 files',
34- ]
35- }
36-
37- const isOnlyNoise = partition.shown.length === 0 && partition.noiseCount > 0
38-
39- return isOnlyNoise
40- ? [
41- 'Only tests and generated files changed',
42- 'Tests and generated files are hidden · click "show" above to view ' +
43- 'them',
44- ]
45- : partition.shown
46-}
Dmods/diff/hooks/views/current-pane/list-body-of/list-place/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './list-place.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/current-pane/list-body-of/list-place/list-place.ts+0 -13
@@ -1,13 +1,0 @@
1-import type Git from '../../../../git'
2-import type PaneState from '../../../../pane-state'
3-
4-/**
5- * What decides the list's body: the fetch, its grouped rows, the header's
6- * counts, and the empty state when there is one.
7- */
8-export type ListPlace = {
9- data: Git.DiffData | null
10- partition: PaneState.Partition
11- totals: PaneState.HeaderTotals
12- empty: PaneState.EmptyState | null
13-}
Dmods/diff/hooks/views/current-pane/message-pane-of/foot-rows/foot-rows.ts+0 -5
@@ -1,5 +1,0 @@
1-/**
2- * The rows the pre-session line takes at the pane's foot: a blank row, the
3- * line, and a blank row under it.
4- */
5-export const FOOT_ROWS = 3
Dmods/diff/hooks/views/current-pane/message-pane-of/foot-rows/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './foot-rows.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/current-pane/message-pane-of/index.ts+0 -5
@@ -1,5 +1,0 @@
1-export * from './foot-rows'
2-export * from './message-pane'
3-export * from './message-pane-of.jsx'
4-
5-export * as default from '.'
Dmods/diff/hooks/views/current-pane/message-pane-of/message-pane/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './message-pane.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/current-pane/message-pane-of/message-pane/message-pane.ts+0 -13
@@ -1,13 +1,0 @@
1-import type { RenderElement } from 'claude-code'
2-
3-/**
4- * What stands around a message in the pane: the header block, the message,
5- * the pickers, the pre-session line, and what is listed under it.
6- */
7-export type MessagePane = {
8- top: readonly RenderElement[]
9- message: readonly string[]
10- controls: RenderElement | null
11- earlier: RenderElement | null
12- rest: readonly RenderElement[]
13-}
Dmods/diff/hooks/views/current-pane/message-pane-of/message-pane-of.tsx+0 -85
@@ -1,85 +1,0 @@
1-/* @jsxRuntime classic */
2-/* @jsx h */
3-/* @jsxFrag Fragment */
4-import type { RenderElement } from 'claude-code'
5-
6-import type { Kit } from '../../kit'
7-import Layout from '../../layout'
8-import { FOOT_ROWS } from './foot-rows'
9-import type { MessagePane } from './message-pane'
10-
11-/**
12- * The pane while a message stands in for the list (ReplDiffSidebar's
13- * centered body): the header, a blank row, the message, the pickers.
14- *
15- * With nothing listed below and room, the lines sit where the built-in
16- * centers them (half the spare rows above), the pickers under them;
17- * otherwise all stacks from the top, the pickers over the pre-session line.
18- *
19- * @param kit the elements, the width, the body's rows
20- * @param pane the header block, the message's lines, the pickers, the
21- * pre-session line, and what is listed under it
22- * @returns the pane's tree
23- */
24-export function messagePaneOf(kit: Kit, pane: MessagePane): RenderElement {
25- const { Box, Text } = kit.ui
26- const { top, controls, earlier, rest } = pane
27-
28- const lines = pane.message.flatMap(text =>
29- Layout.wrappedLines(Layout.sanitizeName(text), kit.columns),
30- )
31-
32- const drawn = lines.map(line => <Text dimColor>{line}</Text>)
33- const controlRows = controls === null ? [] : [controls]
34- const earlierRows = earlier === null ? [] : [earlier]
35-
36- const isControlsAboveEarlier =
37- controlRows.length > 0 || earlierRows.length === 0
38-
39- const aboveEarlier = isControlsAboveEarlier
40- ? controlRows
41- : [<Box height={1} />]
42-
43- const footRows = earlier === null ? 0 : FOOT_ROWS
44- const middleRows = kit.rows - top.length - 1 - footRows
45- const above = Math.floor((middleRows - lines.length) / 2)
46-
47- const isCentered =
48- rest.length === 0 && middleRows - above - lines.length >= controlRows.length
49-
50- if (!isCentered) {
51- return (
52- <Box flexDirection="column">
53- {[
54- ...top,
55- <Box height={1} />,
56- ...drawn,
57- ...aboveEarlier,
58- ...earlierRows,
59- ...rest,
60- ]}
61- </Box>
62- )
63- }
64-
65- const spacer = above > 0 ? [<Box height={above} />] : []
66-
67- const foot = earlierRows.map(row => (
68- <Box flexDirection="column" marginTop={1} paddingBottom={1}>
69- {row}
70- </Box>
71- ))
72-
73- return (
74- <Box flexDirection="column" height={kit.rows}>
75- {[
76- ...top,
77- <Box height={1} />,
78- <Box flexDirection="column" height={middleRows} alignItems="center">
79- {[...spacer, ...drawn, ...controlRows]}
80- </Box>,
81- ...foot,
82- ]}
83- </Box>
84- )
85-}
@@ -12,56 +12,34 @@
1212 import type Types from './types'
1313
1414 /**
15- * One file's detail (DiffDetailView): its bold name and aside, the ask
16- * Button, the built-in's dim rule under them, the body.
15+ * One file's detail (DiffDetailView): its name row (nameRow), the
16+ * built-in's dim rule under it, the body.
1717 *
18- * The name is cut from its start to the width. The body is a placeholder,
19- * or the hunks as the engine's diff `Code` blocks (codeBlocksOf) within the
20- * room given, then a footer when anything was cut; the room left over.
18+ * The body is a placeholder, or the hunks as the engine's diff `Code`
19+ * blocks (codeBlocksOf) within the room given, then a footer when anything
20+ * was cut; with it, the room left over.
2121 *
2222 * @param kit the elements and the width
2323 * @param detail the file
24- * @param draw the room the bodies have left, and the ask's press
24+ * @param room the room the pane's bodies have left
2525 * @returns the detail element and the room after it
2626 */
2727 export function detailView(
2828 kit: Kit,
2929 detail: DetailModel,
30- draw: Types.DetailDraw,
30+ room: Types.BodyRoom,
3131 ): Types.DrawnDetail {
32- const { Box, Text, Button, Code } = kit.ui
33- const { room, onToggleAsk } = draw
32+ const { Box, Text, Code } = kit.ui
3433 const placeholder = placeholderOf(detail)
35- const name = Layout.sanitizeName(detail.displayPath)
3634
37- const framed: Types.BodyRoom = {
38- chars: room.chars - name.length,
35+ const code = codeBlocksOf(placeholder ? [] : (detail.body?.hunks ?? []), {
36+ chars: room.chars - Layout.sanitizeName(detail.displayPath).length,
3937 nodes: room.nodes - FILE_FRAME_NODES,
40- }
38+ })
4139
42- const code = codeBlocksOf(
43- placeholder ? [] : (detail.body?.hunks ?? []),
44- framed,
45- )
46-
4740 const isTruncated = detail.body?.isTruncated === true || code.isTruncated
4841 const path = Layout.sanitizeName(detail.path).slice(-MAX_CODE_CHARS)
4942
50- const asides = [
51- detail.isUntracked ? 'untracked' : null,
52- isTruncated ? 'truncated' : null,
53- ].filter(word => word !== null)
54-
55- const aside = asides.length === 0 ? '' : ` (${asides.join(', ')})`
56-
57- const ask = placeholder
58- ? []
59- : [
60- <Button key={Sections.askKeyOf(detail.path)} onPress={onToggleAsk}>
61- {detail.isArmed ? 'asked ✓' : 'ask'}
62- </Button>,
63- ]
64-
6543 const footer = isTruncated
6644 ? [
6745 <Text dimColor italic>
@@ -83,16 +61,11 @@
8361 const element = (
8462 <Box flexDirection="column">
8563 {[
86- <Box flexDirection="row">
87- {[
88- <Text bold wrap="truncate-start">
89- {Layout.truncateStart(name, kit.columns)}
90- </Text>,
91- <Text dimColor>{aside}</Text>,
92- <Box flexGrow={1} />,
93- ...ask,
94- ]}
95- </Box>,
64+ Sections.nameRow(kit, {
65+ ...detail,
66+ isTruncated,
67+ isAskable: placeholder === null,
68+ }),
9669 Sections.divider(kit),
9770 ...(placeholder ? notes : hunks),
9871 ...footer,
@@ -1,5 +1,4 @@
1-import { countOf } from '../../../count-of'
2-import type Git from '../../../git'
1+import Git from '../../../git'
32 import { leadingHunkOf } from '../leading-hunk-of'
43 import { firstLineCut } from './first-line-cut'
54
@@ -31,16 +30,9 @@
3130 ? { ...rest, lines: [firstLineCut(rest, maxChars)] }
3231 : leading
3332
34- const taken = rest.lines.slice(0, piece.lines.length)
35-
3633 hunks.push(piece)
3734 isTruncated ||= isLineCut
38-
39- rest = {
40- oldStart: rest.oldStart + countOf(taken, line => !line.startsWith('+')),
41- newStart: rest.newStart + countOf(taken, line => !line.startsWith('-')),
42- lines: rest.lines.slice(piece.lines.length),
43- }
35+ rest = Git.hunkAfter(rest, piece.lines.length)
4436 }
4537
4638 return { hunks, isTruncated }
Dmods/diff/hooks/views/detail/types/detail-draw/detail-draw.ts+0 -14
@@ -1,14 +1,0 @@
1-import type { BodyRoom } from '../body-room'
2-
3-/**
4- * What drawing one file's detail takes besides the file: the room the
5- * pane's bodies have left, and the press that arms or disarms its ask.
6- */
7-export type DetailDraw = {
8- room: BodyRoom
9-
10- /**
11- * Arms the file for the next prompt, or disarms it.
12- */
13- onToggleAsk: () => void
14-}
Dmods/diff/hooks/views/detail/types/detail-draw/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './detail-draw.js'
2-
3-export * as default from '.'
@@ -1,6 +1,6 @@
11 export * from './body-room'
22 export * from './code-body'
3-export * from './detail-draw'
43 export * from './drawn-detail'
4+export * from './named-detail'
55
66 export * as default from '.'
@@ -1,0 +1,3 @@
1+export type * from './named-detail.js'
2+
3+export * as default from '.'
@@ -1,0 +1,10 @@
1+import type { DetailModel } from '../../detail-model'
2+
3+/**
4+ * A file as its name row draws it: the detail, whether its body was cut,
5+ * and whether it can be asked about (it has hunks to attach).
6+ */
7+export type NamedDetail = DetailModel & {
8+ isTruncated: boolean
9+ isAskable: boolean
10+}
@@ -1,0 +1,24 @@
1+import Git from '../../../git'
2+import PaneState from '../../../pane-state'
3+import Entries from '../../entries'
4+
5+/**
6+ * The files the dialog lists, in its order: the picked turn's, or every
7+ * file of the fetch, pre-session or not, sorted by its displayed name.
8+ *
9+ * @param model the pane's state
10+ * @returns the entries
11+ */
12+export function dialogEntriesOf(
13+ model: PaneState.PaneModel,
14+): readonly Entries.BodyEntry[] {
15+ const turn = PaneState.pickedTurnOf(model)
16+
17+ return turn
18+ ? turn.files.map(Entries.turnEntryOf)
19+ : [...(model.data?.files ?? [])]
20+ .sort((a, b) =>
21+ Git.displayPathOf(a).localeCompare(Git.displayPathOf(b)),
22+ )
23+ .map(file => Entries.bodyEntryOf(file, model))
24+}
@@ -1,0 +1,3 @@
1+export * from './dialog-entries-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,146 @@
1+/* @jsxRuntime classic */
2+/* @jsx h */
3+/* @jsxFrag Fragment */
4+import type { RenderElement } from 'claude-code'
5+
6+import Limits from '../../limits'
7+import PaneState from '../../pane-state'
8+import Detail from '../detail'
9+import Entries from '../entries'
10+import type { Kit } from '../kit'
11+import Layout from '../layout'
12+import { preludeOf } from '../prelude-of'
13+import Sections from '../sections'
14+import { dialogEntriesOf } from './dialog-entries-of'
15+import { dialogWindowOf } from './dialog-window-of'
16+
17+/**
18+ * The inline pane (DiffDialog): its title, the dim count, then the file
19+ * list's window, or the picked file's detail alone, and the key hints.
20+ *
21+ * Every file lists in the built-in's order, five at a time between `more
22+ * files` rows that move the window; the source picker stands in for the
23+ * tabs. Arrows walk the rows, Enter views one, Escape backs out or closes.
24+ *
25+ * @param kit the elements, the handlers, the width
26+ * @param model the pane's state
27+ * @returns the pane's tree
28+ */
29+export function dialogPane(
30+ kit: Kit,
31+ model: PaneState.PaneModel,
32+): RenderElement {
33+ const { Box, Text } = kit.ui
34+ const prelude = preludeOf(kit, model)
35+
36+ if (prelude) {
37+ return prelude
38+ }
39+
40+ const turn = PaneState.pickedTurnOf(model)
41+ const { data } = model
42+ const entries = dialogEntriesOf(model)
43+ const totals = turn ? PaneState.turnTotalsOf(turn) : (data?.stats ?? null)
44+ const isTurn = model.source.kind === 'turn'
45+ const isPaged = entries.length > Limits.MAX_VISIBLE_FILES
46+ const picked = entries.find(entry => entry.path === model.selectedPath)
47+ const isDetail = model.dialogView === 'detail' && picked !== undefined
48+
49+ const start = dialogWindowOf(
50+ model,
51+ entries.map(entry => entry.path),
52+ )
53+
54+ const shown = entries.slice(start, start + Limits.MAX_VISIBLE_FILES)
55+ const blankEdge = Sections.dimNote(kit, ' ')
56+ const below = entries.length - start - shown.length
57+
58+ const isTooMany =
59+ !isTurn &&
60+ data !== null &&
61+ data.stats.filesCount > 0 &&
62+ entries.length === 0
63+
64+ const emptyWords = isTurn
65+ ? 'No file changes in this turn'
66+ : isTooMany
67+ ? 'Too many files to display details'
68+ : 'No changes yet'
69+
70+ const message = entries.length > 0 ? null : emptyWords
71+
72+ const subtitle = totals && (
73+ <Text dimColor wrap="truncate-end">
74+ {`${Layout.plural(totals.filesCount, 'file')} changed `}
75+ {Sections.diffStat(kit, totals.linesAdded, totals.linesRemoved)}
76+ </Text>
77+ )
78+
79+ const noteOf = (entry: Entries.BodyEntry) =>
80+ entry.isUntracked ? 'untracked' : entry.isBinary ? 'Binary file' : null
81+
82+ const rowOf = (entry: Entries.BodyEntry) =>
83+ Sections.dialogFileRow(
84+ kit,
85+ {
86+ key: Sections.fileKeyOf(entry),
87+ path: entry.path,
88+ displayPath: entry.displayPath,
89+ added: entry.added,
90+ removed: entry.removed,
91+ note: noteOf(entry),
92+ isSelected: false,
93+ },
94+ () => kit.actions.selectFile(entry.path),
95+ )
96+
97+ function moreOf(way: 'up' | 'down', count: number): RenderElement {
98+ const arrow = way === 'up' ? '\u2191' : '\u2193'
99+ const words = Layout.plural(count, 'file').replace(' ', ' more ')
100+ const edge = way === 'up' ? 'files-up' : 'files-down'
101+
102+ return Sections.listEdgeButton(kit, edge, ` ${arrow} ${words}`)
103+ }
104+
105+ const pagedEdgeOf = (way: 'up' | 'down', count: number) =>
106+ count > 0 ? moreOf(way, count) : blankEdge
107+
108+ const edgeOf = (way: 'up' | 'down', count: number) =>
109+ isPaged ? pagedEdgeOf(way, count) : null
110+
111+ const detailOf = (entry: Entries.BodyEntry): RenderElement[] => [
112+ Detail.detailView(
113+ kit,
114+ Entries.detailModelOf(entry, model),
115+ Detail.BODY_BUDGET,
116+ ).element,
117+ ]
118+
119+ const rows = Sections.present([
120+ edgeOf('up', start),
121+ ...shown.map(rowOf),
122+ edgeOf('down', below),
123+ ])
124+
125+ const listing = isDetail && picked ? detailOf(picked) : rows
126+ const body = message === null ? listing : [Sections.dimNote(kit, message)]
127+
128+ const hint = isDetail
129+ ? '\u2191/\u2193 to scroll \u00b7 Esc to back'
130+ : '\u2191/\u2193 to select \u00b7 Enter to view \u00b7 Esc to close'
131+
132+ return (
133+ <Box flexDirection="column">
134+ {Sections.present([
135+ Sections.titleRow(kit, PaneState.dialogTitleOf(model)),
136+ <Box height={1} />,
137+ subtitle,
138+ Sections.sourcePicker(kit, model),
139+ <Box flexDirection="column" marginTop={1} marginBottom={1}>
140+ {body}
141+ </Box>,
142+ Sections.dimNote(kit, hint),
143+ ])}
144+ </Box>
145+ )
146+}
@@ -1,0 +1,25 @@
1+import Limits from '../../../limits'
2+import type PaneState from '../../../pane-state'
3+
4+/**
5+ * The first of the MAX_VISIBLE_FILES rows the dialog lists (DiffFileList's
6+ * window): centred on the file last viewed, else where its edge rows put it.
7+ *
8+ * @param model the pane's state: the pick and the list's start
9+ * @param paths the listed files' paths, in order
10+ * @returns the first listed row's index
11+ */
12+export function dialogWindowOf(
13+ model: Pick<PaneState.PaneModel, 'selectedPath' | 'place'>,
14+ paths: readonly string[],
15+): number {
16+ const last = Math.max(0, paths.length - Limits.MAX_VISIBLE_FILES)
17+ const picked = paths.indexOf(model.selectedPath ?? '')
18+ const isPicked = model.selectedPath !== null && picked >= 0
19+
20+ const wanted = isPicked
21+ ? picked - Math.floor(Limits.MAX_VISIBLE_FILES / 2)
22+ : model.place.listStart
23+
24+ return Math.max(0, Math.min(last, wanted))
25+}
@@ -1,0 +1,3 @@
1+export * from './dialog-window-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,7 @@
1+export * from './dialog-entries-of'
2+export * from './dialog-pane.jsx'
3+export * from './dialog-window-of'
4+export * from './list-body-of'
5+export * from './message-pane-of'
6+
7+export * as default from '.'
@@ -1,0 +1,4 @@
1+export * from './list-body-of.js'
2+export * from './list-place'
3+
4+export * as default from '.'
@@ -1,0 +1,43 @@
1+import type Git from '../../../git'
2+import type { ListPlace } from './list-place'
3+
4+/**
5+ * The list's body as ReplDiffSidebarBody decides it: the empty state's
6+ * hint, the too-many state, the only-noise state, or the session's rows.
7+ *
8+ * Strings are dim lines; rows are files to draw.
9+ *
10+ * @param place the fetch, its groups, the counts, the empty state
11+ * @returns the lines and rows, in order
12+ */
13+export function listBodyOf(
14+ place: ListPlace,
15+): readonly (string | Git.FileStat)[] {
16+ const { data, partition, totals, empty } = place
17+
18+ const hint = empty?.hint ?? null
19+
20+ if (empty) {
21+ return hint === null ? [] : [hint]
22+ }
23+
24+ const isTooMany =
25+ data !== null && data.files.length === 0 && totals.filesCount > 0
26+
27+ if (isTooMany) {
28+ return [
29+ 'Too many changed files to show diff',
30+ 'Per-file diff is skipped above 500 files',
31+ ]
32+ }
33+
34+ const isOnlyNoise = partition.shown.length === 0 && partition.noiseCount > 0
35+
36+ return isOnlyNoise
37+ ? [
38+ 'Only tests and generated files changed',
39+ 'Tests and generated files are hidden · click "show" above to view ' +
40+ 'them',
41+ ]
42+ : partition.shown
43+}
@@ -1,0 +1,3 @@
1+export type * from './list-place.js'
2+
3+export * as default from '.'
@@ -1,0 +1,13 @@
1+import type Git from '../../../../git'
2+import type PaneState from '../../../../pane-state'
3+
4+/**
5+ * What decides the list's body: the fetch, its grouped rows, the header's
6+ * counts, and the empty state when there is one.
7+ */
8+export type ListPlace = {
9+ data: Git.DiffData | null
10+ partition: PaneState.Partition
11+ totals: PaneState.HeaderTotals
12+ empty: PaneState.EmptyState | null
13+}
@@ -1,0 +1,5 @@
1+/**
2+ * The rows the pre-session line takes at the pane's foot: a blank row, the
3+ * line, and a blank row under it.
4+ */
5+export const FOOT_ROWS = 3
@@ -1,0 +1,3 @@
1+export * from './foot-rows.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+export * from './foot-rows'
2+export * from './message-pane'
3+export * from './message-pane-of.jsx'
4+
5+export * as default from '.'
@@ -1,0 +1,3 @@
1+export type * from './message-pane.js'
2+
3+export * as default from '.'
@@ -1,0 +1,13 @@
1+import type { RenderElement } from 'claude-code'
2+
3+/**
4+ * What stands around a message in the pane: the header block, the message,
5+ * the pickers, the pre-session line, and what is listed under it.
6+ */
7+export type MessagePane = {
8+ top: readonly RenderElement[]
9+ message: readonly string[]
10+ controls: RenderElement | null
11+ earlier: RenderElement | null
12+ rest: readonly RenderElement[]
13+}
@@ -1,0 +1,85 @@
1+/* @jsxRuntime classic */
2+/* @jsx h */
3+/* @jsxFrag Fragment */
4+import type { RenderElement } from 'claude-code'
5+
6+import type { Kit } from '../../kit'
7+import Layout from '../../layout'
8+import { FOOT_ROWS } from './foot-rows'
9+import type { MessagePane } from './message-pane'
10+
11+/**
12+ * The pane while a message stands in for the list (ReplDiffSidebar's
13+ * centered body): the header, a blank row, the message, the pickers.
14+ *
15+ * With nothing listed below and room, the lines sit where the built-in
16+ * centers them (half the spare rows above), the pickers under them;
17+ * otherwise all stacks from the top, the pickers over the pre-session line.
18+ *
19+ * @param kit the elements, the width, the body's rows
20+ * @param pane the header block, the message's lines, the pickers, the
21+ * pre-session line, and what is listed under it
22+ * @returns the pane's tree
23+ */
24+export function messagePaneOf(kit: Kit, pane: MessagePane): RenderElement {
25+ const { Box, Text } = kit.ui
26+ const { top, controls, earlier, rest } = pane
27+
28+ const lines = pane.message.flatMap(text =>
29+ Layout.wrappedLines(Layout.sanitizeName(text), kit.columns),
30+ )
31+
32+ const drawn = lines.map(line => <Text dimColor>{line}</Text>)
33+ const controlRows = controls === null ? [] : [controls]
34+ const earlierRows = earlier === null ? [] : [earlier]
35+
36+ const isControlsAboveEarlier =
37+ controlRows.length > 0 || earlierRows.length === 0
38+
39+ const aboveEarlier = isControlsAboveEarlier
40+ ? controlRows
41+ : [<Box height={1} />]
42+
43+ const footRows = earlier === null ? 0 : FOOT_ROWS
44+ const middleRows = kit.rows - top.length - 1 - footRows
45+ const above = Math.floor((middleRows - lines.length) / 2)
46+
47+ const isCentered =
48+ rest.length === 0 && middleRows - above - lines.length >= controlRows.length
49+
50+ if (!isCentered) {
51+ return (
52+ <Box flexDirection="column">
53+ {[
54+ ...top,
55+ <Box height={1} />,
56+ ...drawn,
57+ ...aboveEarlier,
58+ ...earlierRows,
59+ ...rest,
60+ ]}
61+ </Box>
62+ )
63+ }
64+
65+ const spacer = above > 0 ? [<Box height={above} />] : []
66+
67+ const foot = earlierRows.map(row => (
68+ <Box flexDirection="column" marginTop={1} paddingBottom={1}>
69+ {row}
70+ </Box>
71+ ))
72+
73+ return (
74+ <Box flexDirection="column" height={kit.rows}>
75+ {[
76+ ...top,
77+ <Box height={1} />,
78+ <Box flexDirection="column" height={middleRows} alignItems="center">
79+ {[...spacer, ...drawn, ...controlRows]}
80+ </Box>,
81+ ...foot,
82+ ]}
83+ </Box>
84+ )
85+}
@@ -1,5 +1,6 @@
1-export * from './current-pane'
1+export * from './body'
22 export * from './detail'
3+export * from './dialog-pane'
34 export * from './entries'
45 export * from './inset-of'
56 export * from './kit'
@@ -9,8 +10,6 @@
910 export * from './pane-view.jsx'
1011 export * from './prelude-of'
1112 export * from './sections'
12-export * from './selected-detail-of'
1313 export * from './sidebar-pane'
14-export * from './turn-pane'
1514
1615 export * as default from '.'
@@ -4,12 +4,18 @@
44 */
55 export type PaneActions = {
66 /**
7- * Docked, scrolls the body to this file's hunks; inline, selects the file
8- * so its body shows under the list.
7+ * Docked, scrolls the body to this file's hunks; inline, picks the file
8+ * and opens its detail.
99 */
1010 selectFile: (path: string) => void
1111
1212 /**
13+ * Moves the list's window by so many files, clamped to the list; in the
14+ * dialog the pick is dropped so the window leaves the file last viewed.
15+ */
16+ scrollList: (delta: number) => void
17+
18+ /**
1319 * Shows or hides the tests and generated files.
1420 */
1521 toggleNoise: () => void
@@ -20,10 +26,9 @@
2026 togglePreSession: () => void
2127
2228 /**
23- * Picks the comparison base by its Select value; an unknown value is
24- * ignored.
29+ * Moves the comparison base on to the next mode, round to the first.
2530 */
26- chooseBase: (value: string) => void
31+ cycleBase: () => void
2732
2833 /**
2934 * Picks the source by its Select value: `current` or a turn's number.
@@ -5,22 +5,21 @@
55
66 import Limits from '../limits'
77 import Names from '../names'
8-import PaneState from '../pane-state'
9-import { currentPane } from './current-pane'
8+import type PaneState from '../pane-state'
9+import { dialogPane } from './dialog-pane'
1010 import { insetOf } from './inset-of'
1111 import type { Kit } from './kit'
1212 import type { PaneSeat } from './pane-seat'
1313 import Sections from './sections'
1414 import { sidebarPane } from './sidebar-pane'
15-import { turnPane } from './turn-pane'
1615
1716 /**
1817 * The diff pane's body for one `ui.render`: docked, the built-in panel's
1918 * layout (sidebarPane); inline, its dialog's, or its wider-terminal line.
2019 *
21- * Inline for want of width (under OPEN_MIN_COLUMNS) the built-in shows no
22- * panel, only that line; inline on a wider terminal there is no fullscreen
23- * layout, where it shows its dialog. Docked, its blank top row and column.
20+ * The line only where the session is fullscreen (or not known not to be)
21+ * and the terminal too narrow to dock, as the built-in shows no panel
22+ * there; without the fullscreen layout the dialog draws at any width.
2423 *
2524 * @param kit the elements, the handlers, the width
2625 * @param model the pane's state
@@ -36,15 +35,10 @@
3635 const isDocked = seat.placement === 'dock'
3736
3837 const isNarrow =
38+ model.isFullscreen !== false &&
3939 seat.terminalColumns !== null &&
4040 seat.terminalColumns < Limits.OPEN_MIN_COLUMNS
4141
42- function dialogOf(): RenderElement {
43- const turn = PaneState.pickedTurnOf(model)
44-
45- return turn ? turnPane(kit, model, turn) : currentPane(kit, model)
46- }
47-
4842 return isDocked ? (
4943 <Box
5044 flexDirection="column"
@@ -56,6 +50,6 @@
5650 ) : isNarrow ? (
5751 <Box>{Sections.dimNote(kit, Names.RESIZE_TERMINAL_TEXT)}</Box>
5852 ) : (
59- <Box flexDirection="column">{dialogOf()}</Box>
53+ dialogPane(kit, model)
6054 )
6155 }
@@ -5,7 +5,7 @@
55
66 import Names from '../../names'
77 import type PaneState from '../../pane-state'
8-import CurrentPane from '../current-pane'
8+import DialogPane from '../dialog-pane'
99 import type { Kit } from '../kit'
1010 import Sections from '../sections'
1111
@@ -34,7 +34,7 @@
3434 )
3535
3636 const loadingOf = (): RenderElement =>
37- CurrentPane.messagePaneOf(kit, {
37+ DialogPane.messagePaneOf(kit, {
3838 top: [Sections.headerView(kit, null, null)],
3939 message: ['Loading diff…'],
4040 controls: null,
Dmods/diff/hooks/views/sections/body-key-of/body-key-of.ts+0 -10
@@ -1,10 +1,0 @@
1-import Layout from '../../layout'
2-
3-/**
4- * The key of a file's block in the docked body, under `body:`: what a row's
5- * press scrolls to (`$.ui.scroll({ to: { key } })`).
6- *
7- * @param path the file's path as git or the transcript gave it
8- * @returns the key
9- */
10-export const bodyKeyOf = (path: string) => `body:${Layout.sanitizeName(path)}`
Dmods/diff/hooks/views/sections/body-key-of/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './body-key-of.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/sections/controls-view.tsx+0 -74
@@ -1,74 +1,0 @@
1-/* @jsxRuntime classic */
2-/* @jsx h */
3-/* @jsxFrag Fragment */
4-import type { RenderElement } from 'claude-code'
5-
6-import PaneState from '../../pane-state'
7-import type { Kit } from '../kit'
8-import Layout from '../layout'
9-import { present } from './present'
10-
11-/**
12- * The pickers under the header: the source (`Current`, then `T<n>` per
13- * turn with edits, newest first) and, for the current source, the base.
14- *
15- * The source picker draws only when a turn exists, as DiffDialog hides its
16- * tabs; the base picker is ctrl+x b's cycle as a Select over the backend's
17- * modes, each worded as the built-in's base line (modeLabelOf) it replaces.
18- *
19- * @param kit the elements and the handlers
20- * @param model what is picked now and the turns to pick from
21- * @returns the row, or null when neither picker applies
22- */
23-export function controlsView(
24- kit: Kit,
25- model: Pick<
26- PaneState.PaneModel,
27- 'source' | 'requestedMode' | 'turns' | 'baseModes' | 'words' | 'data'
28- >,
29-): RenderElement | null {
30- const { Box, Select } = kit.ui
31- const { source, turns } = model
32- const hasTurns = turns.length > 0
33-
34- const sourcePicker = hasTurns ? (
35- <Select
36- key="source"
37- label="source"
38- options={[
39- { value: 'current', label: 'Current' },
40- ...turns.map(turn => ({
41- value: String(turn.index),
42- label: `T${turn.index}`,
43- })),
44- ]}
45- value={source.kind === 'current' ? 'current' : String(source.index)}
46- onSelect={value => kit.actions.chooseSource(value)}
47- />
48- ) : null
49-
50- const isCurrent = source.kind === 'current'
51-
52- const basePicker = isCurrent ? (
53- <Select
54- key="base"
55- label="base"
56- options={model.baseModes.map(mode => ({
57- value: mode,
58- label: Layout.sanitizeName(PaneState.modeLabelOf(mode, model)),
59- }))}
60- value={model.requestedMode}
61- onSelect={value => kit.actions.chooseBase(value)}
62- />
63- ) : null
64-
65- const pickers = present([sourcePicker, basePicker])
66-
67- const row = (
68- <Box flexDirection="row" gap={2}>
69- {pickers}
70- </Box>
71- )
72-
73- return pickers.length === 0 ? null : row
74-}
@@ -40,5 +40,9 @@
4040 const hasNote = row.note !== null
4141 const tail = hasNote ? note : diffStat(kit, row.added, row.removed)
4242
43- return listRow(kit, { key: row.key, label: `${mark} ${name}`, onPress }, tail)
43+ return listRow(
44+ kit,
45+ { key: row.key, label: `${mark} ${name}`, onPress, isDim: false },
46+ tail,
47+ )
4448 }
@@ -1,9 +1,11 @@
1+import type { BodyEntry } from '../../entries'
12 import Layout from '../../layout'
23
34 /**
45 * A file row's Button address: the path made printable, under `file:`.
56 *
6- * @param path the file's path as git or the transcript gave it
7+ * @param entry the file, its path as git or the transcript gave it
78 * @returns the key
89 */
9-export const fileKeyOf = (path: string) => `file:${Layout.sanitizeName(path)}`
10+export const fileKeyOf = (entry: Pick<BodyEntry, 'path'>) =>
11+ `file:${Layout.sanitizeName(entry.path)}`
@@ -36,7 +36,7 @@
3636
3737 return listRow(
3838 kit,
39- { key: row.key, label, onPress },
39+ { key: row.key, label, onPress, isDim: true },
4040 diffStat(kit, row.added, row.removed),
4141 )
4242 }
@@ -1,6 +1,4 @@
11 export * from './ask-key-of'
2-export * from './body-key-of'
3-export * from './controls-view.jsx'
42 export * from './dialog-file-row'
53 export * from './dialog-path-floor'
64 export * from './dialog-path-reserve'
@@ -12,11 +10,15 @@
1210 export * from './file-row-model'
1311 export * from './header-view.jsx'
1412 export * from './keyed-pressable'
13+export * from './legend-row.jsx'
14+export * from './list-edge-button'
1515 export * from './list-row'
16+export * from './name-row'
1617 export * from './path-floor'
1718 export * from './pointer'
1819 export * from './present'
1920 export * from './pressable'
21+export * from './source-picker.jsx'
2022 export * from './stat-cells'
2123 export * from './stat-text-of'
2224 export * from './title-row.jsx'
@@ -1,8 +1,10 @@
11 import type { Pressable } from '../pressable'
22
33 /**
4- * A plain Button's face, press and address: a list row's Button.
4+ * A plain Button's face, press and address: a list row's Button, dim at
5+ * rest where the built-in draws the row dim.
56 */
67 export type KeyedPressable = Pressable & {
78 key: string
9+ isDim: boolean
810 }
@@ -1,0 +1,38 @@
1+/* @jsxRuntime classic */
2+/* @jsx h */
3+/* @jsxFrag Fragment */
4+import type { RenderElement } from 'claude-code'
5+
6+import type Entries from '../entries'
7+import type { Kit } from '../kit'
8+import Layout from '../layout'
9+import { diffStat } from './diff-stat'
10+import { PATH_FLOOR } from './path-floor'
11+import { STAT_CELLS } from './stat-cells'
12+
13+/**
14+ * One row of the open pre-session section's legend (PreSessionSection): the
15+ * file's dim name cut from its start, its counts at the right; no press.
16+ *
17+ * @param kit the elements and the width
18+ * @param entry the pre-session file
19+ * @returns the row
20+ */
21+export function legendRow(kit: Kit, entry: Entries.BodyEntry): RenderElement {
22+ const { Box, Text } = kit.ui
23+
24+ return (
25+ <Box flexDirection="row">
26+ {[
27+ <Text dimColor wrap="truncate-end">
28+ {Layout.truncateStart(
29+ Layout.sanitizeName(entry.displayPath),
30+ Math.max(kit.columns - STAT_CELLS, PATH_FLOOR),
31+ )}
32+ </Text>,
33+ <Box flexGrow={1} />,
34+ diffStat(kit, entry.added, entry.removed),
35+ ]}
36+ </Box>
37+ )
38+}
@@ -1,0 +1,3 @@
1+export * from './list-edge-button.jsx'
2+
3+export * as default from '.'
@@ -1,0 +1,43 @@
1+/* @jsxRuntime classic */
2+/* @jsx h */
3+/* @jsxFrag Fragment */
4+import type { RenderElement } from 'claude-code'
5+
6+import Limits from '../../../limits'
7+import type { Kit } from '../../kit'
8+
9+/**
10+ * A list's `more` row as a dim plain Button that moves its window: the
11+ * docked list's a file, under the built-in's keys too; the dialog's a page.
12+ *
13+ * The built-in dialog moves its window with the selection; a plugin cannot
14+ * see the person walk the rows, so there the row itself carries the move.
15+ *
16+ * @param kit the elements and the handlers
17+ * @param edge which list and which edge, the Button's key
18+ * @param label the row's text
19+ * @returns the Button
20+ */
21+export function listEdgeButton(
22+ kit: Kit,
23+ edge: 'list-up' | 'list-down' | 'files-up' | 'files-down',
24+ label: string,
25+): RenderElement {
26+ const { Button } = kit.ui
27+ const isDocked = edge === 'list-up' || edge === 'list-down'
28+ const isUp = edge === 'list-up' || edge === 'files-up'
29+ const size = isDocked ? 1 : Limits.MAX_VISIBLE_FILES
30+ const action = isUp ? 'app:diffFileListUp' : 'app:diffFileListDown'
31+
32+ return (
33+ <Button
34+ key={edge}
35+ plain
36+ dimColor
37+ {...(isDocked ? { action } : {})}
38+ onPress={() => kit.actions.scrollList(isUp ? -size : size)}
39+ >
40+ {label}
41+ </Button>
42+ )
43+}
@@ -11,7 +11,7 @@
1111 * row shows at its right edge (its counts, a note).
1212 *
1313 * @param kit the elements
14- * @param row the Button's key, label and press
14+ * @param row the Button's key, label and press, and whether it rests dim
1515 * @param tail the element at the right edge
1616 * @returns the row element
1717 */
@@ -24,7 +24,7 @@
2424
2525 return (
2626 <Box flexDirection="row">
27- <Button key={row.key} plain onPress={row.onPress}>
27+ <Button key={row.key} plain dimColor={row.isDim} onPress={row.onPress}>
2828 {row.label}
2929 </Button>
3030 <Box flexGrow={1} />
@@ -1,0 +1,3 @@
1+export * from './name-row.jsx'
2+
3+export * as default from '.'
@@ -1,0 +1,56 @@
1+/* @jsxRuntime classic */
2+/* @jsx h */
3+/* @jsxFrag Fragment */
4+import type { RenderElement } from 'claude-code'
5+
6+import type Detail from '../../detail'
7+import type { Kit } from '../../kit'
8+import Layout from '../../layout'
9+import { askKeyOf } from '../ask-key-of'
10+
11+/**
12+ * A file's name row over its body (DiffDetailView's first line): the bold
13+ * name cut from its start, its asides in dim, and its ask Button.
14+ *
15+ * The Button reads `asked ✓` while the file is armed; a file with no hunks
16+ * to attach (a placeholder body) has none.
17+ *
18+ * @param kit the elements, the handlers, the width
19+ * @param name the file, with whether it is truncated, armed and askable
20+ * @returns the row
21+ */
22+export function nameRow(kit: Kit, name: Detail.NamedDetail): RenderElement {
23+ const { Box, Text, Button } = kit.ui
24+
25+ const asides = [
26+ name.isUntracked ? 'untracked' : null,
27+ name.isTruncated ? 'truncated' : null,
28+ ].filter(word => word !== null)
29+
30+ const aside = asides.length === 0 ? '' : ` (${asides.join(', ')})`
31+
32+ const ask = (
33+ <Button
34+ key={askKeyOf(name.path)}
35+ onPress={() => kit.actions.toggleAsk(name.path)}
36+ >
37+ {name.isArmed ? 'asked ✓' : 'ask'}
38+ </Button>
39+ )
40+
41+ return (
42+ <Box flexDirection="row">
43+ {[
44+ <Text bold wrap="truncate-start">
45+ {Layout.truncateStart(
46+ Layout.sanitizeName(name.displayPath),
47+ kit.columns,
48+ )}
49+ </Text>,
50+ <Text dimColor>{aside}</Text>,
51+ <Box flexGrow={1} />,
52+ ...(name.isAskable ? [ask] : []),
53+ ]}
54+ </Box>
55+ )
56+}
@@ -1,0 +1,42 @@
1+/* @jsxRuntime classic */
2+/* @jsx h */
3+/* @jsxFrag Fragment */
4+import type { RenderElement } from 'claude-code'
5+
6+import type PaneState from '../../pane-state'
7+import type { Kit } from '../kit'
8+
9+/**
10+ * The pick between the working tree and a turn's edits (`Current`, then
11+ * `T<n>` per turn, newest first), drawn only when a turn exists.
12+ *
13+ * As DiffDialog hides its tabs with one source.
14+ *
15+ * @param kit the elements and the handlers
16+ * @param model what is picked now and the turns to pick from
17+ * @returns the picker, or null without turns
18+ */
19+export function sourcePicker(
20+ kit: Kit,
21+ model: Pick<PaneState.PaneModel, 'source' | 'turns'>,
22+): RenderElement | null {
23+ const { Select } = kit.ui
24+ const { source, turns } = model
25+ const hasTurns = turns.length > 0
26+
27+ return hasTurns ? (
28+ <Select
29+ key="source"
30+ label="source"
31+ options={[
32+ { value: 'current', label: 'Current' },
33+ ...turns.map(turn => ({
34+ value: String(turn.index),
35+ label: `T${turn.index}`,
36+ })),
37+ ]}
38+ value={source.kind === 'current' ? 'current' : String(source.index)}
39+ onSelect={value => kit.actions.chooseSource(value)}
40+ />
41+ ) : null
42+}
@@ -24,7 +24,7 @@
2424
2525 return (
2626 <Box flexDirection="row">
27- <Button key={key} plain onPress={pressable.onPress}>
27+ <Button key={key} plain dimColor onPress={pressable.onPress}>
2828 {pressable.label}
2929 </Button>
3030 </Box>
Dmods/diff/hooks/views/selected-detail-of/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './selected-detail-of.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/selected-detail-of/selected-detail-of.ts+0 -33
@@ -1,33 +1,0 @@
1-import type { RenderElement } from 'claude-code'
2-
3-import type PaneState from '../../pane-state'
4-import Detail from '../detail'
5-import Entries from '../entries'
6-import type { Kit } from '../kit'
7-import Sections from '../sections'
8-
9-/**
10- * The inline pane's one body: a rule, then the selected file's detail with
11- * the whole body budget to itself; nothing when no file is selected.
12- *
13- * @param kit the elements, the handlers, the width
14- * @param entry the selected file, or null
15- * @param model which file is armed, and the backend's words
16- * @returns the rule and the detail, or none
17- */
18-export function selectedDetailOf(
19- kit: Kit,
20- entry: Entries.BodyEntry | null,
21- model: Pick<PaneState.PaneModel, 'armedPath' | 'words'>,
22-): RenderElement[] {
23- if (!entry) {
24- return []
25- }
26-
27- const drawn = Detail.detailView(kit, Entries.detailModelOf(entry, model), {
28- room: Detail.BODY_BUDGET,
29- onToggleAsk: () => kit.actions.toggleAsk(entry.path),
30- })
31-
32- return [Sections.divider(kit), drawn.element]
33-}
Dmods/diff/hooks/views/sidebar-pane/body-frame/body-frame.ts+0 -17
@@ -1,17 +1,0 @@
1-import type PaneState from '../../../pane-state'
2-import type Detail from '../../detail'
3-
4-/**
5- * What drawing a run of file bodies takes besides the files: the pane's
6- * state they read, the room left, and whether a blank row parts the files.
7- */
8-export type BodyFrame = {
9- model: Pick<PaneState.PaneModel, 'armedPath' | 'words' | 'isPreSessionShown'>
10- room: Detail.BodyRoom
11-
12- /**
13- * True in the session's body, whose files the built-in parts with a blank
14- * row; false among the pre-session bodies, which it stacks flush.
15- */
16- isSpaced: boolean
17-}
Dmods/diff/hooks/views/sidebar-pane/body-frame/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './body-frame.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/sidebar-pane/drawn-blocks/drawn-blocks.ts+0 -11
@@ -1,11 +1,0 @@
1-import type { RenderElement } from 'claude-code'
2-
3-import type Detail from '../../detail'
4-
5-/**
6- * A run of file bodies as drawn, and the room left for whatever follows.
7- */
8-export type DrawnBlocks = {
9- blocks: RenderElement[]
10- room: Detail.BodyRoom
11-}
Dmods/diff/hooks/views/sidebar-pane/drawn-blocks/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './drawn-blocks.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/sidebar-pane/earlier-section-of/earlier-section-of.tsx+0 -87
@@ -1,87 +1,0 @@
1-/* @jsxRuntime classic */
2-/* @jsx h */
3-/* @jsxFrag Fragment */
4-import type { RenderElement } from 'claude-code'
5-
6-import Limits from '../../../limits'
7-import type Entries from '../../entries'
8-import type { Kit } from '../../kit'
9-import Layout from '../../layout'
10-import Sections from '../../sections'
11-import type { BodyFrame } from '../body-frame'
12-import { fileBlocksOf } from '../file-blocks-of'
13-
14-/**
15- * The pre-session section after the body (PreSessionSection): its toggle
16- * line, and once open a dim legend of every file, then their bodies.
17- *
18- * Past PRE_SESSION_BODY_CAP files a line says the bodies are hidden and
19- * the legend alone lists them.
20- *
21- * @param kit the elements, the handlers, the width
22- * @param entries the pre-session files, in order; none draws nothing
23- * @param frame whether the section is open, the armed file, the room left
24- * @returns the section's rows, none without pre-session files
25- */
26-export function earlierSectionOf(
27- kit: Kit,
28- entries: readonly Entries.BodyEntry[],
29- frame: BodyFrame,
30-): RenderElement[] {
31- const { Box, Text } = kit.ui
32-
33- if (entries.length === 0) {
34- return []
35- }
36-
37- const face = frame.model.isPreSessionShown ? 'hide' : 'show'
38- const count = Layout.plural(entries.length, 'file')
39-
40- const toggle = Sections.toggleRow(kit, 'presession', {
41- label: `+${count} edited before this session (${face})`,
42- onPress: kit.actions.togglePreSession,
43- })
44-
45- if (!frame.model.isPreSessionShown) {
46- return [toggle]
47- }
48-
49- const nameRoom = Math.max(
50- kit.columns - Sections.STAT_CELLS,
51- Sections.PATH_FLOOR,
52- )
53-
54- const legend = entries.map(entry => (
55- <Box flexDirection="row">
56- {[
57- <Text dimColor wrap="truncate-end">
58- {Layout.truncateStart(
59- Layout.sanitizeName(entry.displayPath),
60- nameRoom,
61- )}
62- </Text>,
63- <Box flexGrow={1} />,
64- Sections.diffStat(kit, entry.added, entry.removed),
65- ]}
66- </Box>
67- ))
68-
69- const isCapped = entries.length > Limits.PRE_SESSION_BODY_CAP
70-
71- const bodies = isCapped
72- ? [
73- Sections.dimNote(
74- kit,
75- `diffs hidden above ${Limits.PRE_SESSION_BODY_CAP} files`,
76- ),
77- ]
78- : fileBlocksOf(kit, entries, { ...frame, isSpaced: false }).blocks
79-
80- return [
81- toggle,
82- <Box flexDirection="column" marginTop={1}>
83- {legend}
84- </Box>,
85- ...bodies,
86- ]
87-}
Dmods/diff/hooks/views/sidebar-pane/earlier-section-of/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './earlier-section-of.jsx'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/sidebar-pane/file-blocks-of/file-blocks-of.tsx+0 -54
@@ -1,54 +1,0 @@
1-/* @jsxRuntime classic */
2-/* @jsx h */
3-/* @jsxFrag Fragment */
4-import type { RenderElement } from 'claude-code'
5-
6-import Detail from '../../detail'
7-import Entries from '../../entries'
8-import type { Kit } from '../../kit'
9-import Sections from '../../sections'
10-import type { BodyFrame } from '../body-frame'
11-import type { DrawnBlocks } from '../drawn-blocks'
12-
13-/**
14- * Every listed file's detail, one under the other as ReplDiffSidebar's
15- * body stacks them: a rule, the file (Detail.detailView), a gap if spaced.
16- *
17- * Each block is keyed (Sections.bodyKeyOf) so a row's press scrolls to it.
18- * The bodies share one budget in drawing order; a file it runs out on
19- * draws truncated, as its footer says.
20- *
21- * @param kit the elements, the handlers, the width
22- * @param entries the files, in drawing order
23- * @param frame the pane's state the details read, and the room they share
24- * @returns the blocks, and the room left after the last
25- */
26-export function fileBlocksOf(
27- kit: Kit,
28- entries: readonly Entries.BodyEntry[],
29- frame: BodyFrame,
30-): DrawnBlocks {
31- const { Box } = kit.ui
32- const blocks: RenderElement[] = []
33- const gap = frame.isSpaced ? [<Box height={1} />] : []
34-
35- let { room } = frame
36-
37- for (const entry of entries) {
38- const drawn = Detail.detailView(
39- kit,
40- Entries.detailModelOf(entry, frame.model),
41- { room, onToggleAsk: () => kit.actions.toggleAsk(entry.path) },
42- )
43-
44- room = drawn.room
45-
46- blocks.push(
47- <Box key={Sections.bodyKeyOf(entry.path)} flexDirection="column">
48- {[Sections.divider(kit), drawn.element, ...gap]}
49- </Box>,
50- )
51- }
52-
53- return { blocks, room }
54-}
Dmods/diff/hooks/views/sidebar-pane/file-blocks-of/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './file-blocks-of.jsx'
2-
3-export * as default from '.'
@@ -1,8 +1,3 @@
1-export * from './body-frame'
2-export * from './drawn-blocks'
3-export * from './earlier-section-of'
4-export * from './file-blocks-of'
5-export * from './list-rows-of'
61 export * from './sidebar-pane.jsx'
72
83 export * as default from '.'
Dmods/diff/hooks/views/sidebar-pane/list-rows-of/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './list-rows-of.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/sidebar-pane/list-rows-of/list-rows-of.ts+0 -57
@@ -1,57 +1,0 @@
1-import type { RenderElement } from 'claude-code'
2-
3-import { keptOf } from '../../../kept-of'
4-import Limits from '../../../limits'
5-import type Entries from '../../entries'
6-import type { Kit } from '../../kit'
7-import Layout from '../../layout'
8-import Sections from '../../sections'
9-
10-/**
11- * The docked list (ReplDiffSidebar's summary rows): MAX_SUMMARY_ROWS files
12- * as rows that scroll the body to their file, then a dim count of the rest.
13- *
14- * The rest are the rows below the window and the files past the row cap.
15- * The built-in moves this window with the wheel over it or a chord; a pane
16- * has neither, so the window stays at the top and names no key.
17- *
18- * @param kit the elements, the handlers, the width
19- * @param entries the listed files, in order
20- * @param notShown how many session files fell past the row cap
21- * @returns the rows
22- */
23-export function listRowsOf(
24- kit: Kit,
25- entries: readonly Entries.BodyEntry[],
26- notShown: number,
27-): RenderElement[] {
28- const shown = entries.slice(0, Limits.MAX_SUMMARY_ROWS)
29- const moreBelow = entries.length - shown.length
30-
31- const rows = shown.map(entry =>
32- Sections.fileRow(
33- kit,
34- {
35- key: Sections.fileKeyOf(entry.path),
36- path: entry.path,
37- displayPath: entry.displayPath,
38- added: entry.added,
39- removed: entry.removed,
40- note: null,
41- isSelected: false,
42- },
43- () => kit.actions.selectFile(entry.path),
44- ),
45- )
46-
47- const counts = keptOf([
48- moreBelow > 0 ? `${moreBelow} more below` : null,
49- notShown > 0 ? `${notShown} not shown` : null,
50- ])
51-
52- const lead = moreBelow > 0 ? '↓ ' : '… '
53- const hasCounts = counts.length > 0
54- const more = Layout.sanitizeName(`${lead}${counts.join(' · ')}`)
55-
56- return hasCounts ? [...rows, Sections.dimNote(kit, more)] : rows
57-}
@@ -3,26 +3,21 @@
33 /* @jsxFrag Fragment */
44 import type { RenderElement } from 'claude-code'
55
6-import Names from '../../names'
7-import PaneState from '../../pane-state'
8-import CurrentPane from '../current-pane'
9-import Detail from '../detail'
10-import Entries from '../entries'
6+import { keptOf } from '../../kept-of'
7+import type PaneState from '../../pane-state'
8+import Body from '../body'
9+import DialogPane from '../dialog-pane'
1110 import type { Kit } from '../kit'
12-import Layout from '../layout'
1311 import { preludeOf } from '../prelude-of'
1412 import Sections from '../sections'
15-import { earlierSectionOf } from './earlier-section-of'
16-import { fileBlocksOf } from './file-blocks-of'
17-import { listRowsOf } from './list-rows-of'
1813
1914 /**
20- * The docked pane (ReplDiffSidebarBody): header, pickers, todo bar, the
21- * list, then every listed file's hunks stacked, the pre-session after.
15+ * The docked pane (ReplDiffSidebarBody): header, base or turn line, todo
16+ * bar and list pinned; beneath them the window over every file's hunks.
2217 *
23- * A row's press scrolls the body to its file. Over a picked turn the rows
24- * and bodies are that turn's edits. With nothing to list, messagePaneOf
25- * centers the empty state under the pickers.
18+ * An empty Button in the header carries the built-in's base chord. The
19+ * window starts at the row the person scrolled to; with nothing to list
20+ * and the pre-session section shut, messagePaneOf centers the empty state.
2621 *
2722 * @param kit the elements, the handlers, the width, the rows
2823 * @param model the pane's state
@@ -32,114 +27,78 @@
3227 kit: Kit,
3328 model: PaneState.PaneModel,
3429 ): RenderElement {
35- const { Box } = kit.ui
36- const { data } = model
37- const turn = PaneState.pickedTurnOf(model)
38-
30+ const { Box, Button } = kit.ui
3931 const prelude = preludeOf(kit, model)
4032
4133 if (prelude) {
4234 return prelude
4335 }
4436
45- const noise = model.isNoiseShown ? 'shown' : 'hidden'
46- const partition = PaneState.partitionOf(data?.files ?? [], noise)
37+ const plan = Body.dockPlanOf(model)
38+ const isListed = plan.message.length === 0
4739
48- const totals = turn
49- ? PaneState.turnTotalsOf(turn)
50- : data
51- ? PaneState.headerTotalsOf(data, partition)
52- : PaneState.ZERO_TOTALS
40+ const isCentered =
41+ !isListed && !(model.isPreSessionShown && plan.earlier.length > 0)
5342
54- const empty = turn ? null : PaneState.emptyStateOf(model, totals.filesCount)
43+ const rows = Body.listBlockOf(kit, model, plan)
44+ const onPress = kit.actions.togglePreSession
45+ const hasListRows = rows.length > 0
5546
56- const noteOf = (text: string | null): RenderElement | null =>
57- text === null ? null : Sections.dimNote(kit, Layout.sanitizeName(text))
47+ const top = Sections.present([
48+ Sections.headerView(
49+ kit,
50+ plan.empty ? null : plan.totals,
51+ <Box flexDirection="row">
52+ {Sections.present([
53+ Sections.sourcePicker(kit, model),
54+ <Button
55+ key="base"
56+ plain
57+ action="app:cycleDiffBase"
58+ onPress={kit.actions.cycleBase}
59+ >
60+ {''}
61+ </Button>,
62+ ])}
63+ </Box>,
64+ ),
65+ plan.line === null ? null : Sections.dimNote(kit, plan.line),
66+ Sections.todoBar(kit, model),
67+ ])
5868
59- const subline = turn
60- ? noteOf(`Turn ${turn.index} "${turn.preview}"`)
61- : noteOf(PaneState.unbornNoteOf(model, totals.filesCount))
62-
63- const entries = turn
64- ? turn.files.map(Entries.turnEntryOf)
65- : partition.shown.map(file => Entries.bodyEntryOf(file, model))
66-
67- const earlier = turn
68- ? []
69- : partition.preSession.map(file => Entries.bodyEntryOf(file, model))
70-
71- const listed = turn
72- ? entries
73- : CurrentPane.listBodyOf(kit, { data, partition, totals, empty })
74-
75- const notes = listed.flatMap(line => (typeof line === 'string' ? [line] : []))
76- const hasRows = notes.length < listed.length
77- const message = empty ? [empty.headline, ...notes] : hasRows ? [] : notes
78-
79- const header = Sections.headerView(
80- kit,
81- empty ? null : totals,
82- Sections.controlsView(kit, model),
83- )
84-
85- const hasNoise = !turn && partition.noiseCount > 0
86- const noiseFace = model.isNoiseShown ? 'hide' : 'show'
87-
88- const noiseToggle = hasNoise
89- ? Sections.toggleRow(kit, 'noise', {
90- label:
91- `${Layout.plural(partition.noiseCount, 'test')}/generated ` +
92- `(${noiseFace})`,
93- onPress: kit.actions.toggleNoise,
94- })
95- : null
96-
97- const isUntrackedNoted =
98- !turn && data?.isUntrackedWithheld === true && empty === null
99-
100- const untrackedNote = noteOf(
101- isUntrackedNoted ? Names.untrackedWithheldTextOf(model) : null,
102- )
103-
104- const body = fileBlocksOf(kit, hasRows ? entries : [], {
105- model,
106- room: Detail.BODY_BUDGET,
107- isSpaced: true,
108- })
109-
110- const earlierRows = earlierSectionOf(kit, earlier, {
111- model,
112- room: body.room,
113- isSpaced: false,
114- })
115-
116- const top = Sections.present([header, subline, Sections.todoBar(kit, model)])
117-
118- const isMessageShown = message.length > 0
119-
120- if (isMessageShown) {
121- return CurrentPane.messagePaneOf(kit, {
122- top: Sections.present([...top, noiseToggle, untrackedNote]),
123- message,
69+ if (isCentered) {
70+ return DialogPane.messagePaneOf(kit, {
71+ top: [...top, ...rows],
72+ message: plan.message,
12473 controls: null,
125- earlier: earlierRows[0] ?? null,
126- rest: earlierRows.slice(1),
74+ earlier:
75+ keptOf([plan.earlierLabel]).map(label =>
76+ Sections.toggleRow(kit, 'presession', { label, onPress }),
77+ )[0] ?? null,
78+ rest: [],
12779 })
12880 }
12981
130- const list = (
131- <Box flexDirection="column" marginTop={1} marginBottom={1}>
132- {Sections.present([
133- ...listRowsOf(kit, entries, totals.notShown),
134- untrackedNote,
135- noiseToggle,
136- ])}
137- </Box>
138- )
82+ const listOf = (): RenderElement =>
83+ hasListRows ? (
84+ <Box flexDirection="column" marginTop={1} marginBottom={1}>
85+ {rows}
86+ </Box>
87+ ) : (
88+ <Box height={1} />
89+ )
13990
14091 return (
14192 <Box flexDirection="column">
142- {[...top, list, ...body.blocks, ...earlierRows]}
93+ {[
94+ ...top,
95+ ...(isListed ? [listOf()] : []),
96+ ...Body.drawWindow(
97+ kit,
98+ Body.bodyLayoutOf(model, plan),
99+ model.place.top,
100+ ),
101+ ]}
143102 </Box>
144103 )
145104 }
Dmods/diff/hooks/views/turn-pane/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './turn-pane.jsx'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/turn-pane/turn-pane.tsx+0 -67
@@ -1,67 +1,0 @@
1-/* @jsxRuntime classic */
2-/* @jsx h */
3-/* @jsxFrag Fragment */
4-import type { RenderElement } from 'claude-code'
5-
6-import PaneState from '../../pane-state'
7-import type Turns from '../../turns'
8-import Entries from '../entries'
9-import type { Kit } from '../kit'
10-import Sections from '../sections'
11-import { selectedDetailOf } from '../selected-detail-of'
12-
13-/**
14- * The inline pane over one past turn's edits (DiffDialog's T<n> tab): its
15- * counts, its prompt's opening words, the pickers, its files, one body.
16- *
17- * @param kit the elements, the handlers, the width
18- * @param model the pane's state
19- * @param turn the turn picked
20- * @returns the pane's tree
21- */
22-export function turnPane(
23- kit: Kit,
24- model: PaneState.PaneModel,
25- turn: Turns.TurnDiff,
26-): RenderElement {
27- const { Box } = kit.ui
28-
29- const selected =
30- turn.files.find(file => file.path === model.selectedPath) ??
31- turn.files[0] ??
32- null
33-
34- const rows = turn.files.map(file =>
35- Sections.dialogFileRow(
36- kit,
37- {
38- key: Sections.fileKeyOf(file.path),
39- path: file.path,
40- displayPath: file.path,
41- added: file.added,
42- removed: file.removed,
43- note: null,
44- isSelected: file.path === selected?.path,
45- },
46- () => kit.actions.selectFile(file.path),
47- ),
48- )
49-
50- const detail = selectedDetailOf(
51- kit,
52- selected ? Entries.turnEntryOf(selected) : null,
53- model,
54- )
55-
56- return (
57- <Box flexDirection="column">
58- {Sections.present([
59- Sections.titleRow(kit, PaneState.dialogTitleOf(model)),
60- Sections.headerView(kit, PaneState.turnTotalsOf(turn), null),
61- Sections.controlsView(kit, model),
62- ...rows,
63- ...detail,
64- ])}
65- </Box>
66- )
67-}