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
14 changes: 14 additions & 0 deletions lib/src/stories/AppBar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ const DEFAULT_SHELLS = [
{ name: 'fish', path: '/usr/bin/fish' },
];

function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

async function openShellSelector({ canvasElement }: { canvasElement: HTMLElement }) {
await wait(100);
const shellButton = Array.from(canvasElement.querySelectorAll<HTMLButtonElement>('button[aria-haspopup="menu"]'))
.find((button) => DEFAULT_SHELLS.some((shell) => button.textContent?.includes(shell.name)));
shellButton?.click();
await wait(100);
}

function AppBarStory(props: React.ComponentProps<typeof AppBar>) {
return (
<div style={{ width: '100%' }}>
Expand All @@ -32,6 +44,7 @@ export const SingleShell: Story = {
args: {
shells: [{ name: 'bash', path: '/bin/bash' }],
},
play: openShellSelector,
};

export const ManyShells: Story = {
Expand All @@ -44,4 +57,5 @@ export const ManyShells: Story = {
{ name: 'nu', path: '/usr/bin/nu' },
],
},
play: openShellSelector,
};
192 changes: 129 additions & 63 deletions lib/src/stories/SelectionOverlay.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,81 +1,147 @@
import { useState, useRef, useEffect } from 'react';
import { useEffect, useRef } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import type { WallMode } from '../components/Wall';
import { MarchingAntsRect } from '../components/Wall';
import '@xterm/xterm/css/xterm.css';
import { SelectionOverlay } from '../components/SelectionOverlay';
import {
focusSession,
getOrCreateTerminal,
getTerminalOverlayDims,
mountElement,
refitSession,
unmountElement,
} from '../lib/terminal-registry';
import { flattenScenario, SCENARIO_LS_OUTPUT } from '../lib/platform';
import {
setHintToken,
setSelection,
type Selection,
type TokenHint,
} from '../lib/mouse-selection';
import { TERMINAL_BOTTOM_RADIUS_CLASS } from '../components/design';

function SelectionOverlayDemo({ initialMode = 'command' as WallMode }) {
const [mode, setMode] = useState<WallMode>(initialMode);
const containerRef = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ width: 484, height: 284 });
function SelectionOverlayStory({
id,
selection,
hintToken = null,
}: {
id: string;
selection: Omit<Selection, 'startedInScrollback'>;
hintToken?: TokenHint | null;
}) {
const terminalHostRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
setSize({ width: entry.contentRect.width - 16, height: entry.contentRect.height - 16 });
});
ro.observe(el);
return () => ro.disconnect();
}, []);

const color = getComputedStyle(document.documentElement).getPropertyValue('--color-header-active-bg').trim() || '#094771';

const overlayStyle: React.CSSProperties = {
position: 'absolute',
inset: 8,
borderRadius: '0.5rem',
pointerEvents: 'none',
transition: 'border 150ms, box-shadow 150ms',
};

if (mode === 'passthrough') {
overlayStyle.border = `2px solid ${color}`;
overlayStyle.boxShadow = `0 0 15px color-mix(in srgb, ${color} 30%, transparent)`;
}
const terminalHost = terminalHostRef.current;
if (!terminalHost) return;

getOrCreateTerminal(id);
mountElement(id, terminalHost);

const observer = new ResizeObserver(() => refitSession(id));
observer.observe(terminalHost);

return () => {
observer.disconnect();
unmountElement(id);
};
}, [id]);

useEffect(() => {
focusSession(id, true);
}, [id]);

useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout>;

const applySelection = () => {
if (cancelled) return;
const dims = getTerminalOverlayDims(id);
if (!dims || dims.cellHeight === 0) {
timer = setTimeout(applySelection, 50);
return;
}

setSelection(id, { ...selection, startedInScrollback: false });
setHintToken(id, hintToken);
};

timer = setTimeout(applySelection, 100);
return () => {
cancelled = true;
clearTimeout(timer);
setSelection(id, null);
setHintToken(id, null);
};
}, [id, selection, hintToken]);

return (
<div ref={containerRef} style={{ width: 500, height: 300 }} className="relative bg-app-bg">
{/* Simulated terminal content */}
<div className="p-4 font-mono text-sm text-terminal-fg">
<div>user@mouseterm:~$ ls -la</div>
<div>total 48</div>
<div>drwxr-xr-x 12 user staff 384 Mar 16 10:30 .</div>
</div>
{/* Selection overlay */}
{mode === 'command' ? (
<div style={{ position: 'absolute', inset: 8, pointerEvents: 'none' }}>
<MarchingAntsRect width={size.width} height={size.height} isDoor={false} color={color} />
</div>
) : (
<div style={overlayStyle} />
)}
{/* Mode toggle */}
<div className="absolute bottom-2 right-2 flex gap-2">
<button
className={`px-3 py-1 rounded text-sm font-mono ${mode === 'passthrough' ? 'bg-header-active-bg text-header-active-fg' : 'bg-header-inactive-bg text-header-inactive-fg'}`}
onClick={() => setMode('passthrough')}
>passthrough</button>
<button
className={`px-3 py-1 rounded text-sm font-mono ${mode === 'command' ? 'bg-header-active-bg text-header-active-fg' : 'bg-header-inactive-bg text-header-inactive-fg'}`}
onClick={() => setMode('command')}
>command</button>
</div>
<div
className={`relative bg-terminal-bg ${TERMINAL_BOTTOM_RADIUS_CLASS}`}
style={{ width: 620, height: 340 }}
>
<div ref={terminalHostRef} className="h-full w-full" />
<SelectionOverlay terminalId={id} />
</div>
);
}

const meta: Meta<typeof SelectionOverlayDemo> = {
const meta: Meta<typeof SelectionOverlayStory> = {
title: 'Components/SelectionOverlay',
component: SelectionOverlayDemo,
component: SelectionOverlayStory,
parameters: {
fakePty: { scenario: flattenScenario(SCENARIO_LS_OUTPUT) },
},
};

export default meta;
type Story = StoryObj<typeof SelectionOverlayDemo>;
type Story = StoryObj<typeof SelectionOverlayStory>;

export const LinewiseDrag: Story = {
args: {
id: 'selection-overlay-linewise-drag',
selection: {
startRow: 2,
startCol: 5,
endRow: 6,
endCol: 24,
shape: 'linewise',
dragging: true,
},
},
};

export const CommandMode: Story = {
args: { initialMode: 'command' },
export const BlockDrag: Story = {
args: {
id: 'selection-overlay-block-drag',
selection: {
startRow: 2,
startCol: 6,
endRow: 5,
endCol: 26,
shape: 'block',
dragging: true,
},
},
};

export const PassthroughMode: Story = {
args: { initialMode: 'passthrough' },
export const SmartPathHint: Story = {
args: {
id: 'selection-overlay-smart-path-hint',
selection: {
startRow: 2,
startCol: 5,
endRow: 6,
endCol: 24,
shape: 'linewise',
dragging: true,
},
hintToken: {
kind: 'path',
row: 8,
startCol: 35,
endCol: 38,
text: 'src',
},
},
};
15 changes: 0 additions & 15 deletions lib/src/stories/TerminalPaneHeader.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,6 @@ async function openAlertRightClickDialog() {
await wait(100);
}

async function clickTodoPill() {
await wait(100);
const todoButton = document.querySelector<HTMLButtonElement>(`[data-session-todo-for="${SESSION_ID}"]`);
todoButton?.click();
await wait(100);
}

const meta: Meta<typeof TabStory> = {
title: 'Components/TerminalPaneHeader',
component: TabStory,
Expand Down Expand Up @@ -179,14 +172,6 @@ export const AlertRightClickDialog: Story = {
play: openAlertRightClickDialog,
};

export const TodoClickToDismiss: Story = {
parameters: primedState({
status: 'NOTHING_TO_SHOW',
todo: true,
}),
play: clickTodoPill,
};

export const TodoOnly: Story = {
parameters: primedState({
status: 'ALERT_DISABLED',
Expand Down
15 changes: 8 additions & 7 deletions lib/src/stories/UpdateBanner.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react';
import { UpdateBanner, type UpdateBannerState } from '../../../standalone/src/UpdateBanner';

function UpdateBannerStory({ state }: { state: UpdateBannerState }) {
function UpdateBannerStory({ state, expectedNullReason }: { state: UpdateBannerState; expectedNullReason?: string }) {
return (
<div className="bg-app-bg" style={{ width: '100%' }}>
<UpdateBanner
Expand All @@ -10,6 +10,11 @@ function UpdateBannerStory({ state }: { state: UpdateBannerState }) {
onOpenChangelog={() => console.log('Open changelog')}
onOpenDebug={() => console.log('Open debug')}
/>
{expectedNullReason ? (
<div className="inline-flex border border-dashed border-border bg-surface-raised px-2 py-1 font-mono text-xs text-muted">
Expected empty banner: {expectedNullReason}
</div>
) : null}
</div>
);
}
Expand Down Expand Up @@ -43,18 +48,14 @@ export const PostUpdateFailure: Story = {
export const Idle: Story = {
args: {
state: { status: 'idle' },
expectedNullReason: 'idle has no update notice to show.',
},
};

export const Dismissed: Story = {
args: {
state: { status: 'dismissed' },
},
};

export const LongVersionString: Story = {
args: {
state: { status: 'downloaded', version: '1.23.456-beta.7+build.2025.04.10' },
expectedNullReason: 'the user has already dismissed this notice.',
},
};

Expand Down
Loading
Loading