mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
feat(app): hover pill on resize handles and a wider sidebar range (#57343)
This commit is contained in:
committed by
GitHub
Unverified
parent
05947ee285
commit
df98f1c803
@@ -423,10 +423,7 @@ test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({
|
||||
|
||||
const handle = sidebar.locator('[data-component="resize-handle"]')
|
||||
|
||||
for (const [offset, width] of [
|
||||
[-80, "180px"],
|
||||
[-200, "140px"],
|
||||
] as const) {
|
||||
const resize = async (offset: number, width: string) => {
|
||||
const box = await handle.boundingBox()
|
||||
|
||||
if (!box) throw new Error("vertical tab resize handle has no bounding box")
|
||||
@@ -437,6 +434,9 @@ test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({
|
||||
await expect(sidebar).toHaveCSS("width", width)
|
||||
}
|
||||
|
||||
await resize(-80, "180px")
|
||||
await resize(-40, "140px")
|
||||
|
||||
for (const name of ["Home", "New session"]) {
|
||||
const label = sidebar.getByRole("button", { name, exact: true }).getByText(name, { exact: true })
|
||||
await expect(label).toBeVisible()
|
||||
@@ -445,6 +445,14 @@ test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({
|
||||
.toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
await resize(-200, "120px")
|
||||
const homeLabel = sidebar.getByRole("button", { name: "Home", exact: true }).getByText("Home", { exact: true })
|
||||
await expect
|
||||
.poll(() => homeLabel.evaluate((element) => element.scrollWidth - element.clientWidth))
|
||||
.toBeLessThanOrEqual(1)
|
||||
await resize(800, "640px")
|
||||
await resize(-380, "260px")
|
||||
|
||||
const scroll = sidebar.locator('[data-slot="vertical-tabs-scroll"]')
|
||||
await scroll.evaluate((element) => element.scrollTo(0, element.scrollHeight))
|
||||
await expect(tabB).toBeInViewport({ ratio: 1 })
|
||||
|
||||
@@ -45,6 +45,7 @@ export function createSessionScreenLayout(
|
||||
width: view().session.width(),
|
||||
available: available(),
|
||||
split: splitSide(),
|
||||
resized: view().session.resized(),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -61,7 +62,7 @@ export function createSessionScreenLayout(
|
||||
|
||||
if (width === undefined) return 1000
|
||||
|
||||
return sessionPanelWidthMax({ available: width, split: splitSide() })
|
||||
return sessionPanelWidthMax({ available: width, split: splitSide(), resized: true })
|
||||
})
|
||||
|
||||
const sideOpen = createMemo(() => tabsOpen() || fileTreeOpen())
|
||||
|
||||
@@ -474,6 +474,7 @@ function SessionScreenContent(props: {
|
||||
>
|
||||
<div onPointerDown={() => screen.size.start()}>
|
||||
<ResizeHandle
|
||||
class="-top-1"
|
||||
direction="vertical"
|
||||
edge="start"
|
||||
size={session.layout.view().bottom.height()}
|
||||
|
||||
@@ -2,14 +2,17 @@ import { expect, test } from "bun:test"
|
||||
import { clampSessionPanelWidth } from "./session-panel-width"
|
||||
|
||||
test.each([
|
||||
["keeps widths already within the limit", 800, 1700, false, 800],
|
||||
["reserves the unified review pane minimum", 1600, 1700, false, 1220],
|
||||
["reserves a larger minimum for split diffs", 1600, 1700, true, 900],
|
||||
["keeps widths already within the limit", 800, 1700, false, false, 800],
|
||||
["reserves the unified review pane minimum", 1600, 1700, false, false, 1220],
|
||||
["reserves a larger minimum for split diffs", 1600, 1700, true, false, 900],
|
||||
// Regression: the old cap was 45% of the window, forcing the review pane to at least 55%.
|
||||
["lets the chat panel take everything beyond the review pane minimum", 3440, 3440, false, 2960],
|
||||
["holds the chat panel minimum when there is no room for both", 1600, 700, true, 450],
|
||||
["never drops below the chat panel minimum on small windows", 1600, 0, false, 450],
|
||||
["skips clamping before the layout is measured", 1600, undefined, false, 1600],
|
||||
])("%s", (_name, width, available, split, expected) => {
|
||||
expect(clampSessionPanelWidth({ width, available, split })).toBe(expected)
|
||||
["lets the chat panel take everything beyond the review pane minimum", 3440, 3440, false, false, 2960],
|
||||
["holds the chat panel minimum when there is no room for both", 1600, 700, true, false, 450],
|
||||
["never drops below the chat panel minimum on small windows", 1600, 0, false, false, 450],
|
||||
["skips clamping before the layout is measured", 1600, undefined, false, false, 1600],
|
||||
["lets a dragged width leave the side pane its hard minimum, split diffs included", 1600, 1700, true, true, 1380],
|
||||
["keeps a dragged width below the default minimum", 320, 700, true, true, 320],
|
||||
["never drops a dragged width below the hard chat minimum", 1600, 0, false, true, 320],
|
||||
])("%s", (_name, width, available, split, resized, expected) => {
|
||||
expect(clampSessionPanelWidth({ width, available, split, resized })).toBe(expected)
|
||||
})
|
||||
|
||||
@@ -2,22 +2,35 @@
|
||||
// leaves behind. Instead of capping the chat panel at a fraction of the window
|
||||
// (which forces the review pane to grow with the monitor), reserve a fixed
|
||||
// minimum for the review pane and let the chat panel take everything else.
|
||||
export const SESSION_PANEL_WIDTH_MIN = 450
|
||||
// Until the user drags the divider, the default width keeps roomier minimums
|
||||
// (wider still for split diffs); a drag can take either side down to the hard
|
||||
// minimum.
|
||||
export const SESSION_PANEL_WIDTH_MIN = 320
|
||||
|
||||
const REVIEW_PANE_WIDTH_MIN = 480
|
||||
const SIDE_PANE_WIDTH_MIN = 320
|
||||
|
||||
const REVIEW_PANE_WIDTH_MIN_SPLIT = 800
|
||||
const SESSION_PANEL_WIDTH_FIT = 450
|
||||
|
||||
export function sessionPanelWidthMax(input: { available: number; split: boolean }) {
|
||||
const pane = input.split ? REVIEW_PANE_WIDTH_MIN_SPLIT : REVIEW_PANE_WIDTH_MIN
|
||||
const REVIEW_PANE_WIDTH_FIT = 480
|
||||
|
||||
return Math.max(SESSION_PANEL_WIDTH_MIN, input.available - pane)
|
||||
const REVIEW_PANE_WIDTH_FIT_SPLIT = 800
|
||||
|
||||
export function sessionPanelWidthMax(input: { available: number; split: boolean; resized: boolean }) {
|
||||
if (input.resized) return Math.max(SESSION_PANEL_WIDTH_MIN, input.available - SIDE_PANE_WIDTH_MIN)
|
||||
const pane = input.split ? REVIEW_PANE_WIDTH_FIT_SPLIT : REVIEW_PANE_WIDTH_FIT
|
||||
|
||||
return Math.max(SESSION_PANEL_WIDTH_FIT, input.available - pane)
|
||||
}
|
||||
|
||||
// `available` is undefined until the layout row is first measured; render the
|
||||
// stored width untouched until then to avoid a first-frame snap.
|
||||
export function clampSessionPanelWidth(input: { width: number; available: number | undefined; split: boolean }) {
|
||||
export function clampSessionPanelWidth(input: {
|
||||
width: number
|
||||
available: number | undefined
|
||||
split: boolean
|
||||
resized: boolean
|
||||
}) {
|
||||
if (input.available === undefined) return input.width
|
||||
|
||||
return Math.min(input.width, sessionPanelWidthMax({ available: input.available, split: input.split }))
|
||||
return Math.min(input.width, sessionPanelWidthMax({ ...input, available: input.available }))
|
||||
}
|
||||
|
||||
@@ -93,11 +93,11 @@ export default function Layout(props: ParentProps) {
|
||||
</div>
|
||||
<Show when={preferences.appearance.sidebarOpen()}>
|
||||
<ResizeHandle
|
||||
class="-end-2"
|
||||
class="-end-1"
|
||||
direction="horizontal"
|
||||
size={state.tabsWidth}
|
||||
min={140}
|
||||
max={520}
|
||||
min={120}
|
||||
max={640}
|
||||
onResize={(width) => setState("tabsWidth", width)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
@@ -576,6 +576,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
session: {
|
||||
width: createMemo(() => regions.sessionWidth() ?? DEFAULT_SESSION_WIDTH),
|
||||
/** The user has dragged the divider, so the width is theirs rather than the default. */
|
||||
resized: createMemo(() => regions.sessionWidth() !== undefined),
|
||||
resize: regions.setSessionWidth,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,16 +2,35 @@
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
|
||||
/* A pill centred on the handle. It waits a moment before showing on hover so crossing a boundary stays quiet. */
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
translate: -50% -50%;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--v2-icon-icon-muted);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 110ms cubic-bezier(0.4, 0, 1, 1),
|
||||
background-color 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
&:hover::after,
|
||||
&:active::after {
|
||||
&:hover::after {
|
||||
opacity: 1;
|
||||
transition:
|
||||
opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1) 200ms,
|
||||
background-color 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
&[data-dragging]::after {
|
||||
opacity: 1;
|
||||
background-color: var(--v2-icon-icon-base);
|
||||
transition:
|
||||
opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
background-color 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
&:where([data-direction="horizontal"]) {
|
||||
@@ -28,10 +47,8 @@
|
||||
}
|
||||
|
||||
&::after {
|
||||
width: 3px;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 4px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
&:dir(rtl) {
|
||||
@@ -40,10 +57,6 @@
|
||||
&[data-edge="start"] {
|
||||
transform: translateX(50%);
|
||||
}
|
||||
|
||||
&::after {
|
||||
transform: translateX(50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,10 +74,8 @@
|
||||
}
|
||||
|
||||
&::after {
|
||||
height: 3px;
|
||||
inset-inline: 0;
|
||||
inset-block-start: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export function ResizeHandle(props: ResizeHandleProps) {
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (e.detail > 1) return
|
||||
e.preventDefault()
|
||||
const handle = e.currentTarget instanceof HTMLElement ? e.currentTarget : undefined
|
||||
const edge = local.edge ?? (local.direction === "vertical" ? "start" : "end")
|
||||
const start = local.direction === "horizontal" ? e.clientX : e.clientY
|
||||
|
||||
@@ -51,6 +52,8 @@ export function ResizeHandle(props: ResizeHandleProps) {
|
||||
|
||||
document.body.style.userSelect = "none"
|
||||
document.body.style.overflow = "hidden"
|
||||
document.body.style.cursor = local.direction === "horizontal" ? "col-resize" : "row-resize"
|
||||
handle?.setAttribute("data-dragging", "")
|
||||
|
||||
const onMouseMove = (moveEvent: MouseEvent) => {
|
||||
const pos = local.direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY
|
||||
@@ -78,6 +81,8 @@ export function ResizeHandle(props: ResizeHandleProps) {
|
||||
const onMouseUp = () => {
|
||||
document.body.style.userSelect = ""
|
||||
document.body.style.overflow = ""
|
||||
document.body.style.cursor = ""
|
||||
handle?.removeAttribute("data-dragging")
|
||||
document.removeEventListener("mousemove", onMouseMove)
|
||||
document.removeEventListener("mouseup", onMouseUp)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user