Files
codecraft/web/src/components/NewSession.tsx
T
Richard ZinkandClaude Opus 5.5 e8e455b779 Store chat images separately; JPEG for large PNGs; gzip/brotli responses
Images of messages (pasted images, screenshots, generated images) were base64
inside the message JSON, so opening a chat downloaded all of them at once
(66 MB for a chat with 120 screenshots). They now live in a `blobs` table,
addressed by SHA-256; messages keep a reference (blob, size). The UI loads
them from /api/blobs/<hash> lazily with an immutable cache header, the runner
fills the bytes in before model requests, JSON exports stay self-contained.

- Store::add_message moves inline images out; large opaque PNGs become JPEG
  (q85) when smaller. Older chats are converted once at start-up in the
  background, unused images are removed (also after deleting a chat) and the
  database is vacuumed when that frees 32 MB or more.
- Responses are compressed with gzip/brotli (not images, SSE streams or
  binary downloads).

Example (copy of production): chat load 66 MB -> 1.5 MB (370 KB brotli),
database 90 MB -> 27 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:21:24 +01:00

639 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Icon } from "./Icon";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { post, uploadFile } from "../api";
import { useAgents, useFetch } from "../hooks";
import { useT, type Key } from "../i18n";
import type { ImageData } from "../types";
import { readImage } from "./Composer";
import { formatSize, imageSrc } from "./FileFigure";
import { ModelSelect } from "./ModelSelect";
import { takePrefill } from "../share";
interface DirListing {
path: string;
parent: string | null;
root: string;
workspace: string;
dirs: { name: string; path: string; git: boolean }[];
}
const LAST_DIR = "codecraft.lastDir";
type Task = "build" | "plan" | "craft" | string;
type Target = "website" | "app" | "game" | "software" | "fresh";
// --- icons (SVG: identical on every system) -------------------------------------
const svg = (children: ReactNode) => (
<svg viewBox="0 0 24 24" className="wiz-icon" aria-hidden="true">
{children}
</svg>
);
const ICONS: Record<string, ReactNode> = {
build: svg(<path d="m8 7-5 5 5 5M16 7l5 5-5 5M13.5 4l-3 16" />),
plan: svg(
<>
<path d="M9 4 3 6v14l6-2 6 2 6-2V4l-6 2-6-2Z" />
<path d="M9 4v14M15 6v14" />
</>,
),
craft: svg(
<>
<path d="m4 20 10-10M14 10l2-2" />
<path d="M17 3v3M15.5 4.5h3M20 8v2M19 9h2M11 3v2M10 4h2" />
</>,
),
agent: svg(
<>
<rect x="5" y="7" width="14" height="11" rx="3" />
<path d="M12 3v4M9 12h.01M15 12h.01M9.5 15h5" />
</>,
),
website: svg(
<>
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3c2.5 2.8 3.5 5.8 3.5 9s-1 6.2-3.5 9c-2.5-2.8-3.5-5.8-3.5-9s1-6.2 3.5-9Z" />
</>,
),
app: svg(
<>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="M3 9h18M6 7h.01M8.5 7h.01" />
</>,
),
game: svg(
<>
<path d="M7 9h10a4 4 0 0 1 4 4v1a3 3 0 0 1-5.4 1.8L14.5 14h-5l-1.1 1.8A3 3 0 0 1 3 14v-1a4 4 0 0 1 4-4Z" />
<path d="M8 11.5v3M6.5 13h3M15.5 12h.01M17.5 13.5h.01" />
</>,
),
software: svg(
<>
<path d="M5 3h11l3 3v15H5Z" />
<path d="M8 3v5h7V3M8 21v-7h8v7" />
</>,
),
fresh: svg(<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8ZM19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8Z" />),
clean: svg(
<>
<path d="M12 3 5 6v6c0 4.2 3 7.6 7 9 4-1.4 7-4.8 7-9V6Z" />
<path d="m9 12 2 2 4-4" />
</>,
),
deep: svg(
<>
<circle cx="11" cy="11" r="6" />
<path d="m20 20-4.5-4.5M9 9l4 4M13 9l-4 4" />
</>,
),
cad: svg(
<>
<path d="m12 3 8 4.5v9L12 21l-8-4.5v-9Z" />
<path d="m4 7.5 8 4.5 8-4.5M12 12v9" />
</>,
),
folder: svg(<path d="M3 6a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1Z" />),
git: svg(
<>
<circle cx="6" cy="6" r="2.2" />
<circle cx="6" cy="18" r="2.2" />
<circle cx="18" cy="9" r="2.2" />
<path d="M6 8.2v7.6M18 11.2c0 3.5-4 3-9.6 5.6" />
</>,
),
upload: svg(<path d="M12 16V4M7 9l5-5 5 5M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" />),
clip: svg(<path d="m20 11-8.5 8.5a5 5 0 0 1-7-7L13 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L14 7" />),
chat: svg(<path d="M4 5h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H9l-4 3v-3H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm4 6h.01M12 11h.01M16 11h.01" />),
start: svg(<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8Z" />),
};
function Card({ icon, title, desc, active, onClick }: { icon: string; title: string; desc?: string; active: boolean; onClick: () => void }) {
return (
<button type="button" className={`wiz-card ${active ? "active" : ""}`} onClick={onClick} aria-pressed={active}>
{ICONS[icon] ?? ICONS.agent}
<span className="wiz-card-text">
<span className="wiz-card-title">{title}</span>
{desc && <span className="wiz-card-desc">{desc}</span>}
</span>
{active && (
<svg viewBox="0 0 24 24" className="wiz-check" aria-hidden="true">
<path d="m5 12 4.5 4.5L19 7" />
</svg>
)}
</button>
);
}
function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) {
return (
<section className="wiz-step">
<h3>
<span className="wiz-num">{n}</span>
{title}
</h3>
{children}
</section>
);
}
const slug = (s: string) =>
s
.toLowerCase()
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/https?:\/\/(www\.)?/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.split("-")
.slice(0, 4)
.join("-")
.slice(0, 40);
async function api(path: string, init: RequestInit) {
const r = await fetch(`/api${path}`, { credentials: "same-origin", ...init });
if (!r.ok) throw new Error((await r.text().catch(() => "")) || `HTTP ${r.status}`);
return r;
}
/** Guided start: what to do → (craft: what to recreate, clean room/deep) → describe → model & folder. */
export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreated: (id: string) => void }) {
const t = useT();
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
const config = useFetch<{ default_model?: string }>("/config").data;
// Text shared from another app (PWA share target) starts a normal task.
const [prefill] = useState(() => takePrefill("new"));
const [task, setTask] = useState<Task>(prefill ? "build" : "craft");
const [target, setTarget] = useState<Target>("fresh");
const [deep, setDeep] = useState(false);
const [url, setUrl] = useState("");
const [text, setText] = useState(prefill ?? "");
const [images, setImages] = useState<ImageData[]>([]);
const [files, setFiles] = useState<File[]>([]);
const [model, setModel] = useState("");
// Folder: a new one (name) inside a root, or an existing one (picked).
const [folderMode, setFolderMode] = useState<"new" | "existing" | "git">("new");
const [repoUrl, setRepoUrl] = useState("");
const [isolated, setIsolated] = useState(false);
// Original software (zip, tar.gz, installer …) for the recreate workflow, uploaded in chunks.
const [originals, setOriginals] = useState<File[]>([]);
const [progress, setProgress] = useState<Record<string, number>>({});
const originalInput = useRef<HTMLInputElement>(null);
const [folderName, setFolderName] = useState("");
const [nameTouched, setNameTouched] = useState(false);
const [rootPath, setRootPath] = useState("");
const [picked, setPicked] = useState<string>(localStorage.getItem(LAST_DIR) ?? "");
const [error, setError] = useState<string>();
const [creating, setCreating] = useState<string>();
const fileInput = useRef<HTMLInputElement>(null);
const agents = useAgents(undefined).filter((a) => a.mode === "primary");
const custom = agents.filter((a) => !["build", "plan", "craft", "chat"].includes(a.name));
useEffect(() => {
if (!model && config?.default_model) setModel(config.default_model);
}, [config, model]);
useEffect(() => {
if (!rootPath && roots.length) setRootPath(roots[roots.length - 1].path);
}, [roots, rootPath]);
// Old software is analysed in deep mode; websites default to clean room.
useEffect(() => {
if (target === "software") setDeep(true);
else if (target === "website") setDeep(false);
}, [target]);
// Working on existing code usually happens in an existing folder.
useEffect(() => {
setFolderMode((m) => (m === "git" ? m : task === "craft" || task === "cad" ? "new" : "existing"));
}, [task]);
const repoName = repoUrl.trim().replace(/\/+$/, "").replace(/\.git$/, "").split(/[/:]/).pop() ?? "";
const autoName = useMemo(
() => (folderMode === "git" && repoName ? slug(repoName) : "") || slug(target === "website" && url ? url : text) || (task === "craft" ? `${target}-project` : task === "cad" ? "3d-model" : "project"),
[text, url, target, task, folderMode, repoName],
);
useEffect(() => {
if (!nameTouched) setFolderName(autoName);
}, [autoName, nameTouched]);
const ideas: Key[] =
task === "cad"
? ["wizard.idea.cadBox", "wizard.idea.cadBracket"]
: task !== "craft"
? task === "plan"
? ["wizard.idea.planFeature", "wizard.idea.planRefactor"]
: ["wizard.idea.buildTests", "wizard.idea.buildBug"]
: ({
website: ["wizard.idea.siteLanding", "wizard.idea.siteShop"],
app: ["wizard.idea.appTodo", "wizard.idea.appNotes"],
game: ["wizard.idea.gameSnake", "wizard.idea.gameBreakout"],
software: ["wizard.idea.swDos", "wizard.idea.swTool"],
fresh: ["wizard.idea.freshBakery", "wizard.idea.freshSnake"],
} as Record<Target, Key[]>)[target];
const addFiles = async (list: FileList | File[]) => {
const all = [...list];
const imgs = all.filter((f) => f.type.startsWith("image/") && target !== "software");
try {
const read = await Promise.all(imgs.map(readImage));
setImages((cur) => [...cur, ...read]);
} catch (e: any) {
setError(e.message);
}
setFiles((cur) => [...cur, ...all.filter((f) => !imgs.includes(f))]);
};
const needsUrl = task === "craft" && target === "website";
const isRecreate = task === "craft" && target === "software";
const canStart =
!creating &&
(text.trim() || (needsUrl && url.trim()) || (isRecreate && originals.length > 0)) &&
(task === "chat" ? !!rootPath : folderMode === "existing" ? !!picked : !!folderName.trim() && !!rootPath && (folderMode !== "git" || /^(https?:\/\/|ssh:\/\/|git@)\S+$/.test(repoUrl.trim())));
/** The first message(s) for the agent. */
const prompts = (uploaded: string[]): string[] => {
const desc = text.trim();
const fileList = uploaded.length ? `\n\nFiles: ${uploaded.join(", ")}` : "";
if (task === "cad") return [`/cad ${desc}${images.length ? " (sketch attached)" : ""}${fileList}`];
if (task !== "craft") return [desc + fileList];
switch (target) {
case "website":
return [`Recreate the website ${url.trim()} as a new project in this folder.${desc ? `\n\n${desc}` : ""}${fileList}`];
case "app":
case "game":
return [
`Recreate this ${target === "app" ? "app" : "game"} as a new project in this folder${images.length ? " – screenshots are attached" : ""}.${desc ? `\n\n${desc}` : ""}${fileList}`,
];
case "software":
return [
`/recreate-init The program files are already in original/ (${uploaded.length ? uploaded.join(", ") : "added by the user"}). Set up the layout around them without moving them.`,
`/recreate-start ${desc}`,
];
default:
return [`Build this from scratch (there is no original to study – start with a short plan, then build, check and refine):\n\n${desc}${fileList}`];
}
};
const create = async () => {
setError(undefined);
try {
// 1. Folder
let cwd = picked;
if (task === "chat") {
// A plain conversation needs no project: it runs in the user's own folder.
cwd = rootPath;
} else if (folderMode === "git") {
setCreating(t("wizard.cloning"));
const target = `${rootPath}/${folderName.trim().replace(/[\\/]/g, "-")}`;
await post("/git", { action: "clone", path: target, url: repoUrl.trim() });
cwd = target;
} else if (folderMode === "new") {
setCreating(t("wizard.creatingFolder"));
const base = folderName.trim().replace(/[\\/]/g, "-");
for (let i = 0; i < 50; i++) {
const candidate = `${rootPath}/${i ? `${base}-${i + 1}` : base}`;
try {
await api("/files/create", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: candidate, dir: true }) });
cwd = candidate;
break;
} catch (e: any) {
if (!/exists/i.test(e.message)) throw e;
}
}
if (!cwd) throw new Error("could not create the folder");
}
if (task !== "chat") localStorage.setItem(LAST_DIR, cwd);
// 2. Files: originals of old software into original/, everything else into .codecraft/uploads/
const uploaded: string[] = [];
const mkdirs = async (dir: string) => {
let parent = cwd;
for (const part of dir.split("/")) {
parent = `${parent}/${part}`;
await api("/files/create", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: parent, dir: true }) }).catch(() => undefined);
}
};
const send = async (list: File[], dir: string) => {
if (!list.length) return;
await mkdirs(dir);
for (const f of list) {
const name = f.name.replace(/[\\/]/g, "_");
setCreating(`${t("wizard.uploading")} ${name}`);
await uploadFile(`${cwd}/${dir}/${name}`, f, (x) => setProgress((p) => ({ ...p, [f.name]: x })));
uploaded.push(`${dir}/${name}`);
}
};
// Originals of old software go to original/, everything else to .codecraft/uploads/.
await send(originals, "original");
await send(files, isRecreate ? "original" : ".codecraft/uploads");
// 3. Session and first prompt(s)
setCreating(t("wizard.starting"));
const isCraft = task === "craft";
const s = await post<{ id: string }>("/sessions", {
cwd,
model: model || undefined,
agent: task === "cad" ? "build" : task,
clean_room: isCraft ? !deep : undefined,
});
if (isolated && folderMode !== "new" && task !== "chat") {
setCreating(t("worktree.creating"));
await post(`/sessions/${s.id}/worktree`, { action: "create" });
}
const msgs = prompts(uploaded).filter((m) => m.trim());
for (const [i, m] of msgs.entries()) await post(`/sessions/${s.id}/prompt`, { text: m, images: i === 0 ? images : [] });
onCreated(s.id);
} catch (e: any) {
setError(e.message);
setCreating(undefined);
}
};
let n = 1;
return (
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
<Icon name="menu" />
</button>
<div className="grow">
<h1>{t("new.title")}</h1>
</div>
</div>
<div className="page">
<div className="page-inner wizard">
<h2 className="wiz-title">{t("wizard.title")}</h2>
<p className="wiz-sub">{t("wizard.subtitle")}</p>
<div className="panel wiz-panel">
<Step n={n++} title={t("wizard.whatToDo")}>
<div className="wiz-cards">
<Card icon="chat" title={t("wizard.task.chat")} desc={t("wizard.task.chatDesc")} active={task === "chat"} onClick={() => setTask("chat")} />
<Card icon="craft" title={t("wizard.task.craft")} desc={t("wizard.task.craftDesc")} active={task === "craft"} onClick={() => setTask("craft")} />
<Card icon="cad" title={t("wizard.task.cad")} desc={t("wizard.task.cadDesc")} active={task === "cad"} onClick={() => setTask("cad")} />
<Card icon="build" title={t("wizard.task.build")} desc={t("wizard.task.buildDesc")} active={task === "build"} onClick={() => setTask("build")} />
<Card icon="plan" title={t("wizard.task.plan")} desc={t("wizard.task.planDesc")} active={task === "plan"} onClick={() => setTask("plan")} />
{custom.map((a) => (
<Card key={a.name} icon="agent" title={a.name} desc={a.description} active={task === a.name} onClick={() => setTask(a.name)} />
))}
</div>
</Step>
{task === "craft" && (
<Step n={n++} title={t("wizard.whatToRecreate")}>
<div className="wiz-cards five">
{(["website", "app", "game", "software", "fresh"] as Target[]).map((x) => (
<Card key={x} icon={x} title={t(`wizard.target.${x}` as Key)} active={target === x} onClick={() => setTarget(x)} />
))}
</div>
{target !== "fresh" && (
<div className="wiz-cards two" style={{ marginTop: 12 }}>
<Card icon="clean" title={t("craft.cleanRoom")} desc={t("wizard.cleanDesc")} active={!deep} onClick={() => setDeep(false)} />
<Card icon="deep" title={t("craft.deep")} desc={t("wizard.deepDesc")} active={deep} onClick={() => setDeep(true)} />
</div>
)}
{target === "software" && !deep && <div className="notice">{t("wizard.softwareNeedsDeep")}</div>}
{target === "software" && (
<div
className="wiz-drop"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
setOriginals((c) => [...c, ...e.dataTransfer.files]);
}}
onClick={() => originalInput.current?.click()}
role="button"
tabIndex={0}
>
{ICONS.upload}
<div>
<b>{t("wizard.originalTitle")}</b>
<div className="hint">{t("wizard.originalHint")}</div>
</div>
<input
ref={originalInput}
type="file"
multiple
hidden
onChange={(e) => {
if (e.target.files) setOriginals((c) => [...c, ...e.target.files!]);
e.target.value = "";
}}
/>
</div>
)}
{target === "software" && originals.length > 0 && (
<div className="wiz-originals">
{originals.map((f, i) => (
<div key={`${f.name}${i}`} className="wiz-original">
<span className="name">{f.name}</span>
<span className="size">{formatSize(f.size)}</span>
{progress[f.name] !== undefined ? (
<span className="bar">
<i style={{ width: `${Math.round(progress[f.name] * 100)}%` }} />
</span>
) : (
<button className="small ghost" onClick={() => setOriginals((c) => c.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
<Icon name="close" />
</button>
)}
</div>
))}
</div>
)}
</Step>
)}
<Step n={n++} title={t("wizard.sayWhat")}>
{needsUrl && (
<input
className="wiz-url"
type="url"
value={url}
placeholder="https://example.com"
onChange={(e) => setUrl(e.target.value)}
/>
)}
<div
className="wiz-input"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
void addFiles(e.dataTransfer.files);
}}
>
<textarea
value={text}
placeholder={t(task === "craft" ? (`wizard.placeholder.${target}` as Key) : task === "cad" ? "wizard.placeholder.cad" : task === "chat" ? "wizard.placeholder.chat" : "wizard.placeholder.task")}
onChange={(e) => setText(e.target.value)}
onPaste={(e) => {
if (e.clipboardData.files.length) {
e.preventDefault();
void addFiles(e.clipboardData.files);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !matchMedia("(pointer: coarse)").matches && canStart) {
e.preventDefault();
void create();
}
}}
/>
{(images.length > 0 || files.length > 0) && (
<div className="attachments">
{images.map((img, i) => (
<div className="att" key={`i${i}`}>
<img src={imageSrc(img)} alt="" />
<button onClick={() => setImages((c) => c.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
<Icon name="close" />
</button>
</div>
))}
{files.map((f, i) => (
<div className="att file" key={`f${i}`}>
<span><Icon name="file" /> {f.name}</span>
<small>{formatSize(f.size)}</small>
<button onClick={() => setFiles((c) => c.filter((_, j) => j !== i))} aria-label={t("common.remove")}>
<Icon name="close" />
</button>
</div>
))}
</div>
)}
<div className="wiz-input-foot">
<button type="button" className="ghost small" onClick={() => fileInput.current?.click()} title={t("wizard.attach")}>
{ICONS.clip}
</button>
<span>{target === "software" && task === "craft" ? t("wizard.softwareHint") : t("wizard.enterHint")}</span>
<input ref={fileInput} type="file" multiple hidden onChange={(e) => e.target.files && void addFiles(e.target.files)} />
</div>
</div>
{ideas && (
<div className="wiz-ideas">
<span>{t("wizard.ideas")}</span>
{ideas.map((k) => (
<button key={k} type="button" className="chip-btn" onClick={() => setText(t(k))}>
{t(k)}
</button>
))}
</div>
)}
</Step>
<div className="wiz-foot">
<div className="wiz-foot-row">
<label>{task === "chat" ? t("composer.model") : t("wizard.builtBy")}</label>
<ModelSelect value={model} onChange={setModel} />
</div>
{task !== "chat" && (
<>
<div className="wiz-folder-modes" role="radiogroup" aria-label={t("wizard.whereLabel")}>
{(["new", "existing", "git"] as const).map((m) => (
<button key={m} type="button" role="radio" aria-checked={folderMode === m} className={`wiz-folder-mode ${folderMode === m ? "active" : ""}`} onClick={() => setFolderMode(m)}>
{ICONS[m === "git" ? "git" : "folder"]}
{t(m === "new" ? "wizard.whereNew" : m === "existing" ? "wizard.whereExisting" : "wizard.whereGit")}
</button>
))}
</div>
{folderMode === "git" && (
<input className="wiz-url" value={repoUrl} placeholder="https://git.example.com/user/repo.git" onChange={(e) => setRepoUrl(e.target.value)} />
)}
{folderMode !== "existing" && (
<div className="wiz-folder">
{ICONS.folder}
<span className="wiz-folder-text">
{t(folderMode === "git" ? "wizard.cloneInto" : "wizard.newFolderIn")}{" "}
{roots.length > 1 ? (
<select value={rootPath} onChange={(e) => setRootPath(e.target.value)}>
{roots.map((r) => (
<option key={r.path} value={r.path}>
{r.name === "/" ? "workspace" : r.name}
</option>
))}
</select>
) : (
<code>{!roots[0] || roots[0].name === "/" ? "workspace" : roots[0].name}</code>
)}
<span>/</span>
<input
className="wiz-folder-name"
value={folderName}
onChange={(e) => {
setFolderName(e.target.value);
setNameTouched(true);
}}
/>
</span>
</div>
)}
{folderMode === "existing" && <FolderPicker path={picked} onPick={setPicked} />}
{folderMode !== "new" && (
<label className="wiz-check-row">
<input type="checkbox" checked={isolated} onChange={(e) => setIsolated(e.target.checked)} />
<span>
<b>{t("worktree.isolate")}</b>
<span className="hint"> – {t("worktree.isolateHint")}</span>
</span>
</label>
)}
</>
)}
{error && <div className="notice err">{error}</div>}
<div className="wiz-start-row">
<button className="primary wiz-start" disabled={!canStart} onClick={create}>
{ICONS.start}
{creating ?? t("wizard.start")}
</button>
</div>
</div>
</div>
</div>
</div>
</>
);
}
/** Browse the user's folders and pick one. */
export function FolderPicker({ path, onPick }: { path: string; onPick: (p: string) => void }) {
const t = useT();
const [browse, setBrowse] = useState(path);
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
const { data: listing, error } = useFetch<DirListing>(`/fs/dirs${browse ? `?path=${encodeURIComponent(browse)}` : ""}`);
useEffect(() => {
if (error && browse) setBrowse("");
}, [error, browse]);
useEffect(() => {
if (listing && listing.path !== path) onPick(listing.path);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listing?.path]);
const rel = listing ? listing.path.slice(listing.workspace.length) || "/" : "";
return (
<div className="wiz-picker">
{roots.length > 1 && (
<div className="mode-tabs" style={{ marginBottom: 8, flexWrap: "wrap" }}>
{roots.map((r) => (
<button key={r.path} className={listing?.root === r.path ? "active" : ""} onClick={() => setBrowse(r.path)}>
{r.name}
</button>
))}
</div>
)}
<div className="row" style={{ marginBottom: 8 }}>
<code className="grow" style={{ flex: 1 }}>
{rel}
</code>
{listing?.parent && (
<button className="small" onClick={() => setBrowse(listing.parent!)}>
<Icon name="arrow-up" /> {t("new.up")}
</button>
)}
</div>
<div className="dir-list">
{listing?.dirs.length === 0 && <div className="empty" style={{ padding: 16 }}>{t("new.noSubdirs")}</div>}
{listing?.dirs.map((d) => (
<button key={d.path} onClick={() => setBrowse(d.path)}>
{ICONS.folder}
<span style={{ flex: 1 }}>{d.name}</span>
{d.git && <span className="chip">git</span>}
</button>
))}
</div>
</div>
);
}