diff --git a/src/workstation/runtime/inkInput.ts b/src/workstation/runtime/inkInput.ts index ba74a72c..424695d9 100644 --- a/src/workstation/runtime/inkInput.ts +++ b/src/workstation/runtime/inkInput.ts @@ -27,6 +27,7 @@ import { } from './inkWorkflows' import { sidebarTabHasSelectableItems } from '../chrome/sidebarSelection' import { handleBisectInput } from '../surfaces/bisect/input' +import { handleChangelogInput } from '../surfaces/changelog/input' import { handleConflictsInput } from '../surfaces/conflicts/input' export type LogInkInputKey = { @@ -2328,55 +2329,14 @@ export function getLogInkInputEvents( return bisectEvents } - // Changelog view local keymap. Scoped to `activeView === 'changelog'` - // so the letters stay free everywhere else. Bindings: - // - // j / k → scroll line down / up (1 line) - // pgdn / pgup → scroll page down / up (10 lines) - // y → yank text to clipboard - // E → open in $EDITOR (companion to compose's `E` from #913) - // c → create-PR seeded with this changelog - // r → regenerate (skip cache, re-run LLM) - // - // Back-out is `<` / Esc handled by the global pop-view path lower - // down. The view only renders when `state.changelogView.status` - // is 'ready' — scroll keystrokes early-return when changelogLineCount - // is missing so they no-op gracefully during loading / error states. - if (state.activeView === 'changelog') { - // Arrows are synonyms for j/k here like on every other surface — - // they used to be swallowed by the loading-state guard below even - // when the view was ready, leaving ↓/↑ silently dead. - if ((inputValue === 'j' || key.downArrow) && context.changelogLineCount) { - return [action({ type: 'pageChangelog', delta: 1, lineCount: context.changelogLineCount })] - } - if ((inputValue === 'k' || key.upArrow) && context.changelogLineCount) { - return [action({ type: 'pageChangelog', delta: -1, lineCount: context.changelogLineCount })] - } - if (key.pageDown && context.changelogLineCount) { - return [action({ type: 'pageChangelog', delta: 10, lineCount: context.changelogLineCount })] - } - if (key.pageUp && context.changelogLineCount) { - return [action({ type: 'pageChangelog', delta: -10, lineCount: context.changelogLineCount })] - } - if (inputValue === 'y') { - return [{ type: 'yankChangelog' }] - } - if (inputValue === 'E') { - return [{ type: 'openChangelogInEditor' }] - } - if (inputValue === 'c') { - return [{ type: 'startCreatePullRequest' }] - } - if (inputValue === 'r') { - return [{ type: 'regenerateChangelog' }] - } - // While loading / errored there's no line count yet — swallow the - // scroll keys instead of letting them fall through to the global - // move handler, which used to scroll the HISTORY cursor invisibly - // beneath this surface (#1348). - if (inputValue === 'j' || inputValue === 'k' || key.upArrow || key.downArrow || key.pageUp || key.pageDown) { - return [] - } + // Changelog view local keymap, extracted to + // `surfaces/changelog/input.ts` (mirrors #1625 bisect surface). Note: + // this surface's `gg`/`G` top/bottom jumps are intentionally NOT part + // of this delegation — they remain below, nested inside the shared + // moveToTop/moveToBottom chord handlers alongside blame/file-history. + const changelogEvents = handleChangelogInput(state, inputValue, key, context) + if (changelogEvents) { + return changelogEvents } if (inputValue === 'g') { diff --git a/src/workstation/surfaces/changelog/input.test.ts b/src/workstation/surfaces/changelog/input.test.ts new file mode 100644 index 00000000..1fa25a99 --- /dev/null +++ b/src/workstation/surfaces/changelog/input.test.ts @@ -0,0 +1,79 @@ +import { handleChangelogInput } from './input' +import { createLogInkState } from '../../runtime/inkViewModel' +import type { GitLogRow } from '../../../git/logData' + +/** + * Direct coverage for the changelog-view input handler extracted out of + * `inkInput.ts`'s router (mirrors #1625 bisect surface). `inkInput.test.ts` + * keeps its existing changelog cases too — those exercise the full + * `getLogInkInputEvents` router and guard that it actually delegates + * here; these tests pin down the handler's own logic in isolation. + */ + +const rows: GitLogRow[] = [ + { + type: 'commit', + graph: '*', + shortHash: 'abc1234', + hash: 'abc123456789', + parents: [], + date: '2026-04-29', + author: 'Coco Test', + refs: [], + message: 'Initial commit', + }, +] + +const changelogState = () => createLogInkState(rows, { activeView: 'changelog' }) + +describe('handleChangelogInput', () => { + it('returns null outside the changelog view', () => { + const state = { ...changelogState(), activeView: 'history' as const } + expect(handleChangelogInput(state, 'j', {}, { changelogLineCount: 50 })).toBeNull() + }) + + it('scrolls one line at a time on j/k', () => { + expect(handleChangelogInput(changelogState(), 'j', {}, { changelogLineCount: 50 })).toEqual([ + { type: 'action', action: { type: 'pageChangelog', delta: 1, lineCount: 50 } }, + ]) + expect(handleChangelogInput(changelogState(), 'k', {}, { changelogLineCount: 50 })).toEqual([ + { type: 'action', action: { type: 'pageChangelog', delta: -1, lineCount: 50 } }, + ]) + }) + + it('treats arrow keys as synonyms for j/k', () => { + expect(handleChangelogInput(changelogState(), '', { downArrow: true }, { changelogLineCount: 50 })).toEqual([ + { type: 'action', action: { type: 'pageChangelog', delta: 1, lineCount: 50 } }, + ]) + expect(handleChangelogInput(changelogState(), '', { upArrow: true }, { changelogLineCount: 50 })).toEqual([ + { type: 'action', action: { type: 'pageChangelog', delta: -1, lineCount: 50 } }, + ]) + }) + + it('scrolls by 10 lines on pgup/pgdn', () => { + expect(handleChangelogInput(changelogState(), '', { pageDown: true }, { changelogLineCount: 50 })).toEqual([ + { type: 'action', action: { type: 'pageChangelog', delta: 10, lineCount: 50 } }, + ]) + expect(handleChangelogInput(changelogState(), '', { pageUp: true }, { changelogLineCount: 50 })).toEqual([ + { type: 'action', action: { type: 'pageChangelog', delta: -10, lineCount: 50 } }, + ]) + }) + + it('swallows scroll keys instead of falling through while no content is loaded', () => { + expect(handleChangelogInput(changelogState(), 'j', {}, {})).toEqual([]) + expect(handleChangelogInput(changelogState(), 'k', {}, {})).toEqual([]) + expect(handleChangelogInput(changelogState(), '', { pageDown: true }, {})).toEqual([]) + expect(handleChangelogInput(changelogState(), '', { pageUp: true }, {})).toEqual([]) + }) + + it('dispatches y/E/c/r workflow events', () => { + expect(handleChangelogInput(changelogState(), 'y', {}, {})).toEqual([{ type: 'yankChangelog' }]) + expect(handleChangelogInput(changelogState(), 'E', {}, {})).toEqual([{ type: 'openChangelogInEditor' }]) + expect(handleChangelogInput(changelogState(), 'c', {}, {})).toEqual([{ type: 'startCreatePullRequest' }]) + expect(handleChangelogInput(changelogState(), 'r', {}, {})).toEqual([{ type: 'regenerateChangelog' }]) + }) + + it('returns null for an unmatched key', () => { + expect(handleChangelogInput(changelogState(), 'q', {}, {})).toBeNull() + }) +}) diff --git a/src/workstation/surfaces/changelog/input.ts b/src/workstation/surfaces/changelog/input.ts new file mode 100644 index 00000000..adea064b --- /dev/null +++ b/src/workstation/surfaces/changelog/input.ts @@ -0,0 +1,87 @@ +import type { LogInkAction, LogInkState } from '../../runtime/inkViewModel' +import type { + LogInkInputContext, + LogInkInputEvent, + LogInkInputKey, +} from '../../runtime/inkInput' + +/** + * Changelog view local keymap, extracted to its own module (mirrors the + * #1625 bisect surface). Scoped to `activeView === 'changelog'` so the + * letters stay free everywhere else. Bindings: + * + * j / k → scroll line down / up (1 line) + * pgdn / pgup → scroll page down / up (10 lines) + * y → yank text to clipboard + * E → open in $EDITOR (companion to compose's `E` from #913) + * c → create-PR seeded with this changelog + * r → regenerate (skip cache, re-run LLM) + * + * Back-out is `<` / Esc handled by the global pop-view path in + * `inkInput.ts`. The view only renders when `state.changelogView.status` + * is 'ready' — scroll keystrokes early-return when changelogLineCount + * is missing so they no-op gracefully during loading / error states. + * + * The `gg` / `G` top/bottom jumps for this view are NOT handled here — + * they live in `inkInput.ts`'s shared `moveToTop`/`moveToBottom` chord + * handlers alongside the blame/file-history branches, since extracting + * them would require restructuring those shared handlers. Left in place + * intentionally (see PR description). + * + * Returns `null` when no changelog-local binding matches, so the caller + * (`getLogInkInputEvents`) falls through to the rest of the router. + */ +export function handleChangelogInput( + state: LogInkState, + inputValue: string, + key: LogInkInputKey, + context: LogInkInputContext +): LogInkInputEvent[] | null { + if (state.activeView !== 'changelog') { + return null + } + + // Arrows are synonyms for j/k here like on every other surface — + // they used to be swallowed by the loading-state guard below even + // when the view was ready, leaving ↓/↑ silently dead. + if ((inputValue === 'j' || key.downArrow) && context.changelogLineCount) { + return [action({ type: 'pageChangelog', delta: 1, lineCount: context.changelogLineCount })] + } + if ((inputValue === 'k' || key.upArrow) && context.changelogLineCount) { + return [action({ type: 'pageChangelog', delta: -1, lineCount: context.changelogLineCount })] + } + if (key.pageDown && context.changelogLineCount) { + return [action({ type: 'pageChangelog', delta: 10, lineCount: context.changelogLineCount })] + } + if (key.pageUp && context.changelogLineCount) { + return [action({ type: 'pageChangelog', delta: -10, lineCount: context.changelogLineCount })] + } + if (inputValue === 'y') { + return [{ type: 'yankChangelog' }] + } + if (inputValue === 'E') { + return [{ type: 'openChangelogInEditor' }] + } + if (inputValue === 'c') { + return [{ type: 'startCreatePullRequest' }] + } + if (inputValue === 'r') { + return [{ type: 'regenerateChangelog' }] + } + // While loading / errored there's no line count yet — swallow the + // scroll keys instead of letting them fall through to the global + // move handler, which used to scroll the HISTORY cursor invisibly + // beneath this surface (#1348). + if (inputValue === 'j' || inputValue === 'k' || key.upArrow || key.downArrow || key.pageUp || key.pageDown) { + return [] + } + + return null +} + +function action(actionValue: LogInkAction): LogInkInputEvent { + return { + type: 'action', + action: actionValue, + } +}