gantry-web 0.3.5 → 0.4.0
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/package.json +43 -43
- package/src/Await.tsx +85 -0
- package/src/ErrorScreen.tsx +98 -0
- package/src/ResizeFrame.tsx +43 -40
- package/src/app.tsx +368 -201
- package/src/bridge.ts +95 -94
- package/src/env.ts +71 -0
- package/src/errors.ts +214 -0
- package/src/index.ts +32 -22
- package/src/resources.ts +17 -0
- package/src/router.tsx +180 -160
- package/src/service.ts +7 -2
- package/src/socket.ts +227 -187
- package/src/styles.css +247 -0
- package/src/vite/index.js +57 -0
package/package.json
CHANGED
|
@@ -1,43 +1,43 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "gantry-web",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Frontend half of the Gantry desktop app framework: window chrome, native bridge, Tea runtime, Vite plugin",
|
|
5
|
-
"license": "MIT",
|
|
6
|
-
"repository": {
|
|
7
|
-
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/B-Commissions/Gantry.git",
|
|
9
|
-
"directory": "web"
|
|
10
|
-
},
|
|
11
|
-
"homepage": "https://github.com/B-Commissions/Gantry",
|
|
12
|
-
"keywords": [
|
|
13
|
-
"gantry",
|
|
14
|
-
"desktop",
|
|
15
|
-
"webview",
|
|
16
|
-
"go",
|
|
17
|
-
"react",
|
|
18
|
-
"vite-plugin"
|
|
19
|
-
],
|
|
20
|
-
"type": "module",
|
|
21
|
-
"exports": {
|
|
22
|
-
".": "./src/index.ts",
|
|
23
|
-
"./tea": "./src/tea/index.ts",
|
|
24
|
-
"./vite": "./src/vite/index.js",
|
|
25
|
-
"./styles.css": "./src/styles.css",
|
|
26
|
-
"./types": "./types/index.d.ts"
|
|
27
|
-
},
|
|
28
|
-
"files": [
|
|
29
|
-
"src",
|
|
30
|
-
"types"
|
|
31
|
-
],
|
|
32
|
-
"peerDependencies": {
|
|
33
|
-
"lucide-react": "*",
|
|
34
|
-
"react": ">=18",
|
|
35
|
-
"react-dom": ">=18",
|
|
36
|
-
"vite": ">=5"
|
|
37
|
-
},
|
|
38
|
-
"peerDependenciesMeta": {
|
|
39
|
-
"vite": {
|
|
40
|
-
"optional": true
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "gantry-web",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Frontend half of the Gantry desktop app framework: window chrome, native bridge, Tea runtime, Vite plugin",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/B-Commissions/Gantry.git",
|
|
9
|
+
"directory": "web"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/B-Commissions/Gantry",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"gantry",
|
|
14
|
+
"desktop",
|
|
15
|
+
"webview",
|
|
16
|
+
"go",
|
|
17
|
+
"react",
|
|
18
|
+
"vite-plugin"
|
|
19
|
+
],
|
|
20
|
+
"type": "module",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./src/index.ts",
|
|
23
|
+
"./tea": "./src/tea/index.ts",
|
|
24
|
+
"./vite": "./src/vite/index.js",
|
|
25
|
+
"./styles.css": "./src/styles.css",
|
|
26
|
+
"./types": "./types/index.d.ts"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"src",
|
|
30
|
+
"types"
|
|
31
|
+
],
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"lucide-react": "*",
|
|
34
|
+
"react": ">=18",
|
|
35
|
+
"react-dom": ">=18",
|
|
36
|
+
"vite": ">=5"
|
|
37
|
+
},
|
|
38
|
+
"peerDependenciesMeta": {
|
|
39
|
+
"vite": {
|
|
40
|
+
"optional": true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/Await.tsx
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Loading states for Go calls. useCall() already exposes { loading,
|
|
2
|
+
// error, data }; Await is the declarative wrapper: show a developer-
|
|
3
|
+
// defined fallback (a spinner, <Skeleton/>, anything) while the call
|
|
4
|
+
// is in flight, an error card with retry when it fails, and the
|
|
5
|
+
// children once data is there.
|
|
6
|
+
//
|
|
7
|
+
// const users = useCall<User[]>("api", "listUsers");
|
|
8
|
+
// return (
|
|
9
|
+
// <Await call={users} fallback={<Skeleton lines={4} />}>
|
|
10
|
+
// {(list) => <ul>{list.map(...)}</ul>}
|
|
11
|
+
// </Await>
|
|
12
|
+
// );
|
|
13
|
+
|
|
14
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
15
|
+
import type { CallResult } from "./service";
|
|
16
|
+
|
|
17
|
+
export interface AwaitProps<T> {
|
|
18
|
+
/** The in-flight call (from useCall, or anything CallResult-shaped). */
|
|
19
|
+
call: CallResult<T>;
|
|
20
|
+
/** What to show while loading: a spinner, <Skeleton/>, custom JSX.
|
|
21
|
+
* Default: <Skeleton lines={3}/>. */
|
|
22
|
+
fallback?: ReactNode;
|
|
23
|
+
/** Custom error rendering; default is a small card with a Retry
|
|
24
|
+
* button. */
|
|
25
|
+
renderError?: (error: string, code: string | null, reload: () => void) => ReactNode;
|
|
26
|
+
/** Renders the loaded data. */
|
|
27
|
+
children: (data: T) => ReactNode;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function Await<T>({ call, fallback, renderError, children }: AwaitProps<T>) {
|
|
31
|
+
if (call.loading) {
|
|
32
|
+
return <>{fallback ?? <Skeleton lines={3} />}</>;
|
|
33
|
+
}
|
|
34
|
+
if (call.error !== null) {
|
|
35
|
+
if (renderError) return <>{renderError(call.error, call.code, call.reload)}</>;
|
|
36
|
+
return (
|
|
37
|
+
<div className="gantry-await-error">
|
|
38
|
+
<span className="gantry-await-error-message">{call.error}</span>
|
|
39
|
+
{call.code && <span className="gantry-await-error-code">{call.code}</span>}
|
|
40
|
+
<button className="gantry-await-retry" onClick={call.reload}>
|
|
41
|
+
Retry
|
|
42
|
+
</button>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return <>{children(call.data as T)}</>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SkeletonProps {
|
|
50
|
+
/** Placeholder text lines (the last one renders shorter). */
|
|
51
|
+
lines?: number;
|
|
52
|
+
/** Explicit size for a single block (a chart area, an avatar). */
|
|
53
|
+
width?: number | string;
|
|
54
|
+
height?: number | string;
|
|
55
|
+
/** Round the block fully (avatars). */
|
|
56
|
+
circle?: boolean;
|
|
57
|
+
className?: string;
|
|
58
|
+
style?: CSSProperties;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Skeleton renders shimmering placeholder blocks sized like the
|
|
63
|
+
* content they stand in for: <Skeleton lines={4}/> for text,
|
|
64
|
+
* <Skeleton width={240} height={120}/> for a block, circle for
|
|
65
|
+
* avatars.
|
|
66
|
+
*/
|
|
67
|
+
export function Skeleton({ lines, width, height, circle, className, style }: SkeletonProps) {
|
|
68
|
+
if (lines === undefined && (width !== undefined || height !== undefined || circle)) {
|
|
69
|
+
const s: CSSProperties = {
|
|
70
|
+
width: width ?? (circle ? 40 : "100%"),
|
|
71
|
+
height: height ?? (circle ? 40 : 16),
|
|
72
|
+
borderRadius: circle ? "50%" : undefined,
|
|
73
|
+
...style,
|
|
74
|
+
};
|
|
75
|
+
return <div className={"gantry-skeleton" + (className ? " " + className : "")} style={s} />;
|
|
76
|
+
}
|
|
77
|
+
const n = Math.max(1, lines ?? 3);
|
|
78
|
+
return (
|
|
79
|
+
<div className={"gantry-skeleton-lines" + (className ? " " + className : "")} style={style}>
|
|
80
|
+
{Array.from({ length: n }, (_, i) => (
|
|
81
|
+
<div key={i} className="gantry-skeleton" style={{ width: i === n - 1 && n > 1 ? "60%" : "100%" }} />
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// The built-in error UI: a full-screen overlay for fatal render
|
|
2
|
+
// crashes and dismissible banners for non-fatal errors (Go panics,
|
|
3
|
+
// unhandled rejections). Development shows the full story - message,
|
|
4
|
+
// stack, the page, and the "what led here" action trail; production
|
|
5
|
+
// shows a friendly minimal card. Replace it per app via
|
|
6
|
+
// createApp({ errors: { screen: MyScreen } }).
|
|
7
|
+
|
|
8
|
+
import type { FC } from "react";
|
|
9
|
+
import type { GantryCrumb, GantryErrorInfo } from "./errors";
|
|
10
|
+
|
|
11
|
+
export interface ErrorScreenProps {
|
|
12
|
+
error: GantryErrorInfo;
|
|
13
|
+
/** Resolved app mode; null while unknown (treated as development). */
|
|
14
|
+
mode: "development" | "production" | null;
|
|
15
|
+
/** "fatal" = render crash overlay, "notice" = dismissible banner. */
|
|
16
|
+
variant: "fatal" | "notice";
|
|
17
|
+
onDismiss: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ago(iso: string, now: number): string {
|
|
21
|
+
const t = Date.parse(iso);
|
|
22
|
+
if (Number.isNaN(t)) return "";
|
|
23
|
+
const s = Math.max(0, Math.round((now - t) / 1000));
|
|
24
|
+
if (s < 1) return "now";
|
|
25
|
+
if (s < 60) return `${s}s ago`;
|
|
26
|
+
return `${Math.floor(s / 60)}m${s % 60 ? ` ${s % 60}s` : ""} ago`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function Trail({ trail }: { trail: GantryCrumb[] }) {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
return (
|
|
32
|
+
<div className="gantry-error-trail">
|
|
33
|
+
<div className="gantry-error-heading">What led here</div>
|
|
34
|
+
{trail.map((c, i) => (
|
|
35
|
+
<div key={i} className={"gantry-error-crumb" + (c.ok ? "" : " gantry-error-crumb-failed")}>
|
|
36
|
+
<span className="gantry-error-crumb-time">{ago(c.time, now)}</span>
|
|
37
|
+
<span className="gantry-error-crumb-type">{c.type}</span>
|
|
38
|
+
<span className="gantry-error-crumb-detail">{c.detail}</span>
|
|
39
|
+
</div>
|
|
40
|
+
))}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function Detail({ error }: { error: GantryErrorInfo }) {
|
|
46
|
+
return (
|
|
47
|
+
<>
|
|
48
|
+
<div className="gantry-error-meta">
|
|
49
|
+
<span className="gantry-error-kind">{error.kind}</span>
|
|
50
|
+
{error.code && <span className="gantry-error-code">{error.code}</span>}
|
|
51
|
+
{error.page && <span className="gantry-error-page">on {error.page}</span>}
|
|
52
|
+
{error.source && <span className="gantry-error-source">{error.source}</span>}
|
|
53
|
+
<span className="gantry-error-origin">{error.origin === "go" ? "Go" : "JS"}</span>
|
|
54
|
+
</div>
|
|
55
|
+
{error.stack && <pre className="gantry-error-stack">{error.stack}</pre>}
|
|
56
|
+
{error.componentStack && (
|
|
57
|
+
<>
|
|
58
|
+
<div className="gantry-error-heading">Component stack</div>
|
|
59
|
+
<pre className="gantry-error-stack">{error.componentStack}</pre>
|
|
60
|
+
</>
|
|
61
|
+
)}
|
|
62
|
+
{error.trail && error.trail.length > 0 && <Trail trail={error.trail} />}
|
|
63
|
+
</>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const ErrorScreen: FC<ErrorScreenProps> = ({ error, mode, variant, onDismiss }) => {
|
|
68
|
+
const dev = mode !== "production";
|
|
69
|
+
if (variant === "fatal") {
|
|
70
|
+
return (
|
|
71
|
+
<div className="gantry-error-overlay">
|
|
72
|
+
<div className="gantry-error-card">
|
|
73
|
+
<div className="gantry-error-title">{dev ? "The page crashed" : "Something went wrong"}</div>
|
|
74
|
+
{dev ? <div className="gantry-error-message">{error.message}</div> : <div className="gantry-error-message">The app hit an unexpected error. Reloading usually fixes it.</div>}
|
|
75
|
+
{dev && <Detail error={error} />}
|
|
76
|
+
<div className="gantry-error-actions">
|
|
77
|
+
<button className="gantry-error-button" onClick={() => location.reload()}>
|
|
78
|
+
Reload
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return (
|
|
86
|
+
<div className="gantry-error-banner">
|
|
87
|
+
<div className="gantry-error-banner-head">
|
|
88
|
+
<span className="gantry-error-banner-title">
|
|
89
|
+
{error.origin === "go" ? "Go error" : "Error"}: {error.message}
|
|
90
|
+
</span>
|
|
91
|
+
<button className="gantry-error-dismiss" onClick={onDismiss} aria-label="Dismiss">
|
|
92
|
+
×
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
{dev && <Detail error={error} />}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
};
|
package/src/ResizeFrame.tsx
CHANGED
|
@@ -1,40 +1,43 @@
|
|
|
1
|
-
import { useShell, useShellCaps } from "./hooks";
|
|
2
|
-
|
|
3
|
-
const EDGES = [
|
|
4
|
-
{ edge: "n", style: { top: 0, left: 8, right: 8, height: 5, cursor: "ns-resize" } },
|
|
5
|
-
{ edge: "s", style: { bottom: 0, left: 8, right: 8, height: 5, cursor: "ns-resize" } },
|
|
6
|
-
{ edge: "w", style: { left: 0, top: 8, bottom: 8, width: 5, cursor: "ew-resize" } },
|
|
7
|
-
{ edge: "e", style: { right: 0, top: 8, bottom: 8, width: 5, cursor: "ew-resize" } },
|
|
8
|
-
{ edge: "nw", style: { top: 0, left: 0, width: 10, height: 10, cursor: "nwse-resize" } },
|
|
9
|
-
{ edge: "ne", style: { top: 0, right: 0, width: 10, height: 10, cursor: "nesw-resize" } },
|
|
10
|
-
{ edge: "sw", style: { bottom: 0, left: 0, width: 10, height: 10, cursor: "nesw-resize" } },
|
|
11
|
-
{ edge: "se", style: { bottom: 0, right: 0, width: 10, height: 10, cursor: "nwse-resize" } },
|
|
12
|
-
] as const;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* ResizeFrame renders invisible edge strips that start a native
|
|
16
|
-
* interactive resize
|
|
17
|
-
* no OS
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
1
|
+
import { useShell, useShellCaps } from "./hooks";
|
|
2
|
+
|
|
3
|
+
const EDGES = [
|
|
4
|
+
{ edge: "n", style: { top: 0, left: 8, right: 8, height: 5, cursor: "ns-resize" } },
|
|
5
|
+
{ edge: "s", style: { bottom: 0, left: 8, right: 8, height: 5, cursor: "ns-resize" } },
|
|
6
|
+
{ edge: "w", style: { left: 0, top: 8, bottom: 8, width: 5, cursor: "ew-resize" } },
|
|
7
|
+
{ edge: "e", style: { right: 0, top: 8, bottom: 8, width: 5, cursor: "ew-resize" } },
|
|
8
|
+
{ edge: "nw", style: { top: 0, left: 0, width: 10, height: 10, cursor: "nwse-resize" } },
|
|
9
|
+
{ edge: "ne", style: { top: 0, right: 0, width: 10, height: 10, cursor: "nesw-resize" } },
|
|
10
|
+
{ edge: "sw", style: { bottom: 0, left: 0, width: 10, height: 10, cursor: "nesw-resize" } },
|
|
11
|
+
{ edge: "se", style: { bottom: 0, right: 0, width: 10, height: 10, cursor: "nwse-resize" } },
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* ResizeFrame renders invisible edge strips that start a native
|
|
16
|
+
* interactive resize and carry the resize cursor. A frameless window has
|
|
17
|
+
* no draggable OS frame: on Linux the compositor offers no hit-test, and
|
|
18
|
+
* on Windows the WebView2 child covers the client area and swallows the
|
|
19
|
+
* parent's WM_NCHITTEST resize margin - so on both the frontend draws the
|
|
20
|
+
* strips and calls the ResizeEdge binding on mousedown. Framed windows
|
|
21
|
+
* (and the browser) render nothing.
|
|
22
|
+
*/
|
|
23
|
+
export function ResizeFrame({ prefix }: { prefix?: string }) {
|
|
24
|
+
const shell = useShell(prefix);
|
|
25
|
+
const caps = useShellCaps(prefix);
|
|
26
|
+
const native = caps?.platform === "linux" || caps?.platform === "windows";
|
|
27
|
+
if (!native || !caps?.frameless) return null;
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
{EDGES.map(({ edge, style }) => (
|
|
31
|
+
<div
|
|
32
|
+
key={edge}
|
|
33
|
+
style={{ position: "fixed", zIndex: 40, ...style }}
|
|
34
|
+
onMouseDown={(e) => {
|
|
35
|
+
if (e.button !== 0) return;
|
|
36
|
+
e.preventDefault();
|
|
37
|
+
shell.resizeEdge(edge);
|
|
38
|
+
}}
|
|
39
|
+
/>
|
|
40
|
+
))}
|
|
41
|
+
</>
|
|
42
|
+
);
|
|
43
|
+
}
|