From bf037cc868361233841e1613855d5d28eb6adb32 Mon Sep 17 00:00:00 2001 From: draw me an elephant <68925779+drawmeanelephant@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:23:08 -0400 Subject: [PATCH 1/3] fix(editor): keep graph zoom readable, coalesce undo, and restore last-open A cold graph at Fit (~10%) is unusable, typing a phrase should undo as one step, and a launch with no open= should reopen the last author file instead of an empty buffer. Co-authored-by: Cursor --- content/guides/editor.md | 11 ++- .../editor-graph-fit-undo-preview.md | 15 +++ docs/contracts/editor-host.md | 23 ++++- editor/README.md | 10 +- editor/scripts/test-host.sh | 7 ++ editor/src/last_open.zig | 90 +++++++++++++++++ editor/src/main.zig | 2 + editor/src/server.zig | 25 ++++- editor/ui/src/App.svelte | 10 +- editor/ui/src/components/GraphMap.svelte | 40 +++++++- editor/ui/src/lib/api.ts | 15 +++ editor/ui/src/lib/state/buffer.svelte.ts | 50 +++++++++- editor/ui/src/lib/types.ts | 2 +- editor/ui/src/styles.css | 6 +- editor/ui/tests/focus-mode.spec.ts | 8 +- editor/ui/tests/graph-map.spec.ts | 98 +++++++++++++++++-- editor/ui/tests/project-tree.spec.ts | 14 ++- editor/ui/tests/safe-editing.spec.ts | 97 ++++++++++++++++-- editor/ui/tests/section-nav.spec.ts | 17 +++- 19 files changed, 494 insertions(+), 46 deletions(-) create mode 100644 docs/changelog.d/editor-graph-fit-undo-preview.md create mode 100644 editor/src/last_open.zig diff --git a/content/guides/editor.md b/content/guides/editor.md index 0590b531..4f556073 100644 --- a/content/guides/editor.md +++ b/content/guides/editor.md @@ -54,6 +54,9 @@ URL **fragment** carries a random session token. Open that URL in your browser; every API request must repeat the token, and any supplied `Origin` must match the session origin. A bare `--boris` command name is resolved through `PATH`; a path-like value is canonicalized against the directory you launched from. +Without an `open=` fragment the editor restores the last author-owned file +you had open, or `content/index.md` when that file is present. An `open=` +fragment still wins, including when it is ignored as unsafe. `Ctrl+K` (or `Cmd+K`) opens a command palette for file actions, Boris commands, preview rebuild, and jumping to a project file. Esc, the palette's @@ -85,7 +88,9 @@ then atomically renames it into place. change does not wait for Save. Transient filesystem errors are skipped and retried. - **Undo / redo** — session-local, keyboard-driven (`Ctrl/Cmd+Z`, `Ctrl/Cmd+ - Shift+Z`) and via the toolbar. + Shift+Z`) and via the toolbar. Consecutive inserts coalesce into one undo + step until a pause, a word boundary, or a non-insert (delete, paste, + undo/redo). - **Recovery snapshots** — dirty buffers are snapshotted to the disposable OS user-cache state root on the first unsaved change, then periodically, and again when the tab hides. A later editor process labels them as *recovered @@ -265,7 +270,9 @@ boris build --input content --incremental --html-dir dist ``` You can also trigger it with the **Rebuild preview** button. The UI reports -`idle`, `running`, `success`, `failed`, and `stale` distinctly. Boris's staged +`idle`, `running`, `success`, `failed`, and `stale` distinctly. If +`dist/index.html` already exists when the editor starts, preview is `stale` +with that content in the iframe rather than an empty idle pane. Boris's staged output commit preserves the last valid `dist/` tree after a failed rebuild, so the preview iframe advances only on success. Embedded preview content is sandboxed; a named link opens the exact site origin in a new tab for full diff --git a/docs/changelog.d/editor-graph-fit-undo-preview.md b/docs/changelog.d/editor-graph-fit-undo-preview.md new file mode 100644 index 00000000..a791c7ec --- /dev/null +++ b/docs/changelog.d/editor-graph-fit-undo-preview.md @@ -0,0 +1,15 @@ + + +### Fixed + +- Editor graph map opens at a readable zoom (50% floor) centered on the active + page while Fit still shows the whole graph; typing coalesces into one undo + step per phrase; a cold launch restores the last author-owned file from the + disposable state root (or `content/index.md`); and an existing `dist/` tree + is framed as stale instead of an empty idle preview. Links: [the editor-host + contract](/docs/contracts/editor-host.md), [the editor + guide](/content/guides/editor.md). diff --git a/docs/contracts/editor-host.md b/docs/contracts/editor-host.md index a3e5c28a..c6af3999 100644 --- a/docs/contracts/editor-host.md +++ b/docs/contracts/editor-host.md @@ -104,6 +104,8 @@ calls carry it in the `x-boris-editor-token` header instead. An embedder may append `&open=` to the fragment as a shell-side convenience; the host never reads a file from the URL and its transport posture is unchanged (see [`editor/README.md`](../../editor/README.md#launch-line-contract)). +Without `open=`, the shell may restore `last_open` from `GET /api/files` or +open `content/index.md` when present (§5.3). ### 3.2 Loopback and header discipline @@ -307,12 +309,21 @@ A malformed fingerprint is `400 invalid_fingerprint`. ### 5.3 Responses and shapes -`/api/files` returns a **flat** list, sorted lexicographically by full path: +`/api/files` returns a **flat** list, sorted lexicographically by full path, +plus the last author-owned path successfully opened, created, or renamed in +this project (stored under the disposable state root, never project truth): ```json -{"files":[{"path":"boris.json"},{"path":"content/index.md"}]} +{"files":[{"path":"boris.json"},{"path":"content/index.md"}], + "last_open":"content/index.md"} ``` +`last_open` is `null` when none has been recorded, the recorded path failed +`validatePath`, or the state-root file is missing or unreadable. A successful +`open` / `create` / `rename` records the new path; a successful `delete` of +that path clears it. The shell may restore it on a cold launch that has no +`open=` fragment, and must still ignore unsafe values. + `open`, `save` (both outcomes), and `create` share the buffer shape: ```json @@ -677,11 +688,15 @@ used_stderr_fallback, message, preview_url, watch_active}`: | `phase` | Meaning | |---|---| -| `idle` | No preview output has been built in this session | +| `idle` | No `dist/index.html` exists yet — nothing to frame | | `running` | A rebuild is in flight | | `success` | The rebuild succeeded; `generation` advanced | | `failed` | The rebuild failed and no valid output exists | -| `stale` | Output exists, but from an earlier build or a failed rebuild | +| `stale` | Output exists from an earlier build, a failed rebuild, or a `dist/` tree that was already on disk when the editor started | + +If `dist/index.html` is present when the host process starts, the first +`/api/preview/state` is `stale` (generation `0`) rather than empty `idle`, so +the shell can frame the existing bytes. `generation` advances **only** on success, so the shell can reload the iframe exactly once per successful build. On failure `message` is the last diff --git a/editor/README.md b/editor/README.md index 1c7750fa..eab33d99 100644 --- a/editor/README.md +++ b/editor/README.md @@ -93,7 +93,12 @@ API ([`file_api.validatePath`](src/file_api.zig)): exactly `boris.json` or a regular file below `content/` or `themes/`, with no leading `/`, no `..` segments, and no backslashes. An invalid path is ignored with a status message; a well-formed but missing path surfaces the host's `file_not_found`; either way -the editor still boots to the project file list. The fragment is consumed only +the editor still boots to the project file list. **`open=` wins:** a present +fragment, even an ignored unsafe one, is never overridden by a restored path. +Without `open=`, the shell restores `last_open` from `GET /api/files` when that +path is still author-owned and still in the list, otherwise it opens +`content/index.md` when that file is present. Unsafe recorded paths are ignored +the same way as an unsafe fragment. The fragment is consumed only by the shell — the host keeps printing the token-only launch line and never reads a file from the URL, so the token/CSP/loopback posture is unchanged. @@ -206,7 +211,8 @@ truth unless the author explicitly saves it. The authenticated file API is intentionally small: -- `GET /api/files` and `POST /api/files/open` enumerate and open safe files; +- `GET /api/files` and `POST /api/files/open` enumerate and open safe files + (`last_open` on the list is the disposable last author-owned path); - `POST /api/files/probe` compares the open-file fingerprint to disk without writing; transient filesystem errors stay in-session; - `POST /api/files/save`, `/create`, `/rename`, and `/delete` perform explicit diff --git a/editor/scripts/test-host.sh b/editor/scripts/test-host.sh index a86af7f5..98e66b37 100755 --- a/editor/scripts/test-host.sh +++ b/editor/scripts/test-host.sh @@ -91,6 +91,7 @@ api_get /api/version | grep -q '"compiler_id":"boris/' files="$(api_get /api/files)" printf '%s' "$files" | grep -q '"path":"content/index.md"' +printf '%s' "$files" | grep -q '"last_open":null' if printf '%s' "$files" | grep -Eq 'dist/index.html|\.boris/graph.json'; then echo "generated output escaped into the author file list" >&2 exit 1 @@ -110,6 +111,12 @@ opened="$(api_post /api/files/open '{"path":"content/index.md"}')" [[ "$(printf '%s' "$opened" | code_of)" == "200" ]] fingerprint="$(printf '%s' "$opened" | body_of | fingerprint_of)" [[ ${#fingerprint} -eq 64 ]] +files="$(api_get /api/files)" +printf '%s' "$files" | grep -q '"last_open":"content/index.md"' +stop_editor +start_editor +files="$(api_get /api/files)" +printf '%s' "$files" | grep -q '"last_open":"content/index.md"' # An external write after open must produce a 409 and preserve both versions. printf '# External edit\n' >"$work/project/content/index.md" diff --git a/editor/src/last_open.zig b/editor/src/last_open.zig new file mode 100644 index 00000000..70f399b1 --- /dev/null +++ b/editor/src/last_open.zig @@ -0,0 +1,90 @@ +//! Last-opened author path, stored under the disposable editor state root. +//! Never project truth: a missing, corrupt, or unsafe file is ignored. + +const std = @import("std"); +const Io = std.Io; +const file_api = @import("file_api.zig"); + +const file_name = "last-open.json"; + +const Document = struct { + path: []const u8, +}; + +pub fn save(allocator: std.mem.Allocator, io: Io, state_root: []const u8, path: []const u8) !void { + try file_api.validatePath(path); + try Io.Dir.cwd().createDirPath(io, state_root); + var dir = try Io.Dir.cwd().openDir(io, state_root, .{ .follow_symlinks = false }); + defer dir.close(io); + + const bytes = try std.json.Stringify.valueAlloc(allocator, .{ .path = path }, .{}); + defer allocator.free(bytes); + var atomic = try dir.createFileAtomic(io, file_name, .{ .replace = true }); + defer atomic.deinit(io); + var write_buffer: [1024]u8 = undefined; + var writer = atomic.file.writer(io, &write_buffer); + try writer.interface.writeAll(bytes); + try writer.flush(); + try atomic.file.sync(io); + try atomic.replace(io); +} + +pub fn load(allocator: std.mem.Allocator, io: Io, state_root: []const u8) std.mem.Allocator.Error!?[]u8 { + var dir = Io.Dir.cwd().openDir(io, state_root, .{ .follow_symlinks = false }) catch return null; + defer dir.close(io); + const bytes = dir.readFileAlloc(io, file_name, allocator, .limited(8192)) catch return null; + defer allocator.free(bytes); + var parsed = std.json.parseFromSlice(Document, allocator, bytes, .{}) catch return null; + defer parsed.deinit(); + file_api.validatePath(parsed.value.path) catch return null; + return try allocator.dupe(u8, parsed.value.path); +} + +pub fn clear(io: Io, state_root: []const u8) !void { + var dir = Io.Dir.cwd().openDir(io, state_root, .{ .follow_symlinks = false }) catch |err| switch (err) { + error.FileNotFound => return, + else => |other| return other, + }; + defer dir.close(io); + dir.deleteFile(io, file_name) catch |err| switch (err) { + error.FileNotFound => {}, + else => |other| return other, + }; +} + +test "last-open path survives a new store instance" { + const allocator = std.testing.allocator; + const io = std.testing.io; + var temp = std.testing.tmpDir(.{}); + defer temp.cleanup(); + const root = try temp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(root); + + try std.testing.expect(try load(allocator, io, root) == null); + try save(allocator, io, root, "content/guides/start.md"); + const loaded = try load(allocator, io, root); + defer if (loaded) |path| allocator.free(path); + try std.testing.expectEqualStrings("content/guides/start.md", loaded.?); + try clear(io, root); + try std.testing.expect(try load(allocator, io, root) == null); +} + +test "unsafe last-open files are ignored" { + const allocator = std.testing.allocator; + const io = std.testing.io; + var temp = std.testing.tmpDir(.{}); + defer temp.cleanup(); + const root = try temp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(root); + + try std.testing.expectError(error.PathNotAuthorOwned, save(allocator, io, root, "dist/index.html")); + try std.testing.expectError(error.InvalidPath, save(allocator, io, root, "../secret")); + + try Io.Dir.cwd().createDirPath(io, root); + var dir = try Io.Dir.cwd().openDir(io, root, .{ .follow_symlinks = false }); + defer dir.close(io); + try dir.writeFile(io, .{ .sub_path = file_name, .data = "{\"path\":\"dist/index.html\"}" }); + try std.testing.expect(try load(allocator, io, root) == null); + try dir.writeFile(io, .{ .sub_path = file_name, .data = "{not-json" }); + try std.testing.expect(try load(allocator, io, root) == null); +} diff --git a/editor/src/main.zig b/editor/src/main.zig index 7b5099ad..079ffe2b 100644 --- a/editor/src/main.zig +++ b/editor/src/main.zig @@ -1,6 +1,7 @@ const std = @import("std"); const authoring = @import("authoring.zig"); const graph = @import("graph.zig"); +const last_open = @import("last_open.zig"); const contracts = @import("contracts.zig"); const diagnostic_packet = @import("diagnostic_packet.zig"); const file_api = @import("file_api.zig"); @@ -197,6 +198,7 @@ test "boris path resolution canonicalizes paths and passes through command names test { _ = authoring; _ = graph; + _ = last_open; _ = contracts; _ = diagnostic_packet; _ = file_api; diff --git a/editor/src/server.zig b/editor/src/server.zig index 66292ec4..af2c339a 100644 --- a/editor/src/server.zig +++ b/editor/src/server.zig @@ -10,6 +10,7 @@ const project = @import("project.zig"); const preview = @import("preview.zig"); const publication = @import("publication.zig"); const recovery = @import("recovery.zig"); +const last_open = @import("last_open.zig"); const runner = @import("runner.zig"); const security = @import("security.zig"); const validation_daemon = @import("validation_daemon.zig"); @@ -291,7 +292,9 @@ const SnapshotRequest = struct { fn serveFileList(io: Io, allocator: std.mem.Allocator, request: *http.Server.Request, config: Config) !void { var files = file_api.list(allocator, io, config.project_root) catch |err| return respondApiError(request, err); defer files.deinit(allocator); - const bytes = try std.json.Stringify.valueAlloc(allocator, .{ .files = files.entries }, .{}); + const last_path = try last_open.load(allocator, io, config.state_root); + defer if (last_path) |path| allocator.free(path); + const bytes = try std.json.Stringify.valueAlloc(allocator, .{ .files = files.entries, .last_open = last_path }, .{}); defer allocator.free(bytes); return respondJson(request, .ok, bytes); } @@ -326,6 +329,7 @@ fn serveFileOpen(io: Io, allocator: std.mem.Allocator, request: *http.Server.Req defer parsed.deinit(); var buffer = file_api.open(allocator, io, config.project_root, parsed.value.path) catch |err| return respondApiError(request, err); defer buffer.deinit(allocator); + rememberLastOpen(allocator, io, config, parsed.value.path); return respondBuffer(allocator, request, .ok, "opened", parsed.value.path, buffer); } @@ -367,6 +371,7 @@ fn serveFileCreate(io: Io, allocator: std.mem.Allocator, request: *http.Server.R var buffer = file_api.create(allocator, io, config.project_root, parsed.value.path, parsed.value.content) catch |err| return respondApiError(request, err); defer buffer.deinit(allocator); config.daemon.noteSave(); + rememberLastOpen(allocator, io, config, parsed.value.path); return respondBuffer(allocator, request, .created, "created", parsed.value.path, buffer); } @@ -379,6 +384,7 @@ fn serveFileRename(io: Io, allocator: std.mem.Allocator, request: *http.Server.R recovery.clear(io, config.state_root, parsed.value.path) catch |err| { std.log.warn("could not clear recovery snapshot after rename: {s}", .{@errorName(err)}); }; + rememberLastOpen(allocator, io, config, parsed.value.new_path); config.daemon.noteSave(); const bytes = try std.json.Stringify.valueAlloc(allocator, .{ .status = "renamed", .path = parsed.value.new_path }, .{}); defer allocator.free(bytes); @@ -394,10 +400,27 @@ fn serveFileDelete(io: Io, allocator: std.mem.Allocator, request: *http.Server.R recovery.clear(io, config.state_root, parsed.value.path) catch |err| { std.log.warn("could not clear recovery snapshot after delete: {s}", .{@errorName(err)}); }; + forgetLastOpenIfMatch(allocator, io, config, parsed.value.path); config.daemon.noteSave(); return respondJson(request, .ok, "{\"status\":\"deleted\"}"); } +fn rememberLastOpen(allocator: std.mem.Allocator, io: Io, config: Config, path: []const u8) void { + last_open.save(allocator, io, config.state_root, path) catch |err| { + std.log.warn("could not record last-open path: {s}", .{@errorName(err)}); + }; +} + +fn forgetLastOpenIfMatch(allocator: std.mem.Allocator, io: Io, config: Config, path: []const u8) void { + const current = last_open.load(allocator, io, config.state_root) catch return; + const owned = current orelse return; + defer allocator.free(owned); + if (!std.mem.eql(u8, owned, path)) return; + last_open.clear(io, config.state_root) catch |err| { + std.log.warn("could not clear last-open path: {s}", .{@errorName(err)}); + }; +} + fn serveRecoveryList(io: Io, allocator: std.mem.Allocator, request: *http.Server.Request, config: Config) !void { var snapshots = recovery.loadAll(allocator, io, config.state_root) catch |err| return respondApiError(request, err); defer snapshots.deinit(allocator); diff --git a/editor/ui/src/App.svelte b/editor/ui/src/App.svelte index 2f52fcd3..cf02f009 100644 --- a/editor/ui/src/App.svelte +++ b/editor/ui/src/App.svelte @@ -1,6 +1,6 @@