mirror of
https://github.com/hanzo-apps/tabs.git
synced 2026-09-15 19:32:29 +00:00
This package ships its sources — `types` is `./index.ts` — so every consumer typechecks `components/workspace.tsx` in its own module graph. Two handlers took their parameter types from `YStack`'s props by inference, which resolves here and does not resolve there: one implicit `any` under a consumer's `noImplicitAny`, and its build fails on a file it does not own. Both are annotated now, from named imports rather than the global React namespace, for the same reason: a shipped source cannot assume what the consumer has in scope. 0.1.6. Co-authored-by: Hanzo Dev <[email protected]>
1342 lines
50 KiB
TypeScript
1342 lines
50 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* The terminal workspace — split, resize, sweep, and open new shells.
|
|
*
|
|
* Arrangement lives in `lib/tiles` (a tree, pure) and what a pane SHOWS lives in
|
|
* `lib/panes` (a binding, pure). This file turns those into boxes, and turns
|
|
* pointers back into rewrites. It decides nothing about layout.
|
|
*
|
|
* FIVE THINGS ARE NOT OBVIOUS, AND ALL FIVE WERE LEARNED THE HARD WAY.
|
|
*
|
|
* 1. THE PANES ARE A FLAT LIST IN A FIXED ORDER. Nesting them the way the tree
|
|
* nests remounts every <iframe> on any split, and each terminal reconnects.
|
|
* The order comes from `stableOrder` — append-only — because it once came from
|
|
* the sessions array, which the control plane sorts by RECENCY: a session
|
|
* touching its cwd could reorder the array and remount the whole page.
|
|
*
|
|
* 2. A DRAG NEEDS A SHEET. The pointer leaves the divider and immediately crosses
|
|
* an iframe, which swallows the event; the drag dies a few pixels in. A
|
|
* transparent overlay for the duration keeps the events on this document.
|
|
*
|
|
* 3. A TERMINAL PROVES ITSELF; IT IS NOT INFERRED. The rescue must be ON TOP of
|
|
* the frame — measured at eight viewports, `elementFromPoint` returned the
|
|
* iframe every time, so a way out underneath is no way out. And WHETHER to
|
|
* show it comes from the terminal saying `hanzo-term: ready`, never from
|
|
* reading the frame's DOM: a gate redirect ending at hanzo.id's
|
|
* `frame-ancestors 'none'` leaves `chrome-error://chromewebdata/`, which
|
|
* throws on contentDocument exactly like a healthy cross-origin load. Silence
|
|
* is the rescue; every way a frame can fail is the same silence.
|
|
*
|
|
* 4. A PHONE PAGES, IT DOES NOT TILE. 390px cannot hold two terminals and stay
|
|
* legible, so `pageGeometry` turns side-by-side splits into swipeable pages
|
|
* and keeps stacked ones stacked. Same tree, same renderer.
|
|
*
|
|
* 5. THE NARROW LAYOUT IS ONE MEASUREMENT, NOT A MEDIA QUERY. `paging` already
|
|
* asks the only question worth asking — can this box hold two terminals —
|
|
* and the chrome that used to answer it separately, at a viewport width,
|
|
* now reads the same value. gui emits nothing at all for a breakpoint prop
|
|
* without its optimizing compiler — measured, `$gtXs={{backgroundColor:
|
|
* 'red'}}` painted no red at 1440 and put no class on the element — so a
|
|
* second breakpoint written that way would have been silently absent
|
|
* rather than merely redundant.
|
|
*/
|
|
|
|
import {
|
|
type PointerEvent as ReactPointerEvent,
|
|
type ReactNode,
|
|
type SyntheticEvent,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import {
|
|
ArrowLeft,
|
|
ArrowRight,
|
|
Cloud,
|
|
Columns2,
|
|
Globe,
|
|
Loader2,
|
|
Minus,
|
|
Monitor,
|
|
Plus,
|
|
RotateCw,
|
|
Rows2,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { Button, Input, Paragraph, SizableText, XStack, YStack } from '@hanzo/ui';
|
|
|
|
import {
|
|
type TerminalHost,
|
|
} from '../lib/panes';
|
|
import {
|
|
type Dir,
|
|
type Geometry,
|
|
type Path,
|
|
type Tile,
|
|
closePane,
|
|
geometry,
|
|
pageGeometry,
|
|
pane,
|
|
paneIds,
|
|
setRatio,
|
|
splitPane,
|
|
stableOrder,
|
|
stackFor,
|
|
} from '../lib/tiles';
|
|
// One import, and it is a PURE READING of an error — no origin, no token, no
|
|
// request. The workspace still holds no credential; what it gains is the
|
|
// platform's own word for "no money", which is the one failure whose remedy
|
|
// differs from every other.
|
|
import { unfunded } from '../lib/api';
|
|
import {
|
|
DEADLINE,
|
|
DOT,
|
|
type Binding,
|
|
isReady,
|
|
proves,
|
|
label,
|
|
machineOf,
|
|
mintName,
|
|
rescued,
|
|
web,
|
|
restore,
|
|
} from '../lib/panes';
|
|
|
|
/** The stack's own props, taken from the stack. @hanzo/ui is the one import
|
|
* source, so a local alias beats reaching past it to @hanzo/gui for a type. */
|
|
type StackProps = React.ComponentProps<typeof YStack>;
|
|
/** What a caller may add to a Chip — a Button's props, not a Stack's. The two
|
|
* differ on `onClick` alone: @hanzo/ui redeclares it as the DOM handler a web
|
|
* caller actually writes, so spreading Stack props onto a Button conflicts
|
|
* there and nowhere else. Layout props reach it either way. */
|
|
type ButtonProps = React.ComponentProps<typeof Button>;
|
|
|
|
const STORE_KEY = 'hanzo.tabs.layout.v1';
|
|
|
|
interface Saved {
|
|
tree: Tile | null;
|
|
bind: Record<string, Binding>;
|
|
seq: number;
|
|
}
|
|
|
|
function load(): Saved | null {
|
|
if (typeof window === 'undefined') return null;
|
|
try {
|
|
const raw = window.localStorage.getItem(STORE_KEY);
|
|
const v = raw ? (JSON.parse(raw) as Saved) : null;
|
|
return v && v.bind ? v : null;
|
|
} catch {
|
|
return null; // a corrupt layout is no layout, never a crash
|
|
}
|
|
}
|
|
|
|
/**
|
|
* How big the type is, in every terminal at once.
|
|
*
|
|
* It is kept APART from the layout, under its own key, because they are answers
|
|
* to different questions: a layout is this workspace's arrangement and a person
|
|
* rearranges it all day, while type size is how their eyes work and should
|
|
* survive closing every pane they have.
|
|
*
|
|
* The terminal is a cross-origin frame, so this cannot be CSS — it is a message
|
|
* the pane's own page applies to its live terminal, on the reverse leg of the
|
|
* channel the readiness handshake already uses. Sending it on every change AND
|
|
* on every pane that reports ready is what makes one setting reach panes that
|
|
* open later, with nothing to remember.
|
|
*/
|
|
const SIZE_KEY = 'hanzo.tabs.size.v1';
|
|
const SIZE = { min: 8, max: 32, step: 1, default: 12 } as const;
|
|
|
|
function clampSize(n: number): number {
|
|
return Math.min(SIZE.max, Math.max(SIZE.min, Math.round(n)));
|
|
}
|
|
|
|
function loadSize(): number {
|
|
if (typeof window === 'undefined') return SIZE.default;
|
|
const n = Number(window.localStorage.getItem(SIZE_KEY));
|
|
return Number.isFinite(n) && n > 0 ? clampSize(n) : SIZE.default;
|
|
}
|
|
|
|
/** A pane's name bar is 28px, so every control in it is 24px and no larger —
|
|
* each pixel here is a row of terminal, times the number of panes. */
|
|
const BAR = 28;
|
|
const CHIP = 24;
|
|
|
|
/** The cover a pane wears when there is no terminal to look at. Six states used
|
|
* to draw this by hand, and the reason it has to be a component rather than a
|
|
* convention is #3 above: it sits ON TOP of the frame, and a way out underneath
|
|
* is no way out. */
|
|
function Cover({ children, ...rest }: StackProps) {
|
|
return (
|
|
<YStack
|
|
position="absolute"
|
|
inset={0}
|
|
zIndex={10}
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
paddingHorizontal="$4"
|
|
{...rest}
|
|
>
|
|
{children}
|
|
</YStack>
|
|
);
|
|
}
|
|
|
|
/** What a cover says. Narrow, small, quiet, centred — the pane is the subject. */
|
|
function Note({ children }: { children: ReactNode }) {
|
|
return (
|
|
<Paragraph maxWidth={320} size="$1" textAlign="center" color="var(--muted-foreground)">
|
|
{children}
|
|
</Paragraph>
|
|
);
|
|
}
|
|
|
|
/** A machine's state, as a dot. */
|
|
function Dot({ status }: { status: string }) {
|
|
return (
|
|
<YStack
|
|
aria-hidden
|
|
width={6}
|
|
height={6}
|
|
flexShrink={0}
|
|
borderRadius={9999}
|
|
backgroundColor={DOT[status] ?? DOT.offline}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/** One control in a pane's name bar. */
|
|
function Chip({
|
|
run,
|
|
title,
|
|
children,
|
|
...rest
|
|
}: { run: () => void; title: string; children: ReactNode } & ButtonProps) {
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
minWidth={CHIP}
|
|
minHeight={CHIP}
|
|
borderRadius={4}
|
|
title={title}
|
|
aria-label={title}
|
|
onPress={run}
|
|
{...rest}
|
|
>
|
|
{children}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A browser pane's chrome: where it is, and the way back.
|
|
*
|
|
* The address takes the place of the title rather than sitting in a second row.
|
|
* For a page the address IS the title, and a row of chrome costs every pane on
|
|
* screen the same pixels a row of terminal would have used.
|
|
*
|
|
* `web` decides what a typed string becomes, so a scheme we do not frame is
|
|
* refused here rather than reaching an iframe src.
|
|
*/
|
|
function Address({
|
|
url,
|
|
go,
|
|
disabled,
|
|
}: {
|
|
url: string;
|
|
go: (to: string) => void;
|
|
disabled?: boolean;
|
|
}) {
|
|
const [typed, setTyped] = useState<string | null>(null);
|
|
const [refused, setRefused] = useState(false);
|
|
|
|
// The field shows what is typed while someone is typing and what is LOADED
|
|
// otherwise, so a navigation started elsewhere is reflected and a half-typed
|
|
// address is never overwritten under the cursor.
|
|
const shown = typed ?? url;
|
|
|
|
const submit = () => {
|
|
const to = web(shown);
|
|
if (!to) {
|
|
setRefused(true);
|
|
return;
|
|
}
|
|
setRefused(false);
|
|
setTyped(null);
|
|
go(to);
|
|
};
|
|
|
|
return (
|
|
<Input
|
|
value={shown}
|
|
placeholder="Search or enter a URL"
|
|
aria-label="Address"
|
|
aria-invalid={refused || undefined}
|
|
spellCheck={false}
|
|
autoCapitalize="none"
|
|
autoCorrect="off"
|
|
disabled={disabled}
|
|
onChangeText={(t: string) => {
|
|
setTyped(t);
|
|
setRefused(false);
|
|
}}
|
|
onKeyPress={(e: { nativeEvent: { key: string } }) => {
|
|
if (e.nativeEvent.key === 'Enter') submit();
|
|
}}
|
|
onBlur={() => setTyped(null)}
|
|
flex={1}
|
|
minWidth={0}
|
|
height={CHIP}
|
|
borderRadius={4}
|
|
fontSize={12}
|
|
paddingHorizontal="$2"
|
|
borderColor={refused ? 'var(--destructive)' : 'var(--border)'}
|
|
backgroundColor="var(--background)"
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function Workspace({
|
|
hosts,
|
|
mint,
|
|
onLaunch,
|
|
start,
|
|
end,
|
|
}: {
|
|
hosts: TerminalHost[];
|
|
/** What sits at the ends of the action row — the brand, and the way out.
|
|
* They are the page's, not the workspace's, but they are three controls
|
|
* wide and a row of their own cost every pane 28px of terminal. A workspace
|
|
* is measured in rows you can read, so the row they belong in is this one. */
|
|
start?: ReactNode;
|
|
end?: ReactNode;
|
|
/** A fresh URL for a pane, for whatever that pane shows. EVERY pane, not only
|
|
* a sandbox's: a machine you linked and a machine we started differ in who
|
|
* serves the page and what credential opens it, and in nothing a layout cares
|
|
* about. The caller owns the token; the workspace only ever holds the URL. */
|
|
mint?: (host: TerminalHost, what: Binding) => Promise<string>;
|
|
/** Start a cloud machine of one class, and answer the name it goes by here. */
|
|
onLaunch?: (kind: 'dev' | 'desktop') => Promise<string>;
|
|
}) {
|
|
// A machine with no `base` serves no terminal, and there is nothing to open on
|
|
// one of those — except a sandbox, whose URL is minted on bind rather than
|
|
// published.
|
|
const live = useMemo(
|
|
() => hosts.filter((h) => (h.base || h.sandbox) && h.status !== 'offline'),
|
|
[hosts],
|
|
);
|
|
/** The live machines with a display. A linked machine has none to offer —
|
|
* what `hanzo link` publishes is a terminal — so this is the sandboxes that
|
|
* were started as desktops. */
|
|
const watchable = useMemo(() => live.filter((h) => h.screen), [live]);
|
|
|
|
const [tile, setTile] = useState<Tile | null>(null);
|
|
const [bind, setBind] = useState<Record<string, Binding>>({});
|
|
const seq = useRef(0);
|
|
const [ready, setReady] = useState(false);
|
|
|
|
// Restore, or open the first machine's default shell. A saved pane whose machine
|
|
// is gone is KEPT and marked — tmux is still holding that session, and a layout
|
|
// is the reader's, not a function of someone else's uptime.
|
|
useEffect(() => {
|
|
if (ready) return;
|
|
const saved = load();
|
|
if (saved?.tree) {
|
|
seq.current = saved.seq ?? 0;
|
|
const known = new Set(live.map((h) => h.machine));
|
|
setBind(
|
|
Object.fromEntries(
|
|
Object.entries(saved.bind).map(([id, b]) => [
|
|
id,
|
|
b.kind === 'shell' && !known.has(b.shell.machine)
|
|
? ({ kind: 'gone', shell: b.shell } as Binding)
|
|
: restore(b),
|
|
]),
|
|
),
|
|
);
|
|
setTile(saved.tree);
|
|
} else if (live[0]) {
|
|
const id = `p${seq.current++}`;
|
|
setBind({ [id]: { kind: 'shell', shell: { machine: live[0].machine, name: mintName([]) } } });
|
|
setTile(pane(id));
|
|
}
|
|
setReady(true);
|
|
}, [live, ready]);
|
|
|
|
useEffect(() => {
|
|
if (!ready) return;
|
|
try {
|
|
if (tile) {
|
|
window.localStorage.setItem(STORE_KEY, JSON.stringify({ tree: tile, bind, seq: seq.current }));
|
|
} else {
|
|
window.localStorage.removeItem(STORE_KEY);
|
|
}
|
|
} catch {
|
|
/* private mode or quota — a layout that cannot be saved still works today */
|
|
}
|
|
}, [tile, bind, ready]);
|
|
|
|
const ids = useMemo(() => (tile ? paneIds(tile) : []), [tile]);
|
|
const [order, setOrder] = useState<string[]>([]);
|
|
useEffect(() => setOrder((p) => stableOrder(p, ids)), [ids]);
|
|
|
|
const [focus, setFocus] = useState<string | null>(null);
|
|
useEffect(() => {
|
|
if (!focus || !ids.includes(focus)) setFocus(ids[0] ?? null);
|
|
}, [ids, focus]);
|
|
|
|
const hostOf = useCallback((m: string) => hosts.find((h) => h.machine === m), [hosts]);
|
|
|
|
/** Shell names already open on a machine, so a new one does not collide. */
|
|
const takenOn = useCallback(
|
|
(m: string) =>
|
|
Object.values(bind)
|
|
.filter(
|
|
(b): b is Extract<Binding, { kind: 'shell' }> =>
|
|
b.kind === 'shell' && b.shell.machine === m,
|
|
)
|
|
.map((b) => b.shell.name),
|
|
[bind],
|
|
);
|
|
|
|
/** What a machine looks like in a pane, each way of looking at one. Two
|
|
* functions and not a flag, because only one of them has a name to mint. */
|
|
const shellOn = useCallback(
|
|
(machine: string): Binding => ({
|
|
kind: 'shell',
|
|
shell: { machine, name: mintName(takenOn(machine)) },
|
|
}),
|
|
[takenOn],
|
|
);
|
|
const screenOn = useCallback((machine: string): Binding => ({ kind: 'screen', machine }), []);
|
|
|
|
/** Open a NEW pane showing `what`, beside `target` — or as the whole layout.
|
|
* Answers with the pane's id, so a caller still waiting on what goes in it
|
|
* can settle that one box later. */
|
|
const open = useCallback((what: Binding, dir: Dir | null, target: string | null) => {
|
|
const id = `p${seq.current++}`;
|
|
setBind((b) => ({ ...b, [id]: what }));
|
|
setTile((t) => (t && target && dir ? splitPane(t, target, dir, id) : (t ?? pane(id))));
|
|
setFocus(id);
|
|
return id;
|
|
}, []);
|
|
|
|
/** Put `what` in a pane that is still open. A pane closed while its machine
|
|
* was starting stays closed — an answer arriving late must not reopen a box
|
|
* someone shut. */
|
|
const settle = useCallback((id: string, what: Binding) => {
|
|
setBind((b) => (b[id] ? { ...b, [id]: what } : b));
|
|
}, []);
|
|
|
|
/** Start a machine and open it. The point of the button is what you get to
|
|
* look at: a machine that arrives with nothing framed on it is a row in a
|
|
* list, and you would have to go and ask for the thing you already asked for. */
|
|
const launch = useCallback(
|
|
(kind: 'dev' | 'desktop') => {
|
|
if (!onLaunch) return;
|
|
const want = kind === 'desktop' ? 'screen' : 'shell';
|
|
// The pane opens on the CLICK, not on the answer. Provisioning is the
|
|
// long part, so waiting for it before drawing anything is a workspace
|
|
// that sits unchanged for the whole minute you are waiting.
|
|
const id = open({ kind: 'starting', want }, focus ? 'row' : null, focus);
|
|
onLaunch(kind)
|
|
.then((machine) => settle(id, want === 'screen' ? screenOn(machine) : shellOn(machine)))
|
|
.catch((e) =>
|
|
settle(id, {
|
|
kind: 'failed',
|
|
why: e instanceof Error ? e.message : 'could not start a machine',
|
|
unfunded: unfunded(e),
|
|
}),
|
|
);
|
|
},
|
|
[onLaunch, open, settle, focus, shellOn, screenOn],
|
|
);
|
|
|
|
const doClose = useCallback((id: string) => {
|
|
setTile((t) => (t ? closePane(t, id) : null));
|
|
setBind((b) => {
|
|
const next = { ...b };
|
|
delete next[id];
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// ---- geometry: desktop tiles, phone pages ------------------------------
|
|
const boxRef = useRef<HTMLElement | null>(null);
|
|
const [box, setBox] = useState({ w: 0, h: 0 });
|
|
useEffect(() => {
|
|
const el = boxRef.current;
|
|
if (!el) return;
|
|
const ro = new ResizeObserver((entries) => {
|
|
const r = entries[0]!.contentRect;
|
|
setBox({ w: r.width, h: r.height });
|
|
});
|
|
ro.observe(el);
|
|
return () => ro.disconnect();
|
|
}, [ready]);
|
|
|
|
// The breakpoint is the ROOM, not the device: a phone in landscape and a narrow
|
|
// desktop window have the same problem, and neither can hold two terminals.
|
|
const paging = box.w > 0 && box.w < 640;
|
|
const paged = useMemo(
|
|
() => (tile && paging ? pageGeometry(tile, stackFor(box.h)) : null),
|
|
[tile, paging, box.h],
|
|
);
|
|
const geo: Geometry = useMemo(
|
|
() => paged ?? (tile ? geometry(tile) : { rects: [], dividers: [] }),
|
|
[paged, tile],
|
|
);
|
|
const placed = useMemo(() => new Map(geo.rects.map((r) => [r.id, r])), [geo]);
|
|
|
|
const pages = paged?.pages ?? 1;
|
|
const [page, setPage] = useState(0);
|
|
useEffect(() => setPage((p) => Math.min(p, pages - 1)), [pages]);
|
|
useEffect(() => {
|
|
// Following the focused pane keeps the strip and the view in agreement.
|
|
if (paged && focus && paged.page[focus] !== undefined) setPage(paged.page[focus]!);
|
|
}, [paged, focus]);
|
|
|
|
// ---- dragging a divider -------------------------------------------------
|
|
const [drag, setDrag] = useState<{ path: Path; dir: Dir } | null>(null);
|
|
useEffect(() => {
|
|
if (!drag) return;
|
|
const move = (e: PointerEvent) => {
|
|
const r = boxRef.current?.getBoundingClientRect();
|
|
if (!r || r.width === 0 || r.height === 0) return;
|
|
// In the paged projection the track is `pages * 100%` wide and shifted, so a
|
|
// horizontal drag is measured against ONE page, not the whole track.
|
|
const frac =
|
|
drag.dir === 'row'
|
|
? (e.clientX - r.left) / r.width
|
|
: (e.clientY - r.top) / r.height;
|
|
setTile((t) => (t ? setRatio(t, drag.path, frac) : t));
|
|
};
|
|
const up = () => setDrag(null);
|
|
window.addEventListener('pointermove', move);
|
|
window.addEventListener('pointerup', up);
|
|
return () => {
|
|
window.removeEventListener('pointermove', move);
|
|
window.removeEventListener('pointerup', up);
|
|
};
|
|
}, [drag]);
|
|
|
|
// Click-to-focus from INSIDE a frame: the click never reaches this document,
|
|
// but the window losing focus does, and activeElement is then the iframe.
|
|
useEffect(() => {
|
|
const onBlur = () =>
|
|
setTimeout(() => {
|
|
const el = document.activeElement;
|
|
if (el instanceof HTMLIFrameElement && el.dataset.pane) setFocus(el.dataset.pane);
|
|
}, 0);
|
|
window.addEventListener('blur', onBlur);
|
|
return () => window.removeEventListener('blur', onBlur);
|
|
}, []);
|
|
|
|
// A pane shows the rescue unless ITS OWN TERMINAL SAYS IT IS THERE.
|
|
//
|
|
// Inferring this from the DOM does not work, and shipped broken twice. The
|
|
// guess was that a refusal stays at `about:blank` — same-origin, readable,
|
|
// empty — while a real load throws on contentDocument. There is a third case
|
|
// and it is the COMMON one: the frame follows the tunnel to its OAuth gate,
|
|
// the gate redirects to hanzo.id, hanzo.id sends `frame-ancestors 'none'`, and
|
|
// Chrome swaps in `chrome-error://chromewebdata/`. That throws exactly like a
|
|
// successful load, so every genuinely-blocked terminal read as fine and the
|
|
// user got an opaque black rectangle with no way out.
|
|
//
|
|
// So stop inferring. Our terminal page posts `hanzo-term: ready` when xterm has
|
|
// booted (cli/assets/term/client.js), and that message is the only thing
|
|
// treated as proof. Silence past the deadline is the rescue, whatever the
|
|
// cause — gate, CSP, dead tunnel, offline machine. One signal, one meaning, and
|
|
// no case analysis to get wrong the next time a browser invents a fourth way to
|
|
// fail.
|
|
const [alive, setAlive] = useState<Record<string, boolean>>({});
|
|
const [waited, setWaited] = useState<Record<string, boolean>>({});
|
|
const frames = useRef<Record<string, HTMLIFrameElement | null>>({});
|
|
|
|
useEffect(() => {
|
|
const onMessage = (e: MessageEvent) => {
|
|
if (!isReady(e.data)) return;
|
|
// The sender identifies the pane: a message is trusted only when it comes
|
|
// from the window of a frame this workspace actually rendered. The origin
|
|
// is not pinned because every machine publishes on its own tunnel host.
|
|
for (const [id, el] of Object.entries(frames.current)) {
|
|
if (el && e.source === el.contentWindow) {
|
|
setAlive((a) => (a[id] ? a : { ...a, [id]: true }));
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
window.addEventListener('message', onMessage);
|
|
return () => window.removeEventListener('message', onMessage);
|
|
}, []);
|
|
|
|
// The reader's type size, told to every terminal that is up.
|
|
//
|
|
// It runs on `alive` as well as on the size itself, so a pane that boots after
|
|
// the setting was chosen is told as soon as it says it is ready — the same
|
|
// signal, used for the same reason, rather than a second notion of when a
|
|
// frame can be spoken to. A pane that never answers is never sent to, which is
|
|
// right: there is nothing there to hear it.
|
|
const [size, setSize] = useState(loadSize);
|
|
useEffect(() => {
|
|
window.localStorage.setItem(SIZE_KEY, String(size));
|
|
for (const [id, el] of Object.entries(frames.current)) {
|
|
if (alive[id]) el?.contentWindow?.postMessage({ source: 'hanzo-term', fontSize: size }, '*');
|
|
}
|
|
}, [size, alive]);
|
|
|
|
const probe = useCallback((id: string, el: HTMLIFrameElement | null) => {
|
|
frames.current[id] = el;
|
|
if (!el) return;
|
|
// Give the terminal a moment to boot before calling it absent — a rescue that
|
|
// flashes over every pane on every load is its own defect.
|
|
const t = setTimeout(() => setWaited((w) => (w[id] ? w : { ...w, [id]: true })), DEADLINE);
|
|
return () => clearTimeout(t);
|
|
}, []);
|
|
|
|
/**
|
|
* Where a browser pane has been, and where in that it is.
|
|
*
|
|
* Kept HERE because it cannot be read from the frame: a cross-origin
|
|
* document's `history` is not ours to touch, so `back()` on it throws. What
|
|
* this holds is the addresses this workspace navigated to — typed, or
|
|
* followed from one of our own affordances — and back and forward re-point
|
|
* the frame at one of them.
|
|
*
|
|
* The limit is worth stating rather than hiding: a link followed INSIDE the
|
|
* page is the page's own navigation and never reaches this trail, so back
|
|
* returns to the last address this workspace set, not to the last page the
|
|
* reader saw. A parent cannot observe the other kind at all.
|
|
*/
|
|
const [trail, setTrail] = useState<Record<string, { at: number; urls: string[] }>>({});
|
|
|
|
/** Go to an address in a pane, forgetting any forward history from here —
|
|
* which is what a new navigation means. */
|
|
const visit = useCallback((id: string, to: string) => {
|
|
setBind((s) => ({ ...s, [id]: { kind: 'page', url: to } }));
|
|
setTrail((t) => {
|
|
const cur = t[id] ?? { at: -1, urls: [] };
|
|
const urls = [...cur.urls.slice(0, cur.at + 1), to];
|
|
return { ...t, [id]: { at: urls.length - 1, urls } };
|
|
});
|
|
}, []);
|
|
|
|
/** Step through a pane's trail. A step past either end is not a step. */
|
|
const step = useCallback((id: string, by: number) => {
|
|
setTrail((t) => {
|
|
const cur = t[id];
|
|
if (!cur) return t;
|
|
const at = cur.at + by;
|
|
if (at < 0 || at >= cur.urls.length) return t;
|
|
setBind((s) => ({ ...s, [id]: { kind: 'page', url: cur.urls[at]! } }));
|
|
return { ...t, [id]: { ...cur, at } };
|
|
});
|
|
}, []);
|
|
|
|
/**
|
|
* Reload a pane's page.
|
|
*
|
|
* By assigning the frame's `src`, which a parent may do cross-origin — it is
|
|
* a navigation the parent initiates, not a read of the document. Re-keying
|
|
* the element would work too and would remount every sibling's frame, which
|
|
* is the one thing this layout is arranged to avoid.
|
|
*/
|
|
const reload = useCallback((id: string) => {
|
|
const el = frames.current[id];
|
|
if (el) el.src = el.src;
|
|
}, []);
|
|
|
|
// Which panes owe us a word that they came up. A page on the open web owes
|
|
// none, so its silence is not a failure to report.
|
|
const proving = useMemo(
|
|
() => Object.fromEntries(Object.entries(bind).map(([id, b]) => [id, proves(b)])),
|
|
[bind],
|
|
);
|
|
const refused = useMemo(() => rescued(waited, alive, proving), [waited, alive, proving]);
|
|
|
|
// A pick is "which machine", and what to MAKE of the one picked comes with
|
|
// the question — so the same picker asks for a shell's machine and a
|
|
// screen's, and neither knows about the other.
|
|
const [picking, setPicking] = useState<null | {
|
|
make: (m: string) => Binding;
|
|
from: TerminalHost[];
|
|
dir: Dir | null;
|
|
target: string | null;
|
|
}>(null);
|
|
const rendered = useMemo(() => order.filter((id) => bind[id]), [order, bind]);
|
|
|
|
// A PANE'S URL IS MINTED, NEVER DERIVED — for every machine, not only the
|
|
// ones we start. Minted per PANE and kept until that pane reconnects, because
|
|
// what the mint returns is spent: a sandbox's ticket by the frame's first
|
|
// load, a tunnel's session by nothing, but both are asked for with a
|
|
// credential this component deliberately does not hold. Re-deriving on render
|
|
// would hand the iframe a dead one.
|
|
//
|
|
// In-flight ids are tracked outside state so a re-render mid-mint cannot start
|
|
// a second mint for the same pane.
|
|
const [minted, setMinted] = useState<Record<string, string>>({});
|
|
const minting = useRef<Set<string>>(new Set());
|
|
useEffect(() => {
|
|
if (!mint) return;
|
|
for (const id of rendered) {
|
|
const b = bind[id];
|
|
const m = b && (b.kind === 'shell' || b.kind === 'screen') ? machineOf(b) : null;
|
|
if (!b || !m) continue;
|
|
const h = hostOf(m);
|
|
if (!h || minted[id] || minting.current.has(id)) continue;
|
|
minting.current.add(id);
|
|
mint(h, b)
|
|
.then((src) => setMinted((m) => ({ ...m, [id]: src })))
|
|
// A pane whose URL never arrived is offered the way out immediately,
|
|
// rather than waiting out a deadline for a frame that was never made.
|
|
.catch(() => setWaited((w) => ({ ...w, [id]: true })))
|
|
.finally(() => minting.current.delete(id));
|
|
}
|
|
}, [rendered, bind, hostOf, mint, minted]);
|
|
|
|
/** Reconnect a pane: forget the spent URL and the frame's history so the mint
|
|
* effect runs again with a fresh credential and the deadline re-arms. */
|
|
const reconnect = useCallback((id: string) => {
|
|
const drop = <T,>(o: Record<string, T>): Record<string, T> => {
|
|
const { [id]: _gone, ...rest } = o;
|
|
return rest;
|
|
};
|
|
setMinted(drop);
|
|
setAlive(drop);
|
|
setWaited(drop);
|
|
}, []);
|
|
|
|
if (live.length === 0 && !tile) {
|
|
return (
|
|
<YStack
|
|
height="100%"
|
|
width="100%"
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
gap="$3"
|
|
paddingHorizontal="$5"
|
|
borderRadius="$4"
|
|
borderWidth={1}
|
|
borderStyle="dashed"
|
|
borderColor="$borderColor"
|
|
>
|
|
<Paragraph maxWidth={384} size="$2" textAlign="center" color="var(--muted-foreground)">
|
|
No machine is serving terminals. Run{' '}
|
|
<SizableText render="code" size="$2" fontFamily="$mono" color="$color">
|
|
hanzo link
|
|
</SizableText>{' '}
|
|
on one and it appears here.
|
|
</Paragraph>
|
|
{/* The header is not rendered in this branch, and someone with no machine
|
|
at all is exactly who has nowhere else to get one. */}
|
|
{onLaunch ? (
|
|
<XStack alignItems="center" gap="$1.5">
|
|
<Act run={() => launch('dev')} icon={<Cloud size={14} />} label="New cloud machine" />
|
|
<Act run={() => launch('desktop')} icon={<Monitor size={14} />} label="New desktop" />
|
|
</XStack>
|
|
) : null}
|
|
</YStack>
|
|
);
|
|
}
|
|
|
|
/** Never make someone choose from a set of one. */
|
|
const openHere = (make: (m: string) => Binding, from: TerminalHost[], dir: Dir | null, target: string | null) => {
|
|
if (from.length === 1) open(make(from[0]!.machine), dir, target);
|
|
else setPicking({ make, from, dir, target });
|
|
};
|
|
|
|
/** A shell goes on any live machine. */
|
|
const openShell = (dir: Dir | null, target: string | null) => openHere(shellOn, live, dir, target);
|
|
|
|
/** A page needs no machine, so it needs no picker and no mint — the pane
|
|
* opens empty and the address is the whole of what it wants. */
|
|
const openPage = (dir: Dir | null, target: string | null) =>
|
|
open({ kind: 'page', url: '' }, dir, target);
|
|
|
|
/** A screen goes only where there IS one — and where there is none, the
|
|
* button starts the machine that has one. One press, one meaning: show me a
|
|
* desktop. */
|
|
const openScreen = (dir: Dir | null, target: string | null) =>
|
|
watchable.length ? openHere(screenOn, watchable, dir, target) : launch('desktop');
|
|
|
|
return (
|
|
<YStack height="100%" width="100%" gap="$1.5">
|
|
{/* One row. Actions, not a status report. */}
|
|
<XStack flexShrink={0} alignItems="center" gap="$1.5">
|
|
{start}
|
|
<Act
|
|
run={() => openShell(focus ? 'row' : null, focus)}
|
|
icon={<Plus size={14} />}
|
|
label="New shell"
|
|
/>
|
|
{/* One button for the screen, whether or not a machine with one exists
|
|
yet: with a desktop live it opens it, without one it starts it. The
|
|
alternative — a disabled button beside a second button that makes it
|
|
work — is two controls for one intention. */}
|
|
{watchable.length || onLaunch ? (
|
|
<Act
|
|
run={() => openScreen(focus ? 'row' : null, focus)}
|
|
icon={<Monitor size={14} />}
|
|
label="Desktop"
|
|
/>
|
|
) : null}
|
|
<Act
|
|
run={() => openPage(focus ? 'row' : null, focus)}
|
|
icon={<Globe size={14} />}
|
|
label="Browse"
|
|
/>
|
|
{onLaunch ? (
|
|
<Act
|
|
run={() => launch('dev')}
|
|
icon={<Cloud size={14} />}
|
|
label="New cloud machine"
|
|
/>
|
|
) : null}
|
|
<XStack marginLeft="auto" flexShrink={0} alignItems="center" gap="$1">
|
|
{/* Type size. Two buttons and the number they move — a stepper IS the
|
|
setting, so there is no panel to open and nothing to find. It reads
|
|
its own state, which a slider or a menu would each need a second
|
|
affordance to do. */}
|
|
<XStack
|
|
marginRight="$1"
|
|
alignItems="center"
|
|
borderRadius="$2"
|
|
borderWidth={1}
|
|
borderColor="$borderColor"
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
paddingHorizontal="$2"
|
|
onPress={() => setSize((n) => clampSize(n - SIZE.step))}
|
|
disabled={size <= SIZE.min}
|
|
title="Smaller text"
|
|
aria-label="Smaller text"
|
|
>
|
|
<Minus size={14} />
|
|
</Button>
|
|
<SizableText
|
|
size="$1"
|
|
minWidth={24}
|
|
textAlign="center"
|
|
userSelect="none"
|
|
style={{ fontVariantNumeric: 'tabular-nums' }}
|
|
render={<span title="Terminal text size" />}
|
|
>
|
|
{size}
|
|
</SizableText>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
paddingHorizontal="$2"
|
|
onPress={() => setSize((n) => clampSize(n + SIZE.step))}
|
|
disabled={size >= SIZE.max}
|
|
title="Larger text"
|
|
aria-label="Larger text"
|
|
>
|
|
<Plus size={14} />
|
|
</Button>
|
|
</XStack>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onPress={() => focus && openShell('row', focus)}
|
|
disabled={!focus}
|
|
title="Split right"
|
|
>
|
|
<Columns2 size={14} />
|
|
{paging ? null : <SizableText size="$1">Right</SizableText>}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onPress={() => focus && openShell('col', focus)}
|
|
disabled={!focus}
|
|
title="Split down"
|
|
>
|
|
<Rows2 size={14} />
|
|
{paging ? null : <SizableText size="$1">Down</SizableText>}
|
|
</Button>
|
|
{end}
|
|
</XStack>
|
|
</XStack>
|
|
|
|
{/* The phone's pager. One chip per PAGE, because pages are what you swipe. */}
|
|
{paging && pages > 1 ? (
|
|
<XStack flexShrink={0} gap="$1" style={{ overflowX: 'auto', overflowY: 'hidden' }}>
|
|
{Array.from({ length: pages }, (_, i) => {
|
|
const first = geo.rects.find((r) => Math.round(r.left / 100) === i);
|
|
const b = first ? bind[first.id] : undefined;
|
|
const name = b ? label(b) : String(i + 1);
|
|
return (
|
|
<Button
|
|
key={i}
|
|
variant="ghost"
|
|
size="sm"
|
|
maxWidth={160}
|
|
flexShrink={0}
|
|
backgroundColor={i === page ? 'var(--muted)' : 'transparent'}
|
|
onPress={() => {
|
|
setPage(i);
|
|
if (first) setFocus(first.id);
|
|
}}
|
|
>
|
|
<SizableText
|
|
size="$1"
|
|
numberOfLines={1}
|
|
ellipsis
|
|
color={i === page ? '$color' : 'var(--muted-foreground)'}
|
|
>
|
|
{name}
|
|
</SizableText>
|
|
</Button>
|
|
);
|
|
})}
|
|
</XStack>
|
|
) : null}
|
|
|
|
<YStack
|
|
// The box measures itself, and what measures is a DOM element — so the
|
|
// ref holds one, narrowed where it is set rather than asserted.
|
|
ref={(el) => {
|
|
boxRef.current = el instanceof HTMLElement ? el : null;
|
|
}}
|
|
position="relative"
|
|
width="100%"
|
|
flex={1}
|
|
minHeight={0}
|
|
overflow="hidden"
|
|
borderRadius="$4"
|
|
borderWidth={1}
|
|
borderColor="$borderColor"
|
|
backgroundColor="black"
|
|
>
|
|
<YStack
|
|
position="absolute"
|
|
inset={0}
|
|
style={
|
|
paging
|
|
? {
|
|
width: `${pages * 100}%`,
|
|
transform: `translate3d(-${(page * 100) / pages}%,0,0)`,
|
|
transition: drag ? 'none' : 'transform 220ms cubic-bezier(0.22,1,0.36,1)',
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
{rendered.map((id) => {
|
|
const r = placed.get(id);
|
|
if (!r) return null;
|
|
const b = bind[id]!;
|
|
const on = id === focus;
|
|
const machine = machineOf(b);
|
|
const host = machine ? hostOf(machine) : undefined;
|
|
const title = label(b);
|
|
// One source, whatever the machine. A pane that has no URL yet is a
|
|
// mint still in flight or one that could not be made — a tunnel
|
|
// publishes a terminal and nothing else, so a linked machine has no
|
|
// screen and never gets one.
|
|
// A page's url IS its binding — there is no credential to mint and
|
|
// nothing spent by framing it. Everything else waits for the mint.
|
|
const url = b.kind === 'page' ? b.url : (minted[id] ?? null);
|
|
// Every rect is page-relative; the track is `pages * 100%` wide, so a
|
|
// page occupies `100/pages` of it.
|
|
const left = paging ? r.left / pages : r.left;
|
|
const width = paging ? r.width / pages : r.width;
|
|
|
|
return (
|
|
<YStack
|
|
key={id}
|
|
onPointerDown={() => setFocus(id)}
|
|
position="absolute"
|
|
overflow="hidden"
|
|
style={{
|
|
left: `${left}%`,
|
|
top: `${r.top}%`,
|
|
width: `${width}%`,
|
|
height: `${r.height}%`,
|
|
}}
|
|
>
|
|
{/* A pane's name bar. It is chrome around the only thing on the
|
|
page worth reading, so it is as short as a touch target
|
|
allows and no shorter — every pixel here is a row of
|
|
terminal, times the number of panes. */}
|
|
<XStack
|
|
height={BAR}
|
|
flexShrink={0}
|
|
alignItems="center"
|
|
gap="$1.5"
|
|
paddingHorizontal="$2"
|
|
backgroundColor={on ? 'var(--muted)' : 'var(--card)'}
|
|
>
|
|
{b.kind === 'page' ? (
|
|
<>
|
|
<Chip
|
|
run={() => step(id, -1)}
|
|
title="Back"
|
|
disabled={(trail[id]?.at ?? 0) <= 0}
|
|
>
|
|
<ArrowLeft size={14} />
|
|
</Chip>
|
|
{paging ? null : (
|
|
<Chip
|
|
run={() => step(id, 1)}
|
|
title="Forward"
|
|
disabled={(trail[id]?.at ?? 0) >= (trail[id]?.urls.length ?? 1) - 1}
|
|
>
|
|
<ArrowRight size={14} />
|
|
</Chip>
|
|
)}
|
|
<Chip run={() => reload(id)} title="Reload">
|
|
<RotateCw size={14} />
|
|
</Chip>
|
|
<Address url={b.url} go={(to) => visit(id, to)} />
|
|
</>
|
|
) : (
|
|
<>
|
|
<Dot status={b.kind === 'gone' ? 'offline' : (host?.status ?? 'offline')} />
|
|
<SizableText
|
|
size="$1"
|
|
numberOfLines={1}
|
|
ellipsis
|
|
color={on ? '$color' : 'var(--muted-foreground)'}
|
|
>
|
|
{title}
|
|
</SizableText>
|
|
</>
|
|
)}
|
|
{paging ? null : (
|
|
<XStack marginLeft="auto" alignItems="center" gap="$1">
|
|
<Chip run={() => openShell('row', id)} title="Split right">
|
|
<Columns2 size={14} />
|
|
</Chip>
|
|
<Chip run={() => openShell('col', id)} title="Split down">
|
|
<Rows2 size={14} />
|
|
</Chip>
|
|
</XStack>
|
|
)}
|
|
{/* ONE bare button per row on a phone: globals.css grows every
|
|
button to 44px on a coarse pointer, so neighbours at a 24px
|
|
pitch overlap and the LAST wins — close was stealing taps
|
|
from split. */}
|
|
<Chip
|
|
run={() => doClose(id)}
|
|
title="Close pane"
|
|
marginLeft={paging ? 'auto' : 0}
|
|
>
|
|
<X size={paging ? 16 : 14} />
|
|
</Chip>
|
|
</XStack>
|
|
|
|
<YStack position="relative" flex={1} minHeight={0} backgroundColor="black">
|
|
{url ? (
|
|
<iframe
|
|
data-pane={id}
|
|
src={url}
|
|
title={title}
|
|
// Scripts (a terminal is one), and `allow-same-origin` gives
|
|
// the frame ITS OWN origin — which ttyd needs for its socket
|
|
// — never ours. What is withheld is top-navigation and popups.
|
|
sandbox="allow-scripts allow-same-origin allow-forms"
|
|
ref={(el) => probe(id, el)}
|
|
onLoad={(e: SyntheticEvent<HTMLIFrameElement>) =>
|
|
probe(id, e.currentTarget)
|
|
}
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
width: '100%',
|
|
height: '100%',
|
|
border: 0,
|
|
background: '#000',
|
|
filter: on ? undefined : 'brightness(0.65)',
|
|
}}
|
|
/>
|
|
) : null}
|
|
|
|
{b.kind === 'empty' ? (
|
|
<Cover padding="$3">
|
|
<Picker
|
|
hosts={live}
|
|
onPick={(m) =>
|
|
setBind((s) => ({
|
|
...s,
|
|
[id]: { kind: 'shell', shell: { machine: m, name: mintName(takenOn(m)) } },
|
|
}))
|
|
}
|
|
onCancel={() => doClose(id)}
|
|
/>
|
|
</Cover>
|
|
) : b.kind === 'starting' ? (
|
|
// The loader lives HERE, in the box the machine is for —
|
|
// not on the button that asked. It is what tells you the
|
|
// click landed, and it is where the terminal appears.
|
|
<Cover gap="$2">
|
|
<Loader2
|
|
size={20}
|
|
color="var(--muted-foreground)"
|
|
style={{ animation: 'spin 1s linear infinite' }}
|
|
/>
|
|
<Note>
|
|
Starting {b.want === 'screen' ? 'a desktop' : 'a machine'}. Its{' '}
|
|
{b.want === 'screen' ? 'screen' : 'terminal'} opens here.
|
|
</Note>
|
|
</Cover>
|
|
) : b.kind === 'failed' ? (
|
|
<Cover gap="$2">
|
|
<Note>{b.why}</Note>
|
|
{/* Out of credit is the ONE failure here with a remedy the
|
|
person already holds: a cloud machine is ours and costs
|
|
money, a linked one is theirs and costs nothing, and
|
|
tabs drives both identically. Saying only "insufficient
|
|
balance" beside a Close button reads as a dead end when
|
|
the workspace still works. */}
|
|
{b.unfunded ? (
|
|
<Note>
|
|
A cloud machine is ours and costs credit. One of yours costs nothing —
|
|
run{' '}
|
|
<SizableText render="code" size="$1" fontFamily="$mono" color="$color">
|
|
hanzo link
|
|
</SizableText>{' '}
|
|
on it and its shell opens here.
|
|
</Note>
|
|
) : null}
|
|
<Button variant="outline" size="sm" onPress={() => doClose(id)}>
|
|
Close pane
|
|
</Button>
|
|
</Cover>
|
|
) : b.kind === 'gone' ? (
|
|
<Cover gap="$2">
|
|
<Note>
|
|
<SizableText size="$1" color="$color">
|
|
{b.shell.machine}
|
|
</SizableText>{' '}
|
|
is offline. The{' '}
|
|
<SizableText size="$1" color="$color">
|
|
{b.shell.name}
|
|
</SizableText>{' '}
|
|
shell is still there — tmux is holding it.
|
|
</Note>
|
|
<Button variant="outline" size="sm" onPress={() => doClose(id)}>
|
|
Close pane
|
|
</Button>
|
|
</Cover>
|
|
) : b.kind === 'page' ? (
|
|
url ? null : (
|
|
<Cover gap="$2">
|
|
<Globe size={22} color="var(--muted-foreground)" />
|
|
<Note>Enter a URL above to open a page.</Note>
|
|
</Cover>
|
|
)
|
|
) : !url ? (
|
|
// No tunnel to frame — a box still coming up, or a link that
|
|
// stopped serving. Both mean wait, and NEITHER is the OAuth
|
|
// gate: without this the pane falls through to the rescue,
|
|
// which offers to sign you in to a terminal at `#`.
|
|
<Cover>
|
|
<Note>
|
|
Waiting for{' '}
|
|
<SizableText size="$1" color="$color">
|
|
{machine}
|
|
</SizableText>{' '}
|
|
to {b.kind === 'screen' ? 'show its screen' : 'serve a terminal'}. It
|
|
appears here as soon as the machine is up.
|
|
</Note>
|
|
</Cover>
|
|
) : refused[id] ? (
|
|
// A frame that never said ready is a credential that no
|
|
// longer opens anything — a spent ticket, an aged session —
|
|
// and the URL cannot be reopened, only asked for again.
|
|
// NEVER a sign-in here: the mint IS the sign-in, and sending
|
|
// someone to a second one is what this stopped doing.
|
|
<Cover gap="$3" backgroundColor="black">
|
|
<Note>
|
|
{b.kind === 'screen' ? 'The desktop' : 'The terminal'} did not come up.
|
|
Reconnecting asks for a fresh credential and tries again.
|
|
</Note>
|
|
<Button variant="outline" minHeight={44} onPress={() => reconnect(id)}>
|
|
Reconnect
|
|
</Button>
|
|
</Cover>
|
|
) : null}
|
|
</YStack>
|
|
</YStack>
|
|
);
|
|
})}
|
|
|
|
{geo.dividers.map((d) => {
|
|
const left = paging ? d.left / pages : d.left;
|
|
const width = paging ? d.width / pages : d.width;
|
|
// The hit area stays a thumb wide where a thumb is what lands on it.
|
|
const thick = d.dir === 'row' ? 12 : paging ? 24 : 12;
|
|
return (
|
|
<YStack
|
|
key={d.path.join('') || 'root'}
|
|
onPointerDown={(e: ReactPointerEvent<HTMLElement>) => {
|
|
e.preventDefault();
|
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
setDrag({ path: d.path, dir: d.dir });
|
|
}}
|
|
group
|
|
role="separator"
|
|
aria-orientation={d.dir === 'row' ? 'vertical' : 'horizontal'}
|
|
position="absolute"
|
|
zIndex={20}
|
|
backgroundColor="transparent"
|
|
cursor={d.dir === 'row' ? 'col-resize' : 'row-resize'}
|
|
x={d.dir === 'row' ? -thick / 2 : 0}
|
|
y={d.dir === 'row' ? 0 : -thick / 2}
|
|
style={{
|
|
touchAction: 'none',
|
|
left: `${left}%`,
|
|
top: `${d.top}%`,
|
|
width: d.dir === 'row' ? thick : `${width}%`,
|
|
height: d.dir === 'row' ? `${d.height}%` : thick,
|
|
}}
|
|
>
|
|
{/* A visible grabber: a touch divider you cannot see is one that
|
|
does not exist. What it LOOKS like and what it CATCHES are
|
|
separate — the hit area above stays a thumb wide, while the
|
|
mark is a hairline, because a seam between two terminals
|
|
should read as a seam and not as a third thing in the
|
|
window. It brightens on hover, where the pointer already
|
|
is. */}
|
|
<YStack
|
|
aria-hidden
|
|
pointerEvents="none"
|
|
position="absolute"
|
|
left="50%"
|
|
top="50%"
|
|
x={d.dir === 'row' ? -0.5 : -12}
|
|
y={d.dir === 'row' ? -12 : -0.5}
|
|
width={d.dir === 'row' ? 1 : 24}
|
|
height={d.dir === 'row' ? 24 : 1}
|
|
borderRadius={9999}
|
|
backgroundColor="var(--white-20)"
|
|
$group-hover={{ backgroundColor: 'var(--white-40)' }}
|
|
/>
|
|
</YStack>
|
|
);
|
|
})}
|
|
</YStack>
|
|
|
|
{drag ? (
|
|
<YStack
|
|
position="absolute"
|
|
inset={0}
|
|
zIndex={30}
|
|
cursor={drag.dir === 'row' ? 'col-resize' : 'row-resize'}
|
|
/>
|
|
) : null}
|
|
|
|
{picking ? (
|
|
<YStack
|
|
position="absolute"
|
|
inset={0}
|
|
zIndex={40}
|
|
alignItems="center"
|
|
justifyContent="center"
|
|
padding="$4"
|
|
backgroundColor="rgb(0 0 0 / 0.7)"
|
|
>
|
|
<Picker
|
|
hosts={picking.from}
|
|
onPick={(m) => {
|
|
open(picking.make(m), picking.dir, picking.target);
|
|
setPicking(null);
|
|
}}
|
|
onCancel={() => setPicking(null)}
|
|
/>
|
|
</YStack>
|
|
) : null}
|
|
</YStack>
|
|
</YStack>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* One labelled button in the action row.
|
|
*
|
|
* It carries no busy state and no failure of its own, and that is the point: a
|
|
* button that asks for a machine opens the pane the machine is for, so the wait
|
|
* and the reason it failed both belong to that pane. Keeping a spinner here too
|
|
* would say the same thing twice, in the one place you are not looking.
|
|
*/
|
|
function Act({ run, icon, label }: { run: () => void; icon: ReactNode; label: string }) {
|
|
return (
|
|
<Button variant="outline" size="sm" flexShrink={0} onPress={run}>
|
|
{icon}
|
|
<SizableText size="$1">{label}</SizableText>
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
/** Where should this shell run? Only ever shown when there is a real choice. */
|
|
function Picker({
|
|
hosts,
|
|
onPick,
|
|
onCancel,
|
|
}: {
|
|
hosts: TerminalHost[];
|
|
onPick: (machine: string) => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
return (
|
|
<YStack
|
|
width="100%"
|
|
maxWidth={320}
|
|
padding="$3"
|
|
borderRadius="$4"
|
|
borderWidth={1}
|
|
borderColor="$borderColor"
|
|
backgroundColor="var(--card)"
|
|
>
|
|
<SizableText marginBottom="$2" size="$1" color="var(--muted-foreground)">
|
|
Where should this shell run?
|
|
</SizableText>
|
|
<YStack render="ul" gap="$1">
|
|
{hosts.map((h) => (
|
|
<YStack key={h.machine} render="li">
|
|
<Button
|
|
variant="ghost"
|
|
width="100%"
|
|
minHeight={44}
|
|
justifyContent="flex-start"
|
|
gap="$2"
|
|
paddingHorizontal="$2"
|
|
onPress={() => onPick(h.machine)}
|
|
>
|
|
<Dot status={h.status} />
|
|
<SizableText size="$2" numberOfLines={1} ellipsis>
|
|
{h.machine}
|
|
</SizableText>
|
|
{h.label ? (
|
|
<SizableText
|
|
marginLeft="auto"
|
|
size="$1"
|
|
numberOfLines={1}
|
|
ellipsis
|
|
color="var(--muted-foreground)"
|
|
>
|
|
{h.label}
|
|
</SizableText>
|
|
) : null}
|
|
</Button>
|
|
</YStack>
|
|
))}
|
|
</YStack>
|
|
<Button variant="ghost" size="sm" width="100%" marginTop="$2" onPress={onCancel}>
|
|
<SizableText size="$1" color="var(--muted-foreground)">
|
|
Cancel
|
|
</SizableText>
|
|
</Button>
|
|
</YStack>
|
|
);
|
|
}
|
|
|
|
export default Workspace;
|