From c04f8b55288075d419eadc6f04d0708d6b16f2bd Mon Sep 17 00:00:00 2001 From: NiJingzhe <1448976235@qq.com> Date: Wed, 14 Oct 2026 00:24:48 +0800 Subject: [PATCH] feat(editor): create a notebook from the workspace tab A 'new' action in the notebooks header: a name (checked against the template's rule and the existing files), part or assembly (assembly starts with the first two parts of the workspace), create. An empty workspace opens on the workspace tab with the form shown. Server errors (a taken name) are shown in the form. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 3 + editor-web/src/App.tsx | 14 ++- editor-web/src/components/NotebookList.tsx | 134 ++++++++++++++++++++- editor-web/src/lib/api.ts | 11 +- 4 files changed, 151 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1604527..6c2c2d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ below was added since 2.1.4, the notebook runtime of 2.2.0b1 included. ### Added +- 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. - 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. */