diff --git a/.changeset/pure-pane-controller.md b/.changeset/pure-pane-controller.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/pure-pane-controller.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/src/ui/App.tsx b/src/ui/App.tsx index fa1ce864f..3cdc58cc2 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,8 +1,4 @@ -import { - MouseButton, - type MouseEvent as TuiMouseEvent, - type ScrollBoxRenderable, -} from "@opentui/core"; +import type { ScrollBoxRenderable } from "@opentui/core"; import { useRenderer, useTerminalDimensions } from "@opentui/react"; import { Suspense, @@ -34,10 +30,8 @@ import type { ExtensionCommandContext, ExtensionEventContext, ExtensionNotifyType, - ExtensionPaneControls, ExtensionLoadResult, RegisteredCommand, - RegisteredPane, } from "../extensions/types"; import type { ReviewProducer } from "../app/review/producer"; import type { HunkSessionBrokerClient } from "../session/broker/brokerClient"; @@ -59,6 +53,7 @@ import { useAppKeyboardShortcuts } from "./hooks/useAppKeyboardShortcuts"; import { useCurrentReviewRefreshController } from "./hooks/useCurrentReviewRefreshController"; import { useExtensionDialogController } from "./hooks/useExtensionDialogController"; import { useExtensionNotifications } from "./hooks/useExtensionNotifications"; +import { useExtensionPaneController } from "./hooks/useExtensionPaneController"; import { useExtensionReviewEvents } from "./hooks/useExtensionReviewEvents"; import { useExtensionTrustController } from "./hooks/useExtensionTrustController"; import { @@ -90,12 +85,6 @@ import { buildAppMenus } from "./lib/appMenus"; import { buildExtensionAppCommands, extensionCommandKeyDefaults } from "./lib/extensionCommands"; import { createExtensionCapabilityLease } from "./lib/extensionCapabilityLease"; import { createExtensionCommandControls } from "./lib/extensionCommandControls"; -import { - applyExtensionCurrentLinePaintUpdate, - extensionCurrentLinePaintMatchesCursor, - type ExtensionCurrentLinePaintState, - type ExtensionCurrentLinePaintUpdate, -} from "./lib/extensionCurrentLine"; import { createGuardedReviewNavigation } from "./lib/extensionNavigation"; import type { CurrentLineAlignment } from "./lib/hunkScroll"; import type { LineCursor } from "./lib/lineCursors"; @@ -108,23 +97,14 @@ import { useLineHighlightsController } from "./highlights/useLineHighlightsContr import { useKeyboardModeController } from "./keyboardModes/useKeyboardModeController"; import { createExtensionPaneKeybindings, resolveCommandKeys } from "./lib/keymap"; import { - buildSessionPanes, EXTENSION_PANE_DIVIDER_SIZE, - initialPaneOpenState, MIN_EXTENSION_REVIEW_HEIGHT, - planExtensionPanes, - reconcilePaneOpenState, - resolvePaneKey, - resolvePaneSlotKey, type PlannedPane, } from "./lib/extensionPanes"; -import type { ExtensionPanePlacement } from "../extension-api/types"; import { HUNK_FILES_PANE_KEY } from "../extensions/extensionIds"; -import { extensionPaneSize } from "../extensions/panes"; import { maxFileHeaderStatsWidth } from "./lib/fileHeader"; import { openSelectedFileInEditor } from "./lib/openInEditor"; import { resolveResponsiveLayout } from "./lib/responsive"; -import { resizeSidebarWidth } from "./lib/sidebar"; import type { WorkspaceRefreshRequest } from "./currentReviewRefresh"; type FocusArea = "files" | "filter" | "note"; @@ -235,62 +215,11 @@ export function App({ }>({ id: 0, alignment: "center" }); const [showHunkHeaders, setShowHunkHeaders] = useState(bootstrap.initialShowHunkHeaders ?? true); const [showMenuBar, setShowMenuBar] = useState(bootstrap.initialShowMenuBar ?? true); - const [sidebarVisible, setSidebarVisible] = useState(() => !pagerMode); - const [forceSidebarOpen, setForceSidebarOpen] = useState( - () => !pagerMode && bootstrap.initialSidebar === true, - ); const [showHelp, setShowHelp] = useState(false); const [showAgentSkill, setShowAgentSkill] = useState(false); const [focusArea, setFocusArea] = useState("files"); - const [paneSizes, setPaneSizes] = useState>({}); - const [paneResize, setPaneResize] = useState<{ - key: string; - registered: RegisteredPane; - placement: ExtensionPanePlacement; - origin: number; - startSize: number; - maxSize: number; - minSize: number; - } | null>(null); const { text: sessionNoticeText, show: showSessionNotice } = useTimedNotice(4_000); const extensions = bootstrap.extensions as ExtensionLoadResult | undefined; - const sessionPanes = useMemo(() => buildSessionPanes(extensions), [extensions]); - const [paneOpenState, setPaneOpenState] = useState(() => { - const initial = initialPaneOpenState(sessionPanes); - if (bootstrap.initialSidebar !== false) return initial; - - // The preference targets the active files slot, not independently open extension panes. - const filesPaneKey = resolvePaneSlotKey({ - panes: sessionPanes, - slotKey: HUNK_FILES_PANE_KEY, - openKeys: initial.open, - }); - return { ...initial, open: initial.open.filter((key) => key !== filesPaneKey) }; - }); - useEffect( - () => setPaneOpenState((current) => reconcilePaneOpenState(sessionPanes, current)), - [sessionPanes], - ); - const sessionPanesRef = useRef(sessionPanes); - sessionPanesRef.current = sessionPanes; - const paneOpenStateRef = useRef(paneOpenState); - paneOpenStateRef.current = paneOpenState; - const currentLinePaintRequested = sessionPanes.some( - (pane) => paneOpenState.open.includes(pane.key) && pane.registered.pane.currentLine === true, - ); - const [currentLinePaintState, setCurrentLinePaintState] = - useState({ - status: "unavailable", - fileId: null, - cursorKey: null, - paint: null, - }); - const onCurrentLinePaintChange = useCallback((update: ExtensionCurrentLinePaintUpdate) => { - setCurrentLinePaintState((current) => applyExtensionCurrentLinePaintUpdate(current, update)); - }, []); - const retainedCurrentLinePaneKeysRef = useRef>(new Set()); - const [paneFailureEpoch, setPaneFailureEpoch] = useState(0); - const paneAvailabilityQuarantineRef = useRef(new WeakSet()); const pendingTrustRepoRoot = extensions?.pendingTrustRepoRoot; const extensionToast = useExtensionNotifications(extensions?.notifications); @@ -342,14 +271,6 @@ export function App({ const selectedFile = review.selectedFile; const selectedHunkIndex = review.selectedHunkIndex; const selectedFileId = selectedFile?.id ?? null; - const currentLinePaintMatchesCursor = extensionCurrentLinePaintMatchesCursor( - currentLinePaintState, - review.lineCursor, - ); - const currentLinePaint = currentLinePaintMatchesCursor ? currentLinePaintState.paint : null; - const currentLinePaintPending = - currentLinePaintState.status === "pending" || - (currentLinePaintState.status === "ready" && !currentLinePaintMatchesCursor); /** The review stream's current line, or null when line-level navigation is off. */ const activeLineCursor = useMemo( () => (cursorLine === "off" ? null : review.lineCursor), @@ -580,76 +501,63 @@ export function App({ reviewGeneration: bootstrap, }); - const setPaneOpen = useCallback((key: string, nextOpen: boolean | "toggle") => { - setPaneOpenState((current) => { - const isOpen = current.open.includes(key); - const resolved = nextOpen === "toggle" ? !isOpen : nextOpen; - if (resolved === isOpen) return current; - return { - known: current.known, - open: resolved ? [...current.open, key] : current.open.filter((open) => open !== key), - }; - }); - }, []); - - /** Build the canonical pane controls; deprecated sidebar controls share this object. */ - const createPaneControls = useCallback( - (extensionId: string): ExtensionPaneControls => { - const lease = createReviewCapabilityLease(); - const hasAuthority = (method: string) => { - if (lease.isLive()) return true; - extensions?.context.notify( - `Extension ${extensionId} ${method} ignored — the review session was reloaded`, - "warning", - ); - return false; - }; - const resolve = (method: string, id: string) => { - const key = resolvePaneKey(sessionPanesRef.current, extensionId, id); - if (!key) - extensions?.context.notify( - `Extension ${extensionId} ${method} targeted unknown pane "${id}"`, - "warning", - ); - return key; - }; - const revealIfSide = (key: string) => { - const pane = sessionPanesRef.current.find((entry) => entry.key === key); - if (pane?.placement === "left" || pane?.placement === "right") - revealSidebarAreaRef.current(); - }; - return { - open(id) { - if (!hasAuthority("panes.open")) return; - const key = resolve("panes.open", id); - if (key) { - setPaneOpen(key, true); - revealIfSide(key); - } - }, - close(id) { - if (!hasAuthority("panes.close")) return; - const key = resolve("panes.close", id); - if (key) setPaneOpen(key, false); - }, - toggle(id) { - if (!hasAuthority("panes.toggle")) return; - const key = resolve("panes.toggle", id); - if (key) { - const opens = !paneOpenStateRef.current.open.includes(key); - setPaneOpen(key, "toggle"); - if (opens) revealIfSide(key); - } - }, - isOpen(id) { - if (!lease.isLive()) return false; - const key = resolvePaneKey(sessionPanesRef.current, extensionId, id); - return key !== undefined && paneOpenStateRef.current.open.includes(key); - }, - }; - }, - [createReviewCapabilityLease, extensions, setPaneOpen], + const bodyPadding = pagerMode ? 0 : BODY_PADDING; + const bodyWidth = Math.max(0, terminal.width - bodyPadding); + const responsiveLayout = resolveResponsiveLayout(layoutMode, terminal.width); + const resolvedLayout = responsiveLayout.layout; + const canForceShowSidebar = + bodyWidth >= SIDEBAR_MIN_WIDTH + EXTENSION_PANE_DIVIDER_SIZE + DIFF_MIN_WIDTH; + const statusBarVisible = + focusArea === "filter" || + Boolean(review.filter) || + Boolean( + sessionNoticeText ?? + transientNoticeText ?? + noticeText ?? + fileViewModeHint ?? + keyboardModeHint, + ); + const bodyHeight = Math.max( + 0, + terminal.height - (showMenuBar ? 1 : 0) - (extensionToast ? 1 : 0) - (statusBarVisible ? 1 : 0), + ); + const showPaneWarning = useCallback( + (message: string) => extensions?.context.notify(message, "warning"), + [extensions], ); + const { + beginPaneResize, + createPaneControls, + currentLinePaint, + currentLinePaintRequested, + endPaneResize, + filesPaneVisible, + onCurrentLinePaintChange, + paneLayout, + reportPaneRenderFailure, + renderSidebar, + resizingPaneKey, + toggleFilesPane, + updatePaneResize, + } = useExtensionPaneController({ + availabilityContext: { + files: getExtensionFileViews(), + selectedFileId, + selectedHunkIndex, + }, + bodyHeight, + bodyWidth, + canForceShowSidebar, + createReviewCapabilityLease, + currentLineCursor: review.lineCursor, + extensions, + initialSidebar: bootstrap.initialSidebar, + minReviewHeight: MIN_EXTENSION_REVIEW_HEIGHT, + minReviewWidth: DIFF_MIN_WIDTH, + notifyWarning: showPaneWarning, + pagerMode, + responsiveShowsSidebar: responsiveLayout.showSidebar, + }); /** Build live, guarded review navigation for one extension-owned handler. */ const createExtensionNavigation = useCallback( @@ -670,12 +578,6 @@ export function App({ [createReviewCapabilityLease, extensions], ); - /** - * Reveal the sidebar area, assigned each render once the responsive layout - * is known (the controls above are created before it is computed). - */ - const revealSidebarAreaRef = useRef<() => void>(() => {}); - const { accept: acceptExtensionDialog, cancel: cancelExtensionDialog, @@ -890,14 +792,6 @@ export function App({ ); }, [keymap, showSessionNotice]); - const bodyPadding = pagerMode ? 0 : BODY_PADDING; - const bodyWidth = Math.max(0, terminal.width - bodyPadding); - const responsiveLayout = resolveResponsiveLayout(layoutMode, terminal.width); - const canForceShowSidebar = - bodyWidth >= SIDEBAR_MIN_WIDTH + EXTENSION_PANE_DIVIDER_SIZE + DIFF_MIN_WIDTH; - const sidebarAreaVisible = - sidebarVisible && (responsiveLayout.showSidebar || (forceSidebarOpen && canForceShowSidebar)); - const resolvedLayout = responsiveLayout.layout; const { publishCommandExecuted, publishNoteEvent, publishWatchReloadPending } = useExtensionReviewEvents({ extensions, @@ -909,109 +803,9 @@ export function App({ selectedHunkIndex, themeId, }); - const statusBarVisible = - focusArea === "filter" || - Boolean(review.filter) || - Boolean( - sessionNoticeText ?? - transientNoticeText ?? - noticeText ?? - fileViewModeHint ?? - keyboardModeHint, - ); - const bodyHeight = Math.max( - 0, - terminal.height - (showMenuBar ? 1 : 0) - (extensionToast ? 1 : 0) - (statusBarVisible ? 1 : 0), - ); - const failedFilesReplacement = sessionPanes.some( - (pane) => - paneOpenState.open.includes(pane.key) && - pane.registered.pane.replaces === HUNK_FILES_PANE_KEY && - paneAvailabilityQuarantineRef.current.has(pane.registered), - ); - const effectiveOpenPaneKeys = paneOpenState.open.filter((key) => { - const pane = sessionPanes.find((entry) => entry.key === key); - return sidebarAreaVisible || (pane?.placement !== "left" && pane?.placement !== "right"); - }); - if ( - failedFilesReplacement && - sidebarAreaVisible && - !effectiveOpenPaneKeys.includes(HUNK_FILES_PANE_KEY) - ) { - effectiveOpenPaneKeys.push(HUNK_FILES_PANE_KEY); - } - const paneLayout = useMemo( - () => - planExtensionPanes({ - panes: sessionPanes, - openKeys: effectiveOpenPaneKeys, - sizes: paneSizes, - bodyWidth, - bodyHeight, - minReviewWidth: DIFF_MIN_WIDTH, - minReviewHeight: MIN_EXTENSION_REVIEW_HEIGHT, - currentLine: currentLinePaint, - retainCurrentLineKeys: currentLinePaintPending - ? retainedCurrentLinePaneKeysRef.current - : undefined, - availabilityContext: { - files: getExtensionFileViews(), - selectedFileId, - selectedHunkIndex, - }, - quarantined: paneAvailabilityQuarantineRef.current, - onAvailabilityError: (pane, error) => - extensions?.context.notify( - `Extension ${pane.registered.extensionId} pane "${pane.registered.pane.id}" availability failed • ${error instanceof Error ? error.message : String(error)}`, - "warning", - ), - }), - [ - bodyHeight, - bodyWidth, - currentLinePaint, - currentLinePaintPending, - effectiveOpenPaneKeys.join("\0"), - extensions, - filteredFiles, - getExtensionFileViews, - paneFailureEpoch, - paneSizes, - selectedFileId, - selectedHunkIndex, - sessionPanes, - ], - ); - useLayoutEffect(() => { - if (currentLinePaintPending) return; - retainedCurrentLinePaneKeysRef.current = new Set( - paneLayout.panes - .filter(({ pane }) => pane.registered.pane.currentLine === true) - .map(({ pane }) => pane.key), - ); - }, [currentLinePaintPending, paneLayout]); - const renderSidebar = paneLayout.panes.some( - ({ pane }) => pane.placement === "left" || pane.placement === "right", - ); - const visiblePaneKeys = paneLayout.panes.map(({ pane }) => pane.key); - const visibleFilesPaneKey = resolvePaneSlotKey({ - panes: sessionPanes, - slotKey: HUNK_FILES_PANE_KEY, - openKeys: visiblePaneKeys, - quarantined: paneAvailabilityQuarantineRef.current, - }); - const filesPaneVisible = visiblePaneKeys.includes(visibleFilesPaneKey); const diffPaneWidth = paneLayout.reviewBounds.width; const diffPaneHeight = paneLayout.reviewBounds.height; const diffContentWidth = Math.max(0, diffPaneWidth - 2); - // Mirrors toggleFilesPane's reveal half: visible again, forced open when the - // responsive layout alone would keep it hidden and the terminal has room. - revealSidebarAreaRef.current = () => { - setSidebarVisible(true); - if (!responsiveLayout.showSidebar && canForceShowSidebar) { - setForceSidebarOpen(true); - } - }; // Publish the live note geometry for daemon-driven markup validation; the // note markup width mirrors what AgentInlineNote lays STML out at. noteGeometryRef.current = { layout: resolvedLayout, width: diffContentWidth }; @@ -1091,23 +885,6 @@ export function App({ ), [diffContentWidth, maxLineNumberDigits, resolvedLayout, showLineNumbers], ); - const isResizingPane = paneResize !== null; - - useEffect(() => { - if ( - paneResize && - !paneLayout.panes.some( - (planned) => - planned.pane.key === paneResize.key && - planned.pane.registered === paneResize.registered && - planned.pane.placement === paneResize.placement && - planned.divider !== undefined, - ) - ) { - setPaneResize(null); - } - }, [paneLayout.panes, paneResize]); - useEffect(() => { // Force an intermediate redraw when app geometry or row-wrapping changes so pane relayout // feels immediate after toggling split/stack or line wrapping. @@ -1216,26 +993,6 @@ export function App({ setWrapLines((current) => !current); }; - /** Toggle only the active files pane without changing extension pane visibility. */ - const toggleFilesPane = () => { - const filesPaneKey = resolvePaneSlotKey({ - panes: sessionPanes, - slotKey: HUNK_FILES_PANE_KEY, - openKeys: paneOpenStateRef.current.open, - quarantined: paneAvailabilityQuarantineRef.current, - }); - - const filesPane = sessionPanes.find((pane) => pane.key === filesPaneKey); - const usesSidebarArea = filesPane?.placement === "left" || filesPane?.placement === "right"; - if (usesSidebarArea && !sidebarAreaVisible) { - setPaneOpen(filesPaneKey, true); - revealSidebarAreaRef.current(); - return; - } - - setPaneOpen(filesPaneKey, "toggle"); - }; - /** Toggle visibility of hunk metadata rows without changing the actual diff lines. */ const toggleHunkHeaders = () => { setShowHunkHeaders((current) => !current); @@ -1538,57 +1295,6 @@ export function App({ themeSelectorOpen, }); - /** Start a mouse drag for one resizable pane. */ - const beginPaneResize = (planned: PlannedPane) => (event: TuiMouseEvent) => { - if (event.button !== MouseButton.LEFT) return; - const vertical = planned.pane.placement === "left" || planned.pane.placement === "right"; - const spec = extensionPaneSize(planned.pane.registered.pane, planned.pane.placement); - const currentSize = vertical ? planned.bounds.width : planned.bounds.height; - closeMenu(); - setPaneResize({ - key: planned.pane.key, - registered: planned.pane.registered, - placement: planned.pane.placement, - origin: vertical ? event.x : event.y, - startSize: currentSize, - maxSize: Math.min( - spec.max ?? Number.MAX_SAFE_INTEGER, - currentSize + - Math.max( - 0, - vertical - ? diffPaneWidth - DIFF_MIN_WIDTH - : diffPaneHeight - MIN_EXTENSION_REVIEW_HEIGHT, - ), - ), - minSize: spec.min ?? 1, - }); - event.preventDefault(); - event.stopPropagation(); - }; - - /** Update the active pane drag on its placement axis. */ - const updatePaneResize = (event: TuiMouseEvent) => { - if (!paneResize) return; - const { key, placement, origin, startSize, maxSize, minSize } = paneResize; - const vertical = placement === "left" || placement === "right"; - const position = vertical ? event.x : event.y; - const inverted = placement === "right" || placement === "bottom"; - const next = inverted - ? resizeSidebarWidth(startSize, position, origin, minSize, maxSize) - : resizeSidebarWidth(startSize, origin, position, minSize, maxSize); - setPaneSizes((current) => (current[key] === next ? current : { ...current, [key]: next })); - event.preventDefault(); - event.stopPropagation(); - }; - - const endPaneResize = (event?: TuiMouseEvent) => { - if (!isResizingPane) return; - setPaneResize(null); - event?.preventDefault(); - event?.stopPropagation(); - }; - const changedFileCount = bootstrap.changeset.files.length; const changedFileLabel = changedFileCount === 1 ? "file" : "files"; const totalAdditions = bootstrap.changeset.files.reduce( @@ -1647,17 +1353,7 @@ export function App({ return review.revealLine(fileId, side, line); }} onRenderFailure={ - pane.key === HUNK_FILES_PANE_KEY - ? undefined - : () => { - paneAvailabilityQuarantineRef.current.add(pane.registered); - if (pane.registered.pane.replaces === HUNK_FILES_PANE_KEY) { - setPaneOpen(pane.key, false); - setPaneOpen(HUNK_FILES_PANE_KEY, true); - revealSidebarAreaRef.current(); - } - setPaneFailureEpoch((value) => value + 1); - } + pane.key === HUNK_FILES_PANE_KEY ? undefined : () => reportPaneRenderFailure(pane) } /> @@ -1680,9 +1376,11 @@ export function App({ orientation={planned.divider.width === 1 ? "vertical" : "horizontal"} width={planned.divider.width} height={planned.divider.height} - isResizing={paneResize?.key === planned.pane.key} + isResizing={resizingPaneKey === planned.pane.key} theme={activeTheme} - onMouseDown={beginPaneResize(planned)} + onMouseDown={(event) => { + if (beginPaneResize(planned, event)) closeMenu(); + }} onMouseDrag={updatePaneResize} onMouseDragEnd={endPaneResize} onMouseUp={endPaneResize} diff --git a/src/ui/AppHost.extension-sidebar.test.tsx b/src/ui/AppHost.extension-sidebar.test.tsx index 1254b4cd5..82cc7c45b 100644 --- a/src/ui/AppHost.extension-sidebar.test.tsx +++ b/src/ui/AppHost.extension-sidebar.test.tsx @@ -859,6 +859,56 @@ describe("extension sidebar views", () => { }); }); + test("the files toggle closes a built-in fallback injected after availability failure", async () => { + const repo = createTestRepo("hunk-ext-sidebar-availability-failure-"); + const extPath = join(createTempDir("hunk-ext-sidebar-availability-failure-ext-"), "ext.ts"); + writeFileSync( + extPath, + `import { createElement } from "react";\n` + + `export default function (hunk) {\n` + + ` hunk.registerPane({\n` + + ` id: "broken-files",\n` + + ` placement: "left",\n` + + ` replaces: "hunk:files",\n` + + ` available: () => { throw new Error("availability exploded"); },\n` + + ` component: () => createElement("text", { content: "BROKEN FILES" }),\n` + + ` });\n` + + `}\n`, + ); + + const bootstrap = await launchWithExtension(repo, extPath); + await withAppHost(bootstrap, async (setup) => { + await flushUntil( + setup, + () => setup.captureCharFrame().includes("availability failed"), + "the availability failure warning to appear", + ); + await flushUntil( + setup, + () => setup.captureCharFrame().includes("M alpha.txt"), + "the built-in files fallback to appear", + ); + + await act(async () => { + await setup.mockInput.typeText("s"); + }); + await flushUntil( + setup, + () => !setup.captureCharFrame().includes("M alpha.txt"), + "the files toggle to close the injected fallback", + ); + + await act(async () => { + await setup.mockInput.typeText("s"); + }); + await flushUntil( + setup, + () => setup.captureCharFrame().includes("M alpha.txt"), + "the files toggle to reopen the built-in files pane", + ); + }); + }); + test("reevaluates pane availability when filtering changes visible files", async () => { const repo = createTestRepo("hunk-ext-pane-availability-"); const extPath = join(createTempDir("hunk-ext-pane-availability-ext-"), "ext.ts"); diff --git a/src/ui/hooks/useExtensionPaneController.test.tsx b/src/ui/hooks/useExtensionPaneController.test.tsx new file mode 100644 index 000000000..a17c0a54a --- /dev/null +++ b/src/ui/hooks/useExtensionPaneController.test.tsx @@ -0,0 +1,541 @@ +import { describe, expect, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act, StrictMode, useLayoutEffect, useState } from "react"; +import type { + ExtensionPaneAvailabilityContext, + ExtensionPanePlacement, + ExtensionPaneSize, +} from "../../extension-api/types"; +import { HUNK_FILES_PANE_KEY } from "../../extensions/extensionIds"; +import { createEmptyExtensionLoadResult, type RegisteredPane } from "../../extensions/types"; +import { + useExtensionPaneController, + type ExtensionPaneController, +} from "./useExtensionPaneController"; + +interface TestPaneOptions { + placement?: ExtensionPanePlacement; + width?: ExtensionPaneSize; + height?: ExtensionPaneSize; + defaultOpen?: boolean; + replaces?: string; + currentLine?: boolean; + available?: (context: ExtensionPaneAvailabilityContext) => boolean; +} + +/** Build one test registration with observable pane policy and inert rendering. */ +function registeredPane( + extensionId: string, + id: string, + options: TestPaneOptions = {}, +): RegisteredPane { + return { + extensionId, + pane: { + id, + component: () => null, + ...options, + } as RegisteredPane["pane"], + }; +} + +/** Build one mutable extension load result from pane registrations. */ +function loadResultWith(panes: RegisteredPane[]) { + const result = createEmptyExtensionLoadResult(); + result.registry.panes.push(...panes); + return result; +} + +/** Build the mouse fields consumed by pane resizing and record event ownership. */ +function mouseEvent({ button = 0, x = 0, y = 0 }: { button?: number; x?: number; y?: number }) { + let prevented = false; + let stopped = false; + return { + event: { + button, + x, + y, + preventDefault: () => { + prevented = true; + }, + stopPropagation: () => { + stopped = true; + }, + } as never, + prevented: () => prevented, + stopped: () => stopped, + }; +} + +/** Mount a pane controller with mutable geometry, registration, and review inputs. */ +async function renderController({ + extensions = loadResultWith([]), + strictMode = false, + initialResponsiveShowsSidebar = true, + initialWidth = 100, + initialHeight = 30, + initialSidebar, +}: { + extensions?: ReturnType; + strictMode?: boolean; + initialResponsiveShowsSidebar?: boolean; + initialWidth?: number; + initialHeight?: number; + initialSidebar?: boolean | "auto"; +} = {}) { + let controller!: ExtensionPaneController; + let live = true; + let setExtensions!: (value: ReturnType) => void; + let setCurrentLineCursor!: (value: { fileId: string; stableKey: string } | null) => void; + let setResponsiveShowsSidebar!: (value: boolean) => void; + let setSelectedFileId!: (value: string | null) => void; + let setSize!: (value: { width: number; height: number }) => void; + const committedFreshOpen: boolean[] = []; + const warnings: string[] = []; + const notifyWarning = (message: string) => warnings.push(message); + const createReviewCapabilityLease = () => ({ isLive: () => live }); + const emptyFiles: ExtensionPaneAvailabilityContext["files"] = []; + + function Harness() { + const [currentExtensions, updateExtensions] = useState(extensions); + const [responsiveShowsSidebar, updateResponsiveShowsSidebar] = useState( + initialResponsiveShowsSidebar, + ); + const [currentLineCursor, updateCurrentLineCursor] = useState<{ + fileId: string; + stableKey: string; + } | null>(null); + const [selectedFileId, updateSelectedFileId] = useState(null); + const [size, updateSize] = useState({ width: initialWidth, height: initialHeight }); + setCurrentLineCursor = updateCurrentLineCursor; + setExtensions = updateExtensions; + setResponsiveShowsSidebar = updateResponsiveShowsSidebar; + setSelectedFileId = updateSelectedFileId; + setSize = updateSize; + controller = useExtensionPaneController({ + availabilityContext: { files: emptyFiles, selectedFileId, selectedHunkIndex: null }, + bodyHeight: size.height, + bodyWidth: size.width, + canForceShowSidebar: size.width >= 71, + createReviewCapabilityLease, + currentLineCursor, + extensions: currentExtensions, + initialSidebar, + minReviewHeight: 5, + minReviewWidth: 48, + notifyWarning, + pagerMode: false, + responsiveShowsSidebar, + }); + useLayoutEffect(() => { + committedFreshOpen.push(controller.createPaneControls("meta").isOpen("fresh")); + }, [currentExtensions]); + return {controller.paneLayout.panes.map(({ pane }) => pane.key).join(",")}; + } + + const setup = await testRender( + strictMode ? ( + + + + ) : ( + + ), + { width: initialWidth, height: initialHeight }, + ); + + /** Flush commit-phase probes and any state they publish. */ + const settle = async () => { + await act(async () => { + await setup.renderOnce(); + await Bun.sleep(0); + await setup.renderOnce(); + }); + }; + await settle(); + + return { + committedFreshOpen, + current: () => controller, + retire: () => { + live = false; + }, + setCurrentLineCursor, + setExtensions, + setResponsiveShowsSidebar, + setSelectedFileId, + setSize, + settle, + setup, + warnings, + }; +} + +/** Destroy one controller harness. */ +async function destroy(setup: Awaited>) { + await act(async () => setup.renderer.destroy()); +} + +describe("useExtensionPaneController", () => { + test("probes availability after commit and keeps false panes logically open", async () => { + let available = false; + let calls = 0; + const pane = registeredPane("meta", "detail", { + placement: "bottom", + height: { preferred: 3, min: 3, max: 3 }, + defaultOpen: true, + available: () => { + calls += 1; + return available; + }, + }); + const harness = await renderController({ + extensions: loadResultWith([pane]), + strictMode: true, + }); + try { + expect(calls).toBe(1); + expect( + harness.current().paneLayout.panes.some(({ pane }) => pane.key === "meta:detail"), + ).toBeFalse(); + expect(harness.current().createPaneControls("meta").isOpen("detail")).toBeTrue(); + expect(harness.warnings).toEqual([]); + + available = true; + await act(async () => harness.setSelectedFileId("next")); + await harness.settle(); + expect(calls).toBe(2); + expect( + harness.current().paneLayout.panes.some(({ pane }) => pane.key === "meta:detail"), + ).toBeTrue(); + } finally { + await destroy(harness.setup); + } + }); + + test("quarantines one failed replacement, warns once, and restores a fresh registration", async () => { + let calls = 0; + const broken = registeredPane("meta", "files", { + replaces: HUNK_FILES_PANE_KEY, + available: () => { + calls += 1; + throw new Error("availability exploded"); + }, + }); + const harness = await renderController({ + extensions: loadResultWith([broken]), + strictMode: true, + }); + try { + expect(calls).toBe(1); + expect(harness.warnings).toEqual([ + 'Extension meta pane "files" availability failed • availability exploded', + ]); + expect(harness.current().paneLayout.panes.map(({ pane }) => pane.key)).toContain( + HUNK_FILES_PANE_KEY, + ); + + await act(async () => harness.setSelectedFileId("changed")); + await harness.settle(); + expect(calls).toBe(1); + expect(harness.warnings).toHaveLength(1); + + const healthy = registeredPane("meta", "files", { + replaces: HUNK_FILES_PANE_KEY, + available: () => true, + }); + await act(async () => harness.setExtensions(loadResultWith([healthy]))); + await harness.settle(); + expect(harness.current().paneLayout.panes.map(({ pane }) => pane.key)).toContain( + "meta:files", + ); + } finally { + await destroy(harness.setup); + } + }); + + test("toggles off a built-in files fallback injected after an availability failure", async () => { + const broken = registeredPane("meta", "files", { + replaces: HUNK_FILES_PANE_KEY, + available: () => { + throw new Error("availability exploded"); + }, + }); + const harness = await renderController({ extensions: loadResultWith([broken]) }); + try { + expect(harness.current().filesPaneVisible).toBeTrue(); + expect(harness.current().paneLayout.panes.map(({ pane }) => pane.key)).toContain( + HUNK_FILES_PANE_KEY, + ); + + await act(async () => harness.current().toggleFilesPane()); + await harness.settle(); + expect(harness.current().filesPaneVisible).toBeFalse(); + expect(harness.current().paneLayout.panes.map(({ pane }) => pane.key)).not.toContain( + HUNK_FILES_PANE_KEY, + ); + + await act(async () => harness.current().toggleFilesPane()); + await harness.settle(); + expect(harness.current().filesPaneVisible).toBeTrue(); + } finally { + await destroy(harness.setup); + } + }); + + test("publishes newly registered default-open state before later layout effects run", async () => { + const harness = await renderController(); + try { + const fresh = registeredPane("meta", "fresh", { + placement: "bottom", + height: { preferred: 2, min: 2, max: 2 }, + defaultOpen: true, + }); + await act(async () => harness.setExtensions(loadResultWith([fresh]))); + expect(harness.committedFreshOpen.at(-1)).toBeTrue(); + await harness.settle(); + expect(harness.current().paneLayout.panes.map(({ pane }) => pane.key)).toContain( + "meta:fresh", + ); + } finally { + await destroy(harness.setup); + } + }); + + test("retires captured controls and reveals side panes only when an authorized action opens", async () => { + const extra = registeredPane("meta", "extra", { defaultOpen: false }); + const harness = await renderController({ + extensions: loadResultWith([extra]), + initialResponsiveShowsSidebar: false, + initialSidebar: "auto", + }); + try { + const controls = harness.current().createPaneControls("meta"); + expect(harness.current().renderSidebar).toBeFalse(); + + await act(async () => controls.open("extra")); + await harness.settle(); + expect(harness.current().renderSidebar).toBeTrue(); + expect(controls.isOpen("extra")).toBeTrue(); + + harness.retire(); + controls.close("extra"); + expect(controls.isOpen("extra")).toBeFalse(); + expect(harness.warnings.at(-1)).toContain("panes.close ignored"); + } finally { + await destroy(harness.setup); + } + }); + + test("falls back after a replacement render failure without retaining its logical open choice", async () => { + const replacement = registeredPane("meta", "files", { replaces: HUNK_FILES_PANE_KEY }); + const harness = await renderController({ extensions: loadResultWith([replacement]) }); + try { + const planned = harness + .current() + .paneLayout.panes.find(({ pane }) => pane.key === "meta:files"); + expect(planned).toBeDefined(); + await act(async () => harness.current().reportPaneRenderFailure(planned!.pane)); + await harness.settle(); + expect(harness.current().paneLayout.panes.map(({ pane }) => pane.key)).toContain( + HUNK_FILES_PANE_KEY, + ); + expect(harness.current().createPaneControls("meta").isOpen("files")).toBeFalse(); + } finally { + await destroy(harness.setup); + } + }); + + test("retains an accepted current-line pane while replacement paint is pending", async () => { + let calls = 0; + const detail = registeredPane("meta", "line", { + placement: "bottom", + height: { preferred: 3, min: 3, max: 3 }, + defaultOpen: true, + currentLine: true, + available: ({ currentLine }) => { + calls += 1; + return currentLine !== null; + }, + }); + const harness = await renderController({ extensions: loadResultWith([detail]) }); + try { + expect(harness.current().currentLinePaintRequested).toBeTrue(); + expect( + harness.current().paneLayout.panes.some(({ pane }) => pane.key === "meta:line"), + ).toBeFalse(); + + const paint = { render: () => null }; + await act(async () => { + harness.setCurrentLineCursor({ fileId: "file", stableKey: "cursor" }); + harness.current().onCurrentLinePaintChange({ + status: "ready", + fileId: "file", + cursorKey: "cursor", + paint, + }); + }); + await harness.settle(); + expect( + harness.current().paneLayout.panes.some(({ pane }) => pane.key === "meta:line"), + ).toBeTrue(); + + const callsBeforePending = calls; + await act(async () => harness.current().onCurrentLinePaintChange({ status: "pending" })); + await harness.settle(); + expect( + harness.current().paneLayout.panes.some(({ pane }) => pane.key === "meta:line"), + ).toBeTrue(); + expect(calls).toBe(callsBeforePending); + + let replacementCalls = 0; + const replacement = registeredPane("meta", "line", { + placement: "bottom", + height: { preferred: 3, min: 3, max: 3 }, + defaultOpen: true, + currentLine: true, + available: ({ currentLine }) => { + replacementCalls += 1; + expect(currentLine).toBeNull(); + return false; + }, + }); + await act(async () => harness.setExtensions(loadResultWith([replacement]))); + await harness.settle(); + expect(replacementCalls).toBe(1); + expect( + harness.current().paneLayout.panes.some(({ pane }) => pane.registered === replacement), + ).toBeFalse(); + } finally { + await destroy(harness.setup); + } + }); + + test("resizes right panes with inverted drag direction and cancels after terminal shrink", async () => { + const right = registeredPane("meta", "right", { + placement: "right", + defaultOpen: true, + width: { preferred: 20, min: 10, max: 50 }, + }); + const harness = await renderController({ + extensions: loadResultWith([right]), + initialSidebar: false, + }); + try { + const planned = harness + .current() + .paneLayout.panes.find(({ pane }) => pane.key === "meta:right")!; + const start = mouseEvent({ x: planned.divider!.x }); + let began = false; + await act(async () => { + began = harness.current().beginPaneResize(planned, start.event); + }); + expect(began).toBeTrue(); + expect(start.prevented()).toBeTrue(); + const drag = mouseEvent({ x: planned.divider!.x - 8 }); + await act(async () => harness.current().updatePaneResize(drag.event)); + await harness.settle(); + expect( + harness.current().paneLayout.panes.find(({ pane }) => pane.key === "meta:right")!.bounds + .width, + ).toBe(planned.bounds.width + 8); + + await act(async () => harness.setSize({ width: 55, height: 30 })); + await harness.settle(); + expect(harness.current().resizingPaneKey).toBeNull(); + const stale = mouseEvent({ x: 0 }); + harness.current().updatePaneResize(stale.event); + expect(stale.prevented()).toBeFalse(); + } finally { + await destroy(harness.setup); + } + }); + + test("resizes bottom panes with inverted row-axis movement", async () => { + const bottom = registeredPane("meta", "bottom", { + placement: "bottom", + defaultOpen: true, + height: { preferred: 5, min: 3, max: 12 }, + }); + const harness = await renderController({ + extensions: loadResultWith([bottom]), + initialSidebar: false, + }); + try { + const planned = harness + .current() + .paneLayout.panes.find(({ pane }) => pane.key === "meta:bottom")!; + const startHeight = planned.bounds.height; + await act(async () => { + harness.current().beginPaneResize(planned, mouseEvent({ y: planned.divider!.y }).event); + }); + await act(async () => { + harness.current().updatePaneResize(mouseEvent({ y: planned.divider!.y - 4 }).event); + }); + await harness.settle(); + expect( + harness.current().paneLayout.panes.find(({ pane }) => pane.key === "meta:bottom")!.bounds + .height, + ).toBe(startHeight + 4); + } finally { + await destroy(harness.setup); + } + }); + + test("cancels an active drag when controls close its pane", async () => { + const extra = registeredPane("meta", "extra", { + defaultOpen: true, + width: { preferred: 24, min: 10, max: 40 }, + }); + const harness = await renderController({ extensions: loadResultWith([extra]) }); + try { + const planned = harness + .current() + .paneLayout.panes.find(({ pane }) => pane.key === "meta:extra")!; + let began = false; + await act(async () => { + began = harness.current().beginPaneResize(planned, mouseEvent({ x: 24 }).event); + }); + expect(began).toBeTrue(); + await harness.settle(); + expect(harness.current().resizingPaneKey).toBe("meta:extra"); + + await act(async () => harness.current().createPaneControls("meta").close("extra")); + await harness.settle(); + expect(harness.current().resizingPaneKey).toBeNull(); + expect( + harness.current().paneLayout.panes.some(({ pane }) => pane.key === "meta:extra"), + ).toBeFalse(); + } finally { + await destroy(harness.setup); + } + }); + + test("cancels a drag when a soft reload replaces the registration behind the same key", async () => { + const first = registeredPane("meta", "extra", { + defaultOpen: true, + width: { preferred: 24, min: 10, max: 40 }, + }); + const harness = await renderController({ extensions: loadResultWith([first]) }); + try { + const planned = harness + .current() + .paneLayout.panes.find(({ pane }) => pane.key === "meta:extra")!; + await act(async () => { + harness.current().beginPaneResize(planned, mouseEvent({ x: planned.divider!.x }).event); + }); + await harness.settle(); + expect(harness.current().resizingPaneKey).toBe("meta:extra"); + + const replacement = registeredPane("meta", "extra", { + defaultOpen: true, + width: { preferred: 24, min: 10, max: 40 }, + }); + await act(async () => harness.setExtensions(loadResultWith([replacement]))); + await harness.settle(); + expect(harness.current().resizingPaneKey).toBeNull(); + } finally { + await destroy(harness.setup); + } + }); +}); diff --git a/src/ui/hooks/useExtensionPaneController.ts b/src/ui/hooks/useExtensionPaneController.ts new file mode 100644 index 000000000..ff7d87608 --- /dev/null +++ b/src/ui/hooks/useExtensionPaneController.ts @@ -0,0 +1,628 @@ +/** + * Coordinates extension panes as users open, resize, hide, and recover them around the review. + * + * Extension commands and the built-in files toggle share logical open choices, while responsive + * layout only controls whether side panes can occupy terminal space. The controller probes + * extension availability after commit, quarantines callback or render failures by registration + * identity, restores the built-in files pane after a failed replacement, and retains current-line + * panes while the review renderer prepares fresh paint. + * + * App supplies terminal geometry, review facts, and capability leases and retains pane rendering. + * The controller owns pane state and exact layout; it never recreates extension authority or + * invokes extension code while React renders. + */ + +import { MouseButton, type MouseEvent as TuiMouseEvent } from "@opentui/core"; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import type { SidebarVisibility } from "../../core/run/commandInputs"; +import type { + ExtensionCurrentLinePaint, + ExtensionPaneAvailabilityContext, + ExtensionPaneControls, +} from "../../extension-api/types"; +import { HUNK_FILES_PANE_KEY } from "../../extensions/extensionIds"; +import { extensionPaneSize } from "../../extensions/panes"; +import type { ExtensionLoadResult, RegisteredPane } from "../../extensions/types"; +import type { ExtensionCapabilityLease } from "../lib/extensionCapabilityLease"; +import { + applyExtensionCurrentLinePaintUpdate, + extensionCurrentLinePaintMatchesCursor, + type ExtensionCurrentLinePaintState, + type ExtensionCurrentLinePaintUpdate, +} from "../lib/extensionCurrentLine"; +import { + buildSessionPanes, + initialPaneOpenState, + planExtensionPanes, + probeExtensionPaneAvailability, + reconcilePaneOpenState, + resolvePaneKey, + resolvePaneSlotKey, + type ExtensionPaneLayoutPlan, + type PaneOpenState, + type PlannedPane, + type SessionPane, +} from "../lib/extensionPanes"; +import { resizeSidebarWidth } from "../lib/sidebar"; + +interface PaneResizeState { + key: string; + registered: RegisteredPane; + placement: SessionPane["placement"]; + origin: number; + startSize: number; + maxSize: number; + minSize: number; +} + +interface AvailabilityRequest { + panes: readonly SessionPane[]; + context: Omit; + currentLine: ExtensionCurrentLinePaint | null; + retainCurrentLineRegistrations?: ReadonlySet; +} + +interface AvailabilitySnapshot { + request: AvailabilityRequest | null; + available: ReadonlySet; +} + +export interface ExtensionPaneController { + beginPaneResize: (planned: PlannedPane, event: TuiMouseEvent) => boolean; + createPaneControls: (extensionId: string) => ExtensionPaneControls; + currentLinePaint: ExtensionCurrentLinePaint | null; + currentLinePaintRequested: boolean; + endPaneResize: (event?: TuiMouseEvent) => void; + filesPaneVisible: boolean; + onCurrentLinePaintChange: (update: ExtensionCurrentLinePaintUpdate) => void; + paneLayout: ExtensionPaneLayoutPlan; + reportPaneRenderFailure: (pane: SessionPane) => void; + renderSidebar: boolean; + resizingPaneKey: string | null; + toggleFilesPane: () => void; + updatePaneResize: (event: TuiMouseEvent) => void; +} + +/** Initialize pane choices while applying the launch-time files-pane preference to its active slot. */ +function initialOpenState( + panes: readonly SessionPane[], + initialSidebar: SidebarVisibility | undefined, +): PaneOpenState { + const initial = initialPaneOpenState(panes); + if (initialSidebar !== false) return initial; + const filesPaneKey = resolvePaneSlotKey({ + panes, + slotKey: HUNK_FILES_PANE_KEY, + openKeys: initial.open, + }); + return { ...initial, open: initial.open.filter((key) => key !== filesPaneKey) }; +} + +/** Return whether one captured drag still owns the exact committed divider. */ +function activeResizePane( + resize: PaneResizeState, + layout: ExtensionPaneLayoutPlan, +): PlannedPane | undefined { + return layout.panes.find( + (planned) => + planned.pane.key === resize.key && + planned.pane.registered === resize.registered && + planned.pane.placement === resize.placement && + planned.divider !== undefined, + ); +} + +/** Format one contained availability failure for the extension warning channel. */ +function availabilityFailureMessage(pane: SessionPane, error: unknown): string { + return `Extension ${pane.registered.extensionId} pane "${pane.registered.pane.id}" availability failed • ${error instanceof Error ? error.message : String(error)}`; +} + +/** Own pane preferences, availability, recovery, responsive reveal, and resize transitions. */ +export function useExtensionPaneController({ + availabilityContext, + bodyHeight, + bodyWidth, + canForceShowSidebar, + createReviewCapabilityLease, + currentLineCursor, + extensions, + initialSidebar, + minReviewHeight, + minReviewWidth, + notifyWarning, + pagerMode, + responsiveShowsSidebar, +}: { + availabilityContext: Omit; + bodyHeight: number; + bodyWidth: number; + canForceShowSidebar: boolean; + createReviewCapabilityLease: () => ExtensionCapabilityLease; + currentLineCursor: { fileId: string; stableKey: string } | null; + extensions: ExtensionLoadResult | undefined; + initialSidebar: SidebarVisibility | undefined; + minReviewHeight: number; + minReviewWidth: number; + notifyWarning: (message: string) => void; + pagerMode: boolean; + responsiveShowsSidebar: boolean; +}): ExtensionPaneController { + const sessionPanes = useMemo(() => buildSessionPanes(extensions), [extensions]); + const [paneOpenState, setPaneOpenState] = useState(() => + initialOpenState(sessionPanes, initialSidebar), + ); + const [paneSizes, setPaneSizes] = useState>({}); + const [paneResize, setPaneResize] = useState(null); + const [sidebarVisible, setSidebarVisible] = useState(() => !pagerMode); + const [forceSidebarOpen, setForceSidebarOpen] = useState( + () => !pagerMode && initialSidebar === true, + ); + const [currentLinePaintState, setCurrentLinePaintState] = + useState({ + status: "unavailable", + fileId: null, + cursorKey: null, + paint: null, + }); + const [paneFailureEpoch, setPaneFailureEpoch] = useState(0); + const [availabilitySnapshot, setAvailabilitySnapshot] = useState({ + request: null, + available: new Set(), + }); + + const sessionPanesRef = useRef(sessionPanes); + const paneOpenStateRef = useRef(paneOpenState); + const paneLayoutRef = useRef(null); + const paneResizeRef = useRef(null); + const responsiveRef = useRef({ canForceShowSidebar, responsiveShowsSidebar }); + const retainedCurrentLinePaneRegistrationsRef = useRef>(new Set()); + const quarantinedRef = useRef(new WeakSet()); + const reportedAvailabilityFailuresRef = useRef(new WeakSet()); + const lastAvailabilityProbeRef = useRef<{ + request: AvailabilityRequest; + available: ReadonlySet; + } | null>(null); + + // Reconcile registrations before committed controls can observe the new pane set. + const committedPaneOpenState = useMemo( + () => reconcilePaneOpenState(sessionPanes, paneOpenState), + [paneOpenState, sessionPanes], + ); + + // Publish pane and responsive facts only after the matching render commits. + useLayoutEffect(() => { + sessionPanesRef.current = sessionPanes; + paneOpenStateRef.current = committedPaneOpenState; + responsiveRef.current = { canForceShowSidebar, responsiveShowsSidebar }; + if (committedPaneOpenState !== paneOpenState) setPaneOpenState(committedPaneOpenState); + }, [ + canForceShowSidebar, + committedPaneOpenState, + paneOpenState, + responsiveShowsSidebar, + sessionPanes, + ]); + + const currentLinePaintMatchesCursor = extensionCurrentLinePaintMatchesCursor( + currentLinePaintState, + currentLineCursor, + ); + const currentLinePaint = currentLinePaintMatchesCursor ? currentLinePaintState.paint : null; + const currentLinePaintPending = + currentLinePaintState.status === "pending" || + (currentLinePaintState.status === "ready" && !currentLinePaintMatchesCursor); + const currentLinePaintRequested = sessionPanes.some( + (pane) => + committedPaneOpenState.open.includes(pane.key) && pane.registered.pane.currentLine === true, + ); + + const onCurrentLinePaintChange = useCallback((update: ExtensionCurrentLinePaintUpdate) => { + setCurrentLinePaintState((current) => applyExtensionCurrentLinePaintUpdate(current, update)); + }, []); + + // Opening a side pane reveals its terminal area when responsive layout allows it. + const revealSidebarArea = useCallback(() => { + setSidebarVisible(true); + const responsive = responsiveRef.current; + if (!responsive.responsiveShowsSidebar && responsive.canForceShowSidebar) { + setForceSidebarOpen(true); + } + }, []); + + const cancelResize = useCallback((key?: string) => { + const active = paneResizeRef.current; + if (!active || (key && active.key !== key)) return; + paneResizeRef.current = null; + setPaneResize(null); + }, []); + + // Update logical open state and cancel any drag owned by a pane being closed. + const setPaneOpen = useCallback( + (key: string, nextOpen: boolean | "toggle") => { + const committedOpen = paneOpenStateRef.current.open.includes(key); + const committedNext = nextOpen === "toggle" ? !committedOpen : nextOpen; + if (!committedNext) cancelResize(key); + setPaneOpenState((current) => { + const reconciled = reconcilePaneOpenState(sessionPanesRef.current, current); + const isOpen = reconciled.open.includes(key); + const resolved = nextOpen === "toggle" ? !isOpen : nextOpen; + if (resolved === isOpen) return reconciled; + return { + known: reconciled.known, + open: resolved + ? [...reconciled.open, key] + : reconciled.open.filter((open) => open !== key), + }; + }); + }, + [cancelResize], + ); + + // Give each extension controls scoped to its own panes and current review lease. + const createPaneControls = useCallback( + (extensionId: string): ExtensionPaneControls => { + const lease = createReviewCapabilityLease(); + const hasAuthority = (method: string) => { + if (lease.isLive()) return true; + notifyWarning( + `Extension ${extensionId} ${method} ignored — the review session was reloaded`, + ); + return false; + }; + const resolve = (method: string, id: string) => { + const key = resolvePaneKey(sessionPanesRef.current, extensionId, id); + if (!key) { + notifyWarning(`Extension ${extensionId} ${method} targeted unknown pane "${id}"`); + } + return key; + }; + const revealIfSide = (key: string) => { + const pane = sessionPanesRef.current.find((entry) => entry.key === key); + if (pane?.placement === "left" || pane?.placement === "right") revealSidebarArea(); + }; + return { + open(id) { + if (!hasAuthority("panes.open")) return; + const key = resolve("panes.open", id); + if (!key) return; + setPaneOpen(key, true); + revealIfSide(key); + }, + close(id) { + if (!hasAuthority("panes.close")) return; + const key = resolve("panes.close", id); + if (key) setPaneOpen(key, false); + }, + toggle(id) { + if (!hasAuthority("panes.toggle")) return; + const key = resolve("panes.toggle", id); + if (!key) return; + const opens = !paneOpenStateRef.current.open.includes(key); + setPaneOpen(key, "toggle"); + if (opens) revealIfSide(key); + }, + isOpen(id) { + if (!lease.isLive()) return false; + const key = resolvePaneKey(sessionPanesRef.current, extensionId, id); + return key !== undefined && paneOpenStateRef.current.open.includes(key); + }, + }; + }, + [createReviewCapabilityLease, notifyWarning, revealSidebarArea, setPaneOpen], + ); + + const sidebarAreaVisible = + sidebarVisible && (responsiveShowsSidebar || (forceSidebarOpen && canForceShowSidebar)); + const failedFilesReplacement = sessionPanes.some( + (pane) => + committedPaneOpenState.open.includes(pane.key) && + pane.registered.pane.replaces === HUNK_FILES_PANE_KEY && + quarantinedRef.current.has(pane.registered), + ); + const effectiveOpenPaneKeys = committedPaneOpenState.open.filter((key) => { + const pane = sessionPanes.find((entry) => entry.key === key); + return sidebarAreaVisible || (pane?.placement !== "left" && pane?.placement !== "right"); + }); + if ( + failedFilesReplacement && + sidebarAreaVisible && + !effectiveOpenPaneKeys.includes(HUNK_FILES_PANE_KEY) + ) { + effectiveOpenPaneKeys.push(HUNK_FILES_PANE_KEY); + } + + const candidatePanes = sessionPanes.filter( + (pane) => + effectiveOpenPaneKeys.includes(pane.key) && !quarantinedRef.current.has(pane.registered), + ); + const availabilityRequest = useMemo( + () => ({ + panes: candidatePanes, + context: availabilityContext, + currentLine: currentLinePaint, + ...(currentLinePaintPending + ? { + retainCurrentLineRegistrations: retainedCurrentLinePaneRegistrationsRef.current, + } + : {}), + }), + [ + availabilityContext.files, + availabilityContext.selectedFileId, + availabilityContext.selectedHunkIndex, + currentLinePaint, + currentLinePaintPending, + effectiveOpenPaneKeys.join("\0"), + paneFailureEpoch, + sessionPanes, + ], + ); + + // Probe availability after commit and quarantine callbacks that throw. + useLayoutEffect(() => { + let available: ReadonlySet; + const cached = lastAvailabilityProbeRef.current; + if (cached?.request === availabilityRequest) { + available = cached.available; + } else { + const probe = probeExtensionPaneAvailability(availabilityRequest); + available = probe.available; + for (const failure of probe.failures) { + quarantinedRef.current.add(failure.pane.registered); + if (!reportedAvailabilityFailuresRef.current.has(failure.pane.registered)) { + reportedAvailabilityFailuresRef.current.add(failure.pane.registered); + notifyWarning(availabilityFailureMessage(failure.pane, failure.error)); + } + } + lastAvailabilityProbeRef.current = { request: availabilityRequest, available }; + } + setAvailabilitySnapshot((current) => + current.request === availabilityRequest && current.available === available + ? current + : { request: availabilityRequest, available }, + ); + }, [availabilityRequest, notifyWarning]); + + // Keep accepted current-line panes mounted while fresh paint is being prepared. + const acceptedOpenPaneKeys = effectiveOpenPaneKeys.filter((key) => { + const pane = sessionPanes.find((entry) => entry.key === key); + if (!pane || quarantinedRef.current.has(pane.registered)) return false; + if ( + currentLinePaintPending && + retainedCurrentLinePaneRegistrationsRef.current.has(pane.registered) + ) { + return true; + } + if (!pane.registered.pane.available) return true; + return ( + availabilitySnapshot.request === availabilityRequest && + availabilitySnapshot.available.has(pane.registered) + ); + }); + + // Compute geometry from accepted panes only; extension callbacks never run here. + const paneLayout = useMemo( + () => + planExtensionPanes({ + panes: sessionPanes, + openKeys: acceptedOpenPaneKeys, + sizes: paneSizes, + bodyWidth, + bodyHeight, + minReviewWidth, + minReviewHeight, + }), + [ + acceptedOpenPaneKeys.join("\0"), + bodyHeight, + bodyWidth, + minReviewHeight, + minReviewWidth, + paneSizes, + sessionPanes, + ], + ); + + useLayoutEffect(() => { + paneLayoutRef.current = paneLayout; + paneResizeRef.current = paneResize; + if (paneResize && !activeResizePane(paneResize, paneLayout)) cancelResize(); + }, [cancelResize, paneLayout, paneResize]); + + useLayoutEffect(() => { + if (currentLinePaintPending) return; + retainedCurrentLinePaneRegistrationsRef.current = new Set( + paneLayout.panes + .filter(({ pane }) => pane.registered.pane.currentLine === true) + .map(({ pane }) => pane.registered), + ); + }, [currentLinePaintPending, paneLayout]); + + // Toggle the active files slot, including the built-in fallback for a failed replacement. + const toggleFilesPane = useCallback(() => { + const panes = sessionPanesRef.current; + const logicalOpenKeys = paneOpenStateRef.current.open; + const visibleKeys = paneLayoutRef.current?.panes.map(({ pane }) => pane.key) ?? []; + const visibleFilesPaneKey = resolvePaneSlotKey({ + panes, + slotKey: HUNK_FILES_PANE_KEY, + openKeys: visibleKeys, + quarantined: quarantinedRef.current, + }); + if ( + visibleKeys.includes(visibleFilesPaneKey) && + visibleFilesPaneKey === HUNK_FILES_PANE_KEY && + !logicalOpenKeys.includes(HUNK_FILES_PANE_KEY) + ) { + const failedReplacement = panes.find( + (pane) => + logicalOpenKeys.includes(pane.key) && + pane.registered.pane.replaces === HUNK_FILES_PANE_KEY && + quarantinedRef.current.has(pane.registered), + ); + if (failedReplacement) { + setPaneOpen(failedReplacement.key, false); + return; + } + } + + const filesPaneKey = resolvePaneSlotKey({ + panes, + slotKey: HUNK_FILES_PANE_KEY, + openKeys: logicalOpenKeys, + quarantined: quarantinedRef.current, + }); + const filesPane = panes.find((pane) => pane.key === filesPaneKey); + const usesSidebarArea = filesPane?.placement === "left" || filesPane?.placement === "right"; + const responsive = responsiveRef.current; + const areaVisible = + sidebarVisible && + (responsive.responsiveShowsSidebar || (forceSidebarOpen && responsive.canForceShowSidebar)); + if (usesSidebarArea && !areaVisible) { + setPaneOpen(filesPaneKey, true); + revealSidebarArea(); + return; + } + setPaneOpen(filesPaneKey, "toggle"); + }, [forceSidebarOpen, revealSidebarArea, setPaneOpen, sidebarVisible]); + + // Quarantine a pane that failed to render and restore the built-in files pane if needed. + const reportPaneRenderFailure = useCallback( + (pane: SessionPane) => { + quarantinedRef.current.add(pane.registered); + cancelResize(pane.key); + if (pane.registered.pane.replaces === HUNK_FILES_PANE_KEY) { + setPaneOpen(pane.key, false); + setPaneOpen(HUNK_FILES_PANE_KEY, true); + revealSidebarArea(); + } + setPaneFailureEpoch((value) => value + 1); + }, + [cancelResize, revealSidebarArea, setPaneOpen], + ); + + // Start a drag only for the divider still owned by this exact pane registration. + const beginPaneResize = useCallback( + (planned: PlannedPane, event: TuiMouseEvent): boolean => { + if (event.button !== MouseButton.LEFT || !planned.divider) return false; + const committed = paneLayoutRef.current?.panes.find( + (entry) => + entry.pane.key === planned.pane.key && + entry.pane.registered === planned.pane.registered && + entry.pane.placement === planned.pane.placement && + entry.divider !== undefined, + ); + if (!committed) return false; + const vertical = committed.pane.placement === "left" || committed.pane.placement === "right"; + const spec = extensionPaneSize(committed.pane.registered.pane, committed.pane.placement); + const currentSize = vertical ? committed.bounds.width : committed.bounds.height; + const layout = paneLayoutRef.current!; + const resize: PaneResizeState = { + key: committed.pane.key, + registered: committed.pane.registered, + placement: committed.pane.placement, + origin: vertical ? event.x : event.y, + startSize: currentSize, + maxSize: Math.min( + spec.max ?? Number.MAX_SAFE_INTEGER, + currentSize + + Math.max( + 0, + vertical + ? layout.reviewBounds.width - minReviewWidth + : layout.reviewBounds.height - minReviewHeight, + ), + ), + minSize: spec.min ?? 1, + }; + paneResizeRef.current = resize; + setPaneResize(resize); + event.preventDefault(); + event.stopPropagation(); + return true; + }, + [minReviewHeight, minReviewWidth], + ); + + // Resize along the pane's axis while preserving the review's minimum bounds. + const updatePaneResize = useCallback( + (event: TuiMouseEvent) => { + const resize = paneResizeRef.current; + const layout = paneLayoutRef.current; + if (!resize || !layout) return; + const planned = activeResizePane(resize, layout); + if (!planned) { + cancelResize(); + return; + } + const vertical = resize.placement === "left" || resize.placement === "right"; + const currentSize = vertical ? planned.bounds.width : planned.bounds.height; + const currentMax = + currentSize + + Math.max( + 0, + vertical + ? layout.reviewBounds.width - minReviewWidth + : layout.reviewBounds.height - minReviewHeight, + ); + const position = vertical ? event.x : event.y; + const inverted = resize.placement === "right" || resize.placement === "bottom"; + const next = inverted + ? resizeSidebarWidth( + resize.startSize, + position, + resize.origin, + resize.minSize, + Math.min(resize.maxSize, currentMax), + ) + : resizeSidebarWidth( + resize.startSize, + resize.origin, + position, + resize.minSize, + Math.min(resize.maxSize, currentMax), + ); + setPaneSizes((current) => + current[resize.key] === next ? current : { ...current, [resize.key]: next }, + ); + event.preventDefault(); + event.stopPropagation(); + }, + [cancelResize, minReviewHeight, minReviewWidth], + ); + + // End the active drag and release mouse event ownership. + const endPaneResize = useCallback((event?: TuiMouseEvent) => { + if (!paneResizeRef.current) return; + paneResizeRef.current = null; + setPaneResize(null); + event?.preventDefault(); + event?.stopPropagation(); + }, []); + + const visiblePaneKeys = paneLayout.panes.map(({ pane }) => pane.key); + const visibleFilesPaneKey = resolvePaneSlotKey({ + panes: sessionPanes, + slotKey: HUNK_FILES_PANE_KEY, + openKeys: visiblePaneKeys, + quarantined: quarantinedRef.current, + }); + + return { + beginPaneResize, + createPaneControls, + currentLinePaint, + currentLinePaintRequested, + endPaneResize, + filesPaneVisible: visiblePaneKeys.includes(visibleFilesPaneKey), + onCurrentLinePaintChange, + paneLayout, + reportPaneRenderFailure, + renderSidebar: paneLayout.panes.some( + ({ pane }) => pane.placement === "left" || pane.placement === "right", + ), + resizingPaneKey: paneResize?.key ?? null, + toggleFilesPane, + updatePaneResize, + }; +} diff --git a/src/ui/lib/extensionPanes.test.ts b/src/ui/lib/extensionPanes.test.ts index 66828ce32..a6c52ab68 100644 --- a/src/ui/lib/extensionPanes.test.ts +++ b/src/ui/lib/extensionPanes.test.ts @@ -12,6 +12,7 @@ import { buildSessionPanes, initialPaneOpenState, planExtensionPanes, + probeExtensionPaneAvailability, reconcilePaneOpenState, resolvePaneKey, resolvePaneSlotKey, @@ -171,8 +172,6 @@ describe("extension panes", () => { bodyHeight: 30, minReviewWidth: 40, minReviewHeight: 5, - currentLine: null, - availabilityContext: { files: [], selectedFileId: null, selectedHunkIndex: null }, }); expect(plan.reviewBounds).toEqual({ x: 20, y: 4, width: 65, height: 23 }); expect(plan.panes.map((entry) => entry.pane.placement)).toEqual([ @@ -183,7 +182,7 @@ describe("extension panes", () => { ]); }); - test("keeps logical open preferences while synchronous availability omits a pane", () => { + test("separates commit-phase availability from pure geometry planning", () => { let available = false; let availabilityCalls = 0; const registered = registeredPane("a", "detail", { @@ -196,40 +195,64 @@ describe("extension panes", () => { }, }); const panes = buildSessionPanes(loadResultWith([registered])); - const state = { known: panes.map((pane) => pane.key), open: ["a:detail"] }; - const options = { + const context = { files: [], selectedFileId: null, selectedHunkIndex: null } as const; + const geometry = { panes, - openKeys: state.open, sizes: {}, bodyWidth: 100, bodyHeight: 20, minReviewWidth: 40, minReviewHeight: 5, - availabilityContext: { files: [], selectedFileId: null, selectedHunkIndex: null }, } as const; - const unavailable = planExtensionPanes({ ...options, currentLine: null }); - expect(unavailable.panes.some((entry) => entry.pane.key === "a:detail")).toBe(false); - expect(unavailable.omittedKeys).toContain("a:detail"); - expect(state.open).toEqual(["a:detail"]); + const unavailable = probeExtensionPaneAvailability({ panes, context, currentLine: null }); + expect(unavailable.available.has(registered)).toBeFalse(); + expect(planExtensionPanes({ ...geometry, openKeys: [] }).panes).toEqual([]); + expect(availabilityCalls).toBe(1); available = true; const paint = { render: () => null }; - const restored = planExtensionPanes({ ...options, currentLine: paint }); - expect(restored.panes.some((entry) => entry.pane.key === "a:detail")).toBe(true); + const restored = probeExtensionPaneAvailability({ panes, context, currentLine: paint }); + expect(restored.available.has(registered)).toBeTrue(); + expect(planExtensionPanes({ ...geometry, openKeys: ["a:detail"] }).panes).toHaveLength(1); const callsBeforePending = availabilityCalls; - const pending = planExtensionPanes({ - ...options, + const pending = probeExtensionPaneAvailability({ + panes, + context, currentLine: null, - retainCurrentLineKeys: new Set(["a:detail"]), + retainCurrentLineRegistrations: new Set([registered]), }); - expect(pending.panes.some((entry) => entry.pane.key === "a:detail")).toBe(true); + expect(pending.available.has(registered)).toBeTrue(); expect(availabilityCalls).toBe(callsBeforePending); - expect(state.open).toEqual(["a:detail"]); }); - test("quarantines an availability callback that throws or returns asynchronously", () => { + test("does not retain a same-key replacement by stale registration identity", () => { + const previous = registeredPane("a", "detail", { + currentLine: true, + available: () => true, + }); + let replacementCalls = 0; + const replacement = registeredPane("a", "detail", { + currentLine: true, + available: () => { + replacementCalls += 1; + return false; + }, + }); + const panes = buildSessionPanes(loadResultWith([replacement])); + const probe = probeExtensionPaneAvailability({ + panes, + context: { files: [], selectedFileId: null, selectedHunkIndex: null }, + currentLine: null, + retainCurrentLineRegistrations: new Set([previous]), + }); + + expect(replacementCalls).toBe(1); + expect(probe.available.has(replacement)).toBeFalse(); + }); + + test("returns availability failures without quarantining or notifying", () => { const throwing = registeredPane("a", "throwing", { available: () => { throw new Error("availability exploded"); @@ -239,31 +262,33 @@ describe("extension panes", () => { available: (() => Promise.resolve(true)) as never, }); const panes = buildSessionPanes(loadResultWith([throwing, asyncPane])); - const quarantined = new WeakSet(); - const errors: string[] = []; - const plan = planExtensionPanes({ + const probe = probeExtensionPaneAvailability({ panes, - openKeys: ["a:throwing", "a:async"], - sizes: {}, - bodyWidth: 100, - bodyHeight: 20, - minReviewWidth: 40, - minReviewHeight: 5, + context: { files: [], selectedFileId: null, selectedHunkIndex: null }, currentLine: null, - availabilityContext: { files: [], selectedFileId: null, selectedHunkIndex: null }, - quarantined, - onAvailabilityError: (_pane, error) => - errors.push(error instanceof Error ? error.message : String(error)), }); - expect(plan.panes).toEqual([]); - expect(plan.omittedKeys).toEqual(["a:throwing", "a:async"]); - expect(quarantined.has(throwing)).toBe(true); - expect(quarantined.has(asyncPane)).toBe(true); - expect(errors).toEqual([ + expect(probe.available.size).toBe(1); + expect(probe.failures.map(({ error }) => (error as Error).message)).toEqual([ "availability exploded", "available() must return a boolean synchronously", ]); + + let called = 0; + throwing.pane.available = () => { + called += 1; + return true; + }; + planExtensionPanes({ + panes, + openKeys: ["a:throwing"], + sizes: {}, + bodyWidth: 100, + bodyHeight: 20, + minReviewWidth: 40, + minReviewHeight: 5, + }); + expect(called).toBe(0); }); test("uses explicit height overrides and reserves a divider only for resizable panes", () => { @@ -280,8 +305,6 @@ describe("extension panes", () => { bodyHeight: 20, minReviewWidth: 40, minReviewHeight: 5, - currentLine: null, - availabilityContext: { files: [], selectedFileId: null, selectedHunkIndex: null }, }); const top = plan.panes.find((entry) => entry.pane.key === "a:top"); @@ -309,8 +332,6 @@ describe("extension panes", () => { bodyHeight: 30, minReviewWidth: 48, minReviewHeight: 5, - currentLine: null, - availabilityContext: { files: [], selectedFileId: null, selectedHunkIndex: null }, }); expect(plan.panes.map((entry) => entry.pane.key)).toEqual(["a:one", "a:two"]); expect(plan.omittedKeys).toContain("a:three"); diff --git a/src/ui/lib/extensionPanes.ts b/src/ui/lib/extensionPanes.ts index 37181f422..13d7189ea 100644 --- a/src/ui/lib/extensionPanes.ts +++ b/src/ui/lib/extensionPanes.ts @@ -144,55 +144,74 @@ export interface ExtensionPaneLayoutPlan { export interface PlanExtensionPanesOptions { panes: readonly SessionPane[]; + /** Pane keys accepted by logical state and commit-phase availability probing. */ openKeys: readonly string[]; sizes: Readonly>; bodyWidth: number; bodyHeight: number; minReviewWidth: number; minReviewHeight: number; - currentLine: ExtensionCurrentLinePaint | null; - /** Keep previously accepted current-line panes mounted while fresh paint is pending. */ - retainCurrentLineKeys?: ReadonlySet; - availabilityContext: Omit; - quarantined?: WeakSet; - onAvailabilityError?: (pane: SessionPane, error: unknown) => void; } -/** Plan exact rectangles on all four edges while reserving minimum review bounds. */ -export function planExtensionPanes(options: PlanExtensionPanesOptions): ExtensionPaneLayoutPlan { - const open = new Set(options.openKeys); - const omittedKeys: string[] = []; - const accepted: SessionPane[] = []; - for (const pane of options.panes) { - if (!open.has(pane.key) || options.quarantined?.has(pane.registered)) continue; +export interface PaneAvailabilityFailure { + pane: SessionPane; + error: unknown; +} + +export interface PaneAvailabilityProbe { + available: ReadonlySet; + failures: readonly PaneAvailabilityFailure[]; +} + +/** Probe extension availability without mutating host state or reporting failures. */ +export function probeExtensionPaneAvailability({ + panes, + context, + currentLine, + retainCurrentLineRegistrations, +}: { + panes: readonly SessionPane[]; + context: Omit; + currentLine: ExtensionCurrentLinePaint | null; + retainCurrentLineRegistrations?: ReadonlySet; +}): PaneAvailabilityProbe { + const available = new Set(); + const failures: PaneAvailabilityFailure[] = []; + + for (const pane of panes) { const registration = pane.registered.pane; - if (registration.currentLine && options.retainCurrentLineKeys?.has(pane.key)) { - accepted.push(pane); + if (registration.currentLine && retainCurrentLineRegistrations?.has(pane.registered)) { + available.add(pane.registered); continue; } - if (registration.available) { - try { - const result = registration.available({ - ...options.availabilityContext, - placement: pane.placement, - currentLine: registration.currentLine ? options.currentLine : null, - }); - if (typeof result !== "boolean") - throw new Error("available() must return a boolean synchronously"); - if (!result) { - omittedKeys.push(pane.key); - continue; - } - } catch (error) { - options.quarantined?.add(pane.registered); - options.onAvailabilityError?.(pane, error); - omittedKeys.push(pane.key); - continue; + if (!registration.available) { + available.add(pane.registered); + continue; + } + try { + const result = registration.available({ + ...context, + placement: pane.placement, + currentLine: registration.currentLine ? currentLine : null, + }); + if (typeof result !== "boolean") { + throw new Error("available() must return a boolean synchronously"); } + if (result) available.add(pane.registered); + } catch (error) { + failures.push({ pane, error }); } - accepted.push(pane); } + return { available, failures }; +} + +/** Plan exact rectangles without invoking extension code or mutating host state. */ +export function planExtensionPanes(options: PlanExtensionPanesOptions): ExtensionPaneLayoutPlan { + const open = new Set(options.openKeys); + const omittedKeys: string[] = []; + const accepted = options.panes.filter((pane) => open.has(pane.key)); + let left = 0; let right = Math.max(0, options.bodyWidth); let top = 0;