diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 7f646e1..25461f6 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -68,7 +68,7 @@ jobs: ext="" if [ "$GOOS" = "windows" ]; then ext=".exe"; fi out="flowgo-${TAG}-${GOOS}-${GOARCH}${ext}" - go build -trimpath -ldflags "-s -w -X main.version=${TAG}" -o "$out" . + go build -trimpath -ldflags "-s -w -X main.version=${TAG}" -o "$out" ./cmd/flowgo echo "OUT=$out" >> "$GITHUB_ENV" - name: Attach to release env: diff --git a/.gitignore b/.gitignore index 6d5bdeb..2f9cc3f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,12 @@ .claude-container -flowgo +.claude + +# Compiled binary at repo root. Anchored with `/` so it doesn't also +# ignore pkg/flowgo (the library package directory). +/flowgo + +# Local-only planning docs (not for public repo). +PLAN.md # MemPalace per-project files (issue #185) mempalace.yaml diff --git a/README.md b/README.md index d5586d2..632737a 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,17 @@ The path of least resistance, in order: **1. In your browser, no install:** [flowgo-map.com](https://flowgo-map.com) -**2. On your machine, one command:** +**2. On macOS / Linux via Homebrew:** + +``` +brew install lassediercks/flowgo/flowgo +flowgo new +``` + +The tap lives at [lassediercks/homebrew-flowgo](https://github.com/lassediercks/homebrew-flowgo) +and tracks the latest release. `brew upgrade flowgo` pulls new versions. + +**3. On your machine, one command:** ``` go install github.com/lassediercks/flowgo@latest diff --git a/listen_test.go b/cmd/flowgo/listen_test.go similarity index 100% rename from listen_test.go rename to cmd/flowgo/listen_test.go diff --git a/main.go b/cmd/flowgo/main.go similarity index 82% rename from main.go rename to cmd/flowgo/main.go index 7b6baa0..3e92231 100644 --- a/main.go +++ b/cmd/flowgo/main.go @@ -1,7 +1,11 @@ +// Package main is the public flowgo CLI. It composes the +// pkg/flowgo library with a small flag parser, browser launcher, and +// version reporter; everything substantive lives in the library so +// downstream consumers can wire flowgo onto their own HTTP mux +// without copying code. package main import ( - _ "embed" "encoding/json" "fmt" "net" @@ -13,39 +17,20 @@ import ( "strings" "sync" + "github.com/lassediercks/flowgo/pkg/flowgo" "github.com/lassediercks/flowgo/pkg/graph" ) -// Re-export the graph types and parser/serializer under their original -// unqualified names so the rest of this binary (mcp.go, serve.go, -// workspace.go, validate*.go) keeps compiling without churn. External -// consumers should import github.com/lassediercks/flowgo/pkg/graph -// directly instead of relying on these aliases. -type ( - Box = graph.Box - Edge = graph.Edge - Text = graph.Text - Line = graph.Line - Stroke = graph.Stroke - NamedMap = graph.NamedMap - Graph = graph.Graph -) - -var ( - parse = graph.Parse - serialize = graph.Serialize -) - +// version is overwritten at release-build time via: +// +// go build -ldflags "-X main.version=" ./cmd/flowgo +// +// `go install ...@` also surfaces the module version via +// runtime/debug, which resolveVersionString falls back on. var version = "dev" -//go:embed dist/index.html -var indexHTML string - -//go:embed .release-please-manifest.json -var releasePleaseManifest []byte - var ( - mu sync.Mutex + fileMu sync.Mutex filePath string ) @@ -106,11 +91,11 @@ func main() { createdFile := false if _, err := os.Stat(filePath); os.IsNotExist(err) { - seed := serialize(Graph{ + seed := graph.Serialize(graph.Graph{ Version: resolveVersionString(), - Maps: []NamedMap{{ + Maps: []graph.NamedMap{{ Path: "/", - Boxes: []Box{{ID: "b1", Label: seedBoxLabel(filePath)}}, + Boxes: []graph.Box{{ID: "b1", Label: seedBoxLabel(filePath)}}, }}, }) if err := os.WriteFile(filePath, []byte(seed), 0644); err != nil { @@ -122,9 +107,16 @@ func main() { fmt.Printf("initialised the flowgo interface on a new file %s\n", filePath) } + flowgo.Configure(flowgo.Config{ + ServeMode: false, + LocalFile: filePath, + LocalFileMu: &fileMu, + Version: resolveVersionString, + }) + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(indexHTML)) + w.Write([]byte(flowgo.IndexHTML)) }) http.HandleFunc("/state", handleState) http.HandleFunc("/save", handleSave) @@ -132,7 +124,7 @@ func main() { w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprintln(w, resolveVersionString()) }) - http.HandleFunc("/mcp", handleMCP) + http.HandleFunc("/mcp", flowgo.MCPHandler) // Walk forward from the canonical port so a second flowgo (or any // process holding 54041) doesn't fail to start. Range is bounded to @@ -144,9 +136,6 @@ func main() { } addr := ln.Addr().(*net.TCPAddr) displayHost := bindHost - // When binding to all interfaces, surface the host's real LAN IP - // in the printed URL — `0.0.0.0` isn't a thing the user can paste - // into another browser tab. if bindHost == "0.0.0.0" { if lan := pickLanIP(); lan != "" { displayHost = lan @@ -169,8 +158,6 @@ func main() { // RFC 1918 private ranges (10/8, 172.16/12, 192.168/16) over any other // non-loopback IPv4. Empty string when nothing usable is reachable — // callers should fall back to bindHost in that case. -// -// Pulled out of main so it can be unit-tested via a fake addr provider. func pickLanIP() string { addrs, err := net.InterfaceAddrs() if err != nil { @@ -204,9 +191,7 @@ func pickLanIPFromAddrs(addrs []net.Addr) string { } // listenFirstFree tries each port in [start, end] on host and returns the -// first listener that binds successfully. The window is small on purpose: -// "next free port" should still produce a predictable URL, not vanish into -// the ephemeral range. +// first listener that binds successfully. func listenFirstFree(host string, start, end int) (net.Listener, error) { var lastErr error for port := start; port <= end; port++ { @@ -220,14 +205,14 @@ func listenFirstFree(host string, start, end int) (net.Listener, error) { } func handleState(w http.ResponseWriter, r *http.Request) { - mu.Lock() - defer mu.Unlock() + fileMu.Lock() + defer fileMu.Unlock() data, err := os.ReadFile(filePath) if err != nil { http.Error(w, err.Error(), 500) return } - g, err := parse(string(data)) + g, err := graph.Parse(string(data)) if err != nil { http.Error(w, err.Error(), 500) return @@ -241,15 +226,15 @@ func handleSave(w http.ResponseWriter, r *http.Request) { http.Error(w, "POST only", 405) return } - var g Graph + var g graph.Graph if err := json.NewDecoder(r.Body).Decode(&g); err != nil { http.Error(w, err.Error(), 400) return } - mu.Lock() - defer mu.Unlock() + fileMu.Lock() + defer fileMu.Unlock() g.Version = resolveVersionString() - if err := os.WriteFile(filePath, []byte(serialize(g)), 0644); err != nil { + if err := os.WriteFile(filePath, []byte(graph.Serialize(g)), 0644); err != nil { http.Error(w, err.Error(), 500) return } @@ -294,10 +279,9 @@ func resolveVersionString() string { if version != "dev" { return version } - var m map[string]string - if err := json.Unmarshal(releasePleaseManifest, &m); err == nil { - if v := m["."]; v != "" { - return v + if info, ok := debug.ReadBuildInfo(); ok { + if v := info.Main.Version; v != "" && v != "(devel)" { + return strings.TrimPrefix(v, "v") } } return "dev" diff --git a/naming.go b/cmd/flowgo/naming.go similarity index 100% rename from naming.go rename to cmd/flowgo/naming.go diff --git a/serve.go b/cmd/flowgo/serve.go similarity index 77% rename from serve.go rename to cmd/flowgo/serve.go index d958361..32177ba 100644 --- a/serve.go +++ b/cmd/flowgo/serve.go @@ -7,27 +7,19 @@ import ( "os" "strings" "time" -) -// serveMode flips dispatchTool / mcpTools / route registration into multi- -// tenant workspace + snapshot-share territory. When false, flowgo is the -// single-file editor it's always been. -var serveMode bool + "github.com/lassediercks/flowgo/pkg/flowgo" +) -type ServeConfig struct { +type serveConfig struct { BindAddr string WebhookURL string WebhookSecret string WorkspaceTTL time.Duration } -var ( - serveCfg *ServeConfig - workspaces *WorkspaceManager -) - func runServe(args []string) { - cfg := &ServeConfig{ + cfg := &serveConfig{ BindAddr: "127.0.0.1:8080", WorkspaceTTL: time.Hour, } @@ -85,26 +77,26 @@ func runServe(args []string) { } } - // Allow secret via env so it doesn't appear in process listings. if cfg.WebhookSecret == "" { cfg.WebhookSecret = os.Getenv("FLOWGO_WEBHOOK_SECRET") } - // share is the only feature that strictly requires the webhook. Allow - // running without it for local testing; agents calling share will get a - // clear error if it's not configured. - serveCfg = cfg - serveMode = true - workspaces = newWorkspaceManager(cfg.WorkspaceTTL) + flowgo.Configure(flowgo.Config{ + ServeMode: true, + Workspaces: flowgo.NewWorkspaceManager(cfg.WorkspaceTTL), + ShareWebhookURL: cfg.WebhookURL, + ShareWebhookSecret: cfg.WebhookSecret, + Version: resolveVersionString, + }) mux := http.NewServeMux() - mux.HandleFunc("/mcp", handleMCP) + mux.HandleFunc("/mcp", flowgo.MCPHandler) mux.HandleFunc("/m/", func(w http.ResponseWriter, r *http.Request) { - // Editor HTML for shared snapshots. The website is expected to - // reverse-proxy /m/* here. The HTML detects snapshot mode from the - // pathname and bootstraps from /api/snapshot/. + // Editor HTML for shared snapshots. The website reverse-proxies + // /m/* here. The HTML detects snapshot mode from the pathname + // and bootstraps from /api/snapshot/. w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(indexHTML)) + w.Write([]byte(flowgo.IndexHTML)) }) mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") @@ -117,7 +109,7 @@ func runServe(args []string) { } addr := ln.Addr().(*net.TCPAddr) fmt.Printf("flowgo serve\n") - fmt.Printf(" bind: %s (port %d)\n", cfg.BindAddr, addr.Port) + fmt.Printf(" bind: %s (port %d)\n", cfg.BindAddr, addr.Port) fmt.Printf(" workspace ttl: %s\n", cfg.WorkspaceTTL) if cfg.WebhookURL != "" { fmt.Printf(" share webhook: %s\n", cfg.WebhookURL) @@ -155,7 +147,6 @@ Flags: (default 1h) The website reverse-proxies /api/mcp* and /m/* to this binary on loopback; -flowgo never serves the public surface directly. See -docs/website-integration-memo.md. +flowgo never serves the public surface directly. `) } diff --git a/upgrade.go b/cmd/flowgo/upgrade.go similarity index 100% rename from upgrade.go rename to cmd/flowgo/upgrade.go diff --git a/upgrade_test.go b/cmd/flowgo/upgrade_test.go similarity index 100% rename from upgrade_test.go rename to cmd/flowgo/upgrade_test.go diff --git a/version_check.go b/cmd/flowgo/version_check.go similarity index 98% rename from version_check.go rename to cmd/flowgo/version_check.go index fd7d6a4..1053235 100644 --- a/version_check.go +++ b/cmd/flowgo/version_check.go @@ -72,7 +72,7 @@ func notifyIfNewer(current, latest string) { fmt.Fprintf(os.Stderr, " update available: flowgo %s (you have %s)\n"+ " run `flowgo upgrade` to update in place (or `brew upgrade flowgo` if installed via brew,\n"+ - " or `go install github.com/lassediercks/flowgo@latest` if installed via go)\n", + " or `go install github.com/lassediercks/flowgo/cmd/flowgo@latest` if installed via go)\n", latest, current) } diff --git a/version_check_test.go b/cmd/flowgo/version_check_test.go similarity index 100% rename from version_check_test.go rename to cmd/flowgo/version_check_test.go diff --git a/dist/index.html b/dist/index.html deleted file mode 100644 index bfd0d5b..0000000 --- a/dist/index.html +++ /dev/null @@ -1,773 +0,0 @@ - - - - - - - - - - - -flowgo - - - - -
- - - - -
-
drop here to delete
-
- - - - -
- - - - - - - - - - - - - - - - - - - -
-Give feedback - - - diff --git a/justfile b/justfile index 6dacbf9..f6cfd6c 100644 --- a/justfile +++ b/justfile @@ -66,9 +66,9 @@ _dev-run file: wait "$GO_PID" 2>/dev/null || true echo "── restarting flowgo ──────────────────────────────────" if (( started )); then - FLOWGO_NO_OPEN=1 go run . "{{file}}" --host & + FLOWGO_NO_OPEN=1 go run ./cmd/flowgo "{{file}}" --host & else - go run . "{{file}}" --host & + go run ./cmd/flowgo "{{file}}" --host & started=1 fi GO_PID=$! @@ -81,18 +81,18 @@ _dev-run file: changed=0 while IFS= read -r f; do if [[ "$f" -nt "$marker" ]]; then changed=1; break; fi - done < <(find . \( -name '*.go' -o -path './dist/index.html' \) -not -path './node_modules/*' 2>/dev/null) + done < <(find . \( -name '*.go' -o -path './pkg/flowgo/dist/index.html' \) -not -path './node_modules/*' 2>/dev/null) (( changed )) && start_go done -# One-shot frontend build (writes dist/index.html that main.go embeds). +# One-shot frontend build (writes pkg/flowgo/dist/index.html that the library embeds). build-frontend: pnpm install --silent pnpm exec vite build # Build the Go binary with the freshly built frontend embedded. build: build-frontend - go build -o flowgo . + go build -o flowgo ./cmd/flowgo # Run both test suites. test: diff --git a/package.json b/package.json index 5c16211..615ef24 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,25 @@ { - "name": "flowgo-frontend", - "private": true, - "version": "0.0.0", + "name": "@flowgo/editor", + "version": "0.1.2", + "description": "TypeScript editor + .flowgo helpers for the flowgo mind-map tool. Built as a single self-contained index.html by the public flowgo binary; also consumable as ESM modules by downstream projects.", "type": "module", "packageManager": "pnpm@10.33.2", - "description": "TypeScript pure-function modules + Vitest suite for the flowgo editor. Phase 1 mirrors helpers from index.html so future phases can swap them in.", + "license": "AGPL-3.0", + "repository": { + "type": "git", + "url": "https://github.com/lassediercks/flowgo.git" + }, + "files": [ + "src", + "vite.config.ts", + "tsconfig.json" + ], + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./editor": "./src/editor/main.ts", + "./editor/*": "./src/editor/*" + }, "scripts": { "dev": "vite", "build": "vite build", diff --git a/pkg/flowgo/dist/index.html b/pkg/flowgo/dist/index.html new file mode 100644 index 0000000..0cede78 --- /dev/null +++ b/pkg/flowgo/dist/index.html @@ -0,0 +1,773 @@ + + + + + + + + + + + +flowgo + + + + +
+ + + + +
+
drop here to delete
+
+ + + + +
+ + + + + + + + + + + + + + + + + + + +
+Give feedback + + + diff --git a/mcp.go b/pkg/flowgo/mcp.go similarity index 98% rename from mcp.go rename to pkg/flowgo/mcp.go index 0ae4f3c..dd9eda6 100644 --- a/mcp.go +++ b/pkg/flowgo/mcp.go @@ -1,4 +1,4 @@ -package main +package flowgo import ( "bytes" @@ -164,7 +164,7 @@ func handleMCP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "name": "flowgo", - "version": resolveVersionString(), + "version": cfg.Version(), "about": "POST JSON-RPC 2.0 to this endpoint per the MCP streamable-HTTP transport.", }) return @@ -194,7 +194,7 @@ func handleMCP(w http.ResponseWriter, r *http.Request) { }, "serverInfo": map[string]string{ "name": "flowgo", - "version": resolveVersionString(), + "version": cfg.Version(), }, "instructions": mcpInstructions, } @@ -280,9 +280,9 @@ func mcpToolError(msg string) map[string]any { // --------------------------------------------------------------------------- func updateFile(f func(g *Graph) error) (Graph, error) { - mu.Lock() - defer mu.Unlock() - data, err := os.ReadFile(filePath) + cfg.LocalFileMu.Lock() + defer cfg.LocalFileMu.Unlock() + data, err := os.ReadFile(cfg.LocalFile) if err != nil { return Graph{}, err } @@ -293,17 +293,17 @@ func updateFile(f func(g *Graph) error) (Graph, error) { if err := f(&g); err != nil { return Graph{}, err } - g.Version = resolveVersionString() - if err := os.WriteFile(filePath, []byte(serialize(g)), 0644); err != nil { + g.Version = cfg.Version() + if err := os.WriteFile(cfg.LocalFile, []byte(serialize(g)), 0644); err != nil { return Graph{}, err } return g, nil } func readFile() (Graph, error) { - mu.Lock() - defer mu.Unlock() - data, err := os.ReadFile(filePath) + cfg.LocalFileMu.Lock() + defer cfg.LocalFileMu.Unlock() + data, err := os.ReadFile(cfg.LocalFile) if err != nil { return Graph{}, err } @@ -936,10 +936,10 @@ func dispatchTool(name string, raw json.RawMessage) (any, error) { } } - if serveMode { + if cfg.ServeMode { switch name { case "start_workspace": - return mcpToolText(workspaces.Start()), nil + return mcpToolText(cfg.Workspaces.Start()), nil case "share": return shareWorkspace(args) } @@ -950,14 +950,14 @@ func dispatchTool(name string, raw json.RawMessage) (any, error) { return nil, fmt.Errorf("unknown tool: %s", name) } - if serveMode { + if cfg.ServeMode { wsID := stringArg(args, "workspace_id", "") if wsID == "" { return nil, fmt.Errorf("workspace_id is required (call start_workspace first)") } var result any var inner error - err := workspaces.With(wsID, func(ws *Workspace) error { + err := cfg.Workspaces.With(wsID, func(ws *Workspace) error { r, e := fn(&ws.Graph, args) result = r inner = e @@ -1006,7 +1006,7 @@ func dispatchTool(name string, raw json.RawMessage) (any, error) { func mcpTools() []mcpToolDef { var tools []mcpToolDef - if serveMode { + if cfg.ServeMode { tools = append(tools, mcpToolDef{ Name: "start_workspace", @@ -1024,7 +1024,7 @@ func mcpTools() []mcpToolDef { } wsArg := func(props map[string]any, required []string) (map[string]any, []string) { - if !serveMode { + if !cfg.ServeMode { return props, required } np := map[string]any{"workspace_id": schemaString("Workspace id from start_workspace.")} @@ -1292,12 +1292,12 @@ func shareWorkspace(args map[string]any) (any, error) { if wsID == "" { return nil, fmt.Errorf("workspace_id is required") } - if serveCfg == nil || serveCfg.WebhookURL == "" { + if cfg.ShareWebhookURL == "" { return nil, fmt.Errorf("share is unconfigured: --share-webhook missing") } var graphCopy Graph - if err := workspaces.With(wsID, func(ws *Workspace) error { + if err := cfg.Workspaces.With(wsID, func(ws *Workspace) error { graphCopy = ws.Graph return nil }); err != nil { @@ -1323,13 +1323,13 @@ func shareWorkspace(args map[string]any) (any, error) { return nil, fmt.Errorf("marshal payload: %v", err) } - req, err := http.NewRequest(http.MethodPost, serveCfg.WebhookURL, bytes.NewReader(payload)) + req, err := http.NewRequest(http.MethodPost, cfg.ShareWebhookURL, bytes.NewReader(payload)) if err != nil { return nil, fmt.Errorf("build request: %v", err) } req.Header.Set("Content-Type", "application/json") - if serveCfg.WebhookSecret != "" { - req.Header.Set("Authorization", "Bearer "+serveCfg.WebhookSecret) + if cfg.ShareWebhookSecret != "" { + req.Header.Set("Authorization", "Bearer "+cfg.ShareWebhookSecret) } client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) diff --git a/mcp_test.go b/pkg/flowgo/mcp_test.go similarity index 99% rename from mcp_test.go rename to pkg/flowgo/mcp_test.go index 166a598..cab91bf 100644 --- a/mcp_test.go +++ b/pkg/flowgo/mcp_test.go @@ -1,4 +1,4 @@ -package main +package flowgo import ( "encoding/json" diff --git a/pkg/flowgo/state.go b/pkg/flowgo/state.go new file mode 100644 index 0000000..782f11b --- /dev/null +++ b/pkg/flowgo/state.go @@ -0,0 +1,102 @@ +// Package flowgo is the library form of the flowgo editor server. It +// exposes the embedded editor bundle, the MCP HTTP handler, and the +// in-memory Workspace manager so consumers can wire flowgo's +// behaviour onto their own HTTP mux without copying code. The CLI +// binary in cmd/flowgo is the canonical consumer. +// +// The package is configured once per process via Configure. The MCP +// handler is exposed as MCPHandler and reads state through the +// configured Backend (single-file or multi-tenant workspace). +package flowgo + +import ( + _ "embed" + "net/http" + "sync" + + "github.com/lassediercks/flowgo/pkg/graph" +) + +// Type aliases keep the rest of this package (and tests written before +// the package extraction) compiling without the `graph.` qualifier on +// every reference. External consumers should import pkg/graph directly. +type ( + Box = graph.Box + Edge = graph.Edge + Text = graph.Text + Line = graph.Line + Stroke = graph.Stroke + NamedMap = graph.NamedMap + Graph = graph.Graph +) + +// parse / serialize / validate are kept as package-level shorthands for +// the same reason as the type aliases above. +var ( + parse = graph.Parse + serialize = graph.Serialize + validateGraph = graph.Validate +) + +// IndexHTML is the embedded single-file editor bundle. Consumers serve +// it at "/" (and at "/m/" for snapshot mode). +// +//go:embed dist/index.html +var IndexHTML string + +// Config holds the per-process configuration for the MCP handler and +// any helpers that need to know whether we're in single-file or +// multi-tenant mode. Configure must be called before MCPHandler serves +// any request. +type Config struct { + // ServeMode flips dispatch and tool listing into multi-tenant + // workspace mode. When false, MCP reads/writes the single .flowgo + // file at LocalFile. + ServeMode bool + + // LocalFile is the on-disk .flowgo path (single-file mode only). + LocalFile string + // LocalFileMu serializes reads and writes against LocalFile. If nil + // when ServeMode is false, Configure allocates one. + LocalFileMu *sync.Mutex + + // Workspaces is the in-memory workspace store (serve mode only). + Workspaces *WorkspaceManager + + // ShareWebhookURL is the POST target for the `share` MCP tool. Empty + // disables `share` (the tool still appears in tools/list but returns + // an error if called). + ShareWebhookURL string + ShareWebhookSecret string + + // Version returns the version string to stamp into serialized graphs + // and to report in initialize / serverInfo. If nil, "dev" is used. + Version func() string +} + +var cfg Config + +// init seeds cfg with safe defaults so the package can be imported and +// metadata-only MCP requests (initialize, tools/list, resources/list, +// resources/read) work without a Configure call. Tests rely on this. +func init() { + cfg = Config{Version: func() string { return "dev" }} +} + +// Configure sets the package-level configuration. Safe to call before +// any handler serves a request; not safe to call concurrently with +// active MCP traffic. +func Configure(c Config) { + if c.Version == nil { + c.Version = func() string { return "dev" } + } + if !c.ServeMode && c.LocalFileMu == nil { + c.LocalFileMu = &sync.Mutex{} + } + cfg = c +} + +// MCPHandler is the JSON-RPC MCP HTTP handler. Configure must run first. +func MCPHandler(w http.ResponseWriter, r *http.Request) { + handleMCP(w, r) +} diff --git a/workspace.go b/pkg/flowgo/workspace.go similarity index 96% rename from workspace.go rename to pkg/flowgo/workspace.go index d888089..84aff6e 100644 --- a/workspace.go +++ b/pkg/flowgo/workspace.go @@ -1,4 +1,4 @@ -package main +package flowgo import ( "crypto/rand" @@ -24,7 +24,7 @@ type WorkspaceManager struct { ttl time.Duration } -func newWorkspaceManager(ttl time.Duration) *WorkspaceManager { +func NewWorkspaceManager(ttl time.Duration) *WorkspaceManager { m := &WorkspaceManager{ items: map[string]*Workspace{}, ttl: ttl, diff --git a/map.flowgo b/pkg/graph/map.flowgo similarity index 100% rename from map.flowgo rename to pkg/graph/map.flowgo diff --git a/stroke_palette_test.go b/pkg/graph/stroke_palette_test.go similarity index 92% rename from stroke_palette_test.go rename to pkg/graph/stroke_palette_test.go index ae88d8d..f3f711b 100644 --- a/stroke_palette_test.go +++ b/pkg/graph/stroke_palette_test.go @@ -1,4 +1,4 @@ -package main +package graph import ( "strings" @@ -21,7 +21,7 @@ func TestParseStrokePalette(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - g, err := parse(tc.line) + g, err := Parse(tc.line) if err != nil { t.Fatalf("parse %q: %v", tc.line, err) } @@ -43,7 +43,7 @@ func TestParseStrokePalette(t *testing.T) { // than a silent fallback — otherwise typos in hand-edited files would // silently drop the first point. func TestParseStrokeBadPalette(t *testing.T) { - _, err := parse("stroke s1 abc 100,200 200,300") + _, err := Parse("stroke s1 abc 100,200 200,300") if err == nil { t.Fatal("expected parse error for non-numeric palette token") } @@ -63,7 +63,7 @@ func TestSerializeStrokePalette(t *testing.T) { {ID: "c", Points: [][]float64{{0, 0}, {1, 1}}, Palette: 9}, }, }}} - out := serialize(g) + out := Serialize(g) wantLines := []string{ "stroke a 0,0 1,1", "stroke b 3 0,0 1,1", @@ -76,7 +76,7 @@ func TestSerializeStrokePalette(t *testing.T) { } } -// Round-trip pins parse(serialize(parse(x))) == parse(x) for every +// Round-trip pins Parse(Serialize(Parse(x))) == Parse(x) for every // palette index, including 0 (default — no token emitted). func TestStrokePaletteRoundTrip(t *testing.T) { for _, p := range []int{0, 2, 3, 4, 5, 6, 7, 8, 9} { @@ -88,7 +88,7 @@ func TestStrokePaletteRoundTrip(t *testing.T) { Palette: p, }}, }}} - out, err := parse(serialize(in)) + out, err := Parse(Serialize(in)) if err != nil { t.Fatalf("palette %d re-parse: %v", p, err) } @@ -99,7 +99,7 @@ func TestStrokePaletteRoundTrip(t *testing.T) { } // Edges accept an optional trailing palette token after the two -// endpoints. Round-tripping through parse(serialize(g)) preserves the +// endpoints. Round-tripping through Parse(Serialize(g)) preserves the // palette for every legal value, including 0 (no token emitted). func TestEdgePaletteRoundTrip(t *testing.T) { for _, p := range []int{0, 2, 5, 9} { @@ -108,7 +108,7 @@ func TestEdgePaletteRoundTrip(t *testing.T) { Boxes: []Box{{ID: "a", Label: "a"}, {ID: "b", Label: "b"}}, Edges: []Edge{{From: "a", To: "b", Palette: p}}, }}} - out, err := parse(serialize(in)) + out, err := Parse(Serialize(in)) if err != nil { t.Fatalf("palette %d re-parse: %v", p, err) } @@ -130,7 +130,7 @@ func TestSerializeEdgePalette(t *testing.T) { {From: "a", To: "b", FromHandle: "tl", ToHandle: "br", Palette: 7}, }, }}} - out := serialize(g) + out := Serialize(g) for _, want := range []string{ "edge a b\n", "edge a b 5\n", @@ -150,7 +150,7 @@ func TestLinePaletteRoundTrip(t *testing.T) { Path: "/", Lines: []Line{{ID: "l1", X1: 0, Y1: 0, X2: 10, Y2: 10, Palette: p}}, }}} - out, err := parse(serialize(in)) + out, err := Parse(Serialize(in)) if err != nil { t.Fatalf("palette %d re-parse: %v", p, err) } @@ -168,7 +168,7 @@ func TestSerializeLinePalette(t *testing.T) { {ID: "l2", X1: 0, Y1: 0, X2: 1, Y2: 1, Palette: 4}, }, }}} - out := serialize(g) + out := Serialize(g) for _, want := range []string{"line l1 0 0 1 1\n", "line l2 0 0 1 1 4\n"} { if !strings.Contains(out, want) { t.Fatalf("missing %q in:\n%s", want, out) @@ -176,7 +176,7 @@ func TestSerializeLinePalette(t *testing.T) { } } -// A line with control points round-trips through parse(serialize(g)) +// A line with control points round-trips through Parse(Serialize(g)) // with Mids preserved — for the palette-less case (sentinel "1" // position-holder emitted), the palette-bearing case, and the // multi-point case. @@ -199,7 +199,7 @@ func TestLineMidsRoundTrip(t *testing.T) { Palette: tc.palette, Mids: tc.mids, }}, }}} - out, err := parse(serialize(in)) + out, err := Parse(Serialize(in)) if err != nil { t.Fatalf("re-parse: %v", err) } @@ -229,7 +229,7 @@ func TestSerializeLineMids(t *testing.T) { {ID: "c", X1: 0, Y1: 0, X2: 10, Y2: 10, Mids: [][]float64{{3, 4}, {6, 7}}}, }, }}} - out := serialize(g) + out := Serialize(g) for _, want := range []string{ "line a 0 0 10 10 1 5 8\n", "line b 0 0 10 10 4 5 8\n", @@ -244,7 +244,7 @@ func TestSerializeLineMids(t *testing.T) { // Hand-edited files that emit an odd number of mid tokens should fail // loudly rather than silently dropping a coordinate. func TestParseLineMidsRequiresPairs(t *testing.T) { - _, err := parse("line l1 0 0 10 10 1 5") + _, err := Parse("line l1 0 0 10 10 1 5") if err == nil { t.Fatal("expected parse error for odd mid token count") } @@ -262,7 +262,7 @@ func TestLineStyleRoundTrip(t *testing.T) { Path: "/", Lines: []Line{{ID: "l1", X1: 0, Y1: 0, X2: 10, Y2: 10, Style: s}}, }}} - out, err := parse(serialize(in)) + out, err := Parse(Serialize(in)) if err != nil { t.Fatalf("style %d re-parse: %v", s, err) } @@ -288,7 +288,7 @@ func TestSerializeLineStyle(t *testing.T) { {ID: "c", X1: 0, Y1: 0, X2: 1, Y2: 1, Style: 3}, }, }}} - out := serialize(g) + out := Serialize(g) for _, want := range []string{ "line a 0 0 1 1\n", "line b 0 0 1 1\n", @@ -309,7 +309,7 @@ func TestSerializeLineStyle(t *testing.T) { // linestyle for an unknown line id is a parse error rather than a // silent drop so hand-edited files surface the typo immediately. func TestParseLineStyleUnknownID(t *testing.T) { - _, err := parse("line l1 0 0 10 10\nlinestyle nope 2\n") + _, err := Parse("line l1 0 0 10 10\nlinestyle nope 2\n") if err == nil { t.Fatal("expected parse error for linestyle pointing at unknown line") } @@ -329,7 +329,7 @@ func TestValidateStrokePalette(t *testing.T) { Path: "/", Strokes: []Stroke{{ID: "s1", Points: [][]float64{{0, 0}, {1, 1}}, Palette: p}}, }}} - if errs := validateGraph(g); len(errs) != 0 { + if errs := Validate(g); len(errs) != 0 { t.Fatalf("palette %d rejected unexpectedly: %v", p, errs) } } @@ -338,7 +338,7 @@ func TestValidateStrokePalette(t *testing.T) { Path: "/", Strokes: []Stroke{{ID: "s1", Points: [][]float64{{0, 0}, {1, 1}}, Palette: p}}, }}} - errs := validateGraph(g) + errs := Validate(g) var found bool for _, e := range errs { if strings.Contains(e.Error(), "palette") { diff --git a/validate.go b/pkg/graph/validate.go similarity index 95% rename from validate.go rename to pkg/graph/validate.go index 9ff3240..281428b 100644 --- a/validate.go +++ b/pkg/graph/validate.go @@ -1,20 +1,14 @@ -package main +package graph import ( "fmt" "strings" - - "github.com/lassediercks/flowgo/pkg/graph" ) -// MaxLabelLen mirrors the JS-side cap. Single source of truth for the -// editor + validator + MCP lives in pkg/graph. -const MaxLabelLen = graph.MaxLabelLen - -// validateGraph runs semantic checks the .flowgo parser doesn't perform. +// Validate runs semantic checks the .flowgo parser doesn't perform. // Returns every violation it finds rather than stopping at the first one, // so a single CI run surfaces all problems at once. -func validateGraph(g Graph) []error { +func Validate(g Graph) []error { var errs []error if len(g.Maps) == 0 { diff --git a/validate_test.go b/pkg/graph/validate_test.go similarity index 88% rename from validate_test.go rename to pkg/graph/validate_test.go index cbe7364..3a10e43 100644 --- a/validate_test.go +++ b/pkg/graph/validate_test.go @@ -1,4 +1,4 @@ -package main +package graph import ( "os" @@ -15,12 +15,12 @@ func TestMapFlowgoIsValid(t *testing.T) { t.Fatalf("read map.flowgo: %v", err) } - g, err := parse(string(raw)) + g, err := Parse(string(raw)) if err != nil { t.Fatalf("parse map.flowgo: %v", err) } - if errs := validateGraph(g); len(errs) > 0 { + if errs := Validate(g); len(errs) > 0 { var b strings.Builder for _, e := range errs { b.WriteString(" - ") @@ -30,19 +30,16 @@ func TestMapFlowgoIsValid(t *testing.T) { t.Fatalf("map.flowgo failed validation (%d issue(s)):\n%s", len(errs), b.String()) } - // parse → serialize → parse must yield an equivalent graph; otherwise - // we have a lossy round-trip somewhere (e.g., a new field that the - // serializer forgot to emit, or the parser dropped on the way in). - round, err := parse(serialize(g)) + round, err := Parse(Serialize(g)) if err != nil { t.Fatalf("re-parse after serialize: %v", err) } - if errs := validateGraph(round); len(errs) > 0 { + if errs := Validate(round); len(errs) > 0 { t.Fatalf("round-tripped graph failed validation: %v", errs) } if !graphsEquivalent(g, round) { t.Fatalf("parse(serialize(g)) != g — lossy round-trip\noriginal: %s\nround-trip: %s", - serialize(g), serialize(round)) + Serialize(g), Serialize(round)) } } diff --git a/version_directive_test.go b/pkg/graph/version_directive_test.go similarity index 90% rename from version_directive_test.go rename to pkg/graph/version_directive_test.go index eb7a666..4cddfe1 100644 --- a/version_directive_test.go +++ b/pkg/graph/version_directive_test.go @@ -1,4 +1,4 @@ -package main +package graph import ( "strings" @@ -23,7 +23,7 @@ func TestParseVersionDirective(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - g, err := parse(tc.input) + g, err := Parse(tc.input) if err != nil { t.Fatalf("parse: %v", err) } @@ -37,7 +37,7 @@ func TestParseVersionDirective(t *testing.T) { // `version` on its own (no value) is a parse error — silently dropping // it would mask a corrupted file from the user. func TestParseVersionMissingValue(t *testing.T) { - _, err := parse("version\n") + _, err := Parse("version\n") if err == nil { t.Fatal("expected error for bare `version` directive") } @@ -52,13 +52,13 @@ func TestParseVersionMissingValue(t *testing.T) { // writer's version. func TestSerializeVersionDirective(t *testing.T) { t.Run("omitted when empty", func(t *testing.T) { - out := serialize(Graph{Maps: []NamedMap{{Path: "/", Boxes: []Box{{ID: "b1", Label: "hi"}}}}}) + out := Serialize(Graph{Maps: []NamedMap{{Path: "/", Boxes: []Box{{ID: "b1", Label: "hi"}}}}}) if strings.HasPrefix(out, "version") { t.Fatalf("unexpected version line in output:\n%s", out) } }) t.Run("first line when set", func(t *testing.T) { - out := serialize(Graph{ + out := Serialize(Graph{ Version: "0.0.23", Maps: []NamedMap{{Path: "/", Boxes: []Box{{ID: "b1", Label: "hi"}}}}, }) @@ -68,7 +68,7 @@ func TestSerializeVersionDirective(t *testing.T) { }) } -// parse(serialize(g)) must preserve Version verbatim so consumers can +// Parse(Serialize(g)) must preserve Version verbatim so consumers can // trust it as a stable record. func TestVersionRoundTrip(t *testing.T) { for _, v := range []string{"", "0.0.23", "1.2.3-rc.4", "dev"} { @@ -76,7 +76,7 @@ func TestVersionRoundTrip(t *testing.T) { Version: v, Maps: []NamedMap{{Path: "/", Boxes: []Box{{ID: "b1", Label: "hi"}}}}, } - out, err := parse(serialize(g)) + out, err := Parse(Serialize(g)) if err != nil { t.Fatalf("version %q re-parse: %v", v, err) } diff --git a/src/editor/align.ts b/src/editor/align.ts index 3134c32..6e4bfe0 100644 --- a/src/editor/align.ts +++ b/src/editor/align.ts @@ -13,6 +13,8 @@ // updateSelectionToolbar() is fired from applyClasses() in render.ts // so selection changes immediately reposition / hide the toolbar. +import { mutatedCurrentMap } from "./mutations.ts"; + interface BoxLike { id: string; x: number; y: number; } interface TextLike { id: string; x: number; y: number; } @@ -25,7 +27,6 @@ interface AlignBindings { readonly canvas: HTMLElement; readonly currentMap: () => CurrentMap; readonly selected: Set; - readonly scheduleSave: () => void; readonly renderAll: () => void; } @@ -143,7 +144,7 @@ export const applyAlign = (axis: "horizontal" | "vertical"): void => { const items = collectAlignable(); if (!alignItems(items, axis)) return; w.renderAll(); - w.scheduleSave(); + mutatedCurrentMap(); }; // Build SVG nodes through createElementNS so every element lands in diff --git a/src/editor/attach.ts b/src/editor/attach.ts index f26ee80..45db1dd 100644 --- a/src/editor/attach.ts +++ b/src/editor/attach.ts @@ -12,7 +12,7 @@ import { renderEdges, renderLines, } from "./render.ts"; -import { scheduleSave } from "./persistence.ts"; +import { mutatedLine } from "./mutations.ts"; import { makeBoxMover, makeLineEndpointMover, @@ -283,7 +283,7 @@ export const attachLineHandlers = ( w.setSelectedEdge(null); renderEdges(); } - scheduleSave(); + mutatedLine(); // Full re-render so the new mid handle is wired up. renderLines(); applyClasses(); @@ -352,7 +352,7 @@ export const attachLineHandlers = ( l.mids.splice(idx, 1); if (l.mids.length === 0) delete l.mids; } - scheduleSave(); + mutatedLine(); renderLines(); applyClasses(); }); diff --git a/src/editor/brush.ts b/src/editor/brush.ts index 5f28d9f..a4fa379 100644 --- a/src/editor/brush.ts +++ b/src/editor/brush.ts @@ -4,6 +4,7 @@ // dispatches mousedown/move/up to the start/extend/finish trio. import { simplifyStroke } from "../index.ts"; +import { mutatedStroke } from "./mutations.ts"; import { toDataX, toDataY } from "./viewport.ts"; interface ActiveStroke { @@ -17,7 +18,6 @@ interface BrushBindings { readonly mintId: () => string; readonly strokeLayer: () => SVGGElement; readonly currentMap: () => { strokes?: Array }; - readonly scheduleSave: () => void; readonly afterCommit: () => void; // call renderStrokes readonly setStatus: (s: string) => void; } @@ -157,7 +157,7 @@ export const finishStroke = (): void => { }; if (active.palette >= 2) stroke.palette = active.palette; (m.strokes ??= []).push(stroke); - must().scheduleSave(); + mutatedStroke(); } else { const g = active.polyEl.parentNode; if (g && g.parentNode) g.parentNode.removeChild(g); diff --git a/src/editor/clipboard.test.ts b/src/editor/clipboard.test.ts index 82bd8d5..242ac5d 100644 --- a/src/editor/clipboard.test.ts +++ b/src/editor/clipboard.test.ts @@ -5,6 +5,7 @@ import { pasteSelection, wireClipboard, } from "./clipboard.ts"; +import { wireMutations } from "./mutations.ts"; interface Box { id: string; @@ -54,6 +55,7 @@ const makeState = (): State => ({ const wire = (s: State): void => { let n = 0; + wireMutations({ scheduleSave: () => {} }); wireClipboard({ selected: s.selected, currentMap: () => ({ @@ -65,7 +67,6 @@ const wire = (s: State): void => { findTextById: (id) => s.texts.find((t) => t.id === id), findLineById: (id) => s.lines.find((l) => l.id === id), mintId: (p) => `${p}_new${++n}`, - scheduleSave: () => {}, renderAll: () => {}, deleteSelection: () => { s.boxes = s.boxes.filter((b) => !s.selected.has(b.id)); diff --git a/src/editor/clipboard.ts b/src/editor/clipboard.ts index d606987..a836bdb 100644 --- a/src/editor/clipboard.ts +++ b/src/editor/clipboard.ts @@ -5,6 +5,8 @@ // copied box set, mirroring the existing semantics. Each paste shifts // by 20px so repeated paste presses cascade rather than stack. +import { mutatedCurrentMap } from "./mutations.ts"; + interface BoxLike { id: string; label: string; @@ -62,7 +64,6 @@ interface ClipboardBindings { readonly findTextById: (id: string) => TextLike | undefined; readonly findLineById: (id: string) => LineLike | undefined; readonly mintId: (prefix: string) => string; - readonly scheduleSave: () => void; readonly renderAll: () => void; readonly deleteSelection: () => void; readonly setStatus: (s: string) => void; @@ -153,7 +154,7 @@ export const cutSelection = (): void => { export const pasteSelection = (): void => { const { - selected, currentMap, mintId, scheduleSave, renderAll, + selected, currentMap, mintId, renderAll, setStatus, clearSelectedEdge, } = must(); if (!buffer) { @@ -207,7 +208,7 @@ export const pasteSelection = (): void => { if (ed.toHandle) newEdge.toHandle = ed.toHandle; map.edges.push(newEdge); } - scheduleSave(); + mutatedCurrentMap(); renderAll(); setStatus("pasted " + selected.size + " items"); }; diff --git a/src/editor/edit.ts b/src/editor/edit.ts index d033342..33d684f 100644 --- a/src/editor/edit.ts +++ b/src/editor/edit.ts @@ -10,6 +10,7 @@ // of that span. import { MAX_LABEL_LEN, normalizeLabel } from "../graph/label.ts"; +import { mutatedBox, mutatedDoc, mutatedText } from "./mutations.ts"; interface BoxLike { id: string; @@ -37,7 +38,6 @@ interface EditBindings { readonly setGraph: (g: { maps: { path: string }[] }) => void; readonly ensureMap: (path: string) => ReturnType; readonly selected: Set; - readonly scheduleSave: () => void; readonly renderAll: () => void; readonly setStatus: (s: string) => void; } @@ -88,7 +88,7 @@ export const startTextEdit = (el: HTMLElement, t: TextLike): void => { const newLabel = readEditableText(el); if (commit && newLabel && newLabel !== t.label) { t.label = newLabel; - must().scheduleSave(); + mutatedText(); } el.textContent = t.label; }; @@ -177,14 +177,14 @@ export const startEdit = ( w.setGraph(g); w.setCurrentMap(w.ensureMap(cur)); w.selected.delete(b.id); - w.scheduleSave(); + mutatedDoc(); w.renderAll(); w.setStatus("cancelled"); return; } if (commit && newLabel && newLabel !== b.label) { b.label = newLabel; - must().scheduleSave(); + mutatedBox(); } // Rebuild the affected box from state. Trying to surgically // pluck out only the stray nodes the contenteditable inserted diff --git a/src/editor/factories.ts b/src/editor/factories.ts index 720abf7..9424be4 100644 --- a/src/editor/factories.ts +++ b/src/editor/factories.ts @@ -15,6 +15,12 @@ // the removed boxes). import { startEdit, startTextEdit } from "./edit.ts"; +import { + mutatedBox, + mutatedDoc, + mutatedLine, + mutatedText, +} from "./mutations.ts"; import { applyClasses, renderAll, renderEdges } from "./render.ts"; interface BoxLike { @@ -60,7 +66,6 @@ interface FactoryBindings { readonly selectedEdge: () => unknown; readonly clearSelectedEdge: () => void; readonly mintId: (prefix?: string) => string; - readonly scheduleSave: () => void; readonly setStatus: (s: string) => void; } @@ -91,7 +96,7 @@ export const createBoxAt = ( el.style.left = b.x + "px"; el.style.top = b.y + "px"; } - w.scheduleSave(); + mutatedBox(); if (el) { w.selected.clear(); w.selected.add(id); @@ -125,7 +130,7 @@ export const createTextAt = (cx: number, cy: number): void => { applyClasses(); startTextEdit(el, t); } - w.scheduleSave(); + mutatedText(); }; export const createLineSegment = ( @@ -145,7 +150,7 @@ export const createLineSegment = ( renderEdges(); } renderAll(); - w.scheduleSave(); + mutatedLine(); }; export const deleteSelection = (): void => { @@ -175,6 +180,6 @@ export const deleteSelection = (): void => { w.setGraph(g); w.setCurrentMap(w.ensureMap(cur)); sel.clear(); - w.scheduleSave(); + mutatedDoc(); renderAll(); }; diff --git a/src/editor/keys.ts b/src/editor/keys.ts index 5957d8b..b3e0348 100644 --- a/src/editor/keys.ts +++ b/src/editor/keys.ts @@ -10,6 +10,11 @@ import { isHelpOpen, setHelpOpen } from "./help.ts"; import { isEditing, startEdit, startTextEdit } from "./edit.ts"; +import { + mutatedBox, + mutatedCurrentMap, + mutatedEdge, +} from "./mutations.ts"; import { undo, redo } from "./persistence.ts"; import { isBrushMode, @@ -97,7 +102,6 @@ interface KeysBindings { readonly setDropTargetHandle: (h: string | null) => void; readonly clearProximity: () => void; readonly lastCursor: { x: number; y: number }; - readonly scheduleSave: () => void; readonly setStatus: (s: string) => void; } @@ -215,7 +219,7 @@ const toggleAnchor = (): void => { if (b.anchor) delete b.anchor; } if (turningOn) target.anchor = true; - w.scheduleSave(); + mutatedBox(); renderAll(); w.setStatus(turningOn ? "anchored " + id : "anchor cleared"); }; @@ -383,7 +387,7 @@ export const attachKeyboardListener = (): void => { if (!hasAnySelection()) return; if (applyPalette(palette)) { e.preventDefault(); - w.scheduleSave(); + mutatedCurrentMap(); renderAll(); } return; @@ -397,7 +401,7 @@ export const attachKeyboardListener = (): void => { const styleChanged = applyLineStyle(n); if (fontChanged || styleChanged) { e.preventDefault(); - w.scheduleSave(); + mutatedCurrentMap(); renderAll(); } return; @@ -417,7 +421,7 @@ export const attachKeyboardListener = (): void => { const dir = e.key === "_" || e.key === "-" ? -1 : 1; if (stepFont(dir as 1 | -1)) { e.preventDefault(); - w.scheduleSave(); + mutatedCurrentMap(); renderAll(); } return; @@ -429,7 +433,7 @@ export const attachKeyboardListener = (): void => { const dir = e.key === "-" ? -1 : 1; if (stepPalette(dir as 1 | -1)) { e.preventDefault(); - w.scheduleSave(); + mutatedCurrentMap(); renderAll(); } return; @@ -503,7 +507,7 @@ export const attachKeyboardListener = (): void => { const idx = map.edges.indexOf(sel); if (idx >= 0) map.edges.splice(idx, 1); w.setSelectedEdge(null); - w.scheduleSave(); + mutatedEdge(); renderEdges(); w.setStatus("edge removed"); return; diff --git a/src/editor/main.ts b/src/editor/main.ts index b1454a7..110d0f6 100644 --- a/src/editor/main.ts +++ b/src/editor/main.ts @@ -41,6 +41,7 @@ import { scheduleSave, wirePersistence, } from "./persistence.ts"; +import { mutatedCurrentMap, wireMutations } from "./mutations.ts"; import { cloneSelection as cloneSelectionPure, wireClone, @@ -127,7 +128,7 @@ function cloneSelection() { const idMap = cloneSelectionPure(); renderAll(); applyClasses(); - scheduleSave(); + mutatedCurrentMap(); return idMap; } @@ -205,7 +206,6 @@ wireFactories({ selectedEdge: () => selectedEdge, clearSelectedEdge: () => { selectedEdge = null; }, mintId: uid, - scheduleSave: () => scheduleSave(), setStatus, }); @@ -218,7 +218,6 @@ wireEdit({ setGraph: (g) => { graph = g; }, ensureMap, selected, - scheduleSave: () => scheduleSave(), renderAll: () => renderAll(), setStatus, }); @@ -235,6 +234,11 @@ wirePersistence({ clearSelectedEdge: () => { selectedEdge = null; }, }); +// Every mutation funnels through mutations.ts. The default wiring +// just calls scheduleSave; downstream consumers can swap it without +// touching the 26 call sites. +wireMutations({ scheduleSave: () => scheduleSave() }); + wireClone({ currentMap: () => state, selected, @@ -247,7 +251,6 @@ wireAlign({ canvas, currentMap: () => state, selected, - scheduleSave: () => scheduleSave(), renderAll: () => renderAll(), }); attachAlignToolbar(); @@ -258,7 +261,6 @@ wireClipboard({ findTextById, findLineById, mintId: uid, - scheduleSave: () => scheduleSave(), renderAll: () => renderAll(), deleteSelection: () => deleteSelection(), setStatus, @@ -269,7 +271,6 @@ wireBrush({ mintId: () => uid("s"), strokeLayer: () => strokeLayer, currentMap: () => state, - scheduleSave: () => scheduleSave(), afterCommit: () => renderStrokes(), setStatus, }); @@ -300,7 +301,6 @@ wireMouse({ setDropTargetId: (id) => { dropTargetId = id; }, dropTargetHandle: () => dropTargetHandle, setDropTargetHandle: (h) => { dropTargetHandle = h; }, - scheduleSave: () => scheduleSave(), setStatus, }); attachMouseListeners(); @@ -324,7 +324,6 @@ wireTouch({ setDropTargetHandle: (h) => { dropTargetHandle = h; }, selectedEdge: () => selectedEdge, setSelectedEdge: (e) => { selectedEdge = e; }, - scheduleSave: () => scheduleSave(), }); attachTouchListeners(); @@ -342,7 +341,6 @@ wireKeys({ setDropTargetHandle: (h) => { dropTargetHandle = h; }, clearProximity: () => clearProximity(), lastCursor, - scheduleSave: () => scheduleSave(), setStatus, }); attachKeyboardListener(); diff --git a/src/editor/mouse.ts b/src/editor/mouse.ts index a002ea4..c812d6b 100644 --- a/src/editor/mouse.ts +++ b/src/editor/mouse.ts @@ -22,6 +22,11 @@ import { startEdit } from "./edit.ts"; import { nearestHandle, pickTargetHandle } from "./anchors.ts"; import { addOrReplaceEdge as addOrReplaceEdgePure } from "../graph/edge.ts"; import { createBoxAt } from "./factories.ts"; +import { + mutatedCurrentMap, + mutatedEdge, + mutatedLine, +} from "./mutations.ts"; interface BoxLike { id: string; @@ -107,7 +112,6 @@ interface MouseBindings { readonly setDropTargetId: (id: string | null) => void; readonly dropTargetHandle: () => string | null; readonly setDropTargetHandle: (h: string | null) => void; - readonly scheduleSave: () => void; readonly setStatus: (s: string) => void; } @@ -243,7 +247,7 @@ const onMouseUp = (e: MouseEvent): void => { const primaryId = drag.primaryId; w.setDrag(null); if (wasActive) { - w.scheduleSave(); + mutatedCurrentMap(); } else { // Single-click without movement: collapse selection to just this item. w.selected.clear(); @@ -331,7 +335,7 @@ const onMouseUp = (e: MouseEvent): void => { if (link.fromHandle) newEdge.fromHandle = link.fromHandle; if (toCode) newEdge.toHandle = toCode; map.edges = addOrReplaceEdgePure(map.edges, newEdge); - w.scheduleSave(); + mutatedEdge(); renderEdges(); } else { // Dropped in empty space: spawn a new box at the cursor and @@ -360,7 +364,7 @@ const onMouseUp = (e: MouseEvent): void => { applyClasses(); startEdit(newEl, newBox, { cancelDeletes: true }); } - w.scheduleSave(); + mutatedCurrentMap(); } w.setLink(null); if (w.dropTargetId() || w.dropTargetHandle()) { @@ -490,7 +494,7 @@ const onBgDblClick = (e: MouseEvent): void => { const hit = tryInsertMidNearPoint(w.currentMap(), cx, cy); if (hit) { cancelPendingLine(); - w.scheduleSave(); + mutatedLine(); renderAll(); } return; diff --git a/src/editor/mutations.ts b/src/editor/mutations.ts new file mode 100644 index 0000000..70e35e8 --- /dev/null +++ b/src/editor/mutations.ts @@ -0,0 +1,41 @@ +// Chokepoint for "the live graph just mutated; persist." Every +// mutation seam in the editor calls one of the typed mutator +// functions below instead of scheduleSave() directly. +// +// Today every mutator funnels into the wired scheduleSave — +// behaviour is identical to calling scheduleSave() at the call site. +// The typed surface exists so that downstream wiring can hook on +// the right kind of change without a 30-site audit later. + +interface MutationBindings { + readonly scheduleSave: () => void; +} + +let bindings: MutationBindings | null = null; + +export const wireMutations = (b: MutationBindings): void => { + bindings = b; +}; + +const fire = (): void => { + if (!bindings) throw new Error("mutations: wireMutations() not called"); + bindings.scheduleSave(); +}; + +// One function per kind on the current map. The function shapes +// reserve room for downstream wiring that wants to scope a diff to +// a specific entity; today they all just fire scheduleSave. + +export const mutatedBox = (): void => fire(); +export const mutatedEdge = (): void => fire(); +export const mutatedText = (): void => fire(); +export const mutatedLine = (): void => fire(); +export const mutatedStroke = (): void => fire(); + +// The current map changed in a way that spans multiple kinds or +// touches the whole map (paste, align, multi-select palette change). +export const mutatedCurrentMap = (): void => fire(); + +// The document structure changed (maps added/removed via box +// deletion, or anything that affects more than one map at once). +export const mutatedDoc = (): void => fire(); diff --git a/src/editor/touch.ts b/src/editor/touch.ts index 1fbc2a1..2f1e546 100644 --- a/src/editor/touch.ts +++ b/src/editor/touch.ts @@ -53,6 +53,7 @@ import { handleAnchor, nearestHandle, pickTargetHandle } from "./anchors.ts"; import { addOrReplaceEdge as addOrReplaceEdgePure } from "../graph/edge.ts"; import { findBoxAt } from "./mouse.ts"; import { createBoxAt, deleteSelection } from "./factories.ts"; +import { mutatedCurrentMap, mutatedEdge } from "./mutations.ts"; import { classifyTap, movedBeyond, type TapRecord } from "./gestures.ts"; import { makeLineEndpointMover, type Mover } from "./movers.ts"; @@ -125,7 +126,6 @@ interface TouchBindings { readonly setDropTargetHandle: (h: string | null) => void; readonly selectedEdge: () => EdgeLike | null; readonly setSelectedEdge: (e: EdgeLike | null) => void; - readonly scheduleSave: () => void; } // Double-tap window — taps on the same target within this many ms @@ -762,7 +762,7 @@ const onTouchEnd = (e: TouchEvent): void => { lastTap = null; return; } - w.scheduleSave(); + mutatedCurrentMap(); lastTap = null; return; } @@ -856,7 +856,7 @@ const finalizeLink = (link: LinkState, t: Touch | null): void => { if (link.fromHandle) newEdge.fromHandle = link.fromHandle; if (toCode) newEdge.toHandle = toCode; map.edges = addOrReplaceEdgePure(map.edges, newEdge); - w.scheduleSave(); + mutatedEdge(); renderEdges(); } else { const newId = w.mintId(); @@ -883,7 +883,7 @@ const finalizeLink = (link: LinkState, t: Touch | null): void => { applyClasses(); startEdit(newEl, newBox, { cancelDeletes: true }); } - w.scheduleSave(); + mutatedCurrentMap(); } w.setLink(null); if (w.dropTargetId() || w.dropTargetHandle()) { diff --git a/vite.config.ts b/vite.config.ts index cfa0113..2596356 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,16 +6,17 @@ import { viteSingleFile } from "vite-plugin-singlefile"; const here = fileURLToPath(new URL(".", import.meta.url)); // Vite root is src/editor (the source HTML lives there). The build -// emits a single self-contained index.html into dist/, which the Go -// binary embeds via //go:embed dist/index.html. Devs run `npm run dev` -// for an HMR server and `npm run build` before `go build`. +// emits a single self-contained index.html into pkg/flowgo/dist/, +// which the Go library embeds via //go:embed dist/index.html in +// pkg/flowgo/state.go. Devs run `pnpm dev` for an HMR server and +// `pnpm build` before `go build ./cmd/flowgo`. export default defineConfig({ root: resolve(here, "src/editor"), publicDir: false, server: { port: 54041, strictPort: true }, preview: { port: 54041, strictPort: true }, build: { - outDir: resolve(here, "dist"), + outDir: resolve(here, "pkg/flowgo/dist"), emptyOutDir: true, assetsInlineLimit: Number.MAX_SAFE_INTEGER, cssCodeSplit: false,