Skip to content
Merged
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
58 changes: 9 additions & 49 deletions src/workstation/runtime/inkInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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') {
Expand Down
79 changes: 79 additions & 0 deletions src/workstation/surfaces/changelog/input.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
87 changes: 87 additions & 0 deletions src/workstation/surfaces/changelog/input.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
Loading