Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions mods/diff/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,13 @@ opens nothing, and where the surface does not say, nothing opens by
itself. A docked pane fetches before it opens, as the built-in panel
primes its data, so it never lands on `Loading diff…`; an open the engine
leaves waiting undrawn is withdrawn, so no later resize seats it, and the
next edit asks again. A session resumed or continued whose transcript
already holds such an edit opens the pane on the same terms as soon as the
width is known, as the built-in opens on the history it restores.
next edit asks again. An open answered `no-surface` (nothing is attached
that draws it yet, as before a host's page attaches) is kept instead:
`/diff` says so, nothing polls or refreshes while no one sees the pane, and
its first drawing reads the repository again and counts it shown. A session
resumed or continued whose transcript already holds such an edit opens the
pane on the same terms as soon as the width is known, as the built-in opens
on the history it restores.

Under the fullscreen layout a terminal under 110 columns gets the
built-in's line asking for a wider one and nothing opens. Without that
Expand All @@ -37,8 +41,8 @@ where the focus ring starts; the plugin follows the ring's walk through
`ui.focus` and re-centres the rows as the built-in does), the key hints;
Enter shows that file's hunks alone,
Escape backs out to the list and then closes, leaving `Diff dialog
dismissed`; toasts are held while it is up. A file's ask button arms that
file: its hunks ride the next prompt as context, once.
dismissed`; toasts show while it is up, as beside the docked pane. A file's
ask button arms that file: its hunks ride the next prompt as context, once.

The pane compares the working tree against HEAD, split at the session's
start (the default; the start the engine gives in `$.session.usage()`, so a
Expand Down
6 changes: 6 additions & 0 deletions mods/diff/hooks/names/texts/awaits-surface-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* `/diff`'s answer where nothing is attached that draws the pane yet: it is
* kept open, and shows as soon as something is.
*/
export const AWAITS_SURFACE_TEXT =
'Nothing here can draw the diff panel yet; it opens as soon as something can'
1 change: 1 addition & 0 deletions mods/diff/hooks/names/texts/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './awaits-surface-text.js'
export * from './builtin-holds-pattern.js'
export * from './git-unanswered-text.js'
export * from './more-below-text.js'
Expand Down
4 changes: 4 additions & 0 deletions mods/diff/hooks/open-outcome/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type * from './open-outcome.js'
export * from './open-outcome-of.js'

export * as default from '.'
25 changes: 25 additions & 0 deletions mods/diff/hooks/open-outcome/open-outcome-of.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { isRecord } from '../is-record'
import type { OpenOutcome } from './open-outcome.js'

/**
* Reads what `$.ui.open` resolved: an unplaced pane is withdrawn, unless the
* reason starts `no-surface`, where the engine keeps it for a surface to come.
*
* An engine that resolves nothing, or never gives that reason, reads as
* before: placed, or withdrawn.
*
* @param opened what the open resolved
* @returns what the plugin does with the pane
*/
export function openOutcomeOf(opened: unknown): OpenOutcome {
const isUnplaced = isRecord(opened) && opened.isPlaced === false

if (!isUnplaced) {
return 'placed'
}

const isUnseen =
typeof opened.reason === 'string' && opened.reason.startsWith('no-surface')

return isUnseen ? 'awaited' : 'withdrawn'
}
5 changes: 5 additions & 0 deletions mods/diff/hooks/open-outcome/open-outcome.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* What became of an open: the pane is drawn (`placed`), kept until something
* attaches that draws it (`awaited`), or taken back (`withdrawn`).
*/
export type OpenOutcome = 'placed' | 'awaited' | 'withdrawn'
64 changes: 45 additions & 19 deletions mods/diff/hooks/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import Limits from './limits'
import { messageOf } from './message-of'
import { mtimeOf } from './mtime-of'
import Names from './names'
import OpenOutcome from './open-outcome'
import PaneState from './pane-state'
import PaneToggle from './pane-toggle'
import Record from './record'
Expand All @@ -44,6 +45,7 @@ export function register(on: On) {
let probing: Promise<boolean> | null = null
let sessionStartMs = 0
let isPaneOpen = false
let awaited: (typeof Record.SHOWN_TRIGGERS)[number] | null = null
let dialogRows: number | null = null
let hasAutoOpened = false
let hasRestoredEdits = false
Expand All @@ -69,6 +71,7 @@ export function register(on: On) {
const loggedBaseKinds = new Set<'ok' | 'sad'>()

const currentOf = (engine: Host): Host => host ?? engine
const isPaneSeen = (): boolean => isPaneOpen && awaited === null

const backendHostOf = (engine: Host): Backend.BackendHost => ({
run: (argv, init) =>
Expand Down Expand Up @@ -180,7 +183,6 @@ export function register(on: On) {
return {
id: Names.PANE_ID,
title: Names.PANE_TITLE,
holdToasts: true,
closeOnEscape: true,
rows: Views.dialogRowsOf(model),
}
Expand Down Expand Up @@ -303,7 +305,7 @@ export function register(on: On) {
timers.set(
'poll',
engine.every(Limits.HEAD_POLL_MS, () => {
if (!isPaneOpen) {
if (!isPaneSeen()) {
return
}

Expand Down Expand Up @@ -415,10 +417,20 @@ export function register(on: On) {
}
}

async function showKept(
engine: Host,
trigger: (typeof Record.SHOWN_TRIGGERS)[number],
): Promise<void> {
await markShown(engine, trigger)
await pinBackend(engine)
keepBaseline()
void refresh(engine)
}

async function openPane(
engine: Host,
trigger: (typeof Record.SHOWN_TRIGGERS)[number],
): Promise<boolean> {
): Promise<OpenOutcome.OpenOutcome> {
const isDialog = model.isFullscreen === false

model = {
Expand All @@ -439,34 +451,39 @@ export function register(on: On) {
const opened = await engine.openPane(
isDialog
? { ...dialogPane(), focus: true }
: { id: Names.PANE_ID, title: Names.PANE_TITLE, holdToasts: true },
: { id: Names.PANE_ID, title: Names.PANE_TITLE },
)

const isWaiting = isRecord(opened) && opened.isPlaced === false
const outcome = OpenOutcome.openOutcomeOf(opened)

if (isWaiting) {
if (outcome === 'withdrawn') {
await engine.closePane({ id: Names.PANE_ID }).catch(() => undefined)

return false
return outcome
}

isPaneOpen = true
keepBaseline()

await markShown(engine, trigger)
if (outcome === 'awaited') {
awaited = trigger
} else {
await markShown(engine, trigger)
}

const isStale = isDialog || landed !== landedBefore

if (isStale) {
void refresh(engine)
}

return true
return outcome
}

async function closePane(engine: Host): Promise<void> {
await engine.closePane({ id: Names.PANE_ID })
isPaneOpen = false
awaited = null
}

function markTabSwitch(engine: Host, tab: (typeof Record.TABS)[number]) {
Expand Down Expand Up @@ -513,7 +530,7 @@ export function register(on: On) {
}

hasAutoOpened = true
hasAutoOpened = await openPane(engine, 'auto_open')
hasAutoOpened = (await openPane(engine, 'auto_open')) !== 'withdrawn'
}

async function openOnRestore(engine: Host): Promise<void> {
Expand Down Expand Up @@ -728,6 +745,13 @@ export function register(on: On) {

columns = e.viewport?.columns ?? columns

if (awaited !== null) {
const trigger = awaited

awaited = null
void showKept(host, trigger).catch(() => undefined)
}

/**
* A seat that changed since the last drawing lists other rows, whose
* bodies are read once.
Expand Down Expand Up @@ -797,14 +821,18 @@ export function register(on: On) {

const isOpening = toggle === 'open'

const isDone = isOpening
const outcome = isOpening
? await openPane(host, 'manual')
: await closePane(host).then(() => true)
: await closePane(host).then(() => 'placed' as const)

if (!isDone) {
if (outcome === 'withdrawn') {
return { text: Names.RESIZE_TERMINAL_TEXT }
}

if (outcome === 'awaited') {
return { text: Names.AWAITS_SURFACE_TEXT }
}

if (!isFullscreen) {
return isOpening ? {} : { text: Names.DIALOG_DISMISSED_TEXT }
}
Expand Down Expand Up @@ -842,6 +870,7 @@ export function register(on: On) {

if (isClosed) {
isPaneOpen = false
awaited = null
}

const isDocking = model.isFullscreen !== false
Expand Down Expand Up @@ -905,7 +934,7 @@ export function register(on: On) {
}

const isResume = e.command === 'resume'
const isKeptOpen = isPaneOpen && !isResume
const isKeptOpen = isPaneSeen() && !isResume

if (isPaneOpen && isResume) {
await closePane(host).catch(() => undefined)
Expand All @@ -925,10 +954,7 @@ export function register(on: On) {
(isResume ? sessionStartMs : await host.now())

if (isKeptOpen) {
await markShown(host, 'manual')
await pinBackend(host)
keepBaseline()
void refresh(host)
await showKept(host, 'manual')
}

if (isResume) {
Expand Down Expand Up @@ -957,7 +983,7 @@ export function register(on: On) {
landed += 1
}

if (hasLanded && isPaneOpen) {
if (hasLanded && isPaneSeen()) {
scheduleRefresh(engine)
}

Expand Down
6 changes: 6 additions & 0 deletions mods/diff/tests/fixtures/beneath/beneath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ export type Beneath = {
*/
isLeftWaiting?: () => boolean

/**
* Whether an open made now is kept with nothing attached to draw it
* (LEFT_UNSEEN), as before a host's page attaches; placed when not given.
*/
isLeftUnseen?: () => boolean

/**
* Why a hunks read asked now is refused, as the host refuses a git that
* outran its timeout; answered from the script when null or not given.
Expand Down
1 change: 1 addition & 0 deletions mods/diff/tests/fixtures/beneath/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export type * from './beneath.js'
export * from './left-unseen.js'
export * from './left-waiting.js'
export * from './slow-diff-ms.js'

Expand Down
16 changes: 16 additions & 0 deletions mods/diff/tests/fixtures/beneath/left-unseen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { ResultOf } from 'claude-code'

/**
* What an engine answers an open it keeps where nothing is attached to draw
* it yet: a host whose page has not attached, or one that never draws.
*
* Typed through `never` so it compiles against declarations that predate the
* answer, where `ui.open` resolves nothing.
*/
export const LEFT_UNSEEN: ResultOf['ui.open'] = {
value: {
isPlaced: false,
reason:
'no-surface: no surface is attached ($.session.surfaces() is empty)',
} as never,
}
21 changes: 16 additions & 5 deletions mods/diff/tests/fixtures/in-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,18 @@ import { startsSession } from './starts-session.js'
* A session in a repository git answers for from a script (REPOSITORY, in
* /work, when none is given), keeping what the plugin does there.
*
* Kept: each git run, ring move, pane opened, left waiting or closed, status
* line. The clock starts at 0 and the engine draws the hint; the rest of the
* world is the test's (Beneath). Rewriting the script changes git's answers.
* Kept: each git run, ring move, pane opened, left waiting, left unseen or
* closed, status line. The clock starts at 0 and the engine draws the hint.
*
* The rest of the world is the test's (Beneath). Rewriting the script changes
* git's answers.
*
* @param on the test's `on`
* @param script git's output for each invocation whose line holds the key
* @param beneath the store, settings, environment, transcript, whether an
* open is seated, whether a hunks read is refused
* @returns the runs, the ring's moves, the panes opened, left waiting and
* closed, the statuses, the clock
* @returns the runs, the ring's moves, the panes opened, left waiting, left
* unseen and closed, the statuses, the clock
*/
export function inRepository(
on: On,
Expand All @@ -32,6 +34,7 @@ export function inRepository(
const focused: Args<'ui.focus'>[] = []
const statuses: (string | undefined)[] = []
const waiting: Args<'ui.open'>[] = []
const unseen: Args<'ui.open'>[] = []
const opened = keeping<Args<'ui.open'>>()
const closed = keeping<Args<'ui.close'>>()
const clock = startsSession(on)
Expand Down Expand Up @@ -59,6 +62,13 @@ export function inRepository(

on('ui.open', (engine, e) => {
const isWaiting = beneath.isLeftWaiting?.() === true
const isUnseen = beneath.isLeftUnseen?.() === true

if (isUnseen) {
unseen.push(e)

return Beneath.LEFT_UNSEEN
}

if (!isWaiting) {
return opened.hook(engine, e)
Expand All @@ -82,6 +92,7 @@ export function inRepository(
focused,
opened: opened.kept,
waiting,
unseen,
closed: closed.kept,
statuses,
clock,
Expand Down
27 changes: 27 additions & 0 deletions mods/diff/tests/open-outcome/open-outcome-of.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, test, tier } from 'claude-code/testing'

import OpenOutcome from '../../hooks/open-outcome'
import Fixtures from '../fixtures'

tier('builtin')

describe('open-outcome-of', () => {
test('a placed pane, and an engine that resolves nothing, are placed', () => {
expect(OpenOutcome.openOutcomeOf({ isPlaced: true })).toBe('placed')
expect(OpenOutcome.openOutcomeOf(undefined)).toBe('placed')
})

test('left waiting it is withdrawn, with a reason or none', () => {
expect(OpenOutcome.openOutcomeOf(Fixtures.LEFT_WAITING.value)).toBe(
'withdrawn',
)

expect(OpenOutcome.openOutcomeOf({ isPlaced: false })).toBe('withdrawn')
})

test('with nothing attached to draw it yet it is awaited', () => {
expect(OpenOutcome.openOutcomeOf(Fixtures.LEFT_UNSEEN.value)).toBe(
'awaited',
)
})
})
Loading
Loading