gantry-web 0.1.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/README.md +47 -0
- package/package.json +43 -0
- package/src/DragStrip.tsx +36 -0
- package/src/ResizeFrame.tsx +40 -0
- package/src/TitleBar.tsx +137 -0
- package/src/app.tsx +155 -0
- package/src/bridge.ts +94 -0
- package/src/gostate.ts +29 -0
- package/src/hooks.ts +31 -0
- package/src/index.ts +20 -0
- package/src/paired.ts +57 -0
- package/src/router.tsx +154 -0
- package/src/service.ts +82 -0
- package/src/socket.ts +187 -0
- package/src/styles.css +309 -0
- package/src/tea/Runtime.tsx +195 -0
- package/src/tea/index.ts +5 -0
- package/src/vite/index.js +203 -0
- package/src/zoom.ts +21 -0
- package/types/index.d.ts +36 -0
package/src/styles.css
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/* Gantry chrome + Tea built-in styles. Theme everything from the app's
|
|
2
|
+
root index.css by overriding these variables - the components read
|
|
3
|
+
them live, so a retheme is just new values, no component changes. */
|
|
4
|
+
|
|
5
|
+
:root {
|
|
6
|
+
--gantry-bg: #101012;
|
|
7
|
+
--gantry-fg: #e8e8ea;
|
|
8
|
+
--gantry-fg-dim: #9a9aa0;
|
|
9
|
+
--gantry-accent: #6ea8fe;
|
|
10
|
+
--gantry-border: #2a2a2e;
|
|
11
|
+
--gantry-titlebar-h: 40px;
|
|
12
|
+
--gantry-titlebar-bg: transparent;
|
|
13
|
+
--gantry-titlebar-fg: var(--gantry-fg);
|
|
14
|
+
--gantry-btn-hover: rgba(255, 255, 255, 0.08);
|
|
15
|
+
--gantry-close-hover: #c42b1c;
|
|
16
|
+
--gantry-control-bg: #1a1a1e;
|
|
17
|
+
--gantry-control-border: #34343a;
|
|
18
|
+
--gantry-radius: 6px;
|
|
19
|
+
--gantry-font:
|
|
20
|
+
"Segoe UI", system-ui, -apple-system, sans-serif;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* ---- window chrome ---- */
|
|
24
|
+
|
|
25
|
+
.gantry-titlebar {
|
|
26
|
+
position: relative;
|
|
27
|
+
display: flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
flex: none;
|
|
30
|
+
background: var(--gantry-titlebar-bg);
|
|
31
|
+
color: var(--gantry-titlebar-fg);
|
|
32
|
+
font-family: var(--gantry-font);
|
|
33
|
+
user-select: none;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.gantry-dragstrip {
|
|
37
|
+
position: absolute;
|
|
38
|
+
top: 0;
|
|
39
|
+
z-index: 30;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* The custom slots sit flush against the window edges; space them
|
|
43
|
+
with your own padding/margins if you want inset. */
|
|
44
|
+
.gantry-titlebar-left {
|
|
45
|
+
display: flex;
|
|
46
|
+
align-items: stretch;
|
|
47
|
+
height: 100%;
|
|
48
|
+
position: relative;
|
|
49
|
+
z-index: 31;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.gantry-titlebar-right {
|
|
53
|
+
display: flex;
|
|
54
|
+
align-items: stretch;
|
|
55
|
+
height: 100%;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* Titlebar buttons for YOUR controls (back/forward, menus, actions) -
|
|
59
|
+
styled independently of the window buttons via their own variables. */
|
|
60
|
+
.gantry-tbbtn {
|
|
61
|
+
display: inline-flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
justify-content: center;
|
|
64
|
+
min-width: var(--gantry-tbbtn-w, 40px);
|
|
65
|
+
height: 100%;
|
|
66
|
+
padding: 0;
|
|
67
|
+
border: none;
|
|
68
|
+
border-radius: 0;
|
|
69
|
+
outline: none;
|
|
70
|
+
background: var(--gantry-tbbtn-bg, transparent);
|
|
71
|
+
color: var(--gantry-tbbtn-fg, var(--gantry-titlebar-fg));
|
|
72
|
+
cursor: default;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.gantry-tbbtn:hover {
|
|
76
|
+
background: var(--gantry-tbbtn-hover, var(--gantry-btn-hover));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.gantry-titlebar-title {
|
|
80
|
+
position: absolute;
|
|
81
|
+
left: 0;
|
|
82
|
+
right: 0;
|
|
83
|
+
text-align: center;
|
|
84
|
+
font-size: 13px;
|
|
85
|
+
color: var(--gantry-fg-dim);
|
|
86
|
+
pointer-events: none; /* stays part of the drag band */
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* Left/right-aligned title text (inside the flex sections). */
|
|
90
|
+
.gantry-titlebar-text {
|
|
91
|
+
display: flex;
|
|
92
|
+
align-items: center;
|
|
93
|
+
font-size: 13px;
|
|
94
|
+
color: var(--gantry-fg-dim);
|
|
95
|
+
padding: 0 8px;
|
|
96
|
+
pointer-events: none;
|
|
97
|
+
white-space: nowrap;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.gantry-titlebar-end {
|
|
101
|
+
display: flex;
|
|
102
|
+
align-items: stretch;
|
|
103
|
+
height: 100%;
|
|
104
|
+
margin-left: auto;
|
|
105
|
+
position: relative;
|
|
106
|
+
z-index: 31;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.gantry-winbtn {
|
|
110
|
+
display: inline-flex;
|
|
111
|
+
align-items: center;
|
|
112
|
+
justify-content: center;
|
|
113
|
+
width: 46px;
|
|
114
|
+
height: 100%; /* follows the bar's height, thin or tall */
|
|
115
|
+
padding: 0;
|
|
116
|
+
border: none;
|
|
117
|
+
border-radius: 0;
|
|
118
|
+
outline: none;
|
|
119
|
+
background: transparent;
|
|
120
|
+
color: var(--gantry-titlebar-fg);
|
|
121
|
+
cursor: default;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.gantry-winbtn:hover {
|
|
125
|
+
background: var(--gantry-btn-hover);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.gantry-winbtn-close:hover {
|
|
129
|
+
background: var(--gantry-close-hover);
|
|
130
|
+
color: #fff;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* ---- Tea built-ins ---- */
|
|
134
|
+
|
|
135
|
+
.gantry-tea-column {
|
|
136
|
+
display: flex;
|
|
137
|
+
flex-direction: column;
|
|
138
|
+
gap: 8px;
|
|
139
|
+
min-height: 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.gantry-tea-row {
|
|
143
|
+
display: flex;
|
|
144
|
+
flex-direction: row;
|
|
145
|
+
align-items: center;
|
|
146
|
+
gap: 8px;
|
|
147
|
+
min-width: 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.gantry-tea-text {
|
|
151
|
+
font-family: var(--gantry-font);
|
|
152
|
+
color: var(--gantry-fg);
|
|
153
|
+
font-size: 14px;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.gantry-tea-heading {
|
|
157
|
+
font-family: var(--gantry-font);
|
|
158
|
+
color: var(--gantry-fg);
|
|
159
|
+
font-size: 18px;
|
|
160
|
+
font-weight: 600;
|
|
161
|
+
margin: 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.gantry-tea-button {
|
|
165
|
+
font-family: var(--gantry-font);
|
|
166
|
+
font-size: 13px;
|
|
167
|
+
color: var(--gantry-fg);
|
|
168
|
+
background: var(--gantry-control-bg);
|
|
169
|
+
border: 1px solid var(--gantry-control-border);
|
|
170
|
+
border-radius: var(--gantry-radius);
|
|
171
|
+
padding: 6px 14px;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.gantry-tea-button:hover {
|
|
175
|
+
border-color: var(--gantry-accent);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.gantry-tea-button:active {
|
|
179
|
+
transform: translateY(1px);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.gantry-tea-input,
|
|
183
|
+
.gantry-tea-select {
|
|
184
|
+
font-family: var(--gantry-font);
|
|
185
|
+
font-size: 13px;
|
|
186
|
+
color: var(--gantry-fg);
|
|
187
|
+
background: var(--gantry-control-bg);
|
|
188
|
+
border: 1px solid var(--gantry-control-border);
|
|
189
|
+
border-radius: var(--gantry-radius);
|
|
190
|
+
padding: 6px 10px;
|
|
191
|
+
outline: none;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.gantry-tea-input:focus,
|
|
195
|
+
.gantry-tea-select:focus {
|
|
196
|
+
border-color: var(--gantry-accent);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.gantry-tea-checkbox {
|
|
200
|
+
display: inline-flex;
|
|
201
|
+
align-items: center;
|
|
202
|
+
gap: 8px;
|
|
203
|
+
font-family: var(--gantry-font);
|
|
204
|
+
font-size: 13px;
|
|
205
|
+
color: var(--gantry-fg);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.gantry-tea-divider {
|
|
209
|
+
border: none;
|
|
210
|
+
border-top: 1px solid var(--gantry-border);
|
|
211
|
+
margin: 4px 0;
|
|
212
|
+
width: 100%;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.gantry-tea-spacer {
|
|
216
|
+
flex: 1;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.gantry-tea-progress {
|
|
220
|
+
height: 6px;
|
|
221
|
+
border-radius: 3px;
|
|
222
|
+
background: var(--gantry-control-bg);
|
|
223
|
+
overflow: hidden;
|
|
224
|
+
min-width: 80px;
|
|
225
|
+
flex: 1;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.gantry-tea-progress-fill {
|
|
229
|
+
height: 100%;
|
|
230
|
+
background: var(--gantry-accent);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.gantry-tea-unknown {
|
|
234
|
+
font-family: var(--gantry-font);
|
|
235
|
+
font-size: 12px;
|
|
236
|
+
color: #ff8080;
|
|
237
|
+
border: 1px dashed #ff8080;
|
|
238
|
+
border-radius: var(--gantry-radius);
|
|
239
|
+
padding: 4px 8px;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* ---- themed defaults for plain elements ----
|
|
243
|
+
Pages write ordinary <input>/<button>/<select> and get the app look
|
|
244
|
+
without any per-page css; the Tea built-ins carry the same values. */
|
|
245
|
+
|
|
246
|
+
.gantry-app input[type="text"],
|
|
247
|
+
.gantry-app input[type="password"],
|
|
248
|
+
.gantry-app input[type="number"],
|
|
249
|
+
.gantry-app input[type="search"],
|
|
250
|
+
.gantry-app input:not([type]),
|
|
251
|
+
.gantry-app textarea,
|
|
252
|
+
.gantry-app select {
|
|
253
|
+
font-family: var(--gantry-font);
|
|
254
|
+
font-size: 13px;
|
|
255
|
+
color: var(--gantry-fg);
|
|
256
|
+
background: var(--gantry-control-bg);
|
|
257
|
+
border: 1px solid var(--gantry-control-border);
|
|
258
|
+
border-radius: var(--gantry-radius);
|
|
259
|
+
padding: 6px 10px;
|
|
260
|
+
outline: none;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.gantry-app input:focus,
|
|
264
|
+
.gantry-app textarea:focus,
|
|
265
|
+
.gantry-app select:focus {
|
|
266
|
+
border-color: var(--gantry-accent);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.gantry-app button:not(.gantry-winbtn):not(.gantry-tbbtn) {
|
|
270
|
+
font-family: var(--gantry-font);
|
|
271
|
+
font-size: 13px;
|
|
272
|
+
color: var(--gantry-fg);
|
|
273
|
+
background: var(--gantry-control-bg);
|
|
274
|
+
border: 1px solid var(--gantry-control-border);
|
|
275
|
+
border-radius: var(--gantry-radius);
|
|
276
|
+
padding: 6px 14px;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.gantry-app button:not(.gantry-winbtn):not(.gantry-tbbtn):hover {
|
|
280
|
+
border-color: var(--gantry-accent);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.gantry-app input[type="checkbox"] {
|
|
284
|
+
accent-color: var(--gantry-accent);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/* Link renders anchors without href (no status bubble); keep the
|
|
288
|
+
pointer cursor they would otherwise lose. */
|
|
289
|
+
.gantry-app a[role="link"] {
|
|
290
|
+
cursor: pointer;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* ---- app scaffold ---- */
|
|
294
|
+
|
|
295
|
+
.gantry-app {
|
|
296
|
+
display: flex;
|
|
297
|
+
flex-direction: column;
|
|
298
|
+
height: 100vh;
|
|
299
|
+
background: var(--gantry-bg);
|
|
300
|
+
color: var(--gantry-fg);
|
|
301
|
+
font-family: var(--gantry-font);
|
|
302
|
+
overflow: hidden;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.gantry-page {
|
|
306
|
+
flex: 1;
|
|
307
|
+
min-height: 0;
|
|
308
|
+
overflow: auto;
|
|
309
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createElement,
|
|
3
|
+
useEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
type FC,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { connect, onRender, sendTeaEvent, type WireNode } from "../socket";
|
|
10
|
+
|
|
11
|
+
/** Props every Tea-rendered component receives. */
|
|
12
|
+
export interface TeaComponentProps {
|
|
13
|
+
node: WireNode;
|
|
14
|
+
/** emit fires one of the node's Go handlers by event name. */
|
|
15
|
+
emit: (event: string, payload?: unknown) => void;
|
|
16
|
+
children?: ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ComponentRegistry = Record<string, FC<TeaComponentProps>>;
|
|
20
|
+
|
|
21
|
+
// extraComponents lets createApp add explicitly-registered components on
|
|
22
|
+
// top of the paired components/ folders.
|
|
23
|
+
let appRegistry: ComponentRegistry = {};
|
|
24
|
+
|
|
25
|
+
export function setRegistry(reg: ComponentRegistry): void {
|
|
26
|
+
appRegistry = reg;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function emitFor(node: WireNode) {
|
|
30
|
+
return (event: string, payload?: unknown) => {
|
|
31
|
+
const id = node.handlers?.[event];
|
|
32
|
+
if (id) sendTeaEvent(id, payload);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function styleHints(props: Record<string, unknown> | undefined): {
|
|
37
|
+
style: Record<string, string | number>;
|
|
38
|
+
className: string;
|
|
39
|
+
} {
|
|
40
|
+
const style: Record<string, string | number> = {};
|
|
41
|
+
let className = "";
|
|
42
|
+
if (props) {
|
|
43
|
+
if (typeof props.gap === "number") style.gap = props.gap;
|
|
44
|
+
if (typeof props.pad === "number") style.padding = props.pad;
|
|
45
|
+
if (props.grow === true) style.flexGrow = 1;
|
|
46
|
+
if (typeof props.class === "string") className = props.class;
|
|
47
|
+
}
|
|
48
|
+
return { style, className };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function renderChildren(children: WireNode[] | undefined): ReactNode[] {
|
|
52
|
+
return (children ?? []).map((c, i) => <NodeView key={c.key ?? i} node={c} />);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// TeaInput is semi-controlled: keystrokes echo locally at once and
|
|
56
|
+
// stream to Go; the Go value only overrides the field when it differs
|
|
57
|
+
// from what we last sent (a real remote change, not our own echo
|
|
58
|
+
// arriving late). A pure controlled input over a websocket round trip
|
|
59
|
+
// drops and jumbles fast typing.
|
|
60
|
+
function TeaInput({ node }: { node: WireNode }) {
|
|
61
|
+
const server = String(node.props?.value ?? "");
|
|
62
|
+
const [local, setLocal] = useState(server);
|
|
63
|
+
const lastSent = useRef<string | null>(null);
|
|
64
|
+
const emit = emitFor(node);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (lastSent.current === null || server !== lastSent.current) {
|
|
68
|
+
setLocal(server);
|
|
69
|
+
lastSent.current = null;
|
|
70
|
+
} else if (server === lastSent.current) {
|
|
71
|
+
lastSent.current = null;
|
|
72
|
+
}
|
|
73
|
+
}, [server]);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<input
|
|
77
|
+
className="gantry-tea-input"
|
|
78
|
+
type="text"
|
|
79
|
+
value={local}
|
|
80
|
+
placeholder={typeof node.props?.placeholder === "string" ? node.props.placeholder : undefined}
|
|
81
|
+
onChange={(e) => {
|
|
82
|
+
setLocal(e.target.value);
|
|
83
|
+
lastSent.current = e.target.value;
|
|
84
|
+
emit("change", e.target.value);
|
|
85
|
+
}}
|
|
86
|
+
/>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function NodeView({ node }: { node: WireNode }) {
|
|
91
|
+
const { style, className } = styleHints(node.props);
|
|
92
|
+
const cls = (base: string) => base + (className ? " " + className : "");
|
|
93
|
+
const emit = emitFor(node);
|
|
94
|
+
|
|
95
|
+
switch (node.type) {
|
|
96
|
+
case "column":
|
|
97
|
+
return (
|
|
98
|
+
<div className={cls("gantry-tea-column")} style={style}>
|
|
99
|
+
{renderChildren(node.children)}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
case "row":
|
|
103
|
+
return (
|
|
104
|
+
<div className={cls("gantry-tea-row")} style={style}>
|
|
105
|
+
{renderChildren(node.children)}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
case "text":
|
|
109
|
+
return (
|
|
110
|
+
<span className={cls("gantry-tea-text")} style={style}>
|
|
111
|
+
{String(node.props?.text ?? "")}
|
|
112
|
+
</span>
|
|
113
|
+
);
|
|
114
|
+
case "heading":
|
|
115
|
+
return (
|
|
116
|
+
<h2 className={cls("gantry-tea-heading")} style={style}>
|
|
117
|
+
{String(node.props?.text ?? "")}
|
|
118
|
+
</h2>
|
|
119
|
+
);
|
|
120
|
+
case "button":
|
|
121
|
+
return (
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
className={cls("gantry-tea-button")}
|
|
125
|
+
style={style}
|
|
126
|
+
onClick={() => emit("click")}
|
|
127
|
+
>
|
|
128
|
+
{String(node.props?.label ?? "")}
|
|
129
|
+
</button>
|
|
130
|
+
);
|
|
131
|
+
case "input":
|
|
132
|
+
return <TeaInput node={node} />;
|
|
133
|
+
case "checkbox":
|
|
134
|
+
return (
|
|
135
|
+
<label className={cls("gantry-tea-checkbox")} style={style}>
|
|
136
|
+
<input
|
|
137
|
+
type="checkbox"
|
|
138
|
+
checked={node.props?.checked === true}
|
|
139
|
+
onChange={(e) => emit("change", e.target.checked)}
|
|
140
|
+
/>
|
|
141
|
+
<span>{String(node.props?.label ?? "")}</span>
|
|
142
|
+
</label>
|
|
143
|
+
);
|
|
144
|
+
case "select":
|
|
145
|
+
return (
|
|
146
|
+
<select
|
|
147
|
+
className={cls("gantry-tea-select")}
|
|
148
|
+
style={style}
|
|
149
|
+
value={String(node.props?.value ?? "")}
|
|
150
|
+
onChange={(e) => emit("change", e.target.value)}
|
|
151
|
+
>
|
|
152
|
+
{((node.props?.options as string[]) ?? []).map((o) => (
|
|
153
|
+
<option key={o} value={o}>
|
|
154
|
+
{o}
|
|
155
|
+
</option>
|
|
156
|
+
))}
|
|
157
|
+
</select>
|
|
158
|
+
);
|
|
159
|
+
case "divider":
|
|
160
|
+
return <hr className="gantry-tea-divider" />;
|
|
161
|
+
case "spacer":
|
|
162
|
+
return <div className="gantry-tea-spacer" />;
|
|
163
|
+
case "progress": {
|
|
164
|
+
const v = Math.min(1, Math.max(0, Number(node.props?.value ?? 0)));
|
|
165
|
+
return (
|
|
166
|
+
<div className={cls("gantry-tea-progress")} style={style}>
|
|
167
|
+
<div className="gantry-tea-progress-fill" style={{ width: (v * 100).toFixed(1) + "%" }} />
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
default: {
|
|
172
|
+
const Custom = appRegistry[node.type];
|
|
173
|
+
if (Custom) {
|
|
174
|
+
return createElement(Custom, { node, emit }, renderChildren(node.children));
|
|
175
|
+
}
|
|
176
|
+
return <div className="gantry-tea-unknown">[unknown component: {node.type}]</div>;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* TeaView renders the Go-driven tree for the current page. Put one in
|
|
183
|
+
* any page whose .go half has a Model; pages without a Model simply do
|
|
184
|
+
* not use it.
|
|
185
|
+
*/
|
|
186
|
+
export function TeaView() {
|
|
187
|
+
const [tree, setTree] = useState<WireNode | null>(null);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
connect();
|
|
190
|
+
onRender(setTree);
|
|
191
|
+
return () => onRender(null);
|
|
192
|
+
}, []);
|
|
193
|
+
if (!tree) return null;
|
|
194
|
+
return <NodeView node={tree} />;
|
|
195
|
+
}
|
package/src/tea/index.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// The Gantry Vite plugin. Plain JavaScript on purpose: vite.config is
|
|
2
|
+
// executed by Node, which cannot load TypeScript out of node_modules.
|
|
3
|
+
//
|
|
4
|
+
// What it does:
|
|
5
|
+
// - scans <appRoot>/pages/** and <appRoot>/components/** for paired
|
|
6
|
+
// <name>/<name>.tsx files and generates the virtual:gantry-app
|
|
7
|
+
// module (route table + component registry, keyed by folder path)
|
|
8
|
+
// - auto-imports <appRoot>/index.css and every colocated same-name
|
|
9
|
+
// .css (root css first, so per-pair css can override variables)
|
|
10
|
+
// - injects the pairing key into bare usePaired() calls, derived from
|
|
11
|
+
// the file's folder path, so the tsx never repeats it
|
|
12
|
+
// - proxies /api and /gantry/ws to the Go server during gantry dev
|
|
13
|
+
// - watches pages/ and components/ so adding or removing a pair
|
|
14
|
+
// regenerates the app without restarting the dev server
|
|
15
|
+
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
const VIRTUAL_ID = "virtual:gantry-app";
|
|
20
|
+
const RESOLVED_ID = "\0" + VIRTUAL_ID;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {{ appRoot?: string, goPort?: number }} [opts]
|
|
24
|
+
* @returns {import("vite").Plugin}
|
|
25
|
+
*/
|
|
26
|
+
export function gantry(opts = {}) {
|
|
27
|
+
/** @type {string} */
|
|
28
|
+
let appRoot = "";
|
|
29
|
+
let goPort = opts.goPort ?? 0;
|
|
30
|
+
|
|
31
|
+
function scanKind(kind) {
|
|
32
|
+
const dir = path.join(appRoot, kind);
|
|
33
|
+
const out = [];
|
|
34
|
+
if (!fs.existsSync(dir)) return out;
|
|
35
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
36
|
+
if (!entry.isDirectory()) continue;
|
|
37
|
+
const name = entry.name;
|
|
38
|
+
const tsx = path.join(dir, name, name + ".tsx");
|
|
39
|
+
if (!fs.existsSync(tsx)) continue;
|
|
40
|
+
const css = path.join(dir, name, name + ".css");
|
|
41
|
+
out.push({
|
|
42
|
+
key: kind + "/" + name,
|
|
43
|
+
name,
|
|
44
|
+
tsx: tsx.split(path.sep).join("/"),
|
|
45
|
+
css: fs.existsSync(css) ? css.split(path.sep).join("/") : null,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
out.sort((a, b) => a.key.localeCompare(b.key));
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function generate() {
|
|
53
|
+
const pages = scanKind("pages");
|
|
54
|
+
const components = scanKind("components");
|
|
55
|
+
const lines = [];
|
|
56
|
+
|
|
57
|
+
const layouts = scanKind("layouts");
|
|
58
|
+
|
|
59
|
+
const rootCss = path.join(appRoot, "index.css");
|
|
60
|
+
if (fs.existsSync(rootCss)) {
|
|
61
|
+
lines.push(`import ${JSON.stringify(rootCss.split(path.sep).join("/"))};`);
|
|
62
|
+
}
|
|
63
|
+
for (const item of [...layouts, ...pages, ...components]) {
|
|
64
|
+
if (item.css) lines.push(`import ${JSON.stringify(item.css)};`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
layouts.forEach((l, i) => {
|
|
68
|
+
lines.push(`import * as l${i} from ${JSON.stringify(l.tsx)};`);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Optional root app.tsx: default-exports CreateAppOptions so apps
|
|
72
|
+
// customize createApp (TitleBar slots, title placement, extra
|
|
73
|
+
// components) without owning the synthesized entry file.
|
|
74
|
+
const appTsx = path.join(appRoot, "app.tsx");
|
|
75
|
+
if (fs.existsSync(appTsx)) {
|
|
76
|
+
lines.push(`import * as appMod from ${JSON.stringify(appTsx.split(path.sep).join("/"))};`);
|
|
77
|
+
lines.push(`export const appConfig = appMod;`);
|
|
78
|
+
} else {
|
|
79
|
+
lines.push(`export const appConfig = null;`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
pages.forEach((p, i) => {
|
|
83
|
+
lines.push(`import * as p${i} from ${JSON.stringify(p.tsx)};`);
|
|
84
|
+
});
|
|
85
|
+
components.forEach((c, i) => {
|
|
86
|
+
lines.push(`import * as c${i} from ${JSON.stringify(c.tsx)};`);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const pageEntries = pages.map((p, i) => {
|
|
90
|
+
// The derived route only; an optional "export const route" on the
|
|
91
|
+
// page module overrides it at runtime (createApp checks mod.route
|
|
92
|
+
// there - referencing it here would make rollup warn on every
|
|
93
|
+
// page that does not export it).
|
|
94
|
+
const route = p.name === "index" ? "/" : "/" + p.name;
|
|
95
|
+
return `{ key: ${JSON.stringify(p.key)}, route: ${JSON.stringify(route)}, mod: p${i} }`;
|
|
96
|
+
});
|
|
97
|
+
lines.push(`export const pages = [${pageEntries.join(", ")}];`);
|
|
98
|
+
|
|
99
|
+
const compEntries = components.map((c, i) => `${JSON.stringify(c.key)}: c${i}`);
|
|
100
|
+
lines.push(`export const components = { ${compEntries.join(", ")} };`);
|
|
101
|
+
|
|
102
|
+
// Layouts are keyed by their short name: layouts/main -> "main",
|
|
103
|
+
// which is what pages reference in "export const layout".
|
|
104
|
+
const layoutEntries = layouts.map((l, i) => `${JSON.stringify(l.name)}: l${i}`);
|
|
105
|
+
lines.push(`export const layouts = { ${layoutEntries.join(", ")} };`);
|
|
106
|
+
lines.push(`export const singlePage = ${pages.length <= 1};`);
|
|
107
|
+
return lines.join("\n");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Derive the pairing key when id is a paired tsx/css file. */
|
|
111
|
+
function keyFor(id) {
|
|
112
|
+
const file = id.split("?")[0].split(path.sep).join("/");
|
|
113
|
+
const root = appRoot.split(path.sep).join("/");
|
|
114
|
+
if (!file.startsWith(root + "/")) return null;
|
|
115
|
+
const rel = file.slice(root.length + 1);
|
|
116
|
+
const m = rel.match(/^(pages|components|layouts)\/([^/]+)\/([^/]+)\.tsx$/);
|
|
117
|
+
if (!m || m[2] !== m[3]) return null;
|
|
118
|
+
return m[1] + "/" + m[2];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
name: "gantry",
|
|
123
|
+
|
|
124
|
+
config(userConfig, { command }) {
|
|
125
|
+
const cfgRoot = userConfig.root
|
|
126
|
+
? path.resolve(userConfig.root)
|
|
127
|
+
: process.cwd();
|
|
128
|
+
appRoot = path.resolve(cfgRoot, opts.appRoot ?? "..");
|
|
129
|
+
|
|
130
|
+
if (!goPort) {
|
|
131
|
+
try {
|
|
132
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(appRoot, "gantry.json"), "utf8"));
|
|
133
|
+
goPort = cfg.port ?? 8330;
|
|
134
|
+
} catch {
|
|
135
|
+
goPort = 8330;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const target = "http://127.0.0.1:" + goPort;
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
// gantry-web ships TypeScript source through an npm file: link;
|
|
142
|
+
// excluding it from prebundling keeps it in the normal
|
|
143
|
+
// transform pipeline (plugin-react handles its tsx), and
|
|
144
|
+
// deduping react avoids a second copy through the symlink.
|
|
145
|
+
optimizeDeps: { exclude: ["gantry-web"] },
|
|
146
|
+
// dedupe makes imports inside the symlinked gantry-web source
|
|
147
|
+
// resolve from the APP's node_modules (the package itself has
|
|
148
|
+
// none) and guarantees a single React instance.
|
|
149
|
+
resolve: { dedupe: ["react", "react-dom", "lucide-react"] },
|
|
150
|
+
server:
|
|
151
|
+
command === "serve"
|
|
152
|
+
? {
|
|
153
|
+
proxy: {
|
|
154
|
+
"/api": { target },
|
|
155
|
+
"/gantry/ws": { target, ws: true },
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
: undefined,
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
resolveId(id) {
|
|
163
|
+
if (id === VIRTUAL_ID) return RESOLVED_ID;
|
|
164
|
+
return null;
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
load(id) {
|
|
168
|
+
if (id === RESOLVED_ID) return generate();
|
|
169
|
+
return null;
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
transform(code, id) {
|
|
173
|
+
const key = keyFor(id);
|
|
174
|
+
if (key && code.includes("usePaired()")) {
|
|
175
|
+
return code.replaceAll("usePaired()", `usePaired(${JSON.stringify(key)})`);
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
configureServer(server) {
|
|
181
|
+
server.watcher.add(appRoot);
|
|
182
|
+
const refresh = (file) => {
|
|
183
|
+
const f = file.split(path.sep).join("/");
|
|
184
|
+
const root = appRoot.split(path.sep).join("/");
|
|
185
|
+
if (!f.startsWith(root)) return;
|
|
186
|
+
if (
|
|
187
|
+
!/\/(pages|components|layouts)\/[^/]+\/[^/]+\.(tsx|css)$/.test(f) &&
|
|
188
|
+
!f.endsWith("/index.css") &&
|
|
189
|
+
!f.endsWith("/app.tsx")
|
|
190
|
+
) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const mod = server.moduleGraph.getModuleById(RESOLVED_ID);
|
|
194
|
+
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
195
|
+
server.ws.send({ type: "full-reload" });
|
|
196
|
+
};
|
|
197
|
+
server.watcher.on("add", refresh);
|
|
198
|
+
server.watcher.on("unlink", refresh);
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export default gantry;
|