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
| @@ -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 @@ | ||
| 8 | 8 | export * from './git-backend-of.js' |
| 9 | 9 | export * from './git-base-modes' |
| 10 | 10 | export * from './git-words' |
| 11 | +export * from './hunk-after.js' | |
| 11 | 12 | export * from './hunk-of' |
| 12 | 13 | export * from './large-file-hunks' |
| 13 | 14 | export * from './parse' |
| @@ -9,7 +9,6 @@ | ||
| 9 | 9 | ProcessRunResult, |
| 10 | 10 | SessionMessage, |
| 11 | 11 | TimerCall, |
| 12 | - UiScrollResult, | |
| 13 | 12 | } from 'claude-code' |
| 14 | 13 | |
| 15 | 14 | /** |
| @@ -96,12 +95,6 @@ | ||
| 96 | 95 | closePane: (pane: PaneCloseArgs) => Promise<void> |
| 97 | 96 | |
| 98 | 97 | /** |
| 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 | - /** | |
| 105 | 98 | * `$.command.register`; rejects while another `/diff` is listed. |
| 106 | 99 | */ |
| 107 | 100 | registerCommand: (spec: CommandSpec) => Promise<unknown> |
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | export * from './max-lines-per-file.js' |
| 5 | 5 | export * from './max-listed-directories.js' |
| 6 | 6 | export * from './max-untracked-probes.js' |
| 7 | +export * from './max-visible-files.js' | |
| 7 | 8 | export * from './pane' |
| 8 | 9 | export * from './prompt-context-max-chars.js' |
| 9 | 10 |
| @@ -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 @@ | ||
| 1 | 1 | export * from './body-fetch-concurrency.js' |
| 2 | -export * from './max-dialog-rows.js' | |
| 2 | +export * from './gutter-chrome.js' | |
| 3 | 3 | export * from './max-summary-rows.js' |
| 4 | 4 | export * from './pane-top-pad-rows.js' |
| 5 | 5 | export * from './pre-session-body-cap.js' |
| 6 | +export * from './scroll-margin-rows.js' | |
| 7 | +export * from './wheel-rows.js' | |
| 6 | 8 | |
| 7 | 9 | export * as default from '.' |
| @@ -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 @@ | ||
| 1 | 1 | export * from './builtin-holds-pattern.js' |
| 2 | 2 | export * from './git-unanswered-text.js' |
| 3 | +export * from './more-below-text.js' | |
| 3 | 4 | export * from './not-in-repository-text.js' |
| 4 | 5 | export * from './panel-toggle' |
| 5 | 6 | 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' | |
| 1 | 2 | export * from './panel-hidden-text.js' |
| 2 | 3 | export * from './panel-shown-text.js' |
| 3 | 4 |
| @@ -9,14 +9,12 @@ | ||
| 9 | 9 | export * from './header-totals' |
| 10 | 10 | export * from './header-totals-of.js' |
| 11 | 11 | export * from './initial-model' |
| 12 | -export * from './listed-of.js' | |
| 13 | 12 | export * from './mode-label-of' |
| 14 | 13 | export * from './no-bodies' |
| 15 | 14 | export * from './pane-model' |
| 16 | 15 | export * from './partition' |
| 17 | 16 | export * from './partition-of.js' |
| 18 | 17 | export * from './picked-turn-of' |
| 19 | -export * from './selection-of' | |
| 20 | 18 | export * from './source' |
| 21 | 19 | export * from './turn-title-of' |
| 22 | 20 | export * from './turn-totals-of' |
| @@ -23,4 +23,7 @@ | ||
| 23 | 23 | todos: Object.freeze({ done: 0, total: 0 }), |
| 24 | 24 | armedPath: null, |
| 25 | 25 | placement: 'dock', |
| 26 | + isFullscreen: null, | |
| 27 | + dialogView: 'list', | |
| 28 | + place: Object.freeze({ top: 0, listStart: 0, columns: 0, rows: 0 }), | |
| 26 | 29 | }) |
| @@ -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 @@ | ||
| 36 | 36 | return `branch vs ${source.baseBranch}` |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | - return isSettledBranch ? `vs ${base} (no base branch)` : 'branch' | |
| 39 | + return isSettledBranch ? `vs ${base} (no base branch)` : 'branch diff' | |
| 40 | 40 | } |
| 41 | 41 | } |
| 42 | 42 |
| @@ -8,9 +8,9 @@ | ||
| 8 | 8 | /** |
| 9 | 9 | * Everything one drawing of the pane reads. |
| 10 | 10 | * |
| 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. | |
| 14 | 14 | */ |
| 15 | 15 | export type PaneModel = { |
| 16 | 16 | words: Backend.BackendWords |
| @@ -29,4 +29,7 @@ | ||
| 29 | 29 | todos: Todos.TodoProgress |
| 30 | 30 | armedPath: string | null |
| 31 | 31 | placement: 'dock' | 'inline' |
| 32 | + isFullscreen: boolean | null | |
| 33 | + dialogView: 'list' | 'detail' | |
| 34 | + place: { top: number; listStart: number; columns: number; rows: number } | |
| 32 | 35 | } |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './selection-of.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -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 @@ | ||
| 343 | 343 | engine: Host, |
| 344 | 344 | trigger: (typeof Record.SHOWN_TRIGGERS)[number], |
| 345 | 345 | ): 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 | + | |
| 348 | 362 | isPaneOpen = true |
| 349 | 363 | |
| 350 | 364 | const sessionId = await engine.sessionId().catch(() => null) |
| @@ -388,6 +402,7 @@ | ||
| 388 | 402 | |
| 389 | 403 | const isEligible = |
| 390 | 404 | preference !== false && |
| 405 | + model.isFullscreen !== false && | |
| 391 | 406 | columns !== null && |
| 392 | 407 | columns >= floor && |
| 393 | 408 | backend !== null |
| @@ -408,14 +423,27 @@ | ||
| 408 | 423 | |
| 409 | 424 | const actionsOf = (engine: Host): Views.PaneActions => ({ |
| 410 | 425 | selectFile: path => { |
| 411 | - model = { ...model, selectedPath: path } | |
| 426 | + const isDocked = model.placement === 'dock' | |
| 412 | 427 | |
| 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, | |
| 415 | 433 | } |
| 416 | 434 | |
| 417 | 435 | redraw(engine) |
| 418 | 436 | }, |
| 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 | + }, | |
| 419 | 447 | toggleNoise: () => { |
| 420 | 448 | model = { ...model, isNoiseShown: !model.isNoiseShown } |
| 421 | 449 | void loadBodies(engine) |
| @@ -426,10 +454,14 @@ | ||
| 426 | 454 | void loadBodies(engine) |
| 427 | 455 | redraw(engine) |
| 428 | 456 | }, |
| 429 | - chooseBase: value => { | |
| 430 | - const mode = PaneState.baseModeOf(value) | |
| 457 | + cycleBase: () => { | |
| 458 | + const { baseModes, requestedMode } = model | |
| 431 | 459 | |
| 432 | - if (!mode || mode === model.requestedMode) { | |
| 460 | + const mode = | |
| 461 | + baseModes[(baseModes.indexOf(requestedMode) + 1) % baseModes.length] ?? | |
| 462 | + requestedMode | |
| 463 | + | |
| 464 | + if (mode === requestedMode) { | |
| 433 | 465 | return |
| 434 | 466 | } |
| 435 | 467 | |
| @@ -459,7 +491,7 @@ | ||
| 459 | 491 | ? { kind: 'turn', index } |
| 460 | 492 | : { kind: 'current' } |
| 461 | 493 | |
| 462 | - model = { ...model, source, selectedPath: null } | |
| 494 | + model = { ...model, source, selectedPath: null, dialogView: 'list' } | |
| 463 | 495 | redraw(engine) |
| 464 | 496 | }, |
| 465 | 497 | toggleAsk: path => { |
| @@ -529,8 +561,6 @@ | ||
| 529 | 561 | uiLog: text => $.ui.log(text), |
| 530 | 562 | openPane: pane => $.ui.open(pane), |
| 531 | 563 | closePane: pane => $.ui.close(pane), |
| 532 | - scrollTo: key => | |
| 533 | - $.ui.scroll({ to: { key }, in: Names.PANE_ID, block: 'start' }), | |
| 534 | 564 | registerCommand: spec => $.command.register(spec), |
| 535 | 565 | sessionId: () => $.session.id(), |
| 536 | 566 | mark: entry => $.telemetry.mark(entry), |
| @@ -556,8 +586,20 @@ | ||
| 556 | 586 | const { Box, Text, Button, Select, Code } = await $.ui.resolve(e) |
| 557 | 587 | |
| 558 | 588 | columns = e.viewport?.columns ?? columns |
| 559 | - model = { ...model, placement: e.props.placement } | |
| 560 | 589 | |
| 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 | + | |
| 561 | 603 | return Views.paneView( |
| 562 | 604 | { |
| 563 | 605 | ui: { Box, Text, Button, Select, Code }, |
| @@ -585,14 +627,27 @@ | ||
| 585 | 627 | } |
| 586 | 628 | } |
| 587 | 629 | |
| 588 | - const toggle = PaneToggle.paneToggleOf({ isOpen: isPaneOpen, columns }) | |
| 630 | + const { isFullscreen } = e.presentation | |
| 589 | 631 | |
| 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 | + | |
| 590 | 640 | if (toggle === 'too-narrow') { |
| 591 | 641 | return { text: Names.RESIZE_TERMINAL_TEXT } |
| 592 | 642 | } |
| 593 | 643 | |
| 594 | 644 | const isOpening = toggle === 'open' |
| 595 | 645 | await (isOpening ? openPane(host, 'manual') : closePane(host)) |
| 646 | + | |
| 647 | + if (!isFullscreen) { | |
| 648 | + return isOpening ? {} : { text: Names.DIALOG_DISMISSED_TEXT } | |
| 649 | + } | |
| 650 | + | |
| 596 | 651 | markTabSwitch(host, isOpening ? 'diff' : 'convo') |
| 597 | 652 | await host.storeSet(Names.STORE_OPEN_KEY, isOpening).catch(() => undefined) |
| 598 | 653 | |
| @@ -602,6 +657,28 @@ | ||
| 602 | 657 | }) |
| 603 | 658 | |
| 604 | 659 | 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 | + | |
| 605 | 682 | const result = await next(e) |
| 606 | 683 | const isClosed = result.deny === undefined |
| 607 | 684 | const isPersons = isClosed && e.origin.kind === 'person' |
| @@ -610,7 +687,13 @@ | ||
| 610 | 687 | isPaneOpen = false |
| 611 | 688 | } |
| 612 | 689 | |
| 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) { | |
| 614 | 697 | markTabSwitch(host, 'convo') |
| 615 | 698 | await host.storeSet(Names.STORE_OPEN_KEY, false).catch(() => undefined) |
| 616 | 699 | } |
| @@ -618,6 +701,19 @@ | ||
| 618 | 701 | return result |
| 619 | 702 | }) |
| 620 | 703 | |
| 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 | + | |
| 621 | 717 | on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => { |
| 622 | 718 | const result = await next(e) |
| 623 | 719 |
| @@ -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 } |
| @@ -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 | -} |
| @@ -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 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './dialog-rows-of.jsx' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -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 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './file-row-of.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -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 '.' |
| @@ -1,4 +1,0 @@ | ||
| 1 | -export * from './list-body-of.js' | |
| 2 | -export * from './list-place' | |
| 3 | - | |
| 4 | -export * as default from '.' |
| @@ -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 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export type * from './list-place.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -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 | -} |
| @@ -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 |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './foot-rows.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -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 '.' |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export type * from './message-pane.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -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 | -} |
| @@ -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 @@ | ||
| 12 | 12 | import type Types from './types' |
| 13 | 13 | |
| 14 | 14 | /** |
| 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. | |
| 17 | 17 | * |
| 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. | |
| 21 | 21 | * |
| 22 | 22 | * @param kit the elements and the width |
| 23 | 23 | * @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 | |
| 25 | 25 | * @returns the detail element and the room after it |
| 26 | 26 | */ |
| 27 | 27 | export function detailView( |
| 28 | 28 | kit: Kit, |
| 29 | 29 | detail: DetailModel, |
| 30 | - draw: Types.DetailDraw, | |
| 30 | + room: Types.BodyRoom, | |
| 31 | 31 | ): Types.DrawnDetail { |
| 32 | - const { Box, Text, Button, Code } = kit.ui | |
| 33 | - const { room, onToggleAsk } = draw | |
| 32 | + const { Box, Text, Code } = kit.ui | |
| 34 | 33 | const placeholder = placeholderOf(detail) |
| 35 | - const name = Layout.sanitizeName(detail.displayPath) | |
| 36 | 34 | |
| 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, | |
| 39 | 37 | nodes: room.nodes - FILE_FRAME_NODES, |
| 40 | - } | |
| 38 | + }) | |
| 41 | 39 | |
| 42 | - const code = codeBlocksOf( | |
| 43 | - placeholder ? [] : (detail.body?.hunks ?? []), | |
| 44 | - framed, | |
| 45 | - ) | |
| 46 | - | |
| 47 | 40 | const isTruncated = detail.body?.isTruncated === true || code.isTruncated |
| 48 | 41 | const path = Layout.sanitizeName(detail.path).slice(-MAX_CODE_CHARS) |
| 49 | 42 | |
| 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 | - | |
| 65 | 43 | const footer = isTruncated |
| 66 | 44 | ? [ |
| 67 | 45 | <Text dimColor italic> |
| @@ -83,16 +61,11 @@ | ||
| 83 | 61 | const element = ( |
| 84 | 62 | <Box flexDirection="column"> |
| 85 | 63 | {[ |
| 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 | + }), | |
| 96 | 69 | Sections.divider(kit), |
| 97 | 70 | ...(placeholder ? notes : hunks), |
| 98 | 71 | ...footer, |
| @@ -1,5 +1,4 @@ | ||
| 1 | -import { countOf } from '../../../count-of' | |
| 2 | -import type Git from '../../../git' | |
| 1 | +import Git from '../../../git' | |
| 3 | 2 | import { leadingHunkOf } from '../leading-hunk-of' |
| 4 | 3 | import { firstLineCut } from './first-line-cut' |
| 5 | 4 | |
| @@ -31,16 +30,9 @@ | ||
| 31 | 30 | ? { ...rest, lines: [firstLineCut(rest, maxChars)] } |
| 32 | 31 | : leading |
| 33 | 32 | |
| 34 | - const taken = rest.lines.slice(0, piece.lines.length) | |
| 35 | - | |
| 36 | 33 | hunks.push(piece) |
| 37 | 34 | 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) | |
| 44 | 36 | } |
| 45 | 37 | |
| 46 | 38 | return { hunks, isTruncated } |
| @@ -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 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export type * from './detail-draw.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | export * from './body-room' |
| 2 | 2 | export * from './code-body' |
| 3 | -export * from './detail-draw' | |
| 4 | 3 | export * from './drawn-detail' |
| 4 | +export * from './named-detail' | |
| 5 | 5 | |
| 6 | 6 | 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' | |
| 2 | 2 | export * from './detail' |
| 3 | +export * from './dialog-pane' | |
| 3 | 4 | export * from './entries' |
| 4 | 5 | export * from './inset-of' |
| 5 | 6 | export * from './kit' |
| @@ -9,8 +10,6 @@ | ||
| 9 | 10 | export * from './pane-view.jsx' |
| 10 | 11 | export * from './prelude-of' |
| 11 | 12 | export * from './sections' |
| 12 | -export * from './selected-detail-of' | |
| 13 | 13 | export * from './sidebar-pane' |
| 14 | -export * from './turn-pane' | |
| 15 | 14 | |
| 16 | 15 | export * as default from '.' |
| @@ -4,12 +4,18 @@ | ||
| 4 | 4 | */ |
| 5 | 5 | export type PaneActions = { |
| 6 | 6 | /** |
| 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. | |
| 9 | 9 | */ |
| 10 | 10 | selectFile: (path: string) => void |
| 11 | 11 | |
| 12 | 12 | /** |
| 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 | + /** | |
| 13 | 19 | * Shows or hides the tests and generated files. |
| 14 | 20 | */ |
| 15 | 21 | toggleNoise: () => void |
| @@ -20,10 +26,9 @@ | ||
| 20 | 26 | togglePreSession: () => void |
| 21 | 27 | |
| 22 | 28 | /** |
| 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. | |
| 25 | 30 | */ |
| 26 | - chooseBase: (value: string) => void | |
| 31 | + cycleBase: () => void | |
| 27 | 32 | |
| 28 | 33 | /** |
| 29 | 34 | * Picks the source by its Select value: `current` or a turn's number. |
| @@ -5,22 +5,21 @@ | ||
| 5 | 5 | |
| 6 | 6 | import Limits from '../limits' |
| 7 | 7 | 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' | |
| 10 | 10 | import { insetOf } from './inset-of' |
| 11 | 11 | import type { Kit } from './kit' |
| 12 | 12 | import type { PaneSeat } from './pane-seat' |
| 13 | 13 | import Sections from './sections' |
| 14 | 14 | import { sidebarPane } from './sidebar-pane' |
| 15 | -import { turnPane } from './turn-pane' | |
| 16 | 15 | |
| 17 | 16 | /** |
| 18 | 17 | * The diff pane's body for one `ui.render`: docked, the built-in panel's |
| 19 | 18 | * layout (sidebarPane); inline, its dialog's, or its wider-terminal line. |
| 20 | 19 | * |
| 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. | |
| 24 | 23 | * |
| 25 | 24 | * @param kit the elements, the handlers, the width |
| 26 | 25 | * @param model the pane's state |
| @@ -36,15 +35,10 @@ | ||
| 36 | 35 | const isDocked = seat.placement === 'dock' |
| 37 | 36 | |
| 38 | 37 | const isNarrow = |
| 38 | + model.isFullscreen !== false && | |
| 39 | 39 | seat.terminalColumns !== null && |
| 40 | 40 | seat.terminalColumns < Limits.OPEN_MIN_COLUMNS |
| 41 | 41 | |
| 42 | - function dialogOf(): RenderElement { | |
| 43 | - const turn = PaneState.pickedTurnOf(model) | |
| 44 | - | |
| 45 | - return turn ? turnPane(kit, model, turn) : currentPane(kit, model) | |
| 46 | - } | |
| 47 | - | |
| 48 | 42 | return isDocked ? ( |
| 49 | 43 | <Box |
| 50 | 44 | flexDirection="column" |
| @@ -56,6 +50,6 @@ | ||
| 56 | 50 | ) : isNarrow ? ( |
| 57 | 51 | <Box>{Sections.dimNote(kit, Names.RESIZE_TERMINAL_TEXT)}</Box> |
| 58 | 52 | ) : ( |
| 59 | - <Box flexDirection="column">{dialogOf()}</Box> | |
| 53 | + dialogPane(kit, model) | |
| 60 | 54 | ) |
| 61 | 55 | } |
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | |
| 6 | 6 | import Names from '../../names' |
| 7 | 7 | import type PaneState from '../../pane-state' |
| 8 | -import CurrentPane from '../current-pane' | |
| 8 | +import DialogPane from '../dialog-pane' | |
| 9 | 9 | import type { Kit } from '../kit' |
| 10 | 10 | import Sections from '../sections' |
| 11 | 11 | |
| @@ -34,7 +34,7 @@ | ||
| 34 | 34 | ) |
| 35 | 35 | |
| 36 | 36 | const loadingOf = (): RenderElement => |
| 37 | - CurrentPane.messagePaneOf(kit, { | |
| 37 | + DialogPane.messagePaneOf(kit, { | |
| 38 | 38 | top: [Sections.headerView(kit, null, null)], |
| 39 | 39 | message: ['Loading diff…'], |
| 40 | 40 | controls: null, |
| @@ -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)}` |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './body-key-of.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -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 @@ | ||
| 40 | 40 | const hasNote = row.note !== null |
| 41 | 41 | const tail = hasNote ? note : diffStat(kit, row.added, row.removed) |
| 42 | 42 | |
| 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 | + ) | |
| 44 | 48 | } |
| @@ -1,9 +1,11 @@ | ||
| 1 | +import type { BodyEntry } from '../../entries' | |
| 1 | 2 | import Layout from '../../layout' |
| 2 | 3 | |
| 3 | 4 | /** |
| 4 | 5 | * A file row's Button address: the path made printable, under `file:`. |
| 5 | 6 | * |
| 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 | |
| 7 | 8 | * @returns the key |
| 8 | 9 | */ |
| 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 @@ | ||
| 36 | 36 | |
| 37 | 37 | return listRow( |
| 38 | 38 | kit, |
| 39 | - { key: row.key, label, onPress }, | |
| 39 | + { key: row.key, label, onPress, isDim: true }, | |
| 40 | 40 | diffStat(kit, row.added, row.removed), |
| 41 | 41 | ) |
| 42 | 42 | } |
| @@ -1,6 +1,4 @@ | ||
| 1 | 1 | export * from './ask-key-of' |
| 2 | -export * from './body-key-of' | |
| 3 | -export * from './controls-view.jsx' | |
| 4 | 2 | export * from './dialog-file-row' |
| 5 | 3 | export * from './dialog-path-floor' |
| 6 | 4 | export * from './dialog-path-reserve' |
| @@ -12,11 +10,15 @@ | ||
| 12 | 10 | export * from './file-row-model' |
| 13 | 11 | export * from './header-view.jsx' |
| 14 | 12 | export * from './keyed-pressable' |
| 13 | +export * from './legend-row.jsx' | |
| 14 | +export * from './list-edge-button' | |
| 15 | 15 | export * from './list-row' |
| 16 | +export * from './name-row' | |
| 16 | 17 | export * from './path-floor' |
| 17 | 18 | export * from './pointer' |
| 18 | 19 | export * from './present' |
| 19 | 20 | export * from './pressable' |
| 21 | +export * from './source-picker.jsx' | |
| 20 | 22 | export * from './stat-cells' |
| 21 | 23 | export * from './stat-text-of' |
| 22 | 24 | export * from './title-row.jsx' |
| @@ -1,8 +1,10 @@ | ||
| 1 | 1 | import type { Pressable } from '../pressable' |
| 2 | 2 | |
| 3 | 3 | /** |
| 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. | |
| 5 | 6 | */ |
| 6 | 7 | export type KeyedPressable = Pressable & { |
| 7 | 8 | key: string |
| 9 | + isDim: boolean | |
| 8 | 10 | } |
| @@ -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 @@ | ||
| 11 | 11 | * row shows at its right edge (its counts, a note). |
| 12 | 12 | * |
| 13 | 13 | * @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 | |
| 15 | 15 | * @param tail the element at the right edge |
| 16 | 16 | * @returns the row element |
| 17 | 17 | */ |
| @@ -24,7 +24,7 @@ | ||
| 24 | 24 | |
| 25 | 25 | return ( |
| 26 | 26 | <Box flexDirection="row"> |
| 27 | - <Button key={row.key} plain onPress={row.onPress}> | |
| 27 | + <Button key={row.key} plain dimColor={row.isDim} onPress={row.onPress}> | |
| 28 | 28 | {row.label} |
| 29 | 29 | </Button> |
| 30 | 30 | <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 @@ | ||
| 24 | 24 | |
| 25 | 25 | return ( |
| 26 | 26 | <Box flexDirection="row"> |
| 27 | - <Button key={key} plain onPress={pressable.onPress}> | |
| 27 | + <Button key={key} plain dimColor onPress={pressable.onPress}> | |
| 28 | 28 | {pressable.label} |
| 29 | 29 | </Button> |
| 30 | 30 | </Box> |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './selected-detail-of.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -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 | -} |
| @@ -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 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export type * from './body-frame.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -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 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export type * from './drawn-blocks.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -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 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './earlier-section-of.jsx' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -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 | -} |
| @@ -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' | |
| 6 | 1 | export * from './sidebar-pane.jsx' |
| 7 | 2 | |
| 8 | 3 | export * as default from '.' |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './list-rows-of.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -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 @@ | ||
| 3 | 3 | /* @jsxFrag Fragment */ |
| 4 | 4 | import type { RenderElement } from 'claude-code' |
| 5 | 5 | |
| 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' | |
| 11 | 10 | import type { Kit } from '../kit' |
| 12 | -import Layout from '../layout' | |
| 13 | 11 | import { preludeOf } from '../prelude-of' |
| 14 | 12 | 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' | |
| 18 | 13 | |
| 19 | 14 | /** |
| 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. | |
| 22 | 17 | * |
| 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. | |
| 26 | 21 | * |
| 27 | 22 | * @param kit the elements, the handlers, the width, the rows |
| 28 | 23 | * @param model the pane's state |
| @@ -32,114 +27,78 @@ | ||
| 32 | 27 | kit: Kit, |
| 33 | 28 | model: PaneState.PaneModel, |
| 34 | 29 | ): RenderElement { |
| 35 | - const { Box } = kit.ui | |
| 36 | - const { data } = model | |
| 37 | - const turn = PaneState.pickedTurnOf(model) | |
| 38 | - | |
| 30 | + const { Box, Button } = kit.ui | |
| 39 | 31 | const prelude = preludeOf(kit, model) |
| 40 | 32 | |
| 41 | 33 | if (prelude) { |
| 42 | 34 | return prelude |
| 43 | 35 | } |
| 44 | 36 | |
| 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 | |
| 47 | 39 | |
| 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) | |
| 53 | 42 | |
| 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 | |
| 55 | 46 | |
| 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 | + ]) | |
| 58 | 68 | |
| 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, | |
| 124 | 73 | 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: [], | |
| 127 | 79 | }) |
| 128 | 80 | } |
| 129 | 81 | |
| 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 | + ) | |
| 139 | 90 | |
| 140 | 91 | return ( |
| 141 | 92 | <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 | + ]} | |
| 143 | 102 | </Box> |
| 144 | 103 | ) |
| 145 | 104 | } |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './turn-pane.jsx' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -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 | -} |