basis-design 0.1.1 → 0.2.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 +2 -1
- package/capture.html +12 -0
- package/package.json +72 -70
- package/server/brief.ts +14 -3
- package/server/capture.ts +128 -0
- package/server/folders.ts +125 -0
- package/server/index.ts +112 -1
- package/server/project.ts +7 -1
- package/server/template.ts +168 -168
- package/server/verify-kit/capture.mjs +206 -0
- package/server/verify-kit/compare.d.mts +39 -0
- package/server/verify-kit/compare.mjs +307 -0
- package/server/verify.ts +383 -0
- package/shared/capture.ts +75 -0
- package/shared/protocol.ts +29 -1
- package/src/Workbench.tsx +1 -0
- package/src/bridge.ts +173 -163
- package/src/capture/capture.css +50 -0
- package/src/capture/main.tsx +146 -0
- package/src/preview/specimens.tsx +21 -0
- package/src/shell/ExportDialog.tsx +41 -4
- package/src/shell/FolderField.tsx +131 -0
- package/src/shell/ProjectMenu.tsx +20 -18
- package/src/shell/Shell.module.css +1146 -1076
- package/src/shell/VerifyKit.tsx +133 -0
- package/template/CHANGES.md +25 -25
- package/template/components/specimen.ts +24 -1
- package/template/history.json +1 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Component, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { captureShots, READY_ATTRIBUTE, type CaptureView } from "../../shared/capture.ts";
|
|
4
|
+
import { parseDesignText, type Design } from "../../shared/design.ts";
|
|
5
|
+
import { previewCss } from "../../shared/tokens.ts";
|
|
6
|
+
import type { ScreenProps, SpecimenBlock } from "../../template/components/specimen.ts";
|
|
7
|
+
import { loadSpecimens, type LoadedSpecimen } from "../preview/specimens.tsx";
|
|
8
|
+
import "./capture.css";
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* One screen of a view, alone on the page at the size the browser gives it, for the verification kit's
|
|
12
|
+
* screenshots: `?view=<view id>&screen=<screen id>&theme=light|dark`. `?list` instead reports every view and
|
|
13
|
+
* the shots its screens ask for (window.__basisViews). The design comes from design.json beside this page, so
|
|
14
|
+
* the same page runs on Basis' preview server and as the kit's static copy of the design.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
declare global {
|
|
18
|
+
interface Window {
|
|
19
|
+
__basisViews?: { views: CaptureView[]; problems: string[] };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const params = new URLSearchParams(location.search);
|
|
24
|
+
type FlowBlock = Extract<SpecimenBlock, { kind: "flow" }>;
|
|
25
|
+
|
|
26
|
+
function flows(specimen: LoadedSpecimen): FlowBlock[] {
|
|
27
|
+
return specimen.blocks.filter((block): block is FlowBlock => block.kind === "flow");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function views(): { views: CaptureView[]; problems: string[] } {
|
|
31
|
+
const { specimens, problems } = loadSpecimens();
|
|
32
|
+
const list = specimens
|
|
33
|
+
.filter((specimen) => specimen.page === "views")
|
|
34
|
+
.map((specimen) => ({
|
|
35
|
+
id: specimen.id,
|
|
36
|
+
title: specimen.title,
|
|
37
|
+
screens: flows(specimen).flatMap((flow) =>
|
|
38
|
+
flow.screens.map((screen) => ({ id: screen.id, label: screen.label, shots: captureShots(flow.capture) })),
|
|
39
|
+
),
|
|
40
|
+
}))
|
|
41
|
+
.filter((view) => view.screens.length > 0);
|
|
42
|
+
return { views: list, problems: problems.map((problem) => `${problem.file}: ${problem.message}`) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Marks the page ready once it has settled: fonts loaded, images decoded, and the layout the same size twice in
|
|
49
|
+
* a row. Timers rather than animation frames, which a background tab does not run.
|
|
50
|
+
*/
|
|
51
|
+
async function markReady(): Promise<void> {
|
|
52
|
+
await document.fonts.ready;
|
|
53
|
+
await Promise.all([...document.images].map((image) => image.decode().catch(() => undefined)));
|
|
54
|
+
let last = "";
|
|
55
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
56
|
+
await wait(50);
|
|
57
|
+
const size = `${document.documentElement.scrollWidth}x${document.documentElement.scrollHeight}`;
|
|
58
|
+
if (size === last) break;
|
|
59
|
+
last = size;
|
|
60
|
+
}
|
|
61
|
+
document.documentElement.setAttribute(READY_ATTRIBUTE, "true");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
class Boundary extends Component<{ children: ReactNode }, { error: unknown }> {
|
|
65
|
+
state = { error: null as unknown };
|
|
66
|
+
static getDerivedStateFromError(error: unknown) {
|
|
67
|
+
return { error };
|
|
68
|
+
}
|
|
69
|
+
componentDidCatch() {
|
|
70
|
+
document.documentElement.setAttribute("data-basis-error", "true");
|
|
71
|
+
}
|
|
72
|
+
render() {
|
|
73
|
+
const { error } = this.state;
|
|
74
|
+
if (error === null) return this.props.children;
|
|
75
|
+
return <p className="capture-problem">This screen failed to render: {String(error)}</p>;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function Screen({ design, theme }: { design: Design; theme: "light" | "dark" }) {
|
|
80
|
+
const { specimens } = useMemo(() => loadSpecimens(), []);
|
|
81
|
+
const view = specimens.find((specimen) => specimen.page === "views" && specimen.id === params.get("view"));
|
|
82
|
+
const screens = view ? flows(view).flatMap((flow) => flow.screens) : [];
|
|
83
|
+
// The kit's copy of the design is clickable: `go` moves through the flow as in the gallery.
|
|
84
|
+
const [position, setPosition] = useState<{ id: string; data?: unknown }>({
|
|
85
|
+
id: params.get("screen") ?? screens[0]?.id ?? "",
|
|
86
|
+
});
|
|
87
|
+
const screen = screens.find((item) => item.id === position.id);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
void markReady();
|
|
91
|
+
}, [position]);
|
|
92
|
+
|
|
93
|
+
if (!view || !screen) {
|
|
94
|
+
return (
|
|
95
|
+
<p className="capture-problem">
|
|
96
|
+
No screen "{position.id}" in the view "{params.get("view")}".
|
|
97
|
+
</p>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const go: ScreenProps["go"] = (id, data) => {
|
|
101
|
+
const next = new URLSearchParams(location.search);
|
|
102
|
+
next.set("screen", id);
|
|
103
|
+
history.replaceState(null, "", `?${next}`);
|
|
104
|
+
document.documentElement.removeAttribute(READY_ATTRIBUTE);
|
|
105
|
+
setPosition({ id, data });
|
|
106
|
+
};
|
|
107
|
+
const Render = screen.render;
|
|
108
|
+
return (
|
|
109
|
+
<div className="ds-root" data-theme={theme}>
|
|
110
|
+
<Boundary key={screen.id}>
|
|
111
|
+
<Render design={design as unknown as ScreenProps["design"]} go={go} data={position.data} />
|
|
112
|
+
</Boundary>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function Page({ design }: { design: Design }) {
|
|
118
|
+
const theme = params.get("theme") === "dark" ? "dark" : "light";
|
|
119
|
+
return (
|
|
120
|
+
<>
|
|
121
|
+
<style>{previewCss(design, ".ds-root")}</style>
|
|
122
|
+
{design.typography.fontImports.map((href) => (
|
|
123
|
+
<link key={href} rel="stylesheet" href={href} />
|
|
124
|
+
))}
|
|
125
|
+
<Screen design={design} theme={theme} />
|
|
126
|
+
</>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function start(): Promise<void> {
|
|
131
|
+
const root = createRoot(document.getElementById("root")!);
|
|
132
|
+
if (params.has("list")) {
|
|
133
|
+
window.__basisViews = views();
|
|
134
|
+
document.documentElement.setAttribute(READY_ATTRIBUTE, "true");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const result = parseDesignText(await (await fetch("./design.json", { cache: "no-store" })).text());
|
|
138
|
+
if (!result.ok) {
|
|
139
|
+
root.render(<p className="capture-problem">design.json is not valid: {result.errors.join("; ")}</p>);
|
|
140
|
+
document.documentElement.setAttribute(READY_ATTRIBUTE, "true");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
root.render(<Page design={result.design} />);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
void start();
|
|
@@ -53,6 +53,26 @@ function isComponent(value: unknown): value is ComponentType<never> {
|
|
|
53
53
|
return typeof value === "function" || (typeof value === "object" && value !== null && "$$typeof" in value);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** What is wrong with a flow block's `capture`, or null when it is absent or well formed. */
|
|
57
|
+
function captureProblem(capture: unknown): string | null {
|
|
58
|
+
if (capture === undefined) return null;
|
|
59
|
+
if (!capture || typeof capture !== "object") return "capture must be an object: { widths, height, themes }";
|
|
60
|
+
const { widths, height, themes } = capture as Record<string, unknown>;
|
|
61
|
+
if (widths !== undefined && (!Array.isArray(widths) || widths.some((width) => typeof width !== "number"))) {
|
|
62
|
+
return "capture.widths must be an array of numbers (viewport widths in px)";
|
|
63
|
+
}
|
|
64
|
+
if (height !== undefined && height !== "full" && typeof height !== "number") {
|
|
65
|
+
return 'capture.height must be "full" or a number (viewport height in px)';
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
themes !== undefined &&
|
|
69
|
+
(!Array.isArray(themes) || themes.some((theme) => theme !== "light" && theme !== "dark"))
|
|
70
|
+
) {
|
|
71
|
+
return 'capture.themes must be an array of "light" and "dark"';
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
56
76
|
/** Why a block cannot be rendered, or null when it is well formed. */
|
|
57
77
|
function blockProblem(block: unknown): string | null {
|
|
58
78
|
if (!block || typeof block !== "object") return "a block is not an object";
|
|
@@ -78,6 +98,7 @@ function blockProblem(block: unknown): string | null {
|
|
|
78
98
|
if (screens.some((screen) => !screen || typeof screen.id !== "string" || !isComponent(screen.render))) {
|
|
79
99
|
return "every screen needs an id, a label and a render component";
|
|
80
100
|
}
|
|
101
|
+
return captureProblem(b.capture);
|
|
81
102
|
} else if (!isComponent(b.render)) {
|
|
82
103
|
return `a ${String(b.kind)} block needs a render component`;
|
|
83
104
|
}
|
|
@@ -2,8 +2,10 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
2
2
|
import type { Design } from "../../shared/design.ts";
|
|
3
3
|
import type { ClientMessage, ExportWriteResult } from "../../shared/protocol.ts";
|
|
4
4
|
import { exportCss, exportJson, exportTailwind } from "../../shared/tokens.ts";
|
|
5
|
+
import { BrowseButton } from "./FolderField.tsx";
|
|
5
6
|
import { readPref, writePref } from "./prefs.ts";
|
|
6
7
|
import styles from "./Shell.module.css";
|
|
8
|
+
import { VerifyKit, type VerifyKitProps } from "./VerifyKit.tsx";
|
|
7
9
|
|
|
8
10
|
const FORMATS = [
|
|
9
11
|
{ id: "css", label: "CSS variables", file: "tokens.css", type: "text/css", build: exportCss },
|
|
@@ -11,7 +13,7 @@ const FORMATS = [
|
|
|
11
13
|
{ id: "json", label: "JSON", file: "design-system.json", type: "application/json", build: exportJson },
|
|
12
14
|
] as const;
|
|
13
15
|
|
|
14
|
-
type Tab = "kit" | (typeof FORMATS)[number]["id"];
|
|
16
|
+
type Tab = "kit" | "verify" | (typeof FORMATS)[number]["id"];
|
|
15
17
|
|
|
16
18
|
interface ExportDialogProps {
|
|
17
19
|
design: Design;
|
|
@@ -22,6 +24,7 @@ interface ExportDialogProps {
|
|
|
22
24
|
result: ExportWriteResult | null;
|
|
23
25
|
send: (message: ClientMessage) => void;
|
|
24
26
|
onClose: () => void;
|
|
27
|
+
verify: Omit<VerifyKitProps, "workspace" | "send">;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
/** The folder the kit was last written to, remembered per project so one never writes into another's. */
|
|
@@ -40,6 +43,7 @@ function StarterKit({
|
|
|
40
43
|
}: Pick<ExportDialogProps, "workspace" | "problems" | "manifest" | "result" | "send">) {
|
|
41
44
|
const [target, setTarget] = useState(() => readPref(targetPref(workspace)) ?? "");
|
|
42
45
|
const [pending, setPending] = useState(false);
|
|
46
|
+
const [browseError, setBrowseError] = useState<string | null>(null);
|
|
43
47
|
// Only show results of writes made while this dialog is open.
|
|
44
48
|
const [stale] = useState(latest);
|
|
45
49
|
const result = latest === stale ? null : latest;
|
|
@@ -103,13 +107,20 @@ function StarterKit({
|
|
|
103
107
|
id="export-target"
|
|
104
108
|
value={target}
|
|
105
109
|
onChange={(event) => setTarget(event.target.value)}
|
|
106
|
-
placeholder="
|
|
110
|
+
placeholder="Browse, or paste the folder's path"
|
|
107
111
|
spellCheck={false}
|
|
108
112
|
/>
|
|
113
|
+
<BrowseButton
|
|
114
|
+
title="Choose the folder to write the starter kit into"
|
|
115
|
+
start={target}
|
|
116
|
+
onPick={setTarget}
|
|
117
|
+
onError={setBrowseError}
|
|
118
|
+
/>
|
|
109
119
|
<button type="submit" className={styles.chromeButton} disabled={!target.trim() || pending}>
|
|
110
120
|
{pending ? "Writing…" : "Write files"}
|
|
111
121
|
</button>
|
|
112
122
|
</div>
|
|
123
|
+
{browseError && <p className={styles.kitError}>{browseError}</p>}
|
|
113
124
|
{result?.error && <p className={styles.kitError}>{result.error}</p>}
|
|
114
125
|
{conflicts && (
|
|
115
126
|
<div className={styles.kitConflicts}>
|
|
@@ -138,7 +149,16 @@ function StarterKit({
|
|
|
138
149
|
);
|
|
139
150
|
}
|
|
140
151
|
|
|
141
|
-
export function ExportDialog({
|
|
152
|
+
export function ExportDialog({
|
|
153
|
+
design,
|
|
154
|
+
workspace,
|
|
155
|
+
problems,
|
|
156
|
+
manifest,
|
|
157
|
+
result,
|
|
158
|
+
send,
|
|
159
|
+
onClose,
|
|
160
|
+
verify,
|
|
161
|
+
}: ExportDialogProps) {
|
|
142
162
|
const dialog = useRef<HTMLDialogElement>(null);
|
|
143
163
|
const [tab, setTab] = useState<Tab>("kit");
|
|
144
164
|
const [copied, setCopied] = useState(false);
|
|
@@ -189,6 +209,9 @@ export function ExportDialog({ design, workspace, problems, manifest, result, se
|
|
|
189
209
|
<button type="button" role="tab" aria-selected={tab === "kit"} onClick={() => setTab("kit")}>
|
|
190
210
|
Starter kit
|
|
191
211
|
</button>
|
|
212
|
+
<button type="button" role="tab" aria-selected={tab === "verify"} onClick={() => setTab("verify")}>
|
|
213
|
+
Verification kit
|
|
214
|
+
</button>
|
|
192
215
|
{FORMATS.map((item) => (
|
|
193
216
|
<button
|
|
194
217
|
key={item.id}
|
|
@@ -203,11 +226,25 @@ export function ExportDialog({ design, workspace, problems, manifest, result, se
|
|
|
203
226
|
</div>
|
|
204
227
|
{tab === "kit" ? (
|
|
205
228
|
<StarterKit workspace={workspace} problems={problems} manifest={manifest} result={result} send={send} />
|
|
229
|
+
) : tab === "verify" ? (
|
|
230
|
+
<VerifyKit workspace={workspace} send={send} {...verify} />
|
|
206
231
|
) : (
|
|
207
232
|
<pre className={styles.code}>{code}</pre>
|
|
208
233
|
)}
|
|
209
234
|
<footer className={styles.dialogFooter}>
|
|
210
|
-
{tab === "
|
|
235
|
+
{tab === "verify" ? (
|
|
236
|
+
<>
|
|
237
|
+
<span className={styles.muted}>basis-verify/ · takes a few seconds per screen</span>
|
|
238
|
+
<button
|
|
239
|
+
type="button"
|
|
240
|
+
className={styles.primaryButton}
|
|
241
|
+
disabled={verify.progress !== null}
|
|
242
|
+
onClick={() => send({ type: "verify-kit" })}
|
|
243
|
+
>
|
|
244
|
+
{verify.progress ? "Building…" : "Build .zip"}
|
|
245
|
+
</button>
|
|
246
|
+
</>
|
|
247
|
+
) : tab === "kit" ? (
|
|
211
248
|
<>
|
|
212
249
|
<span className={styles.muted}>{manifest ? `${manifest.folder}.zip` : ""}</span>
|
|
213
250
|
<a className={styles.primaryButton} href="/export/starter-kit.zip" download>
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import styles from "./Shell.module.css";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Choosing a folder without typing its path: the operating system's own folder dialog, opened by the Basis server
|
|
6
|
+
* (it runs on this computer), and the git repos it finds in the usual places for code.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
async function api<T>(path: string, body: object = {}): Promise<T> {
|
|
10
|
+
const response = await fetch(path, {
|
|
11
|
+
method: "POST",
|
|
12
|
+
headers: { "Content-Type": "application/json" },
|
|
13
|
+
body: JSON.stringify(body),
|
|
14
|
+
});
|
|
15
|
+
if (!response.ok) throw new Error(`Basis answered ${response.status}`);
|
|
16
|
+
return (await response.json()) as T;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface BrowseButtonProps {
|
|
20
|
+
/** The title of the system's folder dialog. */
|
|
21
|
+
title: string;
|
|
22
|
+
/** Where the dialog starts; the user's home folder when empty or missing. */
|
|
23
|
+
start: string;
|
|
24
|
+
onPick: (path: string) => void;
|
|
25
|
+
onError: (message: string | null) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Opens the system's folder dialog; the folder the user chooses fills the field. */
|
|
29
|
+
export function BrowseButton({ title, start, onPick, onError }: BrowseButtonProps) {
|
|
30
|
+
const [open, setOpen] = useState(false);
|
|
31
|
+
const browse = async () => {
|
|
32
|
+
setOpen(true);
|
|
33
|
+
onError(null);
|
|
34
|
+
try {
|
|
35
|
+
const answer = await api<{ path: string | null; error?: string }>("/api/pick-folder", { title, start });
|
|
36
|
+
if (answer.path) onPick(answer.path);
|
|
37
|
+
if (answer.error) onError(answer.error);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
onError((error as Error).message);
|
|
40
|
+
} finally {
|
|
41
|
+
setOpen(false);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
return (
|
|
45
|
+
<button type="button" className={styles.chromeButton} onClick={() => void browse()} disabled={open}>
|
|
46
|
+
{open ? "Choose in the dialog…" : "Browse…"}
|
|
47
|
+
</button>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface RepoSuggestion {
|
|
52
|
+
name: string;
|
|
53
|
+
dir: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface FolderFieldProps {
|
|
57
|
+
id: string;
|
|
58
|
+
label: string;
|
|
59
|
+
value: string;
|
|
60
|
+
onChange: (path: string) => void;
|
|
61
|
+
placeholder: string;
|
|
62
|
+
/** The title of the system's folder dialog. */
|
|
63
|
+
dialogTitle: string;
|
|
64
|
+
/** Offers the git repos found on this computer, the most recently used first. */
|
|
65
|
+
suggestRepos?: boolean;
|
|
66
|
+
autoFocus?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A folder path: typed or pasted, chosen in the system's dialog, or picked from the repos Basis found. */
|
|
70
|
+
export function FolderField({
|
|
71
|
+
id,
|
|
72
|
+
label,
|
|
73
|
+
value,
|
|
74
|
+
onChange,
|
|
75
|
+
placeholder,
|
|
76
|
+
dialogTitle,
|
|
77
|
+
suggestRepos = false,
|
|
78
|
+
autoFocus,
|
|
79
|
+
}: FolderFieldProps) {
|
|
80
|
+
const [error, setError] = useState<string | null>(null);
|
|
81
|
+
const [repos, setRepos] = useState<RepoSuggestion[]>([]);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!suggestRepos) return;
|
|
85
|
+
let current = true;
|
|
86
|
+
api<{ repos: RepoSuggestion[] }>("/api/repos")
|
|
87
|
+
.then((answer) => current && setRepos(answer.repos))
|
|
88
|
+
.catch(() => {});
|
|
89
|
+
return () => {
|
|
90
|
+
current = false;
|
|
91
|
+
};
|
|
92
|
+
}, [suggestRepos]);
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<>
|
|
96
|
+
<label htmlFor={id}>{label}</label>
|
|
97
|
+
<div className={styles.folderRow}>
|
|
98
|
+
<input
|
|
99
|
+
id={id}
|
|
100
|
+
value={value}
|
|
101
|
+
onChange={(event) => onChange(event.target.value)}
|
|
102
|
+
placeholder={placeholder}
|
|
103
|
+
spellCheck={false}
|
|
104
|
+
autoComplete="off"
|
|
105
|
+
autoFocus={autoFocus}
|
|
106
|
+
/>
|
|
107
|
+
<BrowseButton title={dialogTitle} start={value} onPick={onChange} onError={setError} />
|
|
108
|
+
</div>
|
|
109
|
+
{error && <p className={styles.kitError}>{error}</p>}
|
|
110
|
+
{repos.length > 0 && (
|
|
111
|
+
<div className={styles.repoSuggestions} role="group" aria-label="Repos on this computer">
|
|
112
|
+
<span className={styles.muted}>Repos on this computer</span>
|
|
113
|
+
<div>
|
|
114
|
+
{repos.slice(0, 8).map((repo) => (
|
|
115
|
+
<button
|
|
116
|
+
key={repo.dir}
|
|
117
|
+
type="button"
|
|
118
|
+
className={styles.repoChip}
|
|
119
|
+
aria-pressed={value === repo.dir}
|
|
120
|
+
title={repo.dir}
|
|
121
|
+
onClick={() => onChange(repo.dir)}
|
|
122
|
+
>
|
|
123
|
+
{repo.name}
|
|
124
|
+
</button>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
</>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useId, useRef, useState } from "react";
|
|
2
2
|
import type { ClientMessage, ProjectEntry, TemplateUpdate } from "../../shared/protocol.ts";
|
|
3
|
+
import { FolderField } from "./FolderField.tsx";
|
|
3
4
|
import styles from "./Shell.module.css";
|
|
4
5
|
|
|
5
6
|
interface ProjectMenuProps {
|
|
@@ -341,32 +342,33 @@ function ProjectDialog({ mode, current, error, onSubmit, onClose }: ProjectDialo
|
|
|
341
342
|
</label>
|
|
342
343
|
</fieldset>
|
|
343
344
|
{fromRepo && (
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
/>
|
|
354
|
-
</>
|
|
345
|
+
<FolderField
|
|
346
|
+
id="project-repo"
|
|
347
|
+
label="Repo folder"
|
|
348
|
+
value={dir}
|
|
349
|
+
onChange={setDir}
|
|
350
|
+
placeholder="Browse, pick a repo below, or paste its path"
|
|
351
|
+
dialogTitle="Choose the product repo"
|
|
352
|
+
suggestRepos
|
|
353
|
+
/>
|
|
355
354
|
)}
|
|
356
355
|
</>
|
|
357
356
|
) : (
|
|
358
357
|
<>
|
|
359
|
-
<
|
|
360
|
-
<input
|
|
358
|
+
<FolderField
|
|
361
359
|
id="project-dir"
|
|
360
|
+
label={mode === "repo" ? "Repo folder" : "Folder"}
|
|
362
361
|
value={dir}
|
|
363
|
-
onChange={
|
|
364
|
-
placeholder={
|
|
365
|
-
|
|
366
|
-
|
|
362
|
+
onChange={setDir}
|
|
363
|
+
placeholder={
|
|
364
|
+
mode === "repo" ? "Browse, pick a repo below, or paste its path" : "Browse, or paste its path"
|
|
365
|
+
}
|
|
366
|
+
dialogTitle={
|
|
367
|
+
mode === "repo" ? "Choose the product repo" : "Choose a Basis workspace or an empty folder"
|
|
368
|
+
}
|
|
369
|
+
suggestRepos={mode === "repo"}
|
|
367
370
|
autoFocus
|
|
368
371
|
/>
|
|
369
|
-
<p className={styles.muted}>Use the full path.</p>
|
|
370
372
|
{mode === "repo" && (
|
|
371
373
|
<label className={styles.projectCheck}>
|
|
372
374
|
<input type="checkbox" checked={bringIn} onChange={(event) => setBringIn(event.target.checked)} />
|