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/src/router.tsx
CHANGED
|
@@ -1,160 +1,180 @@
|
|
|
1
|
-
// Tiny pathname router shared by createApp, Link and useRoute. No
|
|
2
|
-
// dependency, history-API based: navigate() pushes, back/forward work,
|
|
3
|
-
// and every subscriber re-renders on change.
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
export function
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
1
|
+
// Tiny pathname router shared by createApp, Link and useRoute. No
|
|
2
|
+
// dependency, history-API based: navigate() pushes, back/forward work,
|
|
3
|
+
// and every subscriber re-renders on change.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createContext,
|
|
7
|
+
useContext,
|
|
8
|
+
useSyncExternalStore,
|
|
9
|
+
type AnchorHTMLAttributes,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { getShell } from "./bridge";
|
|
13
|
+
|
|
14
|
+
/** Params captured from a dynamic route: a string for a [id] segment, a
|
|
15
|
+
* string[] for a [...slug] catch-all. */
|
|
16
|
+
export type RouteParams = Record<string, string | string[]>;
|
|
17
|
+
|
|
18
|
+
// The active page's captured params, provided by createApp around the
|
|
19
|
+
// page. Empty for static routes.
|
|
20
|
+
export const ParamsContext = createContext<RouteParams>({});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* useParams returns the params captured from the current dynamic route.
|
|
24
|
+
* For pages/examples/page1/[id] on /examples/page1/7 it is { id: "7" };
|
|
25
|
+
* for a [...slug] catch-all the value is a string[]. Empty on static
|
|
26
|
+
* pages. Type it with the shape you expect: useParams<{ id: string }>().
|
|
27
|
+
*/
|
|
28
|
+
export function useParams<T extends RouteParams = RouteParams>(): T {
|
|
29
|
+
return useContext(ParamsContext) as T;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Subscriptions go through a window-scoped event, NOT a module-local
|
|
33
|
+
// set: if bundling ever instantiates this module twice (it happened -
|
|
34
|
+
// Vite served excluded node_modules source under ?v=hash and bare URLs
|
|
35
|
+
// at once), a local set splits the subscribers and navigation silently
|
|
36
|
+
// stops re-rendering half the app. The window survives any number of
|
|
37
|
+
// module copies; the route itself already lives in location.pathname.
|
|
38
|
+
const NAV_EVENT = "gantry:navigate";
|
|
39
|
+
|
|
40
|
+
function notify() {
|
|
41
|
+
window.dispatchEvent(new Event(NAV_EVENT));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof window !== "undefined") {
|
|
45
|
+
window.addEventListener("popstate", notify);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** navigate switches pages without a full reload. */
|
|
49
|
+
export function navigate(path: string): void {
|
|
50
|
+
if (location.pathname !== path) {
|
|
51
|
+
history.pushState(null, "", path);
|
|
52
|
+
}
|
|
53
|
+
notify();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** redirect replaces the current URL (no history entry) - used when a
|
|
57
|
+
* route no longer exists and the app falls back to another page. */
|
|
58
|
+
export function redirect(path: string): void {
|
|
59
|
+
if (location.pathname !== path) {
|
|
60
|
+
history.replaceState(null, "", path);
|
|
61
|
+
}
|
|
62
|
+
notify();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** goBack navigates back through the page history (popstate re-renders). */
|
|
66
|
+
export function goBack(): void {
|
|
67
|
+
history.back();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** goForward navigates forward through the page history. */
|
|
71
|
+
export function goForward(): void {
|
|
72
|
+
history.forward();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function subscribe(fn: () => void): () => void {
|
|
76
|
+
window.addEventListener(NAV_EVENT, fn);
|
|
77
|
+
return () => window.removeEventListener(NAV_EVENT, fn);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** useRoute returns the current pathname; re-renders on navigation. */
|
|
81
|
+
export function useRoute(): string {
|
|
82
|
+
return useSyncExternalStore(subscribe, () => location.pathname);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** isActive: exact match, or prefix match when exact is false. */
|
|
86
|
+
export function isActive(path: string, to: string, exact = true): boolean {
|
|
87
|
+
const clean = path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
|
|
88
|
+
if (exact || to === "/") return clean === to;
|
|
89
|
+
return clean === to || clean.startsWith(to + "/");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface LinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
93
|
+
/** Route to navigate to, e.g. "/settings". */
|
|
94
|
+
to: string;
|
|
95
|
+
/** Extra class applied while the link's route is current. */
|
|
96
|
+
activeClassName?: string;
|
|
97
|
+
/** Match route prefixes too ("/docs" active on "/docs/intro"). */
|
|
98
|
+
matchPrefix?: boolean;
|
|
99
|
+
children?: ReactNode;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Link navigates between pages and knows when it is the current one:
|
|
104
|
+
* it sets data-active="true"/"false" (style with
|
|
105
|
+
* [data-active="true"] in css, or data-[active=true]: variants in
|
|
106
|
+
* Tailwind), aria-current="page", and appends activeClassName when
|
|
107
|
+
* given.
|
|
108
|
+
*/
|
|
109
|
+
export interface ExternalLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
110
|
+
/** The external URL to open. */
|
|
111
|
+
href: string;
|
|
112
|
+
prefix?: string;
|
|
113
|
+
children?: ReactNode;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* ExternalLink opens a URL in the user's DEFAULT BROWSER instead of
|
|
118
|
+
* navigating the app's webview, and (in the native window) renders
|
|
119
|
+
* without an href so no URL-preview bubble ever appears. In a plain
|
|
120
|
+
* browser tab it falls back to a normal new-tab link.
|
|
121
|
+
*/
|
|
122
|
+
export function ExternalLink({ href, prefix, children, onClick, ...rest }: ExternalLinkProps) {
|
|
123
|
+
const shell = getShell(prefix);
|
|
124
|
+
if (!shell.available) {
|
|
125
|
+
return (
|
|
126
|
+
<a href={href} target="_blank" rel="noreferrer" onClick={onClick} {...rest}>
|
|
127
|
+
{children}
|
|
128
|
+
</a>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return (
|
|
132
|
+
<a
|
|
133
|
+
role="link"
|
|
134
|
+
tabIndex={0}
|
|
135
|
+
onClick={(e) => {
|
|
136
|
+
onClick?.(e);
|
|
137
|
+
if (!e.defaultPrevented) shell.openExternal(href);
|
|
138
|
+
}}
|
|
139
|
+
onKeyDown={(e) => {
|
|
140
|
+
if (e.key === "Enter") shell.openExternal(href);
|
|
141
|
+
}}
|
|
142
|
+
{...rest}
|
|
143
|
+
>
|
|
144
|
+
{children}
|
|
145
|
+
</a>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function Link({ to, activeClassName, matchPrefix, className, children, onClick, ...rest }: LinkProps) {
|
|
150
|
+
const path = useRoute();
|
|
151
|
+
const active = isActive(path, to, !matchPrefix);
|
|
152
|
+
const cls = [className, active ? activeClassName : undefined].filter(Boolean).join(" ");
|
|
153
|
+
// Deliberately NO href: navigation is client-side anyway, and an
|
|
154
|
+
// href makes the webview show the URL preview bubble in the bottom
|
|
155
|
+
// corner on hover - a browser artifact that looks wrong in a
|
|
156
|
+
// desktop app. role/tabIndex/keyboard keep it accessible.
|
|
157
|
+
return (
|
|
158
|
+
<a
|
|
159
|
+
role="link"
|
|
160
|
+
tabIndex={0}
|
|
161
|
+
className={cls || undefined}
|
|
162
|
+
data-active={active ? "true" : "false"}
|
|
163
|
+
aria-current={active ? "page" : undefined}
|
|
164
|
+
onClick={(e) => {
|
|
165
|
+
onClick?.(e);
|
|
166
|
+
if (e.defaultPrevented || e.button !== 0) return;
|
|
167
|
+
navigate(to);
|
|
168
|
+
}}
|
|
169
|
+
onKeyDown={(e) => {
|
|
170
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
171
|
+
e.preventDefault();
|
|
172
|
+
navigate(to);
|
|
173
|
+
}
|
|
174
|
+
}}
|
|
175
|
+
{...rest}
|
|
176
|
+
>
|
|
177
|
+
{children}
|
|
178
|
+
</a>
|
|
179
|
+
);
|
|
180
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import { callGo, connect } from "./socket";
|
|
2
|
+
import { callGo, connect, GantryCallError } from "./socket";
|
|
3
3
|
|
|
4
4
|
export interface Service {
|
|
5
5
|
/** call awaits a Go function registered on this service (ui.Calls). */
|
|
@@ -35,6 +35,8 @@ export function useService(name: string): Service {
|
|
|
35
35
|
export interface CallResult<T> {
|
|
36
36
|
data: T | undefined;
|
|
37
37
|
error: string | null;
|
|
38
|
+
/** The gerr code of a failed call ("panic.call", "auth.expired", ...). */
|
|
39
|
+
code: string | null;
|
|
38
40
|
loading: boolean;
|
|
39
41
|
/** reload re-runs the call. */
|
|
40
42
|
reload: () => void;
|
|
@@ -48,6 +50,7 @@ export interface CallResult<T> {
|
|
|
48
50
|
export function useCall<T = unknown>(key: string, name: string, payload?: unknown): CallResult<T> {
|
|
49
51
|
const [data, setData] = useState<T | undefined>(undefined);
|
|
50
52
|
const [error, setError] = useState<string | null>(null);
|
|
53
|
+
const [code, setCode] = useState<string | null>(null);
|
|
51
54
|
const [loading, setLoading] = useState(true);
|
|
52
55
|
const [tick, setTick] = useState(0);
|
|
53
56
|
const alive = useRef(true);
|
|
@@ -57,6 +60,7 @@ export function useCall<T = unknown>(key: string, name: string, payload?: unknow
|
|
|
57
60
|
alive.current = true;
|
|
58
61
|
setLoading(true);
|
|
59
62
|
setError(null);
|
|
63
|
+
setCode(null);
|
|
60
64
|
callGo(key, name, payload)
|
|
61
65
|
.then((v) => {
|
|
62
66
|
if (alive.current) {
|
|
@@ -67,6 +71,7 @@ export function useCall<T = unknown>(key: string, name: string, payload?: unknow
|
|
|
67
71
|
.catch((e: Error) => {
|
|
68
72
|
if (alive.current) {
|
|
69
73
|
setError(e.message);
|
|
74
|
+
if (e instanceof GantryCallError && e.code) setCode(e.code);
|
|
70
75
|
setLoading(false);
|
|
71
76
|
}
|
|
72
77
|
});
|
|
@@ -78,5 +83,5 @@ export function useCall<T = unknown>(key: string, name: string, payload?: unknow
|
|
|
78
83
|
}, [key, name, payloadKey, tick]);
|
|
79
84
|
|
|
80
85
|
const reload = useCallback(() => setTick((t) => t + 1), []);
|
|
81
|
-
return { data, error, loading, reload };
|
|
86
|
+
return { data, error, code, loading, reload };
|
|
82
87
|
}
|