import { Signal } from "@char/aftercare"; import { type Project, projectFor, type ProjectIndex, type ProjectInput, type Workspace, workspaceFor, } from "@berry/api/projects.ts"; import { api } from "./client.ts"; import { configurationField } from "./configuration.tsx"; import { folderInput } from "./folders.tsx"; import { Scope } from "./ui.ts"; const workspaceLabel = (root: { environment: string; cwd: string }) => `${root.environment === "local" ? "" : `${root.environment} · `}${root.cwd || "home"}`; /** The sidebar's groups in order, `""` being no project; those never ordered follow by name. */ export function sidebarOrder(projects: ProjectIndex, order: readonly string[]): string[] { const named = Object.keys(projects).sort((a, b) => projects[a].name.localeCompare(projects[b].name) ); return [...new Set([...order, ...named, ""])].filter((id) => id === "" || id in projects); } /** Move a sidebar group to `index`, saving the new order. */ export async function moveGroup( projects: ProjectIndex, order: Signal, id: string, index: number, ) { const ids = sidebarOrder(projects, order.get()).filter((other) => other !== id); ids.splice(index, 0, id); await api("PUT", "/api/project-order", { ids }); order.set(ids); } export function projectPicker( scope: Scope, projects: Signal, choose: (workspace: Workspace | null) => void, ) { const select = as HTMLSelectElement; const workspaceField = ( ) as HTMLLabelElement; const element = (
{workspaceField}
) as HTMLElement; const draw = (reset = false) => { const previous = workspace.selectedOptions[0]?.dataset.key; const selected = select.value; const index = projects.get(); select.replaceChildren( , ...Object.entries(index) .sort(([, a], [, b]) => a.name.localeCompare(b.name)) .map(([id, project]) => ), ); select.value = index[selected] ? selected : ""; const project = index[select.value]; const key = (root: Workspace) => `${root.environment}\n${root.cwd}`; workspace.replaceChildren( ...(project?.roots ?? []).map((root, i) => ( )), ); const previousIndex = reset ? -1 : project?.roots.findIndex((root) => key(root) === previous) ?? -1; workspace.value = String(previousIndex >= 0 ? previousIndex : project?.defaultRoot ?? 0); workspaceField.hidden = !project || project.roots.length < 2; element.hidden = Object.keys(index).length === 0; }; const pick = (id: string) => { select.value = id; draw(true); const project = projects.get()[select.value]; choose(project?.roots[project.defaultRoot] ?? null); }; select.addEventListener("change", () => pick(select.value)); workspace.addEventListener("change", () => { const root = projects.get()[select.value]?.roots[Number(workspace.value)]; if (root) choose(root); }); scope.add(projects.subscribe(() => draw()).unsubscribe); draw(); return { element, pick, /** Show the project, and its workspace, that a place chosen by hand is in, without choosing it again. */ show(place: { environment: string; cwd: string }) { const id = projectFor(projects.get(), place) ?? ""; if (select.value !== id) { select.value = id; draw(true); } const project = projects.get()[id]; const root = project && workspaceFor(project, place); if (root) workspace.value = String(project.roots.indexOf(root)); }, }; } export function projectEditor( scope: Scope, machines: Signal, projects: Signal, order = new Signal([]), ) { const title =

as HTMLElement; const name = as HTMLInputElement; const roots =
as HTMLElement; const status =

as HTMLElement; const remove = ( ) as HTMLButtonElement; const move = as HTMLInputElement; const moveField = ( ) as HTMLLabelElement; let original: Project | undefined; let id: string | undefined; let draft: ProjectInput = { name: "", roots: [], defaultRoot: 0 }; let fields = new Scope(); let pending = false; const updateRemoval = () => { moveField.hidden = !original?.roots.some((root) => !draft.roots.some((saved) => saved.environment === root.environment && saved.cwd === root.cwd) ); }; const draw = () => { fields.dispose(); fields = new Scope(); roots.replaceChildren(...draft.roots.map((root, i) => { const environment = new Signal(root.environment); const folder = folderInput({ scope: fields, environment, projects, base: () => null }); folder.input.value = root.cwd; folder.input.setAttribute("aria-label", `folder of workspace ${i + 1}`); folder.input.addEventListener("input", () => { root.cwd = folder.input.value; updateRemoval(); }); const machine = configurationField( "environment", { type: "string" }, root.environment, (value) => { root.environment = String(value); root.cwd = folder.input.value = ""; environment.set(root.environment); updateRemoval(); }, { scope: fields, choices: machines }, ).input; machine.setAttribute("aria-label", `environment of workspace ${i + 1}`); return (

draft.defaultRoot = i} /> {machine} {folder.element}
); })); updateRemoval(); }; const close = () => { if (pending) return; dialog.close(); fields.dispose(); }; const up = ( ) as HTMLButtonElement; const down = ( ) as HTMLButtonElement; // reordering saves at once, so it sits apart from the fields that wait for save const ordering =
{up}{down}
as HTMLElement; const drawOrder = () => { const ids = sidebarOrder(projects.get(), order.get()); ordering.hidden = id === undefined; up.disabled = ids[0] === id; down.disabled = ids.at(-1) === id; }; for (const [button, delta] of [[up, -1], [down, 1]] as const) { button.addEventListener("click", async () => { if (pending || id === undefined) return; pending = true; status.textContent = ""; try { const at = sidebarOrder(projects.get(), order.get()).indexOf(id); await moveGroup(projects.get(), order, id, at + delta); } catch (error) { status.textContent = (error as Error).message; } finally { pending = false; drawOrder(); } }); } const details = (
workspaces {roots}
{moveField}
) as HTMLElement; const dismiss = as HTMLButtonElement; const save = as HTMLButtonElement; const controls = (
{details} {status}
{remove} {dismiss} {save}
) as HTMLFieldSetElement; const dialog = (
{ event.preventDefault(); if (pending) return; pending = true; controls.disabled = true; status.textContent = ""; try { await api(id ? "PUT" : "POST", id ? `/api/projects/${id}` : "/api/projects", { ...draft, name: name.value, ...(!moveField.hidden && move.checked ? { moveConversations: true } : {}), }); pending = false; close(); } catch (error) { status.textContent = (error as Error).message; } finally { pending = false; controls.disabled = false; } }} >
{title} {ordering}
{controls}
) as HTMLDialogElement; dialog.addEventListener("cancel", (event) => { if (pending) event.preventDefault(); }); dialog.addEventListener("close", () => fields.dispose()); remove.addEventListener("click", async () => { if ( !id || pending || !confirm("delete this project? conversations will move to 'no project'") ) { return; } pending = true; controls.disabled = true; try { await api("DELETE", `/api/projects/${id}`); pending = false; close(); } catch (error) { status.textContent = (error as Error).message; } finally { pending = false; controls.disabled = false; } }); scope.add(() => { fields.dispose(); dialog.remove(); }); return { element: dialog, open(projectId?: string) { id = projectId; const project: Project | undefined = id ? projects.get()[id] : undefined; original = project; move.checked = false; title.textContent = id === "" ? "no project" : project ? "edit project" : "new project"; details.hidden = save.hidden = id === ""; dismiss.textContent = id === "" ? "done" : "cancel"; name.value = project?.name ?? ""; draft = { name: name.value, roots: project?.roots.map(({ environment, cwd }) => ({ environment, cwd })) ?? [{ environment: "local", cwd: "" }], defaultRoot: project?.defaultRoot ?? 0, }; status.textContent = ""; remove.hidden = !project; draw(); drawOrder(); dialog.showModal(); if (id !== "") name.focus(); }, }; }