diff --git a/CHANGELOG.md b/CHANGELOG.md index 63bc0fb..28e84b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ user-facing notes live in [`docs/updates/`](docs/updates/) (English and ## Unreleased +### Added + +- Editor: code completion from the SDK itself. `scad.` (and `scad.ql.`, `scad.std.gear.`, `ql.`) + lists the real functions with their signatures and docstrings; accepting one, or typing `(` + after a known name, writes the call with every required parameter as `name=` and Tab moves + between them; inside a call the parameters not given yet complete as `name=` (required first). + The names come from the running package (`GET /api/sdk`), so they cannot drift from the code. +- Editor: a **new** action in the workspace tab starts a notebook from the SDK's template (a part, + or an assembly that uses the first two parts of the workspace), with the name checked as you + type. An empty workspace opens on that form, so `sca edit` on an empty directory is usable. + +### Fixed + +- Editor: the cell editors rebuilt their configuration on every render (a new `onChange` and + `basicSetup` each time), which dropped any snippet in progress and could disturb typing; both + are stable now. + ## 2.2.1 — 2026-10-13 The first stable release of the 2.2 line (2.2.0b1 was a pre-release): everything @@ -21,11 +38,6 @@ below was added since 2.1.4, the notebook runtime of 2.2.0b1 included. ### Added -- Editor: code completion from the SDK itself. `scad.` (and `scad.ql.`, `scad.std.gear.`, `ql.`) - lists the real functions with their signatures and docstrings; accepting one, or typing `(` - after a known name, writes the call with every required parameter as `name=` and Tab moves - between them; inside a call the parameters not given yet complete as `name=` (required first). - The names come from the running package (`GET /api/sdk`), so they cannot drift from the code. - Modeling: `scale`, affine `transform`, signed `offset`, `draft`, tapered and until-limited extrusions and multisection sweeps are recorded features, and boolean results can keep every output of a set operation; graph nodes keep diff --git a/editor-web/src/App.tsx b/editor-web/src/App.tsx index 2d481e4..a19d286 100644 --- a/editor-web/src/App.tsx +++ b/editor-web/src/App.tsx @@ -9,7 +9,7 @@ import { ModuleView, type ModuleState } from "@/components/ModuleView"; import { NotebookList } from "@/components/NotebookList"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { fetchPreview, getWorkspace, openNotebook, postViewport, type ModuleRow, type NotebookRow } from "@/lib/api"; +import { fetchPreview, getWorkspace, newNotebook, openNotebook, postViewport, type ModuleRow, type NotebookRow } from "@/lib/api"; import { parsePreview } from "@/lib/preview"; import { EnvelopeSocket, type Envelope } from "@/lib/envelope"; import { useRunLog } from "@/lib/runlog"; @@ -118,12 +118,14 @@ export function App() { socket.on("hello", (envelope) => { setNotebook((envelope["notebook"] as string) ?? null); setCurrentPath((envelope["path"] as string) ?? null); + // an empty workspace: the way forward is the workspace tab's "new" + if (!envelope["notebook"]) setLeftTab("workspace"); setKernelAlive(Boolean(envelope["kernel"])); // No notebook: nothing to wait for. Else the model is on its way. if (hasModel.current) return; const stage = envelope["notebook"] ? (envelope["kernel"] ? "building the model" : "starting the kernel") : null; setLoading(stage); - if (stage) setStatus(`${stage[0].toUpperCase()}${stage.slice(1)}…`); + setStatus(stage ? `${stage[0].toUpperCase()}${stage.slice(1)}…` : "No notebook yet: create one in the workspace tab"); setMarimoUrl((envelope["marimo_url"] as string) ?? null); refreshWorkspace(); }); @@ -312,6 +314,13 @@ export function App() { }); }; + const handleCreate = async (name: string, kind: "part" | "assembly") => { + setStatus(`creating ${name}…`); + await newNotebook(name, kind); + // the server opens it: show its cells + setLeftTab("notebook"); + }; + return (
socketRef.current?.send({ type: "module.open", path })} + onCreate={handleCreate} /> {moduleView && ( diff --git a/editor-web/src/components/NotebookList.tsx b/editor-web/src/components/NotebookList.tsx index 0551015..21e6d05 100644 --- a/editor-web/src/components/NotebookList.tsx +++ b/editor-web/src/components/NotebookList.tsx @@ -1,11 +1,18 @@ +import { Plus } from "lucide-react"; +import { useRef, useState } from "react"; + import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { cn } from "@/lib/utils"; import type { ModuleRow, NotebookRow } from "@/lib/api"; +/** Names the template accepts: the notebook's id and its file name. */ +const NAME = /^[A-Za-z_][A-Za-z0-9_-]*$/; + /** The workspace: its SimpleCAD notebooks (assemblies show what they use) and * the local modules they import. Editing a module changes every notebook - * that imports it. */ + * that imports it. A new notebook starts from the SDK's template. */ export function NotebookList({ notebooks, current, @@ -13,6 +20,7 @@ export function NotebookList({ openModule, onOpen, onOpenModule, + onCreate, }: { notebooks: NotebookRow[]; /** Workspace-relative path of the open notebook. */ @@ -21,13 +29,27 @@ export function NotebookList({ openModule: string | null; onOpen: (path: string) => void; onOpenModule: (path: string) => void; + /** Write a new notebook and open it; rejects with the reason it could not. */ + onCreate: (name: string, kind: "part" | "assembly") => Promise; }) { - if (notebooks.length === 0 && modules.length === 0) { - return

No SimpleCAD notebooks in this workspace.

; - } + const [adding, setAdding] = useState(false); + const empty = notebooks.length === 0; return ( -

notebooks

+
+ notebooks + +
+ {(adding || empty) && ( + setAdding(false)} onCancel={() => setAdding(false)} /> + )}
    {notebooks.map((row) => (
  • @@ -82,3 +104,105 @@ export function NotebookList({ ); } + +/** The form for a new notebook: a name and what it is. */ +function NewEntry({ + notebooks, + first, + onCreate, + onDone, + onCancel, +}: { + notebooks: NotebookRow[]; + /** The workspace has no notebook yet. */ + first: boolean; + onCreate: (name: string, kind: "part" | "assembly") => Promise; + onDone: () => void; + onCancel: () => void; +}) { + const [name, setName] = useState(""); + const [kind, setKind] = useState<"part" | "assembly">("part"); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const input = useRef(null); + const parts = notebooks.filter((row) => row.kind === "part"); + const taken = (value: string) => notebooks.some((row) => row.path === `${value}.py`); + + const submit = async () => { + const value = name.trim(); + if (!NAME.test(value)) return setError("Use letters, digits, - and _, starting with a letter or _."); + if (taken(value)) return setError(`${value}.py already exists.`); + setBusy(true); + setError(null); + try { + await onCreate(value, kind); + onDone(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Could not create the notebook."); + } finally { + setBusy(false); + } + }; + + return ( +
    { + event.preventDefault(); + void submit(); + }} + > + {first &&

    An empty workspace. Start with a part: a notebook that builds one solid.

    } + { + setName(event.target.value); + setError(null); + }} + onKeyDown={(event) => event.key === "Escape" && !first && onCancel()} + placeholder="name, e.g. bracket" + aria-label="Name of the new notebook" + spellCheck={false} + className="w-full border-2 border-input bg-transparent px-2 py-1 focus:border-focus focus:outline-none" + /> +
    + {(["part", "assembly"] as const).map((option) => ( + + ))} +
    + {kind === "assembly" && parts.length > 0 && ( +

    + Starts with {parts.slice(0, 2).map((row) => row.name).join(" and ")}; change what it uses in its cells. +

    + )} + {error &&

    {error}

    } +
    + + {!first && ( + + )} +
    +
    + ); +} diff --git a/editor-web/src/lib/api.ts b/editor-web/src/lib/api.ts index 8509c6c..d7e601f 100644 --- a/editor-web/src/lib/api.ts +++ b/editor-web/src/lib/api.ts @@ -47,13 +47,16 @@ export function openNotebook(path: string): Promise<{ ok: boolean }> { }).then((response) => json<{ ok: boolean }>(response)); } -/** Provision a new entry notebook from the server template and open it. */ -export function newNotebook(name: string, kind: "part" | "assembly"): Promise<{ created: string }> { - return fetch("/api/workspace/new", { +/** Provision a new entry notebook from the server template and open it. Rejects with the server's reason (a bad or taken name). */ +export async function newNotebook(name: string, kind: "part" | "assembly"): Promise<{ created: string }> { + const response = await fetch("/api/workspace/new", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, kind }), - }).then((response) => json<{ created: string }>(response)); + }); + const body = (await response.json().catch(() => null)) as { created?: string; error?: string } | null; + if (!response.ok || !body?.created) throw new Error(body?.error ?? `${response.status} ${response.statusText}`); + return { created: body.created }; } /** What the viewport shows (PNG data URL), for coding agents to look at. */