Skip to content
Open
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
10 changes: 10 additions & 0 deletions .changeset/resizable-detail-panels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@solidjs/start-devtools': minor
---

Make side detail panels in Ownership and Reactivity resizable.

- Added `ResizeHandle` and `useResizableDetail` primitives in `src/ui`.
- Detail panes in both the Ownership Tree and Reactivity Graph now feature a draggable left edge handle with a generous 12px hitbox and visual feedback.
- Panel width persists in `localStorage` across page reloads.
- Double-clicking the resize handle resets the panel back to its default width (320px).
10 changes: 9 additions & 1 deletion src/dev-toolbar/ownership/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { createEffect, createMemo, createSignal, For, getOwner, Show } from 'sol
import { Badge } from '../../ui/Badge.js';
import IconButton from '../../ui/IconButton.js';
import Placeholder from '../../ui/Placeholder.js';
import { ResizeHandle } from '../../ui/ResizeHandle.js';
import { Text } from '../../ui/Text.js';
import { useResizableDetail } from '../../ui/use-resizable-detail.js';
import { CollapseIcon, ExpandIcon, EyeIcon, PauseIcon, PlayIcon } from '../icons.js';
import { openInEditor } from '../open-in-editor.js';
import { CopyReportButton } from '../report/CopyReportButton.js';
Expand Down Expand Up @@ -64,6 +66,7 @@ export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Elemen
const [query, setQuery] = createSignal('');
const [collapsed, setCollapsed] = createSignal<string[]>([]);
const [selected, setSelected] = createSignal<string>();
const detail = useResizableDetail();

const firstSeen = new Map<string, number>();
let appliedFocus: { owner: object } | undefined;
Expand Down Expand Up @@ -334,7 +337,12 @@ export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Elemen

<Show when={selectedNode()}>
{(node) => (
<aside data-solid-ownership-detail>
<aside data-solid-ownership-detail style={{ width: `${detail.width()}px` }}>
<ResizeHandle
onResize={detail.onResizeDelta}
onResizeEnd={detail.onResizeEnd}
onReset={detail.reset}
/>
<div data-solid-ownership-detail-content>
<div data-solid-ownership-detail-head>
<span data-solid-ownership-kind={node().kind} />
Expand Down
2 changes: 1 addition & 1 deletion src/dev-toolbar/ownership/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -251,10 +251,10 @@
}

[data-solid-ownership-detail] {
position: relative;
display: flex;
flex-direction: column;

width: 20rem;
flex-shrink: 0;
min-height: 0;

Expand Down
10 changes: 9 additions & 1 deletion src/dev-toolbar/reactivity/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { createEffect, createMemo, createSignal, For, getOwner, Show } from 'sol
import { Badge } from '../../ui/Badge.js';
import IconButton from '../../ui/IconButton.js';
import Placeholder from '../../ui/Placeholder.js';
import { ResizeHandle } from '../../ui/ResizeHandle.js';
import { Text } from '../../ui/Text.js';
import { useResizableDetail } from '../../ui/use-resizable-detail.js';
import { FitIcon, PauseIcon, PlayIcon } from '../icons.js';
import { CopyReportButton } from '../report/CopyReportButton.js';
import { pageRoute } from '../report/markdown.js';
Expand Down Expand Up @@ -163,6 +165,7 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem
const [selected, setSelected] = createSignal<string>();
const [hovered, setHovered] = createSignal<string>();
const [view, setView] = createSignal<ViewTransform>({ x: 0, y: 0, k: 1 });
const detail = useResizableDetail();

let viewport: HTMLDivElement | undefined;
let fittedSize = '';
Expand Down Expand Up @@ -701,7 +704,12 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem

<Show when={selectedNode()}>
{(node) => (
<aside data-solid-reactivity-detail>
<aside data-solid-reactivity-detail style={{ width: `${detail.width()}px` }}>
<ResizeHandle
onResize={detail.onResizeDelta}
onResizeEnd={detail.onResizeEnd}
onReset={detail.reset}
/>
<div data-solid-reactivity-detail-content>
<NodeSummary
node={node()}
Expand Down
2 changes: 1 addition & 1 deletion src/dev-toolbar/reactivity/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -484,10 +484,10 @@
}

[data-solid-reactivity-detail] {
position: relative;
display: flex;
flex-direction: column;

width: 19rem;
flex-shrink: 0;
min-height: 0;

Expand Down
53 changes: 53 additions & 0 deletions src/ui/ResizeHandle.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
[data-solid-resize-handle] {
position: absolute;
top: 0;
bottom: 0;
left: -4px;
width: 8px;
z-index: 10;

cursor: ew-resize;
touch-action: none;
}

/* Hitbox: wide invisible target area centered on the border */
[data-solid-resize-handle]::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: -2px;
width: 12px;
}

/* Visual line: centered on border, subtle hover, solid thicker line when dragging */
[data-solid-resize-handle]::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 3px;
width: 2px;

background: transparent;
transition:
background-color 150ms ease-out,
width 150ms ease-out,
left 150ms ease-out;
pointer-events: none;
}

/* Hover state: visible, semi-transparent accent line */
[data-solid-resize-handle]:hover::after {
background: var(--start-dt-accent);
opacity: 0.6;
}

/* Active / Dragging state: thicker (3px), full solid opacity without glow */
[data-solid-resize-handle]:active::after,
[data-solid-resize-handle][data-dragging]::after {
left: 2.5px;
width: 3px;
background: var(--start-dt-accent);
opacity: 1;
}
52 changes: 52 additions & 0 deletions src/ui/ResizeHandle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { JSX } from '@solidjs/web';
import { createSignal } from 'solid-js';
import './ResizeHandle.css';

export interface ResizeHandleProps {
onResize: (deltaX: number) => void;
onResizeEnd?: () => void;
onReset?: () => void;
title?: string;
}

export function ResizeHandle(props: ResizeHandleProps): JSX.Element {
const [dragging, setDragging] = createSignal(false);

function onPointerDown(event: PointerEvent): void {
if (event.button !== 0) return;
event.preventDefault();
setDragging(true);

let startX = event.clientX;

const onPointerMove = (next: PointerEvent): void => {
const deltaX = startX - next.clientX;
startX = next.clientX;
props.onResize(deltaX);
};

const onPointerUp = (): void => {
setDragging(false);
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerUp);
props.onResizeEnd?.();
};

window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerUp);
}

return (
<div
role="separator"
aria-orientation="vertical"
data-solid-resize-handle
data-dragging={dragging() ? '' : undefined}
title={props.title ?? 'Drag to resize, double-click to reset'}
onPointerDown={onPointerDown}
onDblClick={() => props.onReset?.()}
/>
);
}
24 changes: 24 additions & 0 deletions src/ui/use-resizable-detail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import {
clampDetailWidth,
DEFAULT_DETAIL_WIDTH,
MIN_DETAIL_WIDTH,
} from './use-resizable-detail.js';

describe('clampDetailWidth', () => {
it('keeps width within standard bounds', () => {
expect(clampDetailWidth(300, 200, 600)).toBe(300);
expect(clampDetailWidth(150, 200, 600)).toBe(200);
expect(clampDetailWidth(750, 200, 600)).toBe(600);
});

it('rounds non-integer widths', () => {
expect(clampDetailWidth(300.4, 200, 600)).toBe(300);
expect(clampDetailWidth(300.6, 200, 600)).toBe(301);
});

it('falls back to default min width', () => {
expect(clampDetailWidth(100)).toBe(MIN_DETAIL_WIDTH);
expect(DEFAULT_DETAIL_WIDTH).toBeGreaterThanOrEqual(MIN_DETAIL_WIDTH);
});
});
86 changes: 86 additions & 0 deletions src/ui/use-resizable-detail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { createSignal, onSettled } from 'solid-js';

const STORAGE_KEY = 'solid-start-devtools:detail-width';
export const DEFAULT_DETAIL_WIDTH = 320;
export const MIN_DETAIL_WIDTH = 240;

function readSavedWidth(key: string): number | undefined {
try {
const raw = localStorage.getItem(key);
if (!raw) return undefined;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : undefined;
} catch {
return undefined;
}
}

function writeSavedWidth(key: string, width: number): void {
try {
localStorage.setItem(key, String(width));
} catch {
// Storage can be blocked.
}
}

export function clampDetailWidth(
width: number,
min = MIN_DETAIL_WIDTH,
max = typeof window !== 'undefined' ? window.innerWidth * 0.7 : 800,
): number {
return Math.round(Math.min(Math.max(width, min), max));
}

export interface UseResizableDetailOptions {
storageKey?: string;
defaultWidth?: number;
minWidth?: number;
}

export interface ResizableDetail {
width: () => number;
setWidth: (width: number) => void;
onResizeDelta: (deltaX: number) => void;
onResizeEnd: () => void;
reset: () => void;
}

export function useResizableDetail(options: UseResizableDetailOptions = {}): ResizableDetail {
const key = options.storageKey ?? STORAGE_KEY;
const initial = options.defaultWidth ?? DEFAULT_DETAIL_WIDTH;
const min = options.minWidth ?? MIN_DETAIL_WIDTH;

const [width, setWidthState] = createSignal(initial);

onSettled(() => {
const saved = readSavedWidth(key);
if (typeof saved === 'number') {
setWidthState(clampDetailWidth(saved, min));
}
});

function setWidth(next: number): void {
setWidthState(clampDetailWidth(next, min));
}

function onResizeDelta(deltaX: number): void {
setWidthState((current) => clampDetailWidth(current + deltaX, min));
}

function onResizeEnd(): void {
writeSavedWidth(key, width());
}

function reset(): void {
setWidthState(initial);
writeSavedWidth(key, initial);
}

return {
width,
setWidth,
onResizeDelta,
onResizeEnd,
reset,
};
}
21 changes: 21 additions & 0 deletions tests/e2e/devtools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,27 @@ test('maps the ownership tree', async ({ page }) => {
.locator('[data-solid-ownership-label]')
.filter({ hasText: /^doubled$/ })
.click();

// The ownership detail pane can be resized horizontally by dragging its left edge handle.
const handle = detail.locator('[data-solid-resize-handle]');
await expect(handle).toBeVisible();
const initialWidth = (await detail.boundingBox())!.width;

const handleBox = (await handle.boundingBox())!;
await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + 100);
await page.mouse.down();
// Drag left by 100px to widen the detail pane
await page.mouse.move(handleBox.x + handleBox.width / 2 - 100, handleBox.y + 100);
await page.mouse.up();

const expandedWidth = (await detail.boundingBox())!.width;
expect(expandedWidth).toBeGreaterThan(initialWidth + 80);

// Double click resets the width
await handle.dblclick();
const resetWidth = (await detail.boundingBox())!.width;
expect(Math.abs(resetWidth - 320)).toBeLessThanOrEqual(2);

await detail
.locator('[data-solid-ownership-detail-head]')
.getByRole('button', { name: 'View in graph' })
Expand Down
Loading