Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/panes-share-space.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Let extension panes cap resizing to a responsive share of the terminal with `maxFraction`.
7 changes: 4 additions & 3 deletions docs/extension-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/hunk/skills/hunk-extensions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<id>]` 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.
Expand Down
9 changes: 8 additions & 1 deletion packages/hunk/src/extension-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}

/**
Expand Down
10 changes: 10 additions & 0 deletions packages/hunk/src/extensions/panes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
36 changes: 29 additions & 7 deletions packages/hunk/src/extensions/runExtension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 });
}
Expand All @@ -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 }],
]);
});

Expand Down Expand Up @@ -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 },
Expand Down
12 changes: 12 additions & 0 deletions packages/hunk/src/extensions/runExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`);
}
Expand Down Expand Up @@ -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,
Expand Down
50 changes: 50 additions & 0 deletions packages/hunk/src/ui/hooks/useExtensionPaneController.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
79 changes: 33 additions & 46 deletions packages/hunk/src/ui/hooks/useExtensionPaneController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -58,7 +58,6 @@ interface PaneResizeState {
placement: SessionPane["placement"];
origin: number;
startSize: number;
maxSize: number;
minSize: number;
}

Expand Down Expand Up @@ -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(
Expand All @@ -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 +
Expand All @@ -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) => {
Expand All @@ -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.
Expand Down
Loading
Loading