diff --git a/.changeset/sliding-panes.md b/.changeset/sliding-panes.md new file mode 100644 index 000000000..788e14c92 --- /dev/null +++ b/.changeset/sliding-panes.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Animate docked panes as they open and close, moving the review pane alongside them. diff --git a/src/ui/App.tsx b/src/ui/App.tsx index ac62e8205..70d9d4572 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -70,6 +70,7 @@ import { } from "./hooks/useExtensionWorkspaceControls"; import { useHunkSessionBridge } from "./hooks/useHunkSessionBridge"; import { useMenuController } from "./hooks/useMenuController"; +import { usePaneSlideAnimation } from "./hooks/usePaneSlideAnimation"; import { useThemeSelectorController } from "./hooks/useThemeSelectorController"; import { useTimedNotice } from "./hooks/useTimedNotice"; import { useUserNoteComposer } from "./hooks/useUserNoteComposer"; @@ -472,6 +473,13 @@ export function App({ responsiveShowsSidebar: responsiveLayout.showSidebar, }); + const { animating: paneLayoutAnimating, layout: presentedPaneLayout } = usePaneSlideAnimation({ + bodyHeight, + bodyWidth, + paneLayout, + resizing: resizingPaneKey !== null, + }); + useEffect(() => { if (resizingPaneKey === null) { setMouseCapture(renderer, undefined); @@ -634,8 +642,8 @@ export function App({ selectedHunkIndex, themeId, }); - const diffPaneWidth = paneLayout.reviewBounds.width; - const diffPaneHeight = paneLayout.reviewBounds.height; + const diffPaneWidth = presentedPaneLayout.reviewBounds.width; + const diffPaneHeight = presentedPaneLayout.reviewBounds.height; const diffContentWidth = Math.max(0, diffPaneWidth - 2); // Publish the live note geometry for daemon-driven markup validation; the // note markup width mirrors what AgentInlineNote lays STML out at. @@ -1166,7 +1174,7 @@ export function App({ const diffHeaderStatsWidth = maxFileHeaderStatsWidth(filteredFiles); const diffHeaderLabelWidth = Math.max(0, diffContentWidth - diffHeaderStatsWidth - 1); const diffSeparatorWidth = Math.max(0, diffContentWidth - 2); - const diffPaneScreenTop = (showMenuBar ? 1 : 0) + paneLayout.reviewBounds.y; + const diffPaneScreenTop = (showMenuBar ? 1 : 0) + presentedPaneLayout.reviewBounds.y; /** Render one pane from the exact accepted host rectangle. */ const renderPane = (planned: PlannedPane) => { @@ -1228,7 +1236,7 @@ export function App({ }; const renderDivider = (planned: PlannedPane) => - planned.divider ? ( + planned.divider && !paneLayoutAnimating ? ( - {paneLayout.panes.map(renderPane)} - {paneLayout.panes.map(renderDivider)} + {presentedPaneLayout.panes.map(renderPane)} + {presentedPaneLayout.panes.map(renderDivider)} (null); + const activeTransitionRef = useRef(null); + const timelineConfiguredRef = useRef(false); + + useLayoutEffect(() => { + if (timelineConfiguredRef.current) return; + timelineConfiguredRef.current = true; + timeline.add( + { progress: 0 }, + { + progress: 1, + duration, + ease: "outQuad", + onUpdate: (animation) => { + const transition = activeTransitionRef.current; + if (!transition) return; + const nextLayout = interpolatePaneLayout( + transition.from, + transition.to, + transition.paneKey, + animation.progress, + ); + if (paneLayoutGeometryEqual(presentedLayoutRef.current, nextLayout)) return; + presentedLayoutRef.current = nextLayout; + setPresentedLayout(nextLayout); + }, + onComplete: () => { + const transition = activeTransitionRef.current; + if (!transition) return; + activeTransitionRef.current = null; + presentedLayoutRef.current = transition.to; + setPresentedLayout(transition.to); + }, + }, + ); + }, [duration, timeline]); + + useLayoutEffect(() => { + const previous = semanticSnapshotRef.current; + semanticSnapshotRef.current = { bodyHeight, bodyWidth, paneLayout }; + const transitionKey = previous + ? paneVisibilityTransitionKey(previous.paneLayout, paneLayout) + : null; + const interruptedByAnotherPane = + activeTransitionRef.current !== null && activeTransitionRef.current.paneKey !== transitionKey; + const canAnimate = + previous !== null && + transitionKey !== null && + !interruptedByAnotherPane && + !resizing && + previous.bodyHeight === bodyHeight && + previous.bodyWidth === bodyWidth; + + if (!canAnimate) { + activeTransitionRef.current = null; + timeline.pause(); + presentedLayoutRef.current = paneLayout; + setPresentedLayout(paneLayout); + return; + } + + activeTransitionRef.current = { + from: presentedLayoutRef.current, + to: paneLayout, + paneKey: transitionKey, + }; + timeline.restart(); + }, [bodyHeight, bodyWidth, paneLayout, resizing, timeline]); + + return { + animating: activeTransitionRef.current !== null, + layout: presentedLayout, + }; +} diff --git a/src/ui/lib/paneSlide.test.ts b/src/ui/lib/paneSlide.test.ts new file mode 100644 index 000000000..f53ad7d32 --- /dev/null +++ b/src/ui/lib/paneSlide.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "bun:test"; +import type { ExtensionPane } from "../../extension-api/types"; +import { HUNK_FILES_PANE_KEY } from "../../extensions/extensionIds"; +import { + buildSessionPanes, + planExtensionPanes, + type ExtensionPaneLayoutPlan, + type SessionPane, +} from "./extensionPanes"; +import { + interpolatePaneLayout, + paneLayoutGeometryEqual, + paneVisibilityTransitionKey, +} from "./paneSlide"; + +/** Build one test pane from the bundled pane's valid registration shell. */ +function createTestPane( + placement: SessionPane["placement"], + suffix: string = placement, +): SessionPane { + const bundled = buildSessionPanes(undefined)[0]!; + const paneKey = + placement === "left" && suffix === placement ? HUNK_FILES_PANE_KEY : `test:${suffix}`; + return { + ...bundled, + key: paneKey, + placement, + registered: { + ...bundled.registered, + pane: { ...bundled.registered.pane, id: suffix, placement } as ExtensionPane, + }, + }; +} + +/** Build matching open and closed layouts for a pane at one edge. */ +function createPaneLayouts(placement: SessionPane["placement"]): { + closed: ExtensionPaneLayoutPlan; + open: ExtensionPaneLayoutPlan; + paneKey: string; +} { + const pane = createTestPane(placement); + const paneKey = pane.key; + const plan = (openKeys: readonly string[]) => + planExtensionPanes({ + panes: [pane], + openKeys, + sizes: { [paneKey]: placement === "left" || placement === "right" ? 30 : 8 }, + bodyWidth: 100, + bodyHeight: 30, + minReviewWidth: 20, + minReviewHeight: 5, + }); + return { + closed: plan([]), + open: plan([paneKey]), + paneKey, + }; +} + +describe("pane slide presentation", () => { + test("recognizes any sole pane visibility change", () => { + for (const placement of ["left", "right", "top", "bottom"] as const) { + const { closed, open, paneKey } = createPaneLayouts(placement); + expect(paneVisibilityTransitionKey(closed, open)).toBe(paneKey); + expect(paneVisibilityTransitionKey(open, closed)).toBe(paneKey); + expect(paneVisibilityTransitionKey(open, open)).toBeNull(); + } + }); + + test("slides horizontal panes and review geometry together", () => { + for (const placement of ["left", "right"] as const) { + const { closed, open, paneKey } = createPaneLayouts(placement); + const start = interpolatePaneLayout(closed, open, paneKey, 0); + const middle = interpolatePaneLayout(closed, open, paneKey, 0.5); + const openPane = open.panes.find(({ pane }) => pane.key === paneKey)!; + const middlePane = middle.panes.find(({ pane }) => pane.key === paneKey)!; + + const startPane = start.panes.find(({ pane }) => pane.key === paneKey)!; + expect(start.reviewBounds).toEqual(closed.reviewBounds); + expect(startPane.bounds.width).toBe(openPane.bounds.width); + expect(startPane.bounds.x).not.toBe(openPane.bounds.x); + expect(middlePane.bounds.width).toBe(openPane.bounds.width); + expect(middlePane.bounds.x).not.toBe(openPane.bounds.x); + expect(middle.reviewBounds.width).toBeGreaterThan(open.reviewBounds.width); + expect(middle.reviewBounds.width).toBeLessThan(closed.reviewBounds.width); + } + }); + + test("slides vertical panes and review geometry together", () => { + for (const placement of ["top", "bottom"] as const) { + const { closed, open, paneKey } = createPaneLayouts(placement); + const start = interpolatePaneLayout(closed, open, paneKey, 0); + const middle = interpolatePaneLayout(closed, open, paneKey, 0.5); + const openPane = open.panes.find(({ pane }) => pane.key === paneKey)!; + const middlePane = middle.panes.find(({ pane }) => pane.key === paneKey)!; + + const startPane = start.panes.find(({ pane }) => pane.key === paneKey)!; + expect(start.reviewBounds).toEqual(closed.reviewBounds); + expect(startPane.bounds.height).toBe(openPane.bounds.height); + expect(startPane.bounds.y).not.toBe(openPane.bounds.y); + expect(middlePane.bounds.height).toBe(openPane.bounds.height); + expect(middlePane.bounds.y).not.toBe(openPane.bounds.y); + expect(middle.reviewBounds.height).toBeGreaterThan(open.reviewBounds.height); + expect(middle.reviewBounds.height).toBeLessThan(closed.reviewBounds.height); + } + }); + + test("starts an additional same-edge pane beyond the occupied outer edge", () => { + const first = createTestPane("left", "first"); + const second = createTestPane("left", "second"); + const plan = (openKeys: readonly string[]) => + planExtensionPanes({ + panes: [first, second], + openKeys, + sizes: { [first.key]: 20, [second.key]: 20 }, + bodyWidth: 100, + bodyHeight: 30, + minReviewWidth: 20, + minReviewHeight: 5, + }); + const before = plan([first.key]); + const after = plan([first.key, second.key]); + const start = interpolatePaneLayout(before, after, second.key, 0); + const firstBounds = start.panes.find(({ pane }) => pane.key === first.key)!.bounds; + const secondBounds = start.panes.find(({ pane }) => pane.key === second.key)!.bounds; + + expect(secondBounds.x + secondBounds.width).toBeLessThanOrEqual(firstBounds.x); + }); + + test("keeps interpolated review edges inside the body", () => { + const { closed, open, paneKey } = createPaneLayouts("left"); + for (const progress of [0.1, 0.25, 0.5, 0.75, 0.9]) { + const { reviewBounds } = interpolatePaneLayout(closed, open, paneKey, progress); + expect(reviewBounds.x + reviewBounds.width).toBe(100); + } + }); + + test("deduplicates timeline updates that round to the same terminal cells", () => { + const { closed, open, paneKey } = createPaneLayouts("top"); + const first = interpolatePaneLayout(closed, open, paneKey, 0.1); + const sameCells = interpolatePaneLayout(closed, open, paneKey, 0.101); + const later = interpolatePaneLayout(closed, open, paneKey, 0.5); + + expect(paneLayoutGeometryEqual(first, sameCells)).toBe(true); + expect(paneLayoutGeometryEqual(first, later)).toBe(false); + }); + + test("retains an exiting pane until the closing frame completes", () => { + const { closed, open, paneKey } = createPaneLayouts("bottom"); + const middle = interpolatePaneLayout(open, closed, paneKey, 0.5); + const end = interpolatePaneLayout(open, closed, paneKey, 1); + + expect(middle.panes.some(({ pane }) => pane.key === paneKey)).toBe(true); + expect(end.panes.some(({ pane }) => pane.key === paneKey)).toBe(true); + const openPane = open.panes.find(({ pane }) => pane.key === paneKey)!; + const endPane = end.panes.find(({ pane }) => pane.key === paneKey)!; + expect(endPane.bounds.height).toBe(openPane.bounds.height); + expect(endPane.bounds.y).toBeGreaterThan(openPane.bounds.y); + expect(end.reviewBounds).toEqual(closed.reviewBounds); + }); +}); diff --git a/src/ui/lib/paneSlide.ts b/src/ui/lib/paneSlide.ts new file mode 100644 index 000000000..d73b015b0 --- /dev/null +++ b/src/ui/lib/paneSlide.ts @@ -0,0 +1,148 @@ +import type { ExtensionPaneLayoutPlan, PaneBounds, PlannedPane } from "./extensionPanes"; + +/** Duration of pane reveal and dismissal motion. */ +export const PANE_SLIDE_DURATION_MS = 180; + +/** Keep test-renderer transitions deterministic without changing interactive timing. */ +export function paneSlideAnimationDuration(): number { + return process.env.NODE_ENV === "test" ? 0 : PANE_SLIDE_DURATION_MS; +} + +/** Interpolate terminal edges and derive dimensions so rounded bounds stay internally exact. */ +function interpolateBounds(from: PaneBounds, to: PaneBounds, progress: number): PaneBounds { + const value = (start: number, end: number) => Math.round(start + (end - start) * progress); + const x = value(from.x, to.x); + const y = value(from.y, to.y); + const right = value(from.x + from.width, to.x + to.width); + const bottom = value(from.y + from.height, to.y + to.height); + return { x, y, width: right - x, height: bottom - y }; +} + +/** Move a full-size pane beyond the outer body edge it enters from. */ +function offscreenPane(planned: PlannedPane, layout: ExtensionPaneLayoutPlan): PlannedPane { + const bodyRight = Math.max( + layout.reviewBounds.x + layout.reviewBounds.width, + ...layout.panes.map(({ bounds }) => bounds.x + bounds.width), + ); + const bodyBottom = Math.max( + layout.reviewBounds.y + layout.reviewBounds.height, + ...layout.panes.map(({ bounds }) => bounds.y + bounds.height), + ); + const { placement } = planned.pane; + const target = + placement === "left" + ? { x: -planned.bounds.width, y: planned.bounds.y } + : placement === "right" + ? { x: bodyRight, y: planned.bounds.y } + : placement === "top" + ? { x: planned.bounds.x, y: -planned.bounds.height } + : { x: planned.bounds.x, y: bodyBottom }; + const offset = { x: target.x - planned.bounds.x, y: target.y - planned.bounds.y }; + const translate = (bounds: PaneBounds): PaneBounds => ({ + ...bounds, + x: bounds.x + offset.x, + y: bounds.y + offset.y, + }); + + return { + ...planned, + bounds: translate(planned.bounds), + ...(planned.divider ? { divider: translate(planned.divider) } : {}), + }; +} + +/** Return the sole pane whose visibility changed, or null for a broader layout change. */ +export function paneVisibilityTransitionKey( + from: ExtensionPaneLayoutPlan, + to: ExtensionPaneLayoutPlan, +): string | null { + const fromKeys = from.panes.map(({ pane }) => pane.key); + const toKeys = to.panes.map(({ pane }) => pane.key); + const fromSet = new Set(fromKeys); + const toSet = new Set(toKeys); + const changedKeys = [ + ...fromKeys.filter((key) => !toSet.has(key)), + ...toKeys.filter((key) => !fromSet.has(key)), + ]; + if (changedKeys.length !== 1) return null; + + const changedKey = changedKeys[0]!; + const withoutChanged = (keys: readonly string[]) => keys.filter((key) => key !== changedKey); + const fromOtherKeys = withoutChanged(fromKeys); + const toOtherKeys = withoutChanged(toKeys); + return fromOtherKeys.length === toOtherKeys.length && + fromOtherKeys.every((key, index) => key === toOtherKeys[index]) + ? changedKey + : null; +} + +/** Return whether two presentation plans occupy the same terminal cells. */ +export function paneLayoutGeometryEqual( + left: ExtensionPaneLayoutPlan, + right: ExtensionPaneLayoutPlan, +): boolean { + const boundsEqual = (a: PaneBounds, b: PaneBounds) => + a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; + if ( + !boundsEqual(left.reviewBounds, right.reviewBounds) || + left.panes.length !== right.panes.length + ) { + return false; + } + return left.panes.every((planned, index) => { + const other = right.panes[index]; + return ( + other !== undefined && + planned.pane.key === other.pane.key && + boundsEqual(planned.bounds, other.bounds) && + (planned.divider === undefined + ? other.divider === undefined + : other.divider !== undefined && boundsEqual(planned.divider, other.divider)) + ); + }); +} + +/** Project one pane animation frame without changing the authoritative semantic plan. */ +export function interpolatePaneLayout( + from: ExtensionPaneLayoutPlan, + to: ExtensionPaneLayoutPlan, + transitioningPaneKey: string, + progress: number, +): ExtensionPaneLayoutPlan { + const boundedProgress = Math.min(1, Math.max(0, progress)); + const fromByKey = new Map(from.panes.map((planned) => [planned.pane.key, planned])); + const toByKey = new Map(to.panes.map((planned) => [planned.pane.key, planned])); + const layoutWithTransitioningPane = fromByKey.has(transitioningPaneKey) ? from : to; + const keys = layoutWithTransitioningPane.panes.map(({ pane }) => pane.key); + + const panes = keys.flatMap((key) => { + const fromPane = fromByKey.get(key); + const toPane = toByKey.get(key); + if (!fromPane && !toPane) return []; + + const start = + fromPane ?? (toPane && key === transitioningPaneKey ? offscreenPane(toPane, to) : toPane); + const end = + toPane ?? + (fromPane && key === transitioningPaneKey ? offscreenPane(fromPane, from) : fromPane); + if (!start || !end) return []; + + const divider = + start.divider && end.divider + ? interpolateBounds(start.divider, end.divider, boundedProgress) + : end.divider; + return [ + { + pane: end.pane, + bounds: interpolateBounds(start.bounds, end.bounds, boundedProgress), + ...(divider ? { divider } : {}), + }, + ]; + }); + + return { + panes, + reviewBounds: interpolateBounds(from.reviewBounds, to.reviewBounds, boundedProgress), + omittedKeys: to.omittedKeys, + }; +}