mirror of
https://github.com/hanzo-apps/tabs.git
synced 2026-09-15 19:32:29 +00:00
A pane can be a browser
tabs framed two of a machine's faces — its shells and its screen — and the web was the one surface you had to leave for. A pane can now be a page: same tree, same splits, same phone paging, with the address where the title goes because for a page the address IS the title. It is the only pane with no machine, so there is nothing to link, nothing to start and no credential to mint. The url is the binding, which is also why it survives a reload. Two rules the other kinds did not need. A typed address reaches an iframe src, where javascript: and data: are script in this document rather than a page in the frame, so the scheme is checked and everything but http and https is refused. And silence now means "did not come up" only for a pane that promised to speak: a site on the open web never posts hanzo-term, so judged by the old rule every browser pane was covered by a rescue six seconds in, offering to reconnect a page already working. Back and forward walk a trail this workspace keeps, because a cross-origin document's history is not ours to touch. A link followed inside the page is the page's own navigation and never reaches that trail — a parent cannot observe that kind at all. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+191
-4
@@ -43,8 +43,21 @@
|
||||
*/
|
||||
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Cloud, Columns2, Loader2, Minus, Monitor, Plus, Rows2, X } from 'lucide-react';
|
||||
import { Button, Paragraph, SizableText, XStack, YStack } from '@hanzo/ui';
|
||||
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 Dir,
|
||||
@@ -71,10 +84,12 @@ import {
|
||||
DOT,
|
||||
type Binding,
|
||||
isReady,
|
||||
proves,
|
||||
label,
|
||||
machineOf,
|
||||
mintName,
|
||||
rescued,
|
||||
web,
|
||||
restore,
|
||||
} from '@/lib/panes';
|
||||
|
||||
@@ -216,6 +231,74 @@ function Chip({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -514,7 +597,65 @@ export function Workspace({
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
const refused = useMemo(() => rescued(waited, alive), [waited, alive]);
|
||||
/**
|
||||
* 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
|
||||
@@ -610,6 +751,11 @@ export function Workspace({
|
||||
/** 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. */
|
||||
@@ -637,6 +783,11 @@ export function Workspace({
|
||||
label="Desktop"
|
||||
/>
|
||||
) : null}
|
||||
<Act
|
||||
run={() => openPage(focus ? 'row' : null, focus)}
|
||||
icon={<Globe size={14} />}
|
||||
label="Browse"
|
||||
/>
|
||||
{onLaunch ? (
|
||||
<Act
|
||||
run={() => launch('dev')}
|
||||
@@ -788,7 +939,9 @@ export function Workspace({
|
||||
// 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.
|
||||
const url = minted[id] ?? null;
|
||||
// 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;
|
||||
@@ -819,6 +972,31 @@ export function Workspace({
|
||||
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"
|
||||
@@ -828,6 +1006,8 @@ export function Workspace({
|
||||
>
|
||||
{title}
|
||||
</SizableText>
|
||||
</>
|
||||
)}
|
||||
{paging ? null : (
|
||||
<XStack marginLeft="auto" alignItems="center" gap="$1">
|
||||
<Chip run={() => openShell('row', id)} title="Split right">
|
||||
@@ -942,6 +1122,13 @@ export function Workspace({
|
||||
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
|
||||
|
||||
+63
-4
@@ -31,6 +31,14 @@ export type Binding =
|
||||
/** The machine's SCREEN — its X display, as pixels. No name, because a
|
||||
* machine has one display where it has as many shells as you ask for. */
|
||||
| { kind: 'screen'; machine: string }
|
||||
/**
|
||||
* A WEB PAGE, at a url.
|
||||
*
|
||||
* The one binding that is not a machine's face, so there is nothing to link,
|
||||
* nothing to start and nothing to mint: the url IS the binding, and it
|
||||
* survives a reload the way a shell name does.
|
||||
*/
|
||||
| { kind: 'page'; url: string; }
|
||||
/** Split made, machine not chosen yet. */
|
||||
| { kind: 'empty' }
|
||||
/**
|
||||
@@ -62,6 +70,8 @@ export function label(b: Binding): string {
|
||||
return `${b.shell.machine} · ${b.shell.name}`;
|
||||
case 'screen':
|
||||
return `${b.machine} · desk`;
|
||||
case 'page':
|
||||
return host(b.url) || 'New tab';
|
||||
case 'starting':
|
||||
return b.want === 'screen' ? 'Starting a desktop' : 'Starting a machine';
|
||||
case 'failed':
|
||||
@@ -143,6 +153,54 @@ export function shellUrl(base: string, name: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a typed address becomes, or null if it cannot become a page.
|
||||
*
|
||||
* Two jobs, and the second is the one that matters. A person types
|
||||
* `news.ycombinator.com` and means https, so a bare host gets a scheme. And a
|
||||
* typed string reaches an `iframe src`, where `javascript:` and `data:` are
|
||||
* script in THIS document rather than a page in the frame — so the scheme is
|
||||
* checked against http and https and nothing else is admitted. Refusing is the
|
||||
* answer: a browser pane that quietly loaded something else would be worse than
|
||||
* one that says it cannot.
|
||||
*/
|
||||
export function web(typed: string): string | null {
|
||||
const raw = typed.trim();
|
||||
if (!raw) return null;
|
||||
const guessed = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(raw) ? raw : `https://${raw}`;
|
||||
try {
|
||||
const u = new URL(guessed);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:' ? u.toString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The host of a url, for a tab to name itself by. `www.` comes off because a
|
||||
* tab strip is narrow and the four characters carry nothing. */
|
||||
export function host(url: string): string {
|
||||
try {
|
||||
return new URL(url).host.replace(/^www\./, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a pane is one of OURS, and so owes us a word that it came up.
|
||||
*
|
||||
* The readiness contract below is a conversation between this workspace and
|
||||
* pages we serve. A page on the open web is in no such conversation: it will
|
||||
* never post `hanzo-term`, so a rescue keyed on silence covers every browser
|
||||
* pane six seconds after it loads — offering to reconnect a site that is
|
||||
* already on screen and working.
|
||||
*
|
||||
* So silence means "did not come up" only for a pane that promised to speak.
|
||||
*/
|
||||
export function proves(b: Binding): boolean {
|
||||
return b.kind === 'shell' || b.kind === 'screen' || b.kind === 'gone';
|
||||
}
|
||||
|
||||
/** Status → the colour of the dot. The ONE map: session statuses first, then a
|
||||
* machine's.
|
||||
*
|
||||
@@ -206,16 +264,17 @@ export function isReady(data: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Panes that should show the way out: waited past the deadline, never heard from.
|
||||
* A pane that has not waited yet is absent, not false — it is still loading, and
|
||||
* nothing should be drawn over it. */
|
||||
/** Panes that should show the way out: waited past the deadline, never heard
|
||||
* from, and OURS to hear from (see `proves`). A pane that has not waited yet is
|
||||
* absent, not false — it is still loading, and nothing should be drawn over it. */
|
||||
export function rescued(
|
||||
waited: Record<string, boolean>,
|
||||
alive: Record<string, boolean>,
|
||||
proving: Record<string, boolean>,
|
||||
): Record<string, boolean> {
|
||||
return Object.fromEntries(
|
||||
Object.keys(waited)
|
||||
.filter((id) => waited[id])
|
||||
.filter((id) => waited[id] && proving[id])
|
||||
.map((id) => [id, !alive[id]]),
|
||||
);
|
||||
}
|
||||
|
||||
+74
-5
@@ -9,8 +9,10 @@ import {
|
||||
mintName,
|
||||
rescued,
|
||||
restore,
|
||||
proves,
|
||||
safeName,
|
||||
shellUrl,
|
||||
web,
|
||||
} from '@/lib/panes';
|
||||
|
||||
/**
|
||||
@@ -101,18 +103,18 @@ describe('a framed page announces itself, and silence is the rescue', () => {
|
||||
it('leaves a pane alone until its terminal has had time to boot', () => {
|
||||
// Nothing waited yet: still connecting. Drawing a rescue here would flash it
|
||||
// over every pane on every load.
|
||||
expect(rescued({}, {})).toEqual({});
|
||||
expect(rescued({ p0: false }, {})).toEqual({});
|
||||
expect(rescued({}, {}, {})).toEqual({});
|
||||
expect(rescued({ p0: false }, {}, { p0: true })).toEqual({});
|
||||
});
|
||||
|
||||
it('rescues a pane that waited and never spoke', () => {
|
||||
expect(rescued({ p0: true }, {})).toEqual({ p0: true });
|
||||
expect(rescued({ p0: true }, { p0: true })).toEqual({ p0: false });
|
||||
expect(rescued({ p0: true }, {}, { p0: true })).toEqual({ p0: true });
|
||||
expect(rescued({ p0: true }, { p0: true }, { p0: true })).toEqual({ p0: false });
|
||||
});
|
||||
|
||||
it('judges each pane on its own terminal', () => {
|
||||
// One machine gated, one fine — the gated pane alone gets the way out.
|
||||
expect(rescued({ p0: true, p1: true }, { p1: true })).toEqual({ p0: true, p1: false });
|
||||
expect(rescued({ p0: true, p1: true }, { p1: true }, { p0: true, p1: true })).toEqual({ p0: true, p1: false });
|
||||
});
|
||||
|
||||
it('waits long enough that a slow connect is not called dead', () => {
|
||||
@@ -197,3 +199,70 @@ describe('a pane can be waiting for a machine', () => {
|
||||
expect(restore(gone)).toBe(gone);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* A browser pane frames the open web, which makes it the one pane whose
|
||||
* address comes from a person's keyboard rather than from a mint. Two rules
|
||||
* follow, and both are here.
|
||||
*/
|
||||
describe('a browser pane', () => {
|
||||
it('takes a bare host to mean https', () => {
|
||||
expect(web('news.ycombinator.com')).toBe('https://news.ycombinator.com/');
|
||||
expect(web(' hanzo.ai ')).toBe('https://hanzo.ai/');
|
||||
});
|
||||
|
||||
it('keeps a scheme that was typed', () => {
|
||||
expect(web('http://localhost:3000/x')).toBe('http://localhost:3000/x');
|
||||
expect(web('https://hanzo.ai/v1')).toBe('https://hanzo.ai/v1');
|
||||
});
|
||||
|
||||
it('REFUSES a scheme it will not frame', () => {
|
||||
// These reach an iframe src, where they are script in THIS document rather
|
||||
// than a page in the frame. Refusing is the answer: a browser pane that
|
||||
// quietly loaded one would be an injection with a text field in front of it.
|
||||
expect(web('javascript:alert(1)')).toBeNull();
|
||||
expect(web('JavaScript:alert(1)')).toBeNull();
|
||||
expect(web('data:text/html,<script>alert(1)</script>')).toBeNull();
|
||||
expect(web('vbscript:msgbox(1)')).toBeNull();
|
||||
expect(web('file:///etc/passwd')).toBeNull();
|
||||
expect(web('blob:https://hanzo.ai/x')).toBeNull();
|
||||
});
|
||||
|
||||
it('has nothing to open for nothing typed', () => {
|
||||
expect(web('')).toBeNull();
|
||||
expect(web(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('names itself by its host', () => {
|
||||
expect(label({ kind: 'page', url: 'https://www.hanzo.ai/x?y=1' })).toBe('hanzo.ai');
|
||||
expect(label({ kind: 'page', url: '' })).toBe('New tab');
|
||||
});
|
||||
|
||||
it('is on no machine', () => {
|
||||
expect(machineOf({ kind: 'page', url: 'https://hanzo.ai' })).toBeNull();
|
||||
});
|
||||
|
||||
it('comes back on the address it was left on', () => {
|
||||
// Nothing was running, so nothing is stale: a url survives a reload the way
|
||||
// a shell name does.
|
||||
const page = { kind: 'page' as const, url: 'https://hanzo.ai' };
|
||||
expect(restore(page)).toEqual(page);
|
||||
});
|
||||
|
||||
it('owes this workspace no word that it came up', () => {
|
||||
// A page on the open web will never post `hanzo-term`. Judged by its
|
||||
// silence it would be covered by a rescue six seconds after loading,
|
||||
// offering to reconnect a site already on screen and working.
|
||||
expect(proves({ kind: 'page', url: 'https://hanzo.ai' })).toBe(false);
|
||||
expect(proves({ kind: 'shell', shell: { machine: 'm', name: 'shell-1' } })).toBe(true);
|
||||
expect(proves({ kind: 'screen', machine: 'm' })).toBe(true);
|
||||
});
|
||||
|
||||
it('is never rescued, however long it stays quiet', () => {
|
||||
const waited = { page: true, term: true };
|
||||
const alive = {};
|
||||
const proving = { page: false, term: true };
|
||||
expect(rescued(waited, alive, proving)).toEqual({ term: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,7 +119,11 @@ describe('the workspace mints once per pane', () => {
|
||||
// published tunnel for a linked machine — which is what left the linked
|
||||
// one framing a URL nobody had authorised, and a person staring at a
|
||||
// second sign-in. Both are minted now, so there is one path to be right.
|
||||
expect(src).toContain('const url = minted[id] ?? null;');
|
||||
//
|
||||
// A page is the one pane with no machine and so no credential: its url IS
|
||||
// its binding. That is a different question, not a second answer to this
|
||||
// one — every pane that has a machine still reads the mint and nothing else.
|
||||
expect(src).toContain("b.kind === 'page' ? b.url : (minted[id] ?? null)");
|
||||
expect(src).not.toContain('shellUrl(');
|
||||
expect(src).not.toMatch(/host\?\.sandbox\s*\?/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user