Skip to content
Merged
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/resizable-detail-panels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@solidjs/start-devtools': minor
---

Make the detail panes in the Ownership and Reactivity panels resizable. Drag the left edge of a pane, or focus it and press the left and right arrow keys. Each pane keeps its own width across reloads, and a double-click on the edge resets it.
6 changes: 5 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, EditIcon, ExpandIcon, EyeIcon, PauseIcon, PlayIcon } from '../icons.js';
import { openInEditor } from '../open-in-editor.js';
import { CopyReportButton } from '../report/CopyReportButton.js';
Expand Down Expand Up @@ -194,6 +196,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({ storageKey: 'solid-start-devtools:ownership-detail-width' });

const firstSeen = new Map<string, number>();
let appliedFocus: { owner: object } | undefined;
Expand Down Expand Up @@ -464,7 +467,8 @@ 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 detail={detail} />
<div data-solid-ownership-detail-content>
<div data-solid-ownership-detail-head>
<span data-solid-ownership-kind={node().kind} />
Expand Down
4 changes: 3 additions & 1 deletion src/dev-toolbar/ownership/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -251,10 +251,12 @@
}

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

width: 20rem;
/* Keeps the tree usable when the window shrinks below a saved width. */
max-width: calc(100% - 240px);
flex-shrink: 0;
min-height: 0;

Expand Down
9 changes: 8 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 @@ -164,6 +166,10 @@ 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({
storageKey: 'solid-start-devtools:reactivity-detail-width',
defaultWidth: 304,
});

let viewport: HTMLDivElement | undefined;
let fittedSize = '';
Expand Down Expand Up @@ -702,7 +708,8 @@ 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 detail={detail} />
<div data-solid-reactivity-detail-content>
<NodeSummary
node={node()}
Expand Down
4 changes: 3 additions & 1 deletion src/dev-toolbar/reactivity/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -484,10 +484,12 @@
}

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

width: 19rem;
/* Keeps the tree usable when the window shrinks below a saved width. */
max-width: calc(100% - 240px);
flex-shrink: 0;
min-height: 0;

Expand Down
62 changes: 62 additions & 0 deletions src/ui/ResizeHandle.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
[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;
}

[data-solid-resize-handle]:focus-visible {
outline: none;
}

[data-solid-resize-handle]:focus-visible::after {
background: var(--start-dt-accent);
opacity: 1;
}
74 changes: 74 additions & 0 deletions src/ui/ResizeHandle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { JSX } from '@solidjs/web';
import { createSignal } from 'solid-js';
import { KEYBOARD_STEP, maxDetailWidth, type ResizableDetail } from './use-resizable-detail.js';
import './ResizeHandle.css';

export interface ResizeHandleProps {
detail: ResizableDetail;
title?: string;
}

/** The left edge of a detail pane. The pane is the handle's parent, inside the panel. */
export function ResizeHandle(props: ResizeHandleProps): JSX.Element {
const [dragging, setDragging] = createSignal(false);
const [panelWidth, setPanelWidth] = createSignal(0);
let handle: HTMLDivElement | undefined;

function measurePanel(): number {
const width = handle?.parentElement?.parentElement?.clientWidth ?? 0;
setPanelWidth(width);
return width;
}

function onPointerDown(event: PointerEvent): void {
if (event.button !== 0) return;
event.preventDefault();
setDragging(true);
props.detail.start(measurePanel());
const startX = event.clientX;

function onPointerMove(next: PointerEvent): void {
props.detail.drag(startX - next.clientX);
}

function onPointerUp(): void {
setDragging(false);
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerUp);
props.detail.end();
}

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

// The handle is the pane's left edge, so the left arrow widens the pane.
function onKeyDown(event: KeyboardEvent): void {
if (event.key === 'ArrowLeft') props.detail.step(KEYBOARD_STEP, measurePanel());
else if (event.key === 'ArrowRight') props.detail.step(-KEYBOARD_STEP, measurePanel());
else return;
event.preventDefault();
}

return (
<div
ref={handle}
role="separator"
tabindex="0"
aria-orientation="vertical"
aria-label="Resize the detail pane"
aria-valuenow={props.detail.width()}
aria-valuemin={props.detail.minWidth}
aria-valuemax={panelWidth() ? maxDetailWidth(panelWidth(), props.detail.minWidth) : undefined}
data-solid-resize-handle
data-dragging={dragging() ? '' : undefined}
title={props.title ?? 'Drag to resize, double-click to reset'}
onPointerDown={onPointerDown}
onKeyDown={onKeyDown}
onFocus={measurePanel}
onDblClick={() => props.detail.reset()}
/>
);
}
66 changes: 66 additions & 0 deletions src/ui/use-resizable-detail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { createRoot } from 'solid-js';
import { describe, expect, it } from 'vitest';
import {
clampDetailWidth,
maxDetailWidth,
MIN_DETAIL_WIDTH,
MIN_MAIN_WIDTH,
useResizableDetail,
} from './use-resizable-detail.js';

describe('clampDetailWidth', () => {
it('keeps width within 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);
});
});

describe('maxDetailWidth', () => {
it('leaves room for the tree beside the pane', () => {
expect(maxDetailWidth(1000)).toBe(1000 - MIN_MAIN_WIDTH);
});

it('never goes below the minimum width', () => {
expect(maxDetailWidth(300)).toBe(MIN_DETAIL_WIDTH);
});
});

describe('useResizableDetail', () => {
function detail() {
return createRoot(() => useResizableDetail({ storageKey: 'test', defaultWidth: 320 }));
}

it('keeps the edge under the pointer after passing a limit', () => {
const pane = detail();
pane.start(1000);
// 100px past the minimum, then 50px back: still past it.
pane.drag(-(320 - MIN_DETAIL_WIDTH) - 100);
expect(pane.width()).toBe(MIN_DETAIL_WIDTH);
pane.drag(-(320 - MIN_DETAIL_WIDTH) - 50);
expect(pane.width()).toBe(MIN_DETAIL_WIDTH);
pane.drag(40);
expect(pane.width()).toBe(360);
});

it('stops at the width the panel allows', () => {
const pane = detail();
pane.start(800);
pane.drag(1000);
expect(pane.width()).toBe(800 - MIN_MAIN_WIDTH);
});

it('steps from the current width', () => {
const pane = detail();
pane.step(16, 1000);
pane.step(16, 1000);
expect(pane.width()).toBe(352);
pane.reset();
expect(pane.width()).toBe(320);
});
});
100 changes: 100 additions & 0 deletions src/ui/use-resizable-detail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { createSignal, onSettled } from 'solid-js';

export const DEFAULT_DETAIL_WIDTH = 320;
export const MIN_DETAIL_WIDTH = 240;
/** The narrowest the tree or graph beside a detail pane may get. */
export const MIN_MAIN_WIDTH = 240;
/** How far one arrow key press moves the edge. */
export const KEYBOARD_STEP = 16;

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.
}
}

/** The widest a detail pane may get inside a panel `panelWidth` wide. */
export function maxDetailWidth(panelWidth: number, min = MIN_DETAIL_WIDTH): number {
return Math.max(min, panelWidth - MIN_MAIN_WIDTH);
}

export function clampDetailWidth(width: number, min: number, max: number): number {
return Math.round(Math.min(Math.max(width, min), max));
}

export interface UseResizableDetailOptions {
/** Each pane keeps its own width. */
storageKey: string;
defaultWidth?: number;
minWidth?: number;
}

export interface ResizableDetail {
width: () => number;
minWidth: number;
/** Starts a drag. `panelWidth` is the width of the panel the pane sits in. */
start: (panelWidth: number) => void;
/** Moves the edge `offsetX` pixels to the left of where the drag started. */
drag: (offsetX: number) => void;
/** Moves the edge `deltaX` pixels to the left now, and saves the width. */
step: (deltaX: number, panelWidth: number) => void;
end: () => void;
reset: () => void;
}

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

const [width, setWidth] = createSignal(initial);
let startWidth = initial;
let max = Number.POSITIVE_INFINITY;

onSettled(() => {
const saved = readSavedWidth(key);
if (typeof saved === 'number') setWidth(Math.max(Math.round(saved), min));
});

function start(panelWidth: number): void {
max = maxDetailWidth(panelWidth, min);
// A saved width can be wider than the panel allows now; CSS shows it clamped.
startWidth = clampDetailWidth(width(), min, max);
}

// Measured from the start of the drag, so the edge stays under the pointer
// after it passes a limit and comes back.
function drag(offsetX: number): void {
setWidth(clampDetailWidth(startWidth + offsetX, min, max));
}

function step(deltaX: number, panelWidth: number): void {
start(panelWidth);
drag(deltaX);
end();
}

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

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

return { width, minWidth: min, start, drag, step, end, reset };
}
Loading
Loading