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
64 changes: 6 additions & 58 deletions src/workstation/runtime/inkInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
import { sidebarTabHasSelectableItems } from '../chrome/sidebarSelection'
import { handleBisectInput } from '../surfaces/bisect/input'
import { handleConflictsInput } from '../surfaces/conflicts/input'
import { handleRebaseInput } from '../surfaces/rebase/input'

export type LogInkInputKey = {
backspace?: boolean
Expand Down Expand Up @@ -2043,14 +2044,11 @@ export function getLogInkInputEvents(
]
}

// #1446 — rebase-plan discard guard. A fully retagged/reordered
// rebase plan is expensive to recreate; Esc-ing away should confirm
// before silently dropping it, matching the compose-draft pattern.
// The confirm is only raised when Esc WOULD pop away from the rebase
// view (viewStack > 1) — otherwise there's nowhere to go and Esc
// is a no-op anyway.
if (key.escape && state.activeView === 'rebase' && state.rebasePlan && state.viewStack.length > 1) {
return [action({ type: 'setPendingConfirmation', value: 'discard-rebase-plan' })]
// #1359 / #1446 — in-TUI interactive rebase surface, extracted to
// `surfaces/rebase/input.ts` (#1625 second surface).
const rebaseEvents = handleRebaseInput(state, inputValue, key, context)
if (rebaseEvents) {
return rebaseEvents
}

if (key.escape && state.viewStack.length > 1) {
Expand Down Expand Up @@ -2423,48 +2421,6 @@ export function getLogInkInputEvents(
return [action({ type: 'setPendingKey', value: 'g' })]
}

// ── In-TUI interactive rebase surface (#1359) ───────────────────────
// The plan claims its keys while the view is active: j/k cursor, J/K
// reorder, p/s/f/d/e retag, r reword (prompt), Enter executes (behind
// a y-confirm), Esc pops (which clears the plan). Placed before every
// other single-letter handler so the rebase letters can't leak into
// sort/fixup/diff-toggle semantics.
if (state.activeView === 'rebase' && state.rebasePlan) {
if (inputValue === 'J') {
return [action({ type: 'moveRebaseRow', delta: 1 })]
}
if (inputValue === 'K') {
return [action({ type: 'moveRebaseRow', delta: -1 })]
}
if (inputValue === 'p') {
return [action({ type: 'setRebaseAction', action: 'pick' })]
}
if (inputValue === 's') {
return [action({ type: 'setRebaseAction', action: 'squash' })]
}
if (inputValue === 'f') {
return [action({ type: 'setRebaseAction', action: 'fixup' })]
}
if (inputValue === 'd') {
return [action({ type: 'setRebaseAction', action: 'drop' })]
}
if (inputValue === 'e') {
return [action({ type: 'setRebaseAction', action: 'edit' })]
}
if (inputValue === 'r') {
const row = state.rebasePlan.rows[state.rebasePlan.selectedIndex]
return [action({
type: 'openInputPrompt',
kind: 'rebase-reword',
label: `New message for ${row?.shortSha ?? 'commit'}`,
initial: row?.newMessage ?? row?.subject ?? '',
})]
}
if (key.return) {
return [action({ type: 'setPendingConfirmation', value: 'execute-rebase-plan' })]
}
}

// `d` on the diff view toggles between unified and side-by-side split
// rendering (#785). Scoped to the diff view so the letter stays free
// for other surfaces. The chord branch above already claimed `gd`,
Expand Down Expand Up @@ -2838,10 +2794,6 @@ export function getLogInkInputEvents(
})]
}

if (state.activeView === 'rebase' && state.rebasePlan) {
return [action({ type: 'moveRebaseCursor', delta: -1 })]
}

// Worktree (staging) diff: ↑/↓ scroll lines — consistent with the
// commit / stash diffs (#1185). `[`/`]` jump between hunks (the
// staging unit), and the current hunk is derived from the scroll
Expand Down Expand Up @@ -3036,10 +2988,6 @@ export function getLogInkInputEvents(
})]
}

if (state.activeView === 'rebase' && state.rebasePlan) {
return [action({ type: 'moveRebaseCursor', delta: 1 })]
}

// Worktree (staging) diff: ↓ scrolls lines (see the ↑ handler) —
// `[`/`]` jump hunks (#1185).
if (isWorktreeDiffTarget(state) && context.worktreeDiffLineCount) {
Expand Down
95 changes: 95 additions & 0 deletions src/workstation/surfaces/rebase/input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { LogInkAction, LogInkState } from '../../runtime/inkViewModel'
import type {
LogInkInputContext,
LogInkInputEvent,
LogInkInputKey,
} from '../../runtime/inkInput'

/**
* In-TUI interactive rebase surface (#1359), extracted out of
* `inkInput.ts`'s monolithic router (#1625 second surface, following
* `surfaces/bisect/input.ts`). Scoped entirely to `state.activeView ===
* 'rebase' && state.rebasePlan`, with no dependency on any other view's
* state, which is what makes it safe to lift verbatim.
*
* Returns `null` when no rebase-local binding matches, so the caller
* (`getLogInkInputEvents`) falls through to the rest of the router.
*/
export function handleRebaseInput(
state: LogInkState,
inputValue: string,
key: LogInkInputKey,
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- signature kept symmetric with handleBisectInput; no rebase binding reads context
context: LogInkInputContext
): LogInkInputEvent[] | null {
if (state.activeView !== 'rebase' || !state.rebasePlan) {
return null
}

// #1446 — rebase-plan discard guard. A fully retagged/reordered
// rebase plan is expensive to recreate; Esc-ing away should confirm
// before silently dropping it, matching the compose-draft pattern.
// The confirm is only raised when Esc WOULD pop away from the rebase
// view (viewStack > 1) — otherwise there's nowhere to go and Esc
// is a no-op anyway.
if (key.escape && state.viewStack.length > 1) {
Comment thread
gfargo-horizon-agent[bot] marked this conversation as resolved.
return [action({ type: 'setPendingConfirmation', value: 'discard-rebase-plan' })]
}

// ── In-TUI interactive rebase surface (#1359) ───────────────────────
Comment thread
gfargo-horizon-agent[bot] marked this conversation as resolved.
// The plan claims its keys while the view is active: j/k cursor, J/K
// reorder, p/s/f/d/e retag, r reword (prompt), Enter executes (behind
// a y-confirm), Esc pops (which clears the plan). Placed before every
// other single-letter handler so the rebase letters can't leak into
// sort/fixup/diff-toggle semantics.
if (inputValue === 'J') {
Comment thread
gfargo-horizon-agent[bot] marked this conversation as resolved.
return [action({ type: 'moveRebaseRow', delta: 1 })]
}
if (inputValue === 'K') {
return [action({ type: 'moveRebaseRow', delta: -1 })]
}
if (inputValue === 'p') {
return [action({ type: 'setRebaseAction', action: 'pick' })]
}
if (inputValue === 's') {
return [action({ type: 'setRebaseAction', action: 'squash' })]
}
if (inputValue === 'f') {
return [action({ type: 'setRebaseAction', action: 'fixup' })]
}
if (inputValue === 'd') {
return [action({ type: 'setRebaseAction', action: 'drop' })]
}
if (inputValue === 'e') {
return [action({ type: 'setRebaseAction', action: 'edit' })]
}
if (inputValue === 'r') {
const row = state.rebasePlan.rows[state.rebasePlan.selectedIndex]
return [action({
type: 'openInputPrompt',
kind: 'rebase-reword',
label: `New message for ${row?.shortSha ?? 'commit'}`,
initial: row?.newMessage ?? row?.subject ?? '',
})]
}
if (key.return) {
return [action({ type: 'setPendingConfirmation', value: 'execute-rebase-plan' })]
}

if (key.upArrow || inputValue === 'k') {
return [action({ type: 'moveRebaseCursor', delta: -1 })]
}

if (key.downArrow || inputValue === 'j') {
return [action({ type: 'moveRebaseCursor', delta: 1 })]
}

return null
}

function action(actionValue: LogInkAction): LogInkInputEvent {
return {
type: 'action',
action: actionValue,
}
}
Loading