@hanzo/build 0.2.54 → 0.2.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/desk.js +3 -1
- package/lib/host.d.ts +49 -1
- package/lib/host.js +8 -0
- package/lib/index.d.ts +3 -3
- package/lib/index.js +2 -2
- package/lib/panel.d.ts +61 -15
- package/lib/panel.js +290 -32
- package/lib/prompt.d.ts +4 -2
- package/lib/prompt.js +48 -15
- package/lib/run.js +1 -1
- package/lib/tabs.d.ts +23 -2
- package/lib/tabs.js +64 -6
- package/package.json +1 -1
package/lib/desk.js
CHANGED
|
@@ -67,7 +67,9 @@ export function Desk({ id, repo, branch, base, environment, mode, pr, title, pro
|
|
|
67
67
|
render: () => (_jsxs(YStack, { gap: "$2", children: [_jsx(Environment, { repo: name, busy: live && mode === 'setup' }), _jsx(SizableText, { size: "$2", color: "$ink", pt: "$4", children: "This run" }), _jsx(Fact, { label: "Run", value: id }), _jsx(Fact, { label: "Repository", value: name || '—' }), _jsx(Fact, { label: "Branch", value: branch || '—' }), _jsx(Fact, { label: "Base", value: base || '—' }), _jsx(Fact, { label: "Runs on", value: environment || 'sandbox' }), _jsx(Fact, { label: "Sandbox", value: sandbox || '—' }), _jsx(Fact, { label: "Mode", value: mode || 'build' })] })),
|
|
68
68
|
},
|
|
69
69
|
];
|
|
70
|
-
return (_jsx(Panel, { side: side, kinds: kinds, sheet: sheet, onHide: onHide, label: "Run details",
|
|
70
|
+
return (_jsx(Panel, { side: side, kinds: kinds, sheet: sheet, onHide: onHide, label: "Run details",
|
|
71
|
+
// An empty tab offers what this run made: its site and its pull request.
|
|
72
|
+
suggested: [...(site ? [{ title: name || site, url: site }] : []), ...(pr.href ? [{ title: `Pull request ${pr.label}`, url: pr.href }] : [])], bar: sandbox ? _jsx(Held, { box: box, live: live }) : null, menu: _jsx(DropdownMenu, { trigger: _jsx(XStack, { render: "button", "aria-label": "Run actions", p: "$1.5", rounded: "$2", hoverStyle: { bg: '$hover' }, children: _jsx(MoreHorizontal, { size: 16 }) }), items: [...menu, { key: 'details', label: 'Details', icon: _jsx(Info, { size: 16 }), onSelect: environ }] }) }));
|
|
71
73
|
}
|
|
72
74
|
/**
|
|
73
75
|
* The run's sandbox as it stands, and what moves it. It is read while the pane is
|
package/lib/host.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* session, a router and a rail). Everything that differs between them is here,
|
|
8
8
|
* as values, so no host forks the builder and the builder imports no host.
|
|
9
9
|
*/
|
|
10
|
-
import { type ReactNode } from 'react';
|
|
10
|
+
import { type ComponentType, type ReactNode } from 'react';
|
|
11
11
|
import type { Target } from './api/call.ts';
|
|
12
12
|
import type { Theme } from './api/pref.ts';
|
|
13
13
|
export interface Person {
|
|
@@ -16,6 +16,46 @@ export interface Person {
|
|
|
16
16
|
/** An https image, or '' for the initial. */
|
|
17
17
|
avatar: string;
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* A page tab's address, drawn by the host rather than in a frame: what the
|
|
21
|
+
* window can show of any site, where a browser page can frame only the sites
|
|
22
|
+
* that allow it. `onGo` is a move the page made itself (a link followed), and
|
|
23
|
+
* `onTitle` the title it gave itself.
|
|
24
|
+
*/
|
|
25
|
+
export type Browse = ComponentType<{
|
|
26
|
+
url: string;
|
|
27
|
+
title: string;
|
|
28
|
+
onGo: (url: string) => void;
|
|
29
|
+
onTitle?: (title: string) => void;
|
|
30
|
+
}>;
|
|
31
|
+
/** A page as the host's browser is asked about it: the tab, and what it shows. */
|
|
32
|
+
export interface Shown {
|
|
33
|
+
id: string;
|
|
34
|
+
url: string;
|
|
35
|
+
title: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* What the host's browser can do to the page a tab shows, each the row of the
|
|
39
|
+
* same name in the tab's menu. A host draws only the rows it supplies, so the
|
|
40
|
+
* menu offers nothing the window cannot do; a browser page supplies none and
|
|
41
|
+
* draws what a page itself can (panel.tsx).
|
|
42
|
+
*/
|
|
43
|
+
export interface Browser {
|
|
44
|
+
find?: (page: Shown) => void;
|
|
45
|
+
print?: (page: Shown) => void;
|
|
46
|
+
/** Zoom the page to `percent` (100 is its own size). */
|
|
47
|
+
zoom?: (page: Shown, percent: number) => void;
|
|
48
|
+
devices?: (page: Shown) => void;
|
|
49
|
+
/** The page's pixels, as an image. */
|
|
50
|
+
capture?: (page: Shown) => Promise<Blob>;
|
|
51
|
+
/** What the reader has selected in the page. */
|
|
52
|
+
selection?: (page: Shown) => Promise<string>;
|
|
53
|
+
passwords?: () => void;
|
|
54
|
+
downloads?: () => void;
|
|
55
|
+
history?: () => void;
|
|
56
|
+
clear?: () => void;
|
|
57
|
+
settings?: () => void;
|
|
58
|
+
}
|
|
19
59
|
export interface Host {
|
|
20
60
|
/** The product's name, drawn at the top left: `Hanzo Build` on hanzo.build, `Hanzo` on hanzo.ai. */
|
|
21
61
|
name: string;
|
|
@@ -60,11 +100,19 @@ export interface Host {
|
|
|
60
100
|
open: (href: string) => void;
|
|
61
101
|
signIn?: () => void;
|
|
62
102
|
signOut?: () => void;
|
|
103
|
+
/** A page tab's remote address, shown by the host (Hanzo Desktop: a native webview). Absent, a browser frames what it may. */
|
|
104
|
+
browse?: Browse;
|
|
105
|
+
/** What the host's browser does to a page, row by row in the tab's menu. */
|
|
106
|
+
browser?: Browser;
|
|
63
107
|
}
|
|
64
108
|
export declare function HostProvider({ host, children }: {
|
|
65
109
|
host: Host;
|
|
66
110
|
children: ReactNode;
|
|
67
111
|
}): import("react").JSX.Element;
|
|
68
112
|
export declare function useHost(): Host;
|
|
113
|
+
/** The host's way of showing a page, where one is mounted and has one. The side panel is drawn without a host too (hanzo.team's rooms). */
|
|
114
|
+
export declare function useBrowse(): Browse | null;
|
|
115
|
+
/** The host's browser, where one is mounted and has one. */
|
|
116
|
+
export declare function useBrowser(): Browser | null;
|
|
69
117
|
/** The call target for this host — stable while the host's api, token reader and org are. */
|
|
70
118
|
export declare function useTarget(): Target;
|
package/lib/host.js
CHANGED
|
@@ -19,6 +19,14 @@ export function useHost() {
|
|
|
19
19
|
throw new Error('The builder was rendered without a host');
|
|
20
20
|
return h;
|
|
21
21
|
}
|
|
22
|
+
/** The host's way of showing a page, where one is mounted and has one. The side panel is drawn without a host too (hanzo.team's rooms). */
|
|
23
|
+
export function useBrowse() {
|
|
24
|
+
return useContext(Context)?.browse ?? null;
|
|
25
|
+
}
|
|
26
|
+
/** The host's browser, where one is mounted and has one. */
|
|
27
|
+
export function useBrowser() {
|
|
28
|
+
return useContext(Context)?.browser ?? null;
|
|
29
|
+
}
|
|
22
30
|
/** The call target for this host — stable while the host's api, token reader and org are. */
|
|
23
31
|
export function useTarget() {
|
|
24
32
|
const { api, token, org } = useHost();
|
package/lib/index.d.ts
CHANGED
|
@@ -31,7 +31,7 @@ export { useStanding, rows, left, type Standing, type Row } from './standing.ts'
|
|
|
31
31
|
export { kind, label, said, share, spent, ways, when, type Label, type Share, type State } from './plan.ts';
|
|
32
32
|
export type { Limits } from './api/limits.ts';
|
|
33
33
|
export type { Target } from './api/call.ts';
|
|
34
|
-
export type { Host, Person } from './host.tsx';
|
|
34
|
+
export type { Browse, Browser, Host, Person, Shown } from './host.tsx';
|
|
35
35
|
export type { Session, Status } from './api/sessions.ts';
|
|
36
36
|
export type { Read } from './data.ts';
|
|
37
37
|
export { href, parse, path, PLACES, route, SESSION, SLUG, type Route, type Screen } from './route.ts';
|
|
@@ -44,5 +44,5 @@ export { href, parse, path, PLACES, route, SESSION, SLUG, type Route, type Scree
|
|
|
44
44
|
export { catalog, EFFORTS, FIRST, keep, kept, nameOf, reasons, useCatalog, useChoice, useMind, type Choice, type Effort, type Mind } from './mind.ts';
|
|
45
45
|
export { cut, GAP, MEASURE, measure, Prompt, prompt, ROUND, Tune, useEar, type PromptOptions } from './prompt.tsx';
|
|
46
46
|
export { artifacts, blob, lead, renders, Reply, Served, speakable, speaker, type Artifact, type Listen } from './reply.tsx';
|
|
47
|
-
export { Page, Panel, PanelToggle, type Kind, type PanelProps } from './panel.tsx';
|
|
48
|
-
export { address, name, PAGE, useDeck, useKey, usePanel, useShown, type Deck, type Tab, type Tabs } from './tabs.ts';
|
|
47
|
+
export { frames, Page, Panel, PanelToggle, type Kind, type PageProps, type PanelProps, type Suggestion } from './panel.tsx';
|
|
48
|
+
export { address, destination, located, name, PAGE, SEARCH, useDeck, useKey, usePanel, useShown, type Deck, type Tab, type Tabs } from './tabs.ts';
|
package/lib/index.js
CHANGED
|
@@ -39,5 +39,5 @@ export { href, parse, path, PLACES, route, SESSION, SLUG } from './route.js';
|
|
|
39
39
|
export { catalog, EFFORTS, FIRST, keep, kept, nameOf, reasons, useCatalog, useChoice, useMind } from './mind.js';
|
|
40
40
|
export { cut, GAP, MEASURE, measure, Prompt, prompt, ROUND, Tune, useEar } from './prompt.js';
|
|
41
41
|
export { artifacts, blob, lead, renders, Reply, Served, speakable, speaker } from './reply.js';
|
|
42
|
-
export { Page, Panel, PanelToggle } from './panel.js';
|
|
43
|
-
export { address, name, PAGE, useDeck, useKey, usePanel, useShown } from './tabs.js';
|
|
42
|
+
export { frames, Page, Panel, PanelToggle } from './panel.js';
|
|
43
|
+
export { address, destination, located, name, PAGE, SEARCH, useDeck, useKey, usePanel, useShown } from './tabs.js';
|
package/lib/panel.d.ts
CHANGED
|
@@ -7,40 +7,86 @@ export interface Kind {
|
|
|
7
7
|
render: () => ReactNode;
|
|
8
8
|
/** Drawn edge to edge, scrolling itself: a terminal, a frame. */
|
|
9
9
|
bleed?: boolean;
|
|
10
|
+
/** Its mark in the strip. */
|
|
11
|
+
icon?: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
/** A place an empty tab offers. */
|
|
14
|
+
export interface Suggestion {
|
|
15
|
+
title: string;
|
|
16
|
+
url: string;
|
|
17
|
+
/** An image of the place's own mark; its initial without one. */
|
|
18
|
+
icon?: string;
|
|
10
19
|
}
|
|
11
20
|
/**
|
|
12
|
-
* The header's
|
|
13
|
-
*
|
|
14
|
-
* one
|
|
15
|
-
* Its name and its keys say what it does; lit while the panel is open. Below
|
|
16
|
-
* `lg`, open, it steps aside: the panel and its own close are in view.
|
|
21
|
+
* The header's control for the tabs, while they are hidden: an icon among the
|
|
22
|
+
* header's icons. Open, the strip's own toggle stands at the panel's end and
|
|
23
|
+
* this one is not drawn, so the page carries one toggle at a time.
|
|
17
24
|
*/
|
|
18
25
|
export declare function PanelToggle({ open, onToggle }: {
|
|
19
26
|
open: boolean;
|
|
20
27
|
onToggle: () => void;
|
|
21
|
-
}): import("react").JSX.Element;
|
|
28
|
+
}): import("react").JSX.Element | null;
|
|
29
|
+
/** Whether a browser page may frame an address: this page's bytes, this origin, and our own hosts. */
|
|
30
|
+
export declare function frames(url: string): boolean;
|
|
31
|
+
/** A site or a preview we host: a page under one of our hosts, not this page's own origin or our apexes. */
|
|
32
|
+
export declare function hosted(url: string): boolean;
|
|
33
|
+
/** A page the menu acts on. */
|
|
34
|
+
export interface Here {
|
|
35
|
+
url: string;
|
|
36
|
+
title: string;
|
|
37
|
+
}
|
|
38
|
+
/** A page asked about, with what the reader had selected in it where a page can read that. */
|
|
39
|
+
export interface Asked extends Here {
|
|
40
|
+
selection?: string;
|
|
41
|
+
}
|
|
42
|
+
/** Something the surface can do with a page: a row of the menu's Skills. */
|
|
43
|
+
export interface Skill {
|
|
44
|
+
id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
run: (page: Here) => void;
|
|
47
|
+
}
|
|
48
|
+
/** What the surface does with a page from its menu: each one draws its row. */
|
|
49
|
+
export interface Acts {
|
|
50
|
+
/** Ask about this page: the conversation takes it, with what was selected. */
|
|
51
|
+
onAsk?: (page: Asked) => void;
|
|
52
|
+
/** Open in Dev: a preview or a site we host (`hosted`), opened where it is built. */
|
|
53
|
+
onDev?: (page: Here) => void;
|
|
54
|
+
/** Take a screenshot: the picture, handed to the surface (Chat attaches it). */
|
|
55
|
+
onCapture?: (file: File, page: Here) => void;
|
|
56
|
+
skills?: readonly Skill[];
|
|
57
|
+
}
|
|
58
|
+
export interface PageProps extends Acts {
|
|
59
|
+
tab: Tab;
|
|
60
|
+
/** Point the page at an address. */
|
|
61
|
+
onGo: (url: string) => void;
|
|
62
|
+
onBack?: () => void;
|
|
63
|
+
onForward?: () => void;
|
|
64
|
+
/** The title the page gave itself. */
|
|
65
|
+
onTitle?: (title: string) => void;
|
|
66
|
+
suggested?: readonly Suggestion[];
|
|
67
|
+
}
|
|
22
68
|
/**
|
|
23
|
-
* A page tab:
|
|
69
|
+
* A page tab: its bar, then an address, bytes rendered as their type, or the
|
|
70
|
+
* suggestions of an empty tab.
|
|
24
71
|
*
|
|
25
72
|
* Two sandboxes. Bytes and an object URL take THIS page's origin, so a script
|
|
26
73
|
* an answer wrote runs without `allow-same-origin`, never with our storage. A
|
|
27
74
|
* remote page takes its own origin, and withholding same-origin breaks it.
|
|
28
75
|
*/
|
|
29
|
-
export declare function Page({ tab, onGo }:
|
|
30
|
-
|
|
31
|
-
onGo: (url: string) => void;
|
|
32
|
-
}): import("react").JSX.Element;
|
|
33
|
-
export interface PanelProps {
|
|
76
|
+
export declare function Page({ tab, onGo, onBack, onForward, onTitle, suggested, ...acts }: PageProps): import("react").JSX.Element;
|
|
77
|
+
export interface PanelProps extends Acts {
|
|
34
78
|
side: Deck;
|
|
35
79
|
kinds: readonly Kind[];
|
|
36
80
|
onHide: () => void;
|
|
37
81
|
/** A sheet over the page (a phone) rather than the host's column. */
|
|
38
82
|
sheet?: boolean;
|
|
39
|
-
/** Under the
|
|
83
|
+
/** Under the strip, over every tab: a line about what they all read (Dev's sandbox). */
|
|
40
84
|
bar?: ReactNode;
|
|
41
|
-
/** At the strip's end, before hide: the surface's own menu. */
|
|
85
|
+
/** At the strip's end, before pop-out and hide: the surface's own menu. */
|
|
42
86
|
menu?: ReactNode;
|
|
43
87
|
label?: string;
|
|
88
|
+
/** What an empty tab offers. */
|
|
89
|
+
suggested?: readonly Suggestion[];
|
|
44
90
|
}
|
|
45
91
|
/** The panel: the strip, and the chosen tab. */
|
|
46
|
-
export declare function Panel({ side, kinds, onHide, sheet, bar, menu, label }: PanelProps): import("react").JSX.Element;
|
|
92
|
+
export declare function Panel({ side, kinds, onHide, sheet, bar, menu, label, suggested, ...acts }: PanelProps): import("react").JSX.Element;
|
package/lib/panel.js
CHANGED
|
@@ -1,29 +1,97 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
3
|
* The side panel, once, for Chat and Dev: tabs over what a conversation or a
|
|
4
|
-
* run made, read and served (tabs.ts)
|
|
5
|
-
* header
|
|
4
|
+
* run made, read and served (tabs.ts) — a browser's tabs beside the
|
|
5
|
+
* conversation. The header shows them (`PanelToggle`, while they are hidden),
|
|
6
|
+
* the strip's own toggle hides them, and Ctrl+Shift+B (⌘⇧B) or ⌘. / Ctrl+.
|
|
7
|
+
* does either from anywhere but a field (`useKey`).
|
|
8
|
+
*
|
|
9
|
+
* The strip: the tabs, `+`, and at its end the surface's menu, pop-out and the
|
|
10
|
+
* hide toggle. A page tab has a browser's bar — Back and Forward through the
|
|
11
|
+
* addresses it went through, reload, the address, and its menu — and an empty
|
|
12
|
+
* one offers the surface's suggestions.
|
|
13
|
+
*
|
|
14
|
+
* WHAT A PAGE CAN SHOW IS SAID, NOT FAKED. A host that draws pages itself
|
|
15
|
+
* (`Host.browse`: Hanzo Desktop's native webviews) shows any address. A browser
|
|
16
|
+
* page frames only what may be framed — this page's bytes and our own hosts
|
|
17
|
+
* (`frames`) — and offers any other address in a new window instead of a frame
|
|
18
|
+
* the site would refuse.
|
|
6
19
|
*
|
|
7
20
|
* The column the panel stands in is the host's: its width and edge. On a phone
|
|
8
21
|
* the panel is a sheet over the page instead (`sheet`).
|
|
9
22
|
*/
|
|
10
23
|
import { SizableText, XStack, YStack } from '@hanzo/gui';
|
|
11
|
-
import { ExternalLink,
|
|
12
|
-
import { DropdownMenu } from '@hanzo/ui';
|
|
13
|
-
import { useEffect, useMemo, useState } from 'react';
|
|
24
|
+
import { ArrowLeft, ArrowRight, ChevronRight, Columns2, ExternalLink, Globe, Maximize2, Minus, MoreHorizontal, Plus, RotateCcw, RotateCw, SquarePlus, X } from '@hanzogui/lucide-icons-2';
|
|
25
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@hanzo/ui';
|
|
26
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
14
27
|
import { createPortal } from 'react-dom';
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
const
|
|
28
|
+
import { useBrowse, useBrowser } from './host.js';
|
|
29
|
+
import { destination, PAGE } from './tabs.js';
|
|
30
|
+
const MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
|
31
|
+
/** The tabs' shortcut, as this platform writes it. */
|
|
32
|
+
const KEYS = MAC ? '⌘⇧B' : 'Ctrl+Shift+B';
|
|
33
|
+
/** Every key that shows and hides the tabs (tabs.ts `useKey`). */
|
|
34
|
+
const SHORTCUTS = 'Control+Shift+B Meta+Shift+B Control+Period Meta+Period';
|
|
35
|
+
/** A tab's title where it has none of its own. */
|
|
36
|
+
const BLANK = 'New tab';
|
|
37
|
+
/** The row heights, px: the strip (the conversation header's row), and a page's bar. */
|
|
38
|
+
const STRIP = 56;
|
|
39
|
+
const BAR = 48;
|
|
40
|
+
/** A round control in a page's bar, px. */
|
|
41
|
+
const PILL = 34;
|
|
42
|
+
/** A press on an icon control of the panel's: quiet until pointed at, lit while its thing is open. */
|
|
43
|
+
const quiet = (on = false, disabled = false) => ({
|
|
44
|
+
items: 'center',
|
|
45
|
+
justify: 'center',
|
|
46
|
+
shrink: 0,
|
|
47
|
+
rounded: 8,
|
|
48
|
+
cursor: disabled ? 'default' : 'pointer',
|
|
49
|
+
opacity: disabled ? 0.35 : 1,
|
|
50
|
+
bg: on ? '$hover' : 'transparent',
|
|
51
|
+
hoverStyle: disabled ? undefined : { bg: '$hover' },
|
|
52
|
+
focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' },
|
|
53
|
+
});
|
|
18
54
|
/**
|
|
19
|
-
* The header's
|
|
20
|
-
*
|
|
21
|
-
* one
|
|
22
|
-
* Its name and its keys say what it does; lit while the panel is open. Below
|
|
23
|
-
* `lg`, open, it steps aside: the panel and its own close are in view.
|
|
55
|
+
* The header's control for the tabs, while they are hidden: an icon among the
|
|
56
|
+
* header's icons. Open, the strip's own toggle stands at the panel's end and
|
|
57
|
+
* this one is not drawn, so the page carries one toggle at a time.
|
|
24
58
|
*/
|
|
25
59
|
export function PanelToggle({ open, onToggle }) {
|
|
26
|
-
|
|
60
|
+
if (open)
|
|
61
|
+
return null;
|
|
62
|
+
return (_jsx(XStack, { render: "button", role: "button", "data-slot": "side-toggle", "aria-label": "Show tabs", "aria-expanded": false, "aria-keyshortcuts": SHORTCUTS, ...{ title: `Show tabs (${KEYS})` }, onPress: onToggle, width: 32, height: 32, ...quiet(), children: _jsx(SquarePlus, { size: 16 }) }));
|
|
63
|
+
}
|
|
64
|
+
/** Our own hosts: a page of theirs answers a frame on any of our pages. */
|
|
65
|
+
const OURS = ['hanzo.ai', 'hanzo.app', 'hanzo.bot'];
|
|
66
|
+
/** Whether a browser page may frame an address: this page's bytes, this origin, and our own hosts. */
|
|
67
|
+
export function frames(url) {
|
|
68
|
+
let u;
|
|
69
|
+
try {
|
|
70
|
+
u = new URL(url, typeof window === 'undefined' ? 'https://hanzo.ai' : window.location.href);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
if (u.protocol === 'blob:' || u.protocol === 'data:')
|
|
76
|
+
return true;
|
|
77
|
+
if (typeof window !== 'undefined' && u.origin === window.location.origin)
|
|
78
|
+
return true;
|
|
79
|
+
if (u.protocol !== 'https:')
|
|
80
|
+
return false;
|
|
81
|
+
return OURS.some((h) => u.hostname === h || u.hostname.endsWith(`.${h}`));
|
|
82
|
+
}
|
|
83
|
+
/** A site or a preview we host: a page under one of our hosts, not this page's own origin or our apexes. */
|
|
84
|
+
export function hosted(url) {
|
|
85
|
+
let u;
|
|
86
|
+
try {
|
|
87
|
+
u = new URL(url);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
if (u.protocol !== 'https:' || (typeof window !== 'undefined' && u.origin === window.location.origin))
|
|
93
|
+
return false;
|
|
94
|
+
return OURS.some((h) => u.hostname.endsWith(`.${h}`) && u.hostname !== `www.${h}`);
|
|
27
95
|
}
|
|
28
96
|
const suffix = (s) => {
|
|
29
97
|
const dot = s.lastIndexOf('.');
|
|
@@ -31,33 +99,212 @@ const suffix = (s) => {
|
|
|
31
99
|
};
|
|
32
100
|
const PICTURES = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg'];
|
|
33
101
|
const FRAMED = ['html', 'htm', 'txt', 'md', 'json', 'csv', 'pdf', 'css', 'js', 'ts'];
|
|
102
|
+
/** The address a tab can be opened at in a window of its own: its own, or its bytes. Null for a kind or a blank page. */
|
|
103
|
+
function outward(tab) {
|
|
104
|
+
if (!tab || tab.kind !== PAGE)
|
|
105
|
+
return null;
|
|
106
|
+
if (tab.body !== undefined)
|
|
107
|
+
return URL.createObjectURL(new Blob([tab.body], { type: tab.type || 'text/plain' }));
|
|
108
|
+
return tab.url || null;
|
|
109
|
+
}
|
|
110
|
+
/** Opens a tab's address or bytes in a new window. */
|
|
111
|
+
function popOut(tab) {
|
|
112
|
+
const href = outward(tab);
|
|
113
|
+
if (href)
|
|
114
|
+
window.open(href, '_blank', 'noopener,noreferrer');
|
|
115
|
+
}
|
|
116
|
+
/** A place's mark: its image, else its initial on a quiet tile. */
|
|
117
|
+
function Mark({ icon, title }) {
|
|
118
|
+
const [broken, setBroken] = useState(false);
|
|
119
|
+
return (_jsx(XStack, { width: 40, height: 40, rounded: 10, items: "center", justify: "center", bg: "$edge", overflow: "hidden", shrink: 0, children: icon && !broken ? (_jsx("img", { src: icon, alt: "", width: 28, height: 28, style: { objectFit: 'contain' }, onError: () => setBroken(true) })) : (_jsx(SizableText, { size: "$4", fontWeight: "600", color: "$ink", "aria-hidden": true, children: (title.trim()[0] ?? '·').toUpperCase() })) }));
|
|
120
|
+
}
|
|
121
|
+
/** An empty page: the places the surface suggests, as cards in a row that scrolls sideways. */
|
|
122
|
+
function Suggested({ suggested, onGo }) {
|
|
123
|
+
return (_jsxs(YStack, { "data-slot": "side-suggested", px: "$4", pt: "$4", gap: "$3", children: [_jsx(SizableText, { fontSize: 13, color: "$soft", children: "Suggested" }), suggested.length ? (_jsx(XStack, { role: "list", "aria-label": "Suggested", gap: "$2", overflowX: "auto", overflowY: "hidden", pb: "$2", children: suggested.map((s) => (_jsxs(YStack, { role: "listitem", render: "button", "aria-label": s.title, onPress: () => onGo(s.url), width: 120, shrink: 0, items: "center", gap: "$2", py: "$2.5", px: "$1.5", rounded: 12, cursor: "pointer", hoverStyle: { bg: '$hover' }, focusVisibleStyle: { outlineWidth: 2, outlineStyle: 'solid', outlineColor: '$outlineColor' }, children: [_jsx(Mark, { icon: s.icon, title: s.title }), _jsx(SizableText, { fontSize: 13, color: "$ink", numberOfLines: 1, maxW: "100%", children: s.title })] }, s.url))) })) : (_jsx(SizableText, { fontSize: 13, color: "$soft", children: "No suggestions." }))] }));
|
|
124
|
+
}
|
|
125
|
+
/** A remote address a browser page may not frame: what it is, and the one way to open it. */
|
|
126
|
+
function Elsewhere({ tab }) {
|
|
127
|
+
return (_jsxs(YStack, { flex: 1, items: "center", justify: "center", gap: "$3", p: "$5", "data-slot": "side-elsewhere", children: [_jsx(Globe, { size: 20, opacity: 0.6 }), _jsx(SizableText, { fontSize: 13, color: "$ink", numberOfLines: 1, maxW: "100%", children: tab.title || tab.url }), _jsxs(XStack, { render: "button", role: "button", onPress: () => popOut(tab), items: "center", gap: "$1.5", px: "$3", height: 32, rounded: 999, borderWidth: 1, borderColor: "$borderColor", cursor: "pointer", hoverStyle: { bg: '$hover' }, children: [_jsx(ExternalLink, { size: 13 }), _jsx(SizableText, { fontSize: 13, color: "$ink", children: "Open in a new window" })] })] }));
|
|
128
|
+
}
|
|
129
|
+
/** A row of the page's menu, px: a browser's menu is denser than a form's. */
|
|
130
|
+
const ROW = 29;
|
|
131
|
+
/** A browser's zoom steps, percent. */
|
|
132
|
+
const ZOOMS = [25, 33, 50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200, 250, 300];
|
|
133
|
+
/** The next zoom step past `now`, one way. */
|
|
134
|
+
const zoomed = (now, way) => {
|
|
135
|
+
const at = ZOOMS.findIndex((z) => z >= now);
|
|
136
|
+
const i = at < 0 ? ZOOMS.length - 1 : ZOOMS[at] === now ? at + way : way > 0 ? at : at - 1;
|
|
137
|
+
return ZOOMS[Math.max(0, Math.min(ZOOMS.length - 1, i))];
|
|
138
|
+
};
|
|
139
|
+
/** A row of the menu: its words, and what stands at its end. */
|
|
140
|
+
function Row({ label, onSelect, disabled, end }) {
|
|
141
|
+
return (_jsxs(DropdownMenuItem, { disabled: disabled, onSelect: onSelect, minH: ROW, py: 0, children: [_jsx(SizableText, { fontSize: 13, color: "$ink", flex: 1, numberOfLines: 1, children: label }), end] }));
|
|
142
|
+
}
|
|
143
|
+
/** The zoom row: the level between − and +, and the way back to 100. Plain buttons, so a press keeps the menu open. */
|
|
144
|
+
function Zoom({ level, onLevel, disabled }) {
|
|
145
|
+
const step = (way) => () => onLevel(zoomed(level, way));
|
|
146
|
+
const box = (label, onPress, child) => (_jsx(XStack, { render: "button", role: "button", "aria-label": label, "aria-disabled": disabled || undefined, onPress: disabled ? undefined : onPress, width: 24, height: 24, ...quiet(false, disabled), rounded: 6, children: child }));
|
|
147
|
+
return (_jsxs(XStack, { "data-slot": "page-zoom", role: "group", "aria-label": "Zoom", items: "center", gap: "$2", px: 8, height: 38, opacity: disabled ? 0.5 : 1, children: [_jsx(SizableText, { fontSize: 13, color: "$ink", flex: 1, children: "Zoom" }), _jsxs(XStack, { items: "center", gap: 2, borderWidth: 1, borderColor: "$borderColor", rounded: 8, px: 2, height: 28, children: [box('Zoom out', step(-1), _jsx(Minus, { size: 12 })), _jsx(SizableText, { fontSize: 12, color: "$ink", width: 40, text: "center", "aria-live": "polite", children: `${level}%` }), box('Zoom in', step(1), _jsx(Plus, { size: 12 }))] }), box('Reset zoom', () => onLevel(100), _jsx(RotateCcw, { size: 13 }))] }));
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Skills, a submenu. Its panel is mounted only while open: a closed one stays
|
|
151
|
+
* laid out over the menu and takes its presses.
|
|
152
|
+
*/
|
|
153
|
+
function Skills({ skills, here }) {
|
|
154
|
+
const [open, setOpen] = useState(false);
|
|
155
|
+
return (_jsxs(DropdownMenuSub, { open: open, onOpenChange: setOpen, children: [_jsx(DropdownMenuSubTrigger, { disabled: !here, minH: ROW, py: 0, children: _jsx(SizableText, { fontSize: 13, color: "$ink", flex: 1, children: "Skills" }) }), open ? (_jsx(DropdownMenuSubContent, { minW: 200, children: skills.map((k) => (_jsx(Row, { label: k.name, onSelect: () => here && k.run(here) }, k.id))) })) : null] }));
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The page's menu, in a browser's order: what we add first (ask about it, open
|
|
159
|
+
* it in Dev, a skill, a new window, its link), then what is done to the page
|
|
160
|
+
* (find, print, zoom, device toolbar, a screenshot), then the browser's own
|
|
161
|
+
* (passwords, downloads, history, clearing) and its settings. A row is drawn
|
|
162
|
+
* only where it can be done — by the host's browser (`Host.browser`), or by a
|
|
163
|
+
* page itself for a frame it may reach — and one that needs a page is dim on
|
|
164
|
+
* an empty tab.
|
|
165
|
+
*/
|
|
166
|
+
function Actions({ tab, here, frame, framed, same, level, onLevel, acts, browser, }) {
|
|
167
|
+
const shown = here ? { id: tab.id, ...here } : null;
|
|
168
|
+
const none = !shown;
|
|
169
|
+
const pick = () => {
|
|
170
|
+
if (!same)
|
|
171
|
+
return undefined;
|
|
172
|
+
try {
|
|
173
|
+
return frame.current?.contentWindow?.getSelection()?.toString() || undefined;
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
const ask = async () => {
|
|
180
|
+
if (!shown || !acts.onAsk)
|
|
181
|
+
return;
|
|
182
|
+
const selection = browser?.selection ? await browser.selection(shown).catch(() => '') : pick();
|
|
183
|
+
acts.onAsk({ url: shown.url, title: shown.title, ...(selection ? { selection } : {}) });
|
|
184
|
+
};
|
|
185
|
+
const capture = async () => {
|
|
186
|
+
if (!shown || !browser?.capture || !acts.onCapture)
|
|
187
|
+
return;
|
|
188
|
+
const blob = await browser.capture(shown);
|
|
189
|
+
const name = `Screenshot ${new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19)}.png`;
|
|
190
|
+
acts.onCapture(new File([blob], name, { type: blob.type || 'image/png' }), { url: shown.url, title: shown.title });
|
|
191
|
+
};
|
|
192
|
+
const print = browser?.print ?? (same ? () => frame.current?.contentWindow?.print() : undefined);
|
|
193
|
+
const zoom = Boolean(browser?.zoom) || framed;
|
|
194
|
+
const ours = [
|
|
195
|
+
acts.onAsk ? _jsx(Row, { label: "Ask about this page", disabled: none, onSelect: () => void ask() }, "ask") : null,
|
|
196
|
+
acts.onDev && shown && hosted(shown.url) ? _jsx(Row, { label: "Open in Dev", onSelect: () => acts.onDev?.({ url: shown.url, title: shown.title }) }, "dev") : null,
|
|
197
|
+
acts.skills?.length ? _jsx(Skills, { skills: acts.skills, here: here }, "skills") : null,
|
|
198
|
+
_jsx(Row, { label: "Open in a new window", disabled: none, onSelect: () => popOut(tab) }, "out"),
|
|
199
|
+
_jsx(Row, { label: "Copy link", disabled: none || !tab.url, onSelect: () => tab.url && void navigator.clipboard?.writeText(tab.url) }, "copy"),
|
|
200
|
+
].filter(Boolean);
|
|
201
|
+
const page = [
|
|
202
|
+
browser?.find ? _jsx(Row, { label: "Find in page", disabled: none, onSelect: () => shown && browser.find?.(shown) }, "find") : null,
|
|
203
|
+
print ? _jsx(Row, { label: "Print", disabled: none, onSelect: () => shown && print(shown) }, "print") : null,
|
|
204
|
+
zoom ? _jsx(Zoom, { level: level, onLevel: onLevel, disabled: none }, "zoom") : null,
|
|
205
|
+
browser?.devices ? _jsx(Row, { label: "Show device toolbar", disabled: none, onSelect: () => shown && browser.devices?.(shown) }, "devices") : null,
|
|
206
|
+
browser?.capture && acts.onCapture ? _jsx(Row, { label: "Take a screenshot", disabled: none, onSelect: () => void capture() }, "capture") : null,
|
|
207
|
+
].filter(Boolean);
|
|
208
|
+
const own = [
|
|
209
|
+
browser?.passwords ? _jsx(Row, { label: "Passwords and autofill", onSelect: browser.passwords, end: _jsx(ChevronRight, { size: 14, opacity: 0.6 }) }, "passwords") : null,
|
|
210
|
+
browser?.downloads ? _jsx(Row, { label: "Downloads", onSelect: browser.downloads }, "downloads") : null,
|
|
211
|
+
browser?.history ? _jsx(Row, { label: "History", onSelect: browser.history }, "history") : null,
|
|
212
|
+
browser?.clear ? _jsx(Row, { label: "Clear browsing data", onSelect: browser.clear }, "clear") : null,
|
|
213
|
+
].filter(Boolean);
|
|
214
|
+
const settings = browser?.settings ? [_jsx(Row, { label: "Browser settings", onSelect: browser.settings }, "settings")] : [];
|
|
215
|
+
const groups = [ours, page, own, settings].filter((g) => g.length);
|
|
216
|
+
return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(XStack, { render: "button", role: "button", "aria-label": "Page actions", width: PILL, height: PILL, ...quiet(), rounded: 999, bg: "$edge", children: _jsx(MoreHorizontal, { size: 16 }) }) }), _jsx(DropdownMenuContent, { align: "end", minW: 240, "data-slot": "page-menu", children: groups.flatMap((g, i) => (i ? [_jsx(DropdownMenuSeparator, {}, `line-${i}`), ...g] : g)) })] }));
|
|
217
|
+
}
|
|
34
218
|
/**
|
|
35
|
-
* A page tab:
|
|
219
|
+
* A page tab: its bar, then an address, bytes rendered as their type, or the
|
|
220
|
+
* suggestions of an empty tab.
|
|
36
221
|
*
|
|
37
222
|
* Two sandboxes. Bytes and an object URL take THIS page's origin, so a script
|
|
38
223
|
* an answer wrote runs without `allow-same-origin`, never with our storage. A
|
|
39
224
|
* remote page takes its own origin, and withholding same-origin breaks it.
|
|
40
225
|
*/
|
|
41
|
-
export function Page({ tab, onGo }) {
|
|
226
|
+
export function Page({ tab, onGo, onBack, onForward, onTitle, suggested = [], ...acts }) {
|
|
227
|
+
const Browse = useBrowse();
|
|
228
|
+
const browser = useBrowser();
|
|
229
|
+
const frame = useRef(null);
|
|
230
|
+
// The page's zoom: the host's browser zooms its own; a frame here is scaled.
|
|
231
|
+
const [level, setLevel] = useState(100);
|
|
42
232
|
const own = tab.body !== undefined;
|
|
43
233
|
const src = useMemo(() => (own ? URL.createObjectURL(new Blob([tab.body], { type: tab.type || 'text/plain' })) : (tab.url ?? '')), [own, tab.body, tab.type, tab.url]);
|
|
44
234
|
useEffect(() => () => (own ? URL.revokeObjectURL(src) : undefined), [own, src]);
|
|
45
235
|
const [round, again] = useState(0);
|
|
46
236
|
const [typed, setTyped] = useState(tab.url ?? '');
|
|
237
|
+
const [typing, setTyping] = useState(false);
|
|
238
|
+
// A move the page made, Back or a suggestion is the bar's new address.
|
|
239
|
+
useEffect(() => setTyped(tab.url ?? ''), [tab.url]);
|
|
47
240
|
const local = own || src.startsWith('blob:');
|
|
48
241
|
const type = tab.type ?? '';
|
|
49
242
|
const ext = suffix(tab.title);
|
|
50
243
|
const picture = type.startsWith('image/') || PICTURES.includes(ext);
|
|
51
244
|
const shows = !local || own || !type || type.startsWith('text/') || type === 'application/pdf' || type === 'application/json' || FRAMED.includes(ext);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
245
|
+
const back = Boolean(tab.back?.length && onBack);
|
|
246
|
+
const ahead = Boolean(tab.ahead?.length && onForward);
|
|
247
|
+
const go = () => {
|
|
248
|
+
const to = destination(typed);
|
|
249
|
+
if (to)
|
|
250
|
+
onGo(to);
|
|
251
|
+
};
|
|
252
|
+
const native = !local && Boolean(Browse);
|
|
253
|
+
const elsewhere = !local && !native && Boolean(src) && !frames(src);
|
|
254
|
+
const framed = Boolean(src) && !native && !elsewhere;
|
|
255
|
+
const same = framed && !local && (() => {
|
|
256
|
+
try {
|
|
257
|
+
return new URL(src, window.location.href).origin === window.location.origin;
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
})();
|
|
263
|
+
const here = src ? { url: tab.url || src, title: tab.title } : null;
|
|
264
|
+
const zoomTo = (percent) => {
|
|
265
|
+
setLevel(percent);
|
|
266
|
+
if (browser?.zoom && native && here)
|
|
267
|
+
browser.zoom({ id: tab.id, ...here }, percent);
|
|
268
|
+
};
|
|
269
|
+
// A frame scaled from its corner, its box grown to fill the panel at the scale.
|
|
270
|
+
const scale = native || level === 100 ? null : { width: `${10000 / level}%`, height: `${10000 / level}%`, transform: `scale(${level / 100})`, transformOrigin: '0 0' };
|
|
271
|
+
return (_jsxs(YStack, { flex: 1, minH: 0, "data-slot": "side-page", children: [_jsxs(XStack, { "data-slot": "side-bar", height: BAR, px: "$2", gap: "$2", items: "center", borderBottomWidth: 1, borderColor: "$borderColor", shrink: 0, children: [_jsxs(XStack, { items: "center", height: PILL, px: 4, gap: 2, rounded: 999, bg: "$edge", shrink: 0, children: [_jsx(XStack, { render: "button", role: "button", "aria-label": "Back", "aria-disabled": !back || undefined, onPress: back ? onBack : undefined, width: 28, height: 28, ...quiet(false, !back), rounded: 999, children: _jsx(ArrowLeft, { size: 15 }) }), _jsx(XStack, { render: "button", role: "button", "aria-label": "Forward", "aria-disabled": !ahead || undefined, onPress: ahead ? onForward : undefined, width: 28, height: 28, ...quiet(false, !ahead), rounded: 999, children: _jsx(ArrowRight, { size: 15 }) }), _jsx(XStack, { render: "button", role: "button", "aria-label": "Reload this tab", onPress: () => again((n) => n + 1), width: 28, height: 28, ...quiet(), rounded: 999, children: _jsx(RotateCw, { size: 14 }) })] }), _jsx(XStack, { flex: 1, minW: 0, items: "center", height: PILL, px: "$3.5", rounded: 999, bg: "$edge", borderWidth: 1,
|
|
272
|
+
// Focused, the address is the bar's one ringed thing, as a browser's is.
|
|
273
|
+
borderColor: typing ? '$outlineColor' : 'transparent', "data-slot": "side-address", children: _jsx("input", { value: own ? tab.title : typed, readOnly: own, placeholder: "Search or enter a URL", "aria-label": "Address", spellCheck: false, autoFocus: !own && !tab.url, onFocus: (e) => {
|
|
274
|
+
setTyping(true);
|
|
275
|
+
e.currentTarget.select();
|
|
276
|
+
}, onBlur: () => setTyping(false), onChange: (e) => setTyped(e.target.value), onKeyDown: (e) => {
|
|
277
|
+
if (e.key === 'Enter')
|
|
278
|
+
go();
|
|
279
|
+
if (e.key === 'Escape')
|
|
280
|
+
setTyped(tab.url ?? '');
|
|
281
|
+
}, style: {
|
|
282
|
+
width: '100%',
|
|
283
|
+
border: 0,
|
|
284
|
+
outline: 'none',
|
|
285
|
+
background: 'transparent',
|
|
286
|
+
color: 'var(--foreground)',
|
|
287
|
+
font: 'inherit',
|
|
288
|
+
fontSize: 14,
|
|
289
|
+
// The resting bar reads centred, as a browser's new tab does; typed in, it reads from the left.
|
|
290
|
+
textAlign: typing || typed ? 'left' : 'center',
|
|
291
|
+
} }, tab.id) }), _jsx(Actions, { tab: tab, here: here, frame: frame, framed: framed, same: same, level: level, onLevel: zoomTo, acts: acts, browser: browser })] }), !src ? (_jsx(Suggested, { suggested: suggested, onGo: onGo })) : native && Browse ? (_jsx(YStack, { flex: 1, minH: 0, children: _jsx(Browse, { url: src, title: tab.title, onGo: onGo, onTitle: onTitle }) }, round)) : elsewhere ? (_jsx(Elsewhere, { tab: tab })) : picture && !own ? (_jsx(YStack, { flex: 1, minH: 0, items: "center", justify: "center", p: "$3", overflowY: "auto", overflowX: "hidden", children: _jsx("img", { src: src, alt: tab.title, style: { maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', ...(scale ? { transform: scale.transform } : null) } }, round) })) : shows ? (_jsx(YStack, { flex: 1, minH: 0, overflow: "hidden", position: "relative", children: _jsx("iframe", { ref: frame, src: src, title: tab.title, sandbox: local ? 'allow-scripts allow-popups allow-forms' : 'allow-same-origin allow-scripts allow-popups allow-forms allow-modals', style: { position: 'absolute', top: 0, left: 0, border: 0, width: '100%', height: '100%', display: 'block', background: 'var(--background)', ...scale } }, round) })) : (_jsxs(YStack, { flex: 1, items: "center", justify: "center", p: "$5", gap: "$2", children: [_jsx(SizableText, { size: "$2", color: "$soft", children: `${(ext || type || 'This').toUpperCase()} previews aren’t supported yet.` }), _jsx(SizableText, { size: "$2", color: "$soft", children: "Open it in a new window to view it." })] }))] }));
|
|
56
292
|
}
|
|
57
293
|
const DRAG = 'text/x-hanzo-tab';
|
|
58
|
-
/** One tab in the strip:
|
|
59
|
-
function Leaf({ tab, on, side, index, label }) {
|
|
60
|
-
return (_jsxs(XStack, { "data-slot": "side-tab", "data-tab": tab.id, items: "center", gap: "$
|
|
294
|
+
/** One tab in the strip: its mark, its title, and its cross. */
|
|
295
|
+
function Leaf({ tab, on, side, index, label, icon }) {
|
|
296
|
+
return (_jsxs(XStack, { "data-slot": "side-tab", "data-tab": tab.id, items: "center", gap: "$1", pl: "$2.5", pr: "$1", height: 30,
|
|
297
|
+
// As wide as a title wants up to a browser tab's width, sharing the strip
|
|
298
|
+
// as a browser's tabs do when there are many.
|
|
299
|
+
flexBasis: 236, shrink: 1, minW: 56, maxW: 236, rounded: 8, borderWidth: 1, borderColor: on ? '$borderColor' : 'transparent', bg: on ? '$edge' : 'transparent', hoverStyle: { bg: on ? '$edge' : '$hover' }, ...{
|
|
300
|
+
// A middle press shuts any tab, as a browser's does; the chosen one also has its cross.
|
|
301
|
+
onAuxClick: (e) => {
|
|
302
|
+
if (e.button !== 1)
|
|
303
|
+
return;
|
|
304
|
+
e.preventDefault();
|
|
305
|
+
side.shut(tab.id);
|
|
306
|
+
},
|
|
307
|
+
title: label,
|
|
61
308
|
draggable: true,
|
|
62
309
|
onDragStart: (e) => {
|
|
63
310
|
e.dataTransfer.setData(DRAG, tab.id);
|
|
@@ -74,7 +321,7 @@ function Leaf({ tab, on, side, index, label }) {
|
|
|
74
321
|
e.preventDefault();
|
|
75
322
|
side.move(id, index);
|
|
76
323
|
},
|
|
77
|
-
}, children: [
|
|
324
|
+
}, children: [_jsxs(XStack, { render: "button", role: "tab", id: `side-tab-${tab.id}`, "aria-selected": on, "aria-controls": "side-body", tabIndex: on ? 0 : -1, onPress: () => side.pick(tab.id), onKeyDown: (e) => {
|
|
78
325
|
const step = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
|
|
79
326
|
if (step && e.shiftKey && (e.metaKey || e.ctrlKey)) {
|
|
80
327
|
e.preventDefault?.();
|
|
@@ -94,23 +341,34 @@ function Leaf({ tab, on, side, index, label }) {
|
|
|
94
341
|
e.preventDefault?.();
|
|
95
342
|
side.shut(tab.id);
|
|
96
343
|
}
|
|
97
|
-
}, flex: 1, minW: 0, cursor: "pointer", children: _jsx(SizableText, {
|
|
344
|
+
}, flex: 1, minW: 0, items: "center", gap: "$2", cursor: "pointer", children: [icon ? (_jsx(XStack, { "aria-hidden": true, shrink: 0, opacity: on ? 1 : 0.7, children: icon })) : null, _jsx(SizableText, { fontSize: 13, color: on ? '$ink' : '$soft', numberOfLines: 1, children: label })] }), _jsx(XStack, { render: "button", role: "button", "aria-label": `Close ${label}`, tabIndex: -1, onPress: () => side.shut(tab.id), width: 22, height: 22, items: "center", justify: "center", rounded: 6, cursor: "pointer", opacity: on ? 0.8 : 0.45, hoverStyle: { bg: '$hover', opacity: 1 },
|
|
345
|
+
// The chosen tab's cross: the others keep their room for their titles when the strip is shared.
|
|
346
|
+
display: on ? 'flex' : 'none', children: _jsx(X, { size: 12 }) })] }));
|
|
98
347
|
}
|
|
99
348
|
/** The panel: the strip, and the chosen tab. */
|
|
100
|
-
export function Panel({ side, kinds, onHide, sheet = false, bar, menu, label = 'Side panel' }) {
|
|
349
|
+
export function Panel({ side, kinds, onHide, sheet = false, bar, menu, label = 'Side panel', suggested, ...acts }) {
|
|
101
350
|
const tab = side.tabs.find((t) => t.id === side.at) ?? null;
|
|
351
|
+
// The chosen tab is in view in a strip that scrolls: a tab just opened stands at its end.
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
if (side.at)
|
|
354
|
+
document.getElementById(`side-tab-${side.at}`)?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
355
|
+
}, [side.at, side.tabs.length]);
|
|
102
356
|
const kind = tab && tab.kind !== PAGE ? kinds.find((k) => k.id === tab.kind) : undefined;
|
|
103
357
|
const bleed = !tab || tab.kind === PAGE || Boolean(kind?.bleed);
|
|
104
358
|
const closed = kinds.filter((k) => !side.tabs.some((t) => t.kind === k.id));
|
|
105
|
-
const title = (t) => (t.kind === PAGE ? t.title ||
|
|
106
|
-
const
|
|
359
|
+
const title = (t) => (t.kind === PAGE ? t.title || BLANK : (kinds.find((k) => k.id === t.kind)?.label ?? t.title));
|
|
360
|
+
const mark = (t) => (t.kind === PAGE ? _jsx(Globe, { size: 14 }) : (kinds.find((k) => k.id === t.kind)?.icon ?? null));
|
|
361
|
+
const blank = () => side.open({ kind: PAGE, title: BLANK, url: '' });
|
|
362
|
+
const poppable = tab?.kind === PAGE && (tab.body !== undefined || Boolean(tab.url));
|
|
363
|
+
const plus = (_jsx(XStack, { render: "button", role: "button", "aria-label": "Open a tab", width: 28, height: 28, ...quiet(), onPress: closed.length ? undefined : blank, children: _jsx(Plus, { size: 16 }) }));
|
|
364
|
+
const body = (_jsxs(YStack, { role: "complementary", "aria-label": label, "data-slot": "side", flex: 1, minW: 0, minH: 0, overflow: "hidden", style: { borderRadius: 'inherit' }, children: [_jsxs(XStack, { "data-slot": "side-strip", height: STRIP, px: "$2", gap: "$1.5", items: "center", borderBottomWidth: 1, borderColor: "$borderColor", shrink: 0, children: [_jsx(XStack, { role: "tablist", "aria-label": "Tabs", shrink: 1, minW: 0, gap: "$1", items: "center", overflowX: "auto", overflowY: "hidden", "$platform-web": { scrollbarWidth: 'none' }, children: side.tabs.map((t, i) => (_jsx(Leaf, { tab: t, on: t.id === side.at, side: side, index: i, label: title(t), icon: mark(t) }, t.id))) }), closed.length ? (_jsx(DropdownMenu, { trigger: plus, items: [
|
|
107
365
|
...closed.map((k) => ({ key: k.id, label: k.label, onSelect: () => side.open({ kind: k.id, title: k.label }) })),
|
|
108
|
-
{ key: PAGE, label: 'Page', description: 'An address, framed here', onSelect:
|
|
109
|
-
] }), menu, _jsx(XStack, { render: "button", role: "button", "aria-label": "
|
|
366
|
+
{ key: PAGE, label: 'Page', description: 'An address, framed here', onSelect: blank },
|
|
367
|
+
] })) : (plus), _jsx(XStack, { flex: 1 }), menu, _jsx(XStack, { render: "button", role: "button", "aria-label": "Open this tab in a new window", "aria-disabled": !poppable || undefined, onPress: poppable ? () => popOut(tab) : undefined, width: 28, height: 28, ...quiet(false, !poppable), children: _jsx(Maximize2, { size: 14 }) }), _jsx(XStack, { render: "button", role: "button", "data-slot": "side-toggle", "aria-label": "Hide tabs", "aria-expanded": true, "aria-keyshortcuts": SHORTCUTS, ...{ title: `Hide tabs (${KEYS})` }, onPress: onHide, width: 32, height: 32, ...quiet(true), children: _jsx(Columns2, { size: 16 }) })] }), bar, _jsx(YStack, { id: "side-body", role: "tabpanel", "aria-labelledby": tab ? `side-tab-${tab.id}` : undefined,
|
|
110
368
|
// What a tab lays over itself (a door's "Connecting…") stays inside it, under the strip.
|
|
111
|
-
position: "relative", flex: 1, minH: 0, overflowY: bleed ? 'hidden' : 'auto', overflowX: "hidden", px: bleed ? 0 : '$3', py: bleed ? 0 : '$3', children: !tab ? (
|
|
369
|
+
position: "relative", flex: 1, minH: 0, overflowY: bleed ? 'hidden' : 'auto', overflowX: "hidden", px: bleed ? 0 : '$3', py: bleed ? 0 : '$3', children: !tab ? (_jsx(YStack, { flex: 1, items: "center", justify: "center", p: "$5", children: _jsx(SizableText, { fontSize: 13, color: "$soft", children: "No tabs open." }) })) : tab.kind === PAGE ? (_jsx(Page, { tab: tab, onGo: (url) => side.go(tab.id, url), onBack: () => side.back(tab.id), onForward: () => side.forward(tab.id), onTitle: (t) => side.rename(tab.id, t), suggested: suggested, ...acts }, tab.id)) : kind ? (kind.render()) : (_jsx(SizableText, { size: "$2", color: "$soft", children: "This tab is not offered here." })) })] }));
|
|
112
370
|
if (!sheet || typeof document === 'undefined')
|
|
113
371
|
return body;
|
|
114
372
|
// A phone: a sheet from the foot of the window over a dim, the same panel in it.
|
|
115
|
-
return createPortal(_jsxs(YStack, { "data-slot": "side-sheet", position: "fixed", t: 0, r: 0, b: 0, l: 0, z: 60, children: [_jsx(YStack, {
|
|
373
|
+
return createPortal(_jsxs(YStack, { "data-slot": "side-sheet", position: "fixed", t: 0, r: 0, b: 0, l: 0, z: 60, children: [_jsx(YStack, { "aria-hidden": true, onPress: onHide, position: "absolute", t: 0, r: 0, l: 0, height: "12dvh", bg: "var(--surface-scrim)" }), _jsx(YStack, { position: "absolute", l: 0, r: 0, b: 0, height: "88dvh", bg: "$background", borderTopWidth: 1, borderColor: "$borderColor", style: { borderTopLeftRadius: 16, borderTopRightRadius: 16 }, overflow: "hidden", children: body })] }), document.body);
|
|
116
374
|
}
|
package/lib/prompt.d.ts
CHANGED
|
@@ -14,8 +14,8 @@ export declare const measure: {
|
|
|
14
14
|
readonly maxW: "calc(var(--container-prose, 48rem) - 1.5rem)";
|
|
15
15
|
readonly mx: 'auto';
|
|
16
16
|
};
|
|
17
|
-
/** The round controls' box, px:
|
|
18
|
-
export declare const ROUND =
|
|
17
|
+
/** The round controls' box, px: the pill's row less its padding. */
|
|
18
|
+
export declare const ROUND = 32;
|
|
19
19
|
/**
|
|
20
20
|
* The frame as a pane of its own, floating on the pane it is drawn in: the
|
|
21
21
|
* `--pane-*` cut on design's first paper rung (a second rung's drop reads as a
|
|
@@ -59,6 +59,8 @@ export interface PromptOptions {
|
|
|
59
59
|
talk?: Machine;
|
|
60
60
|
/** Drawn on a pane: the frame is cut as a pane of its own (`cut`). */
|
|
61
61
|
framed?: boolean;
|
|
62
|
+
/** Centred just above the pill: what every message is sent under. */
|
|
63
|
+
note?: ReactNode;
|
|
62
64
|
}
|
|
63
65
|
/**
|
|
64
66
|
* The chip beside send, and the panel it opens: the effort, a sentence saying
|
package/lib/prompt.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
|
-
* The composer, once, for Chat and Dev:
|
|
4
|
-
* the attach
|
|
5
|
-
* and effort (`Tune`), dictation, talk and send
|
|
3
|
+
* The composer, once, for Chat and Dev: one pill. While the draft is a line,
|
|
4
|
+
* the pill is one row — attach (`+`) and the surface's own tools at the start,
|
|
5
|
+
* the field, then the model and effort (`Tune`), dictation, talk and send. A
|
|
6
|
+
* draft longer than a line takes the pill's whole width above, and the
|
|
7
|
+
* controls keep the bottom row. A note the surface owes every message (the
|
|
8
|
+
* "can make mistakes" line) stands centred just above the pill.
|
|
6
9
|
*
|
|
7
10
|
* `prompt` builds @hanzo/ui's `Composer` props, so a surface that draws the
|
|
8
11
|
* composer through `Chat` (its `composer` prop) and one that draws it directly
|
|
@@ -14,7 +17,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
14
17
|
* `MEASURE` and this lines up with its words.
|
|
15
18
|
*/
|
|
16
19
|
import { SizableText, XStack, YStack } from '@hanzo/gui';
|
|
17
|
-
import { ArrowUp, AudioLines, Check, ChevronDown, Mic,
|
|
20
|
+
import { ArrowUp, AudioLines, Check, ChevronDown, Mic, Plus, Square } from '@hanzogui/lucide-icons-2';
|
|
18
21
|
import { Control } from '@hanzo/composer';
|
|
19
22
|
import { Popover, PopoverContent, PopoverTrigger, Separator, Tooltip, TooltipContent, TooltipTrigger } from '@hanzo/ui';
|
|
20
23
|
import { Composer } from '@hanzo/ui/chat';
|
|
@@ -29,8 +32,13 @@ export const MEASURE = 'var(--container-prose, 48rem)';
|
|
|
29
32
|
export const GAP = 20;
|
|
30
33
|
/** The composer's width: the measure less the thread column's 0.75rem gutters, centred, so its edges are the text's. */
|
|
31
34
|
export const measure = { width: 'calc(100% - 1.5rem)', maxW: `calc(${MEASURE} - 1.5rem)`, mx: 'auto' };
|
|
32
|
-
/** The round controls' box, px:
|
|
33
|
-
export const ROUND =
|
|
35
|
+
/** The round controls' box, px: the pill's row less its padding. */
|
|
36
|
+
export const ROUND = 32;
|
|
37
|
+
/** The pill's height on one line, px, and the corner a taller draft keeps. */
|
|
38
|
+
const PILL = 44;
|
|
39
|
+
const CORNER = 22;
|
|
40
|
+
/** About a line of draft at the measure: past it, or past a newline, the field takes the pill's width. */
|
|
41
|
+
const LINE = 72;
|
|
34
42
|
/**
|
|
35
43
|
* The frame as a pane of its own, floating on the pane it is drawn in: the
|
|
36
44
|
* `--pane-*` cut on design's first paper rung (a second rung's drop reads as a
|
|
@@ -62,7 +70,7 @@ export function Tune({ mind, limits, disabled = false }) {
|
|
|
62
70
|
const thinks = reasons(mind.model, mind.models);
|
|
63
71
|
const pace = EFFORTS.find((e) => e.id === mind.effort);
|
|
64
72
|
const routed = mind.model === ENSO;
|
|
65
|
-
return (_jsxs(Popover, { open: open, onOpenChange: setOpen, allowFlip: true, children: [_jsx(PopoverTrigger, { disabled: disabled, children: _jsxs(XStack, { "data-slot": "mind", role: "button", tabIndex: disabled ? -1 : 0, "aria-label": thinks ? `Model and effort: ${name}, ${pace.label}` : `Model: ${name}`, "aria-haspopup": "dialog", "aria-expanded": open, items: "center", gap: "$1.5", px: "$2.5", height:
|
|
73
|
+
return (_jsxs(Popover, { open: open, onOpenChange: setOpen, allowFlip: true, children: [_jsx(PopoverTrigger, { disabled: disabled, children: _jsxs(XStack, { "data-slot": "mind", role: "button", tabIndex: disabled ? -1 : 0, "aria-label": thinks ? `Model and effort: ${name}, ${pace.label}` : `Model: ${name}`, "aria-haspopup": "dialog", "aria-expanded": open, items: "center", gap: "$1.5", px: "$2.5", height: ROUND, maxW: 220, rounded: 999, cursor: disabled ? 'default' : 'pointer', hoverStyle: disabled ? undefined : { bg: '$hover' }, focusVisibleStyle: { outlineColor: '$outlineColor', outlineWidth: 2, outlineStyle: 'solid' }, opacity: disabled ? 0.4 : 1, onKeyDown: (e) => {
|
|
66
74
|
if (disabled || (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'ArrowUp' && e.key !== 'ArrowDown'))
|
|
67
75
|
return;
|
|
68
76
|
e.preventDefault?.();
|
|
@@ -84,9 +92,9 @@ export function Tune({ mind, limits, disabled = false }) {
|
|
|
84
92
|
setOpen(false);
|
|
85
93
|
}, loading: mind.loading, error: mind.error })] })] }) })] }));
|
|
86
94
|
}
|
|
87
|
-
/** The
|
|
95
|
+
/** The pill's `+`: files go with the message. Its name on hover. */
|
|
88
96
|
function Attach({ onAttach, disabled }) {
|
|
89
|
-
return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { children: _jsx(Control, { type: "button", size: ROUND, "aria-label": "Attach files", "aria-disabled": disabled || undefined, onClick: disabled ? undefined : onAttach, children: _jsx(
|
|
97
|
+
return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { children: _jsx(Control, { type: "button", size: ROUND, "aria-label": "Attach files", "aria-disabled": disabled || undefined, onClick: disabled ? undefined : onAttach, children: _jsx(Plus, { size: 18 }) }) }), _jsx(TooltipContent, { children: _jsx(SizableText, { size: "$2", children: "Attach files" }) })] }));
|
|
90
98
|
}
|
|
91
99
|
/** @hanzo/ui `Composer` props for the one composer. */
|
|
92
100
|
export function prompt(o) {
|
|
@@ -94,6 +102,11 @@ export function prompt(o) {
|
|
|
94
102
|
const busy = Boolean(o.busy);
|
|
95
103
|
const disabled = Boolean(o.disabled);
|
|
96
104
|
const live = busy ? Boolean(o.onStop) : lit && !disabled;
|
|
105
|
+
// A draft past a line takes the pill's width above the controls. The same
|
|
106
|
+
// field either way, moved by flex order alone: a different tree would remount
|
|
107
|
+
// it and drop the caret mid-word.
|
|
108
|
+
const tall = o.value.includes('\n') || o.value.length > LINE;
|
|
109
|
+
const head = o.head || o.note ? (_jsxs(YStack, { ...measure, gap: "$1.5", "data-slot": "prompt-head", children: [o.head, o.note ? (_jsx(SizableText, { "data-slot": "prompt-note", fontSize: 12, color: "$soft", text: "center", self: "center", children: o.note })) : null] })) : undefined;
|
|
97
110
|
return {
|
|
98
111
|
value: o.value,
|
|
99
112
|
onChange: o.onChange,
|
|
@@ -103,13 +116,33 @@ export function prompt(o) {
|
|
|
103
116
|
disabled,
|
|
104
117
|
...(o.placeholder ? { placeholder: o.placeholder } : {}),
|
|
105
118
|
...(o.label ? { label: o.label } : {}),
|
|
106
|
-
|
|
107
|
-
//
|
|
108
|
-
|
|
119
|
+
// The field: first and the pill's width on a tall draft, after `+` on a line.
|
|
120
|
+
// inline-style: flex `order`, which no gui prop carries; the field keeps its node either way.
|
|
121
|
+
// An empty field keeps its placeholder to the line, cut short rather than wrapped inside the pill.
|
|
122
|
+
field: {
|
|
123
|
+
...o.field,
|
|
124
|
+
style: { ...(o.field?.style ?? {}), order: tall ? 0 : 1, ...(o.value ? null : { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }) },
|
|
125
|
+
...(tall ? { width: '100%', flexBasis: '100%', px: '$2' } : null),
|
|
126
|
+
// A phone keeps the field a readable width: what does not fit beside it goes to a second row.
|
|
127
|
+
'$max-sm': { minW: 140 },
|
|
128
|
+
},
|
|
129
|
+
// Above the pill and at its width, so a chip lines up with the field's edge.
|
|
130
|
+
head,
|
|
131
|
+
inline: true,
|
|
109
132
|
...measure,
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
133
|
+
minH: PILL,
|
|
134
|
+
rounded: tall ? CORNER : 999,
|
|
135
|
+
pl: 6,
|
|
136
|
+
pr: 6,
|
|
137
|
+
gap: 4,
|
|
138
|
+
items: tall ? 'flex-end' : 'center',
|
|
139
|
+
flexWrap: tall ? 'wrap' : 'nowrap',
|
|
140
|
+
'$max-sm': { flexWrap: 'wrap', rounded: CORNER },
|
|
141
|
+
borderColor: '$borderColor',
|
|
142
|
+
bg: '$edge',
|
|
143
|
+
...(o.framed ? { boxShadow: 'var(--shadow-sheet-1)' } : null),
|
|
144
|
+
children: (_jsxs(XStack, { items: "center", gap: 2, "data-slot": "prompt-start", style: { order: tall ? 1 : 0 }, shrink: 0, children: [o.onAttach ? _jsx(Attach, { onAttach: o.onAttach, disabled: disabled }) : null, o.tools] })),
|
|
145
|
+
send: (_jsxs(XStack, { items: "center", gap: 2, "data-slot": "prompt-end", style: { order: 2 }, ml: tall ? 'auto' : 0, "$max-sm": { ml: 'auto' }, shrink: 0, children: [o.mind ? _jsx(Tune, { mind: o.mind, limits: o.limits, disabled: disabled && !busy }) : null, o.voice ? (_jsx(Control, { asChild: true, size: ROUND, children: _jsx(Voice, { voice: o.voice, disabled: disabled, says: { idle: 'Dictate', listening: 'Dictating — click to stop' }, children: (state) => _jsx(Mic, { size: 15, fill: state === 'idle' ? 'none' : 'currentColor' }) }) })) : null, o.talk ? (_jsx(Control, { asChild: true, size: ROUND, children: _jsx(Voice, { voice: o.talk, disabled: disabled, "data-slot": "composer-talk", says: { idle: 'Talk with Hanzo', listening: 'In conversation — click to hang up', speaking: 'Hanzo is speaking — talk to interrupt' }, children: () => _jsx(AudioLines, { size: 15 }) }) })) : null, _jsx(Control, { type: "button", size: ROUND, fill: true, rounded: 999, "data-slot": "composer-send", "aria-label": busy ? 'Stop' : 'Send', "aria-disabled": !live || undefined, opacity: live ? 1 : 0.4, onClick: () => {
|
|
113
146
|
if (!live)
|
|
114
147
|
return;
|
|
115
148
|
if (busy)
|
package/lib/run.js
CHANGED
|
@@ -388,7 +388,7 @@ export function Run({ id }) {
|
|
|
388
388
|
...(busy || waiting ? ['pause', 'resume', 'stop'] : []),
|
|
389
389
|
] }), _jsx(Button, { size: "sm", disabled: notify, onPress: () => void ask(), children: notify ? 'You will be notified' : 'Notify me' })] })) : null] }), _jsx(Prompt, { framed: true, value: draft, onChange: setDraft, onSend: send, ready: Boolean(draft.trim()), disabled: !signed || busy || waiting || !(running || finished), placeholder: placeholder, label: steering ? 'Steer this run' : 'Follow up on this run', head: files.length ? _jsx(XStack, { gap: "$1.5", flexWrap: "wrap", children: _jsx(Files, { files: files, onFiles: setFiles }) }) : undefined, onAttach: () => pick(files, setFiles, setNote),
|
|
390
390
|
// What a new run from here is made with; words that steer this run use neither.
|
|
391
|
-
mind: signed && !steering ? mind : null, limits: limits, voice: voice
|
|
391
|
+
mind: signed && !steering ? mind : null, limits: limits, voice: voice, note: "Hanzo is AI and can make mistakes." })] })] }), desk && !wide ? desk_(true) : null, desk && wide ? (_jsxs(YStack, { "data-slot": "desk", ref: (el) => {
|
|
392
392
|
deskBox.current = el instanceof HTMLElement ? el : null;
|
|
393
393
|
}, ...pane,
|
|
394
394
|
// A pane of its own beside the run's, the gutter between them.
|
package/lib/tabs.d.ts
CHANGED
|
@@ -8,6 +8,9 @@ export interface Tab {
|
|
|
8
8
|
/** A page's own bytes, rendered as `type`. */
|
|
9
9
|
body?: string;
|
|
10
10
|
type?: string;
|
|
11
|
+
/** The addresses a page came from, oldest first, and those Back stepped over, nearest first. */
|
|
12
|
+
back?: string[];
|
|
13
|
+
ahead?: string[];
|
|
11
14
|
}
|
|
12
15
|
/** The tabs in a scope, in order, and the chosen one. */
|
|
13
16
|
export interface Tabs {
|
|
@@ -32,9 +35,18 @@ export interface Deck extends Tabs {
|
|
|
32
35
|
pick: (id: string) => void;
|
|
33
36
|
shut: (id: string) => void;
|
|
34
37
|
move: (id: string, to: number) => void;
|
|
35
|
-
/** Point a page somewhere else. */
|
|
38
|
+
/** Point a page somewhere else: where it was goes on its Back, and Forward is forgotten. */
|
|
36
39
|
go: (id: string, url: string) => void;
|
|
40
|
+
/** A page's Back and Forward. */
|
|
41
|
+
back: (id: string) => void;
|
|
42
|
+
forward: (id: string) => void;
|
|
43
|
+
/** A page's title, as what it shows calls itself. */
|
|
44
|
+
rename: (id: string, title: string) => void;
|
|
37
45
|
}
|
|
46
|
+
/** A page pointed at `url`, where it was on its Back. */
|
|
47
|
+
export declare function went(t: Tab, url: string): Tab;
|
|
48
|
+
/** A page one step back, or forward: the address it leaves goes the other way. */
|
|
49
|
+
export declare function stepped(t: Tab, way: 'back' | 'forward'): Tab;
|
|
38
50
|
/** A scope's tabs: as kept, else `seed`, and every change kept. */
|
|
39
51
|
export declare function useDeck(scope: string, seed: Tabs): Deck;
|
|
40
52
|
/** Whether the panel is open in `mode`, kept; `first` before anyone has said. */
|
|
@@ -45,9 +57,18 @@ export declare function useShown(mode: string, first: boolean): readonly [boolea
|
|
|
45
57
|
* the conversation, never on a sheet it did not ask for.
|
|
46
58
|
*/
|
|
47
59
|
export declare function usePanel(mode: string, first: boolean, wide: boolean): readonly [boolean, (open: boolean) => void];
|
|
48
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* The panel's keys, from anywhere on the page but a field: Ctrl+Shift+B (⌘⇧B on
|
|
62
|
+
* a Mac), the tabs' own, and ⌘. / Ctrl+., which it had first.
|
|
63
|
+
*/
|
|
49
64
|
export declare function useKey(toggle: () => void): void;
|
|
50
65
|
/** The host and path of an address: as much as a title can honestly be. */
|
|
51
66
|
export declare function name(url: string): string;
|
|
67
|
+
/** Where words typed in a page's bar are looked up. */
|
|
68
|
+
export declare const SEARCH = "https://duckduckgo.com/?q=";
|
|
69
|
+
/** Whether what was typed in a page's bar is an address rather than words to look up. */
|
|
70
|
+
export declare function located(typed: string): boolean;
|
|
71
|
+
/** What a page's bar opens for what was typed: the address, or a search for the words. */
|
|
72
|
+
export declare function destination(typed: string): string;
|
|
52
73
|
/** An address as typed, made one that opens: https for a host, http for this machine. */
|
|
53
74
|
export declare function address(typed: string): string;
|
package/lib/tabs.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* new page; a tab is dragged to reorder (Ctrl/⌘+Shift+←/→ from the keyboard)
|
|
13
13
|
* and shut with its cross (Delete). A page's own bytes are kept with it, so an
|
|
14
14
|
* artifact's tab survives a reload; an object URL does not and is not kept.
|
|
15
|
+
* A page keeps the addresses it went through (`back`, `ahead`), so its bar's
|
|
16
|
+
* Back and Forward walk them as a browser's do.
|
|
15
17
|
*
|
|
16
18
|
*/
|
|
17
19
|
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
@@ -102,6 +104,27 @@ export function moved(was, id, to) {
|
|
|
102
104
|
tabs.splice(Math.max(0, Math.min(tabs.length, to)), 0, tab);
|
|
103
105
|
return { ...was, tabs };
|
|
104
106
|
}
|
|
107
|
+
/** How many addresses a page's Back keeps. */
|
|
108
|
+
const DEPTH = 50;
|
|
109
|
+
/** A page pointed at `url`, where it was on its Back. */
|
|
110
|
+
export function went(t, url) {
|
|
111
|
+
if (url === t.url)
|
|
112
|
+
return t;
|
|
113
|
+
const back = t.url ? [...(t.back ?? []), t.url].slice(-DEPTH) : (t.back ?? []);
|
|
114
|
+
return { ...t, url, title: name(url), body: undefined, type: undefined, back, ahead: [] };
|
|
115
|
+
}
|
|
116
|
+
/** A page one step back, or forward: the address it leaves goes the other way. */
|
|
117
|
+
export function stepped(t, way) {
|
|
118
|
+
const from = way === 'back' ? (t.back ?? []) : (t.ahead ?? []);
|
|
119
|
+
const url = way === 'back' ? from.at(-1) : from[0];
|
|
120
|
+
if (!url)
|
|
121
|
+
return t;
|
|
122
|
+
const rest = way === 'back' ? from.slice(0, -1) : from.slice(1);
|
|
123
|
+
const here = t.url ? [t.url] : [];
|
|
124
|
+
return way === 'back'
|
|
125
|
+
? { ...t, url, title: name(url), back: rest, ahead: [...here, ...(t.ahead ?? [])] }
|
|
126
|
+
: { ...t, url, title: name(url), ahead: rest, back: [...(t.back ?? []), ...here].slice(-DEPTH) };
|
|
127
|
+
}
|
|
105
128
|
/** A scope's tabs: as kept, else `seed`, and every change kept. */
|
|
106
129
|
export function useDeck(scope, seed) {
|
|
107
130
|
const kept = useSyncExternalStore(subscribe, () => read(scope), () => null);
|
|
@@ -113,7 +136,10 @@ export function useDeck(scope, seed) {
|
|
|
113
136
|
pick: (id) => change((was) => ({ ...was, at: id })),
|
|
114
137
|
shut: (id) => change((was) => shut(was, id)),
|
|
115
138
|
move: (id, to) => change((was) => moved(was, id, to)),
|
|
116
|
-
go: (id, url) => change((was) => ({ ...was, tabs: was.tabs.map((t) => (t.id === id ?
|
|
139
|
+
go: (id, url) => change((was) => ({ ...was, tabs: was.tabs.map((t) => (t.id === id ? went(t, url) : t)) })),
|
|
140
|
+
back: (id) => change((was) => ({ ...was, tabs: was.tabs.map((t) => (t.id === id ? stepped(t, 'back') : t)) })),
|
|
141
|
+
forward: (id) => change((was) => ({ ...was, tabs: was.tabs.map((t) => (t.id === id ? stepped(t, 'forward') : t)) })),
|
|
142
|
+
rename: (id, title) => change((was) => ({ ...was, tabs: was.tabs.map((t) => (t.id === id && title && t.title !== title ? { ...t, title } : t)) })),
|
|
117
143
|
};
|
|
118
144
|
}
|
|
119
145
|
const shownKey = (mode) => where(`${mode}.open`);
|
|
@@ -158,16 +184,28 @@ export function usePanel(mode, first, wide) {
|
|
|
158
184
|
const [sheet, setSheet] = useState(false);
|
|
159
185
|
return wide ? [kept, keep] : [sheet, setSheet];
|
|
160
186
|
}
|
|
161
|
-
/**
|
|
187
|
+
/** Whether a key went to something the reader types in, where it is theirs. */
|
|
188
|
+
const typing = (e) => {
|
|
189
|
+
const el = e.target;
|
|
190
|
+
return !!el && (el.isContentEditable || el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT');
|
|
191
|
+
};
|
|
192
|
+
/**
|
|
193
|
+
* The panel's keys, from anywhere on the page but a field: Ctrl+Shift+B (⌘⇧B on
|
|
194
|
+
* a Mac), the tabs' own, and ⌘. / Ctrl+., which it had first.
|
|
195
|
+
*/
|
|
162
196
|
export function useKey(toggle) {
|
|
163
197
|
const now = useRef(toggle);
|
|
164
198
|
now.current = toggle;
|
|
165
199
|
useEffect(() => {
|
|
166
200
|
const press = (e) => {
|
|
167
|
-
if ((e.metaKey || e.ctrlKey)
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
201
|
+
if (!(e.metaKey || e.ctrlKey) || e.altKey || typing(e))
|
|
202
|
+
return;
|
|
203
|
+
const tabs = e.shiftKey && e.key.toLowerCase() === 'b';
|
|
204
|
+
const dot = !e.shiftKey && e.key === '.';
|
|
205
|
+
if (!tabs && !dot)
|
|
206
|
+
return;
|
|
207
|
+
e.preventDefault();
|
|
208
|
+
now.current();
|
|
171
209
|
};
|
|
172
210
|
window.addEventListener('keydown', press);
|
|
173
211
|
return () => window.removeEventListener('keydown', press);
|
|
@@ -183,6 +221,26 @@ export function name(url) {
|
|
|
183
221
|
return url;
|
|
184
222
|
}
|
|
185
223
|
}
|
|
224
|
+
/** Where words typed in a page's bar are looked up. */
|
|
225
|
+
export const SEARCH = 'https://duckduckgo.com/?q=';
|
|
226
|
+
/** Whether what was typed in a page's bar is an address rather than words to look up. */
|
|
227
|
+
export function located(typed) {
|
|
228
|
+
const t = typed.trim();
|
|
229
|
+
if (!t || /\s/.test(t))
|
|
230
|
+
return false;
|
|
231
|
+
if (/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/i.test(t))
|
|
232
|
+
return true;
|
|
233
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(t) || /^(about|blob|data):/i.test(t))
|
|
234
|
+
return true;
|
|
235
|
+
return /^[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}(:\d+)?(\/.*)?$/i.test(t);
|
|
236
|
+
}
|
|
237
|
+
/** What a page's bar opens for what was typed: the address, or a search for the words. */
|
|
238
|
+
export function destination(typed) {
|
|
239
|
+
const t = typed.trim();
|
|
240
|
+
if (!t)
|
|
241
|
+
return '';
|
|
242
|
+
return located(t) ? address(t) : SEARCH + encodeURIComponent(t);
|
|
243
|
+
}
|
|
186
244
|
/** An address as typed, made one that opens: https for a host, http for this machine. */
|
|
187
245
|
export function address(typed) {
|
|
188
246
|
const t = typed.trim();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/build",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.55",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0 OR MIT",
|
|
6
6
|
"description": "Hanzo Build — say what you want, watch it built, deployed and running. The builder as a component, mounted by the Hanzo app at hanzo.ai.",
|