Skip to content
Closed
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
130 changes: 130 additions & 0 deletions packages/app/e2e/regression/preview-baseline-contract.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,11 @@ test("splits a Preview leaf at its right edge without recreating the moved rende
await page.mouse.down()
await page.mouse.move(leafBox.x + leafBox.width - 2, leafBox.y + leafBox.height / 2, { steps: 8 })
await expect(panel.locator('[data-preview-dragging="notes/second.md"]')).toBeVisible()
const dragProxy = panel.locator("[data-preview-tab-drag-proxy]")
await expect(dragProxy).toBeVisible()
await expect(dragProxy).toContainText("second.md")
await expect(dragProxy).toHaveCSS("pointer-events", "none")
expect((await dragProxy.boundingBox())?.x).toBeGreaterThan(leafBox.x)
const preview = panel.locator('[data-preview-drop-preview="right"]')
await expect(preview).toBeVisible()
await expect(preview).toHaveCSS("pointer-events", "none")
Expand All @@ -245,6 +250,7 @@ test("splits a Preview leaf at its right edge without recreating the moved rende
await page.mouse.up()
await page.waitForTimeout(250)
await expect(preview).toHaveCount(0)
await expect(dragProxy).toHaveCount(0)

const destinationLeaf = panel.locator('[data-preview-leaf="pane-1"]')
await expect(panel.locator("[data-preview-leaf]")).toHaveCount(2)
Expand Down Expand Up @@ -330,6 +336,125 @@ test("retains an unsaved editor draft through a Preview edge split", async ({ pa
expect(await host.evaluate((element) => element.isConnected)).toBe(true)
})

test("keeps Preview zoom independent in split panes", async ({ page }) => {
await openPreview(page)
await openPreviewFile(page, secondMarkdownFile)

const panel = page.locator("#review-panel")
const source = panel.getByRole("tab", { name: "second.md" })
const rootLeaf = panel.locator('[data-preview-leaf="root"]')
const sourceBox = await source.boundingBox()
const rootBox = await rootLeaf.boundingBox()
if (!sourceBox || !rootBox) throw new Error("Preview tab and leaf must be measurable before splitting")

await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2)
await page.mouse.down()
await page.mouse.move(rootBox.x + rootBox.width - 2, rootBox.y + rootBox.height / 2, { steps: 8 })
await page.mouse.up()

const destinationLeaf = panel.locator('[data-preview-leaf="pane-1"]')
const rootZoom = rootLeaf.locator('input[type="text"]:visible')
const destinationZoom = destinationLeaf.locator('input[type="text"]')
await expect(rootZoom).toHaveValue("100%")
await expect(destinationZoom).toHaveValue("100%")

await rootZoom.fill("130")
await rootZoom.press("Enter")

await expect(rootZoom).toHaveValue("130%")
await expect(destinationZoom).toHaveValue("100%")
})

test("keeps each pane's zoom control inside its minimum-width leaf", async ({ page }) => {
await openPreview(page)
await openPreviewFile(page, secondMarkdownFile)

const panel = page.locator("#review-panel")
const source = panel.getByRole("tab", { name: "second.md" })
const rootLeaf = panel.locator('[data-preview-leaf="root"]')
const sourceBox = await source.boundingBox()
const rootBox = await rootLeaf.boundingBox()
if (!sourceBox || !rootBox) throw new Error("Preview tab and leaf must be measurable before splitting")

await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2)
await page.mouse.down()
await page.mouse.move(rootBox.x + rootBox.width - 2, rootBox.y + rootBox.height / 2, { steps: 8 })
await page.mouse.up()

const destinationLeaf = panel.locator('[data-preview-leaf="pane-1"]')
for (const leaf of [rootLeaf, destinationLeaf]) {
const leafBox = await leaf.boundingBox()
const inputBox = await leaf.locator('input[type="text"]:visible').boundingBox()
if (!leafBox || !inputBox) throw new Error("Preview leaf and zoom input must be measurable")
expect(inputBox.x).toBeGreaterThanOrEqual(leafBox.x)
expect(inputBox.x + inputBox.width).toBeLessThanOrEqual(leafBox.x + leafBox.width)
}
})

test("inherits source zoom and focuses the new Preview leaf after a split", async ({ page }) => {
await openPreview(page)
await openPreviewFile(page, secondMarkdownFile)

const panel = page.locator("#review-panel")
const rootLeaf = panel.locator('[data-preview-leaf="root"]')
const rootZoom = rootLeaf.locator('[data-preview-host="notes/second.md"] input[type="text"]')
await rootZoom.fill("130")
await rootZoom.press("Enter")
await expect(rootZoom).toHaveValue("130%")

const source = panel.getByRole("tab", { name: "second.md" })
const sourceBox = await source.boundingBox()
const rootBox = await rootLeaf.boundingBox()
if (!sourceBox || !rootBox) throw new Error("Preview tab and leaf must be measurable before splitting")

await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2)
await page.mouse.down()
await page.mouse.move(rootBox.x + rootBox.width - 2, rootBox.y + rootBox.height / 2, { steps: 8 })
await page.mouse.up()

const destinationLeaf = panel.locator('[data-preview-leaf="pane-1"]')
await expect(destinationLeaf).toHaveAttribute("data-focused", "true")
await expect(destinationLeaf.locator('input[type="text"]')).toHaveValue("130%")
})

test("adopts destination pane zoom when transferring a Preview renderer", async ({ page }) => {
await openPreview(page)
await openPreviewFile(page, secondMarkdownFile)

const panel = page.locator("#review-panel")
const source = panel.getByRole("tab", { name: "second.md" })
const rootLeaf = panel.locator('[data-preview-leaf="root"]')
const sourceBox = await source.boundingBox()
const rootBox = await rootLeaf.boundingBox()
if (!sourceBox || !rootBox) throw new Error("Preview tab and leaf must be measurable before splitting")

await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2)
await page.mouse.down()
await page.mouse.move(rootBox.x + rootBox.width - 2, rootBox.y + rootBox.height / 2, { steps: 8 })
await page.mouse.up()

const rootZoom = rootLeaf.locator('input[type="text"]')
await rootZoom.fill("130")
await rootZoom.press("Enter")
await expect(rootZoom).toHaveValue("130%")

const sourceLeaf = panel.locator('[data-preview-leaf="pane-1"]')
const movedHost = sourceLeaf.locator('[data-preview-host="notes/second.md"]')
const host = await movedHost.elementHandle()
const transferTab = sourceLeaf.getByRole("tab", { name: "second.md" })
const transferBox = await transferTab.boundingBox()
const transferTarget = await rootLeaf.boundingBox()
if (!host || !transferBox || !transferTarget) throw new Error("Preview leaves must be measurable before transferring")

await page.mouse.move(transferBox.x + transferBox.width / 2, transferBox.y + transferBox.height / 2)
await page.mouse.down()
await page.mouse.move(transferTarget.x + transferTarget.width / 2, transferTarget.y + transferTarget.height / 2, { steps: 8 })
await page.mouse.up()

await expect(rootLeaf.locator('[data-preview-host="notes/second.md"] input[type="text"]')).toHaveValue("130%")
expect(await host.evaluate((element) => element.isConnected)).toBe(true)
})

test("keeps both Preview leaves at their minimum width while resizing a divider", async ({ page }) => {
await openPreview(page)
await openPreviewFile(page, secondMarkdownFile)
Expand Down Expand Up @@ -431,10 +556,15 @@ test("clears the prospective pane preview when a Preview drag is cancelled", asy
await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2)
await page.mouse.down()
await page.mouse.move(leafBox.x + leafBox.width - 2, leafBox.y + leafBox.height / 2, { steps: 8 })
const dragProxy = panel.locator("[data-preview-tab-drag-proxy]")
await expect(dragProxy).toBeVisible()
await expect(panel.locator('[data-preview-drop-preview="right"]')).toBeVisible()
await page.mouse.move(leafBox.x - 20, leafBox.y + leafBox.height / 2, { steps: 4 })
await expect(panel.locator("[data-preview-drop-preview]")).toHaveCount(0)
await page.mouse.up()
await expect(dragProxy).toHaveAttribute("data-leaving", "true")
await page.waitForTimeout(200)
await expect(dragProxy).toHaveCount(0)

await expect(panel.locator("[data-preview-leaf]")).toHaveCount(1)
await expect(leaf.getByRole("tab", { name: "second.md" })).toHaveAttribute("aria-selected", "true")
Expand Down
4 changes: 1 addition & 3 deletions packages/app/src/components/session/preview-file-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -360,15 +360,13 @@ export function PreviewFileView(props: {
>
{/* Floating controls — top-right overlay */}
<div
data-preview-controls
onMouseEnter={handleControlsMouseEnter}
onMouseLeave={handleControlsMouseLeave}
style={{
position: "absolute",
top: "8px",
right: "14px",
"z-index": "20",
display: "flex",
gap: "6px",
"align-items": "center",
opacity: showControls() ? "1" : "0",
"pointer-events": showControls() ? "auto" : "none",
Expand Down
78 changes: 65 additions & 13 deletions packages/app/src/components/session/session-preview-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
removePreviewPath,
resizePreviewSplit,
selectPreviewPath,
setPreviewLeafZoom,
type PreviewDropPosition,
type PreviewLeaf,
type PreviewPane,
Expand All @@ -39,21 +40,24 @@ type PreviewDrop = {
type PreviewDrag = {
path: string
sourceLeafID: string
x: number
y: number
drop?: PreviewDrop
}

export function SessionPreviewTab(props: { previewFile: Accessor<string | null> }) {
const [dirtyPaths, setDirtyPaths] = createStore<Record<string, boolean>>({})
const [saveRequests, setSaveRequests] = createStore<Record<string, number>>({})
const [zoom, setZoom] = createSignal(100)
const [workspace, setWorkspace] = createSignal(createPreviewWorkspace())
const [capacityMessage, setCapacityMessage] = createSignal<string | null>(null)
const [closingPath, setClosingPath] = createSignal<string | null>(null)
const [previewDrag, setPreviewDrag] = createSignal<PreviewDrag | null>(null)
const [dragProxy, setDragProxy] = createSignal<{ path: string; x: number; y: number; leaving: boolean } | null>(null)

const leafElements = new Map<string, HTMLElement>()
const [hostMounts, setHostMounts] = createStore<Record<string, HTMLDivElement | undefined>>({})
let stopPreviewDrag: (() => void) | undefined
let dragProxyTimer: ReturnType<typeof setTimeout> | undefined

const openedPaths = () => previewLeaves(workspace().tree).flatMap((leaf) => leaf.tabs)

Expand Down Expand Up @@ -84,11 +88,16 @@ export function SessionPreviewTab(props: { previewFile: Accessor<string | null>
window.addEventListener("message", handlePreviewFile)
onCleanup(() => window.removeEventListener("message", handlePreviewFile))
})
onCleanup(() => stopPreviewDrag?.())
onCleanup(() => {
stopPreviewDrag?.()
if (dragProxyTimer) clearTimeout(dragProxyTimer)
})

const zoomIn = () => setZoom((value) => Math.min(value + 10, 500))
const zoomOut = () => setZoom((value) => Math.max(value - 10, 50))
const onZoomChange = (value: number) => setZoom(Math.round(Math.min(Math.max(value, 50), 500)))
const zoomForPath = (path: string) => previewLeafContaining(workspace().tree, path)?.zoom ?? 100
const setZoomForPath = (path: string, value: number) => {
const leaf = previewLeafContaining(workspace().tree, path)
if (leaf) setWorkspace((current) => setPreviewLeafZoom(current, leaf.id, value))
}

const removePath = (path: string) => {
setWorkspace((current) => removePreviewPath(current, path))
Expand Down Expand Up @@ -144,29 +153,56 @@ export function SessionPreviewTab(props: { previewFile: Accessor<string | null>
const source = event.currentTarget as HTMLElement
source.setPointerCapture?.(event.pointerId)

const stop = () => {
const clearDragProxy = (fade: boolean) => {
const proxy = dragProxy()
if (!proxy) return
if (!fade) {
if (dragProxyTimer) clearTimeout(dragProxyTimer)
dragProxyTimer = undefined
setDragProxy(null)
return
}
setDragProxy({ ...proxy, leaving: true })
if (dragProxyTimer) clearTimeout(dragProxyTimer)
dragProxyTimer = setTimeout(() => {
setDragProxy(null)
dragProxyTimer = undefined
}, 160)
}
const stop = (fadeProxy = false) => {
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", onUp)
window.removeEventListener("pointercancel", onCancel)
if (source.hasPointerCapture?.(event.pointerId)) source.releasePointerCapture(event.pointerId)
stopPreviewDrag = undefined
setPreviewDrag(null)
clearDragProxy(fadeProxy)
}
const onMove = (moveEvent: PointerEvent) => {
if (!active) {
if (Math.hypot(moveEvent.clientX - origin.x, moveEvent.clientY - origin.y) < 4) return
active = true
}
moveEvent.preventDefault()
setPreviewDrag({ path, sourceLeafID, drop: dropTargetAt(moveEvent.clientX, moveEvent.clientY, path, sourceLeafID) })
if (dragProxyTimer) clearTimeout(dragProxyTimer)
dragProxyTimer = undefined
const next = {
path,
sourceLeafID,
x: moveEvent.clientX,
y: moveEvent.clientY,
drop: dropTargetAt(moveEvent.clientX, moveEvent.clientY, path, sourceLeafID),
}
setPreviewDrag(next)
setDragProxy({ path, x: next.x, y: next.y, leaving: false })
}
const onUp = (upEvent: PointerEvent) => {
const drop = active ? dropTargetAt(upEvent.clientX, upEvent.clientY, path, sourceLeafID) : undefined
stop()
stop(!drop)
if (!drop) return
setWorkspace((current) => movePreviewTab(current, { path, targetLeafID: drop.leafID, position: drop.position, targetIndex: drop.targetIndex }))
}
const onCancel = () => stop()
const onCancel = () => stop(true)

stopPreviewDrag?.()
stopPreviewDrag = stop
Expand Down Expand Up @@ -303,6 +339,22 @@ export function SessionPreviewTab(props: { previewFile: Accessor<string | null>

return (
<div class="h-full flex flex-col overflow-hidden">
<Show when={dragProxy()}>
{(proxy) => (
<div
data-preview-tab-drag-proxy
data-leaving={proxy().leaving || undefined}
class="preview-tab-drag-proxy"
aria-hidden="true"
style={{
"--preview-drag-x": `${proxy().x}px`,
"--preview-drag-y": `${proxy().y}px`,
}}
>
<FileVisual path={proxy().path} active={false} explorerIconTheme />
</div>
)}
</Show>
<Show when={capacityMessage()}>
<div class="shrink-0 px-3 py-2 text-12-regular text-text-weak" role="alert">
{capacityMessage()}
Expand Down Expand Up @@ -371,10 +423,10 @@ export function SessionPreviewTab(props: { previewFile: Accessor<string | null>
removePath(path)
setClosingPath(null)
}}
zoom={zoom}
zoomIn={zoomIn}
zoomOut={zoomOut}
onZoomChange={onZoomChange}
zoom={() => zoomForPath(path)}
zoomIn={() => setZoomForPath(path, zoomForPath(path) + 10)}
zoomOut={() => setZoomForPath(path, zoomForPath(path) - 10)}
onZoomChange={(value) => setZoomForPath(path, value)}
/>
</div>
</Portal>
Expand Down
35 changes: 31 additions & 4 deletions packages/app/src/components/session/session-preview-tree.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { describe, expect, test } from "bun:test"
import { createPreviewWorkspace, movePreviewTab, openPreviewPath, previewLeaves, previewMinimumExtent } from "./session-preview-tree"
import {
createPreviewWorkspace,
movePreviewTab,
openPreviewPath,
previewLeafByID,
previewLeaves,
previewMinimumExtent,
setPreviewLeafZoom,
} from "./session-preview-tree"

describe("Preview workspace pane tree", () => {
test("moves a tab into a focused right sibling leaf", () => {
Expand All @@ -11,9 +19,9 @@ describe("Preview workspace pane tree", () => {
position: "right",
})

expect(previewLeaves(moved.tree).map(({ id, tabs, selectedPath }) => ({ id, tabs, selectedPath }))).toEqual([
{ id: "root", tabs: ["notes/baseline.md"], selectedPath: "notes/baseline.md" },
{ id: "pane-1", tabs: ["notes/second.md"], selectedPath: "notes/second.md" },
expect(previewLeaves(moved.tree).map(({ id, tabs, selectedPath, zoom }) => ({ id, tabs, selectedPath, zoom }))).toEqual([
{ id: "root", tabs: ["notes/baseline.md"], selectedPath: "notes/baseline.md", zoom: 100 },
{ id: "pane-1", tabs: ["notes/second.md"], selectedPath: "notes/second.md", zoom: 100 },
])
expect(moved.focusedLeafID).toBe("pane-1")
})
Expand Down Expand Up @@ -46,4 +54,23 @@ describe("Preview workspace pane tree", () => {
expect(previewMinimumExtent(nested.tree, "horizontal")).toBe(450)
expect(previewMinimumExtent(nested.tree, "vertical")).toBe(150)
})

test("inherits source zoom on split and destination zoom on transfer", () => {
const sourceZoomed = setPreviewLeafZoom(createPreviewWorkspace(["notes/one.md", "notes/two.md"]), "root", 130)
const split = movePreviewTab(sourceZoomed, {
path: "notes/two.md",
targetLeafID: "root",
position: "right",
})

expect(previewLeafByID(split.tree, "pane-1")?.zoom).toBe(130)
const destinationZoomed = setPreviewLeafZoom(split, "root", 90)
const transferred = movePreviewTab(destinationZoomed, {
path: "notes/two.md",
targetLeafID: "root",
position: "center",
})

expect(previewLeafByID(transferred.tree, "root")?.zoom).toBe(90)
})
})
Loading
Loading