Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
14 changes: 12 additions & 2 deletions editor-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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 (
<div className="flex h-screen flex-col overflow-hidden">
<Header
Expand Down Expand Up @@ -356,6 +365,7 @@ export function App() {
openModule={moduleView?.path ?? null}
onOpen={handleOpen}
onOpenModule={(path) => socketRef.current?.send({ type: "module.open", path })}
onCreate={handleCreate}
/>
</TabsContent>
{moduleView && (
Expand Down
134 changes: 129 additions & 5 deletions editor-web/src/components/NotebookList.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
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,
modules,
openModule,
onOpen,
onOpenModule,
onCreate,
}: {
notebooks: NotebookRow[];
/** Workspace-relative path of the open notebook. */
Expand All @@ -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<void>;
}) {
if (notebooks.length === 0 && modules.length === 0) {
return <p className="p-3 text-[11px] text-muted-foreground">No SimpleCAD notebooks in this workspace.</p>;
}
const [adding, setAdding] = useState(false);
const empty = notebooks.length === 0;
return (
<ScrollArea className="h-full">
<p className="label border-b border-border px-3 py-1.5">notebooks</p>
<div className="flex items-center justify-between border-b border-border px-3 py-1.5">
<span className="label">notebooks</span>
<button
type="button"
className="label flex items-center gap-1 hover:text-foreground"
title="Start a new notebook from the template"
onClick={() => setAdding((open) => !open)}
>
<Plus className="h-3 w-3" /> new
</button>
</div>
{(adding || empty) && (
<NewEntry notebooks={notebooks} first={empty} onCreate={onCreate} onDone={() => setAdding(false)} onCancel={() => setAdding(false)} />
)}
<ul>
{notebooks.map((row) => (
<li key={row.path} className="border-b border-border">
Expand Down Expand Up @@ -82,3 +104,105 @@ export function NotebookList({
</ScrollArea>
);
}

/** 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<void>;
onDone: () => void;
onCancel: () => void;
}) {
const [name, setName] = useState("");
const [kind, setKind] = useState<"part" | "assembly">("part");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const input = useRef<HTMLInputElement>(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 (
<form
className="space-y-2 border-b border-border bg-card px-3 py-3"
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
{first && <p className="text-[11px] text-muted-foreground">An empty workspace. Start with a part: a notebook that builds one solid.</p>}
<input
ref={input}
autoFocus
value={name}
onChange={(event) => {
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"
/>
<div className="flex gap-1">
{(["part", "assembly"] as const).map((option) => (
<button
key={option}
type="button"
disabled={option === "assembly" && parts.length === 0}
title={option === "assembly" && parts.length === 0 ? "An assembly puts parts together: create a part first" : undefined}
onClick={() => {
setKind(option);
input.current?.focus(); // Enter then creates
}}
className={cn(
"border-2 px-2 py-px text-[10px] font-semibold uppercase tracking-[0.1em] transition-colors disabled:cursor-not-allowed disabled:opacity-40",
kind === option ? "border-primary bg-primary text-primary-foreground" : "border-transparent text-muted-foreground hover:border-input hover:text-foreground",
)}
>
{option}
</button>
))}
</div>
{kind === "assembly" && parts.length > 0 && (
<p className="text-[11px] text-muted-foreground">
Starts with {parts.slice(0, 2).map((row) => row.name).join(" and ")}; change what it uses in its cells.
</p>
)}
{error && <p className="text-[11px] text-fail">{error}</p>}
<div className="flex gap-2">
<Button size="sm" type="submit" disabled={busy || !name.trim()}>
create
</Button>
{!first && (
<Button size="sm" type="button" variant="ghost" onClick={onCancel}>
cancel
</Button>
)}
</div>
</form>
);
}
11 changes: 7 additions & 4 deletions editor-web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading