diff --git a/lib/src/stories/AppBar.stories.tsx b/lib/src/stories/AppBar.stories.tsx index 44cc60f..f0ff87f 100644 --- a/lib/src/stories/AppBar.stories.tsx +++ b/lib/src/stories/AppBar.stories.tsx @@ -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('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) { return (
@@ -32,6 +44,7 @@ export const SingleShell: Story = { args: { shells: [{ name: 'bash', path: '/bin/bash' }], }, + play: openShellSelector, }; export const ManyShells: Story = { @@ -44,4 +57,5 @@ export const ManyShells: Story = { { name: 'nu', path: '/usr/bin/nu' }, ], }, + play: openShellSelector, }; diff --git a/lib/src/stories/SelectionOverlay.stories.tsx b/lib/src/stories/SelectionOverlay.stories.tsx index 3924b55..7b45f12 100644 --- a/lib/src/stories/SelectionOverlay.stories.tsx +++ b/lib/src/stories/SelectionOverlay.stories.tsx @@ -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(initialMode); - const containerRef = useRef(null); - const [size, setSize] = useState({ width: 484, height: 284 }); +function SelectionOverlayStory({ + id, + selection, + hintToken = null, +}: { + id: string; + selection: Omit; + hintToken?: TokenHint | null; +}) { + const terminalHostRef = useRef(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; + + 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 ( -
- {/* Simulated terminal content */} -
-
user@mouseterm:~$ ls -la
-
total 48
-
drwxr-xr-x 12 user staff 384 Mar 16 10:30 .
-
- {/* Selection overlay */} - {mode === 'command' ? ( -
- -
- ) : ( -
- )} - {/* Mode toggle */} -
- - -
+
+
+
); } -const meta: Meta = { +const meta: Meta = { title: 'Components/SelectionOverlay', - component: SelectionOverlayDemo, + component: SelectionOverlayStory, + parameters: { + fakePty: { scenario: flattenScenario(SCENARIO_LS_OUTPUT) }, + }, }; export default meta; -type Story = StoryObj; +type Story = StoryObj; + +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', + }, + }, }; diff --git a/lib/src/stories/TerminalPaneHeader.stories.tsx b/lib/src/stories/TerminalPaneHeader.stories.tsx index 1eaa8e6..488dda0 100644 --- a/lib/src/stories/TerminalPaneHeader.stories.tsx +++ b/lib/src/stories/TerminalPaneHeader.stories.tsx @@ -92,13 +92,6 @@ async function openAlertRightClickDialog() { await wait(100); } -async function clickTodoPill() { - await wait(100); - const todoButton = document.querySelector(`[data-session-todo-for="${SESSION_ID}"]`); - todoButton?.click(); - await wait(100); -} - const meta: Meta = { title: 'Components/TerminalPaneHeader', component: TabStory, @@ -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', diff --git a/lib/src/stories/UpdateBanner.stories.tsx b/lib/src/stories/UpdateBanner.stories.tsx index 29f9021..f42ace8 100644 --- a/lib/src/stories/UpdateBanner.stories.tsx +++ b/lib/src/stories/UpdateBanner.stories.tsx @@ -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 (
console.log('Open changelog')} onOpenDebug={() => console.log('Open debug')} /> + {expectedNullReason ? ( +
+ Expected empty banner: {expectedNullReason} +
+ ) : null}
); } @@ -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.', }, }; diff --git a/lib/src/stories/Wall.stories.tsx b/lib/src/stories/Wall.stories.tsx index f961013..751c6ca 100644 --- a/lib/src/stories/Wall.stories.tsx +++ b/lib/src/stories/Wall.stories.tsx @@ -5,9 +5,8 @@ import { SCENARIO_SHELL_PROMPT, SCENARIO_LS_OUTPUT, SCENARIO_ANSI_COLORS, - SCENARIO_LONG_RUNNING, } from '../lib/platform'; -import { getActivitySnapshot, primeActivity, type ActivityState } from '../lib/terminal-registry'; +import type { ActivityState } from '../lib/terminal-registry'; const meta: Meta = { title: 'App/Wall', @@ -21,14 +20,12 @@ function wait(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } -function primeByIndex(states: Partial[]) { - const ids = [...getActivitySnapshot().keys()]; - states.forEach((state, index) => { - const id = ids[index]; - if (id) { - primeActivity(id, state); - } - }); +function withPrimedActivity(byId: Record>) { + return { + primedSessionState: { + byId, + }, + }; } async function splitPanes() { @@ -47,6 +44,13 @@ async function minimizeSelectedPane() { await wait(150); } +async function minimizeFirstVisiblePane() { + await wait(100); + const button = document.querySelector('button[aria-label="Minimize"]'); + button?.click(); + await wait(200); +} + async function openAlertDialog() { await wait(250); const alertButton = document.querySelector('[data-alert-button-for]'); @@ -71,153 +75,133 @@ export const MultiPane: Story = { play: splitPanes, }; -export const MultiPaneDark: Story = { - parameters: { fakePty: { scenario: flattenScenario(SCENARIO_LS_OUTPUT) } }, - globals: { theme: 'GitHub Dark Default' }, - play: splitPanes, -}; - -export const MultiPaneLight: Story = { - parameters: { fakePty: { scenario: flattenScenario(SCENARIO_LS_OUTPUT) } }, - globals: { theme: 'GitHub Light Default' }, - play: splitPanes, -}; - export const WithDoors: Story = { parameters: { fakePty: { scenario: flattenScenario(SCENARIO_LS_OUTPUT) } }, play: async () => { await splitPanes(); - await minimizeSelectedPane(); - }, -}; - -export const MarketingDemo: Story = { - parameters: { fakePty: { scenario: SCENARIO_LONG_RUNNING } }, - play: async () => { - await wait(1_500); - window.dispatchEvent(new KeyboardEvent('keydown', { key: '"', bubbles: true })); - await wait(1_000); - window.dispatchEvent(new KeyboardEvent('keydown', { key: '%', bubbles: true })); + await minimizeFirstVisiblePane(); + await minimizeFirstVisiblePane(); }, }; export const AlertEnabledIdlePane: Story = { + args: { + initialPaneIds: ['wall-alert-enabled'], + }, parameters: { fakePty: { scenario: flattenScenario(SCENARIO_SHELL_PROMPT) }, - primedSessionState: { - byIndex: [ - { - status: 'NOTHING_TO_SHOW', - - todo: false, - }, - ], - }, + ...withPrimedActivity({ + 'wall-alert-enabled': { + status: 'NOTHING_TO_SHOW', + todo: false, + }, + }), }, }; export const AlertRingingPane: Story = { + args: { + initialPaneIds: ['wall-alert-ringing'], + }, parameters: { fakePty: { scenario: flattenScenario(SCENARIO_SHELL_PROMPT) }, - primedSessionState: { - byIndex: [ - { - status: 'ALERT_RINGING', - - todo: false, - }, - ], - }, + ...withPrimedActivity({ + 'wall-alert-ringing': { + status: 'ALERT_RINGING', + todo: false, + }, + }), }, }; export const AlertRingingDoor: Story = { - parameters: { fakePty: { scenario: flattenScenario(SCENARIO_SHELL_PROMPT) } }, - play: async () => { - await minimizeSelectedPane(); - primeByIndex([ - { + args: { + initialPaneIds: ['wall-alert-ringing-door'], + }, + parameters: { + fakePty: { scenario: flattenScenario(SCENARIO_SHELL_PROMPT) }, + ...withPrimedActivity({ + 'wall-alert-ringing-door': { status: 'ALERT_RINGING', - todo: false, }, - ]); + }), + }, + play: async () => { + await minimizeSelectedPane(); await wait(100); }, }; export const AlertModalOpen: Story = { + args: { + initialPaneIds: ['wall-alert-modal'], + }, parameters: { fakePty: { scenario: flattenScenario(SCENARIO_SHELL_PROMPT) }, - primedSessionState: { - byIndex: [ - { - status: 'ALERT_RINGING', - - todo: false, - }, - ], - }, + ...withPrimedActivity({ + 'wall-alert-modal': { + status: 'ALERT_RINGING', + todo: false, + }, + }), }, play: openAlertDialog, }; export const TodoAfterDismiss: Story = { + args: { + initialPaneIds: ['wall-todo-after-dismiss'], + }, parameters: { fakePty: { scenario: flattenScenario(SCENARIO_SHELL_PROMPT) }, - primedSessionState: { - byIndex: [ - { - status: 'ALERT_RINGING', - - todo: true, - }, - ], - }, + ...withPrimedActivity({ + 'wall-todo-after-dismiss': { + status: 'ALERT_RINGING', + todo: true, + }, + }), }, }; export const MinimizedRingingSession: Story = { - parameters: { fakePty: { scenario: flattenScenario(SCENARIO_SHELL_PROMPT) } }, - play: async () => { - await minimizeSelectedPane(); - primeByIndex([ - { + args: { + initialPaneIds: ['wall-minimized-ringing'], + }, + parameters: { + fakePty: { scenario: flattenScenario(SCENARIO_SHELL_PROMPT) }, + ...withPrimedActivity({ + 'wall-minimized-ringing': { status: 'ALERT_RINGING', - todo: true, }, - { - status: 'NOTHING_TO_SHOW', - - todo: false, - }, - ]); + }), + }, + play: async () => { + await minimizeSelectedPane(); await wait(100); }, }; export const MultipleRingingSessions: Story = { - parameters: { fakePty: { scenario: flattenScenario(SCENARIO_SHELL_PROMPT) } }, - play: async () => { - await splitPanes(); - primeByIndex([ - { + args: { + initialPaneIds: ['wall-ringing-one', 'wall-ringing-todo', 'wall-alert-enabled-idle'], + }, + parameters: { + fakePty: { scenario: flattenScenario(SCENARIO_SHELL_PROMPT) }, + ...withPrimedActivity({ + 'wall-ringing-one': { status: 'ALERT_RINGING', - todo: false, }, - { + 'wall-ringing-todo': { status: 'ALERT_RINGING', - todo: true, }, - { + 'wall-alert-enabled-idle': { status: 'NOTHING_TO_SHOW', - todo: false, }, - ]); - await wait(100); + }), }, };