From adfbe351c23f32456dd17dd4f1779a740f55ea8b Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 10 Sep 2026 09:21:39 -0400 Subject: [PATCH] feat(extensions): cap pane size responsively --- .changeset/panes-share-space.md | 5 ++ docs/extension-architecture.md | 7 +- docs/extensions.md | 14 +++- packages/hunk/skills/hunk-extensions/SKILL.md | 2 +- packages/hunk/src/extension-api/types.ts | 9 ++- packages/hunk/src/extensions/panes.ts | 10 +++ .../hunk/src/extensions/runExtension.test.ts | 36 +++++++-- packages/hunk/src/extensions/runExtension.ts | 12 +++ .../hooks/useExtensionPaneController.test.tsx | 50 ++++++++++++ .../ui/hooks/useExtensionPaneController.ts | 79 ++++++++----------- .../hunk/src/ui/lib/extensionPanes.test.ts | 25 ++++++ packages/hunk/src/ui/lib/extensionPanes.ts | 8 +- scripts/packaging/check-pack.ts | 8 +- test/pty/extensions-integration.test.ts | 12 ++- .../docs/docs/extend/custom-sidebars.md | 4 +- .../content/docs/docs/extend/extension-api.md | 4 +- 16 files changed, 215 insertions(+), 70 deletions(-) create mode 100644 .changeset/panes-share-space.md diff --git a/.changeset/panes-share-space.md b/.changeset/panes-share-space.md new file mode 100644 index 000000000..8b3bec84b --- /dev/null +++ b/.changeset/panes-share-space.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Let extension panes cap resizing to a responsive share of the terminal with `maxFraction`. diff --git a/docs/extension-architecture.md b/docs/extension-architecture.md index 28880bf0d..df62a4f1e 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -101,9 +101,10 @@ commands never pay OpenTUI's native-library extraction). `packages/hunk/src/ui/lib/extensionPanes.ts` owns open state, availability, and one rectangle plan for panes, dividers, and review bounds. Left/right panes consume columns; top/bottom panes consume rows from the central review column, outside review -stream coordinates. Pane registrations may opt into a body-axis `fraction`; -the planner resolves it to an integer target before applying bounds and lets a -session-local divider drag override that automatic size. +stream coordinates. Pane registrations may opt into a body-axis `fraction` and +cap layout and dragging with `maxFraction`; the planner resolves those body-axis shares to +integer bounds before applying review constraints and lets a session-local +divider drag override the automatic target. `packages/hunk/src/ui/components/panes/ExtensionPane.tsx` mounts panes with guarded actions, immutable review metadata, and failure containment. The fixed three-row `hunk:review-info` top diff --git a/docs/extensions.md b/docs/extensions.md index 3b30dc1fc..ba391ed6a 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -302,8 +302,9 @@ and retires the replaced instance at that explicit ownership boundary. ### `hunk.apiVersion` -The API generation this Hunk speaks (currently `25`). Branch on it if you want -one file to support several Hunk versions. Version 25 adds Promise-returning watch signatures and +The API generation this Hunk speaks (currently `26`). Branch on it if you want +one file to support several Hunk versions. Version 26 adds proportional pane maximums; +version 25 adds Promise-returning watch signatures and watch cancellation; version 24 adds review metadata to VCS patch results and short display revisions to commit descriptors; version 23 adds canonical unified-layout fields while preserving the previous event vocabulary; version 22 adds frame-derived pane preferred sizing, @@ -890,7 +891,7 @@ export default function (hunk: HunkExtensionAPI) { ``` `placement` defaults to `"left"`. Left/right panes use `width`; top/bottom panes -use `height`. Both accept `{ preferred, min?, max?, fraction? }`; equal bounds +use `height`. Both accept `{ preferred, min?, max?, fraction?, maxFraction? }`; equal bounds make a fixed pane. Defaults are `{ preferred: 34, min: 22 }` columns and `{ preferred: 8, min: 3 }` rows. @@ -904,6 +905,13 @@ Panes without `fraction` retain their fixed preferred startup size. Folder extensions that use `fraction` should declare `"hunk": { "apiVersion": 12 }` in their manifest. +`maxFraction` caps both responsive startup sizing and divider dragging to a +share of the host body axis. It follows the same greater-than-zero and +at-most-one range as `fraction`; Hunk floors the result so the pane never exceeds +the requested share. When both `max` and `maxFraction` are present, the tighter +limit wins. Extensions that use `maxFraction` should declare +`"hunk": { "apiVersion": 26 }` in their manifest. + `preferredSize(context)` can derive that automatic cell target from current review facts. Hunk invokes it synchronously with the same context as `available`, clamps its positive whole-number result to `min`/`max`, and still diff --git a/packages/hunk/skills/hunk-extensions/SKILL.md b/packages/hunk/skills/hunk-extensions/SKILL.md index dc33e7a17..7f5896aaa 100644 --- a/packages/hunk/skills/hunk-extensions/SKILL.md +++ b/packages/hunk/skills/hunk-extensions/SKILL.md @@ -111,7 +111,7 @@ bad or duplicate id is skipped with a startup notice. | Reload after an external agent changes reviewed inputs | `ctx.review.requestReload()` in an event | | Read user-supplied settings | `hunk.config` (`[extension.]` table) | | Snapshot stable files and every saved review note | `ctx.review.snapshot()` in a command | -| Branch on the API generation (currently `25`) | `hunk.apiVersion` | +| Branch on the API generation (currently `26`) | `hunk.apiVersion` | Registration is only valid while the factory runs — Hunk seals the API object afterwards. diff --git a/packages/hunk/src/extension-api/types.ts b/packages/hunk/src/extension-api/types.ts index e014f47e5..f4eb810f6 100644 --- a/packages/hunk/src/extension-api/types.ts +++ b/packages/hunk/src/extension-api/types.ts @@ -21,7 +21,7 @@ * Extensions can branch on `hunk.apiVersion` so a newer Hunk can keep loading * older extensions without guessing at their expectations. */ -export const HUNK_EXTENSION_API_VERSION = 25; +export const HUNK_EXTENSION_API_VERSION = 26; export type HunkExtensionApiVersion = typeof HUNK_EXTENSION_API_VERSION; export type ExtensionNotifyType = "info" | "warning" | "error"; @@ -1271,6 +1271,13 @@ export interface ExtensionPaneSize { * required by the review to the chosen automatic or manual target. */ fraction?: number; + /** + * Largest responsive share of the host body width or height. + * + * Hunk floors this fraction to a terminal cell and applies the tighter of + * `max`, `maxFraction`, and the space required by the review. + */ + maxFraction?: number; } /** diff --git a/packages/hunk/src/extensions/panes.ts b/packages/hunk/src/extensions/panes.ts index 5915915cb..1e14b8c95 100644 --- a/packages/hunk/src/extensions/panes.ts +++ b/packages/hunk/src/extensions/panes.ts @@ -29,3 +29,13 @@ export function extensionPaneSize( defaultExtensionPaneSize(placement) ); } + +/** Resolve one pane's absolute and proportional maximum against the host body axis. */ +export function extensionPaneMaximumSize(size: ExtensionPaneSize, axisSize: number): number { + const absolute = size.max ?? Number.MAX_SAFE_INTEGER; + const proportional = + size.maxFraction === undefined + ? Number.MAX_SAFE_INTEGER + : Math.floor(Math.max(0, axisSize) * size.maxFraction); + return Math.min(absolute, proportional); +} diff --git a/packages/hunk/src/extensions/runExtension.test.ts b/packages/hunk/src/extensions/runExtension.test.ts index 2e9df1191..4651b7137 100644 --- a/packages/hunk/src/extensions/runExtension.test.ts +++ b/packages/hunk/src/extensions/runExtension.test.ts @@ -13,8 +13,8 @@ function bundledMetadata(id: string) { } describe("runExtensionFactory", () => { - test("advertises async watch signatures through extension API v25", () => { - expect(HUNK_EXTENSION_API_VERSION).toBe(25); + test("advertises the current extension API version", () => { + expect(HUNK_EXTENSION_API_VERSION).toBe(26); }); test("applies a synchronous factory before returning, with nothing to await", () => { @@ -168,7 +168,13 @@ describe("registerPane", () => { registry, issues, factory: (hunk) => { - const size = { preferred: 3, min: 2, max: 4, fraction: 0.25 }; + const size = { + preferred: 3, + min: 2, + max: 4, + fraction: 0.25, + maxFraction: 0.8, + }; for (const placement of ["left", "right"] as const) { hunk.registerPane({ id: placement, placement, width: size, component: () => null }); } @@ -185,10 +191,10 @@ describe("registerPane", () => { pane.placement === "left" || pane.placement === "right" ? pane.width : pane.height, ]), ).toEqual([ - ["left", "left", { preferred: 3, min: 2, max: 4, fraction: 0.25 }], - ["right", "right", { preferred: 3, min: 2, max: 4, fraction: 0.25 }], - ["top", "top", { preferred: 3, min: 2, max: 4, fraction: 0.25 }], - ["bottom", "bottom", { preferred: 3, min: 2, max: 4, fraction: 0.25 }], + ["left", "left", { preferred: 3, min: 2, max: 4, fraction: 0.25, maxFraction: 0.8 }], + ["right", "right", { preferred: 3, min: 2, max: 4, fraction: 0.25, maxFraction: 0.8 }], + ["top", "top", { preferred: 3, min: 2, max: 4, fraction: 0.25, maxFraction: 0.8 }], + ["bottom", "bottom", { preferred: 3, min: 2, max: 4, fraction: 0.25, maxFraction: 0.8 }], ]); }); @@ -260,6 +266,22 @@ describe("registerPane", () => { { id: "string-fraction", width: { preferred: 3, fraction: "0.2" }, component: () => null }, { id: "boolean-fraction", width: { preferred: 3, fraction: true }, component: () => null }, { id: "null-fraction", width: { preferred: 3, fraction: null }, component: () => null }, + { id: "zero-max-fraction", width: { preferred: 3, maxFraction: 0 }, component: () => null }, + { + id: "large-max-fraction", + width: { preferred: 3, maxFraction: 1.01 }, + component: () => null, + }, + { + id: "nan-max-fraction", + width: { preferred: 3, maxFraction: Number.NaN }, + component: () => null, + }, + { + id: "string-max-fraction", + width: { preferred: 3, maxFraction: "0.8" }, + component: () => null, + }, { id: "unsafe", width: { preferred: Number.MAX_SAFE_INTEGER + 1 }, diff --git a/packages/hunk/src/extensions/runExtension.ts b/packages/hunk/src/extensions/runExtension.ts index 714bf1aa2..53af4c1c9 100644 --- a/packages/hunk/src/extensions/runExtension.ts +++ b/packages/hunk/src/extensions/runExtension.ts @@ -869,6 +869,17 @@ export function createExtensionApi( ) { throw new Error(`registerPane ${dimension}.fraction must be greater than 0 and at most 1.`); } + if ( + size.maxFraction !== undefined && + (typeof size.maxFraction !== "number" || + !Number.isFinite(size.maxFraction) || + size.maxFraction <= 0 || + size.maxFraction > 1) + ) { + throw new Error( + `registerPane ${dimension}.maxFraction must be greater than 0 and at most 1.`, + ); + } if (min > size.preferred || size.preferred > max) { throw new Error(`registerPane ${dimension} must satisfy min <= preferred <= max.`); } @@ -899,6 +910,7 @@ export function createExtensionApi( min, max, ...(size.fraction === undefined ? {} : { fraction: size.fraction }), + ...(size.maxFraction === undefined ? {} : { maxFraction: size.maxFraction }), }; registry.panes.push({ extensionId: metadata.id, diff --git a/packages/hunk/src/ui/hooks/useExtensionPaneController.test.tsx b/packages/hunk/src/ui/hooks/useExtensionPaneController.test.tsx index 31008bb05..a4462be89 100644 --- a/packages/hunk/src/ui/hooks/useExtensionPaneController.test.tsx +++ b/packages/hunk/src/ui/hooks/useExtensionPaneController.test.tsx @@ -620,6 +620,56 @@ describe("useExtensionPaneController", () => { } }); + test("caps bottom-pane dragging at maxFraction of the host height", async () => { + const bottom = registeredPane("meta", "bottom", { + placement: "bottom", + defaultOpen: true, + height: { preferred: 5, min: 3, maxFraction: 0.8 }, + }); + const harness = await renderController({ + extensions: loadResultWith([bottom]), + initialSidebar: false, + initialHeight: 30, + }); + try { + const planned = harness + .current() + .paneLayout.panes.find(({ pane }) => pane.key === "meta:bottom")!; + await act(async () => { + harness.current().beginPaneResize(planned, mouseEvent({ y: planned.divider!.y }).event); + harness.current().updatePaneResize(mouseEvent({ y: 0 }).event); + }); + await harness.settle(); + expect( + harness.current().paneLayout.panes.find(({ pane }) => pane.key === "meta:bottom")!.bounds + .height, + ).toBe(24); + + await act(async () => harness.current().endPaneResize()); + await act(async () => harness.setSize({ width: 100, height: 40 })); + await harness.settle(); + expect( + harness.current().paneLayout.panes.find(({ pane }) => pane.key === "meta:bottom")!.bounds + .height, + ).toBe(24); + + const expanded = harness + .current() + .paneLayout.panes.find(({ pane }) => pane.key === "meta:bottom")!; + await act(async () => { + harness.current().beginPaneResize(expanded, mouseEvent({ y: expanded.divider!.y }).event); + harness.current().updatePaneResize(mouseEvent({ y: -10 }).event); + }); + await harness.settle(); + expect( + harness.current().paneLayout.panes.find(({ pane }) => pane.key === "meta:bottom")!.bounds + .height, + ).toBe(32); + } finally { + await destroy(harness.setup); + } + }); + test("cancels an active drag when controls close its pane", async () => { const extra = registeredPane("meta", "extra", { defaultOpen: true, diff --git a/packages/hunk/src/ui/hooks/useExtensionPaneController.ts b/packages/hunk/src/ui/hooks/useExtensionPaneController.ts index d52dd80c8..fe108df32 100644 --- a/packages/hunk/src/ui/hooks/useExtensionPaneController.ts +++ b/packages/hunk/src/ui/hooks/useExtensionPaneController.ts @@ -21,7 +21,7 @@ import type { ExtensionPaneControls, } from "../../extension-api/types"; import { HUNK_FILES_PANE_KEY } from "../../extensions/extensionIds"; -import { extensionPaneSize } from "../../extensions/panes"; +import { extensionPaneMaximumSize, extensionPaneSize } from "../../extensions/panes"; import type { ExtensionLoadResult, RegisteredPane } from "../../extensions/types"; import type { ExtensionCapabilityLease } from "../lib/extensionCapabilityLease"; import { @@ -58,7 +58,6 @@ interface PaneResizeState { placement: SessionPane["placement"]; origin: number; startSize: number; - maxSize: number; minSize: number; } @@ -543,47 +542,33 @@ export function useExtensionPaneController({ ); // 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], - ); + 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 resize: PaneResizeState = { + key: committed.pane.key, + registered: committed.pane.registered, + placement: committed.pane.placement, + origin: vertical ? event.x : event.y, + startSize: currentSize, + minSize: spec.min ?? 1, + }; + paneResizeRef.current = resize; + setPaneResize(resize); + event.preventDefault(); + event.stopPropagation(); + return true; + }, []); // Resize along the pane's axis while preserving the review's minimum bounds. const updatePaneResize = useCallback( @@ -597,6 +582,8 @@ export function useExtensionPaneController({ return; } const vertical = resize.placement === "left" || resize.placement === "right"; + const spec = extensionPaneSize(resize.registered.pane, resize.placement); + const axisSize = vertical ? bodyWidth : bodyHeight; const currentSize = vertical ? planned.bounds.width : planned.bounds.height; const currentMax = currentSize + @@ -614,14 +601,14 @@ export function useExtensionPaneController({ position, resize.origin, resize.minSize, - Math.min(resize.maxSize, currentMax), + Math.min(extensionPaneMaximumSize(spec, axisSize), currentMax), ) : resizeSidebarWidth( resize.startSize, resize.origin, position, resize.minSize, - Math.min(resize.maxSize, currentMax), + Math.min(extensionPaneMaximumSize(spec, axisSize), currentMax), ); const axis: PaneResizeAxis = vertical ? "width" : "height"; setPaneSizeOverrides((current) => { @@ -633,7 +620,7 @@ export function useExtensionPaneController({ event.preventDefault(); event.stopPropagation(); }, - [cancelResize, minReviewHeight, minReviewWidth], + [bodyHeight, bodyWidth, cancelResize, minReviewHeight, minReviewWidth], ); // End the active drag and release mouse event ownership. diff --git a/packages/hunk/src/ui/lib/extensionPanes.test.ts b/packages/hunk/src/ui/lib/extensionPanes.test.ts index 2f91226c4..29053b8cb 100644 --- a/packages/hunk/src/ui/lib/extensionPanes.test.ts +++ b/packages/hunk/src/ui/lib/extensionPanes.test.ts @@ -573,6 +573,31 @@ describe("extension panes", () => { expect(plan.reviewBounds).toEqual({ x: 0, y: 8, width: 100, height: 12 }); }); + test("caps pane overrides at the tighter proportional, absolute, or review maximum", () => { + const registered = registeredPane("a", "bottom", { + placement: "bottom", + height: { preferred: 8, min: 3, max: 30, maxFraction: 0.8 }, + }); + const pane = buildSessionPanes(loadResultWith([registered])).find( + (candidate) => candidate.key === "a:bottom", + )!; + const height = (bodyHeight: number) => + planExtensionPanes({ + panes: [pane], + openKeys: [pane.key], + sizes: { [pane.key]: 100 }, + bodyWidth: 100, + bodyHeight, + minReviewWidth: 40, + minReviewHeight: 5, + }).panes[0]?.bounds.height; + + expect(height(40)).toBe(30); + expect(height(30)).toBe(24); + expect(height(31)).toBe(24); + expect(height(20)).toBe(14); + }); + test("omits later panes when minimum review bounds are exhausted", () => { const panes: SessionPane[] = ["one", "two", "three"].map((id) => ({ key: `a:${id}`, diff --git a/packages/hunk/src/ui/lib/extensionPanes.ts b/packages/hunk/src/ui/lib/extensionPanes.ts index a85a4362b..36b36c28b 100644 --- a/packages/hunk/src/ui/lib/extensionPanes.ts +++ b/packages/hunk/src/ui/lib/extensionPanes.ts @@ -6,7 +6,7 @@ import type { ExtensionPaneAvailabilityContext, ExtensionPanePlacement, } from "../../extension-api/types"; -import { extensionPaneSize } from "../../extensions/panes"; +import { extensionPaneMaximumSize, extensionPaneSize } from "../../extensions/panes"; import type { ExtensionLoadResult, RegisteredPane } from "../../extensions/types"; /** One cell reserved between each resizable pane and its neighbor. */ @@ -237,7 +237,6 @@ export function planExtensionPanes(options: PlanExtensionPanesOptions): Extensio const sizeSpec = (pane: SessionPane) => { const spec = extensionPaneSize(pane.registered.pane, pane.placement); const min = spec.min ?? 1; - const max = spec.max ?? Number.MAX_SAFE_INTEGER; const axisSize = pane.placement === "left" || pane.placement === "right" ? Math.max(0, options.bodyWidth) @@ -248,8 +247,9 @@ export function planExtensionPanes(options: PlanExtensionPanesOptions): Extensio target: options.sizes[pane.key] ?? options.preferredSizes?.get(pane.registered) ?? automaticSize, min, - max, - fixed: pane.registered.pane.resizable === false || min === max, + max: extensionPaneMaximumSize(spec, axisSize), + fixed: + pane.registered.pane.resizable === false || min === (spec.max ?? Number.MAX_SAFE_INTEGER), }; }; diff --git a/scripts/packaging/check-pack.ts b/scripts/packaging/check-pack.ts index 1d5bbb05c..395db1443 100644 --- a/scripts/packaging/check-pack.ts +++ b/scripts/packaging/check-pack.ts @@ -118,7 +118,13 @@ export default function (hunk: HunkExtensionAPI) { hunk.log(props.currentLine ? props.currentLine.side + ":" + props.currentLine.line : "no line"); return null; }; - const paneSize: ExtensionPaneSize = { preferred: 3, min: 2, max: 4, fraction: 0.25 }; + const paneSize: ExtensionPaneSize = { + preferred: 3, + min: 2, + max: 4, + fraction: 0.25, + maxFraction: 0.8, + }; for (const placement of ["left", "right"] as const) { const verticalPane: ExtensionVerticalPane = { id: placement, diff --git a/test/pty/extensions-integration.test.ts b/test/pty/extensions-integration.test.ts index fd43276f8..4ca4663ca 100644 --- a/test/pty/extensions-integration.test.ts +++ b/test/pty/extensions-integration.test.ts @@ -202,7 +202,7 @@ export default function (hunk) { placement, defaultOpen: false, height: placement === "top" - ? { preferred: 2, min: 2, max: 5 } + ? { preferred: 2, min: 2, max: 20, maxFraction: 0.2 } : { preferred: 2, min: 2, max: 2 }, component: (props) => createElement("text", { content: "PANE " + placement.toUpperCase() + " " + props.width + "x" + props.height, @@ -763,6 +763,16 @@ describe("PTY extensions", () => { await dragMouse(session, 70, 4, 70, 6); await session.waitForText(/PANE TOP 138x4/, { timeout: 5_000 }); + // Further dragging cannot grow the pane beyond 20% of the host body. + await dragMouse(session, 70, 6, 70, 12); + const capped = await harness.waitForSnapshot( + session, + (text) => text.includes("PANE TOP 138x4"), + 5_000, + ); + expect(capped).toContain("PANE TOP 138x4"); + expect(capped).not.toContain("PANE TOP 138x5"); + session.writeRaw("Y"); await harness.waitForSnapshot( session, diff --git a/website/src/content/docs/docs/extend/custom-sidebars.md b/website/src/content/docs/docs/extend/custom-sidebars.md index 8b0180c9d..e65b45245 100644 --- a/website/src/content/docs/docs/extend/custom-sidebars.md +++ b/website/src/content/docs/docs/extend/custom-sidebars.md @@ -43,10 +43,12 @@ export default function (hunk: HunkExtensionAPI) { } ``` -`placement` defaults to `"left"`. Left/right panes use `width`; top/bottom panes use `height`. Both accept `{ preferred, min?, max?, fraction? }`, defaulting to `{ preferred: 34, min: 22 }` columns or `{ preferred: 8, min: 3 }` rows. Equal bounds make a fixed pane. +`placement` defaults to `"left"`. Left/right panes use `width`; top/bottom panes use `height`. Both accept `{ preferred, min?, max?, fraction?, maxFraction? }`, defaulting to `{ preferred: 34, min: 22 }` columns or `{ preferred: 8, min: 3 }` rows. Equal bounds make a fixed pane. `fraction` opts into live responsive sizing until the user drags the divider. It must be greater than `0` and at most `1`; Hunk rounds that fraction of the full host body width or height to a terminal cell, then applies `min`, `max`, and the space required by the review. `preferred` remains the fixed-cell target when `fraction` is omitted. `preferredSize(context)` can instead derive the automatic whole-cell target from current review facts; Hunk clamps it to the declared bounds. A divider drag establishes a session-local cell override: later terminal shrink may clamp it temporarily, and expanding restores it. Set `resizable: false` to suppress that divider and keep following the automatic target. Panes without `fraction` or `preferredSize` retain their fixed preferred startup size. Folder extensions that use `fraction` should declare `"hunk": { "apiVersion": 12 }`; those using `preferredSize` or `resizable` require version 22. +`maxFraction` caps both responsive startup sizing and divider dragging to a share of the host body axis. It follows the same greater-than-zero and at-most-one range as `fraction`; Hunk floors the result so the pane never exceeds the requested share. When both `max` and `maxFraction` are present, the tighter limit wins. Extensions that use `maxFraction` should declare `"hunk": { "apiVersion": 26 }` in their manifest. + Use `defaultOpen` to open a pane initially, `replaces: "hunk:files"` to replace it (and override `defaultOpen`), or `available(context)` to hide it conditionally. One pane may replace each named target; the first registration owns that slot and later claims are skipped with a warning. `replaces` may also name another pane by its fully qualified `":"` key, and Hunk follows those replacement chains. Both `available(context)` and the mounted component receive `review`: immutable metadata from a delegated review or interactive history selection, or `null` for ordinary reviews. Hunk's bundled `hunk:review-info` top pane uses it for change requests and commits and consumes no rows when absent. Pane extensions that read `review` should declare `"hunk": { "apiVersion": 17 }` in their manifest so older Hunk versions refuse them cleanly instead of mounting with an incomplete prop contract. `onActivate()` observes a primary mouse press anywhere in the pane's content, including content nested in a ``. Use it to focus an extension-owned editor or update pane-local active state without adding mouse handlers to every row. Hunk does not stop propagation or prevent the press, so extension-local mouse behavior can continue. Other mouse buttons do not activate the pane. A thrown or rejected callback is contained and reported as an attributed warning. diff --git a/website/src/content/docs/docs/extend/extension-api.md b/website/src/content/docs/docs/extend/extension-api.md index 987fa1304..8210dcc48 100644 --- a/website/src/content/docs/docs/extend/extension-api.md +++ b/website/src/content/docs/docs/extend/extension-api.md @@ -7,8 +7,8 @@ The extension factory receives one API object. Registration calls are only valid ## `hunk.apiVersion` -The API generation this Hunk speaks (currently `25`). Branch on it if you want -one file to support several Hunk versions. Version 25 adds Promise-returning watch signatures and watch cancellation; version 24 adds review metadata to VCS patch results and short display revisions to commit descriptors; version 23 adds canonical unified-layout fields while preserving the previous event vocabulary; version 22 adds frame-derived pane preferred sizing, non-resizable dynamic panes, and commit-history paint tokens; version 21 adds optional inclusive history-range review +The API generation this Hunk speaks (currently `26`). Branch on it if you want +one file to support several Hunk versions. Version 26 adds proportional pane maximums; version 25 adds Promise-returning watch signatures and watch cancellation; version 24 adds review metadata to VCS patch results and short display revisions to commit descriptors; version 23 adds canonical unified-layout fields while preserving the previous event vocabulary; version 22 adds frame-derived pane preferred sizing, non-resizable dynamic panes, and commit-history paint tokens; version 21 adds optional inclusive history-range review planning and bounded comparison commit summaries; version 20 adds optional commit timestamps to review metadata, pane clipboard actions, and the `theme.copyAction` paint token; version 19 adds provider-owned history enumeration and review planning; version 18 lets lifecycle and custom-event handlers request a host-owned review reload; version 17 adds structured review metadata to delegated