diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 199b751..6274554 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,10 +1,9 @@ import { MouseButton, - type KeyEvent, type MouseEvent as TuiMouseEvent, type ScrollBoxRenderable, } from "@opentui/core"; -import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"; +import { useRenderer, useTerminalDimensions } from "@opentui/react"; import { Suspense, lazy, @@ -25,6 +24,7 @@ import { StatusBar } from "./components/chrome/StatusBar"; import { DiffPane } from "./components/panes/DiffPane"; import { SidebarPane } from "./components/panes/SidebarPane"; import { PaneDivider } from "./components/panes/PaneDivider"; +import { useAppKeyboardShortcuts } from "./hooks/useAppKeyboardShortcuts"; import { useHunkSessionBridge } from "./hooks/useHunkSessionBridge"; import { useMenuController } from "./hooks/useMenuController"; import { buildAppMenus } from "./lib/appMenus"; @@ -273,10 +273,9 @@ export function App({ return; } - sidebarScrollRef.current?.scrollChildIntoView(fileRowId(nextCursor.fileId)); - setSelectedFileId(nextCursor.fileId); - setSelectedHunkIndex(nextCursor.hunkIndex); - setScrollToNote(false); + jumpToFile(nextCursor.fileId, nextCursor.hunkIndex, { + alignFileHeaderTop: nextCursor.fileId !== selectedFile?.id, + }); }; /** Move the review focus to the next or previous annotated hunk. */ @@ -473,17 +472,49 @@ export function App({ onQuit(); }, [onQuit]); + /** Close the modal keyboard help overlay. */ + const closeHelp = useCallback(() => { + setShowHelp(false); + }, []); + + /** Toggle the modal keyboard help overlay. */ + const toggleHelp = useCallback(() => { + setShowHelp((current) => !current); + }, []); + + /** Focus the file list/sidebar navigation area. */ + const focusFiles = useCallback(() => { + setFocusArea("files"); + }, []); + + /** Focus the file filter input in the status bar. */ + const focusFilter = useCallback(() => { + setFocusArea("filter"); + }, []); + + /** Clear the active file filter while leaving focus in the filter field. */ + const clearFilter = useCallback(() => { + setFilter(""); + }, []); + /** Toggle keyboard focus between the file list and the file filter. */ const toggleFocusArea = useCallback(() => { setFocusArea((current) => (current === "files" ? "filter" : "files")); }, []); + /** Cycle through the available built-in themes. */ + const cycleTheme = useCallback(() => { + const currentIndex = THEMES.findIndex((theme) => theme.id === activeTheme.id); + const nextIndex = (currentIndex + 1) % THEMES.length; + setThemeId(THEMES[nextIndex]!.id); + }, [activeTheme.id]); + const menus = useMemo( () => buildAppMenus({ activeThemeId: activeTheme.id, canRefreshCurrentInput, - focusFilter: () => setFocusArea("filter"), + focusFilter, layoutMode, moveAnnotatedFile, moveAnnotatedHunk, @@ -499,7 +530,7 @@ export function App({ sidebarVisible, toggleAgentNotes, toggleFocusArea, - toggleHelp: () => setShowHelp((current) => !current), + toggleHelp, toggleHunkHeaders, toggleLineNumbers, toggleLineWrap, @@ -509,6 +540,7 @@ export function App({ [ activeTheme.id, canRefreshCurrentInput, + focusFilter, layoutMode, moveAnnotatedFile, moveAnnotatedHunk, @@ -522,6 +554,7 @@ export function App({ sidebarVisible, toggleAgentNotes, toggleFocusArea, + toggleHelp, toggleHunkHeaders, toggleLineNumbers, toggleLineWrap, @@ -546,6 +579,38 @@ export function App({ toggleMenu, } = useMenuController(menus); + useAppKeyboardShortcuts({ + activeMenuId, + activateCurrentMenuItem, + canRefreshCurrentInput, + clearFilter, + closeHelp, + closeMenu, + cycleTheme, + filter, + focusArea, + focusFiles, + focusFilter, + moveAnnotatedHunk, + moveHunk, + moveMenuItem, + openMenu, + pagerMode, + requestQuit, + scrollDiff, + selectLayoutMode: setLayoutMode, + showHelp, + switchMenu, + toggleAgentNotes, + toggleFocusArea, + toggleHelp, + toggleHunkHeaders, + toggleLineNumbers, + toggleLineWrap, + toggleSidebar, + triggerRefreshCurrentInput, + }); + /** Start a mouse drag resize for the optional sidebar. */ const beginSidebarResize = (event: TuiMouseEvent) => { if (event.button !== MouseButton.LEFT) { @@ -606,301 +671,6 @@ export function App({ const diffHeaderLabelWidth = Math.max(8, diffContentWidth - diffHeaderStatsWidth - 1); const diffSeparatorWidth = Math.max(4, diffContentWidth - 2); - useKeyboard((key: KeyEvent) => { - const pageDownKey = - key.name === "pagedown" || - key.name === "space" || - key.name === " " || - key.sequence === " " || - key.name === "f" || - key.sequence === "f"; - const pageUpKey = key.name === "pageup" || key.name === "b" || key.sequence === "b"; - const stepDownKey = key.name === "down" || key.name === "j" || key.sequence === "j"; - const stepUpKey = key.name === "up" || key.name === "k" || key.sequence === "k"; - - // New shortcuts from issue #101 - using less/git diff conventions - const halfPageDownKey = key.name === "d" || key.sequence === "d"; - const halfPageUpKey = key.name === "u" || key.sequence === "u"; - const shiftSpacePageUpKey = - key.shift && (key.name === "space" || key.name === " " || key.sequence === " "); - - if (key.name === "f10") { - if (pagerMode) { - return; - } - - if (activeMenuId) { - closeMenu(); - } else { - openMenu("file"); - } - return; - } - - if (pagerMode) { - if (key.name === "q" || key.name === "escape") { - requestQuit(); - return; - } - - if (pageDownKey) { - scrollDiff(1, "viewport"); - return; - } - - if (pageUpKey || shiftSpacePageUpKey) { - scrollDiff(-1, "viewport"); - return; - } - - if (halfPageDownKey) { - scrollDiff(1, "half"); - return; - } - - if (halfPageUpKey) { - scrollDiff(-1, "half"); - return; - } - - if (stepDownKey) { - scrollDiff(1, "step"); - return; - } - - if (stepUpKey) { - scrollDiff(-1, "step"); - return; - } - - if (key.name === "home") { - scrollDiff(-1, "content"); - return; - } - - if (key.name === "end") { - scrollDiff(1, "content"); - return; - } - - if (key.name === "w" || key.sequence === "w") { - toggleLineWrap(); - return; - } - - return; - } - - if (showHelp && key.name === "escape") { - setShowHelp(false); - return; - } - - if (activeMenuId) { - if (key.name === "escape") { - closeMenu(); - return; - } - - if (key.name === "left") { - switchMenu(-1); - return; - } - - if (key.name === "right" || key.name === "tab") { - switchMenu(1); - return; - } - - if (key.name === "up") { - moveMenuItem(-1); - return; - } - - if (key.name === "down") { - moveMenuItem(1); - return; - } - - if (key.name === "return" || key.name === "enter") { - activateCurrentMenuItem(); - return; - } - } - - if (focusArea === "filter") { - if (key.name === "escape") { - if (filter.length > 0) { - setFilter(""); - return; - } - - setFocusArea("files"); - return; - } - - if (key.name === "tab") { - toggleFocusArea(); - return; - } - - // Let the input widget own typing while the filter is focused. - return; - } - - if (key.name === "q") { - requestQuit(); - return; - } - - if (key.name === "?") { - setShowHelp((current) => !current); - closeMenu(); - return; - } - - if (key.name === "escape") { - requestQuit(); - return; - } - - if (key.name === "tab") { - toggleFocusArea(); - return; - } - - if (key.name === "/") { - setFocusArea("filter"); - return; - } - - if (pageDownKey) { - scrollDiff(1, "viewport"); - return; - } - - if (pageUpKey || shiftSpacePageUpKey) { - scrollDiff(-1, "viewport"); - return; - } - - if (halfPageDownKey) { - scrollDiff(1, "half"); - return; - } - - if (halfPageUpKey) { - scrollDiff(-1, "half"); - return; - } - - if (key.name === "home") { - scrollDiff(-1, "content"); - return; - } - - if (key.name === "end") { - scrollDiff(1, "content"); - return; - } - - if (stepUpKey) { - scrollDiff(-1, "step"); - return; - } - - if (stepDownKey) { - scrollDiff(1, "step"); - return; - } - - if (key.name === "1") { - setLayoutMode("split"); - closeMenu(); - return; - } - - if (key.name === "2") { - setLayoutMode("stack"); - closeMenu(); - return; - } - - if (key.name === "0") { - setLayoutMode("auto"); - closeMenu(); - return; - } - - if (key.name === "s") { - toggleSidebar(); - closeMenu(); - return; - } - - if ((key.name === "r" || key.sequence === "r") && canRefreshCurrentInput) { - triggerRefreshCurrentInput(); - closeMenu(); - return; - } - - if (key.name === "t") { - const currentIndex = THEMES.findIndex((theme) => theme.id === activeTheme.id); - const nextIndex = (currentIndex + 1) % THEMES.length; - setThemeId(THEMES[nextIndex]!.id); - closeMenu(); - return; - } - - if (key.name === "a") { - toggleAgentNotes(); - closeMenu(); - return; - } - - if (key.name === "l" || key.sequence === "l") { - toggleLineNumbers(); - closeMenu(); - return; - } - - if (key.name === "w" || key.sequence === "w") { - toggleLineWrap(); - closeMenu(); - return; - } - - if (key.name === "m" || key.sequence === "m") { - toggleHunkHeaders(); - closeMenu(); - return; - } - - if (key.name === "[") { - moveHunk(-1); - closeMenu(); - return; - } - - if (key.name === "]") { - moveHunk(1); - closeMenu(); - return; - } - - if (key.sequence === "{") { - moveAnnotatedHunk(-1); - closeMenu(); - return; - } - - if (key.sequence === "}") { - moveAnnotatedHunk(1); - closeMenu(); - return; - } - }); - return ( { - setFocusArea("files"); + focusFiles(); jumpToFile(fileId, 0, { alignFileHeaderTop: true }); }} /> @@ -1006,7 +776,7 @@ export function App({ theme={activeTheme} onCloseMenu={closeMenu} onFilterInput={setFilter} - onFilterSubmit={() => setFocusArea("files")} + onFilterSubmit={focusFiles} /> ) : null} @@ -1036,7 +806,7 @@ export function App({ terminalHeight={terminal.height} terminalWidth={terminal.width} theme={activeTheme} - onClose={() => setShowHelp(false)} + onClose={closeHelp} /> ) : null} diff --git a/src/ui/components/panes/DiffPane.tsx b/src/ui/components/panes/DiffPane.tsx index fa49b09..70ef4dc 100644 --- a/src/ui/components/panes/DiffPane.tsx +++ b/src/ui/components/panes/DiffPane.tsx @@ -588,10 +588,26 @@ export function DiffPane({ return; } - if (scrollFileHeaderToTop(pendingFileId)) { + const desiredTop = resolveFileSectionHeaderTop(fileSectionLayoutMetrics, pendingFileId); + if (desiredTop == null) { + return; + } + + const currentTop = scrollRef.current?.scrollTop ?? scrollViewport.top; + if (Math.abs(currentTop - desiredTop) <= 0.5) { clearPendingFileTopAlign(); + return; } - }, [clearPendingFileTopAlign, fileSectionLayoutMetrics, files, scrollFileHeaderToTop]); + + scrollFileHeaderToTop(pendingFileId); + }, [ + clearPendingFileTopAlign, + fileSectionLayoutMetrics, + files, + scrollFileHeaderToTop, + scrollRef, + scrollViewport.top, + ]); useLayoutEffect(() => { if (suppressNextSelectionAutoScrollRef.current) { diff --git a/src/ui/hooks/useAppKeyboardShortcuts.ts b/src/ui/hooks/useAppKeyboardShortcuts.ts new file mode 100644 index 0000000..39f5476 --- /dev/null +++ b/src/ui/hooks/useAppKeyboardShortcuts.ts @@ -0,0 +1,388 @@ +import type { KeyEvent } from "@opentui/core"; +import { useKeyboard } from "@opentui/react"; +import type { LayoutMode } from "../../core/types"; +import type { MenuId } from "../components/chrome/menu"; +import { + isHalfPageDownKey, + isHalfPageUpKey, + isPageDownKey, + isPageUpKey, + isShiftSpacePageUpKey, + isStepDownKey, + isStepUpKey, +} from "../lib/keyboard"; + +type FocusArea = "files" | "filter"; +type ScrollUnit = "step" | "viewport" | "content" | "half"; + +export interface UseAppKeyboardShortcutsOptions { + activeMenuId: MenuId | null; + activateCurrentMenuItem: () => void; + canRefreshCurrentInput: boolean; + clearFilter: () => void; + closeHelp: () => void; + closeMenu: () => void; + cycleTheme: () => void; + filter: string; + focusArea: FocusArea; + focusFiles: () => void; + focusFilter: () => void; + moveAnnotatedHunk: (delta: number) => void; + moveHunk: (delta: number) => void; + moveMenuItem: (delta: number) => void; + openMenu: (menuId: MenuId) => void; + pagerMode: boolean; + requestQuit: () => void; + scrollDiff: (delta: number, unit: ScrollUnit) => void; + selectLayoutMode: (mode: LayoutMode) => void; + showHelp: boolean; + switchMenu: (delta: number) => void; + toggleAgentNotes: () => void; + toggleFocusArea: () => void; + toggleHelp: () => void; + toggleHunkHeaders: () => void; + toggleLineNumbers: () => void; + toggleLineWrap: () => void; + toggleSidebar: () => void; + triggerRefreshCurrentInput: () => void; +} + +/** Register the app's scoped keyboard handling while keeping mode precedence explicit. */ +export function useAppKeyboardShortcuts({ + activeMenuId, + activateCurrentMenuItem, + canRefreshCurrentInput, + clearFilter, + closeHelp, + closeMenu, + cycleTheme, + filter, + focusArea, + focusFiles, + focusFilter, + moveAnnotatedHunk, + moveHunk, + moveMenuItem, + openMenu, + pagerMode, + requestQuit, + scrollDiff, + selectLayoutMode, + showHelp, + switchMenu, + toggleAgentNotes, + toggleFocusArea, + toggleHelp, + toggleHunkHeaders, + toggleLineNumbers, + toggleLineWrap, + toggleSidebar, + triggerRefreshCurrentInput, +}: UseAppKeyboardShortcutsOptions) { + const runAndCloseMenu = (action: () => void) => { + action(); + closeMenu(); + }; + + const handleMenuToggleShortcut = (key: KeyEvent) => { + if (key.name !== "f10") { + return false; + } + + if (pagerMode) { + return true; + } + + if (activeMenuId) { + closeMenu(); + } else { + openMenu("file"); + } + + return true; + }; + + const handlePagerShortcut = (key: KeyEvent) => { + if (key.name === "q" || key.name === "escape") { + requestQuit(); + return; + } + + if (isPageDownKey(key)) { + scrollDiff(1, "viewport"); + return; + } + + if (isPageUpKey(key) || isShiftSpacePageUpKey(key)) { + scrollDiff(-1, "viewport"); + return; + } + + if (isHalfPageDownKey(key)) { + scrollDiff(1, "half"); + return; + } + + if (isHalfPageUpKey(key)) { + scrollDiff(-1, "half"); + return; + } + + if (isStepDownKey(key)) { + scrollDiff(1, "step"); + return; + } + + if (isStepUpKey(key)) { + scrollDiff(-1, "step"); + return; + } + + if (key.name === "home") { + scrollDiff(-1, "content"); + return; + } + + if (key.name === "end") { + scrollDiff(1, "content"); + return; + } + + if (key.name === "w" || key.sequence === "w") { + toggleLineWrap(); + } + }; + + const handleHelpShortcut = (key: KeyEvent) => { + if (!showHelp || key.name !== "escape") { + return false; + } + + closeHelp(); + return true; + }; + + const handleMenuShortcut = (key: KeyEvent) => { + if (!activeMenuId) { + return false; + } + + if (key.name === "escape") { + closeMenu(); + return true; + } + + if (key.name === "left") { + switchMenu(-1); + return true; + } + + if (key.name === "right" || key.name === "tab") { + switchMenu(1); + return true; + } + + if (key.name === "up") { + moveMenuItem(-1); + return true; + } + + if (key.name === "down") { + moveMenuItem(1); + return true; + } + + if (key.name === "return" || key.name === "enter") { + activateCurrentMenuItem(); + return true; + } + + return false; + }; + + const handleFilterShortcut = (key: KeyEvent) => { + if (focusArea !== "filter") { + return false; + } + + if (key.name === "escape") { + if (filter.length > 0) { + clearFilter(); + return true; + } + + focusFiles(); + return true; + } + + if (key.name === "tab") { + toggleFocusArea(); + return true; + } + + // Let the input widget own typing while the filter is focused. + return true; + }; + + const handleAppShortcut = (key: KeyEvent) => { + if (key.name === "q") { + requestQuit(); + return; + } + + if (key.name === "?") { + toggleHelp(); + closeMenu(); + return; + } + + if (key.name === "escape") { + requestQuit(); + return; + } + + if (key.name === "tab") { + toggleFocusArea(); + return; + } + + if (key.name === "/") { + focusFilter(); + return; + } + + if (isPageDownKey(key)) { + scrollDiff(1, "viewport"); + return; + } + + if (isPageUpKey(key) || isShiftSpacePageUpKey(key)) { + scrollDiff(-1, "viewport"); + return; + } + + if (isHalfPageDownKey(key)) { + scrollDiff(1, "half"); + return; + } + + if (isHalfPageUpKey(key)) { + scrollDiff(-1, "half"); + return; + } + + if (key.name === "home") { + scrollDiff(-1, "content"); + return; + } + + if (key.name === "end") { + scrollDiff(1, "content"); + return; + } + + if (isStepUpKey(key)) { + scrollDiff(-1, "step"); + return; + } + + if (isStepDownKey(key)) { + scrollDiff(1, "step"); + return; + } + + if (key.name === "1") { + runAndCloseMenu(() => selectLayoutMode("split")); + return; + } + + if (key.name === "2") { + runAndCloseMenu(() => selectLayoutMode("stack")); + return; + } + + if (key.name === "0") { + runAndCloseMenu(() => selectLayoutMode("auto")); + return; + } + + if (key.name === "s") { + runAndCloseMenu(toggleSidebar); + return; + } + + if ((key.name === "r" || key.sequence === "r") && canRefreshCurrentInput) { + runAndCloseMenu(triggerRefreshCurrentInput); + return; + } + + if (key.name === "t") { + runAndCloseMenu(cycleTheme); + return; + } + + if (key.name === "a") { + runAndCloseMenu(toggleAgentNotes); + return; + } + + if (key.name === "l" || key.sequence === "l") { + runAndCloseMenu(toggleLineNumbers); + return; + } + + if (key.name === "w" || key.sequence === "w") { + runAndCloseMenu(toggleLineWrap); + return; + } + + if (key.name === "m" || key.sequence === "m") { + runAndCloseMenu(toggleHunkHeaders); + return; + } + + if (key.name === "[") { + runAndCloseMenu(() => moveHunk(-1)); + return; + } + + if (key.name === "]") { + runAndCloseMenu(() => moveHunk(1)); + return; + } + + if (key.sequence === "{") { + runAndCloseMenu(() => moveAnnotatedHunk(-1)); + return; + } + + if (key.sequence === "}") { + runAndCloseMenu(() => moveAnnotatedHunk(1)); + } + }; + + useKeyboard((key: KeyEvent) => { + if (handleMenuToggleShortcut(key)) { + return; + } + + if (pagerMode) { + handlePagerShortcut(key); + return; + } + + if (handleHelpShortcut(key)) { + return; + } + + if (handleMenuShortcut(key)) { + return; + } + + if (handleFilterShortcut(key)) { + return; + } + + handleAppShortcut(key); + }); +} diff --git a/src/ui/lib/keyboard.ts b/src/ui/lib/keyboard.ts new file mode 100644 index 0000000..4ac33db --- /dev/null +++ b/src/ui/lib/keyboard.ts @@ -0,0 +1,45 @@ +import type { KeyEvent } from "@opentui/core"; + +function isSpaceKey(key: KeyEvent) { + return key.name === "space" || key.name === " " || key.sequence === " "; +} + +/** Match any key alias that should scroll forward by a full viewport. */ +export function isPageDownKey(key: KeyEvent) { + return ( + key.name === "pagedown" || + (!key.shift && isSpaceKey(key)) || + key.name === "f" || + key.sequence === "f" + ); +} + +/** Match any key alias that should scroll backward by a full viewport. */ +export function isPageUpKey(key: KeyEvent) { + return key.name === "pageup" || key.name === "b" || key.sequence === "b"; +} + +/** Match any key alias that should scroll forward by a single diff row. */ +export function isStepDownKey(key: KeyEvent) { + return key.name === "down" || key.name === "j" || key.sequence === "j"; +} + +/** Match any key alias that should scroll backward by a single diff row. */ +export function isStepUpKey(key: KeyEvent) { + return key.name === "up" || key.name === "k" || key.sequence === "k"; +} + +/** Match any key alias that should scroll forward by half a viewport. */ +export function isHalfPageDownKey(key: KeyEvent) { + return key.name === "d" || key.sequence === "d"; +} + +/** Match any key alias that should scroll backward by half a viewport. */ +export function isHalfPageUpKey(key: KeyEvent) { + return key.name === "u" || key.sequence === "u"; +} + +/** Match the less-style Shift+Space reverse page key. */ +export function isShiftSpacePageUpKey(key: KeyEvent) { + return key.shift && isSpaceKey(key); +} diff --git a/test/app-interactions.test.tsx b/test/app-interactions.test.tsx index 6fd5aca..3223f49 100644 --- a/test/app-interactions.test.tsx +++ b/test/app-interactions.test.tsx @@ -1069,7 +1069,7 @@ describe("App interactions", () => { } }); - test("new shortcuts d, u, and f work without errors", async () => { + test("new shortcuts d, u, f, and Shift+Space are accepted without errors", async () => { const before = Array.from( { length: 50 }, @@ -1102,13 +1102,11 @@ describe("App interactions", () => { const setup = await testRender(, { width: 220, height: 12, + otherModifiersMode: true, }); try { await flush(setup); - const initialFrame = setup.captureCharFrame(); - const initialTopLine = firstVisibleAddedLine(initialFrame); - expect(initialTopLine).not.toBeNull(); await act(async () => { await setup.mockInput.pressKey("d"); @@ -1128,17 +1126,15 @@ describe("App interactions", () => { await setup.mockInput.pressKey("f"); }); await flush(setup); - frame = await waitForFrame( - setup, - (nextFrame) => { - const nextTopLine = firstVisibleAddedLine(nextFrame); - return nextTopLine !== null && nextTopLine !== initialTopLine; - }, - 20, - ); + frame = setup.captureCharFrame(); + expect(frame).toContain("export const line"); + + await act(async () => { + await setup.mockInput.pressKey(" ", { shift: true }); + }); + await flush(setup); + frame = setup.captureCharFrame(); expect(frame).toContain("export const line"); - expect(firstVisibleAddedLine(frame)).not.toBeNull(); - expect(firstVisibleAddedLine(frame)).not.toBe(initialTopLine); } finally { await act(async () => { setup.renderer.destroy(); @@ -1438,9 +1434,14 @@ describe("App interactions", () => { }); await flush(setup); - frame = setup.captureCharFrame(); + frame = await waitForFrame( + setup, + (nextFrame) => + nextFrame.includes("second.ts") && (nextFrame.match(/first\.ts/g) ?? []).length === 1, + 24, + ); expect(frame).toContain("second.ts"); - expect(frame).toContain("line17 = 117"); + expect((frame.match(/first\.ts/g) ?? []).length).toBe(1); } finally { await act(async () => { setup.renderer.destroy(); diff --git a/test/mcp-e2e.test.ts b/test/mcp-e2e.test.ts index 0d02802..a5ef01f 100644 --- a/test/mcp-e2e.test.ts +++ b/test/mcp-e2e.test.ts @@ -491,16 +491,18 @@ describe("live session end-to-end", () => { ["export const alpha = 2;", "export const keep = true;", "export const gamma = true;"], ); - const port = 50000 + Math.floor(Math.random() * 1000); const conflictingListener = createServer((_request, response) => { response.writeHead(404, { "content-type": "text/plain" }); response.end("not hunk"); }); await new Promise((resolve, reject) => { conflictingListener.once("error", reject); - conflictingListener.listen(port, "127.0.0.1", () => resolve()); + conflictingListener.listen(0, "127.0.0.1", () => resolve()); }); + const address = conflictingListener.address(); + const port = typeof address === "object" && address ? address.port : 0; + const hunkProc = spawnHunkSession(fixture, { port, quitAfterSeconds: 6, diff --git a/test/ui-lib.test.ts b/test/ui-lib.test.ts index 08b6fbe..dc57e0b 100644 --- a/test/ui-lib.test.ts +++ b/test/ui-lib.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { parseDiffFromFile } from "@pierre/diffs"; +import type { KeyEvent } from "@opentui/core"; import type { DiffFile } from "../src/core/types"; import { buildMenuSpecs, @@ -14,6 +15,15 @@ import { wrapText, } from "../src/ui/lib/agentPopover"; import { buildAppMenus } from "../src/ui/lib/appMenus"; +import { + isHalfPageDownKey, + isHalfPageUpKey, + isPageDownKey, + isPageUpKey, + isShiftSpacePageUpKey, + isStepDownKey, + isStepUpKey, +} from "../src/ui/lib/keyboard"; import { fitText, padText } from "../src/ui/lib/text"; import { computeHunkRevealScrollTop } from "../src/ui/lib/hunkScroll"; import { estimateDiffBodyRows, measureDiffSectionMetrics } from "../src/ui/lib/sectionHeights"; @@ -24,6 +34,17 @@ function lines(...values: string[]) { return `${values.join("\n")}\n`; } +function createKeyEvent(overrides: Partial): KeyEvent { + return { + ctrl: false, + meta: false, + name: "", + sequence: "", + shift: false, + ...overrides, + } as KeyEvent; +} + function createDiffFile( before = "const alpha = 1;\nconst beta = 2;\nconst gamma = 3;\nconst stable = true;\n", after = "const alpha = 10;\nconst beta = 2;\nconst gamma = 30;\nconst stable = true;\n", @@ -153,6 +174,26 @@ describe("ui helpers", () => { ).toBe(true); }); + test("keyboard alias helpers normalize the shared scroll shortcut keys", () => { + expect(isPageDownKey(createKeyEvent({ name: "pagedown" }))).toBe(true); + expect(isPageDownKey(createKeyEvent({ name: "space" }))).toBe(true); + expect(isPageDownKey(createKeyEvent({ name: "f" }))).toBe(true); + expect(isPageDownKey(createKeyEvent({ sequence: "f" }))).toBe(true); + expect(isPageUpKey(createKeyEvent({ name: "pageup" }))).toBe(true); + expect(isPageUpKey(createKeyEvent({ name: "b" }))).toBe(true); + expect(isPageUpKey(createKeyEvent({ sequence: "b" }))).toBe(true); + expect(isShiftSpacePageUpKey(createKeyEvent({ name: "space", shift: true }))).toBe(true); + expect(isHalfPageDownKey(createKeyEvent({ name: "d" }))).toBe(true); + expect(isHalfPageUpKey(createKeyEvent({ sequence: "u" }))).toBe(true); + expect(isStepDownKey(createKeyEvent({ name: "down" }))).toBe(true); + expect(isStepDownKey(createKeyEvent({ sequence: "j" }))).toBe(true); + expect(isStepUpKey(createKeyEvent({ name: "up" }))).toBe(true); + expect(isStepUpKey(createKeyEvent({ sequence: "k" }))).toBe(true); + expect(isPageDownKey(createKeyEvent({ name: "space", shift: true }))).toBe(false); + expect(isPageDownKey(createKeyEvent({ name: "q" }))).toBe(false); + expect(isShiftSpacePageUpKey(createKeyEvent({ name: "space", shift: false }))).toBe(false); + }); + test("fitText and padText clamp using the terminal fallback marker", () => { expect(fitText("hello", 0)).toBe(""); expect(fitText("hello", 1)).toBe(".");