From 3854f79aeb0b47499e6f3a6ea9558498270f877d Mon Sep 17 00:00:00 2001 From: foxnne Date: Tue, 4 Aug 2026 07:54:49 -0500 Subject: [PATCH 01/10] on macOS let the bundle draw the icon --- src/App.zig | 18 ++++++++++++++++++ src/editor/Infobar.zig | 42 +++++++++++++++++++++++++++++++++--------- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/App.zig b/src/App.zig index a1864f30..9e92a92f 100644 --- a/src/App.zig +++ b/src/App.zig @@ -58,6 +58,15 @@ const start_options_base: dvui.App.StartOptions = .{ }, }; +/// macOS only: is the process image inside a `.app` bundle (as opposed to a loose +/// `zig-out/bin/fizzy` from `zig build run`)? Mirrors `auto_update.installLayoutSupported`'s +/// probe, minus its Velopack gating. +fn runningFromAppBundle(io: std.Io) bool { + var buf: [std.fs.max_path_bytes]u8 = undefined; + const n = std.process.executablePath(io, &buf) catch return false; + return std.mem.indexOf(u8, buf[0..n], ".app/") != null; +} + fn startOptions() dvui.App.StartOptions { var opts = start_options_base; @@ -70,6 +79,15 @@ fn startOptions() dvui.App.StartOptions { if (comptime builtin.target.cpu.arch != .wasm32) { opts.gpa = appAllocator(); const main_init = dvui.App.main_init orelse return opts; + // SDL's Cocoa backend implements SDL_SetWindowIcon as `[NSApp setApplicationIconImage:]`, + // i.e. it replaces the whole *application* icon while we run. That hands AppKit a finished + // bitmap, skipping the system treatment (rounded-rect backdrop, mask) it applies to the + // bundle's `.icns` — so the Dock icon visibly loses its background the moment fizzy + // launches. Inside a bundle the `.icns` is already the right icon; leave it alone. Loose + // dev builds have no bundle icon at all, so there we still want the runtime one. + if (comptime builtin.os.tag == .macos) { + if (runningFromAppBundle(main_init.io)) opts.icon = null; + } if (paths.configFolderZ(&pref_path_buf, main_init.io, fizzy.processEnviron(), ".")) |pref_path| { pref_path_len = pref_path.len; opts.pref_path = pref_path_buf[0..pref_path_len :0]; diff --git a/src/editor/Infobar.zig b/src/editor/Infobar.zig index 45e7eb0a..994c0a19 100644 --- a/src/editor/Infobar.zig +++ b/src/editor/Infobar.zig @@ -2,6 +2,7 @@ const std = @import("std"); const fizzy = @import("../fizzy.zig"); const dvui = @import("dvui"); const icons = @import("icons"); +const assets = @import("assets"); const update_notify = @import("../backend/update_notify.zig"); const Dialogs = fizzy.Editor.Dialogs; const Constants = @import("Constants.zig"); @@ -68,15 +69,38 @@ pub fn draw(_: Infobar) !void { var box = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .margin = .all(0), .padding = .all(0) }); defer box.deinit(); - dvui.icon( - @src(), - "info_icon", - icons.tvg.entypo.@"info-circled", - .{ .fill_color = dvui.themeGet().color(.window, .text) }, - .{ .gravity_y = 0.5, .padding = .{ - .x = 4, - } }, - ); + // The pixel-art F (`icon.png`, not `fox.png`), same logo the settings tree and file + // explorer use. `.imageFile` so dvui caches the texture — `fromImageFileBytes` + // re-decodes every frame. Sized off the bar height so it never grows the infobar. + const logo_side = bar_h - 8; + const logo: dvui.ImageSource = .{ .imageFile = .{ + .bytes = assets.files.@"icon.png", + .name = "icon.png", + .interpolation = .nearest, + } }; + { + // Fixed slot (min == max) so the artwork fits the bar instead of dictating its + // height, same shape as `treeRowGlyph` but sized off `infobar_height`. + var logo_slot = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .gravity_y = 0.5, + .expand = .none, + .background = false, + .min_size_content = .{ .w = logo_side, .h = logo_side }, + .max_size_content = .size(.{ .w = logo_side, .h = logo_side }), + .padding = .all(0), + .margin = .{ .x = 4, .w = 2 }, + }); + defer logo_slot.deinit(); + + _ = dvui.image(@src(), .{ .source = logo, .shrink = .ratio }, .{ + .gravity_x = 0.5, + .gravity_y = 0.5, + .expand = .ratio, + .padding = .all(0), + .margin = .all(0), + .background = false, + }); + } dvui.label(@src(), "fizzy", .{}, .{ .font = font, .gravity_y = 0.5, .margin = .all(0) }); if (button.clicked()) { From c26ce3e45f1b7960bdf88b011863b3733260fc3a Mon Sep 17 00:00:00 2001 From: foxnne Date: Tue, 4 Aug 2026 09:25:03 -0500 Subject: [PATCH 02/10] expose when documents change --- build/app.zig | 4 + docs/PLUGINS.md | 44 +++- docs/PLUGIN_MANIFEST_PLAN.md | 1 + sdk/sdk_version.zig | 2 +- src/sdk/Host.zig | 14 ++ src/sdk/Plugin.zig | 20 ++ src/sdk/dylib.zig | 15 ++ src/sdk/sdk.zig | 3 +- src/sdk/services/wikilink.zig | 421 ++++++++++++++++++++++++++++++++++ src/sdk/version.zig | 2 +- 10 files changed, 522 insertions(+), 4 deletions(-) create mode 100644 src/sdk/services/wikilink.zig diff --git a/build/app.zig b/build/app.zig index 8ca112c8..91b6703d 100644 --- a/build/app.zig +++ b/build/app.zig @@ -367,6 +367,10 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil // below never reaches it (nothing in the graph forces `sdk.manifest`), so it // needs its own root either way. .{ "fizzy-sdk-manifest-tests", "src/sdk/manifest.zig" }, + // The `[[wikilink]]` tokenizer. std-only on purpose: it's shared verbatim by the + // markdown renderer and by out-of-tree indexers, so it must not depend on dvui or + // anything else the SDK-rooted artifact drags in. + .{ "fizzy-sdk-wikilink-tests", "src/sdk/services/wikilink.zig" }, // The text plugin's headless editing model. Lives under src/plugins/ but is // deliberately dvui-free (see textcore.zig), so it tests as pure logic from the // app build. One root covers every file below it — they're relative imports. diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 9ff965b4..d8555541 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -523,7 +523,12 @@ paired `host.*` request. Call sites are in `src/editor/Editor.zig` (verify line | `drawOverlay` | broadcast | right after `tickKeybinds`, on top of the frame | Outside the frame loop: `onFolderClose` / `onFolderOpen` fire `[broadcast]` from -`setProjectFolder` / `closeProjectFolder`; `saveNeedsConfirmation` / `requestSaveConfirmation` +`setProjectFolder` / `closeProjectFolder`; `documentContentChanged` fires `[broadcast]` from +`host.notifyDocumentContentChanged`, which a document's **owner** calls when its buffer settles +after an edit (debounced — a lull in typing, and on save; never per keystroke). That hook is how a +plugin that owns no documents observes *unsaved* text: nothing else in the SDK exposes another +plugin's live buffer. Treat it as an overlay on what's on disk, not a reason to write anything +through. `saveNeedsConfirmation` / `requestSaveConfirmation` fire `[active-doc]` from the save / close / quit-all paths; `loadDocument` runs on a **background load-worker thread** (touch only the host allocator + the given buffer, no dvui). @@ -747,6 +752,43 @@ You do not need to handle JSON-RPC framing, threading, request/response id corre position-encoding negotiation, or server-initiated requests yourself — all of that is generic LSP-spec behavior `core.lsp.Client` already implements once, for every server. +### 3.10 Inter-plugin services + +`registerService(name, ptr, owner)` publishes an API under a string name; +`host.getServiceTyped(SomeApi)` looks it up by that API type's `service_name`. Fizzy stores only +an `*anyopaque` — it never interprets a service — so the API struct's *layout* is part of the ABI +fingerprint and every service type used across dylibs is listed in `dylib.zig`'s +`sdk_boundary_types`. + +The SDK ships definitions for the services plugins in this ecosystem publish, in +[`src/sdk/services/`](../src/sdk/services/): + +| Service | Provider | What it's for | +|---|---|---| +| `"workbench"` | `workbench` | Open/close/save documents, enumerate open tabs, file-tree operations, `revealPosition` | +| `"markdown"` | `markdown` | Render a markdown byte slice into the current dvui parent (native only — absent on web) | +| `"wikilink"` | any indexer (e.g. `brain`) | Resolve `[[Note]]` to a file, plus completion candidates and index state | + +**Every lookup must tolerate absence.** A service's provider may be uninstalled, disabled, or +simply not built for this target — `markdown` is missing on web, and `wikilink` is missing unless +the user installed an indexer. The idiom is one line, and the fallback is a real behavior, not an +error path: + +```zig +const wl = sdk.host().getServiceTyped(sdk.services.wikilink.Api) orelse { + // No resolver: `[[Note]]` is just text. Render it verbatim. + return renderPlain(literal); +}; +``` + +**`wikilink` splits into a pure half and a service half**, which is worth copying if you define a +service of your own. `wikilink.tokenize` — *what is a link* — is a plain function in the SDK, +compiled into both the renderer and the indexer, so the two can never disagree about the syntax. +Only *which file does this link mean* goes through the vtable, because only that needs an index. +Resolution results are memoized by the caller against `wikilink.generation()`, which is what makes +a link flip from broken to live when its target file appears — with no edit to the linking +document, and so no re-parse of it. + --- ## 4. Two plugins working together (`pixi` + `workbench`) diff --git a/docs/PLUGIN_MANIFEST_PLAN.md b/docs/PLUGIN_MANIFEST_PLAN.md index d5259f26..5e89be6a 100644 --- a/docs/PLUGIN_MANIFEST_PLAN.md +++ b/docs/PLUGIN_MANIFEST_PLAN.md @@ -42,6 +42,7 @@ | R18 — publisher/author split + probe consolidation | done | 2026-07-30 — attribution was a single hand-typed `registry/.json` `author` string with **no fallback and no validation**, which had already silently drifted: `pixi`/`ghostty`/`zig` all read `"author": "foxnne"` while their `homepage` pointed at the `fizzyedit` org, and nothing cross-checks the two. Split into the two claims that were being conflated. **`publisher`** — derived at ingest by `ingest.publisherFromUrl` from `manifest_url`'s GitHub owner, i.e. from where the binary is actually served; not writable by any plugin, null for a self-hosted manifest the heuristic can't attribute (the store then shows the author alone rather than inventing one). New `plugins.publisher` column + `summary.json` field + client `SummaryEntry.publisher`. **`author`/`author_url`** — new *cosmetic* `Manifest` fields in `plugin.zig.zon`, self-asserted, with the usual registry→builtin→probe fallback; `author_url` rides the catalog too so an *uninstalled* store plugin's credit is still clickable (no local dylib to probe yet). Rendered by `drawAuthorLine` as `publisher · author`, each linked only where there's somewhere real to go — publisher's link is built from the publisher name itself, never from an author-supplied URL. **Security:** `author_url` reaches `dvui.openURL` → the OS URL handler, so `isSafeExternalUrl` restricts it to `http`/`https`; a `file:`/custom-scheme URL in a store-listed manifest would otherwise launch an arbitrary registered handler. **Probe consolidation (the reason this stayed small):** adding two more fields would have meant a 4th and 5th `probeX` + parallel cache, each re-`dlopen`ing the *same* embedded zon. Replaced `probeDescription`/`probeTags` with one `PluginLoader.probeManifestInfo` → `ProbedManifest`, `builtinDescription`/`builtinTags` with one `Editor.builtinManifest`, and `description_cache`+`tags_cache` with one `manifest_cache` — one dlopen + parse now serves description/tags/author/author_url. **Also fixed, found while doing this:** `sdk.manifest.parse` used strict `std.zon.parse`, so **every** field ever added to `Manifest` was a breaking change in two directions — a plugin declaring a newer field would fail to *build* against an older pinned SDK, and an older fizzy probing a newer plugin's embedded manifest would lose data it could otherwise read. Now `ignore_unknown_fields = true`, making the format forward-compatible by construction (tradeoff: a misspelled field is ignored, not diagnosed). Pipeline: `read_plugin_zon.py`/`assemble_manifest.py`/`build.yml` carry `author`/`author_url` (env-routed, not `${{ }}`-interpolated); `pixi`/`ghostty`/`zig` `plugin.zig.zon`s updated and probe-verified out of their rebuilt dylibs. Verified: fizzy `zig build`/`test`/`test-sdk-version`/`check-web`/`test-integration`, store `zig build`/`test` (new `publisherFromUrl` tests confirmed executing via a deliberate-failure check), all three external plugins rebuilt. **Not verified:** the rendered `publisher · author` line and its links — UI, needs eyes on it. **Registry note:** `registry.db` is committed and `CREATE TABLE IF NOT EXISTS` won't alter it, so `db.migrate` gained best-effort `ALTER TABLE … ADD COLUMN` calls for `publisher`/`author_url`. | | Old Phase 2 (sidecar enforcement) | **cancelled** | superseded by this revision | | R16 — Store detail page: VSCode-marketplace-style header + tabs, `description` in `Manifest` | done | 2026-07-30 — the store's center-provider README view (only the center; the sidebar list is untouched) is now a full detail page. **Manifest:** `description: []const u8 = ""` added to `Manifest` (`src/sdk/manifest.zig`) — the identity-only lock from R2 is deliberately relaxed here, since the detail page needs a description for every plugin, not just ones with a registry entry; not part of `sdk_boundary_types` (never crosses the C-ABI boundary, only ever `std.zon.parse`d from `plugin.zig.zon` text), so no SDK version/fingerprint bump. All 4 built-in `plugin.zig.zon`s got real one-liners. **Description resolution** (`PluginStore.descriptionFor`): registry's own (freshest) → `Editor.builtinDescription` (built-ins read their own compiled-in `plugin_options.manifest_zon` directly, no dylib involved) → `PluginLoader.probeDescription` (new, mirrors `probeName`: opens the on-disk dylib, reads the embedded `fizzy_plugin_manifest_zon` export, parses it) for anything else. **Header** (`drawDetailHeader`): logo (same fetch-or-fallback chain the card list uses) + a stacked name (`.heading` font, matching `SettingsTree`'s root-branch style)/id (small dim mono)/author (dim)/description (wrapped) column, with the existing `drawCardControls` (install/update/uninstall) reused as-is, right-justified. **Tabs** (`drawDetailTabs`): a plain two-tab DETAILS/CHANGELOG strip — same selected/unselected color convention every other tab bar in the app uses, but no drag/drop or scroll area (there are only ever two). Reconstructing the selected plugin's `StoreEntry` for the header needed its own helper (`selectedEntry`), since the center provider draws independently of the sidebar's list-building pass and registry data is only valid while the catalog lock is held for that one frame — same acquire/release-per-frame discipline the list already follows. **Background:** the README view's old rounded `sdk.pane_layout.emptyStateCard` (meant for a genuinely empty hint screen) is now `sdk.pane_layout.mainCanvasVbox` — a plain flat fill, the same background every other content pane in the app uses. **CHANGELOG tab** is a placeholder empty state ("Changelog coming soon") — real GitHub Releases fetching (per-release notes) is out of scope for this pass, by explicit choice. **Install counts** are out of scope entirely — there is no backend/analytics service to source them from; revisit once one exists. Verified: `zig build`, `zig build test`, `zig build test-sdk-version`, `zig build check-web` all clean; a live isolated-`HOME`/`TMPDIR` run rendered the header/tabs/flat-background README correctly end-to-end (ghostty, registry description + README both showing, "No compatible build in store" control state correct for an uninstalled entry). | +| R19 — `wikilink` service + `documentContentChanged` broadcast | in progress | 2026-08-04 — the SDK seam for `[[wikilink]]` support, so an out-of-tree indexer (`brain`) can resolve links that the in-tree `markdown` renderer draws, without either importing the other. **New `src/sdk/services/wikilink.zig`**, split deliberately in two: (1) a **pure tokenizer** (`Token`, `tokenize`, `tokenizeAlloc`) — *what is a link* — living in the SDK rather than in either plugin, because a renderer and an indexer that disagree about the syntax produce graph edges the preview never drew (or vice versa); one implementation, one test suite (18 cases: alias/heading/block-id/embed forms, empty and unterminated targets, newline rejection, span recovery, out-buffer bounds, `tokenizeAlloc` parity). (2) an **`Api` vtable** — *which file does this link mean* — `resolve` / `generation` / `complete` / `indexing`. `complete` and `indexing` ship unused on day one on purpose: every field added later is another fingerprint bump that breaks every installed plugin. `resolve` is allocator-in/allocation-out rather than returning borrowed slices, because a background reindex can invalidate the provider's own strings between the call and the end of the frame; callers pass a frame arena. The `generation` counter is what lets a consumer memoize resolution *and* still have a link flip from broken to live when its target file appears — resolution can't be precomputed at parse time, since the linking document's bytes don't change when the target is created. Unlike `workbench`/`markdown`, both ends of this service are plugins (fizzy only stores the `*anyopaque`), so a shape mismatch would be dylib-to-dylib and invisible to the host — hence `Api`, `Api.VTable`, `Resolution`, `Candidate`, and `Token` all get explicit `sdk_boundary_types` entries (the `CompletionItem` lesson again: slices and by-value reaches aren't followed by `hashType`). **New `Plugin.VTable.documentContentChanged`** + `Host.notifyDocumentContentChanged` (a plain Host method over `plugins.items` — no `EditorAPI` vtable entry needed, so `EditorAPI`'s shape is untouched): a `[broadcast]` an owner fires when its buffer settles, letting a plugin that owns no documents see *unsaved* text at all. Owners debounce (a typing lull, plus on save); consumers treat it as an overlay on disk state. Tokenizer tests are wired as their own `addTest` root (`fizzy-sdk-wikilink-tests` in `build/app.zig`'s pure-logic list — std-only by design, so it must not sit under the SDK-rooted artifact that drags in dvui). sdk **0.1.49** (fingerprint `0x45dc3739334bebb`). Verified: `zig build`, `zig build test`, `zig build test-sdk-version`, `zig build check-web` all clean. **Not done yet:** the `markdown`-side render pass and the `text`-side debounced notify call (next phase), and `pixi`/`zig`/`ghostty` still need rebuilding against 0.1.49 before they will load. | | R17 — `tags` in `Manifest` + registry-side description/tags dedup | done | 2026-07-30 — closes the gap R16 left for `description`: `tags` couldn't be authored anywhere except a hand-typed `registry/.json` PR in the separate `fizzyedit/plugins` repo, so a plugin with no registry entry yet (or one whose author never filled tags in) had zero search surface for them. **`Manifest`** (`src/sdk/manifest.zig`): `tags: []const []const u8 = &.{}` added, same off-`sdk_boundary_types` treatment as `description` (no fingerprint bump). All 4 built-in `plugin.zig.zon`s got real tags. **Resolution chain** (`PluginStore.tagsFor`, mirrors `descriptionFor` exactly): registry's own → `Editor.builtinTags` (new, mirrors `builtinDescription`) → `PluginLoader.probeTags` (new, mirrors `probeDescription`; returns a caller-owned `[][]u8` via a small `dupeTags` helper, since a manifest's `tags` — unlike `description` — is an array, not a single string) → `tags_cache` (new, same `StringArrayHashMapUnmanaged` shape as `description_cache`, cleared at the same two call sites: `refreshDiskScan` and `deinit`). **`scoreEntry`** now calls `descriptionFor`/`tagsFor` instead of reading `entry.registry.?.{description,tags}` directly, so a built-in or locally-probed dylib's own prose/tags contribute to store search even with no registry entry at all — `author` is the one field left with no fallback, since it was never a `plugin.zig.zon` concept to begin with (attribution, not something a build declares about itself). **No new UI** — tags still have no display surface (chips, filter row) anywhere in the store; this pass is resolution-chain-only, matching what already existed for `description` before R16's header. **Registry-side dedup** (separate repos, coordinated in this pass since the whole point was "don't require authors to hand-duplicate description/tags"): `fizzyedit/plugin-build-action`'s `read_plugin_zon.py` now also reads `description`/`tags` off `plugin.zig.zon`; `build.yml`'s setup job exposes them as job outputs (routed through `env:` rather than direct `${{ }}` interpolation into the assemble-manifest shell step, since these are free-form author-controlled strings — direct interpolation would be a script-injection hole); `assemble_manifest.py` embeds `name`/`description`/`tags` at the top level of the author's `manifest.json` (previously just `{id, releases}`). `fizzyedit/plugins`'s `store/src/manifest.zig` (the *aggregator's* copy of the author-manifest shape, distinct from `sdk/manifest.zig`) gained matching `name`/`description`/`tags` fields; `ingest.zig`'s `upsertPlugin`/`upsertTags` now fall back to the fetched manifest's values when `registry/.json` leaves its own `description`/`tags` empty — registry entry still wins when both are set, so a maintainer can override the store-listed copy without waiting on a plugin release. `docs/manifest.example.json` and both repos' `README.md` updated. **Not done, left for the user:** this is an interface change to `plugin-build-action`'s `build.yml`/`assemble_manifest.py` — existing `release.yml` callers pin `uses: .../build.yml@v3`, and `build.yml`'s own auxiliary-checkout step hardcodes the matching `ref="v3"` literal for its own script checkout, so nothing picks this up until a **new `v4` tag is cut and pushed** (a shared-CI action, deliberately not done automatically) and each external plugin repo (`pixi`/`ghostty`/`zig`/`json`/`markdown`) bumps its own `release.yml` to `@v4`; no `registry/.json` PR was reauthored to drop its now-optional `description`/`tags` either (a per-plugin-author call, not this repo's to make). | --- diff --git a/sdk/sdk_version.zig b/sdk/sdk_version.zig index 7435d017..e3dae229 100644 --- a/sdk/sdk_version.zig +++ b/sdk/sdk_version.zig @@ -22,5 +22,5 @@ const std = @import("std"); pub const sdk_version = std.SemanticVersion{ .major = 0, .minor = 1, - .patch = 48, + .patch = 49, }; diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig index 0299a758..e587c9bf 100644 --- a/src/sdk/Host.zig +++ b/src/sdk/Host.zig @@ -654,6 +654,20 @@ pub fn pluginById(self: *Host, id: []const u8) ?*Plugin { return null; } +/// Broadcast an open document's in-memory content change to every registered plugin. +/// +/// Called by the document's **owner** when its buffer settles after an edit — see +/// `Plugin.VTable.documentContentChanged` for the debouncing contract. This is how a plugin +/// that owns nothing (a link indexer, a word counter) sees unsaved text at all: nothing else +/// in the SDK exposes another plugin's live buffer. +/// +/// The owner is included in the fan-out. That's deliberate — filtering it out would mean +/// owners behave differently from everyone else for no reason, and an owner that doesn't want +/// its own notification simply doesn't implement the hook. +pub fn notifyDocumentContentChanged(self: *Host, path: []const u8, bytes: []const u8) void { + for (self.plugins.items) |plugin| plugin.documentContentChanged(path, bytes); +} + /// First registered plugin that implements `createDocument` (for fizzy New File flows). pub fn pluginWithCreateDocument(self: *Host) ?*Plugin { for (self.plugins.items) |plugin| { diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig index f694f1e9..8223a617 100644 --- a/src/sdk/Plugin.zig +++ b/src/sdk/Plugin.zig @@ -183,6 +183,22 @@ pub const VTable = struct { /// plugin can load state it keyed to that folder. onFolderOpen: ?*const fn (state: *anyopaque, allocator: std.mem.Allocator) void = null, + // ---- document content ---- + /// [broadcast] An open document's in-memory contents changed. Fired for *every* registered + /// plugin, not just the owner — the point is to let a plugin that doesn't own the document + /// observe it anyway (a link indexer watching markdown it will never render, say). The + /// owner is the one that reports the change, via `Host.notifyDocumentContentChanged`. + /// + /// Owners are expected to **debounce**: report after a short lull in typing (a few hundred + /// ms) and immediately on save, never per keystroke. `path` is the document's path, empty + /// for an unsaved buffer. `bytes` is the live buffer and is only valid for the duration of + /// the call — copy anything you keep. + /// + /// This is a hint about *unsaved* state; the file on disk still says something else. A + /// consumer that also watches the filesystem should treat this as an overlay it can drop + /// once the on-disk version catches up, not as a reason to write anything through. + documentContentChanged: ?*const fn (state: *anyopaque, path: []const u8, bytes: []const u8) void = null, + // ---- save protocol ---- /// [active-doc] True when the owner wants a confirmation before `saveDocument` (e.g. a save /// that would flatten lossy data, change encoding, or overwrite an on-disk change). When @@ -285,6 +301,10 @@ pub fn onFolderOpen(self: Plugin, allocator: std.mem.Allocator) void { if (self.vtable.onFolderOpen) |f| f(self.state, allocator); } +pub fn documentContentChanged(self: Plugin, path: []const u8, bytes: []const u8) void { + if (self.vtable.documentContentChanged) |f| f(self.state, path, bytes); +} + pub fn bindDocumentToPane(self: Plugin, doc: DocHandle, canvas_id: dvui.Id, workspace_handle: *anyopaque, center: bool) void { if (self.vtable.bindDocumentToPane) |f| f(self.state, doc, canvas_id, workspace_handle, center); } diff --git a/src/sdk/dylib.zig b/src/sdk/dylib.zig index 5f72917c..8adce3a0 100644 --- a/src/sdk/dylib.zig +++ b/src/sdk/dylib.zig @@ -26,6 +26,7 @@ const regions = @import("regions.zig"); const language_mod = @import("language.zig"); const workbench_service = @import("services/workbench.zig"); const markdown_service = @import("services/markdown.zig"); +const wikilink_service = @import("services/wikilink.zig"); /// C ABI — host loader injects host-owned pointers into the plugin image before `register`. /// @@ -131,6 +132,20 @@ const sdk_boundary_types = .{ workbench_service.Api.VTable, markdown_service.Api, markdown_service.Api.VTable, + // Unlike `workbench`/`markdown`, this service's producer and consumer are *both* plugins + // (an indexer registers it, the markdown renderer calls it) — fizzy only stores the + // `*anyopaque`. So a mismatch here is dylib-to-dylib and the host would never notice: + // a provider built with a 4-slot vtable and a consumer built against a 5-slot one both + // pass every other check, and the consumer calls a fn pointer past the end. Listing both + // turns that into a clean `err_abi_mismatch` at load. + wikilink_service.Api, + wikilink_service.Api.VTable, + // `Resolution`/`Candidate` are only reached through a slice or by value across the + // boundary — same lesson as `CompletionItem` above; give them explicit entries so a field + // added later can't change the real layout without moving the fingerprint. + wikilink_service.Api.Resolution, + wikilink_service.Api.Candidate, + wikilink_service.Token, VersionTriplet, }; diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig index a988717e..f516648d 100644 --- a/src/sdk/sdk.zig +++ b/src/sdk/sdk.zig @@ -62,10 +62,11 @@ pub const document = @import("document.zig"); pub const manifest = @import("manifest.zig"); pub const Manifest = manifest.Manifest; -/// Inter-plugin services (`"workbench"`, `"markdown"`). +/// Inter-plugin services (`"workbench"`, `"markdown"`, `"wikilink"`). pub const services = struct { pub const workbench = @import("services/workbench.zig"); pub const markdown = @import("services/markdown.zig"); + pub const wikilink = @import("services/wikilink.zig"); }; /// SDK version + ABI fingerprint lock (`sdk_version`, `recorded_abi_fingerprints`). diff --git a/src/sdk/services/wikilink.zig b/src/sdk/services/wikilink.zig new file mode 100644 index 00000000..40649c60 --- /dev/null +++ b/src/sdk/services/wikilink.zig @@ -0,0 +1,421 @@ +//! Wikilink inter-plugin service — SDK-facing definition of the `"wikilink"` service. +//! +//! Two halves that serve opposite directions of the same feature: +//! +//! - **The tokenizer** (`Token`, `tokenize`, `tokenizeAlloc`) is pure and lives here rather than +//! in either plugin *deliberately*. A renderer (markdown) and an indexer (brain) both have to +//! agree, byte for byte, on what counts as a wikilink — if they drift, the graph shows edges +//! the preview didn't draw, or the preview links to something the index never recorded. One +//! implementation in the package both sides already pin makes that class of bug impossible. +//! +//! - **`Api`** is the resolver: *which file does `[[Note]]` mean?* That answer needs a whole +//! index of the open folder, so it's provided by a plugin (brain) and consumed by whoever +//! renders or navigates wikilinks. Like `markdown`, a missing service is a normal, expected +//! case — with no resolver registered, callers must render `[[Note]]` as the literal text it +//! is, not as a broken link. +//! +//! Note that `Api` deliberately says nothing about *how* resolution works (shortest-unique-name +//! matching, aliases, phantom notes). That's the provider's policy, and keeping it out of the +//! ABI means the rules can improve without a fingerprint bump. +const std = @import("std"); + +/// One `[[wikilink]]` found in a run of text. +/// +/// `target`/`heading`/`block_id`/`alias` are all slices *into the input literal*, so they live +/// exactly as long as it does — copy them if the tokens outlive the buffer. +pub const Token = struct { + /// Byte range of the whole link within the literal it was found in, brackets included + /// (and the leading `!` for an embed). `literal[start..end]` reproduces it exactly, which + /// is what a renderer needs to emit the untouched original when there's no resolver. + start: usize, + end: usize, + /// The link target: everything before `|`, `#` and `^`. Never empty — a link with an empty + /// target isn't a link and is skipped entirely. + target: []const u8, + /// Heading anchor after `#`, `""` when absent. + heading: []const u8 = "", + /// Block anchor after `#^`, `""` when absent. Parsed so the syntax round-trips; no + /// consumer acts on it yet. + block_id: []const u8 = "", + /// Display text after `|`, `""` when absent (render `target` then). + alias: []const u8 = "", + /// `![[…]]` rather than `[[…]]` — a transclusion request. Renderers that don't implement + /// transclusion draw it as an ordinary link; indexers should still record the edge. + embed: bool = false, + + /// What to show the user for this link. + pub fn label(self: Token) []const u8 { + return if (self.alias.len > 0) self.alias else self.target; + } +}; + +/// Scan `literal` for wikilinks, writing at most `out.len` of them and returning the filled +/// prefix. Allocation-free — intended for the common case where a caller has a small stack +/// buffer and just wants to know whether a run of text contains any links at all. +/// +/// `literal` is expected to be the contents of a single markdown text node or source line: a +/// link may not span a newline, and one containing `\n` is not a link. +pub fn tokenize(literal: []const u8, out: []Token) []Token { + var n: usize = 0; + var i: usize = 0; + while (i + 1 < literal.len and n < out.len) { + if (!(literal[i] == '[' and literal[i + 1] == '[')) { + i += 1; + continue; + } + const tok = scanAt(literal, i) orelse { + // Not a link after all (unterminated, empty, or newline inside). Step one byte + // rather than past the `[[` so `[[[A]]` still finds `[A]`… and, more importantly, + // so a stray `[[` can't swallow a real link that follows it. + i += 1; + continue; + }; + out[n] = tok; + n += 1; + i = tok.end; + } + return out[0..n]; +} + +/// `tokenize` into a freshly allocated slice sized to the result. Returns an empty (but still +/// allocated) slice when there are no links, so callers can free unconditionally. +pub fn tokenizeAlloc(gpa: std.mem.Allocator, literal: []const u8) ![]Token { + var list: std.ArrayList(Token) = .empty; + errdefer list.deinit(gpa); + + var i: usize = 0; + while (i + 1 < literal.len) { + if (!(literal[i] == '[' and literal[i + 1] == '[')) { + i += 1; + continue; + } + const tok = scanAt(literal, i) orelse { + i += 1; + continue; + }; + try list.append(gpa, tok); + i = tok.end; + } + return list.toOwnedSlice(gpa); +} + +/// Parse one link starting at `open` (which must point at `[[`), or null when what's there +/// isn't a well-formed wikilink. +fn scanAt(literal: []const u8, open: usize) ?Token { + const body_start = open + 2; + // Find the closing `]]`. A `]` inside the body is fine (`[[a]b]]` targets `a]b`) as long + // as it isn't doubled, which matches how Obsidian behaves in practice. + var j = body_start; + const close = while (j + 1 < literal.len) : (j += 1) { + if (literal[j] == '\n') return null; // links don't span lines + if (literal[j] == ']' and literal[j + 1] == ']') break j; + } else return null; + + const body = literal[body_start..close]; + if (body.len == 0) return null; + + // `!` immediately before `[[` makes it an embed, and is part of the token's span so the + // renderer's "emit the original" path reproduces it. + const embed = open > 0 and literal[open - 1] == '!'; + const start = if (embed) open - 1 else open; + + // Split off the alias first: everything after the *first* `|` is display text, and a `#` + // inside the alias is just a character. + var link = body; + var alias: []const u8 = ""; + if (std.mem.indexOfScalar(u8, body, '|')) |bar| { + link = body[0..bar]; + alias = std.mem.trim(u8, body[bar + 1 ..], " \t"); + } + + // Then the anchor. `#^id` is a block ref, plain `#text` is a heading. + var target = link; + var heading: []const u8 = ""; + var block_id: []const u8 = ""; + if (std.mem.indexOfScalar(u8, link, '#')) |hash| { + target = link[0..hash]; + const anchor = link[hash + 1 ..]; + if (anchor.len > 0 and anchor[0] == '^') { + block_id = std.mem.trim(u8, anchor[1..], " \t"); + } else { + heading = std.mem.trim(u8, anchor, " \t"); + } + } + + target = std.mem.trim(u8, target, " \t"); + if (target.len == 0) return null; + + return .{ + .start = start, + .end = close + 2, + .target = target, + .heading = heading, + .block_id = block_id, + .alias = alias, + .embed = embed, + }; +} + +pub const Api = struct { + pub const service_name = "wikilink"; + + ctx: *anyopaque, + vtable: *const VTable, + + pub const Status = enum(u8) { + /// Exactly one target, or a clear winner. `path` is set. + resolved, + /// No note matches. Renderers should style this distinctly (a "broken" link) — but + /// note it is a completely normal state in a wiki: it's how you plan a note before + /// writing it. + unresolved, + /// Several notes match and the tie-break picked one. `path` is set; renderers may + /// warn. + ambiguous, + /// The provider doesn't know yet — an index build is in flight. Callers should render + /// neutrally and ask again next frame, so opening a folder doesn't flash every link + /// red for a second. + indexing, + }; + + pub const Resolution = struct { + status: Status, + /// Absolute path to the target file. Set when `.resolved` or `.ambiguous`, empty + /// otherwise. Allocated from the caller's allocator. + path: []const u8 = "", + /// 0-based line of the requested `#heading` within the target, when one was requested + /// and found. 0 (the top of the file) otherwise — a heading that doesn't exist is not + /// an error, it just doesn't scroll. + line: u32 = 0, + /// The display title the provider would use for this target (front-matter title, the + /// matched alias, or the file stem). Allocated from the caller's allocator. + title: []const u8 = "", + }; + + pub const Candidate = struct { + /// Text to insert between the brackets to link to this note. + target: []const u8, + /// Absolute path, for a preview or tooltip. Empty for a phantom. + path: []const u8, + title: []const u8, + /// The note doesn't exist yet (something links to it, nothing wrote it) — a picker + /// can offer to create it. + phantom: bool = false, + }; + + pub const VTable = struct { + /// Resolve `target` (already stripped of `|alias` and any anchor) as seen from + /// `source_path`, an absolute path to the linking document. `source_path` may be empty + /// — an unsaved buffer, or content fetched from the network — in which case the + /// provider must skip any relative/same-directory rules; callers must accept + /// `.unresolved` for content that has no place in the folder. + /// + /// `heading` is `""` when the link had no anchor. Strings in the returned `Resolution` + /// are allocated from `gpa` and owned by the caller — pass a frame arena. Returning + /// borrowed slices was rejected on purpose: a background reindex can invalidate the + /// provider's own strings between the call and the end of the frame. + /// + /// Called from the UI thread during draw, so it must not block. Providers are expected + /// to answer from an in-memory or local index; callers memoize against `generation`. + resolve: *const fn ( + ctx: *anyopaque, + target: []const u8, + heading: []const u8, + source_path: []const u8, + gpa: std.mem.Allocator, + ) anyerror!Resolution, + + /// Monotonic counter, bumped once per committed change to the index. Cheap (an atomic + /// load) — call it once per frame and drop any memoized `resolve` results when it + /// moves. This is what makes a link go from broken to live when its target file + /// appears, *without* the linking document changing at all. + generation: *const fn (ctx: *anyopaque) u64, + + /// Candidates for a `[[`-completion popup, best first, at most `limit`. The returned + /// slice and its strings are allocated from `gpa` and owned by the caller. + complete: *const fn ( + ctx: *anyopaque, + prefix: []const u8, + source_path: []const u8, + limit: usize, + gpa: std.mem.Allocator, + ) anyerror![]Candidate, + + /// True while a scan is in flight. Distinct from `.indexing` on a single resolution: + /// this is the whole-provider state a progress indicator wants. + indexing: *const fn (ctx: *anyopaque) bool, + }; + + pub fn resolve( + self: Api, + target: []const u8, + heading: []const u8, + source_path: []const u8, + gpa: std.mem.Allocator, + ) !Resolution { + return self.vtable.resolve(self.ctx, target, heading, source_path, gpa); + } + pub fn generation(self: Api) u64 { + return self.vtable.generation(self.ctx); + } + pub fn complete( + self: Api, + prefix: []const u8, + source_path: []const u8, + limit: usize, + gpa: std.mem.Allocator, + ) ![]Candidate { + return self.vtable.complete(self.ctx, prefix, source_path, limit, gpa); + } + pub fn indexing(self: Api) bool { + return self.vtable.indexing(self.ctx); + } +}; + +// -- tests ------------------------------------------------------------------------------ + +const testing = std.testing; + +fn expectOne(literal: []const u8) Token { + var buf: [8]Token = undefined; + const toks = tokenize(literal, &buf); + testing.expectEqual(@as(usize, 1), toks.len) catch @panic("expected exactly one token"); + return toks[0]; +} + +fn expectNone(literal: []const u8) !void { + var buf: [8]Token = undefined; + try testing.expectEqual(@as(usize, 0), tokenize(literal, &buf).len); +} + +test "plain target" { + const t = expectOne("[[A]]"); + try testing.expectEqualStrings("A", t.target); + try testing.expectEqualStrings("", t.alias); + try testing.expect(!t.embed); + try testing.expectEqual(@as(usize, 0), t.start); + try testing.expectEqual(@as(usize, 5), t.end); +} + +test "alias" { + const t = expectOne("[[A|B]]"); + try testing.expectEqualStrings("A", t.target); + try testing.expectEqualStrings("B", t.alias); + try testing.expectEqualStrings("B", t.label()); +} + +test "heading" { + const t = expectOne("[[A#H]]"); + try testing.expectEqualStrings("A", t.target); + try testing.expectEqualStrings("H", t.heading); + try testing.expectEqualStrings("", t.block_id); +} + +test "heading and alias" { + const t = expectOne("[[A#H|B]]"); + try testing.expectEqualStrings("A", t.target); + try testing.expectEqualStrings("H", t.heading); + try testing.expectEqualStrings("B", t.alias); +} + +test "block id" { + const t = expectOne("[[A#^abc123]]"); + try testing.expectEqualStrings("A", t.target); + try testing.expectEqualStrings("abc123", t.block_id); + try testing.expectEqualStrings("", t.heading); +} + +test "embed spans the bang" { + const t = expectOne("![[A]]"); + try testing.expect(t.embed); + try testing.expectEqualStrings("A", t.target); + try testing.expectEqual(@as(usize, 0), t.start); + try testing.expectEqual(@as(usize, 6), t.end); +} + +test "relative path target" { + const t = expectOne("[[../rel/A]]"); + try testing.expectEqualStrings("../rel/A", t.target); +} + +test "surrounding text is excluded from the span" { + const src = "see [[A]] now"; + const t = expectOne(src); + try testing.expectEqualStrings("[[A]]", src[t.start..t.end]); +} + +test "two links" { + var buf: [8]Token = undefined; + const toks = tokenize("[[A]] and [[B]]", &buf); + try testing.expectEqual(@as(usize, 2), toks.len); + try testing.expectEqualStrings("A", toks[0].target); + try testing.expectEqualStrings("B", toks[1].target); +} + +test "nested brackets keep the inner link" { + // `[[[A]]]` — the first `[[` opens, `]]` closes, so the target is `[A`. What matters is + // that exactly one link is found and the untouched original is recoverable. + var buf: [8]Token = undefined; + const toks = tokenize("[[[A]]]", &buf); + try testing.expectEqual(@as(usize, 1), toks.len); +} + +test "whitespace around target and alias is trimmed" { + const t = expectOne("[[ A | B ]]"); + try testing.expectEqualStrings("A", t.target); + try testing.expectEqualStrings("B", t.alias); +} + +test "empty target is not a link" { + try expectNone("[[]]"); + try expectNone("[[ ]]"); + try expectNone("[[|B]]"); + try expectNone("[[#H]]"); +} + +test "unterminated is not a link" { + try expectNone("[[A"); + try expectNone("A]]"); + try expectNone("[[A]"); + try expectNone(""); + try expectNone("["); +} + +test "a link may not span a newline" { + try expectNone("[[A\nB]]"); +} + +test "a stray open bracket does not swallow the next link" { + var buf: [8]Token = undefined; + const toks = tokenize("[[ oops \n [[A]]", &buf); + try testing.expectEqual(@as(usize, 1), toks.len); + try testing.expectEqualStrings("A", toks[0].target); +} + +test "out buffer bounds are respected" { + var buf: [2]Token = undefined; + const toks = tokenize("[[A]] [[B]] [[C]]", &buf); + try testing.expectEqual(@as(usize, 2), toks.len); +} + +test "tokenizeAlloc matches tokenize" { + const src = "[[A]] x ![[B|b]] y [[C#H]]"; + const toks = try tokenizeAlloc(testing.allocator, src); + defer testing.allocator.free(toks); + + var buf: [8]Token = undefined; + const stack = tokenize(src, &buf); + + try testing.expectEqual(stack.len, toks.len); + for (stack, toks) |a, b| { + try testing.expectEqual(a.start, b.start); + try testing.expectEqual(a.end, b.end); + try testing.expectEqualStrings(a.target, b.target); + } +} + +test "tokenizeAlloc returns a freeable empty slice" { + const toks = try tokenizeAlloc(testing.allocator, "no links here"); + defer testing.allocator.free(toks); + try testing.expectEqual(@as(usize, 0), toks.len); +} diff --git a/src/sdk/version.zig b/src/sdk/version.zig index 3a80faa9..0c212369 100644 --- a/src/sdk/version.zig +++ b/src/sdk/version.zig @@ -71,7 +71,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value (from the `@compileError` it triggers) and bump `sdk_version` in the same commit /// whenever it changes. -pub const recorded_sdk_shape_fingerprint: u64 = 0x5140f93c991d777d; +pub const recorded_sdk_shape_fingerprint: u64 = 0x45dc3739334bebb; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { From 578e067002e8bdeccd61486018458a41196cb8cc Mon Sep 17 00:00:00 2001 From: foxnne Date: Tue, 4 Aug 2026 09:39:44 -0500 Subject: [PATCH 03/10] Phase 1 of markdown sharpening --- docs/PLUGIN_MANIFEST_PLAN.md | 2 +- src/plugins/markdown/build.zig | 18 ++ src/plugins/markdown/plugin.zig | 2 + src/plugins/markdown/src/markdown.zig | 15 +- src/plugins/markdown/src/md/cmark_parse.zig | 22 ++ src/plugins/markdown/src/md/render_ast.zig | 264 +++++++++++++++- src/plugins/markdown/src/md/wikilink_scan.zig | 289 ++++++++++++++++++ src/plugins/text/plugin.zig | 13 + src/plugins/text/src/Document.zig | 52 ++++ 9 files changed, 665 insertions(+), 12 deletions(-) create mode 100644 src/plugins/markdown/src/md/wikilink_scan.zig diff --git a/docs/PLUGIN_MANIFEST_PLAN.md b/docs/PLUGIN_MANIFEST_PLAN.md index 5e89be6a..438b104b 100644 --- a/docs/PLUGIN_MANIFEST_PLAN.md +++ b/docs/PLUGIN_MANIFEST_PLAN.md @@ -42,7 +42,7 @@ | R18 — publisher/author split + probe consolidation | done | 2026-07-30 — attribution was a single hand-typed `registry/.json` `author` string with **no fallback and no validation**, which had already silently drifted: `pixi`/`ghostty`/`zig` all read `"author": "foxnne"` while their `homepage` pointed at the `fizzyedit` org, and nothing cross-checks the two. Split into the two claims that were being conflated. **`publisher`** — derived at ingest by `ingest.publisherFromUrl` from `manifest_url`'s GitHub owner, i.e. from where the binary is actually served; not writable by any plugin, null for a self-hosted manifest the heuristic can't attribute (the store then shows the author alone rather than inventing one). New `plugins.publisher` column + `summary.json` field + client `SummaryEntry.publisher`. **`author`/`author_url`** — new *cosmetic* `Manifest` fields in `plugin.zig.zon`, self-asserted, with the usual registry→builtin→probe fallback; `author_url` rides the catalog too so an *uninstalled* store plugin's credit is still clickable (no local dylib to probe yet). Rendered by `drawAuthorLine` as `publisher · author`, each linked only where there's somewhere real to go — publisher's link is built from the publisher name itself, never from an author-supplied URL. **Security:** `author_url` reaches `dvui.openURL` → the OS URL handler, so `isSafeExternalUrl` restricts it to `http`/`https`; a `file:`/custom-scheme URL in a store-listed manifest would otherwise launch an arbitrary registered handler. **Probe consolidation (the reason this stayed small):** adding two more fields would have meant a 4th and 5th `probeX` + parallel cache, each re-`dlopen`ing the *same* embedded zon. Replaced `probeDescription`/`probeTags` with one `PluginLoader.probeManifestInfo` → `ProbedManifest`, `builtinDescription`/`builtinTags` with one `Editor.builtinManifest`, and `description_cache`+`tags_cache` with one `manifest_cache` — one dlopen + parse now serves description/tags/author/author_url. **Also fixed, found while doing this:** `sdk.manifest.parse` used strict `std.zon.parse`, so **every** field ever added to `Manifest` was a breaking change in two directions — a plugin declaring a newer field would fail to *build* against an older pinned SDK, and an older fizzy probing a newer plugin's embedded manifest would lose data it could otherwise read. Now `ignore_unknown_fields = true`, making the format forward-compatible by construction (tradeoff: a misspelled field is ignored, not diagnosed). Pipeline: `read_plugin_zon.py`/`assemble_manifest.py`/`build.yml` carry `author`/`author_url` (env-routed, not `${{ }}`-interpolated); `pixi`/`ghostty`/`zig` `plugin.zig.zon`s updated and probe-verified out of their rebuilt dylibs. Verified: fizzy `zig build`/`test`/`test-sdk-version`/`check-web`/`test-integration`, store `zig build`/`test` (new `publisherFromUrl` tests confirmed executing via a deliberate-failure check), all three external plugins rebuilt. **Not verified:** the rendered `publisher · author` line and its links — UI, needs eyes on it. **Registry note:** `registry.db` is committed and `CREATE TABLE IF NOT EXISTS` won't alter it, so `db.migrate` gained best-effort `ALTER TABLE … ADD COLUMN` calls for `publisher`/`author_url`. | | Old Phase 2 (sidecar enforcement) | **cancelled** | superseded by this revision | | R16 — Store detail page: VSCode-marketplace-style header + tabs, `description` in `Manifest` | done | 2026-07-30 — the store's center-provider README view (only the center; the sidebar list is untouched) is now a full detail page. **Manifest:** `description: []const u8 = ""` added to `Manifest` (`src/sdk/manifest.zig`) — the identity-only lock from R2 is deliberately relaxed here, since the detail page needs a description for every plugin, not just ones with a registry entry; not part of `sdk_boundary_types` (never crosses the C-ABI boundary, only ever `std.zon.parse`d from `plugin.zig.zon` text), so no SDK version/fingerprint bump. All 4 built-in `plugin.zig.zon`s got real one-liners. **Description resolution** (`PluginStore.descriptionFor`): registry's own (freshest) → `Editor.builtinDescription` (built-ins read their own compiled-in `plugin_options.manifest_zon` directly, no dylib involved) → `PluginLoader.probeDescription` (new, mirrors `probeName`: opens the on-disk dylib, reads the embedded `fizzy_plugin_manifest_zon` export, parses it) for anything else. **Header** (`drawDetailHeader`): logo (same fetch-or-fallback chain the card list uses) + a stacked name (`.heading` font, matching `SettingsTree`'s root-branch style)/id (small dim mono)/author (dim)/description (wrapped) column, with the existing `drawCardControls` (install/update/uninstall) reused as-is, right-justified. **Tabs** (`drawDetailTabs`): a plain two-tab DETAILS/CHANGELOG strip — same selected/unselected color convention every other tab bar in the app uses, but no drag/drop or scroll area (there are only ever two). Reconstructing the selected plugin's `StoreEntry` for the header needed its own helper (`selectedEntry`), since the center provider draws independently of the sidebar's list-building pass and registry data is only valid while the catalog lock is held for that one frame — same acquire/release-per-frame discipline the list already follows. **Background:** the README view's old rounded `sdk.pane_layout.emptyStateCard` (meant for a genuinely empty hint screen) is now `sdk.pane_layout.mainCanvasVbox` — a plain flat fill, the same background every other content pane in the app uses. **CHANGELOG tab** is a placeholder empty state ("Changelog coming soon") — real GitHub Releases fetching (per-release notes) is out of scope for this pass, by explicit choice. **Install counts** are out of scope entirely — there is no backend/analytics service to source them from; revisit once one exists. Verified: `zig build`, `zig build test`, `zig build test-sdk-version`, `zig build check-web` all clean; a live isolated-`HOME`/`TMPDIR` run rendered the header/tabs/flat-background README correctly end-to-end (ghostty, registry description + README both showing, "No compatible build in store" control state correct for an uninstalled entry). | -| R19 — `wikilink` service + `documentContentChanged` broadcast | in progress | 2026-08-04 — the SDK seam for `[[wikilink]]` support, so an out-of-tree indexer (`brain`) can resolve links that the in-tree `markdown` renderer draws, without either importing the other. **New `src/sdk/services/wikilink.zig`**, split deliberately in two: (1) a **pure tokenizer** (`Token`, `tokenize`, `tokenizeAlloc`) — *what is a link* — living in the SDK rather than in either plugin, because a renderer and an indexer that disagree about the syntax produce graph edges the preview never drew (or vice versa); one implementation, one test suite (18 cases: alias/heading/block-id/embed forms, empty and unterminated targets, newline rejection, span recovery, out-buffer bounds, `tokenizeAlloc` parity). (2) an **`Api` vtable** — *which file does this link mean* — `resolve` / `generation` / `complete` / `indexing`. `complete` and `indexing` ship unused on day one on purpose: every field added later is another fingerprint bump that breaks every installed plugin. `resolve` is allocator-in/allocation-out rather than returning borrowed slices, because a background reindex can invalidate the provider's own strings between the call and the end of the frame; callers pass a frame arena. The `generation` counter is what lets a consumer memoize resolution *and* still have a link flip from broken to live when its target file appears — resolution can't be precomputed at parse time, since the linking document's bytes don't change when the target is created. Unlike `workbench`/`markdown`, both ends of this service are plugins (fizzy only stores the `*anyopaque`), so a shape mismatch would be dylib-to-dylib and invisible to the host — hence `Api`, `Api.VTable`, `Resolution`, `Candidate`, and `Token` all get explicit `sdk_boundary_types` entries (the `CompletionItem` lesson again: slices and by-value reaches aren't followed by `hashType`). **New `Plugin.VTable.documentContentChanged`** + `Host.notifyDocumentContentChanged` (a plain Host method over `plugins.items` — no `EditorAPI` vtable entry needed, so `EditorAPI`'s shape is untouched): a `[broadcast]` an owner fires when its buffer settles, letting a plugin that owns no documents see *unsaved* text at all. Owners debounce (a typing lull, plus on save); consumers treat it as an overlay on disk state. Tokenizer tests are wired as their own `addTest` root (`fizzy-sdk-wikilink-tests` in `build/app.zig`'s pure-logic list — std-only by design, so it must not sit under the SDK-rooted artifact that drags in dvui). sdk **0.1.49** (fingerprint `0x45dc3739334bebb`). Verified: `zig build`, `zig build test`, `zig build test-sdk-version`, `zig build check-web` all clean. **Not done yet:** the `markdown`-side render pass and the `text`-side debounced notify call (next phase), and `pixi`/`zig`/`ghostty` still need rebuilding against 0.1.49 before they will load. | +| R19 — `wikilink` service + `documentContentChanged` broadcast + markdown/text consumers | done | 2026-08-04 — the SDK seam for `[[wikilink]]` support, so an out-of-tree indexer (`brain`) can resolve links that the in-tree `markdown` renderer draws, without either importing the other. **New `src/sdk/services/wikilink.zig`**, split deliberately in two: (1) a **pure tokenizer** (`Token`, `tokenize`, `tokenizeAlloc`) — *what is a link* — living in the SDK rather than in either plugin, because a renderer and an indexer that disagree about the syntax produce graph edges the preview never drew (or vice versa); one implementation, one test suite (18 cases: alias/heading/block-id/embed forms, empty and unterminated targets, newline rejection, span recovery, out-buffer bounds, `tokenizeAlloc` parity). (2) an **`Api` vtable** — *which file does this link mean* — `resolve` / `generation` / `complete` / `indexing`. `complete` and `indexing` ship unused on day one on purpose: every field added later is another fingerprint bump that breaks every installed plugin. `resolve` is allocator-in/allocation-out rather than returning borrowed slices, because a background reindex can invalidate the provider's own strings between the call and the end of the frame; callers pass a frame arena. The `generation` counter is what lets a consumer memoize resolution *and* still have a link flip from broken to live when its target file appears — resolution can't be precomputed at parse time, since the linking document's bytes don't change when the target is created. Unlike `workbench`/`markdown`, both ends of this service are plugins (fizzy only stores the `*anyopaque`), so a shape mismatch would be dylib-to-dylib and invisible to the host — hence `Api`, `Api.VTable`, `Resolution`, `Candidate`, and `Token` all get explicit `sdk_boundary_types` entries (the `CompletionItem` lesson again: slices and by-value reaches aren't followed by `hashType`). **New `Plugin.VTable.documentContentChanged`** + `Host.notifyDocumentContentChanged` (a plain Host method over `plugins.items` — no `EditorAPI` vtable entry needed, so `EditorAPI`'s shape is untouched): a `[broadcast]` an owner fires when its buffer settles, letting a plugin that owns no documents see *unsaved* text at all. Owners debounce (a typing lull, plus on save); consumers treat it as an overlay on disk state. Tokenizer tests are wired as their own `addTest` root (`fizzy-sdk-wikilink-tests` in `build/app.zig`'s pure-logic list — std-only by design, so it must not sit under the SDK-rooted artifact that drags in dvui). sdk **0.1.49** (fingerprint `0x45dc3739334bebb`).

**Consumers, same pass.** `markdown` now renders wikilinks, and this turned up a real hazard the design had only flagged as a risk: `cmark_parser_finish` ends with `cmark_consolidate_text_nodes` (`blocks.c`), which merges every adjacent TEXT run into one literal — and since `handle_backslash` represents an escape as its own little text node, `\[\[A]]` and `[[A]]` arrive at the renderer as **the same literal**. Tokenizing the literal alone therefore turns deliberately-escaped text into a live link, with nothing in the AST to tell them apart. What survives is position: `make_literal` (`inlines.c`) sets `start_line`/`start_column` unconditionally (no `CMARK_OPT_SOURCEPOS` needed — that option only governs HTML *output*), and consolidation keeps the first fragment's start while extending `end_column`. New `src/md/wikilink_scan.zig` uses that to read the node's original bytes back out of the source, re-applies cmark's own escape rule to produce (bytes, was-escaped) pairs, and — **only when those bytes match the literal exactly** — drops links whose opening brackets were flagged. On any drift (smart punctuation rewrote a quote, an entity expanded) it **fails open** and the link renders: a link that appears where the author wanted literal text is visible and correctable, one that silently vanishes is an afternoon lost. Fast path is one `memchr` for a backslash. Tested against the **real vendored cmark** via a new `zig build test` step in the markdown plugin's own standalone `build.zig` (16 cases — it can't join fizzy's pure-logic list like `html_images`/`url_join`, which are std-only by design, because the whole point is a claim about what cmark does). Code spans and fenced blocks need no handling at all and now have tests pinning that: both get their own node types and never reach a TEXT node. Link *labels* do need a guard (`insideLinkOrImage`), since `[see [[A]]](url)` puts that text under a LINK parent.

Resolution is memoized per node+token against the resolver's `generation()` and explicitly **not** stored beside the parse (`RenderState.wikilinks` holds positions only): `Preview.ensureParsed` caches by content hash, so a scan-time resolution would freeze "broken" forever — the linking document's bytes don't change when its target is finally created. `tryRevealFileUri` split into `parseFileUri` + `revealPath` so a resolved wikilink reveals a path directly instead of round-tripping through a `file://` URI it would immediately re-parse (percent-encoding a path with a space or `#` is exactly where that goes wrong). `PreviewOptions.document_path` threads the source file down; empty disables wikilinks entirely, which is what keeps the store's fetched-README pane from resolving `[[Note]]` against the user's own local files. `markdown.Api.RenderOptions` deliberately untouched. `text` fires the new broadcast from `Document.tickContentChanged` (300ms typing-quiescence debounce keyed on `history.topOpId()` — already changes on exactly the right events, and comparing two integers beats hashing the buffer every frame) plus immediately in `save`, returning "still pending" up through a new `tickOpenDocuments` so the app keeps drawing until the burst settles rather than idling with a notification owed.

Verified: `zig build`, `zig build test`, `zig build test-sdk-version`, `zig build check-web`, `zig build test-integration` clean; markdown's own 16 cmark-backed tests; text's standalone build. **Live on macOS** in an isolated `HOME`/`TMPDIR` sandbox: `pixi`/`zig`/`ghostty` rebuilt against 0.1.49 all load, and a `.md` full of `[[links]]` with **no resolver installed** renders byte-identically to before — every form plain text, code span and fence untouched, ordinary markdown links still live. **Not done:** no resolver plugin exists yet, so the resolved/ambiguous/unresolved render paths are untested against a real provider; `pixi`/`zig`/`ghostty` are pinned to the local SDK path and still need a released `sdk-v0.1.49` tarball plus their own re-release before store installs work. | | R17 — `tags` in `Manifest` + registry-side description/tags dedup | done | 2026-07-30 — closes the gap R16 left for `description`: `tags` couldn't be authored anywhere except a hand-typed `registry/.json` PR in the separate `fizzyedit/plugins` repo, so a plugin with no registry entry yet (or one whose author never filled tags in) had zero search surface for them. **`Manifest`** (`src/sdk/manifest.zig`): `tags: []const []const u8 = &.{}` added, same off-`sdk_boundary_types` treatment as `description` (no fingerprint bump). All 4 built-in `plugin.zig.zon`s got real tags. **Resolution chain** (`PluginStore.tagsFor`, mirrors `descriptionFor` exactly): registry's own → `Editor.builtinTags` (new, mirrors `builtinDescription`) → `PluginLoader.probeTags` (new, mirrors `probeDescription`; returns a caller-owned `[][]u8` via a small `dupeTags` helper, since a manifest's `tags` — unlike `description` — is an array, not a single string) → `tags_cache` (new, same `StringArrayHashMapUnmanaged` shape as `description_cache`, cleared at the same two call sites: `refreshDiskScan` and `deinit`). **`scoreEntry`** now calls `descriptionFor`/`tagsFor` instead of reading `entry.registry.?.{description,tags}` directly, so a built-in or locally-probed dylib's own prose/tags contribute to store search even with no registry entry at all — `author` is the one field left with no fallback, since it was never a `plugin.zig.zon` concept to begin with (attribution, not something a build declares about itself). **No new UI** — tags still have no display surface (chips, filter row) anywhere in the store; this pass is resolution-chain-only, matching what already existed for `description` before R16's header. **Registry-side dedup** (separate repos, coordinated in this pass since the whole point was "don't require authors to hand-duplicate description/tags"): `fizzyedit/plugin-build-action`'s `read_plugin_zon.py` now also reads `description`/`tags` off `plugin.zig.zon`; `build.yml`'s setup job exposes them as job outputs (routed through `env:` rather than direct `${{ }}` interpolation into the assemble-manifest shell step, since these are free-form author-controlled strings — direct interpolation would be a script-injection hole); `assemble_manifest.py` embeds `name`/`description`/`tags` at the top level of the author's `manifest.json` (previously just `{id, releases}`). `fizzyedit/plugins`'s `store/src/manifest.zig` (the *aggregator's* copy of the author-manifest shape, distinct from `sdk/manifest.zig`) gained matching `name`/`description`/`tags` fields; `ingest.zig`'s `upsertPlugin`/`upsertTags` now fall back to the fetched manifest's values when `registry/.json` leaves its own `description`/`tags` empty — registry entry still wins when both are set, so a maintainer can override the store-listed copy without waiting on a plugin release. `docs/manifest.example.json` and both repos' `README.md` updated. **Not done, left for the user:** this is an interface change to `plugin-build-action`'s `build.yml`/`assemble_manifest.py` — existing `release.yml` callers pin `uses: .../build.yml@v3`, and `build.yml`'s own auxiliary-checkout step hardcodes the matching `ref="v3"` literal for its own script checkout, so nothing picks this up until a **new `v4` tag is cut and pushed** (a shared-CI action, deliberately not done automatically) and each external plugin repo (`pixi`/`ghostty`/`zig`/`json`/`markdown`) bumps its own `release.yml` to `@v4`; no `registry/.json` PR was reauthored to drop its now-optional `description`/`tags` either (a per-plugin-author call, not this repo's to make). | --- diff --git a/src/plugins/markdown/build.zig b/src/plugins/markdown/build.zig index d2b27109..71c93807 100644 --- a/src/plugins/markdown/build.zig +++ b/src/plugins/markdown/build.zig @@ -9,6 +9,24 @@ pub fn build(b: *std.Build) void { linkCmark(b, target, optimize, plugin.module); fizzy.plugin.install(b, plugin.lib, .{}); + + // `zig build test` — the escape/source-position logic in `src/md/wikilink_scan.zig`, run + // against the **real vendored cmark**. It can't live in fizzy's own pure-logic test list + // (`build/app.zig`) like `html_images`/`url_join` do: those are std-only by design, and this + // one is a claim about what cmark itself does to backslash escapes, which only cmark can + // confirm. So it tests from here, where cmark is already linked. + const test_step = b.step("test", "Run the markdown plugin's unit tests"); + const scan_tests = b.addTest(.{ + .name = "markdown-wikilink-scan-tests", + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/md/wikilink_scan.zig"), + }), + }); + scan_tests.root_module.addImport("fizzy_sdk", plugin.module.import_table.get("fizzy_sdk").?); + linkCmark(b, target, optimize, scan_tests.root_module); + test_step.dependOn(&b.addRunArtifact(scan_tests).step); } /// Duplicated from `static/integration.zig`'s `linkCmark` — deliberately, not `@import`ed: diff --git a/src/plugins/markdown/plugin.zig b/src/plugins/markdown/plugin.zig index 8bcb3c22..8c10588a 100644 --- a/src/plugins/markdown/plugin.zig +++ b/src/plugins/markdown/plugin.zig @@ -87,6 +87,8 @@ fn previewPane(state: *anyopaque, ext: []const u8, path: []const u8, bytes: []co .io = dvui.io, .id_extra = id_extra, }); + // `drawPreviewForDocument` fills in `document_path` from `path` — that's what enables + // `[[wikilinks]]` here but not in the store's README pane, which has no local file. } fn svcRender(ctx: *anyopaque, bytes: []const u8, gpa: std.mem.Allocator, opts: sdk.services.markdown.Api.RenderOptions) !void { diff --git a/src/plugins/markdown/src/markdown.zig b/src/plugins/markdown/src/markdown.zig index f9d18629..dc8ee51c 100644 --- a/src/plugins/markdown/src/markdown.zig +++ b/src/plugins/markdown/src/markdown.zig @@ -37,13 +37,16 @@ pub const Preview = struct { hasher.update(content); const h = hasher.final(); if (self.content_hash == h and self.ast_root != null) return; + // `scanNode` needs the original source, not just the AST: cmark's text nodes have had + // backslash escapes applied and adjacent runs merged, so `\[\[A]]` is indistinguishable + // from `[[A]]` by then. See `md/wikilink_scan.zig`. md_parse.freeCachedRoot(self.ast_root); self.ast_root = null; self.rs.clear(gpa); self.content_hash = h; if (md_parse.parseMarkdown(content)) |ast| { self.ast_root = @ptrCast(ast.root.n); - _ = render_ast.scanNode(ast.root, &self.rs, gpa); + _ = render_ast.scanNode(ast.root, &self.rs, gpa, content); } } }; @@ -60,6 +63,14 @@ pub const PreviewOptions = struct { image_base_dir: []const u8 = ".", /// Seed for widget ids so multiple previews don't collide. id_extra: u64 = 0, + /// Absolute path of the document being previewed, or `""` when it has none — an unsaved + /// buffer, or markdown fetched from the network (the store's README pane). + /// + /// Distinct from `image_base_dir`, which is a *directory* and may be a URL. This is the file + /// itself, and it's what `[[wikilink]]` resolution is relative to. Leaving it empty disables + /// wikilinks entirely: a fetched README must not resolve `[[Note]]` against the user's own + /// local files, and a link to nowhere is worse than the literal text it was written as. + document_path: []const u8 = "", /// Whether the preview paints fills behind its text at all — both the scroll area's own and /// each text widget's. `false` for a caller (the store's plugin detail page) that already /// draws its own background behind this and wants the preview to read as part of that pane @@ -147,6 +158,7 @@ pub fn drawPreview( .rs = &state.rs, .id_base = @intCast(opts.id_extra << 16), .background = opts.background, + .document_path = opts.document_path, }); } else { dvui.labelNoFmt( @@ -184,5 +196,6 @@ pub fn drawPreviewForDocument( "." else std.fs.path.dirname(document_path) orelse "."; + merged.document_path = document_path; drawPreview(state, bytes, gpa, merged); } diff --git a/src/plugins/markdown/src/md/cmark_parse.zig b/src/plugins/markdown/src/md/cmark_parse.zig index 6c10dc67..936ddc24 100644 --- a/src/plugins/markdown/src/md/cmark_parse.zig +++ b/src/plugins/markdown/src/md/cmark_parse.zig @@ -15,6 +15,11 @@ pub const Node = struct { return .{ .n = ptr }; } + pub fn parent(n: Node) ?Node { + const ptr = c.cmark_node_parent(n.n) orelse return null; + return .{ .n = ptr }; + } + pub fn nextSibling(n: Node) ?Node { const ptr = c.cmark_node_next(n.n) orelse return null; return .{ .n = ptr }; @@ -34,6 +39,23 @@ pub const Node = struct { return std.mem.span(ptr); } + /// Source position, 1-based. Populated for inline nodes unconditionally (`make_literal` in + /// cmark's `inlines.c`) — `CMARK_OPT_SOURCEPOS` only governs whether positions are *emitted* + /// in HTML output, not whether they're tracked. `cmark_consolidate_text_nodes` keeps the + /// first fragment's start and extends `end_column`, so a merged TEXT node still describes + /// the whole run it came from. See `wikilink_scan.zig` for what that's used for. + pub fn startLine(n: Node) i32 { + return c.cmark_node_get_start_line(n.n); + } + + pub fn startColumn(n: Node) i32 { + return c.cmark_node_get_start_column(n.n); + } + + pub fn endColumn(n: Node) i32 { + return c.cmark_node_get_end_column(n.n); + } + pub fn linkUrl(n: Node) ?[:0]const u8 { const ptr = c.cmark_node_get_url(n.n) orelse return null; return std.mem.span(ptr); diff --git a/src/plugins/markdown/src/md/render_ast.zig b/src/plugins/markdown/src/md/render_ast.zig index bbd6c618..7a702ac7 100644 --- a/src/plugins/markdown/src/md/render_ast.zig +++ b/src/plugins/markdown/src/md/render_ast.zig @@ -10,6 +10,24 @@ const net_image = @import("net_image.zig"); const html_images_mod = @import("html_images.zig"); const image_format = @import("image_format.zig"); const url_join = @import("url_join.zig"); +const wikilink_scan = @import("wikilink_scan.zig"); + +const WikilinkApi = sdk.services.wikilink.Api; + +/// Where one `[[wikilink]]` resolved to, memoized per resolver generation. +pub const ResolvedLink = struct { + status: WikilinkApi.Status, + /// Absolute target path, gpa-owned. Empty unless `status` is `.resolved`/`.ambiguous`. + path: []u8 = &.{}, + /// 0-based line to reveal (a `#heading` that was found). + line: u32 = 0, +}; + +/// Memo key for one link: which text node, and which link within it. Node pointers are stable +/// for the life of the AST, and the whole memo is dropped when the AST is rebuilt. +fn wikilinkMemoKey(node: md.Node, token_index: usize) u64 { + return std.hash.Wyhash.hash(@intFromPtr(node.n), std.mem.asBytes(&token_index)); +} const is_windows = builtin.target.os.tag == .windows; @@ -45,6 +63,21 @@ pub const RenderState = struct { /// @intFromPtr(html_node.n) → every `` in that raw-HTML node (src + requested size), in /// document order (gpa-owned). Absent when the node has no ``. html_images: std.AutoHashMapUnmanaged(usize, []html_images_mod.Image) = .empty, + /// @intFromPtr(text_node.n) → the `[[wikilinks]]` in that node's literal, in document order + /// (gpa-owned). Absent when the node has none, which is the common case and the fast path. + /// + /// Content-derived only — token *positions*, never resolution results. Resolution lives in + /// `wikilink_resolved` below and deliberately does not belong here: this map is rebuilt only + /// when the document's content hash changes, but a link flips from broken to resolved when + /// its *target file* is created, which doesn't touch this document at all. + wikilinks: std.AutoHashMapUnmanaged(usize, []wikilink_scan.Token) = .empty, + /// `wikilinkMemoKey(node, token_index)` → where that link resolved to. Valid only while + /// `wikilink_generation` matches the resolver's `generation()`; cleared wholesale when it + /// moves. Owns its paths (gpa). + wikilink_resolved: std.AutoHashMapUnmanaged(u64, ResolvedLink) = .empty, + /// Resolver generation `wikilink_resolved` was populated against. `maxInt` means "nothing + /// memoized yet", which no real generation counter will collide with. + wikilink_generation: u64 = std.math.maxInt(u64), pub fn deinit(self: *RenderState, gpa: std.mem.Allocator) void { self.clear(gpa); @@ -56,6 +89,8 @@ pub const RenderState = struct { self.table_col_counts.deinit(gpa); self.task_items.deinit(gpa); self.html_images.deinit(gpa); + self.wikilinks.deinit(gpa); + self.wikilink_resolved.deinit(gpa); } pub fn clear(self: *RenderState, gpa: std.mem.Allocator) void { @@ -74,6 +109,20 @@ pub const RenderState = struct { var hi = self.html_images.valueIterator(); while (hi.next()) |urls| html_images_mod.free(urls.*, gpa); self.html_images.clearRetainingCapacity(); + var wi = self.wikilinks.valueIterator(); + while (wi.next()) |toks| gpa.free(toks.*); + self.wikilinks.clearRetainingCapacity(); + self.clearResolvedWikilinks(gpa); + } + + /// Drop every memoized resolution. Called when the content changes (`clear`) and when the + /// resolver's generation moves — a new file appearing is exactly the case that has to + /// invalidate a "this link is broken" answer without the document itself changing. + pub fn clearResolvedWikilinks(self: *RenderState, gpa: std.mem.Allocator) void { + var it = self.wikilink_resolved.valueIterator(); + while (it.next()) |r| gpa.free(r.path); + self.wikilink_resolved.clearRetainingCapacity(); + self.wikilink_generation = std.math.maxInt(u64); } }; @@ -109,6 +158,13 @@ pub const RenderContext = struct { /// block's surrounding panel, the HTML-block tint, table header/row banding, and task /// bullets. Those are part of how the element reads, not a background behind the text. background: bool = true, + /// Absolute path of the document being rendered, `""` when it has none. Wikilinks resolve + /// relative to it, and are disabled entirely when it's empty — see `PreviewOptions`. + document_path: []const u8 = "", + /// The `"wikilink"` resolver, looked up once per document draw rather than per link. + /// Null whenever wikilinks are off: no resolver plugin installed, or no `document_path`. + /// When null, `[[Note]]` renders as the literal text it always was. + wikilink: ?*WikilinkApi = null, }; /// Top/bottom margin every paragraph's `textLayout` carries. List markers match the top half so @@ -135,9 +191,30 @@ inline fn hasImageSubtree(ctx: RenderContext, n: md.Node) bool { // AST pre-scan (called once after parsing, results stored in State) // --------------------------------------------------------------------------- -/// Walk the AST once, populating rs.ext_node_kinds and rs.subtree_has_image. +/// Original markdown source, for the one thing the AST can't answer on its own — see +/// `wikilink_scan.zig`. Built once per parse rather than per node. +const ScanSource = struct { + bytes: []const u8, + index: ?wikilink_scan.LineIndex, + + fn spanFor(self: ScanSource, node: md.Node) ?[]const u8 { + const index = self.index orelse return null; + return wikilink_scan.sourceSpanFor(self.bytes, index, node); + } +}; + +/// Walk the AST once, populating rs.ext_node_kinds, rs.subtree_has_image, and rs.wikilinks. /// Returns true when any node in the subtree rooted at `node` is an IMAGE. -pub fn scanNode(node: md.Node, rs: *RenderState, gpa: std.mem.Allocator) bool { +/// +/// `source` is the markdown these nodes were parsed from. +pub fn scanNode(node: md.Node, rs: *RenderState, gpa: std.mem.Allocator, source: []const u8) bool { + // A failed line index only costs escape detection, so scanning continues without it. + var index: ?wikilink_scan.LineIndex = wikilink_scan.LineIndex.build(gpa, source) catch null; + defer if (index) |*i| i.deinit(gpa); + return scanNodeInner(node, rs, gpa, .{ .bytes = source, .index = index }); +} + +fn scanNodeInner(node: md.Node, rs: *RenderState, gpa: std.mem.Allocator, source: ScanSource) bool { const ts = node.typeString(); if (std.mem.eql(u8, ts, "table")) { rs.ext_node_kinds.put(gpa, @intFromPtr(node.n), .table) catch {}; @@ -167,6 +244,22 @@ pub fn scanNode(node: md.Node, rs: *RenderState, gpa: std.mem.Allocator) bool { if (node.nodeType() == md.c.CMARK_NODE_ITEM and node.isTaskListItem()) rs.task_items.put(gpa, @intFromPtr(node.n), node.taskListItemChecked()) catch {}; + // `[[wikilinks]]`. Only TEXT nodes: inline code (CMARK_NODE_CODE), fenced/indented code + // blocks, and raw HTML all have their own node types and never reach here, so "don't link + // inside code" needs no work. Link *labels* do — `[see [[A]]](http://x)` puts that text + // under a LINK parent, and turning part of a link's own label into a second link is not a + // thing a `TextLayoutWidget` can express. + if (node.nodeType() == md.c.CMARK_NODE_TEXT and !insideLinkOrImage(node)) { + if (node.literal()) |t| { + if (wikilink_scan.tokensFor(gpa, t, source.spanFor(node))) |toks| { + if (toks.len > 0) + rs.wikilinks.put(gpa, @intFromPtr(node.n), toks) catch gpa.free(toks) + else + gpa.free(toks); + } else |_| {} + } + } + var self_has_image = (node.nodeType() == md.c.CMARK_NODE_IMAGE); // Raw HTML: GitHub READMEs routinely wrap their hero image in `

`, @@ -185,7 +278,7 @@ pub fn scanNode(node: md.Node, rs: *RenderState, gpa: std.mem.Allocator) bool { var child = node.firstChild(); while (child) |ch| : (child = ch.nextSibling()) { - if (scanNode(ch, rs, gpa)) self_has_image = true; + if (scanNodeInner(ch, rs, gpa, source)) self_has_image = true; } if (self_has_image) rs.subtree_has_image.put(gpa, @intFromPtr(node.n), {}) catch {}; @@ -193,6 +286,19 @@ pub fn scanNode(node: md.Node, rs: *RenderState, gpa: std.mem.Allocator) bool { return self_has_image; } +/// True when `node` sits inside a markdown link or image, where its text is a label rather than +/// body prose. Walks parents once per parse, never per frame. +fn insideLinkOrImage(node: md.Node) bool { + var p = node.parent(); + while (p) |parent| : (p = parent.parent()) { + switch (parent.nodeType()) { + md.c.CMARK_NODE_LINK, md.c.CMARK_NODE_IMAGE => return true, + else => {}, + } + } + return false; +} + // --------------------------------------------------------------------------- // Image preloading (keep GPU textures warm every frame, even when pane is closed) // --------------------------------------------------------------------------- @@ -364,19 +470,33 @@ fn openMarkdownUrl(url: []const u8, open_side: bool) void { /// Opens a `file://` URI (optionally with a `#L` / `#LC` fragment) in the editor. /// Returns false when the URL isn't a file URI or workbench isn't available. fn tryRevealFileUri(url: []const u8, open_side: bool) bool { - const wb = sdk.host().getServiceTyped(sdk.services.workbench.Api) orelse return false; const arena = dvui.currentWindow().arena(); const parsed = parseFileUri(arena, url) orelse return false; // zls (and VS Code-style `#L` fragments) are 1-based; workbench is 0-based. const line: u32 = if (parsed.line_1based > 0) parsed.line_1based - 1 else 0; const character: u32 = if (parsed.character_1based > 0) parsed.character_1based - 1 else 0; - _ = wb.revealPosition(parsed.path, line, character, open_side) catch |err| { - dvui.log.err("markdown: revealPosition failed for {s}: {any}", .{ parsed.path, err }); - return true; // still a file URI — don't fall through to openURL - }; + // Reaching workbench is what actually opens it, but a `file://` URL is *ours* either way — + // returning true even when that fails keeps a broken editor link from being handed to the + // system browser. + _ = revealPath(parsed.path, line, character, open_side); return true; } +/// Opens `path` (native, absolute) in the editor at a 0-based `line`/`character`, splitting to +/// the side when `open_side`. Returns false when workbench isn't available or refused. +/// +/// Split out of `tryRevealFileUri` so a caller that already *has* a path — a resolved wikilink — +/// doesn't have to encode it into a `file://` URI just to have it decoded straight back. That +/// round trip isn't merely wasteful: it has to percent-encode, and a path containing a space or +/// a `#` is exactly where a hand-rolled encoder goes wrong. +fn revealPath(path: []const u8, line: u32, character: u32, open_side: bool) bool { + const wb = sdk.host().getServiceTyped(sdk.services.workbench.Api) orelse return false; + return wb.revealPosition(path, line, character, open_side) catch |err| { + dvui.log.err("markdown: revealPosition failed for {s}: {any}", .{ path, err }); + return false; + }; +} + const ParsedFileUri = struct { path: []const u8, line_1based: u32 = 0, @@ -880,10 +1000,78 @@ fn renderInlines(tl: *dvui.TextLayoutWidget, n: md.Node, span: dvui.Options, ctx } } +/// A run of body text, with any `[[wikilinks]]` in it drawn as links. +/// +/// The no-wikilinks path — no resolver, or none in this node — must produce **exactly** what +/// this used to: one `addText` of the whole literal, brackets and all. That's not just an +/// optimization, it's the contract that markdown renders identically with no indexer plugin +/// installed, and it's why the fast path is a single hash miss. +fn renderTextWithWikilinks( + tl: *dvui.TextLayoutWidget, + node: md.Node, + literal: []const u8, + span: dvui.Options, + ctx: RenderContext, +) void { + const plain: dvui.Options = .{ .font = span.font, .color_text = span.color_text }; + if (ctx.wikilink == null) return tl.addText(literal, plain); + const tokens = ctx.rs.wikilinks.get(@intFromPtr(node.n)) orelse return tl.addText(literal, plain); + + var cursor: usize = 0; + for (tokens, 0..) |tok, i| { + if (tok.start > cursor) tl.addText(literal[cursor..tok.start], plain); + renderWikilink(tl, node, i, tok, span, ctx); + cursor = tok.end; + } + if (cursor < literal.len) tl.addText(literal[cursor..], plain); +} + +fn renderWikilink( + tl: *dvui.TextLayoutWidget, + node: md.Node, + token_index: usize, + tok: wikilink_scan.Token, + span: dvui.Options, + ctx: RenderContext, +) void { + const theme = dvui.themeGet(); + const label = tok.label(); + const res = resolveWikilink(ctx, node, token_index, tok); + + switch (res.status) { + // Still scanning. Deliberately unstyled: painting every link red for the second after a + // folder opens, then flipping them all blue, is worse than showing nothing at all. + .indexing => tl.addText(label, .{ .font = span.font, .color_text = span.color_text }), + + .resolved, .ambiguous => { + const color = if (res.status == .ambiguous) theme.color(.err, .fill) else theme.focus; + const opts = span.override(.{ + .font = span.fontGet().withUnderline(.{}), + .color_text = color, + }); + if (tl.addTextClick(label, opts)) |click| { + const open_side = click == .mouse and + (click.mouse.button == .middle or click.mouse.mod.matchBind("ctrl/cmd")); + _ = revealPath(res.path, res.line, 0, open_side); + } + }, + + // Nothing to open — but a link to a note you haven't written yet is a completely normal + // thing to have in a wiki, not an error. So: still visibly a link, just unfinished — a + // hairline underline and dimmed text, rather than the error red a broken URL would get. + // (dvui's `Underline` carries thickness only, no dash style, so weight is what's + // available to say "provisional" with.) Inert until there's a create-note flow. + .unresolved => tl.addText(label, .{ + .font = span.fontGet().withUnderline(.{ .thick = 0.04 }), + .color_text = (span.color_text orelse theme.color(.content, .text)).opacity(0.6), + }), + } +} + fn renderInlineNodeToTl(tl: *dvui.TextLayoutWidget, x: md.Node, span: dvui.Options, ctx: RenderContext, ids: *IdGen) void { switch (x.nodeType()) { md.c.CMARK_NODE_TEXT => { - if (x.literal()) |t| tl.addText(t, .{ .font = span.font, .color_text = span.color_text }); + if (x.literal()) |t| renderTextWithWikilinks(tl, x, t, span, ctx); }, md.c.CMARK_NODE_SOFTBREAK => { tl.addText(" ", .{}); @@ -1311,6 +1499,62 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { } pub fn renderDocument(root: md.Node, ctx: RenderContext) void { + var resolved_ctx = ctx; + resolved_ctx.wikilink = wikilinkResolver(ctx); + var ids: IdGen = .{ .n = ctx.id_base }; - renderBlock(root, &ids, ctx); + renderBlock(root, &ids, resolved_ctx); +} + +/// The wikilink resolver to use for this document draw, or null when wikilinks are off. +/// +/// Also the point where a stale resolution memo is dropped: the resolver bumps `generation()` +/// on every committed index change, and a link that was broken a moment ago becomes live the +/// instant its target file exists — with no edit to *this* document, so nothing else in the +/// pipeline would notice. +fn wikilinkResolver(ctx: RenderContext) ?*WikilinkApi { + // A document with no path on disk has nothing to resolve *relative to*, and — for the + // store's README pane — is remote content that must not reach into the user's own files. + if (ctx.document_path.len == 0) return null; + const api = sdk.host().getServiceTyped(WikilinkApi) orelse return null; + + const gen = api.generation(); + if (ctx.rs.wikilink_generation != gen) { + ctx.rs.clearResolvedWikilinks(ctx.gpa); + ctx.rs.wikilink_generation = gen; + } + return api; +} + +/// Resolve one link, memoized against the resolver generation. Called every frame for every +/// visible link, so the steady-state path must be the hash lookup and nothing more. +fn resolveWikilink( + ctx: RenderContext, + node: md.Node, + token_index: usize, + tok: wikilink_scan.Token, +) ResolvedLink { + const api = ctx.wikilink orelse return .{ .status = .unresolved }; + const key = wikilinkMemoKey(node, token_index); + if (ctx.rs.wikilink_resolved.get(key)) |hit| return hit; + + const res = api.resolve(tok.target, tok.heading, ctx.document_path, ctx.gpa) catch + return .{ .status = .unresolved }; + // `resolve` allocates from the allocator we hand it, and we hand it the persistent one so + // the memo can outlive the frame. `title` is not kept — nothing renders it yet, and holding + // it would mean freeing two strings per entry instead of one. + ctx.gpa.free(res.title); + + const entry: ResolvedLink = .{ + .status = res.status, + .path = @constCast(res.path), + .line = res.line, + }; + ctx.rs.wikilink_resolved.put(ctx.gpa, key, entry) catch { + // Out of memory for the memo only — the answer is still good for this frame, it just + // costs a resolve again next frame. + ctx.gpa.free(res.path); + return .{ .status = res.status, .line = res.line }; + }; + return entry; } diff --git a/src/plugins/markdown/src/md/wikilink_scan.zig b/src/plugins/markdown/src/md/wikilink_scan.zig new file mode 100644 index 00000000..b4565b2a --- /dev/null +++ b/src/plugins/markdown/src/md/wikilink_scan.zig @@ -0,0 +1,289 @@ +//! Finding `[[wikilinks]]` in a parsed cmark AST — specifically, the part the SDK's pure +//! tokenizer cannot do alone: telling `[[A]]` apart from `\[\[A]]`. +//! +//! **Why this file exists.** `cmark_parser_finish` ends with `cmark_consolidate_text_nodes` +//! (`blocks.c`), which merges every run of adjacent `CMARK_NODE_TEXT` siblings into one node by +//! concatenating their literals. A backslash escape is parsed by `handle_backslash` into its own +//! little text node holding just the escaped character, so after consolidation `\[\[A]]` and +//! `[[A]]` produce **the same literal** — `[[A]]`. Tokenizing the literal therefore turns a +//! deliberately escaped link into a real one, and there is no way to tell from the AST alone. +//! +//! What survives is position: `make_literal` in `inlines.c` sets `start_line`/`start_column` +//! unconditionally (no `CMARK_OPT_SOURCEPOS` needed), and consolidation keeps the first +//! fragment's start while extending `end_column`. So a consolidated text node still knows the +//! source span it came from, and the original bytes — backslashes included — can be read back +//! out of the document. +//! +//! **The rule.** Re-apply cmark's own escape handling to that source span, producing the bytes +//! it would have yielded plus a flag per byte for "this came from an escape". When those bytes +//! match the node's literal exactly, the flags line up with it positionally and a link whose +//! opening brackets are flagged is dropped. When they *don't* match — smart punctuation +//! (`CMARK_OPT_SMART` is on) rewrote a quote, or an HTML entity expanded — the mapping is +//! untrustworthy and we **fail open**: the link renders. A link that renders when the author +//! wanted literal text is a visible, correctable annoyance; a link that silently vanishes is a +//! bug someone spends an afternoon on. +const std = @import("std"); +const md = @import("cmark_parse.zig"); +const wikilink = @import("fizzy_sdk").services.wikilink; + +pub const Token = wikilink.Token; + +/// Byte offset of the start of each 1-based source line. Built once per parse (`scanNode`), +/// not per node — locating a node's span is then two array reads. +pub const LineIndex = struct { + /// `starts[i]` is the offset of line `i + 1`. Always begins with 0. + starts: []const u32, + + pub fn build(gpa: std.mem.Allocator, source: []const u8) !LineIndex { + var starts: std.ArrayList(u32) = .empty; + errdefer starts.deinit(gpa); + try starts.append(gpa, 0); + for (source, 0..) |b, i| { + if (b == '\n') try starts.append(gpa, @intCast(i + 1)); + } + return .{ .starts = try starts.toOwnedSlice(gpa) }; + } + + pub fn deinit(self: *LineIndex, gpa: std.mem.Allocator) void { + gpa.free(self.starts); + self.* = .{ .starts = &.{} }; + } + + /// Source bytes for one line, without its newline. + pub fn line(self: LineIndex, source: []const u8, line_1based: u32) ?[]const u8 { + if (line_1based == 0 or line_1based > self.starts.len) return null; + const start = self.starts[line_1based - 1]; + if (start > source.len) return null; + const end = if (line_1based < self.starts.len) + @max(start, self.starts[line_1based] -| 1) + else + source.len; + return source[start..@min(end, source.len)]; + } +}; + +/// Raw source bytes a consolidated TEXT node came from, or null when its recorded span doesn't +/// fit the document (a node cmark synthesized rather than read, say). +/// +/// Inline nodes never span lines — a line break becomes its own SOFTBREAK/LINEBREAK node — so +/// this only ever needs `start_line`. +pub fn sourceSpanFor(source: []const u8, index: LineIndex, node: md.Node) ?[]const u8 { + const start_line = node.startLine(); + const start_col = node.startColumn(); + const end_col = node.endColumn(); + if (start_line <= 0 or start_col <= 0 or end_col < start_col) return null; + + const text = index.line(source, @intCast(start_line)) orelse return null; + const from: usize = @intCast(start_col - 1); + const to: usize = @intCast(end_col); + if (from > text.len or to > text.len) return null; + return text[from..to]; +} + +/// Bytes `span` would produce after cmark's backslash-escape handling, and a parallel flag per +/// byte marking the ones that came from an escape. +const Unescaped = struct { + bytes: []u8, + escaped: []bool, + + fn deinit(self: *Unescaped, gpa: std.mem.Allocator) void { + gpa.free(self.bytes); + gpa.free(self.escaped); + } +}; + +/// Mirrors `handle_backslash` in cmark's `inlines.c`: a backslash before ASCII punctuation +/// yields that punctuation literally; anything else keeps the backslash as-is. +fn unescape(gpa: std.mem.Allocator, span: []const u8) !Unescaped { + var bytes: std.ArrayList(u8) = .empty; + errdefer bytes.deinit(gpa); + var escaped: std.ArrayList(bool) = .empty; + errdefer escaped.deinit(gpa); + + var i: usize = 0; + while (i < span.len) { + if (span[i] == '\\' and i + 1 < span.len and isCmarkPunct(span[i + 1])) { + try bytes.append(gpa, span[i + 1]); + try escaped.append(gpa, true); + i += 2; + } else { + try bytes.append(gpa, span[i]); + try escaped.append(gpa, false); + i += 1; + } + } + return .{ + .bytes = try bytes.toOwnedSlice(gpa), + .escaped = try escaped.toOwnedSlice(gpa), + }; +} + +/// `cmark_ispunct` — ASCII punctuation only, which is exactly the escapable set. +fn isCmarkPunct(c: u8) bool { + return switch (c) { + '!'...'/', ':'...'@', '['...'`', '{'...'~' => true, + else => false, + }; +} + +/// Wikilinks in one consolidated TEXT node's `literal`, with backslash-escaped ones removed. +/// +/// `source_span` is that node's original bytes (from `sourceSpanFor`); pass null when they can't +/// be located, which disables escape detection rather than dropping links. Offsets in the +/// returned tokens index `literal`, so the caller can slice display text straight out of it. +/// +/// Returns an owned slice, empty when there are no links. +pub fn tokensFor( + gpa: std.mem.Allocator, + literal: []const u8, + source_span: ?[]const u8, +) ![]Token { + const tokens = try wikilink.tokenizeAlloc(gpa, literal); + if (tokens.len == 0) return tokens; + errdefer gpa.free(tokens); + + const span = source_span orelse return tokens; + // The overwhelmingly common case: no backslash anywhere in this run of text, so nothing + // can have been escaped and the literal is the source. Costs one memchr. + if (std.mem.indexOfScalar(u8, span, '\\') == null) return tokens; + + var un = try unescape(gpa, span); + defer un.deinit(gpa); + + // Fail open on any drift between what we reconstructed and what cmark actually produced + // (smart punctuation, entity expansion) — the flags would no longer line up positionally. + if (!std.mem.eql(u8, un.bytes, literal)) return tokens; + + var kept: usize = 0; + for (tokens) |tok| { + // `start` points at `!` for an embed; the brackets follow it. + const open = if (tok.embed) tok.start + 1 else tok.start; + if (open + 1 < un.escaped.len and (un.escaped[open] or un.escaped[open + 1])) continue; + tokens[kept] = tok; + kept += 1; + } + if (kept == tokens.len) return tokens; + return gpa.realloc(tokens, kept) catch tokens[0..kept]; +} + +// -- tests ------------------------------------------------------------------------------ +// +// These run the **real vendored cmark**, not a stand-in. The whole point of this file is a +// claim about what cmark does to escapes and source positions, and only cmark can confirm it. + +const testing = std.testing; + +/// Parse `src`, walk every TEXT node, and collect the wikilinks `tokensFor` finds in it. +fn linksIn(gpa: std.mem.Allocator, src: []const u8, out: *std.ArrayList([]const u8)) !void { + const ast = md.parseMarkdown(src) orelse return error.ParseFailed; + var index = try LineIndex.build(gpa, src); + defer index.deinit(gpa); + try walk(gpa, ast.root, src, index, out); +} + +fn walk( + gpa: std.mem.Allocator, + node: md.Node, + src: []const u8, + index: LineIndex, + out: *std.ArrayList([]const u8), +) !void { + if (node.nodeType() == md.c.CMARK_NODE_TEXT) { + if (node.literal()) |lit| { + const toks = try tokensFor(gpa, lit, sourceSpanFor(src, index, node)); + defer gpa.free(toks); + for (toks) |t| try out.append(gpa, try gpa.dupe(u8, t.target)); + } + } + var child = node.firstChild(); + while (child) |c| : (child = c.nextSibling()) try walk(gpa, c, src, index, out); +} + +fn expectTargets(src: []const u8, expected: []const []const u8) !void { + const gpa = testing.allocator; + var found: std.ArrayList([]const u8) = .empty; + defer { + for (found.items) |s| gpa.free(s); + found.deinit(gpa); + } + try linksIn(gpa, src, &found); + + testing.expectEqual(expected.len, found.items.len) catch |err| { + std.debug.print("source: {s}\nfound:", .{src}); + for (found.items) |s| std.debug.print(" [[{s}]]", .{s}); + std.debug.print("\n", .{}); + return err; + }; + for (expected, found.items) |want, got| try testing.expectEqualStrings(want, got); +} + +test "a plain wikilink is found" { + try expectTargets("See [[Note]] here.\n", &.{"Note"}); +} + +test "several wikilinks in one paragraph" { + try expectTargets("[[A]] and [[B]] and [[C]]\n", &.{ "A", "B", "C" }); +} + +test "escaped brackets are not a wikilink" { + // The regression this whole file exists for. cmark consolidates the escape into the + // surrounding text, so the literal here is indistinguishable from a real link. + try expectTargets("\\[\\[Note]] is how you write a link.\n", &.{}); +} + +test "escaping only the first bracket is enough" { + try expectTargets("\\[[Note]]\n", &.{}); +} + +test "an escape elsewhere in the line does not suppress a real link" { + try expectTargets("\\*not emphasis\\* but [[Note]] is a link\n", &.{"Note"}); +} + +test "inline code is never a wikilink" { + // Not handled here at all — cmark gives inline code its own CMARK_NODE_CODE node, so it + // never reaches a TEXT node. This test pins that assumption. + try expectTargets("Write `[[Note]]` to link.\n", &.{}); +} + +test "fenced code is never a wikilink" { + try expectTargets("```\n[[Note]]\n```\n", &.{}); +} + +test "indented code is never a wikilink" { + try expectTargets(" [[Note]]\n", &.{}); +} + +test "a wikilink inside emphasis is still found" { + try expectTargets("*see [[Note]]*\n", &.{"Note"}); +} + +test "wikilinks survive inside a list item and a blockquote" { + try expectTargets("- [[A]]\n\n> [[B]]\n", &.{ "A", "B" }); +} + +test "alias and heading forms round-trip through the parser" { + try expectTargets("[[A|shown]] and [[B#Heading]]\n", &.{ "A", "B" }); +} + +test "an embed is found" { + try expectTargets("![[Note]]\n", &.{"Note"}); +} + +test "an escaped embed is not" { + try expectTargets("!\\[\\[Note]]\n", &.{}); +} + +test "smart punctuation next to an escape fails open rather than dropping the link" { + // CMARK_OPT_SMART rewrites the quotes, so the reconstructed bytes can't match the literal + // and escape detection is skipped. The documented, deliberate outcome is that the link + // renders — not that it disappears. + try expectTargets("\"quoted\" \\* [[Note]]\n", &.{"Note"}); +} + +test "a wikilink spanning a line break is not a link" { + try expectTargets("[[A\nB]]\n", &.{}); +} + +test "unclosed brackets are not a link" { + try expectTargets("[[A and then nothing\n", &.{}); +} diff --git a/src/plugins/text/plugin.zig b/src/plugins/text/plugin.zig index c39b8e59..44fd4bc4 100644 --- a/src/plugins/text/plugin.zig +++ b/src/plugins/text/plugin.zig @@ -59,6 +59,7 @@ const vtable: sdk.Plugin.VTable = .{ .documentHasNativeExtension = documentHasNativeExtension, .documentHasRecognizedSaveExtension = documentHasRecognizedSaveExtension, // rendering + lifecycle + .tickOpenDocuments = tickOpenDocuments, .drawDocument = drawDocument, .closeDocument = closeDocument, .reloadDocument = reloadDocument, @@ -306,6 +307,18 @@ fn reloadDocument(_: *anyopaque, handle: DocHandle) anyerror!void { fn isDirty(_: *anyopaque, handle: DocHandle) bool { return (docFrom(handle) orelse return false).isDirty(); } + +/// Drive each open document's content-change debounce. Returns true while any of them still +/// owes a notification, so fizzy keeps drawing until the burst settles instead of idling with +/// one pending. +fn tickOpenDocuments(state: *anyopaque) bool { + const st: *State = @ptrCast(@alignCast(state)); + var pending = false; + for (st.docs.values()) |doc| { + if (doc.tickContentChanged()) pending = true; + } + return pending; +} fn saveDocument(state: *anyopaque, handle: DocHandle) anyerror!void { const doc = docFrom(handle) orelse return; const st: *State = @ptrCast(@alignCast(state)); diff --git a/src/plugins/text/src/Document.zig b/src/plugins/text/src/Document.zig index 7b375262..869c234d 100644 --- a/src/plugins/text/src/Document.zig +++ b/src/plugins/text/src/Document.zig @@ -5,6 +5,7 @@ const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); const sdk = @import("fizzy_sdk"); +const perf = @import("core").perf; const tc = @import("textcore/textcore.zig"); const TextEntryWidget = @import("widgets/TextEntryWidget.zig"); @@ -123,6 +124,22 @@ history: tc.History = .{}, /// edit gets a fresh id that never collides with the one recorded at save time. clean_op_id: u64 = 0, +/// Debounce state for `Host.notifyDocumentContentChanged` — see `tickContentChanged`. +/// +/// Keyed on `history.topOpId()` rather than a hash of the text: the id already changes on +/// exactly the events we care about (any genuinely new edit) and comparing two integers costs +/// nothing per frame, whereas hashing a large file every frame to find out it didn't change is +/// the sort of thing that quietly eats a millisecond on every keystroke. +notify_seen_op_id: u64 = 0, +notify_sent_op_id: u64 = 0, +/// `perf.nanoTimestamp()` after which the current burst counts as settled. +notify_due_ns: i128 = 0, + +/// How long the text has to stop changing before observers hear about it. Long enough that +/// ordinary typing produces one notification per pause rather than per character, short enough +/// that it feels immediate when you stop. +const notify_debounce_ns: i128 = 300 * std.time.ns_per_ms; + /// 64 MiB — generous for source files; guards against opening something huge by mistake. const max_file_bytes: usize = 64 * 1024 * 1024; @@ -243,6 +260,37 @@ pub fn isDirty(self: *const Document) bool { return self.history.topOpId() != self.clean_op_id; } +/// Broadcast this document's live contents to every plugin, now. +/// +/// The text plugin owns `.md` (and everything else nothing claimed), so a plugin that indexes +/// markdown links, counts words, or previews structure can only see unsaved text if we hand it +/// over — nothing in the SDK exposes another plugin's buffer. +pub fn notifyContentChanged(self: *Document) void { + self.notify_seen_op_id = self.history.topOpId(); + self.notify_sent_op_id = self.notify_seen_op_id; + sdk.host().notifyDocumentContentChanged(self.path, self.text.items); +} + +/// Per-frame half of the debounce. Returns true while a notification is still pending, which +/// the caller passes up through `tickOpenDocuments` to keep frames coming — otherwise the app +/// idles the moment you stop typing and the pending notification waits for whatever happens to +/// wake it next. +pub fn tickContentChanged(self: *Document) bool { + const top = self.history.topOpId(); + if (top != self.notify_seen_op_id) { + // Still changing — restart the clock. A held key or a paste storm therefore produces + // one notification at the end, not one per event. + self.notify_seen_op_id = top; + self.notify_due_ns = perf.nanoTimestamp() + notify_debounce_ns; + return true; + } + if (self.notify_sent_op_id == top) return false; + if (perf.nanoTimestamp() < self.notify_due_ns) return true; + + self.notifyContentChanged(); + return false; +} + /// Write the current contents back to `path`. pub fn save(self: *Document) !void { if (comptime is_wasm) return error.Unsupported; @@ -253,6 +301,10 @@ pub fn save(self: *Document) !void { // same reason. self.history.closeGroup(); self.clean_op_id = self.history.topOpId(); + // Immediately, not on the debounce: an observer that also watches the filesystem is about + // to see this write land, and it should have our version of the contents first so it can + // recognize the on-disk change as already accounted for. + self.notifyContentChanged(); } /// Replace in-memory contents from disk and clear undo history (external change / discard). From bf2be04829c2cde7ba7c5124a32fdd376c09c209 Mon Sep 17 00:00:00 2001 From: foxnne Date: Tue, 4 Aug 2026 09:55:25 -0500 Subject: [PATCH 04/10] Draw sidebar icons both fill and stroke color --- src/editor/Sidebar.zig | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/editor/Sidebar.zig b/src/editor/Sidebar.zig index 4ab24b59..7ec4fc9b 100644 --- a/src/editor/Sidebar.zig +++ b/src/editor/Sidebar.zig @@ -121,11 +121,14 @@ fn drawOption(view: *const SidebarView, index: usize, size: f32) !Action { const color: dvui.Color = if (selected) theme.color(.highlight, .fill) else if (bw.hovered()) theme.color(.window, .text) else theme.color(.window, .fill); + // Apply both fill and stroke: Entypo glyphs are fill-based, Lucide (and most + // plugin icons) are stroke-based. Setting only one leaves the other at DVUI's + // default white — which is how a stroke icon looks "full white" in the rail. dvui.icon( @src(), view.id, view.icon, - .{ .fill_color = color }, + .{ .fill_color = color, .stroke_color = color }, .{ .id_extra = index, .min_size_content = .{ .h = size }, From 5b0b07a55fb9975ee3d3835b01bf9c312220fb77 Mon Sep 17 00:00:00 2001 From: foxnne Date: Tue, 4 Aug 2026 11:30:10 -0500 Subject: [PATCH 05/10] fix files tab performance --- src/plugins/workbench/src/files.zig | 55 +++++++++++++++-------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig index b96cd16a..b77ea00c 100644 --- a/src/plugins/workbench/src/files.zig +++ b/src/plugins/workbench/src/files.zig @@ -791,34 +791,32 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u } const current_point = dvui.currentWindow().mouse_pt; + const rect = branch.data().borderRectScale().r; + const max_distance = if (!expanded) rect.h * 3.0 else rect.w / 8.0; - const max_distance = if (!expanded) branch.data().borderRectScale().r.h * 3.0 else branch.data().borderRectScale().r.w / 8.0; + // Quick bounds check: skip expensive distance calculation if mouse is far + var dx: f32 = 0; + var dy: f32 = 0; - var dx: f32 = std.math.floatMax(f32); - - if (current_point.x < branch.data().borderRectScale().r.x + if (expanded) (expanded_indent * dvui.currentWindow().natural_scale) else 0.0) { - dx = std.math.floatMax(f32); - } else if (current_point.x > branch.data().borderRectScale().r.bottomRight().x) { - dx = @abs(current_point.x - branch.data().borderRectScale().r.bottomRight().x); - } else { - dx = 0.0; + if (current_point.x < rect.x + if (expanded) (expanded_indent * dvui.currentWindow().natural_scale) else 0.0) { + dx = @abs(current_point.x - (rect.x + if (expanded) (expanded_indent * dvui.currentWindow().natural_scale) else 0.0)); + } else if (current_point.x > rect.bottomRight().x) { + dx = @abs(current_point.x - rect.bottomRight().x); } - var dy: f32 = std.math.floatMax(f32); - - if (current_point.y < branch.data().borderRectScale().r.y) { - dy = @abs(current_point.y - branch.data().borderRectScale().r.y); - } else if (current_point.y > branch.data().borderRectScale().r.bottomRight().y) { - dy = @abs(current_point.y - branch.data().borderRectScale().r.bottomRight().y); - } else { - dy = 0.0; + if (current_point.y < rect.y) { + dy = @abs(current_point.y - rect.y); + } else if (current_point.y > rect.bottomRight().y) { + dy = @abs(current_point.y - rect.bottomRight().y); } - const distance = @sqrt(dx * dx + dy * dy); - - const t = 1.0 - (distance / max_distance); - - color = dvui.themeGet().color(.window, .fill).lerp(color, t); + // Only compute expensive distance if we're in range (Chebyshev approximation) + const chebyshev = @max(dx, dy); + if (chebyshev < max_distance) { + const distance = @sqrt(dx * dx + dy * dy); + const t = 1.0 - (distance / max_distance); + color = dvui.themeGet().color(.window, .fill).lerp(color, t); + } if (branch.floating()) { if (dvui.dataGetSlice(null, inner_unique_id, "removed_path", []u8) == null) @@ -1010,10 +1008,13 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u } } + const doc = runtime.host().docFromPath(abs_path); + const file_label = if (filter_text.len > 0) std.fs.path.relativePosix(dvui.currentWindow().arena(), ".", runtime.host().folder().?, abs_path) catch entry.name else entry.name; + editableLabel( inner_id_extra.*, - if (filter_text.len > 0) std.fs.path.relativePosix(dvui.currentWindow().arena(), ".", runtime.host().folder().?, abs_path) catch entry.name else entry.name, - if (runtime.host().docFromPath(abs_path) != null) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text), + file_label, + if (doc != null) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text), entry.kind, abs_path, active_query, @@ -1021,8 +1022,8 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u dvui.log.err("Failed to draw editable label", .{}); }; - if (runtime.host().docFromPath(abs_path)) |doc| { - if (doc.owner.showsSaveStatusIndicator(doc)) { + if (doc) |d| { + if (d.owner.showsSaveStatusIndicator(d)) { wdvui.bubbleSpinner(@src(), .{ .id_extra = inner_id_extra.* +% 4001, .expand = .none, @@ -1031,7 +1032,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u .gravity_y = 0.5, .color_text = dvui.themeGet().color(.window, .text), }, .{ - .complete_elapsed_ns = doc.owner.timeSinceSaveCompleteNs(doc), + .complete_elapsed_ns = d.owner.timeSinceSaveCompleteNs(d), }); } } From 4fdb568fa78c873d4ac54e4efdc9e382983792a0 Mon Sep 17 00:00:00 2001 From: foxnne Date: Tue, 4 Aug 2026 13:14:00 -0500 Subject: [PATCH 06/10] markdown links --- src/plugins/markdown/src/md/render_ast.zig | 95 +++++++++++++++++++--- 1 file changed, 85 insertions(+), 10 deletions(-) diff --git a/src/plugins/markdown/src/md/render_ast.zig b/src/plugins/markdown/src/md/render_ast.zig index 7a702ac7..125068b0 100644 --- a/src/plugins/markdown/src/md/render_ast.zig +++ b/src/plugins/markdown/src/md/render_ast.zig @@ -447,26 +447,101 @@ fn resolveImageBytes(ctx: RenderContext, arena: std.mem.Allocator, raw_url: []co return .{ .bytes = fresh }; } -/// Clickable markdown hyperlink. `file://` URLs (including zls hover's `file:///path#L12` -/// form) open in the editor via workbench `revealPosition`; everything else falls through to -/// `dvui.openURL`. Middle-click / Ctrl/Cmd+click requests a side split for file targets (and -/// a new browser window for http(s)), matching `TextLayoutWidget.addLink`. -fn addMarkdownLink(tl: *dvui.TextLayoutWidget, url: []const u8, text: ?[]const u8, opts: dvui.Options) void { +/// Clickable markdown hyperlink. Resolution order: +/// 1. `file://` URIs (including zls hover's `file:///path#L12`) → editor +/// 2. Scheme-less relative/absolute paths against the document directory → editor +/// (brain's `[Title](../note.md)` inserts, and ordinary in-vault markdown links) +/// 3. Everything else → `dvui.openURL` (http(s), mailto, …) +/// +/// Middle-click / Ctrl/Cmd+click requests a side split for file targets (and a new browser +/// window for http(s)), matching `TextLayoutWidget.addLink`. +fn addMarkdownLink( + tl: *dvui.TextLayoutWidget, + url: []const u8, + text: ?[]const u8, + opts: dvui.Options, + ctx: RenderContext, +) void { const defs: dvui.Options = .{ .color_text = dvui.themeGet().focus, .font = dvui.Font.theme(.body).withUnderline(.{}) }; if (tl.addTextClick(text orelse url, defs.override(opts))) |click_event| { const open_side = (click_event == .mouse and (click_event.mouse.button == .middle or click_event.mouse.mod.matchBind("ctrl/cmd"))); - openMarkdownUrl(url, open_side); + openMarkdownUrl(url, open_side, ctx); } } -fn openMarkdownUrl(url: []const u8, open_side: bool) void { +fn openMarkdownUrl(url: []const u8, open_side: bool, ctx: RenderContext) void { if (tryRevealFileUri(url, open_side)) return; + if (tryRevealRelativePath(url, open_side, ctx)) return; // `untitled://` (zls hover for unsaved buffers) and other non-http schemes have nowhere // useful to go via the system opener — skip them rather than hand SDL a junk URL. if (std.ascii.startsWithIgnoreCase(url, "untitled:")) return; _ = dvui.openURL(.{ .url = url, .new_window = open_side }); } +/// Resolve a scheme-less link against the document's directory and open it in the editor. +/// Returns false for URLs with a scheme (`http:`, `mailto:`, …), when there's no local base, +/// or when resolution fails. Fragments (`#heading`) are stripped for the path lookup; line +/// stays 0 for now (heading→line needs the brain index and can land later). +fn tryRevealRelativePath(url: []const u8, open_side: bool, ctx: RenderContext) bool { + const trimmed = std.mem.trim(u8, url, " \t\r\n"); + if (trimmed.len == 0) return false; + + // Anything with `://` is a real URL. A single `:` could be a Windows drive (`C:…`) — we + // only treat that as local when it looks like `X:/` or `X:\`; otherwise bail to openURL. + if (std.mem.indexOf(u8, trimmed, "://") != null) return false; + if (std.mem.indexOfScalar(u8, trimmed, ':')) |colon| { + const windows_drive = colon == 1 and std.ascii.isAlphabetic(trimmed[0]) and + trimmed.len > 2 and (trimmed[2] == '/' or trimmed[2] == '\\'); + if (!windows_drive) return false; + } + + var path_part = trimmed; + if (std.mem.indexOfScalar(u8, path_part, '#')) |hash| path_part = path_part[0..hash]; + if (path_part.len == 0) return false; + + // Percent-decode `%20` etc. so brain's encoded inserts round-trip. + const arena = dvui.currentWindow().arena(); + const decoded = percentDecode(arena, path_part) catch return false; + + const abs = blk: { + if (std.fs.path.isAbsolute(decoded)) + break :blk std.fs.path.resolve(arena, &.{decoded}) catch return false; + const base = ctx.image_base_dir orelse dirnameOf(ctx.document_path) orelse return false; + // Remote README bases are URLs — relative *page* links aren't editor targets. + if (std.mem.indexOf(u8, base, "://") != null) return false; + break :blk std.fs.path.resolve(arena, &.{ base, decoded }) catch return false; + }; + + return revealPath(abs, 0, 0, open_side); +} + +fn dirnameOf(path: []const u8) ?[]const u8 { + if (path.len == 0) return null; + return std.fs.path.dirname(path); +} + +fn percentDecode(arena: std.mem.Allocator, src: []const u8) ![]const u8 { + if (std.mem.indexOfScalar(u8, src, '%') == null) return src; + var out: std.ArrayList(u8) = .empty; + try out.ensureTotalCapacity(arena, src.len); + var i: usize = 0; + while (i < src.len) { + if (src[i] == '%' and i + 2 < src.len) { + const byte = std.fmt.parseInt(u8, src[i + 1 .. i + 3], 16) catch { + try out.append(arena, src[i]); + i += 1; + continue; + }; + try out.append(arena, byte); + i += 3; + } else { + try out.append(arena, src[i]); + i += 1; + } + } + return out.toOwnedSlice(arena); +} + /// Opens a `file://` URI (optionally with a `#L` / `#LC` fragment) in the editor. /// Returns false when the URL isn't a file URI or workbench isn't available. fn tryRevealFileUri(url: []const u8, open_side: bool) bool { @@ -637,7 +712,7 @@ fn renderUndecodableImage(alt: []const u8, url: []const u8, ctx: RenderContext, .id_extra = ids.next(), }); defer tl.deinit(); - addMarkdownLink(tl, url, text, .{ .font = dvui.Font.theme(.mono).larger(-1) }); + addMarkdownLink(tl, url, text, .{ .font = dvui.Font.theme(.mono).larger(-1) }, ctx); } fn renderMarkdownImagePlaceholder(msg: []const u8, ids: *IdGen) void { @@ -732,7 +807,7 @@ fn renderImageUrl(raw_url: []const u8, alt: []const u8, want: RequestedSize, ctx .id_extra = ids.next(), }); defer tl.deinit(); - addMarkdownLink(tl, url_trim, "open", .{ .font = dvui.Font.theme(.mono) }); + addMarkdownLink(tl, url_trim, "open", .{ .font = dvui.Font.theme(.mono) }, ctx); } return; }, @@ -1110,7 +1185,7 @@ fn renderInlineNodeToTl(tl: *dvui.TextLayoutWidget, x: md.Node, span: dvui.Optio } else { const arena = dvui.currentWindow().arena(); if (linkLabelPlainText(x, arena)) |display| { - addMarkdownLink(tl, url, if (display.len == 0) null else display, link_opts); + addMarkdownLink(tl, url, if (display.len == 0) null else display, link_opts, ctx); } else |_| { if (x.firstChild()) |_| renderInlines(tl, x, link_opts, ctx, ids); } From 0c50b6aba032b2bd536afc853c43000164f0581b Mon Sep 17 00:00:00 2001 From: foxnne Date: Tue, 4 Aug 2026 13:15:34 -0500 Subject: [PATCH 07/10] increase files tab performance/stability --- src/core/paths.zig | 45 +++++++++++++++++++++++++++++ src/editor/Editor.zig | 15 ++++++++-- src/plugins/workbench/src/files.zig | 24 ++++++++------- 3 files changed, 71 insertions(+), 13 deletions(-) diff --git a/src/core/paths.zig b/src/core/paths.zig index 2b90b688..c46c2168 100644 --- a/src/core/paths.zig +++ b/src/core/paths.zig @@ -16,6 +16,51 @@ pub fn normalize(allocator: std.mem.Allocator, path: []const u8) ![]u8 { return std.fs.path.resolve(allocator, &.{path}); } +/// True when `normalize(path)` would return `path` byte-for-byte, decided without allocating. +/// +/// `normalize` costs a heap allocation plus a full `resolve` walk, and the hot callers +/// (`Editor.docFromPath`, once per file-tree row per frame) hand it paths that were built by +/// joining an already-absolute project root — i.e. canonical the overwhelming majority of the +/// time. Testing first lets those callers skip the allocation entirely and fall back to +/// `normalize` only for the odd spellings it exists to repair. +pub fn isNormalizedAbsolute(path: []const u8) bool { + if (!std.fs.path.isAbsolute(path)) return false; + // On Windows `resolve` also rewrites separators and drive-letter case; not worth + // replicating, so only the POSIX shape claims the fast path. + if (builtin.os.tag == .windows) return false; + + if (std.mem.eql(u8, path, "/")) return true; + // A trailing separator is always dropped by `resolve`. + if (path[path.len - 1] == '/') return false; + + var it = std.mem.splitScalar(u8, path[1..], '/'); + while (it.next()) |component| { + // Empty component == a doubled separator; `.`/`..` get collapsed. + if (component.len == 0) return false; + if (std.mem.eql(u8, component, ".")) return false; + if (std.mem.eql(u8, component, "..")) return false; + } + return true; +} + +test isNormalizedAbsolute { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + + // Canonical shapes take the fast path, and agree with `normalize`. + for ([_][]const u8{ "/", "/a", "/a/b.txt", "/a/b/c", "/a/.hidden", "/a/..b", "/a/b../c" }) |p| { + try std.testing.expect(isNormalizedAbsolute(p)); + const n = try normalize(gpa, p); + defer gpa.free(n); + try std.testing.expectEqualStrings(p, n); + } + + // Non-canonical shapes must decline, so the caller still normalizes them. + for ([_][]const u8{ "relative", "a/b", "", "/a/", "/a//b", "/a/./b", "/a/../b", "/.", "/.." }) |p| { + try std.testing.expect(!isNormalizedAbsolute(p)); + } +} + /// `normalize` of `base` joined with `path`; an absolute `path` wins outright (so a /// cwd + argv pair resolves the way a shell would). pub fn normalizeJoin(allocator: std.mem.Allocator, base: []const u8, path: []const u8) ![]u8 { diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 39cc09ce..83afdc52 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -2103,8 +2103,16 @@ pub fn docFromPath(editor: *Editor, path: []const u8) ?sdk.DocHandle { if (std.mem.eql(u8, editor.docPath(doc), path)) return doc; } - const key = fizzy.paths.normalize(fizzy.app.allocator, path) catch return null; - defer fizzy.app.allocator.free(key); + // The file tree calls this once per row per frame, and the miss (file not open) is by far the + // common case — so every allocation below is paid on every non-open row. Both normalizes are + // skippable whenever the path is already canonical, which is the norm here: tree rows are + // joined onto an absolute project root. Checking costs a scan, not a heap allocation. + const path_canonical = fizzy.paths.isNormalizedAbsolute(path); + const key: []const u8 = if (path_canonical) + path + else + fizzy.paths.normalize(fizzy.app.allocator, path) catch return null; + defer if (!path_canonical) fizzy.app.allocator.free(@constCast(key)); for (editor.open_files.values()) |doc| { const stored = editor.docPath(doc); @@ -2113,6 +2121,9 @@ pub fn docFromPath(editor: *Editor, path: []const u8) ?sdk.DocHandle { // already equals `key`. Only needed when a pre-normalization doc still carries a `.` // component that the caller's key has already collapsed. if (std.mem.eql(u8, stored, path)) continue; + // A canonical `stored` normalizes to itself, and both comparisons above already ruled it + // out — no need to allocate a copy just to re-compare it. + if (fizzy.paths.isNormalizedAbsolute(stored)) continue; const stored_canon = fizzy.paths.normalize(fizzy.app.allocator, stored) catch continue; defer fizzy.app.allocator.free(stored_canon); if (std.mem.eql(u8, stored_canon, key)) return doc; diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig index b77ea00c..8932ced6 100644 --- a/src/plugins/workbench/src/files.zig +++ b/src/plugins/workbench/src/files.zig @@ -794,29 +794,31 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u const rect = branch.data().borderRectScale().r; const max_distance = if (!expanded) rect.h * 3.0 else rect.w / 8.0; - // Quick bounds check: skip expensive distance calculation if mouse is far - var dx: f32 = 0; - var dy: f32 = 0; + var dx: f32 = std.math.floatMax(f32); if (current_point.x < rect.x + if (expanded) (expanded_indent * dvui.currentWindow().natural_scale) else 0.0) { - dx = @abs(current_point.x - (rect.x + if (expanded) (expanded_indent * dvui.currentWindow().natural_scale) else 0.0)); + dx = std.math.floatMax(f32); } else if (current_point.x > rect.bottomRight().x) { dx = @abs(current_point.x - rect.bottomRight().x); + } else { + dx = 0.0; } + var dy: f32 = std.math.floatMax(f32); + if (current_point.y < rect.y) { dy = @abs(current_point.y - rect.y); } else if (current_point.y > rect.bottomRight().y) { dy = @abs(current_point.y - rect.bottomRight().y); + } else { + dy = 0.0; } - // Only compute expensive distance if we're in range (Chebyshev approximation) - const chebyshev = @max(dx, dy); - if (chebyshev < max_distance) { - const distance = @sqrt(dx * dx + dy * dy); - const t = 1.0 - (distance / max_distance); - color = dvui.themeGet().color(.window, .fill).lerp(color, t); - } + const distance = @sqrt(dx * dx + dy * dy); + + const t = 1.0 - (distance / max_distance); + + color = dvui.themeGet().color(.window, .fill).lerp(color, t); if (branch.floating()) { if (dvui.dataGetSlice(null, inner_unique_id, "removed_path", []u8) == null) From 84df92a54d3e7d88c288e6d0dfe7f7b71de18849 Mon Sep 17 00:00:00 2001 From: foxnne Date: Tue, 4 Aug 2026 15:44:44 -0500 Subject: [PATCH 08/10] strengthen message when optimize modes dont match store --- src/editor/PluginStore.zig | 141 +++++++++++++++++++++++++------------ src/sdk/dylib.zig | 7 +- 2 files changed, 103 insertions(+), 45 deletions(-) diff --git a/src/editor/PluginStore.zig b/src/editor/PluginStore.zig index 3009b844..d49e8907 100644 --- a/src/editor/PluginStore.zig +++ b/src/editor/PluginStore.zig @@ -1712,6 +1712,14 @@ const card_min_w: f32 = 280; /// longest description happens to be", which is exactly what the scrollArea must not do. const card_text_no_floor: f32 = 1; +/// The one card text line that does *not* get `card_text_no_floor`: the title. A card whose name +/// has been squeezed away is unusable — you can't tell which plugin the controls belong to — so +/// the title reports up to this much width and the scrollArea grows a horizontal bar rather than +/// eating into it. Bounded by construction (unlike a description, whose length is unbounded and +/// author-controlled): a title longer than this still reports only this much and ellipsizes, so +/// the card's min width can't drift with the catalog's longest name. +const card_title_min_w: f32 = 96; + /// Padding for every text line inside a card's info column. `LabelWidget.defaults` is /// `Rect.all(6)`, which across four always-drawn lines (title/description/author/row2) adds ~48px /// of pure whitespace to a card whose text is only ~64px tall. The lines are already separated by @@ -1827,7 +1835,7 @@ fn drawCardShell(entry: StoreEntry, controls: *const fn (StoreEntry) void, row2_ .font = title_font.withWeight(.bold), .expand = .horizontal, .padding = card_text_padding, - .max_size_content = .{ .w = card_text_no_floor, .h = std.math.floatMax(f32) }, + .max_size_content = .{ .w = card_title_min_w, .h = std.math.floatMax(f32) }, }); if (releaseDate(entry)) |date| { dvui.labelNoFmt(@src(), date, .{}, .{ @@ -2028,6 +2036,91 @@ const part_separator = " · "; /// words per line without going all the way out to the card's actual (fluid) width. const min_failure_wrap_w: f32 = 220; +/// The optimize class every published store build is produced in: the plugin release CI +/// (`fizzyedit/plugin-build-action`) always builds `-Doptimize=ReleaseFast`. A property of the +/// store, not of any one plugin. +const store_optimize_class = "fast"; + +/// False when this Fizzy is a `Debug`/`ReleaseSafe` build. Such a host folds the `"safe"` +/// optimize class into its `abi_fingerprint` (see `dylib.optimize_safety_class`), so it fetches a +/// shard URL the store never publishes under, and *every* plugin — including ones whose SDK +/// version matches this host exactly — reads "No compatible build in store". That message points +/// at the store, but the cause is entirely local and comptime-known, so say so instead. Same +/// condition the local load path reports as `error.AbiBuildEnvMismatch` ("SDK versions match, but +/// optimize mode does not match"). +const host_optimize_matches_store = std.mem.eql(u8, dylib.optimize_safety_class, store_optimize_class); + +/// Cap on the reported min width of the no-build message (same `max_size_content` trick as +/// `card_text_no_floor`, just with a usable floor instead of ~0). The message sits in the controls +/// column, which is *not* expand-horizontal: whatever it reports, it takes out of the info column +/// beside it. Left uncapped, a long message plus the icon reserved so much of a narrowed card that +/// the title/description — all of which report ~0 and yield — collapsed to nothing while the error +/// text alone stayed fully drawn. Capped, it ellipsizes (its tooltip carries the full text either +/// way) and the title keeps its own floor below. +const no_build_msg_max_w: f32 = 110; + +/// The "nothing here to install" message, shown wherever no host-compatible release resolved — +/// identical in both panes (store card, installed card, detail header) so a card never changes +/// width just by which list it's in. Out-of-class hosts (see `host_optimize_matches_store`) get +/// the optimize-mode wording plus the same alert icon a failed local load carries; the tooltip +/// holds the long-form explanation in both cases. +fn drawNoStoreBuild(opts: dvui.Options) void { + const theme = dvui.themeGet(); + + var no_build_box = dvui.box(@src(), .{ .dir = .horizontal }, opts.override(.{ .gravity_y = 0.5 })); + defer no_build_box.deinit(); + + if (!host_optimize_matches_store) { + dvui.icon( + @src(), + "StoreOptimizeMismatchIcon", + icons.tvg.lucide.@"circle-alert", + .{ .stroke_color = theme.color(.err, .fill), .fill_color = theme.color(.err, .fill) }, + .{ .gravity_y = 0.5, .margin = .{ .x = 2 }, .min_size_content = .{ .w = 14, .h = 14 } }, + ); + } + + dvui.labelNoFmt( + @src(), + if (host_optimize_matches_store) "No store build" else "Needs release", + .{}, + .{ + .color_text = theme.color(.err, .text), + .font = dvui.Font.theme(.mono), + .gravity_y = 0.5, + .max_size_content = .{ .w = no_build_msg_max_w, .h = std.math.floatMax(f32) }, + }, + ); + + if (host_optimize_matches_store) { + dvui.tooltip( + @src(), + .{ .active_rect = no_build_box.data().borderRectScale().r }, + "No compatible build in store (SDK {d}.{d}.{d} · ABI 0x{x} · {s})", + .{ version.sdk_version.major, version.sdk_version.minor, version.sdk_version.patch, dylib.abi_fingerprint, compat.hostKey() }, + .{}, + ); + } else { + dvui.tooltip( + @src(), + .{ .active_rect = no_build_box.data().borderRectScale().r }, + "This Fizzy is a {s} build. Store plugins are published ReleaseFast only, so the " ++ + "optimize mode does not match even when the SDK version does. Run zig build run -Doptimize=ReleaseFast, or build the plugin from source in {s}. " ++ + "(SDK {d}.{d}.{d} · ABI 0x{x} · {s})", + .{ + @tagName(builtin.mode), + @tagName(builtin.mode), + version.sdk_version.major, + version.sdk_version.minor, + version.sdk_version.patch, + dylib.abi_fingerprint, + compat.hostKey(), + }, + .{}, + ); + } +} + /// Join `parts` with " · ", truncating (rather than overflowing) if `buf` is too small. fn joinParts(buf: []u8, parts: []const []const u8) []const u8 { var len: usize = 0; @@ -2144,19 +2237,7 @@ fn drawCardControls(entry: StoreEntry) void { // *from*. Say why (short form — this card also carries the wrapped failure text, // and the controls row shares its width with it) instead of leaving a lone trash // icon next to an unexplained "Failed to load". - var no_build_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ .gravity_y = 0.5, .margin = .{ .x = 4 } }); - defer no_build_box.deinit(); - dvui.labelNoFmt(@src(), "No store build", .{}, .{ - .color_text = theme.color(.err, .text), - .font = dvui.Font.theme(.mono), - }); - dvui.tooltip( - @src(), - .{ .active_rect = no_build_box.data().borderRectScale().r }, - "No compatible build in store (SDK {d}.{d}.{d} · ABI 0x{x} · {s})", - .{ version.sdk_version.major, version.sdk_version.minor, version.sdk_version.patch, dylib.abi_fingerprint, compat.hostKey() }, - .{}, - ); + drawNoStoreBuild(.{ .margin = .{ .x = 4 } }); } } if (dvui.buttonIcon(@src(), "Uninstall", icons.tvg.lucide.@"trash-2", .{}, .{ .stroke_color = theme.color(.err, .text) }, .{ .gravity_y = 0.5 })) @@ -2179,21 +2260,7 @@ fn drawCardControls(entry: StoreEntry) void { // published a build for this exact Fizzy version/arch yet — nothing the user can fix // locally (unlike a failed local build, handled above), so the wording and the tooltip // both point at "the store doesn't have one" rather than "rebuild your plugin". - { - var no_build_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ .gravity_y = 0.5 }); - defer no_build_box.deinit(); - dvui.labelNoFmt(@src(), "No compatible build in store", .{}, .{ - .color_text = theme.color(.err, .text), - .font = dvui.Font.theme(.mono), - }); - dvui.tooltip( - @src(), - .{ .active_rect = no_build_box.data().borderRectScale().r }, - "No compatible build in store (SDK {d}.{d}.{d} · ABI 0x{x} · {s})", - .{ version.sdk_version.major, version.sdk_version.minor, version.sdk_version.patch, dylib.abi_fingerprint, compat.hostKey() }, - .{}, - ); - } + drawNoStoreBuild(.{}); } /// Upper-pane (store) card controls: browse-only. Just an in-flight job status, an Install @@ -2231,21 +2298,7 @@ fn drawStoreCardControls(entry: StoreEntry) void { // Registry row with no host-compatible release: the *store* hasn't published a build for // this exact Fizzy version/arch yet. - { - var no_build_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ .gravity_y = 0.5 }); - defer no_build_box.deinit(); - dvui.labelNoFmt(@src(), "No compatible build in store", .{}, .{ - .color_text = theme.color(.err, .text), - .font = dvui.Font.theme(.mono), - }); - dvui.tooltip( - @src(), - .{ .active_rect = no_build_box.data().borderRectScale().r }, - "No compatible build in store (SDK {d}.{d}.{d} · ABI 0x{x} · {s})", - .{ version.sdk_version.major, version.sdk_version.minor, version.sdk_version.patch, dylib.abi_fingerprint, compat.hostKey() }, - .{}, - ); - } + drawNoStoreBuild(.{}); } /// A repo URL plus an optional path within it to look under for `README.md` / `ICON.png`. diff --git a/src/sdk/dylib.zig b/src/sdk/dylib.zig index 8adce3a0..1e542644 100644 --- a/src/sdk/dylib.zig +++ b/src/sdk/dylib.zig @@ -192,7 +192,12 @@ const dvui_shared_state_types = .{ /// zero-size it. A host and plugin in different classes have genuinely incompatible offsets even /// with an identical boundary shape, so this is folded into `abi_fingerprint` — the one /// real-layout axis the shape hash deliberately ignores. -const optimize_safety_class: []const u8 = switch (builtin.mode) { +/// +/// Public because it is the *only* fingerprint input a host can explain to the user in isolation: +/// the plugin store publishes `"fast"` builds exclusively, so a `"safe"` host knows up front that +/// no store shard can ever match it, whatever the SDK version says (see `PluginStore`'s +/// `host_optimize_matches_store`). +pub const optimize_safety_class: []const u8 = switch (builtin.mode) { .Debug, .ReleaseSafe => "safe", .ReleaseFast, .ReleaseSmall => "fast", }; From b8cf1676c7f90f3e7cba0a8eefe2e62db30fa3e7 Mon Sep 17 00:00:00 2001 From: foxnne Date: Wed, 5 Aug 2026 09:16:56 -0500 Subject: [PATCH 09/10] add better folder watching support and rework dependencies --- build.zig | 6 +- build.zig.zon | 6 +- build/app.zig | 62 +- build/common.zig | 2 +- build/exe.zig | 23 +- build/sdk.zig | 24 + build/web.zig | 4 +- docs/PLUGINS.md | 46 +- docs/PLUGIN_MANIFEST_PLAN.md | 1 + sdk/build.zig | 10 + sdk/build.zig.zon | 11 +- sdk/plugin_sdk_check.zig | 1 - sdk/sdk_version.zig | 2 +- src/core/dvui.zig | 10 + src/editor/Editor.zig | 53 +- src/editor/FolderWatcher.zig | 322 +++++++++ src/editor/Infobar.zig | 2 +- src/editor/KeybindSettings.zig | 2 +- src/editor/SettingsTree.zig | 2 +- src/editor/folder_events.zig | 240 +++++++ src/plugins/markdown/plugin.zig | 3 + src/plugins/markdown/src/markdown.zig | 17 + src/plugins/markdown/src/md/cmark_parse.zig | 4 + src/plugins/markdown/src/md/render_ast.zig | 626 ++++++++++++++++-- .../text/src/widgets/TextEntryWidget.zig | 131 ++-- src/plugins/workbench/src/Workspace.zig | 8 +- src/plugins/workbench/src/files.zig | 5 +- src/sdk/EditorAPI.zig | 8 + src/sdk/Host.zig | 16 + src/sdk/Plugin.zig | 56 ++ src/sdk/version.zig | 2 +- src/web_main.zig | 2 +- tests/bench/bench_markdown.zig | 287 ++++++++ tests/bench/bench_text.zig | 47 ++ tests/integration.zig | 113 ++++ 35 files changed, 2016 insertions(+), 138 deletions(-) create mode 100644 src/editor/FolderWatcher.zig create mode 100644 src/editor/folder_events.zig create mode 100644 tests/bench/bench_markdown.zig diff --git a/build.zig b/build.zig index 9d9c3b9a..d4dc3ac3 100644 --- a/build.zig +++ b/build.zig @@ -2,7 +2,11 @@ const std = @import("std"); /// App-side re-export of the plugin build API (lives in `sdk/`). Plugins should depend on /// the `sdk/` package directly — see CLAUDE.md — not this root package. -pub const plugin = @import("sdk/plugin_sdk.zig"); +/// +/// Reached through the dependency rather than by path (`sdk/plugin_sdk.zig`): the app consumes +/// `sdk/` as a package so the two can share one dvui pin, and a file may belong to only one module, +/// so claiming these for the root's build module would make that impossible. +pub const plugin = @import("fizzy_sdk").plugin; pub fn build(b: *std.Build) !void { const windows_msvc_libc_opt = b.option([]const u8, "windows-msvc-libc", "zig libc manifest for *-windows-msvc when cross-compiling; forwarded by packageall for Windows children") orelse null; diff --git a/build.zig.zon b/build.zig.zon index 7ae71f4d..2166cf44 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -29,10 +29,8 @@ .hash = "icons-0.0.0-iJxA-VvGMwAgiKSXRe_Y0O7RpasdtEJhBfVx8IGGEBl_", .lazy = true, }, - .dvui = .{ - .url = "https://github.com/foxnne/dvui-dev/archive/ed2f1c67f0316184783c8dba7d79ed4c49d26f97.tar.gz", - .hash = "dvui-0.5.0-dev-AQFJmX1d_QA2wHjWCweU26ZxqIrA9LwWeysGFbfVMc7y", - //.path = "../dvui-dev", + .fizzy_sdk = .{ + .path = "sdk/", }, .assetpack = .{ .url = "https://github.com/foxnne/assetpack/archive/ac7592f3f5988857840d0df4610e1e1fad690e2e.tar.gz", diff --git a/build/app.zig b/build/app.zig index 91b6703d..5936c19b 100644 --- a/build/app.zig +++ b/build/app.zig @@ -1,8 +1,12 @@ const std = @import("std"); -const plugin = @import("../sdk/plugin_sdk.zig"); -const core_mod = @import("../sdk/core_module.zig"); -const dvui = @import("dvui"); +// Through the `sdk/` dependency, not by relative path — see `build/sdk.zig`'s `dvuiDependency` for +// why the app consumes the SDK as a package, and `sdk/build.zig` for what it exposes. dvui's build +// API arrives the same way because `sdk/` owns the repo's only dvui pin. +const fizzy_sdk = @import("fizzy_sdk"); +const plugin = fizzy_sdk.plugin; +const core_mod = fizzy_sdk.core_module; +const dvui = fizzy_sdk.dvui; const velopack = @import("velopack.zig"); pub const Options = struct { @@ -390,6 +394,9 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil // Content-swap reveal phase machine. std-only by design (see reveal.zig) — the dvui // half is the thin wrapper in core/dvui.zig. .{ "fizzy-reveal-tests", "src/core/reveal.zig" }, + // Ring buffering and dot-segment filtering for the folder watcher. std-only so it can + // be tested here; FolderWatcher.zig itself needs a live editor. + .{ "fizzy-folder-events-tests", "src/editor/folder_events.zig" }, }) |entry| { try unit_test_artifacts.append(b.allocator, b.addTest(.{ .name = entry[0], @@ -452,7 +459,7 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil return; } - const dvui_testing_dep = b.dependency("dvui", .{ + const dvui_testing_dep = sdk.dvuiDependency(b, .{ .target = target, .optimize = optimize, .backend = .testing, @@ -485,7 +492,12 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil const icons_test = core_mod.addImports(b, core_module_test, dvui_testing_dep.module("dvui_testing"), target, optimize); fizzy_test_module.addImport("core", core_module_test); if (icons_test) |icons| fizzy_test_module.addImport("icons", icons); - if (b.lazyDependency("nightwatch", .{ .target = target, .optimize = optimize })) |dep| { + // See `exe.zig` for why macOS needs the FSEvents backend. + const nightwatch_test_dep = if (target.result.os.tag == .macos) + b.lazyDependency("nightwatch", .{ .target = target, .optimize = optimize, .macos_fsevents = true }) + else + b.lazyDependency("nightwatch", .{ .target = target, .optimize = optimize }); + if (nightwatch_test_dep) |dep| { fizzy_test_module.addImport("nightwatch", dep.module("nightwatch")); } @@ -503,7 +515,7 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil .sdk = sdk_module_test, .icons = icons_test, }, fizzy_test_module); - _ = plugins.markdown.addStaticModule(b, target, optimize, .{ + const markdown_module_test = plugins.markdown.addStaticModule(b, target, optimize, .{ .dvui = dvui_testing_dep.module("dvui_testing"), .core = core_module_test, .sdk = sdk_module_test, @@ -540,6 +552,13 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil // built above rather than rooting a second one at the widget — a file may belong to only // one module per compilation, and the plugin's own module already owns it. integration_module.addImport("text", text_module_test); + // Same reasoning for the markdown preview: its block virtualization is a claim about what + // gets *drawn*, which only a real headless frame can check. + integration_module.addImport("markdown", markdown_module_test); + integration_module.addAnonymousImport("markdown_sample", .{ .root_source_file = b.path("docs/PLUGINS.md") }); + // The document with the 45KB table — the case table-row culling exists for, and the one it + // could get wrong. + integration_module.addAnonymousImport("markdown_sample_tables", .{ .root_source_file = b.path("docs/PLUGIN_MANIFEST_PLAN.md") }); const integration_tests = b.addTest(.{ .name = "fizzy-integration-tests", @@ -600,6 +619,37 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil bench_step.dependOn(&run_bench.step); } + // `zig build bench-markdown` — markdown preview frame-cost benchmark. Same rules as + // `bench-text` above: its own step, prints timings instead of asserting, only comparable at + // equal `-Doptimize` (cmark and freetype build at the app's optimize level). + { + const bench_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("tests/bench/bench_markdown.zig"), + }); + bench_module.addImport("dvui", dvui_testing_dep.module("dvui_testing")); + bench_module.addImport("markdown", markdown_module_test); + // This repo's own docs, as anonymous imports rather than checked-in fixtures — the same + // reasoning as `bench-text`'s samples. `PLUGINS.md` is the document that prompted the + // benchmark. + bench_module.addAnonymousImport("sample_huge", .{ .root_source_file = b.path("docs/PLUGINS.md") }); + bench_module.addAnonymousImport("sample_prose", .{ .root_source_file = b.path("docs/PLUGIN_MANIFEST_PLAN.md") }); + bench_module.addAnonymousImport("sample_medium", .{ .root_source_file = b.path("CLAUDE.md") }); + bench_module.addAnonymousImport("sample_small", .{ .root_source_file = b.path("docs/MODULARIZATION_RELEASE_NOTES.md") }); + + const bench_markdown = b.addTest(.{ .name = "fizzy-bench-markdown", .root_module = bench_module }); + bench_markdown.root_module.link_libcpp = !target_is_windows_msvc; + if (target.result.os.tag == .windows) { + bench_markdown.root_module.linkSystemLibrary("comctl32", .{}); + } + + const bench_step = b.step("bench-markdown", "Benchmark the markdown preview's per-frame draw cost (prints timings)"); + const run_bench = b.addRunArtifact(bench_markdown); + run_bench.has_side_effects = true; + bench_step.dependOn(&run_bench.step); + } + // Pure-logic tests that nevertheless sit in a file importing `dvui` (or the SDK) // can't join the unit layer, so they get their own roots here. Rooting at // `src/sdk/sdk.zig` collects every SDK file reachable from it by relative diff --git a/build/common.zig b/build/common.zig index 17b974d2..cc759410 100644 --- a/build/common.zig +++ b/build/common.zig @@ -1,6 +1,6 @@ const std = @import("std"); -const plugin = @import("../sdk/plugin_sdk.zig"); +const plugin = @import("fizzy_sdk").plugin; const update = @import("../update.zig"); const GitDependency = update.GitDependency; diff --git a/build/exe.zig b/build/exe.zig index 92ab2269..9212704a 100644 --- a/build/exe.zig +++ b/build/exe.zig @@ -1,9 +1,10 @@ const std = @import("std"); -const dvui = @import("dvui"); +// dvui's build API via the SDK package, which owns the repo's only dvui pin. +const dvui = @import("fizzy_sdk").dvui; // Vendored Velopack glue — see build/velopack.zig header (never `@import("velopack_zig")`). const velopack = @import("velopack.zig"); -const plugin = @import("../sdk/plugin_sdk.zig"); -const core_mod = @import("../sdk/core_module.zig"); +const plugin = @import("fizzy_sdk").plugin; +const core_mod = @import("fizzy_sdk").core_module; const common = @import("common.zig"); const plugins = @import("plugins.zig"); const sdk = @import("sdk.zig"); @@ -79,7 +80,7 @@ pub fn addFizzyExecutableForTarget( velopack_enabled: bool, ) !FizzyExecutable { const dvui_dep = if (macos_sdl_paths) |p| - b.dependency("dvui", .{ + sdk.dvuiDependency(b, .{ .target = resolved_target, .optimize = optimize, .backend = .sdl3, @@ -89,9 +90,9 @@ pub fn addFizzyExecutableForTarget( .library_path = p.lib, }) else - b.dependency("dvui", .{ .target = resolved_target, .optimize = optimize, .backend = .sdl3, .accesskit = accesskit }); + sdk.dvuiDependency(b, .{ .target = resolved_target, .optimize = optimize, .backend = .sdl3, .accesskit = accesskit }); - const dvui_proxy_dep = b.dependency("dvui", .{ + const dvui_proxy_dep = sdk.dvuiDependency(b, .{ .target = resolved_target, .optimize = optimize, .backend = .proxy, @@ -149,7 +150,15 @@ pub fn addFizzyExecutableForTarget( }); _ = core_mod.addImports(b, core_proxy_module, dvui_proxy_mod, resolved_target, optimize); - if (b.lazyDependency("nightwatch", .{ .target = resolved_target, .optimize = optimize })) |dep| { + // `macos_fsevents` is load-bearing for `FolderWatcher`: it watches a whole project folder, + // and the kqueue fallback needs a file descriptor per directory *and* per file — exactly the + // shape that exhausts the fd limit on a real repo. FSEvents covers the subtree with one + // stream. The option only exists when nightwatch is built for macOS, hence the split. + const nightwatch_dep = if (resolved_target.result.os.tag == .macos) + b.lazyDependency("nightwatch", .{ .target = resolved_target, .optimize = optimize, .macos_fsevents = true }) + else + b.lazyDependency("nightwatch", .{ .target = resolved_target, .optimize = optimize }); + if (nightwatch_dep) |dep| { exe.root_module.addImport("nightwatch", dep.module("nightwatch")); } diff --git a/build/sdk.zig b/build/sdk.zig index 5e1f0cba..cb1e9f02 100644 --- a/build/sdk.zig +++ b/build/sdk.zig @@ -1,5 +1,29 @@ const std = @import("std"); +/// The repo's one dvui, borrowed from the `sdk/` package instead of pinned by the app. +/// +/// dvui is not a dependency of the root package at all: `sdk/build.zig.zon` declares the only pin +/// and this reaches through to it, so there is a single place to bump a version or point at a local +/// checkout. `args` is forwarded to dvui's own build untouched (backend, target, optimize, …), so +/// callers keep full control of *how* it is built; only *which* dvui is shared. +/// +/// Worth the indirection because the two are not free to disagree. dvui types reachable from the +/// plugin boundary feed `dylib.sdk_shape_fingerprint`, which both the app build and the plugin-SDK +/// build check against the single `recorded_sdk_shape_fingerprint` literal in `src/sdk/version.zig`. +/// When each build compiled a different dvui, they computed different fingerprints from that one +/// literal and no value satisfied both — every fix broke the other side, and the error blamed +/// `sdk_version`, which a bump cannot repair. One pin makes that state unreachable rather than +/// merely discouraged. +/// +/// The direction is forced: `sdk/` ships standalone as `fizzy-sdk-v*.tar.gz` for third-party +/// plugins, so it must carry its own pin and can never read anything above its own root. The app +/// can always reach down into it. +pub fn dvuiDependency(b: *std.Build, args: anytype) *std.Build.Dependency { + // Only the SDK package's resolved dependency table is wanted here, not its artifacts, so its + // own target/optimize are left at default; `args` carries the target dvui is really built for. + return b.dependency("fizzy_sdk", .{}).builder.dependency("dvui", args); +} + pub fn addProxyBridgeModule( b: *std.Build, target: std.Build.ResolvedTarget, diff --git a/build/web.zig b/build/web.zig index 4aa432ad..ceb26df5 100644 --- a/build/web.zig +++ b/build/web.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const core_mod = @import("../sdk/core_module.zig"); +const core_mod = @import("fizzy_sdk").core_module; const plugins = @import("plugins.zig"); const sdk = @import("sdk.zig"); @@ -24,7 +24,7 @@ pub fn addSteps( }), }); - const dvui_web_dep = b.dependency("dvui", .{ + const dvui_web_dep = sdk.dvuiDependency(b, .{ .target = web_target, .optimize = optimize, .backend = .web, diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index d8555541..9a78748a 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -481,6 +481,8 @@ plugin gets an Enabled-toggle-only row instead of its fields. domain work *inside* these generic phases (see the lifecycle table below for exactly when each fires). - **Folder lifecycle** — `onFolderClose` / `onFolderOpen`. +- **Filesystem** — `folderPathsChanged` (files changed on disk under the open folder, from Fizzy's + own recursive watch; see below). - **Save protocol** — `saveNeedsConfirmation(doc)` + `requestSaveConfirmation(doc, mode, …)`. - **Contributions** — `contributeMenu`, `contributeKeybinds`. - **New document** — `requestNewDocumentDialog`. @@ -532,6 +534,47 @@ through. `saveNeedsConfirmation` / `requestSaveConfirmation` fire `[active-doc]` from the save / close / quit-all paths; `loadDocument` runs on a **background load-worker thread** (touch only the host allocator + the given buffer, no dvui). +#### `folderPathsChanged` — on-disk changes under the open folder + +`documentContentChanged` covers buffers *this editor* has open. `folderPathsChanged` covers the +rest of the tree: a note an agent wrote, a `git checkout`, a file deleted in Finder. It fires +`[broadcast]` from `FolderWatcher.tick` on the UI thread, with a coalesced batch: + +```zig +fn folderPathsChanged(state: *anyopaque, changes: sdk.Plugin.PathChanges) void { + const st: *State = @ptrCast(@alignCast(state)); + if (changes.truncated) return st.rescanEverything(); + for (changes.events) |e| switch (e.kind) { + .created, .modified => st.reindex(e.path), + .deleted => st.forget(e.path), + .renamed => { st.forget(e.old_path); st.reindex(e.path); }, + }; +} +``` + +Fizzy runs **one** watch over the folder and hands out the results, so a plugin that cares about +files does not pin a watcher library or stand up a thread of its own. Four things about the +contract are worth knowing before you rely on it: + +- **The slices live for the call only.** `changes`, every `event.path`, and every `old_path` are + borrowed. Copy anything you keep. +- **Already filtered.** Events are run through Fizzy's `IgnoreRules` first, so `.git`, build + output and gitignored paths never arrive. You do not need to re-derive that with + `host.isPathIgnored`. +- **`truncated` means "go look".** More changed than Fizzy could buffer, so `events` is an + incomplete picture — expect it during a build or a branch switch. A consumer that must not miss + anything should rescan rather than trust the list. +- **A rename may arrive as delete + create.** `.renamed` with `old_path` set is a best case + (Linux, Windows); elsewhere the two halves are separate events, so handle that shape regardless. + Likewise `event.object` can be `.unknown` when the object was already gone by the time Fizzy + looked. + +`host.folderWatchActive()` says whether a watch is actually running — false with no folder open, +on wasm, and when the platform watch could not start. A plugin that must stay correct either way +should keep a slow periodic rescan and simply stretch its interval when this returns true, rather +than dropping the fallback: "the watcher started" and "the watcher is still delivering" are +different claims, and the backends differ per platform. + ### 3.3 Reaching Fizzy: SDK-held injection, no storage file Plugin code can't import Fizzy, so Fizzy **injects pointers** into the plugin once at @@ -540,7 +583,7 @@ catches them into the SDK itself, so your code just reads: - **`sdk.allocator()`** — the persistent host allocator. - **`sdk.host()`** — Fizzy's `*Host`: registries, services, and the `EditorAPI` read surface - (open folder, active doc, arena allocator, save dialogs). + (open folder, active doc, arena allocator, save dialogs, `folderWatchActive()`). - **`sdk.refresh()`** — wake the app event loop for another frame. **Safe from any thread** (LSP workers, load jobs, PTY readers). Call this when background work finishes and the UI may be idle with no mouse/keyboard events — otherwise a sleeping draw loop will not pick up @@ -1068,6 +1111,7 @@ drop straight into the plugins directory, exactly like §2.6. | `src/sdk/settings.zig` | Comptime settings API (`sdk.settings.Schema(T)`) — see §3.1.1 | | `src/editor/SettingsPluginsZon.zig` | ZON-AST byte-span surgery for `settings.zon`'s merged `.plugins.` fields — fizzy-only, not part of the SDK | | `src/editor/SettingsWatcher.zig` | Thin nightwatch adapter for live external `settings.zon` / dropped-in plugin reconciliation (see above) — fizzy-only, not part of the SDK | +| `src/editor/FolderWatcher.zig`, `folder_events.zig` | Recursive watch on the open folder, fanned out to plugins as `folderPathsChanged` (§3.2). The only watcher adapter whose output leaves fizzy; nightwatch stays behind the hook so it can be swapped per platform. `folder_events.zig` is the std-only buffering/filtering half, split out so it can be unit-tested | | `sdk/plugin_sdk.zig` | `fizzy.plugin.create` / `.install` / `.addCModule` — the build-side API a plugin's `build.zig` calls | | `src/plugins/text/` | Canonical document-owning editor plugin — copy to start a new editor plugin | | `src/plugins/image/` | Read-only image viewer (PNG/JPG/JPEG) with zoom/pan | diff --git a/docs/PLUGIN_MANIFEST_PLAN.md b/docs/PLUGIN_MANIFEST_PLAN.md index 438b104b..1bc201d4 100644 --- a/docs/PLUGIN_MANIFEST_PLAN.md +++ b/docs/PLUGIN_MANIFEST_PLAN.md @@ -43,6 +43,7 @@ | Old Phase 2 (sidecar enforcement) | **cancelled** | superseded by this revision | | R16 — Store detail page: VSCode-marketplace-style header + tabs, `description` in `Manifest` | done | 2026-07-30 — the store's center-provider README view (only the center; the sidebar list is untouched) is now a full detail page. **Manifest:** `description: []const u8 = ""` added to `Manifest` (`src/sdk/manifest.zig`) — the identity-only lock from R2 is deliberately relaxed here, since the detail page needs a description for every plugin, not just ones with a registry entry; not part of `sdk_boundary_types` (never crosses the C-ABI boundary, only ever `std.zon.parse`d from `plugin.zig.zon` text), so no SDK version/fingerprint bump. All 4 built-in `plugin.zig.zon`s got real one-liners. **Description resolution** (`PluginStore.descriptionFor`): registry's own (freshest) → `Editor.builtinDescription` (built-ins read their own compiled-in `plugin_options.manifest_zon` directly, no dylib involved) → `PluginLoader.probeDescription` (new, mirrors `probeName`: opens the on-disk dylib, reads the embedded `fizzy_plugin_manifest_zon` export, parses it) for anything else. **Header** (`drawDetailHeader`): logo (same fetch-or-fallback chain the card list uses) + a stacked name (`.heading` font, matching `SettingsTree`'s root-branch style)/id (small dim mono)/author (dim)/description (wrapped) column, with the existing `drawCardControls` (install/update/uninstall) reused as-is, right-justified. **Tabs** (`drawDetailTabs`): a plain two-tab DETAILS/CHANGELOG strip — same selected/unselected color convention every other tab bar in the app uses, but no drag/drop or scroll area (there are only ever two). Reconstructing the selected plugin's `StoreEntry` for the header needed its own helper (`selectedEntry`), since the center provider draws independently of the sidebar's list-building pass and registry data is only valid while the catalog lock is held for that one frame — same acquire/release-per-frame discipline the list already follows. **Background:** the README view's old rounded `sdk.pane_layout.emptyStateCard` (meant for a genuinely empty hint screen) is now `sdk.pane_layout.mainCanvasVbox` — a plain flat fill, the same background every other content pane in the app uses. **CHANGELOG tab** is a placeholder empty state ("Changelog coming soon") — real GitHub Releases fetching (per-release notes) is out of scope for this pass, by explicit choice. **Install counts** are out of scope entirely — there is no backend/analytics service to source them from; revisit once one exists. Verified: `zig build`, `zig build test`, `zig build test-sdk-version`, `zig build check-web` all clean; a live isolated-`HOME`/`TMPDIR` run rendered the header/tabs/flat-background README correctly end-to-end (ghostty, registry description + README both showing, "No compatible build in store" control state correct for an uninstalled entry). | | R19 — `wikilink` service + `documentContentChanged` broadcast + markdown/text consumers | done | 2026-08-04 — the SDK seam for `[[wikilink]]` support, so an out-of-tree indexer (`brain`) can resolve links that the in-tree `markdown` renderer draws, without either importing the other. **New `src/sdk/services/wikilink.zig`**, split deliberately in two: (1) a **pure tokenizer** (`Token`, `tokenize`, `tokenizeAlloc`) — *what is a link* — living in the SDK rather than in either plugin, because a renderer and an indexer that disagree about the syntax produce graph edges the preview never drew (or vice versa); one implementation, one test suite (18 cases: alias/heading/block-id/embed forms, empty and unterminated targets, newline rejection, span recovery, out-buffer bounds, `tokenizeAlloc` parity). (2) an **`Api` vtable** — *which file does this link mean* — `resolve` / `generation` / `complete` / `indexing`. `complete` and `indexing` ship unused on day one on purpose: every field added later is another fingerprint bump that breaks every installed plugin. `resolve` is allocator-in/allocation-out rather than returning borrowed slices, because a background reindex can invalidate the provider's own strings between the call and the end of the frame; callers pass a frame arena. The `generation` counter is what lets a consumer memoize resolution *and* still have a link flip from broken to live when its target file appears — resolution can't be precomputed at parse time, since the linking document's bytes don't change when the target is created. Unlike `workbench`/`markdown`, both ends of this service are plugins (fizzy only stores the `*anyopaque`), so a shape mismatch would be dylib-to-dylib and invisible to the host — hence `Api`, `Api.VTable`, `Resolution`, `Candidate`, and `Token` all get explicit `sdk_boundary_types` entries (the `CompletionItem` lesson again: slices and by-value reaches aren't followed by `hashType`). **New `Plugin.VTable.documentContentChanged`** + `Host.notifyDocumentContentChanged` (a plain Host method over `plugins.items` — no `EditorAPI` vtable entry needed, so `EditorAPI`'s shape is untouched): a `[broadcast]` an owner fires when its buffer settles, letting a plugin that owns no documents see *unsaved* text at all. Owners debounce (a typing lull, plus on save); consumers treat it as an overlay on disk state. Tokenizer tests are wired as their own `addTest` root (`fizzy-sdk-wikilink-tests` in `build/app.zig`'s pure-logic list — std-only by design, so it must not sit under the SDK-rooted artifact that drags in dvui). sdk **0.1.49** (fingerprint `0x45dc3739334bebb`).

**Consumers, same pass.** `markdown` now renders wikilinks, and this turned up a real hazard the design had only flagged as a risk: `cmark_parser_finish` ends with `cmark_consolidate_text_nodes` (`blocks.c`), which merges every adjacent TEXT run into one literal — and since `handle_backslash` represents an escape as its own little text node, `\[\[A]]` and `[[A]]` arrive at the renderer as **the same literal**. Tokenizing the literal alone therefore turns deliberately-escaped text into a live link, with nothing in the AST to tell them apart. What survives is position: `make_literal` (`inlines.c`) sets `start_line`/`start_column` unconditionally (no `CMARK_OPT_SOURCEPOS` needed — that option only governs HTML *output*), and consolidation keeps the first fragment's start while extending `end_column`. New `src/md/wikilink_scan.zig` uses that to read the node's original bytes back out of the source, re-applies cmark's own escape rule to produce (bytes, was-escaped) pairs, and — **only when those bytes match the literal exactly** — drops links whose opening brackets were flagged. On any drift (smart punctuation rewrote a quote, an entity expanded) it **fails open** and the link renders: a link that appears where the author wanted literal text is visible and correctable, one that silently vanishes is an afternoon lost. Fast path is one `memchr` for a backslash. Tested against the **real vendored cmark** via a new `zig build test` step in the markdown plugin's own standalone `build.zig` (16 cases — it can't join fizzy's pure-logic list like `html_images`/`url_join`, which are std-only by design, because the whole point is a claim about what cmark does). Code spans and fenced blocks need no handling at all and now have tests pinning that: both get their own node types and never reach a TEXT node. Link *labels* do need a guard (`insideLinkOrImage`), since `[see [[A]]](url)` puts that text under a LINK parent.

Resolution is memoized per node+token against the resolver's `generation()` and explicitly **not** stored beside the parse (`RenderState.wikilinks` holds positions only): `Preview.ensureParsed` caches by content hash, so a scan-time resolution would freeze "broken" forever — the linking document's bytes don't change when its target is finally created. `tryRevealFileUri` split into `parseFileUri` + `revealPath` so a resolved wikilink reveals a path directly instead of round-tripping through a `file://` URI it would immediately re-parse (percent-encoding a path with a space or `#` is exactly where that goes wrong). `PreviewOptions.document_path` threads the source file down; empty disables wikilinks entirely, which is what keeps the store's fetched-README pane from resolving `[[Note]]` against the user's own local files. `markdown.Api.RenderOptions` deliberately untouched. `text` fires the new broadcast from `Document.tickContentChanged` (300ms typing-quiescence debounce keyed on `history.topOpId()` — already changes on exactly the right events, and comparing two integers beats hashing the buffer every frame) plus immediately in `save`, returning "still pending" up through a new `tickOpenDocuments` so the app keeps drawing until the burst settles rather than idling with a notification owed.

Verified: `zig build`, `zig build test`, `zig build test-sdk-version`, `zig build check-web`, `zig build test-integration` clean; markdown's own 16 cmark-backed tests; text's standalone build. **Live on macOS** in an isolated `HOME`/`TMPDIR` sandbox: `pixi`/`zig`/`ghostty` rebuilt against 0.1.49 all load, and a `.md` full of `[[links]]` with **no resolver installed** renders byte-identically to before — every form plain text, code span and fence untouched, ordinary markdown links still live. **Not done:** no resolver plugin exists yet, so the resolved/ambiguous/unresolved render paths are untested against a real provider; `pixi`/`zig`/`ghostty` are pinned to the local SDK path and still need a released `sdk-v0.1.49` tarball plus their own re-release before store installs work. | +| R20 — host folder watch + `folderPathsChanged` broadcast | done | 2026-08-05 — the missing half of R19. `documentContentChanged` tells a plugin about buffers *this editor* has open; nothing told it about the rest of the tree, so a `[[wikilink]]` deleted from a file nobody had open stayed in `brain`'s graph indefinitely — nothing ever re-read that file. The first fix was a 15s poll inside brain, which is the wrong place for it: every plugin that cares about files would end up pinning a watcher library and standing up its own thread over the same tree. **New `src/editor/FolderWatcher.zig`** — the third nightwatch adapter in `src/editor/`, and the only one whose output leaves fizzy (`SettingsWatcher` reconciles `settings.zon`, `DocumentWatcher` reloads open tabs). Three things put it on the host side of the boundary rather than in each plugin: (1) **thread hop** — nightwatch calls its handler on its own thread, and plugins are dylibs, so a callback arriving on a thread the plugin never created, possibly mid-unload, is a crash; events are buffered and fan out from `tick` on the UI thread. (2) **ignore rules** — only fizzy knows them (`IgnoreRules`), so `.git`, build output and gitignored paths never reach a plugin instead of every plugin re-deriving the same filter through `Host.isPathIgnored` one path at a time. (3) **one watch** — three interested plugins would otherwise mean three threads and three sets of fds or event streams over one tree. **Nightwatch is deliberately not exposed**: it is an implementation detail behind `folderPathsChanged`, so it can be swapped, forked, or replaced with per-platform code without any plugin noticing — which matters, since its Windows behavior is unproven and its macOS default needs the `macos_fsevents = true` build option (wired conditionally in `build/exe.zig` and `build/app.zig`) because the kqueue fallback wants a file descriptor per directory *and* per file, and a project folder is exactly the shape that exhausts the fd limit.

**SDK surface.** `Plugin.VTable.folderPathsChanged` (`[broadcast]`) plus `PathEvent`/`PathChanges` on `Plugin`, and `Host.notifyFolderPathsChanged` over `plugins.items`. `folderWatchActive` needed a real `EditorAPI` vtable entry (unlike R19's notify, which was a plain `Host` method) because the answer lives in the editor, not the SDK — so `EditorAPI`'s shape moves and `Editor.fizzyFolderWatchActive` joins the vtable. `have_impl` is false on wasm and unsupported targets; `folderWatchActive` reports false there and with no folder open, so a plugin knows to keep its own fallback. Slices in the batch are borrowed for the call only, and `truncated` reports overflow rather than growing the buffer — a branch switch emits events by the tens of thousands, and the useful answer for a consumer at that point is "rescan", not a longer list it still has to walk. `.renamed` carries `old_path` only where the backend can pair the halves (Linux, Windows); elsewhere it arrives as delete + create, which the doc now says explicitly because a consumer has to handle that shape regardless. sdk **0.1.50** (fingerprint `0x80448d5960ab4849`).

**Threading.** The producer never allocates: two fixed ring buffers with a flat path arena (one arena rather than a slot per event, so a handful of deep paths can't crowd out everything else and no path length is a special case), swapped under the lock so the fan-out reads a buffer nothing else can touch and no plugin call ever runs with the lock held. The lock is a spin over `std.atomic.Mutex` rather than a blocking primitive, because blocking would mean `std.Io.Mutex` and therefore `dvui.io` on nightwatch's thread — precisely what the other two adapters' doc comments single out as not to be touched from a watcher callback; both critical sections are a bounded memcpy or a pointer swap. A 200ms coalesce window means one logical save arrives as one batch and a consumer reindexing a file finds it finished being written. A cheap dot-segment reject runs on the watcher thread before the lock (pure string work — no allocation, no host call) so a `git checkout` or a build churning `.zig-cache` can't fill the ring before the authoritative `IgnoreRules` pass gets to run on the UI thread. `stopWatch` tears the watcher down entirely instead of calling nightwatch's `unwatch`, which drops only the path it was given and not the subdirectories its recursive walk added — a folder switch would otherwise leak watches on the old tree.

**Testing.** The buffering and filtering are split into **`src/editor/folder_events.zig`** (`Ring`, `underDotSegment`) and wired as its own `addTest` root (`fizzy-folder-events-tests`), for the same reason `keymap.zig` and `reveal.zig` are: `FolderWatcher.zig` reaches `fizzy.zig` and dvui and can only be exercised through a live editor, while the bugs that would actually bite (an overrun on a path that doesn't fit, a filter that lets `.git` through) live in the std-only half. `Ring` is generic over the event enums rather than importing them, since `Plugin.zig` imports dvui and would drag the file back into the module whose tests never run. 8 cases, including both halves of a rename counted together against the arena, and `empty()` distinguishing nothing-happened from everything-was-dropped — `tick` leans on that, because a batch that truncated with zero surviving events still has to be broadcast.

**Consumer.** brain's `Watcher.zig` routes markdown paths straight to `Indexer.enqueue` (create/modify/delete are all "re-read this path" — the worker treats missing-on-disk as the delete) and falls back to a quiet sweep for the two things a path alone can't identify: directories (one `mv notes/ archive/` moves every note beneath it, which is the tree walk the sweep already does) and attachments (the media table is only rebuilt by a walk). `truncated` goes straight to a sweep. The periodic sweep **stays** rather than being deleted, stretched from 15s to 5min while `folderWatchActive()` — "the watcher started" and "the watcher is still delivering" are different claims, the backends differ per platform, and a silently dead one should cost a few minutes of staleness instead of a permanently wrong graph.

Verified: `zig build`, `zig build check`, `zig build test` (8 new tests), `zig build test-sdk-version`, `zig build test-integration` clean on macOS; brain rebuilt against 0.1.50 (`zig build`, 291 tests). **Not done:** Windows and Linux are untested end to end; `pixi`/`zig`/`ghostty` need a released `sdk-v0.1.50` tarball and their own re-release before store installs work. | | R17 — `tags` in `Manifest` + registry-side description/tags dedup | done | 2026-07-30 — closes the gap R16 left for `description`: `tags` couldn't be authored anywhere except a hand-typed `registry/.json` PR in the separate `fizzyedit/plugins` repo, so a plugin with no registry entry yet (or one whose author never filled tags in) had zero search surface for them. **`Manifest`** (`src/sdk/manifest.zig`): `tags: []const []const u8 = &.{}` added, same off-`sdk_boundary_types` treatment as `description` (no fingerprint bump). All 4 built-in `plugin.zig.zon`s got real tags. **Resolution chain** (`PluginStore.tagsFor`, mirrors `descriptionFor` exactly): registry's own → `Editor.builtinTags` (new, mirrors `builtinDescription`) → `PluginLoader.probeTags` (new, mirrors `probeDescription`; returns a caller-owned `[][]u8` via a small `dupeTags` helper, since a manifest's `tags` — unlike `description` — is an array, not a single string) → `tags_cache` (new, same `StringArrayHashMapUnmanaged` shape as `description_cache`, cleared at the same two call sites: `refreshDiskScan` and `deinit`). **`scoreEntry`** now calls `descriptionFor`/`tagsFor` instead of reading `entry.registry.?.{description,tags}` directly, so a built-in or locally-probed dylib's own prose/tags contribute to store search even with no registry entry at all — `author` is the one field left with no fallback, since it was never a `plugin.zig.zon` concept to begin with (attribution, not something a build declares about itself). **No new UI** — tags still have no display surface (chips, filter row) anywhere in the store; this pass is resolution-chain-only, matching what already existed for `description` before R16's header. **Registry-side dedup** (separate repos, coordinated in this pass since the whole point was "don't require authors to hand-duplicate description/tags"): `fizzyedit/plugin-build-action`'s `read_plugin_zon.py` now also reads `description`/`tags` off `plugin.zig.zon`; `build.yml`'s setup job exposes them as job outputs (routed through `env:` rather than direct `${{ }}` interpolation into the assemble-manifest shell step, since these are free-form author-controlled strings — direct interpolation would be a script-injection hole); `assemble_manifest.py` embeds `name`/`description`/`tags` at the top level of the author's `manifest.json` (previously just `{id, releases}`). `fizzyedit/plugins`'s `store/src/manifest.zig` (the *aggregator's* copy of the author-manifest shape, distinct from `sdk/manifest.zig`) gained matching `name`/`description`/`tags` fields; `ingest.zig`'s `upsertPlugin`/`upsertTags` now fall back to the fetched manifest's values when `registry/.json` leaves its own `description`/`tags` empty — registry entry still wins when both are set, so a maintainer can override the store-listed copy without waiting on a plugin release. `docs/manifest.example.json` and both repos' `README.md` updated. **Not done, left for the user:** this is an interface change to `plugin-build-action`'s `build.yml`/`assemble_manifest.py` — existing `release.yml` callers pin `uses: .../build.yml@v3`, and `build.yml`'s own auxiliary-checkout step hardcodes the matching `ref="v3"` literal for its own script checkout, so nothing picks this up until a **new `v4` tag is cut and pushed** (a shared-CI action, deliberately not done automatically) and each external plugin repo (`pixi`/`ghostty`/`zig`/`json`/`markdown`) bumps its own `release.yml` to `@v4`; no `registry/.json` PR was reauthored to drop its now-optional `description`/`tags` either (a per-plugin-author call, not this repo's to make). | --- diff --git a/sdk/build.zig b/sdk/build.zig index 65bd50ca..620018dd 100644 --- a/sdk/build.zig +++ b/sdk/build.zig @@ -2,7 +2,17 @@ //! app-only deps like Velopack never enter their zon graph — see CLAUDE.md. const std = @import("std"); +// Build-time surface of this package, for the app as well as third-party plugins. The app consumes +// `sdk/` as a dependency and reaches these through it (`@import("fizzy_sdk").plugin`) rather than by +// relative path: a file may belong to only one module, and importing these from the root build +// scripts by path would claim them for the root's build module and make this package unusable as a +// dependency of it. Going through the dependency is also what lets the app share this package's +// dvui instead of pinning its own — see `build/sdk.zig`'s `dvuiDependency`. pub const plugin = @import("plugin_sdk.zig"); +pub const core_module = @import("core_module.zig"); +/// dvui's *build* API (`AccesskitOptions` and friends), re-exported because this package owns the +/// only dvui pin in the repo, so the app cannot `@import("dvui")` on its own. +pub const dvui = @import("dvui"); pub fn build(b: *std.Build) !void { const target = b.standardTargetOptions(.{}); diff --git a/sdk/build.zig.zon b/sdk/build.zig.zon index 329e3ce6..66b2ef2a 100644 --- a/sdk/build.zig.zon +++ b/sdk/build.zig.zon @@ -22,9 +22,16 @@ .hash = "icons-0.0.0-iJxA-VvGMwAgiKSXRe_Y0O7RpasdtEJhBfVx8IGGEBl_", .lazy = true, }, + // Must resolve to the *same* dvui as the repo-root zon. Boundary-reachable dvui types feed + // the SDK shape fingerprint, and `recorded_sdk_shape_fingerprint` is one literal compiled by + // both this package and the app — so if these two pins disagree, each build demands a + // different value and satisfying one breaks the other. Local path during development, for + // the same reason the root zon uses one; swap both back to the URL+hash pin together before + // tagging, since CI requires a content-addressed pin. .dvui = .{ - .url = "https://github.com/foxnne/dvui-dev/archive/ed2f1c67f0316184783c8dba7d79ed4c49d26f97.tar.gz", - .hash = "dvui-0.5.0-dev-AQFJmX1d_QA2wHjWCweU26ZxqIrA9LwWeysGFbfVMc7y", + //.url = "https://github.com/foxnne/dvui-dev/archive/ed2f1c67f0316184783c8dba7d79ed4c49d26f97.tar.gz", + //.hash = "dvui-0.5.0-dev-AQFJmX1d_QA2wHjWCweU26ZxqIrA9LwWeysGFbfVMc7y", + .path = "../../dvui-dev", }, .zf = .{ .url = "git+https://github.com/natecraddock/zf#c35c421f84895193246db06c40683c1a30e616ef", diff --git a/sdk/plugin_sdk_check.zig b/sdk/plugin_sdk_check.zig index 211e4c80..c2cc5f62 100644 --- a/sdk/plugin_sdk_check.zig +++ b/sdk/plugin_sdk_check.zig @@ -55,7 +55,6 @@ pub fn main(main_init: std.process.Init) !void { const want_fingerprint = try std.fmt.allocPrint(arena, "0x{x}", .{sdk.dylib.abi_fingerprint}); std.debug.print("pinned fizzy SDK: {s} abi_fingerprint: {s}\n", .{ want_version, want_fingerprint }); - std.debug.print("(plugin-build-action v3+ derives these from the built dylib — no need to copy them into release.yml)\n", .{}); const file = std.Io.Dir.cwd().openFile(main_init.io, args[1], .{}) catch |err| switch (err) { error.FileNotFound => { diff --git a/sdk/sdk_version.zig b/sdk/sdk_version.zig index e3dae229..1ab61cd9 100644 --- a/sdk/sdk_version.zig +++ b/sdk/sdk_version.zig @@ -22,5 +22,5 @@ const std = @import("std"); pub const sdk_version = std.SemanticVersion{ .major = 0, .minor = 1, - .patch = 49, + .patch = 50, }; diff --git a/src/core/dvui.zig b/src/core/dvui.zig index 48761b43..0b4c0386 100644 --- a/src/core/dvui.zig +++ b/src/core/dvui.zig @@ -421,6 +421,16 @@ pub fn hovered(wd: *dvui.WidgetData) bool { return false; } +/// Rest fill for a control that should be invisible until hovered. +/// +/// `Color.transparent` is transparent *black*, and dvui's hover fade lerps straight (non +/// premultiplied) RGBA, so a `.transparent` -> `hover` fade dips through a dark wash before it +/// reaches the hover tint. That is invisible on near-black themes and jarring on saturated ones +/// (Strawberry). Reusing the hover colour's RGB at zero alpha makes the fade ramp alpha only. +pub fn hoverRestFill(hover: dvui.Color) dvui.Color { + return hover.opacity(0); +} + pub fn reorder(src: std.builtin.SourceLocation, init_opts: ReorderWidget.InitOptions, opts: dvui.Options) *ReorderWidget { var ret = dvui.widgetAlloc(ReorderWidget); ret.init(src, init_opts, opts); diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 83afdc52..f9e4778f 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -54,6 +54,7 @@ const SettingsPluginsZon = @import("SettingsPluginsZon.zig"); const SettingsWatcher = @import("SettingsWatcher.zig"); const Constants = @import("Constants.zig"); const DocumentWatcher = @import("DocumentWatcher.zig"); +const FolderWatcher = @import("FolderWatcher.zig"); pub const Workspace = workbench_mod.Workspace; pub const Explorer = @import("explorer/Explorer.zig"); @@ -250,6 +251,10 @@ settings_watcher: ?SettingsWatcher = null, /// `Plugin.reloadDocument`; dirty docs set a conflict flag and `save` shows /// `FileChangedOnDisk`. Null on wasm / unsupported OS / start failure — best-effort. document_watcher: ?DocumentWatcher = null, +/// Recursive watch on the open root folder, fanned out to plugins as `folderPathsChanged`. +/// Same final-address constraint as the two above — started in `postInit`, retargeted whenever +/// the root folder changes. +folder_watcher: ?FolderWatcher = null, /// Timestamp of the most recent touch press anywhere in the app, or null if there /// hasn't been one. `Editor.draw` forces a per-frame refresh during the post-press @@ -419,20 +424,25 @@ pub fn init( fizzy_dark.font_mono = .find(.{ .family = "CozetteVector", .size = editor.settings.font_mono_size }); var strawberry: dvui.Theme = fizzy_dark; + strawberry.dark = true; strawberry.name = "Strawberry"; strawberry.window = .{ .fill = .{ .r = 84, .g = 12, .b = 26, .a = 255 }, .border = .{ .r = 104, .g = 62, .b = 72, .a = 255 }, - .text = .{ .r = 255, .g = 200, .b = 210, .a = 255 }, + .text = .{ .r = 180, .g = 80, .b = 90, .a = 255 }, }; strawberry.control = .{ - .fill = .{ .r = 206, .g = 54, .b = 76, .a = 255 }, - .border = .{ .r = 104, .g = 62, .b = 72, .a = 255 }, - .text = .{ .r = 220, .g = 45, .b = 57, .a = 255 }, + .fill = .{ .r = 130, .g = 54, .b = 76, .a = 255 }, + // Derived from an already-bright fill, the default hover/press tints wash out + // to near-white pink, so pin darker on-es. + .fill_hover = .{ .r = 178, .g = 44, .b = 66, .a = 255 }, + .fill_press = .{ .r = 150, .g = 34, .b = 56, .a = 255 }, + .border = .{ .r = 104, .g = 20, .b = 28, .a = 255 }, + .text = .{ .r = 230, .g = 120, .b = 130, .a = 255 }, }; strawberry.highlight = .{ - .fill = .{ .r = 236, .g = 64, .b = 89, .a = 255 }, + .fill = .{ .r = 175, .g = 24, .b = 36, .a = 255 }, .text = strawberry.window.fill.?, }; @@ -441,7 +451,7 @@ pub fn init( }; strawberry.fill = .{ .r = 124, .g = 24, .b = 52, .a = 255 }; - strawberry.text = strawberry.window.text.?.lighten(-10); + strawberry.text = strawberry.control.text.?.lighten(-20); strawberry.focus = strawberry.highlight.fill.?; var fizzy_light = fizzy_dark; @@ -1597,6 +1607,17 @@ pub fn postInit(editor: *Editor) !void { editor.document_watcher = null; }; } + + // Project-wide on-disk change broadcast for plugins (`folderPathsChanged`). Only the + // buffers are set up here; the watch itself is armed by `setProjectFolder`, which may + // already have run — hence the catch-up call below. + editor.folder_watcher = FolderWatcher.init(fizzy.app.allocator) catch |err| blk: { + dvui.log.warn("folder watcher: failed to init ({s}); plugins won't be told about on-disk changes", .{@errorName(err)}); + break :blk null; + }; + if (editor.folder_watcher) |*w| { + if (editor.folder) |f| w.setFolder(f); + } } } @@ -1643,6 +1664,7 @@ const fizzy_api_vtable: sdk.EditorAPI.VTable = .{ .recentFolderAt = fizzyRecentFolderAt, .openInFileBrowser = fizzyOpenInFileBrowser, .isPathIgnored = fizzyIsPathIgnored, + .folderWatchActive = fizzyFolderWatchActive, .explorerBranchIsOpen = fizzyExplorerBranchIsOpen, .setExplorerBranchOpen = fizzySetExplorerBranchOpen, .drawWorkspaces = fizzyDrawWorkspaces, @@ -1853,6 +1875,11 @@ fn fizzyRecentFolderAt(ctx: *anyopaque, index: usize) ?[]const u8 { fn fizzyOpenInFileBrowser(ctx: *anyopaque, path: []const u8) anyerror!void { return fizzyCtx(ctx).openInFileBrowser(path); } +fn fizzyFolderWatchActive(ctx: *anyopaque) bool { + const editor = fizzyCtx(ctx); + return if (editor.folder_watcher) |*w| w.active() else false; +} + fn fizzyIsPathIgnored( ctx: *anyopaque, project_root: []const u8, @@ -2743,6 +2770,10 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // Reload clean open docs / flag dirty conflicts when files change on disk. if (editor.document_watcher) |*w| w.tick(editor); + // Fan out on-disk changes under the root folder to plugins. Cheap no-op unless the watcher + // thread buffered something. + if (editor.folder_watcher) |*w| w.tick(editor); + var needs_save_status_anim_tick = false; for (editor.host.plugins.items) |plugin| { if (plugin.tickOpenDocuments()) needs_save_status_anim_tick = true; @@ -3574,10 +3605,14 @@ pub fn setProjectFolder(editor: *Editor, path_in: []const u8) !void { for (editor.host.plugins.items) |plugin| plugin.onFolderOpen(fizzy.app.allocator); editor.ignore = try IgnoreRules.load(fizzy.app.allocator, path); + // After `ignore` — `FolderWatcher.tick` filters through it, and arming first would let a + // burst arrive while the rules still belong to the previous folder. + if (editor.folder_watcher) |*w| w.setFolder(editor.folder); } pub fn closeProjectFolder(editor: *Editor) void { if (editor.folder) |folder| { + if (editor.folder_watcher) |*w| w.setFolder(null); editor.ignore.deinit(fizzy.app.allocator); for (editor.host.plugins.items) |plugin| plugin.onFolderClose(); fizzy.app.allocator.free(folder); @@ -4445,6 +4480,12 @@ pub fn deinit(editor: *Editor) !void { w.stop(); editor.settings_watcher = null; } + // Before the plugin `deinit` loop below: `tick` fans out into plugin vtables, and this + // joins the thread that feeds it. + if (editor.folder_watcher) |*w| { + w.deinit(); + editor.folder_watcher = null; + } // Tear workspaces down first: `Workspace.deinit` calls back into the owning plugin // (e.g. `removeCanvasPane`), so it must run while plugin state is still alive — i.e. before diff --git a/src/editor/FolderWatcher.zig b/src/editor/FolderWatcher.zig new file mode 100644 index 00000000..29d806be --- /dev/null +++ b/src/editor/FolderWatcher.zig @@ -0,0 +1,322 @@ +//! Watches the open root folder (recursive) for on-disk changes and broadcasts them to every +//! plugin via `Plugin.VTable.folderPathsChanged`. +//! +//! The third nightwatch adapter in this directory, and the only one whose output leaves fizzy. +//! `SettingsWatcher` reconciles `settings.zon`; `DocumentWatcher` reloads open tabs. Neither +//! helps a plugin that cares about files nobody has open — a file tree that should show what an +//! agent just created, a link indexer whose graph goes stale when a wikilink is deleted from a +//! closed file, a language server owing `didChangeWatchedFiles`. Before this, each of those +//! would have had to pin nightwatch itself and stand up its own thread over the same tree. +//! +//! Three things belong on this side of the SDK boundary rather than in each plugin: +//! +//! 1. **Thread hop.** Nightwatch calls its handler on its own thread. Plugins are dylibs; a +//! callback arriving on a thread the plugin never created — possibly mid-unload — is a +//! crash. Events are buffered here and fan out from `tick`, on the UI thread. +//! 2. **Ignore rules.** Only fizzy knows them (`IgnoreRules`). Filtering here means `.git`, +//! build output and gitignored paths never reach a plugin, instead of every plugin +//! re-deriving the same filter from `Host.isPathIgnored` one path at a time. +//! 3. **One watcher.** Three plugins each watching the project folder would mean three threads +//! and three sets of fds or event streams over the same tree. +//! +//! Nightwatch is deliberately not exposed: it is an implementation detail behind +//! `folderPathsChanged`, so it can be swapped, forked, or replaced with per-platform code +//! without any plugin noticing. +//! +//! `have_impl` is false on wasm and any unsupported OS — the watcher is simply not started +//! there, and `Host.folderWatchActive` reports false so a plugin knows to keep its own slow +//! rescan (same degrade-gracefully spirit as `SettingsWatcher` / `DocumentWatcher`). +const builtin = @import("builtin"); +const std = @import("std"); +const fizzy = @import("../fizzy.zig"); +const dvui = @import("dvui"); +const Allocator = std.mem.Allocator; + +const Plugin = fizzy.sdk.Plugin; +const IgnoreRules = @import("explorer/IgnoreRules.zig"); +const folder_events = @import("folder_events.zig"); +const underDotSegment = folder_events.underDotSegment; + +/// One side of the double buffer. The watcher thread fills `shared`; `tick` swaps it with +/// `staging` under the mutex and then reads at leisure, so no plugin call ever runs with the +/// lock held or races the producer. +const Buf = folder_events.Ring(Plugin.PathEvent.Kind, Plugin.PathEvent.ObjectType); + +const FolderWatcher = @This(); + +/// How long to keep coalescing further events once the first arrives. One logical save is +/// several raw filesystem events, and a consumer reindexing a file wants it to have finished +/// being written. +const debounce_ns: i128 = 200 * std.time.ns_per_ms; + +/// Ring capacity. Overflow is reported as `PathChanges.truncated` rather than grown: a branch +/// switch or an `npm install` emits events by the tens of thousands, and the honest answer for +/// a consumer at that point is "rescan", not a longer list it still has to walk. +const max_events: usize = 512; +/// Flat backing store for the paths. One arena rather than a fixed slot per event, so a handful +/// of deep paths can't crowd out everything else and no path length is special-cased. +const path_arena_bytes: usize = 64 * 1024; + +pub const have_impl = switch (builtin.os.tag) { + .macos, .linux, .windows => true, + else => false, +}; + +/// Spin lock over `std.atomic.Mutex` (which is try-lock only). A blocking primitive here would +/// mean `std.Io.Mutex` and therefore `dvui.io` on nightwatch's thread — the one thing the doc +/// comments on the other two adapters single out as not to be touched from a watcher callback. +/// Both critical sections are a bounded memcpy or a pointer swap, so there is nothing to block +/// on for long enough to be worth a real wait. +const Spin = struct { + inner: std.atomic.Mutex = .unlocked, + + fn lock(self: *Spin) void { + while (!self.inner.tryLock()) std.atomic.spinLoopHint(); + } + + fn unlock(self: *Spin) void { + self.inner.unlock(); + } +}; + +gpa: Allocator, +/// Owned copy of the folder currently watched, or null when nothing is. +folder: ?[]u8 = null, +impl: if (have_impl) Impl else void = if (have_impl) .{} else {}, + +/// Guards `shared` only. Held for a bounded memcpy on the producer and a pointer swap on the +/// consumer — never across a plugin call. +mutex: Spin = .{}, +shared: Buf, +staging: Buf, + +/// Main-thread coalesce deadline (`perf.nanoTimestamp()`); 0 = nothing pending. +coalesce_deadline_ns: i128 = 0, +/// Scratch for the fan-out, sized once so `tick` never allocates. +out: []Plugin.PathEvent, + +const Impl = if (have_impl) struct { + const nightwatch = @import("nightwatch"); + /// `Default` on purpose — this watches a whole tree, which is the case every backend's + /// default variant is built for. On macOS that is FSEvents (see the `macos_fsevents` build + /// option), which watches the subtree from a single stream; the kqueue fallback would want + /// a file descriptor per directory *and* per file, and a project folder is exactly the + /// shape that exhausts the fd limit. + const Watcher = nightwatch.Default; + const Handler = Watcher.Handler; + + handler: Handler = .{ .vtable = &vtable }, + nw: ?Watcher = null, + /// Set in `startWatch` once `FolderWatcher` is at its final address. + owner: ?*FolderWatcher = null, + + const vtable = Handler.VTable{ + .change = onChange, + .rename = onRename, + }; + + fn kindOf(ev: nightwatch.EventType) Plugin.PathEvent.Kind { + return switch (ev) { + .created => .created, + .modified, .closed => .modified, + .deleted => .deleted, + }; + } + + fn objectOf(obj: nightwatch.ObjectType) Plugin.PathEvent.ObjectType { + return switch (obj) { + .file => .file, + .dir => .dir, + .unknown => .unknown, + }; + } + + fn record( + h: *Handler, + path: []const u8, + old_path: []const u8, + kind: Plugin.PathEvent.Kind, + object: Plugin.PathEvent.ObjectType, + ) void { + const impl: *Impl = @fieldParentPtr("handler", h); + const self = impl.owner orelse return; + const folder = self.folder orelse return; + // Cheap dot-directory reject before taking the lock. The authoritative `IgnoreRules` + // pass happens on the main thread in `tick`; this one exists only so a `git checkout` + // or a build churning `.zig-cache` can't flood the ring before we get there. + if (underDotSegment(folder, path)) return; + + self.mutex.lock(); + self.shared.push(path, old_path, kind, object); + self.mutex.unlock(); + wake(); + } + + fn onChange(h: *Handler, path: []const u8, event_type: nightwatch.EventType, object_type: nightwatch.ObjectType) error{HandlerFailed}!void { + record(h, path, "", kindOf(event_type), objectOf(object_type)); + } + + fn onRename(h: *Handler, src: []const u8, dst: []const u8, object_type: nightwatch.ObjectType) error{HandlerFailed}!void { + record(h, dst, src, .renamed, objectOf(object_type)); + } +} else void; + +fn wake() void { + // Safe from any thread — see `Editor.zig`'s `fizzyRefresh` doc comment. + fizzy.app.window.backend.refresh(); +} + +/// Allocates the ring buffers. Does not start nightwatch — `setFolder` does, once a folder is +/// open and `self` is at its final address. +pub fn init(gpa: Allocator) !FolderWatcher { + if (comptime !have_impl) return error.Unsupported; + + const shared_paths = try gpa.alloc(u8, path_arena_bytes); + errdefer gpa.free(shared_paths); + const shared_events = try gpa.alloc(Buf.Event, max_events); + errdefer gpa.free(shared_events); + const staging_paths = try gpa.alloc(u8, path_arena_bytes); + errdefer gpa.free(staging_paths); + const staging_events = try gpa.alloc(Buf.Event, max_events); + errdefer gpa.free(staging_events); + const out = try gpa.alloc(Plugin.PathEvent, max_events); + + return .{ + .gpa = gpa, + .shared = .init(shared_paths, shared_events), + .staging = .init(staging_paths, staging_events), + .out = out, + }; +} + +pub fn deinit(self: *FolderWatcher) void { + self.stopWatch(); + self.gpa.free(self.shared.paths); + self.gpa.free(self.shared.events); + self.gpa.free(self.staging.paths); + self.gpa.free(self.staging.events); + self.gpa.free(self.out); + self.* = undefined; +} + +/// True when a watch is live, i.e. when `folderPathsChanged` can be relied on to fire. Backs +/// `Host.folderWatchActive`. +pub fn active(self: *const FolderWatcher) bool { + if (comptime !have_impl) return false; + return self.impl.nw != null; +} + +/// Point the watcher at `path` (or nowhere, when null). Must be called only once `self` is at +/// its **final** address — nightwatch retains `&self.impl.handler` for the watcher's lifetime, +/// the same constraint `SettingsWatcher.start` documents. +/// +/// Best-effort throughout: a folder that can't be watched is a degraded experience, never a +/// failure to open the folder. +pub fn setFolder(self: *FolderWatcher, path: ?[]const u8) void { + self.stopWatch(); + if (path) |p| { + self.folder = self.gpa.dupe(u8, p) catch { + dvui.log.warn("folder watcher: out of memory; plugins won't see on-disk changes under {s}", .{p}); + return; + }; + self.startWatch() catch |err| { + dvui.log.warn("folder watcher: failed to watch {s} ({s}); plugins won't see on-disk changes there", .{ p, @errorName(err) }); + self.stopWatch(); + }; + } +} + +fn startWatch(self: *FolderWatcher) !void { + if (comptime !have_impl) return error.Unsupported; + const folder = self.folder orelse return error.NoFolder; + self.impl.owner = self; + var nw = try Impl.Watcher.init(dvui.io, self.gpa, &self.impl.handler); + errdefer nw.deinit(); + try nw.watch(folder); + self.impl.nw = nw; +} + +/// Tears the watcher down entirely rather than calling `unwatch`: nightwatch's `unwatch` drops +/// only the path it was given, not the subdirectories its recursive walk added, so a folder +/// switch would otherwise leak watches on the old tree. +fn stopWatch(self: *FolderWatcher) void { + if (comptime have_impl) { + if (self.impl.nw) |*nw| { + nw.deinit(); + self.impl.nw = null; + } + self.impl.owner = null; + } + if (self.folder) |f| { + self.gpa.free(f); + self.folder = null; + } + self.mutex.lock(); + self.shared.reset(); + self.mutex.unlock(); + self.coalesce_deadline_ns = 0; +} + +/// Call once per frame. Cheap no-op unless the watcher thread actually buffered something. +pub fn tick(self: *FolderWatcher, editor: *fizzy.Editor) void { + if (comptime !have_impl) return; + + const now = fizzy.perf.nanoTimestamp(); + { + self.mutex.lock(); + defer self.mutex.unlock(); + if (!self.shared.empty()) self.coalesce_deadline_ns = now + debounce_ns; + } + if (self.coalesce_deadline_ns == 0) return; + if (now < self.coalesce_deadline_ns) { + // Keep the event loop alive until the coalesce window settles. + wake(); + return; + } + self.coalesce_deadline_ns = 0; + + // Swap rather than copy, so the producer is unblocked immediately and the fan-out below + // reads a buffer nothing else can touch. + { + self.mutex.lock(); + defer self.mutex.unlock(); + std.mem.swap(Buf, &self.shared, &self.staging); + self.shared.reset(); + } + defer self.staging.reset(); + + const folder = editor.folder orelse return; + var n: usize = 0; + for (self.staging.slice()) |e| { + const path = self.staging.pathOf(e); + const name = std.fs.path.basename(path); + // A deleted path can no longer be stat'd, so `.unknown` has to guess; `.file` is both + // the common case and the conservative one (directory rules are the broader filter). + const kind: std.Io.File.Kind = switch (e.object) { + .dir => .directory, + .file, .unknown => .file, + }; + if (editor.ignore.isIgnored(folder, path, name, kind)) continue; + self.out[n] = .{ + .path = path, + .kind = e.kind, + .object = e.object, + .old_path = self.staging.oldPathOf(e), + }; + n += 1; + } + + // A truncated batch still has to go out even when every surviving event was ignored: the + // dropped ones are precisely the events nobody got to inspect. + if (n == 0 and !self.staging.truncated) return; + editor.host.notifyFolderPathsChanged(.{ + .events = self.out[0..n], + .truncated = self.staging.truncated, + }); +} + +test { + // The buffering and filtering live in `folder_events.zig` (std-only, so its tests actually + // run); pull them in here too so they aren't orphaned if this file grows its own root. + _ = folder_events; +} diff --git a/src/editor/Infobar.zig b/src/editor/Infobar.zig index 994c0a19..f7809bc6 100644 --- a/src/editor/Infobar.zig +++ b/src/editor/Infobar.zig @@ -58,7 +58,7 @@ pub fn draw(_: Infobar) !void { .gravity_y = 0.5, .margin = .all(0), .padding = .all(0), - .color_fill = .transparent, + .color_fill = fizzy.dvui.hoverRestFill(dvui.themeGet().color(.control, .fill_hover)), .color_fill_hover = dvui.themeGet().color(.control, .fill_hover), .color_fill_press = dvui.themeGet().color(.control, .fill_press), }); diff --git a/src/editor/KeybindSettings.zig b/src/editor/KeybindSettings.zig index 06c0ebae..a6ddf614 100644 --- a/src/editor/KeybindSettings.zig +++ b/src/editor/KeybindSettings.zig @@ -293,7 +293,7 @@ fn drawOwnerBranch( .expand = .horizontal, .color_fill_hover = theme.color(.control, .fill).opacity(0.5), .color_fill_press = theme.color(.control, .fill_press), - .color_fill = .transparent, + .color_fill = core.dvui.hoverRestFill(theme.color(.control, .fill)), .padding = dvui.Rect.all(1), }); defer b.deinit(); diff --git a/src/editor/SettingsTree.zig b/src/editor/SettingsTree.zig index 77403427..fed668a3 100644 --- a/src/editor/SettingsTree.zig +++ b/src/editor/SettingsTree.zig @@ -368,7 +368,7 @@ fn drawBranch( .expand = .horizontal, .color_fill_hover = theme.color(.control, .fill).opacity(0.5), .color_fill_press = theme.color(.control, .fill_press), - .color_fill = .transparent, + .color_fill = core.dvui.hoverRestFill(theme.color(.control, .fill)), .padding = dvui.Rect.all(1), }); defer b.deinit(); diff --git a/src/editor/folder_events.zig b/src/editor/folder_events.zig new file mode 100644 index 00000000..43c46f87 --- /dev/null +++ b/src/editor/folder_events.zig @@ -0,0 +1,240 @@ +//! Buffering and filtering for `FolderWatcher` — the half that runs on nightwatch's thread. +//! +//! Split out and std-only on purpose. `FolderWatcher.zig` reaches `fizzy.zig` and `dvui`, so it +//! can only be exercised through a live editor; this is where the bugs would actually live (an +//! overrun on a path that doesn't fit, a filter that lets `.git` through) and it costs nothing +//! to test directly. Same reasoning as `keymap.zig` and `reveal.zig`. +//! +//! `Ring` is generic over the event enums rather than importing them: the real ones live on +//! `sdk.Plugin.PathEvent`, and `Plugin.zig` imports dvui, which would drag this file back out of +//! std-only territory and into the module whose tests never run. +const std = @import("std"); + +/// True when any path segment *below* `root` starts with a dot — `.git`, `.zig-cache`, `.env`. +/// +/// Pure string work: no allocation, no filesystem, no host call, so it is safe to run on the +/// watcher's own thread. It is not the authoritative ignore check — fizzy's `IgnoreRules` is, +/// and that runs later on the UI thread. This exists so a `git checkout` or a build churning a +/// cache directory can't fill the ring before anyone gets to apply the real rules. +/// +/// Only what is below `root` counts: a project folder may itself live under `~/.config`, which +/// is no reason to ignore every file in it. +pub fn underDotSegment(root: []const u8, path: []const u8) bool { + if (!std.mem.startsWith(u8, path, root)) return false; + var rest = path[root.len..]; + while (rest.len > 0) { + while (rest.len > 0 and (rest[0] == '/' or rest[0] == '\\')) rest = rest[1..]; + if (rest.len == 0) return false; + if (rest[0] == '.') return true; + const next = std.mem.indexOfAny(u8, rest, "/\\") orelse return false; + rest = rest[next..]; + } + return false; +} + +/// Fixed-capacity event buffer, filled by the watcher thread and drained by the UI thread. +/// +/// Nothing here allocates, and that is the whole point: the producer runs on a thread nightwatch +/// owns, where reaching for a shared allocator is exactly what the other watcher adapters in +/// this directory are careful never to do. When the buffer fills, the overflow is *reported* +/// (`truncated`) rather than absorbed — a branch switch emits events by the tens of thousands, +/// and the useful answer for a consumer at that point is "rescan", not a longer list it still +/// has to walk. +pub fn Ring(comptime Kind: type, comptime Object: type) type { + return struct { + const Self = @This(); + + pub const Event = struct { + off: u32, + len: u32, + old_off: u32 = 0, + old_len: u32 = 0, + kind: Kind, + object: Object, + }; + + /// Flat backing store for paths — one arena rather than a fixed slot per event, so a + /// handful of deep paths can't crowd out everything else and no path length is a + /// special case. + paths: []u8, + used: usize = 0, + events: []Event, + count: usize = 0, + /// Something didn't fit. Sticky until `reset`. + truncated: bool = false, + + pub fn init(paths: []u8, events: []Event) Self { + return .{ .paths = paths, .events = events }; + } + + pub fn reset(self: *Self) void { + self.used = 0; + self.count = 0; + self.truncated = false; + } + + /// Nothing to report. Distinct from `count == 0`, which is also true for a batch whose + /// every event was dropped — and that batch still has to go out. + pub fn empty(self: *const Self) bool { + return self.count == 0 and !self.truncated; + } + + /// Append one event, or mark the buffer truncated if it won't fit. `old_path` is the + /// pre-rename path, empty for everything else. + pub fn push(self: *Self, path: []const u8, old_path: []const u8, kind: Kind, object: Object) void { + if (self.count >= self.events.len or + self.used + path.len + old_path.len > self.paths.len) + { + self.truncated = true; + return; + } + const off: u32 = @intCast(self.used); + @memcpy(self.paths[self.used..][0..path.len], path); + self.used += path.len; + const old_off: u32 = @intCast(self.used); + @memcpy(self.paths[self.used..][0..old_path.len], old_path); + self.used += old_path.len; + + self.events[self.count] = .{ + .off = off, + .len = @intCast(path.len), + .old_off = old_off, + .old_len = @intCast(old_path.len), + .kind = kind, + .object = object, + }; + self.count += 1; + } + + pub fn pathOf(self: *const Self, e: Event) []const u8 { + return self.paths[e.off..][0..e.len]; + } + + pub fn oldPathOf(self: *const Self, e: Event) []const u8 { + return self.paths[e.old_off..][0..e.old_len]; + } + + pub fn slice(self: *const Self) []const Event { + return self.events[0..self.count]; + } + }; +} + +// -- tests ------------------------------------------------------------------------ + +const testing = std.testing; + +const TestKind = enum { created, modified, deleted, renamed }; +const TestObject = enum { file, dir, unknown }; +const TestRing = Ring(TestKind, TestObject); + +test "dot-segment reject keeps build and vcs churn out of the ring" { + const root = "/home/u/proj"; + try testing.expect(underDotSegment(root, "/home/u/proj/.git/index")); + try testing.expect(underDotSegment(root, "/home/u/proj/.zig-cache/o/abc/x.o")); + try testing.expect(underDotSegment(root, "/home/u/proj/src/.hidden/f.md")); + try testing.expect(underDotSegment(root, "/home/u/proj/.env")); + + try testing.expect(!underDotSegment(root, "/home/u/proj/README.md")); + try testing.expect(!underDotSegment(root, "/home/u/proj/src/index/Db.zig")); + // A dot *inside* a segment is a file extension, not a hidden entry. + try testing.expect(!underDotSegment(root, "/home/u/proj/docs/a.b.md")); + // The root may itself sit under a dot-directory; only what's below it is our business. + try testing.expect(!underDotSegment("/home/u/.config/vault", "/home/u/.config/vault/note.md")); + // Unrelated paths aren't ours to classify. + try testing.expect(!underDotSegment(root, "/etc/passwd")); +} + +test "dot-segment reject handles windows separators" { + const root = "C:\\proj"; + try testing.expect(underDotSegment(root, "C:\\proj\\.git\\HEAD")); + try testing.expect(!underDotSegment(root, "C:\\proj\\src\\main.zig")); +} + +test "push records paths and rename pairs" { + var paths: [64]u8 = undefined; + var events: [4]TestRing.Event = undefined; + var r = TestRing.init(&paths, &events); + + r.push("/a/one.md", "", .created, .file); + r.push("/a/new.md", "/a/old.md", .renamed, .file); + + try testing.expectEqual(@as(usize, 2), r.count); + try testing.expect(!r.truncated); + + const list = r.slice(); + try testing.expectEqualStrings("/a/one.md", r.pathOf(list[0])); + try testing.expectEqualStrings("", r.oldPathOf(list[0])); + try testing.expectEqualStrings("/a/new.md", r.pathOf(list[1])); + try testing.expectEqualStrings("/a/old.md", r.oldPathOf(list[1])); + try testing.expectEqual(TestKind.renamed, list[1].kind); +} + +test "running out of event slots truncates without corrupting what fit" { + var paths: [1024]u8 = undefined; + var events: [2]TestRing.Event = undefined; + var r = TestRing.init(&paths, &events); + + r.push("/a", "", .created, .file); + r.push("/b", "", .created, .file); + r.push("/c", "", .created, .file); + + try testing.expectEqual(@as(usize, 2), r.count); + try testing.expect(r.truncated); + // Truncation drops the tail; it never overwrites what was already recorded. + try testing.expectEqualStrings("/a", r.pathOf(r.slice()[0])); + try testing.expectEqualStrings("/b", r.pathOf(r.slice()[1])); +} + +test "a path too long for the arena truncates rather than overruns" { + var paths: [8]u8 = undefined; + var events: [4]TestRing.Event = undefined; + var r = TestRing.init(&paths, &events); + + r.push("/short", "", .created, .file); + r.push("/a/much/longer/path.md", "", .created, .file); + + try testing.expectEqual(@as(usize, 1), r.count); + try testing.expect(r.truncated); + try testing.expectEqualStrings("/short", r.pathOf(r.slice()[0])); +} + +test "a rename's two halves are counted together against the arena" { + // The pair is stored back to back, so capacity has to account for both or the second + // memcpy walks past the end. + var paths: [12]u8 = undefined; + var events: [4]TestRing.Event = undefined; + var r = TestRing.init(&paths, &events); + + r.push("/aaaaaa", "/bbbbbb", .renamed, .file); + try testing.expectEqual(@as(usize, 0), r.count); + try testing.expect(r.truncated); +} + +test "reset clears the truncation flag along with the events" { + var paths: [64]u8 = undefined; + var events: [1]TestRing.Event = undefined; + var r = TestRing.init(&paths, &events); + r.push("/a", "", .created, .file); + r.push("/b", "", .created, .file); + try testing.expect(r.truncated); + + r.reset(); + try testing.expectEqual(@as(usize, 0), r.count); + try testing.expectEqual(@as(usize, 0), r.used); + try testing.expect(!r.truncated); + try testing.expect(r.empty()); +} + +test "empty distinguishes nothing-happened from everything-was-dropped" { + // `FolderWatcher.tick` leans on this: a batch that truncated with zero surviving events + // still has to be broadcast, because the dropped ones are exactly what nobody got to see. + var paths: [4]u8 = undefined; + var events: [1]TestRing.Event = undefined; + var r = TestRing.init(&paths, &events); + try testing.expect(r.empty()); + + r.push("/aaaaaaaaaa", "", .created, .file); + try testing.expectEqual(@as(usize, 0), r.count); + try testing.expect(!r.empty()); +} diff --git a/src/plugins/markdown/plugin.zig b/src/plugins/markdown/plugin.zig index 8c10588a..76ef47da 100644 --- a/src/plugins/markdown/plugin.zig +++ b/src/plugins/markdown/plugin.zig @@ -15,6 +15,9 @@ pub const drawPreviewForDocument = md.drawPreviewForDocument; /// `src/editor/readme.zig` calls it directly; this plugin's `deinit` does the same for the /// dylib copy's separate globals. pub const deinitShared = md.deinitShared; +/// Exposed for `zig build bench-markdown` only (`tests/bench/bench_markdown.zig`), which reads +/// `render_ast.stats` after a frame. Nothing in the app reaches through here. +pub const render_ast = @import("src/md/render_ast.zig"); /// Injected at build time from `plugin.zig.zon` (see `static/integration.zig` / /// `src/plugins/shared/build/helpers.zig`'s `pluginOptions`) — one source of truth for diff --git a/src/plugins/markdown/src/markdown.zig b/src/plugins/markdown/src/markdown.zig index dc8ee51c..1e0fd1fa 100644 --- a/src/plugins/markdown/src/markdown.zig +++ b/src/plugins/markdown/src/markdown.zig @@ -44,10 +44,12 @@ pub const Preview = struct { self.ast_root = null; self.rs.clear(gpa); self.content_hash = h; + const t0 = std.Io.Clock.boot.now(dvui.io).nanoseconds; if (md_parse.parseMarkdown(content)) |ast| { self.ast_root = @ptrCast(ast.root.n); _ = render_ast.scanNode(ast.root, &self.rs, gpa, content); } + render_ast.stats.parse_ns +%= @intCast(std.Io.Clock.boot.now(dvui.io).nanoseconds - t0); } }; @@ -159,6 +161,21 @@ pub fn drawPreview( .id_base = @intCast(opts.id_extra << 16), .background = opts.background, .document_path = opts.document_path, + // What lets the renderer lay out only the blocks on screen. `viewport` is in the + // scroll area's virtual coordinates, where the column box starts at 0 — so the first + // block sits at its top padding. + // + // On a document's very first frame the `ScrollInfo` has not been laid out yet and its + // viewport is all zeros, which would read as "no viewport, draw everything" — and that + // frame is exactly the one that must not lay out a whole 60KB document, because it is + // the frame the preview pane opens on. The scroll area's own rect is already known by + // then, so it stands in. + .viewport = if (state.scroll.viewport.h > 0) + state.scroll.viewport + else + .{ .h = scroll.data().contentRect().h }, + .content_origin_y = pad.y, + .column_width = column_w, }); } else { dvui.labelNoFmt( diff --git a/src/plugins/markdown/src/md/cmark_parse.zig b/src/plugins/markdown/src/md/cmark_parse.zig index 936ddc24..b7fa66f6 100644 --- a/src/plugins/markdown/src/md/cmark_parse.zig +++ b/src/plugins/markdown/src/md/cmark_parse.zig @@ -48,6 +48,10 @@ pub const Node = struct { return c.cmark_node_get_start_line(n.n); } + pub fn endLine(n: Node) i32 { + return c.cmark_node_get_end_line(n.n); + } + pub fn startColumn(n: Node) i32 { return c.cmark_node_get_start_column(n.n); } diff --git a/src/plugins/markdown/src/md/render_ast.zig b/src/plugins/markdown/src/md/render_ast.zig index 125068b0..dd111675 100644 --- a/src/plugins/markdown/src/md/render_ast.zig +++ b/src/plugins/markdown/src/md/render_ast.zig @@ -31,6 +31,146 @@ fn wikilinkMemoKey(node: md.Node, token_index: usize) u64 { const is_windows = builtin.target.os.tag == .windows; +/// What one `renderDocument` call emitted, for `zig build bench-markdown`. Wall time varies by +/// machine and build mode; these counts don't, so they're the reproducible half of a before/after +/// comparison — and they're what the wall time is a function of, since every widget here costs a +/// layout pass and every `addText` costs text shaping. +/// +/// Always on: incrementing a counter next to a widget construction is unmeasurable against the +/// widget itself, and a build-mode gate would mean the numbers stop existing in exactly the +/// release build worth checking. +pub const Stats = struct { + /// `renderBlock` calls (every block node reached, visible or not). + blocks: u32 = 0, + /// `dvui.textLayout` widgets created. + text_layouts: u32 = 0, + /// `dvui.box` widgets created. + boxes: u32 = 0, + add_text_calls: u32 = 0, + add_text_bytes: u64 = 0, + /// Off-screen blocks and table rows whose height this frame is a cached guess that hasn't been + /// confirmed by two agreeing layout passes yet — the work the per-frame measuring budgets + /// (`resettle_budget`, `table_measure_bytes`) have deferred. + /// + /// `renderDocument` asks for another frame while this is non-zero, which is what makes the + /// budgets a way of *spreading* layout across frames rather than skipping it. Without that, + /// deferred work stalls whenever a frame happens to change no widget's size: dvui only + /// redraws when something asks it to, and a cached height asks for nothing by construction. + pending_measure: u32 = 0, + /// Nanoseconds spent parsing + pre-scanning the document, **accumulated** — one-time work + /// that lands entirely on the frame a document is opened on, which is the frame the user + /// feels as a hitch. Kept separate from `render_ns` so the two can be told apart. + parse_ns: u64 = 0, + /// Nanoseconds inside `renderDocument`, **accumulated** across frames — the benchmark zeroes + /// it and divides by its own iteration count. Everything else is per-document-draw. + render_ns: u64 = 0, +}; + +pub var stats: Stats = .{}; + +/// Per-top-level-block timing for `zig build bench-markdown`. Off unless a profiler is installed, +/// because it costs two clock reads per block. +pub const BlockSample = struct { + index: usize, + kind: [:0]const u8, + ns: u64, + text_layouts: u32, + add_text_bytes: u64, +}; +pub var block_profile: ?*std.ArrayListUnmanaged(BlockSample) = null; +pub var block_profile_gpa: ?std.mem.Allocator = null; + +/// One top-level block's laid-out height, and whether re-measuring it could still change it. +/// +/// dvui sizes a widget from what its children reported the frame *before*, so a block's first +/// draw at a given width is still settling. A block that got skipped from then on would freeze at +/// that half-settled value forever, pushing everything below it out of place — so `settled` only +/// goes true once two consecutive draws agree, and until then the block is re-drawn (within +/// `resettle_budget`) even off screen. +pub const BlockHeight = struct { + h: f32, + settled: bool, +}; + +/// The span of markdown source one top-level block was parsed from. +pub const SourceExtent = struct { + lines: u32, + bytes: u32, +}; + +/// One table cell's measured content size. `settled` follows the same two-agreeing-draws rule as +/// `BlockHeight`, and for the same reason. +pub const CellSize = struct { + size: dvui.Size, + /// The column width the size was measured at. A wrapped cell's height is a function of the + /// width it was laid out in, so a height on its own says nothing — and the widths a grid + /// hands out before any cell has reported what it needs are placeholders (`colWidth` returns + /// a flat 100 for a column it has never sized). Two draws at a placeholder width agree with + /// each other perfectly, so without recording the width, a cell measured on a table's first + /// frames settles at one line and stays there. + col_w: f32, + settled: bool, +}; + +/// Escape hatch for tests: with this off, `renderTopLevel` lays out every block, on screen or +/// not. The integration test that asserts the two produce identical pixels is the only thing +/// that turns it off — and the reason it can stay a plain global is that the same test is what +/// would catch a divergence if some future caller ever set it. +pub var virtualize_blocks: bool = true; + +/// Off-screen blocks `renderTopLevel` may re-measure per frame after a width change. Bounds what +/// a resize costs: without it, every frame of a window drag or a panel's open animation is a +/// full-document layout, which is the whole cost this virtualization exists to remove. +/// +/// Each block needs two draws to settle (dvui sizes a widget from what its children reported the +/// frame before), so a 180-block document is fully accurate again about 30 frames after the drag +/// stops. Until then the only thing that is off is the scrollbar's idea of the total height. +const resettle_budget: usize = 12; + +/// Off-screen table text (in bytes of markdown) that may be measured per frame the first time a +/// table is seen. Same idea as `resettle_budget`, one level down: a 45KB table +/// (docs/PLUGIN_MANIFEST_PLAN.md has one) costs several milliseconds to lay out in full, and +/// doing that on the frame the document opens is the hitch this whole file is about. Spread over +/// frames instead, the table's height is briefly short — by however many rows are still unmeasured +/// — and settles within half a second. +/// +/// Counted in bytes rather than rows because rows differ wildly: this document's big table runs +/// ~2KB to a row, where an ordinary one runs ~50. A row budget that is gentle for the second is +/// several milliseconds a frame for the first. +const table_measure_bytes: u64 = 4000; + +/// Table text (in bytes) laid out on the frame a table is first seen, before any of its geometry +/// exists — enough to fill a screen with something. +/// +/// On that frame every row reports the grid's default height (a single line), so *every* row of a +/// tall table looks like it fits on screen and the visibility test above lets all of them through +/// — which is how one 45KB table came to cost 4.6ms on the frame its document opened. Capping the +/// first sight to roughly a screenful, and letting `table_measure_bytes` bring in the rest over +/// the next frames, is what keeps that frame cheap. From the second frame on the row heights are +/// real and the cap no longer applies. +const table_first_sight_bytes: u64 = 8000; + +inline fn statBlock() void { + stats.blocks += 1; +} + +/// `dvui.textLayout` + the counter, so no call site can add one without the other. +inline fn textLayout(src: std.builtin.SourceLocation, init_opts: dvui.TextLayoutWidget.InitOptions, opts: dvui.Options) *dvui.TextLayoutWidget { + stats.text_layouts += 1; + return dvui.textLayout(src, init_opts, opts); +} + +inline fn box(src: std.builtin.SourceLocation, init_opts: dvui.BoxWidget.InitOptions, opts: dvui.Options) *dvui.BoxWidget { + stats.boxes += 1; + return dvui.box(src, init_opts, opts); +} + +inline fn addText(tl: *dvui.TextLayoutWidget, txt: []const u8, opts: dvui.Options) void { + stats.add_text_calls += 1; + stats.add_text_bytes += txt.len; + tl.addText(txt, opts); +} + // Extension node kinds that cmark-gfm identifies by type string rather than // integer constant. Precomputed once after parsing so rendering never calls // typeString() or any C FFI inside the per-frame draw loop. @@ -53,6 +193,12 @@ pub const RenderState = struct { ext_node_kinds: std.AutoHashMapUnmanaged(usize, ExtNodeKind) = .empty, /// Set of @intFromPtr(node.n) for every node whose subtree contains an IMAGE. subtree_has_image: std.AutoHashMapUnmanaged(usize, void) = .empty, + /// Set of @intFromPtr(node.n) for every node whose subtree contains a TABLE. A table is + /// drawn with `dvui.grid`, which is a scroll container — and a scroll container lays out only + /// the rows inside its own viewport, so an off-screen table measures as its header alone. + /// That makes it the one block whose height `renderTopLevel` may not believe unless the block + /// was really on screen. + subtree_has_table: std.AutoHashMapUnmanaged(usize, void) = .empty, /// @intFromPtr(table_node.n) → column count (from header row). /// Avoids re-traversing the header row every render frame. table_col_counts: std.AutoHashMapUnmanaged(usize, usize) = .empty, @@ -79,6 +225,27 @@ pub const RenderState = struct { /// memoized yet", which no real generation counter will collide with. wikilink_generation: u64 = std.math.maxInt(u64), + /// Laid-out height of each top-level block, by document order — what lets `renderTopLevel` + /// skip a block that isn't on screen and still hand the scroll container the right total + /// height. Grown as blocks are measured; a block past the end has never been measured, and + /// is always drawn. + block_heights: std.ArrayListUnmanaged(BlockHeight) = .empty, + /// How much *source* each top-level block came from, in document order — enough to guess its + /// laid-out height before it has ever been laid out. Without this, opening a document costs + /// one full-document layout (~13-20ms in ReleaseFast for a 60KB file, and it lands on the + /// frame a panel is animating open), because a block with no height cannot be placed and so + /// cannot be skipped. + block_source: std.ArrayListUnmanaged(SourceExtent) = .empty, + /// Content size each table cell measured at, keyed by @intFromPtr(cell node) — what a culled + /// row hands the grid in place of its contents. It has to be the cell's *own* measurement and + /// not the grid's row height / column width: the grid takes the max across a column, so + /// feeding those back widens the column a little, which rewraps a visible cell and moves the + /// whole table. (Measured the hard way: it showed up as one extra line of text in one row.) + cell_sizes: std.AutoHashMapUnmanaged(usize, CellSize) = .empty, + /// Column width `block_heights` was measured at. A different width invalidates every entry + /// wholesale — wrapped prose reflows, so no cached height survives a resize. + block_layout_width: f32 = -1, + pub fn deinit(self: *RenderState, gpa: std.mem.Allocator) void { self.clear(gpa); self.image_cache.deinit(gpa); @@ -86,11 +253,15 @@ pub const RenderState = struct { self.image_decode_failed.deinit(gpa); self.ext_node_kinds.deinit(gpa); self.subtree_has_image.deinit(gpa); + self.subtree_has_table.deinit(gpa); self.table_col_counts.deinit(gpa); self.task_items.deinit(gpa); self.html_images.deinit(gpa); self.wikilinks.deinit(gpa); self.wikilink_resolved.deinit(gpa); + self.block_heights.deinit(gpa); + self.cell_sizes.deinit(gpa); + self.block_source.deinit(gpa); } pub fn clear(self: *RenderState, gpa: std.mem.Allocator) void { @@ -104,6 +275,7 @@ pub const RenderState = struct { self.image_decode_failed.clearRetainingCapacity(); self.ext_node_kinds.clearRetainingCapacity(); self.subtree_has_image.clearRetainingCapacity(); + self.subtree_has_table.clearRetainingCapacity(); self.table_col_counts.clearRetainingCapacity(); self.task_items.clearRetainingCapacity(); var hi = self.html_images.valueIterator(); @@ -112,6 +284,10 @@ pub const RenderState = struct { var wi = self.wikilinks.valueIterator(); while (wi.next()) |toks| gpa.free(toks.*); self.wikilinks.clearRetainingCapacity(); + self.block_heights.clearRetainingCapacity(); + self.cell_sizes.clearRetainingCapacity(); + self.block_source.clearRetainingCapacity(); + self.block_layout_width = -1; self.clearResolvedWikilinks(gpa); } @@ -161,6 +337,16 @@ pub const RenderContext = struct { /// Absolute path of the document being rendered, `""` when it has none. Wikilinks resolve /// relative to it, and are disabled entirely when it's empty — see `PreviewOptions`. document_path: []const u8 = "", + /// The scroll area's visible region, in its own virtual coordinates, and the virtual `y` the + /// first top-level block starts at. Together they say which blocks are on screen, which is + /// what lets everything else be skipped — see `renderTopLevel`. A zero-height viewport + /// disables the skipping and draws the whole document (what a caller with no scroll area of + /// its own would want). + viewport: dvui.Rect = .{}, + content_origin_y: f32 = 0, + /// Width the blocks lay out at. Only used to notice it changed, which invalidates every + /// cached block height. + column_width: f32 = 0, /// The `"wikilink"` resolver, looked up once per document draw rather than per link. /// Null whenever wikilinks are off: no resolver plugin installed, or no `document_path`. /// When null, `[[Note]]` renders as the literal text it always was. @@ -211,12 +397,43 @@ pub fn scanNode(node: md.Node, rs: *RenderState, gpa: std.mem.Allocator, source: // A failed line index only costs escape detection, so scanning continues without it. var index: ?wikilink_scan.LineIndex = wikilink_scan.LineIndex.build(gpa, source) catch null; defer if (index) |*i| i.deinit(gpa); - return scanNodeInner(node, rs, gpa, .{ .bytes = source, .index = index }); + const scan_source: ScanSource = .{ .bytes = source, .index = index }; + recordBlockExtents(node, rs, gpa, scan_source); + return scanNodeInner(node, rs, gpa, scan_source); +} + +/// Record each top-level block's source span, for `renderTopLevel`'s first-sight height guess. +/// cmark reports 1-based line numbers for block nodes; a node whose lines don't fit the source +/// (nothing observed doing this, but the API doesn't promise it) simply gets a zero extent, and a +/// zero extent means "no guess available" — that block is drawn rather than estimated. +fn recordBlockExtents(doc_node: md.Node, rs: *RenderState, gpa: std.mem.Allocator, source: ScanSource) void { + var child = doc_node.firstChild(); + while (child) |ch| : (child = ch.nextSibling()) { + var extent: SourceExtent = .{ .lines = 0, .bytes = 0 }; + const start = ch.startLine(); + const end = ch.endLine(); + if (start >= 1 and end >= start) { + extent.lines = @intCast(end - start + 1); + if (source.index) |idx| { + const starts = idx.starts; + const first: usize = @intCast(start - 1); + const after: usize = @intCast(end); + if (first < starts.len) { + const from = starts[first]; + const to = if (after < starts.len) starts[after] else @as(u32, @intCast(source.bytes.len)); + if (to > from) extent.bytes = to - from; + } + } + } + rs.block_source.append(gpa, extent) catch {}; + } } fn scanNodeInner(node: md.Node, rs: *RenderState, gpa: std.mem.Allocator, source: ScanSource) bool { + var self_has_table = false; const ts = node.typeString(); if (std.mem.eql(u8, ts, "table")) { + self_has_table = true; rs.ext_node_kinds.put(gpa, @intFromPtr(node.n), .table) catch {}; // Count columns once from the header (or first body row) so the render // loop never needs to re-traverse the row for this. @@ -279,9 +496,12 @@ fn scanNodeInner(node: md.Node, rs: *RenderState, gpa: std.mem.Allocator, source var child = node.firstChild(); while (child) |ch| : (child = ch.nextSibling()) { if (scanNodeInner(ch, rs, gpa, source)) self_has_image = true; + if (rs.subtree_has_table.contains(@intFromPtr(ch.n))) self_has_table = true; } if (self_has_image) rs.subtree_has_image.put(gpa, @intFromPtr(node.n), {}) catch {}; + if (self_has_table) + rs.subtree_has_table.put(gpa, @intFromPtr(node.n), {}) catch {}; return self_has_image; } @@ -705,7 +925,7 @@ fn renderUndecodableImage(alt: []const u8, url: []const u8, ctx: RenderContext, renderMarkdownImagePlaceholder(text, ids); return; } - var tl = dvui.textLayout(@src(), .{}, .{ + var tl = textLayout(@src(), .{}, .{ .expand = .horizontal, .margin = .{ .y = 2, .h = 2 }, .background = ctx.background, @@ -729,7 +949,7 @@ fn renderMarkdownImage(img: md.Node, span: dvui.Options, ctx: RenderContext, ids _ = span; const arena = dvui.currentWindow().arena(); const raw_url = img.linkUrl() orelse { - var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ + var outer = box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .margin = .{ .y = 4, .h = 4 }, .id_extra = ids.next(), @@ -780,7 +1000,7 @@ fn renderImageUrl(raw_url: []const u8, alt: []const u8, want: RequestedSize, ctx else => {}, } - var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ + var outer = box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .margin = .{ .y = 4, .h = 4 }, .id_extra = ids.next(), @@ -801,7 +1021,7 @@ fn renderImageUrl(raw_url: []const u8, alt: []const u8, want: RequestedSize, ctx renderMarkdownImagePlaceholder(msg, ids); // A remote image that failed to load is still worth reaching: offer the link. if (net_image.isRemote(url_trim)) { - var tl = dvui.textLayout(@src(), .{}, .{ + var tl = textLayout(@src(), .{}, .{ .expand = .horizontal, .background = ctx.background, .id_extra = ids.next(), @@ -899,14 +1119,14 @@ fn alignGravityX(want: RequestedSize) f32 { fn renderImageCaption(alt: []const u8, ctx: RenderContext, ids: *IdGen) void { if (alt.len == 0) return; - var cap = dvui.textLayout(@src(), .{}, .{ + var cap = textLayout(@src(), .{}, .{ .expand = .horizontal, .margin = .{ .y = 2, .h = 0 }, .background = ctx.background, .id_extra = ids.next(), }); defer cap.deinit(); - cap.addText(alt, .{ + addText(cap, alt, .{ .font = dvui.Font.theme(.body).larger(-1), .color_text = dvui.themeGet().color(.control, .text).opacity(0.65), }); @@ -949,7 +1169,7 @@ const MarkerMetrics = struct { fn renderTaskCheckbox(checked: bool, m: MarkerMetrics, ids: *IdGen) void { const theme = dvui.themeGet(); - var b = dvui.box(@src(), .{ .dir = .horizontal }, .{ + var b = box(@src(), .{ .dir = .horizontal }, .{ .min_size_content = .{ .w = m.side, .h = m.side }, .max_size_content = .{ .w = m.side, .h = m.side }, .gravity_y = 0, @@ -1022,13 +1242,13 @@ fn renderInlineFlowContainer(container: md.Node, span: dvui.Options, ctx: Render } else if (node.firstChild()) |_| { renderInlineFlowContainer(node, span, ctx, ids); } else if (node.literal()) |t| { - var tl = dvui.textLayout(@src(), .{}, .{ + var tl = textLayout(@src(), .{}, .{ .expand = .horizontal, .background = span.background, .id_extra = ids.next(), }); defer tl.deinit(); - tl.addText(t, .{ .font = span.font, .color_text = span.color_text }); + addText(tl, t, .{ .font = span.font, .color_text = span.color_text }); } }, } @@ -1047,7 +1267,7 @@ fn renderInlineFlowContainer(container: md.Node, span: dvui.Options, ctx: Render scan = s.nextSibling(); } - var tl = dvui.textLayout(@src(), .{}, .{ + var tl = textLayout(@src(), .{}, .{ .expand = .horizontal, .margin = .{ .y = 2, .h = 2 }, .background = span.background, @@ -1089,16 +1309,16 @@ fn renderTextWithWikilinks( ctx: RenderContext, ) void { const plain: dvui.Options = .{ .font = span.font, .color_text = span.color_text }; - if (ctx.wikilink == null) return tl.addText(literal, plain); - const tokens = ctx.rs.wikilinks.get(@intFromPtr(node.n)) orelse return tl.addText(literal, plain); + if (ctx.wikilink == null) return addText(tl, literal, plain); + const tokens = ctx.rs.wikilinks.get(@intFromPtr(node.n)) orelse return addText(tl, literal, plain); var cursor: usize = 0; for (tokens, 0..) |tok, i| { - if (tok.start > cursor) tl.addText(literal[cursor..tok.start], plain); + if (tok.start > cursor) addText(tl, literal[cursor..tok.start], plain); renderWikilink(tl, node, i, tok, span, ctx); cursor = tok.end; } - if (cursor < literal.len) tl.addText(literal[cursor..], plain); + if (cursor < literal.len) addText(tl, literal[cursor..], plain); } fn renderWikilink( @@ -1116,7 +1336,7 @@ fn renderWikilink( switch (res.status) { // Still scanning. Deliberately unstyled: painting every link red for the second after a // folder opens, then flipping them all blue, is worse than showing nothing at all. - .indexing => tl.addText(label, .{ .font = span.font, .color_text = span.color_text }), + .indexing => addText(tl, label, .{ .font = span.font, .color_text = span.color_text }), .resolved, .ambiguous => { const color = if (res.status == .ambiguous) theme.color(.err, .fill) else theme.focus; @@ -1136,7 +1356,7 @@ fn renderWikilink( // hairline underline and dimmed text, rather than the error red a broken URL would get. // (dvui's `Underline` carries thickness only, no dash style, so weight is what's // available to say "provisional" with.) Inert until there's a create-note flow. - .unresolved => tl.addText(label, .{ + .unresolved => addText(tl, label, .{ .font = span.fontGet().withUnderline(.{ .thick = 0.04 }), .color_text = (span.color_text orelse theme.color(.content, .text)).opacity(0.6), }), @@ -1149,14 +1369,14 @@ fn renderInlineNodeToTl(tl: *dvui.TextLayoutWidget, x: md.Node, span: dvui.Optio if (x.literal()) |t| renderTextWithWikilinks(tl, x, t, span, ctx); }, md.c.CMARK_NODE_SOFTBREAK => { - tl.addText(" ", .{}); + addText(tl, " ", .{}); }, md.c.CMARK_NODE_LINEBREAK => { - tl.addText("\n", .{}); + addText(tl, "\n", .{}); }, md.c.CMARK_NODE_CODE => { if (x.literal()) |t| { - tl.addText(t, .{ + addText(tl, t, .{ // Match the editor's monospace size (also `Font.theme(.mono)`). .font = dvui.Font.theme(.mono), .color_text = dvui.themeGet().color(.control, .text).opacity(0.9), @@ -1193,7 +1413,7 @@ fn renderInlineNodeToTl(tl: *dvui.TextLayoutWidget, x: md.Node, span: dvui.Optio }, md.c.CMARK_NODE_IMAGE => unreachable, md.c.CMARK_NODE_HTML_INLINE => { - if (x.literal()) |t| tl.addText(t, .{ + if (x.literal()) |t| addText(tl, t, .{ .font = dvui.Font.theme(.mono), .color_text = dvui.themeGet().color(.err, .text), }); @@ -1202,9 +1422,9 @@ fn renderInlineNodeToTl(tl: *dvui.TextLayoutWidget, x: md.Node, span: dvui.Optio if (x.literal()) |t| { const fn_font = dvui.Font.theme(.mono).larger(-1); const fn_color = dvui.themeGet().focus.opacity(0.8); - tl.addText("[^", .{ .font = fn_font, .color_text = fn_color }); - tl.addText(t, .{ .font = fn_font, .color_text = fn_color }); - tl.addText("]", .{ .font = fn_font, .color_text = fn_color }); + addText(tl, "[^", .{ .font = fn_font, .color_text = fn_color }); + addText(tl, t, .{ .font = fn_font, .color_text = fn_color }); + addText(tl, "]", .{ .font = fn_font, .color_text = fn_color }); } }, else => { @@ -1215,21 +1435,188 @@ fn renderInlineNodeToTl(tl: *dvui.TextLayoutWidget, x: md.Node, span: dvui.Optio } else if (x.firstChild()) |_| { renderInlines(tl, x, span, ctx, ids); } else if (x.literal()) |t| { - tl.addText(t, .{ .font = span.font, .color_text = span.color_text }); + addText(tl, t, .{ .font = span.font, .color_text = span.color_text }); } }, } } +/// Draw the document's top-level blocks, laying out only the ones near the viewport. +/// +/// Why this exists: every widget the renderer emits costs a full layout pass, and a +/// `TextLayoutWidget` re-shapes all of its text every frame — there is no per-string shaping +/// cache in dvui, and a paragraph is far too small for `cache_layout` (which skips *within* one +/// widget) to help. So the old "walk the whole AST every frame" cost was linear in the document's +/// **bytes**, not in what was on screen: docs/PLUGINS.md spent ~34ms/frame in Debug laying out +/// 58KB of text to show maybe 3KB of it, and scrolling to the end cost exactly the same as +/// sitting at the top. See `tests/bench/bench_markdown.zig`. +/// +/// Each top-level block gets a wrapper box carrying its measured height. Off screen, the wrapper +/// is emitted with that height and its contents are skipped entirely; the scroll container still +/// sees the document's true total height, so the scrollbar and every scroll position stay exactly +/// as they were. A block whose height isn't known at all is always drawn, so the cache fills in +/// without ever showing a gap — which makes a document's first frame one full layout, and only +/// one. +/// +/// Widths change every frame while a panel animates open or a window is dragged, which is +/// handled by `resettle_budget` rather than by throwing the cache away — see below. +/// +/// The wrapper is also what makes the ids stable: widget ids inside a block are relative to it, +/// so `ids` restarts per block and a skipped neighbour can't shift anything. +fn renderTopLevel(doc_node: md.Node, ids: *IdGen, ctx: RenderContext) void { + const rs = ctx.rs; + if (rs.block_layout_width != ctx.column_width) { + // A width change reflows every wrapped paragraph, so no cached height is right any more. + // But *discarding* them would make every block "never measured" and force a full-document + // layout on that frame — and the widths that change are the ones that change every frame: + // the panel's open animation sliding out, and a window resize drag. So the heights are + // kept as estimates and merely stop being trusted: they still place each block (which is + // what keeps the on-screen set correct and gap-free — a skipped block really does occupy + // its estimate this frame), while `resettle_budget` re-measures a few per frame until + // they're all true again. + for (rs.block_heights.items) |*e| e.settled = false; + rs.block_layout_width = ctx.column_width; + } + + // Draw beyond the viewport by half a screen each way. dvui needs the widget to exist for a + // frame before it can be scrolled onto properly, and a keyboard/scrollbar jump can move the + // viewport by more than a wheel tick does; the margin absorbs both without being large + // enough to matter for cost. + const virtualize = virtualize_blocks and ctx.viewport.h > 0; + const slack = @max(200, ctx.viewport.h * 0.5); + const vis_top = ctx.viewport.y - slack; + const vis_bot = ctx.viewport.y + ctx.viewport.h + slack; + + // Off-screen blocks re-measured this frame, from the top down: the top of the document is + // what everything below is positioned against, so settling it first stops the visible content + // from wandering. The cost of one is roughly the cost of one visible block, which is what + // sets the size of this number. + var resettle_left: usize = resettle_budget; + + var y = ctx.content_origin_y; + var index: usize = 0; + var child = doc_node.firstChild(); + while (child) |ch| : ({ + child = ch.nextSibling(); + index += 1; + }) { + // A block never laid out yet is placed by a guess from how much source it came from. + // The guess only has to be good enough to decide "near the viewport or not", and it is + // deliberately biased *low* (see `estimateBlockHeight`): guessing short draws a few extra + // blocks, while guessing tall would skip one that is actually on screen and flash a gap. + const known: ?BlockHeight = if (index < rs.block_heights.items.len) + rs.block_heights.items[index] + else if (estimateBlockHeight(rs, index, ctx.column_width)) |est| + BlockHeight{ .h = est, .settled = false } + else + null; + const on_screen_est = known == null or (y < vis_bot and (y + known.?.h) > vis_top); + var draw = !virtualize or on_screen_est; + if (!draw and !known.?.settled) { + if (resettle_left > 0) { + draw = true; + resettle_left -= 1; + } else { + // Owed a re-measure that this frame's budget couldn't pay for; see + // `Stats.pending_measure`. + stats.pending_measure += 1; + } + } + + var wrapper = box(@src(), .{ .dir = .vertical }, .{ + .expand = .horizontal, + .id_extra = index, + // Only when skipping: a drawn block must be free to report a *smaller* height than + // last frame's, and a floor of the old value would keep it from ever shrinking. + .min_size_content = if (draw) null else dvui.Size{ .h = known.?.h }, + }); + const wrapper_id = wrapper.data().id; + const prof_t0 = if (block_profile == null) 0 else std.Io.Clock.boot.now(dvui.io).nanoseconds; + const prof_tl = stats.text_layouts; + const prof_bytes = stats.add_text_bytes; + if (draw) { + ids.n = 0; + renderBlock(ch, ids, ctx); + } + wrapper.deinit(); + if (block_profile) |list| { + list.append(block_profile_gpa.?, .{ + .index = index, + .kind = ch.typeString(), + .ns = @intCast(std.Io.Clock.boot.now(dvui.io).nanoseconds - prof_t0), + .text_layouts = stats.text_layouts - prof_tl, + .add_text_bytes = stats.add_text_bytes - prof_bytes, + }) catch {}; + } + + const entry: BlockHeight = if (draw) blk: { + const measured = (dvui.minSizeGet(wrapper_id) orelse dvui.Size{}).h; + // Now that the height is known, was the block *really* on screen? The estimate above + // decides what to draw; this decides what to believe. They differ exactly where it + // matters: on the first frame every block is drawn, but a table drawn off screen + // reports its header's height and nothing more. + const was_visible = y < vis_bot and (y + measured) > vis_top; + if (!was_visible and rs.subtree_has_table.contains(@intFromPtr(ch.n))) { + // Keep the height from the last time it was on screen. With no such height yet, + // the collapsed one is still a better placeholder than nothing — and either way + // this block is done being re-measured until it is scrolled to, so mark it + // settled rather than let it eat the budget every frame forever. + break :blk .{ .h = if (known) |k| k.h else measured, .settled = true }; + } + break :blk .{ .h = measured, .settled = known != null and known.?.h == measured }; + } else known.?; + if (index < rs.block_heights.items.len) { + rs.block_heights.items[index] = entry; + } else { + rs.block_heights.append(ctx.gpa, entry) catch {}; + } + y += entry.h; + } +} + +/// Rough laid-out height for a block that has never been drawn, from the source it was parsed +/// from. Null when there is nothing to go on, which means "draw it". +/// +/// Biased low on purpose — an underestimate costs a few extra blocks of layout, an overestimate +/// costs a visible gap where a block should have been. +fn estimateBlockHeight(rs: *RenderState, index: usize, column_width: f32) ?f32 { + if (index >= rs.block_source.items.len) return null; + const extent = rs.block_source.items[index]; + if (extent.lines == 0) return null; + + const font = dvui.Font.theme(.body); + const line_h = font.lineHeight(); + // `sizeM` is the width of an "M"; ordinary prose averages a good deal narrower than that, and + // erring narrow means erring toward *more* estimated lines, which is the safe direction. + const avg_char_w = @max(1, font.sizeM(1, 1).w * 0.5); + const chars_per_line = @max(20, column_width / avg_char_w); + const wrapped: f32 = @ceil(@as(f32, @floatFromInt(extent.bytes)) / chars_per_line); + const lines = @max(@as(f32, @floatFromInt(extent.lines)), wrapped); + return lines * line_h * 0.8; +} + +/// True when any cell in this table row still needs a real layout pass — never measured, measured +/// only once and so possibly still settling, or measured against a column width the grid has +/// since changed its mind about. +fn rowNeedsMeasure(ctx: RenderContext, g: *dvui.GridWidget, row: md.Node) bool { + var col: usize = 0; + var cl = row.firstChild(); + while (cl) |cell| : (cl = cell.nextSibling()) { + if (extKind(ctx, cell) != .table_cell) continue; + defer col += 1; + const cached = ctx.rs.cell_sizes.get(@intFromPtr(cell.n)) orelse return true; + if (!cached.settled or cached.col_w != g.colWidth(col)) return true; + } + return false; +} + fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { + statBlock(); const t = n.nodeType(); switch (t) { - md.c.CMARK_NODE_DOCUMENT => { - var c = n.firstChild(); - while (c) |ch| : (c = ch.nextSibling()) renderBlock(ch, ids, ctx); - }, + md.c.CMARK_NODE_DOCUMENT => renderTopLevel(n, ids, ctx), md.c.CMARK_NODE_BLOCK_QUOTE => { - var outer = dvui.box(@src(), .{ .dir = .horizontal }, .{ + var outer = box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .margin = .{ .x = 4, .y = 4, .w = 4, .h = 4 }, .id_extra = ids.next(), @@ -1245,7 +1632,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { .id_extra = ids.next(), }); - var content = dvui.box(@src(), .{ .dir = .vertical }, .{ + var content = box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .padding = .{ .x = 10, .y = 4, .w = 0, .h = 4 }, .id_extra = ids.next(), @@ -1268,7 +1655,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { renderBlock(item_node, ids, ctx); continue; } - var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ + var row = box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .margin = .{ .y = 1 }, .id_extra = ids.next(), @@ -1284,7 +1671,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { if (list_kind == .ol) idx += 1; { - var pb = dvui.box(@src(), .{ .dir = .horizontal }, .{ + var pb = box(@src(), .{ .dir = .horizontal }, .{ .min_size_content = .{ .w = col_w, .h = 0 }, .gravity_y = 0, // The item's content is a paragraph, and `CMARK_NODE_PARAGRAPH` gives its @@ -1308,7 +1695,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 5, .h = 0 }, .id_extra = ids.next() }); - var col = dvui.box(@src(), .{ .dir = .vertical }, .{ + var col = box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .id_extra = ids.next(), }); @@ -1327,7 +1714,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { md.c.CMARK_NODE_CODE_BLOCK => { const info = n.fenceInfo() orelse ""; const code = n.literal() orelse ""; - var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ + var outer = box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .margin = .{ .y = 6 }, .background = true, @@ -1340,7 +1727,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { defer outer.deinit(); if (info.len > 0) { - var hdr = dvui.box(@src(), .{ .dir = .horizontal }, .{ + var hdr = box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .padding = .{ .x = 10, .y = 5, .w = 10, .h = 5 }, .background = true, @@ -1348,28 +1735,28 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { .id_extra = ids.next(), }); defer hdr.deinit(); - var tl_i = dvui.textLayout(@src(), .{}, .{ .expand = .horizontal, .background = false, .id_extra = ids.next() }); - tl_i.addText(info, .{ + var tl_i = textLayout(@src(), .{}, .{ .expand = .horizontal, .background = false, .id_extra = ids.next() }); + addText(tl_i, info, .{ .font = dvui.Font.theme(.mono).withWeight(.bold), .color_text = dvui.themeGet().color(.control, .text).opacity(0.55), }); tl_i.deinit(); } - var tl_c = dvui.textLayout(@src(), .{}, .{ + var tl_c = textLayout(@src(), .{}, .{ .expand = .horizontal, .padding = .{ .x = 10, .y = 8, .w = 10, .h = 8 }, .background = false, .id_extra = ids.next(), }); defer tl_c.deinit(); - tl_c.addText(code, .{ .font = dvui.Font.theme(.mono) }); + addText(tl_c, code, .{ .font = dvui.Font.theme(.mono) }); }, md.c.CMARK_NODE_HTML_BLOCK => { // `

` is how most READMEs carry their hero image; render // the images and drop the wrapper markup rather than dumping the tags as raw text. if (ctx.rs.html_images.get(@intFromPtr(n.n))) |urls| { - var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ + var outer = box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .id_extra = ids.next(), }); @@ -1384,7 +1771,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { return; } if (n.literal()) |h| { - var tl = dvui.textLayout(@src(), .{}, .{ + var tl = textLayout(@src(), .{}, .{ .expand = .horizontal, .margin = .{ .y = 2 }, .padding = .{ .x = 8, .y = 4, .w = 8, .h = 4 }, @@ -1393,7 +1780,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { .id_extra = ids.next(), }); defer tl.deinit(); - tl.addText(h, .{ + addText(tl, h, .{ .font = dvui.Font.theme(.mono), .color_text = dvui.themeGet().color(.err, .text).opacity(0.85), }); @@ -1401,7 +1788,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { }, md.c.CMARK_NODE_PARAGRAPH => { if (!hasImageSubtree(ctx, n)) { - var tl = dvui.textLayout(@src(), .{}, .{ + var tl = textLayout(@src(), .{}, .{ .expand = .horizontal, .margin = .{ .y = paragraph_margin_y, .h = paragraph_margin_y }, .background = ctx.background, @@ -1410,7 +1797,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { defer tl.deinit(); renderInlines(tl, n, .{ .background = ctx.background }, ctx, ids); } else { - var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ + var outer = box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .margin = .{ .y = paragraph_margin_y, .h = paragraph_margin_y }, .id_extra = ids.next(), @@ -1438,7 +1825,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { const span: dvui.Options = .{ .font = heading_font, .background = ctx.background }; if (!hasImageSubtree(ctx, n)) { - var tl = dvui.textLayout(@src(), .{}, .{ + var tl = textLayout(@src(), .{}, .{ .expand = .horizontal, .margin = .{ .y = top_margin, .h = 2 }, .font = heading_font, @@ -1448,7 +1835,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { defer tl.deinit(); renderInlines(tl, n, span, ctx, ids); } else { - var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ + var outer = box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .margin = .{ .y = top_margin, .h = 2 }, .id_extra = ids.next(), @@ -1467,7 +1854,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { }, md.c.CMARK_NODE_FOOTNOTE_DEFINITION => { if (n.literal()) |name| { - var tl = dvui.textLayout(@src(), .{}, .{ + var tl = textLayout(@src(), .{}, .{ .expand = .horizontal, .margin = .{ .y = 4 }, .background = ctx.background, @@ -1475,9 +1862,9 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { }); const fn_font = dvui.Font.theme(.mono).larger(-1); const fn_color = dvui.themeGet().focus.opacity(0.8); - tl.addText("[^", .{ .font = fn_font, .color_text = fn_color }); - tl.addText(name, .{ .font = fn_font, .color_text = fn_color }); - tl.addText("]: ", .{ .font = fn_font, .color_text = fn_color }); + addText(tl, "[^", .{ .font = fn_font, .color_text = fn_color }); + addText(tl, name, .{ .font = fn_font, .color_text = fn_color }); + addText(tl, "]: ", .{ .font = fn_font, .color_text = fn_color }); tl.deinit(); } var c = n.firstChild(); @@ -1490,7 +1877,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { const num_cols = ctx.rs.table_col_counts.get(@intFromPtr(n.n)) orelse return; if (num_cols == 0) return; - var table_wrap = dvui.box(@src(), .{ .dir = .vertical }, .{ + var table_wrap = box(@src(), .{ .dir = .vertical }, .{ .expand = .none, .margin = .{ .y = 6 }, .id_extra = ids.next(), @@ -1529,6 +1916,18 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { } }; + // Screen-space band a row has to touch to be laid out, with the same half-screen + // of slack `renderTopLevel` gives blocks. + const cull_rows = virtualize_blocks and ctx.viewport.h > 0; + const clip = dvui.clipGet(); + const row_slack = @max(200 * clip.h / @max(1, ctx.viewport.h), clip.h * 0.5); + const row_clip_top = clip.y - row_slack; + const row_clip_bot = clip.y + clip.h + row_slack; + var row_anchor: ?struct { screen_y: f32, scale: f32, row_offset: f32 } = null; + var measure_bytes_left: u64 = table_measure_bytes; + var first_sight_left: u64 = table_first_sight_bytes; + var rows_unsettled = false; + var body_row: usize = 0; var c = n.firstChild(); while (c) |row| : (c = row.nextSibling()) { @@ -1553,18 +1952,120 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { col += 1; } } else { + // Is this row anywhere near the screen? A markdown table is drawn as a + // *content-sized* grid — it scrolls with the page rather than inside + // itself — so dvui's own row virtualization (`GridWidget.rowsVisible`) + // can't help: as far as the grid is concerned its whole body is in view. + // Without this, one 45KB table (docs/PLUGIN_MANIFEST_PLAN.md has one) is + // laid out in full on every frame it appears on, which costs more than + // the rest of that document put together. + // + // The anchor comes from the first body cell rather than from the grid's + // internals: every cell's rect is placed from the grid's own cached row + // heights, so one real cell rect plus `rowOffset`/`rowHeight` gives every + // other row's position exactly. + const measured_before = !rowNeedsMeasure(ctx, g, row); + if (!measured_before) rows_unsettled = true; + var row_visible = if (!cull_rows or row_anchor == null) true else blk: { + const a = row_anchor.?; + const top = a.screen_y + (g.rowOffset(body_row) - a.row_offset) * a.scale; + const h = g.rowHeight(body_row) * a.scale; + break :blk top < row_clip_bot and (top + h) > row_clip_top; + }; + // A row with no measurements behind it has no trustworthy position either + // — see `table_first_sight_rows`. + if (row_visible and !measured_before and cull_rows and first_sight_left == 0) + row_visible = false; + + // An off-screen row whose cells have never been measured (or whose + // measurement hasn't been confirmed by a second draw) is worth one + // measuring pass so the table's height is right — but only a few such + // rows per frame, so a big table costs a little on each of several frames + // instead of all of it on the frame the document opens. + const measure_row = !row_visible and !measured_before and measure_bytes_left > 0; + // An off-screen row that isn't settled yet is work owed, not work dropped: + // `renderDocument` asks for another frame while any remains. It stays + // owed on the frame it *is* measured on, because a measurement only + // counts once a second pass agrees with it. + if (!measured_before and !row_visible) stats.pending_measure += 1; + const row_text_before = stats.add_text_bytes; + var col: usize = 0; var cl = row.firstChild(); while (cl) |cell| : (cl = cell.nextSibling()) { if (extKind(ctx, cell) != .table_cell) continue; - const cell_box = g.cell(.{ .col = col, .row = body_row }, banded.opts(body_row, cell_padding)); - defer cell_box.deinit(); - renderInlineFlowContainer(cell, .{ .background = false }, ctx, ids); + // A skipped cell still has to hand the grid the size its contents + // would have, or the row collapses and the column shrinks to whatever + // happens to be on screen. A cell with no measurement yet — or one + // whose measurement hasn't been confirmed by a second draw — is drawn + // regardless of where it is, which is what makes the table's total + // height right from the first frame it appears on. + const cell_key = @intFromPtr(cell.n); + const cached = ctx.rs.cell_sizes.get(cell_key); + // Read before the cell exists, so it is the width this cell is about + // to be laid out in rather than the one it produces. + const cell_w = g.colWidth(col); + const draw_cell = row_visible or measure_row; + const cell_box = g.cell( + .{ .col = col, .row = body_row }, + banded.opts(body_row, cell_padding).override( + if (draw_cell) .{} else .{ .min_size_content = if (cached) |cs| cs.size else dvui.Size{} }, + ), + ); + if (row_anchor == null) { + const rs = cell_box.data().rectScale(); + row_anchor = .{ + .screen_y = rs.r.y, + .scale = rs.s, + .row_offset = g.rowOffset(body_row), + }; + } + if (draw_cell) { + // Ids inside a cell hang off the cell widget, so restarting them + // per cell keeps a skipped neighbour from shifting anything. + ids.n = 0; + renderInlineFlowContainer(cell, .{ .background = false }, ctx, ids); + // Read before `deinit`, and with the padding taken back off: + // `min_size_content` has the padding added to it again, so + // storing the padded size would grow the cell every frame. + const measured: dvui.Size = .{ + .w = @max(0, cell_box.data().min_size.w - cell_padding.x - cell_padding.w), + .h = @max(0, cell_box.data().min_size.h - cell_padding.y - cell_padding.h), + }; + const agrees = cached != null and cached.?.col_w == cell_w and + cached.?.size.w == measured.w and cached.?.size.h == measured.h; + ctx.rs.cell_sizes.put(ctx.gpa, cell_key, .{ + .size = measured, + .col_w = cell_w, + .settled = agrees, + }) catch {}; + } + cell_box.deinit(); col += 1; } + // Charge whichever budget paid for this row. Measured after the fact: a + // row's size is only known once it has been laid out, so a budget can be + // overshot by at most the one row that exhausts it. + const row_text = stats.add_text_bytes - row_text_before; + if (row_visible and !measured_before) { + first_sight_left -|= row_text; + } else if (measure_row) { + measure_bytes_left -|= row_text; + } body_row += 1; } } + // A grid only writes the row heights it measured into the ones it *keeps* during an + // auto-size pass, and it stops running those passes as soon as a frame changes + // nothing. A row measured after that point — which, with the measuring budget + // above, is most of a big table's rows — would have its height computed and then + // thrown away, leaving the row stuck at the grid's minimum. So the pass is re-armed + // for as long as any row is still settling. + // + // Only for as long, though: `autoSize` re-arms itself and refreshes each frame + // until the measurements agree, so calling it unconditionally would leave the + // preview repainting forever. + if (rows_unsettled) g.autoSize(.{ .auto = .both }); } else { var c = n.firstChild(); while (c) |ch| : (c = ch.nextSibling()) renderBlock(ch, ids, ctx); @@ -1574,11 +2075,26 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { } pub fn renderDocument(root: md.Node, ctx: RenderContext) void { + // Per-draw counters reset here; `render_ns` deliberately accumulates (see `Stats`). + const carried_ns = stats.render_ns; + const carried_parse_ns = stats.parse_ns; + stats = .{ .render_ns = carried_ns, .parse_ns = carried_parse_ns }; + // wasm has no monotonic clock wired into `dvui.io` (`std.Io.failing` returns zero for every + // timestamp), so the counters above are the whole story there; timing is native-only. + const t0: i128 = if (comptime builtin.target.cpu.arch == .wasm32) 0 else std.Io.Clock.boot.now(dvui.io).nanoseconds; + var resolved_ctx = ctx; resolved_ctx.wikilink = wikilinkResolver(ctx); var ids: IdGen = .{ .n = ctx.id_base }; renderBlock(root, &ids, resolved_ctx); + + // Keep frames coming until the measuring budgets have caught up — see `Stats.pending_measure`. + if (stats.pending_measure > 0) dvui.refresh(null, @src(), null); + + if (comptime builtin.target.cpu.arch != .wasm32) { + stats.render_ns +%= @intCast(std.Io.Clock.boot.now(dvui.io).nanoseconds - t0); + } } /// The wikilink resolver to use for this document draw, or null when wikilinks are off. diff --git a/src/plugins/text/src/widgets/TextEntryWidget.zig b/src/plugins/text/src/widgets/TextEntryWidget.zig index 349a6833..98b907a8 100644 --- a/src/plugins/text/src/widgets/TextEntryWidget.zig +++ b/src/plugins/text/src/widgets/TextEntryWidget.zig @@ -938,53 +938,58 @@ pub fn draw(self: *TextEntryWidget) void { defer dvui.c.ts_query_cursor_delete(qc); dvui.c.ts_query_cursor_set_match_limit(qc, tree_sitter_match_limit); - dvui.c.ts_query_cursor_exec(qc, ts_parser.query, root); - - var iter = ts_parser.queryCursorCaptureIterator(qc.?, self.text); - iter.debug = ts.log_captures; - // Restrict the capture walk to what's actually on screen — this is the dominant - // per-frame cost of a highlighted document (see `highlightByteRange` for why it - // can't just reuse dvui's layout range). Text outside the queried range still - // renders via the gap/leftover chunks below; it's just uncolored until scrolled - // into range. - if (self.highlightByteRange()) |r| { - iter.setByteRange(r.start, r.end); + // per-frame cost of a highlighted document, and it comes as several ranges rather + // than one (see `highlightRanges`). Text outside them still renders via the + // gap/leftover chunks below; it's just uncolored until scrolled into range. + var range_buf: [max_highlight_ranges]ByteRange = undefined; + var ranges = self.highlightRanges(&range_buf); + if (ranges.len == 0) { + range_buf[0] = .{ .start = 0, .end = self.len }; + ranges = range_buf[0..1]; } - while (true) { - //const capture_start = perfBegin(); - const maybe_match = iter.next(); - //perfAccumCapture(capture_start); - const match = maybe_match orelse break; - - const nstart = dvui.c.ts_node_start_byte(match.node); - const nend = dvui.c.ts_node_end_byte(match.node); - if (start < nstart) { - // render non highlighted text up to this node - //const shape_start = perfBegin(); - self.emitChunk(start, self.text[start..nstart], .{}, false, true); - //perfAccumShape(shape_start); - } else if (nstart < start) { - // this match is inside (or overlapping) the previous match - // maybe we could be smarter here, but for now drop it - continue; - } - var opts: dvui.Options = .{}; - const capture_name = match.captureName(); - for (0..ts.highlights.len) |i| { - const sh = ts.highlights[ts.highlights.len - i - 1]; - if (std.mem.startsWith(u8, capture_name, sh.name)) { - opts = sh.opts; - break; + for (ranges) |r| { + dvui.c.ts_query_cursor_exec(qc, ts_parser.query, root); + var iter = ts_parser.queryCursorCaptureIterator(qc.?, self.text); + iter.debug = ts.log_captures; + iter.setByteRange(r.start, r.end); + + while (true) { + //const capture_start = perfBegin(); + const maybe_match = iter.next(); + //perfAccumCapture(capture_start); + const match = maybe_match orelse break; + + const nstart = dvui.c.ts_node_start_byte(match.node); + const nend = dvui.c.ts_node_end_byte(match.node); + if (start < nstart) { + // render non highlighted text up to this node + //const shape_start = perfBegin(); + self.emitChunk(start, self.text[start..nstart], .{}, false, true); + //perfAccumShape(shape_start); + } else if (nstart < start) { + // this match is inside (or overlapping) the previous match + // maybe we could be smarter here, but for now drop it + continue; } - } - //const shape_start = perfBegin(); - self.emitChunk(nstart, self.text[nstart..nend], opts, true, captureAllowsRainbow(capture_name)); - //perfAccumShape(shape_start); + var opts: dvui.Options = .{}; + const capture_name = match.captureName(); + for (0..ts.highlights.len) |i| { + const sh = ts.highlights[ts.highlights.len - i - 1]; + if (std.mem.startsWith(u8, capture_name, sh.name)) { + opts = sh.opts; + break; + } + } + + //const shape_start = perfBegin(); + self.emitChunk(nstart, self.text[nstart..nend], opts, true, captureAllowsRainbow(capture_name)); + //perfAccumShape(shape_start); - start = nend; + start = nend; + } } if (start < self.len) { @@ -1063,6 +1068,50 @@ pub fn highlightByteRange(self: *TextEntryWidget) ?ByteRange { }; } +/// The most byte ranges `highlightRanges` will query in one frame. Every range costs another +/// `ts_query_cursor_exec` and tree descent, and dvui reports at most +/// `TextLayoutWidget.VisibleRanges.max` runs anyway. +const max_highlight_ranges = dvui.TextLayoutWidget.VisibleRanges.max; + +/// `highlightByteRange` split into the runs actually worth querying, in increasing byte order. +/// +/// The single interval isn't enough on its own: a line wider than the viewport is on screen at +/// its left edge and again — as a different line — below it, so an interval covering both also +/// covers the line's off-screen middle. That middle is where a pathological line keeps all of its +/// syntax nodes, so querying it costs the whole frame (36ms for one 200k-character line) to color +/// pixels that don't exist. dvui reports which bytes its layout actually put on screen last +/// frame, gaps included; each run gets its own query pass, and the gaps between them come out as +/// uncolored text nobody can see. +/// +/// Each run is padded, and the result clipped back to `highlightByteRange`, for the same reason +/// that range is padded: dvui's runs are a frame stale, so a scroll or edit has to be able to +/// land inside them and still be colored. +fn highlightRanges(self: *TextEntryWidget, buf: *[max_highlight_ranges]ByteRange) []const ByteRange { + const range = self.highlightByteRange() orelse return &.{}; + const visible = self.textLayout.visibleBytesLastFrame(); + if (visible.len == 0) { + buf[0] = range; + return buf[0..1]; + } + + var n: usize = 0; + for (visible) |vis| { + const headroom = @max(2 * (vis.end -| vis.start), 4096); + const start = @max(range.start, vis.start -| headroom); + const end = @min(range.end, vis.end +| headroom); + if (end <= start) continue; + // Padding can make neighbouring runs meet; two passes over one span would emit the same + // captures twice, which the emit loop reads as overlapping matches and drops. + if (n > 0 and start <= buf[n - 1].end) { + buf[n - 1].end = @max(buf[n - 1].end, end); + } else { + buf[n] = .{ .start = start, .end = end }; + n += 1; + } + } + return buf[0..n]; +} + /// One ghost-text splice resolved for this frame: `text` shown dimmed at byte offset `anchor`. /// `emitChunk` sources this from `current_completion` (acceptable via Tab/Enter) when showing, /// else `signature_hint` (purely informational, never acceptable) — see `signature_hint`'s doc diff --git a/src/plugins/workbench/src/Workspace.zig b/src/plugins/workbench/src/Workspace.zig index 2dfda1b7..60ecb6b6 100644 --- a/src/plugins/workbench/src/Workspace.zig +++ b/src/plugins/workbench/src/Workspace.zig @@ -880,7 +880,7 @@ pub fn drawHomePage(_: *Workspace) !void { .gravity_x = 0.5, .expand = .horizontal, .padding = dvui.Rect.all(2), - .color_fill = .transparent, + .color_fill = wdvui.hoverRestFill(dvui.themeGet().color(.window, .fill_hover)), .color_fill_hover = dvui.themeGet().color(.window, .fill_hover), .color_fill_press = dvui.themeGet().color(.window, .fill_press), }); @@ -908,7 +908,7 @@ pub fn drawHomePage(_: *Workspace) !void { .gravity_x = 0.5, .expand = .horizontal, .padding = dvui.Rect.all(2), - .color_fill = .transparent, + .color_fill = wdvui.hoverRestFill(dvui.themeGet().color(.window, .fill_hover)), .color_fill_hover = dvui.themeGet().color(.window, .fill_hover), .color_fill_press = dvui.themeGet().color(.window, .fill_press), }); @@ -936,7 +936,7 @@ pub fn drawHomePage(_: *Workspace) !void { .gravity_x = 0.5, .expand = .horizontal, .padding = dvui.Rect.all(2), - .color_fill = .transparent, + .color_fill = wdvui.hoverRestFill(dvui.themeGet().color(.window, .fill_hover)), .color_fill_hover = dvui.themeGet().color(.window, .fill_hover), .color_fill_press = dvui.themeGet().color(.window, .fill_press), }); @@ -999,7 +999,7 @@ pub fn drawHomePage(_: *Workspace) !void { .id_extra = i, .margin = dvui.Rect.all(1), .padding = dvui.Rect.all(2), - .color_fill = .transparent, + .color_fill = wdvui.hoverRestFill(dvui.themeGet().color(.window, .fill_hover)), .color_fill_hover = dvui.themeGet().color(.window, .fill_hover), .color_fill_press = dvui.themeGet().color(.window, .fill_press), .color_text = dvui.themeGet().color(.control, .text).opacity(0.5), diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig index 8932ced6..67f2bfa0 100644 --- a/src/plugins/workbench/src/files.zig +++ b/src/plugins/workbench/src/files.zig @@ -773,7 +773,10 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u //.color_fill_hover = .fill, .color_fill_hover = dvui.themeGet().color(.control, .fill).opacity(0.5), .color_fill_press = dvui.themeGet().color(.control, .fill_press), - .color_fill = if (selected and tree.drag_point == null) dvui.themeGet().color(.control, .fill).opacity(0.5) else .transparent, + .color_fill = if (selected and tree.drag_point == null) + dvui.themeGet().color(.control, .fill).opacity(0.5) + else + wdvui.hoverRestFill(dvui.themeGet().color(.control, .fill)), .padding = dvui.Rect.all(1), }); defer branch.deinit(); diff --git a/src/sdk/EditorAPI.zig b/src/sdk/EditorAPI.zig index c9db673e..74f428a9 100644 --- a/src/sdk/EditorAPI.zig +++ b/src/sdk/EditorAPI.zig @@ -125,6 +125,10 @@ pub const VTable = struct { name: []const u8, kind: std.Io.File.Kind, ) bool, + /// True when fizzy has a live filesystem watcher on the open root folder, i.e. when + /// `Plugin.VTable.folderPathsChanged` can be relied on to fire. False with no folder open, + /// on a platform with no watcher backend, or when starting one failed. + folderWatchActive: *const fn (ctx: *anyopaque) bool, /// Explorer tree branch expanded state. explorerBranchIsOpen: *const fn (ctx: *anyopaque, branch_id: dvui.Id) bool, setExplorerBranchOpen: *const fn (ctx: *anyopaque, branch_id: dvui.Id, open: bool) void, @@ -346,6 +350,10 @@ pub fn isPathIgnored( return self.vtable.isPathIgnored(self.ctx, project_root, abs_path, name, kind); } +pub fn folderWatchActive(self: EditorAPI) bool { + return self.vtable.folderWatchActive(self.ctx); +} + pub fn explorerBranchIsOpen(self: EditorAPI, branch_id: dvui.Id) bool { return self.vtable.explorerBranchIsOpen(self.ctx, branch_id); } diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig index e587c9bf..1cc3f692 100644 --- a/src/sdk/Host.zig +++ b/src/sdk/Host.zig @@ -355,6 +355,14 @@ pub fn isPathIgnored( return if (self.fizzy_api) |a| a.isPathIgnored(project_root, abs_path, name, kind) else false; } +/// True when fizzy has a live filesystem watcher on the open root folder — i.e. when +/// `Plugin.VTable.folderPathsChanged` will actually fire. A plugin that must stay correct +/// (an index, a file tree) should keep a slow rescan for when this is false, and can skip it +/// entirely when it is true. +pub fn folderWatchActive(self: *Host) bool { + return if (self.fizzy_api) |a| a.folderWatchActive() else false; +} + pub fn explorerBranchIsOpen(self: *Host, branch_id: dvui.Id) bool { return if (self.fizzy_api) |a| a.explorerBranchIsOpen(branch_id) else false; } @@ -668,6 +676,14 @@ pub fn notifyDocumentContentChanged(self: *Host, path: []const u8, bytes: []cons for (self.plugins.items) |plugin| plugin.documentContentChanged(path, bytes); } +/// Broadcast a coalesced batch of on-disk changes under the open root folder to every plugin. +/// +/// Called by `FolderWatcher.tick` on the UI thread, never from the watcher's own thread — see +/// `Plugin.VTable.folderPathsChanged` for the contract this upholds. +pub fn notifyFolderPathsChanged(self: *Host, changes: Plugin.PathChanges) void { + for (self.plugins.items) |plugin| plugin.folderPathsChanged(changes); +} + /// First registered plugin that implements `createDocument` (for fizzy New File flows). pub fn pluginWithCreateDocument(self: *Host) ?*Plugin { for (self.plugins.items) |plugin| { diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig index 8223a617..36aff6ad 100644 --- a/src/sdk/Plugin.zig +++ b/src/sdk/Plugin.zig @@ -19,6 +19,35 @@ pub const Plugin = @This(); /// claim so `Host.pluginForExtension` only picks it as a fallback. pub const file_type_fallback_priority: u8 = 100; +/// One filesystem change under the open root folder, delivered via +/// `VTable.folderPathsChanged`. +pub const PathEvent = struct { + /// Absolute path of the affected object. Valid only for the duration of the call. + path: []const u8, + kind: Kind, + object: ObjectType, + /// The pre-rename path, for `.renamed` only — and only on platforms whose watcher can pair + /// the two halves (Linux, Windows). Elsewhere a rename arrives as `.deleted` + `.created`, + /// which is why a consumer must handle that shape regardless. + old_path: []const u8 = "", + + pub const Kind = enum { created, modified, deleted, renamed }; + /// `.unknown` happens when the object is already gone by the time fizzy looks (a delete on + /// Windows, mostly) — treat it as "could be either". + pub const ObjectType = enum { file, dir, unknown }; +}; + +/// A coalesced batch of filesystem changes. +pub const PathChanges = struct { + /// Valid only for the duration of the call — copy anything you keep. Already filtered + /// against fizzy's ignore rules, so `.git`, build caches and gitignored paths never appear. + events: []const PathEvent, + /// More changes arrived than fizzy could buffer, so `events` is an incomplete picture of + /// what happened. A consumer that must not miss anything should rescan the folder rather + /// than trusting the list. Expect this during a build, a branch switch, or an npm install. + truncated: bool, +}; + /// Opaque, plugin-owned state passed back to every vtable call. state: *anyopaque, vtable: *const VTable, @@ -199,6 +228,29 @@ pub const VTable = struct { /// once the on-disk version catches up, not as a reason to write anything through. documentContentChanged: ?*const fn (state: *anyopaque, path: []const u8, bytes: []const u8) void = null, + // ---- filesystem ---- + /// [broadcast] Files under the open root folder changed **on disk**. Fired for every + /// registered plugin — a file tree keeping itself current, a link indexer, a language + /// server syncing `didChangeWatchedFiles`. + /// + /// The counterpart to `documentContentChanged`, and the two are not interchangeable: that + /// one reports *unsaved buffers* fizzy already knows about, this one reports *the disk*, + /// including files nothing has open and changes fizzy had no part in — an agent editing + /// the tree, a `git checkout`, another editor. Neither implies the other, and a plugin + /// wanting a complete picture wants both. + /// + /// Contract: + /// - Delivered on the **UI thread**, from fizzy's frame tick — never the watcher's thread. + /// A dylib must not take a callback on a thread it did not create. + /// - **Coalesced** (~200ms) so one logical save doesn't arrive as five events, and + /// **pre-filtered** against fizzy's ignore rules, so `.git`, build output and gitignored + /// paths are already gone. + /// - Best-effort. Not every platform has a working watcher, and one that does can still + /// drop events under load (see `PathChanges.truncated`). Treat this as a prompt to go + /// look, not as a ledger. `host.folderWatchActive()` says whether it is running at all; + /// a plugin that must stay correct should keep a slow rescan for when it isn't. + folderPathsChanged: ?*const fn (state: *anyopaque, changes: PathChanges) void = null, + // ---- save protocol ---- /// [active-doc] True when the owner wants a confirmation before `saveDocument` (e.g. a save /// that would flatten lossy data, change encoding, or overwrite an on-disk change). When @@ -305,6 +357,10 @@ pub fn documentContentChanged(self: Plugin, path: []const u8, bytes: []const u8) if (self.vtable.documentContentChanged) |f| f(self.state, path, bytes); } +pub fn folderPathsChanged(self: Plugin, changes: PathChanges) void { + if (self.vtable.folderPathsChanged) |f| f(self.state, changes); +} + pub fn bindDocumentToPane(self: Plugin, doc: DocHandle, canvas_id: dvui.Id, workspace_handle: *anyopaque, center: bool) void { if (self.vtable.bindDocumentToPane) |f| f(self.state, doc, canvas_id, workspace_handle, center); } diff --git a/src/sdk/version.zig b/src/sdk/version.zig index 0c212369..fcabac6b 100644 --- a/src/sdk/version.zig +++ b/src/sdk/version.zig @@ -71,7 +71,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value (from the `@compileError` it triggers) and bump `sdk_version` in the same commit /// whenever it changes. -pub const recorded_sdk_shape_fingerprint: u64 = 0x45dc3739334bebb; +pub const recorded_sdk_shape_fingerprint: u64 = 0xd2de25bab58a617a; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/src/web_main.zig b/src/web_main.zig index f558d06b..b1fa9f6d 100644 --- a/src/web_main.zig +++ b/src/web_main.zig @@ -19,7 +19,7 @@ const fizzy = @import("fizzy.zig"); // symbols whose files import `@import("backend")` (SDL3) at file scope. Zig's // lazy analysis means a dead/unused file-scope `const` never triggers its // `@import`. We only pay the wasm-incompatibility cost when a reachable function -// actually calls into native APIs. See WEB_PORT_PLAN.md. +// actually calls into native APIs. comptime { // Pure constants / re-exports _ = fizzy.version; diff --git a/tests/bench/bench_markdown.zig b/tests/bench/bench_markdown.zig new file mode 100644 index 00000000..14904319 --- /dev/null +++ b/tests/bench/bench_markdown.zig @@ -0,0 +1,287 @@ +//! `zig build bench-markdown` — frame-cost benchmark for the markdown preview's draw path. +//! +//! Drives the real preview renderer (`src/plugins/markdown`, the same entry point the editor's +//! preview pane and the plugin store's README pane call) over real markdown documents in dvui's +//! headless testing backend, and reports microseconds per frame. Deliberately *not* part of +//! `zig build test`: it prints timings rather than asserting, and timings are machine-dependent. +//! +//! What it can and can't tell you: the testing backend does no GPU work, so this measures the +//! CPU side — the cmark AST walk, widget construction, text shaping and layout. That is where +//! the preview's per-frame time actually goes; the GPU submission it omits doesn't change with +//! document size. +//! +//! Alongside the wall time it prints the renderer's own counters (`render_ast.stats`): how many +//! blocks were visited and how many text layouts / boxes were emitted for a single frame. Those +//! are what the wall time is a function of, and — unlike microseconds — they are exactly +//! reproducible, so they're the number to quote when comparing an optimization across machines. +//! +//! Always compare runs at the same `-Doptimize`. cmark and freetype compile at the app's +//! optimize level, so a Debug run measures unoptimized C and is several times slower than what +//! ships. + +const std = @import("std"); +const dvui = @import("dvui"); +const markdown = @import("markdown"); +const render_ast = markdown.render_ast; + +/// Documents to render. These are the repo's own, wired in as anonymous imports from +/// `build/app.zig` rather than checked in as fixtures — `PLUGINS.md` is the document that +/// prompted this benchmark (single-digit fps in Debug), and the smaller ones separate costs +/// that scale with document size from those that don't. +const sample_huge = @embedFile("sample_huge"); // docs/PLUGINS.md +/// Same size as `sample_huge` but shaped completely differently: very long paragraphs (single +/// blocks of several thousand characters) and several tables. It is the document that stayed slow +/// after block virtualization, which is exactly why it is in here. +const sample_prose = @embedFile("sample_prose"); // docs/PLUGIN_MANIFEST_PLAN.md +const sample_medium = @embedFile("sample_medium"); // CLAUDE.md +const sample_small = @embedFile("sample_small"); // docs/MODULARIZATION_RELEASE_NOTES.md + +/// Live document + preview state for the frame function, which `dvui.App.frameFunction` +/// requires to take no arguments. +var doc: []const u8 = ""; +var preview: markdown.Preview = .{}; +var gpa: std.mem.Allocator = undefined; +/// Width the preview is given this frame, or null for the whole window. Driven per frame by the +/// resize case — the preview pane really does change width every frame while a panel animates +/// open, and that used to invalidate every cached block height at once. +var forced_width: ?f32 = null; +var profile_blocks: bool = false; +var frame_times: [10]i128 = @splat(0); +var open_samples: std.ArrayListUnmanaged(render_ast.BlockSample) = .empty; + +fn frame() !dvui.App.Result { + var b = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = if (forced_width == null) .both else .vertical, + .min_size_content = if (forced_width) |w| dvui.Size{ .w = w } else null, + .max_size_content = if (forced_width) |w| dvui.Options.MaxSize.width(w) else null, + }); + defer b.deinit(); + + // No `document_path`: that disables wikilink resolution, which needs a `Host` this harness + // has no reason to stand up. It is also what the store's README pane passes, so this is a + // path the app really takes rather than one invented for the benchmark. + markdown.drawPreview(&preview, doc, gpa, .{ + .io = dvui.io, + .image_base_dir = ".", + .id_extra = 0, + }); + return .ok; +} + +/// Scrolls the way the user does — a real wheel event through dvui's own event routing. +/// Writing `ScrollInfo.viewport.y` directly instead puts the scroll container in a state its +/// own code never produces, which trips an overflow check inside dvui in ReleaseSafe. +fn sendScroll(ticks: f32) !void { + const cw = dvui.currentWindow(); + _ = try cw.addEventMouseMotion(.{ .pt = .{ .x = 400, .y = 300 } }); + _ = try cw.addEventMouseWheel(ticks, .vertical, null); +} + +fn nowNs() i128 { + return std.Io.Clock.boot.now(dvui.io).nanoseconds; +} + +const Case = struct { + /// Wheel ticks sent once before the timed frames, to park the viewport somewhere other than + /// the very top. + park_scroll_ticks: f32 = 0, + /// Lines scrolled per frame — 0 keeps the viewport still (the idle case, which is what the + /// app spends nearly all its time in). + scroll_lines_per_frame: f32 = 0, + /// Change the pane's width every frame, as a panel's open animation and a window-resize drag + /// both do. This is the case block-height caching is worst at, so it is the one to watch. + resizing: bool = false, + /// Off = the whole document is laid out every frame, which is what this renderer did before + /// `render_ast.renderTopLevel` learned to skip off-screen blocks. Kept as a row in the output + /// so the baseline is measured on the same machine and run as everything it is compared to. + virtualize: bool = true, +}; + +/// Runs timed frames after letting the preview settle, and reports µs/frame plus the render +/// counters for one frame. +fn run(label: []const u8, sample: []const u8, case: Case) !void { + doc = sample; + preview = .{}; + render_ast.virtualize_blocks = case.virtualize; + defer render_ast.virtualize_blocks = true; + + var t = try dvui.testing.init(.{ + .allocator = std.testing.allocator, + .window_size = .{ .w = 1200, .h = 800 }, + }); + defer { + t.deinit(); + preview.deinit(); + } + + // Warm up: the first frames parse the document, build the glyph atlas and settle every + // widget's min size, none of which recur. + for (0..15) |_| _ = try dvui.testing.step(frame); + + if (case.park_scroll_ticks != 0) { + try sendScroll(case.park_scroll_ticks); + for (0..5) |_| _ = try dvui.testing.step(frame); + } + + // Report the *minimum* of several rounds, not the mean: anything else running on the + // machine can only make a round slower, so the fastest round is the closest estimate of the + // work actually being measured. + const rounds: usize = 5; + const iters: usize = 30; + var best_ns: i128 = std.math.maxInt(i128); + var render_ns: u64 = 0; + var counters: render_ast.Stats = .{}; + for (0..rounds) |_| { + // Every round scrolls the same span, so the min across rounds compares like with like. + if (case.scroll_lines_per_frame != 0) { + try sendScroll(10_000); + _ = try dvui.testing.step(frame); + } + const t0 = nowNs(); + render_ast.stats.render_ns = 0; + for (0..iters) |i| { + if (case.scroll_lines_per_frame != 0) try sendScroll(-case.scroll_lines_per_frame * 20); + if (case.resizing) forced_width = 700 + @as(f32, @floatFromInt(i % 30)) * 15; + _ = try dvui.testing.step(frame); + } + forced_width = null; + const round_ns = nowNs() - t0; + if (round_ns < best_ns) { + best_ns = round_ns; + render_ns = render_ast.stats.render_ns / iters; + counters = render_ast.stats; + } + } + const per_frame_us: u64 = @intCast(@divTrunc(best_ns, iters * 1000)); + + if (profile_blocks) { + var samples: std.ArrayListUnmanaged(render_ast.BlockSample) = .empty; + defer samples.deinit(gpa); + render_ast.block_profile = &samples; + render_ast.block_profile_gpa = gpa; + _ = try dvui.testing.step(frame); + render_ast.block_profile = null; + std.mem.sort(render_ast.BlockSample, samples.items, {}, struct { + fn lt(_: void, a: render_ast.BlockSample, b: render_ast.BlockSample) bool { + return a.ns > b.ns; + } + }.lt); + for (samples.items[0..@min(6, samples.items.len)]) |worst| { + std.debug.print(" block {d:>3} {s:<14} {d:>6} us textlayouts={d} text={d}B\n", .{ + worst.index, worst.kind, worst.ns / 1000, worst.text_layouts, worst.add_text_bytes, + }); + } + } + + std.debug.print( + " {s:<30} {d:>6} us/frame ({d:>6} us in renderDocument) blocks={d} textlayouts={d} boxes={d} addText={d}/{d}B\n", + .{ + label, + per_frame_us, + render_ns / 1000, + counters.blocks, + counters.text_layouts, + counters.boxes, + counters.add_text_calls, + counters.add_text_bytes, + }, + ); +} + +/// What opening the file costs: the document is parsed, its blocks are placed for the first time, +/// and the preview settles — timed frame by frame, because this is the hitch the user actually +/// feels (and it lands while the preview panel is animating open, when frames are scarcest). +/// +/// The window is warmed on a *different* document first. dvui's glyph atlas is per-window and the +/// app's window is long-lived, so a cold atlas would charge this measurement for rasterizing every +/// glyph — hundreds of microseconds per block, none of which the real app pays when opening its +/// second markdown file. Warming makes the number mean "opening a document", not "starting fizzy". +fn runOpen(sample: []const u8) !void { + const rounds: usize = 5; + const frames: usize = 10; + var best_ns: i128 = std.math.maxInt(i128); + for (0..rounds) |_| { + var warm: markdown.Preview = .{}; + doc = sample_small; + preview = warm; + var t = try dvui.testing.init(.{ + .allocator = std.testing.allocator, + .window_size = .{ .w = 1200, .h = 800 }, + }); + for (0..20) |_| _ = try dvui.testing.step(frame); + warm = preview; + warm.deinit(); + + // Now open the document under test in that same, warm window. + doc = sample; + preview = .{}; + render_ast.stats.parse_ns = 0; + var samples: std.ArrayListUnmanaged(render_ast.BlockSample) = .empty; + defer samples.deinit(std.testing.allocator); + if (profile_blocks) { + render_ast.block_profile = &samples; + render_ast.block_profile_gpa = std.testing.allocator; + } + const t0 = nowNs(); + var per: [10]i128 = @splat(0); + var prev = t0; + for (0..frames) |i| { + _ = try dvui.testing.step(frame); + if (i == 0) render_ast.block_profile = null; + const now = nowNs(); + per[i] = @divTrunc(now - prev, 1000); + prev = now; + } + if (nowNs() - t0 < best_ns) { + best_ns = nowNs() - t0; + frame_times = per; + open_samples.clearRetainingCapacity(); + open_samples.appendSlice(std.testing.allocator, samples.items) catch {}; + } + render_ast.block_profile = null; + t.deinit(); + preview.deinit(); + } + + if (profile_blocks) { + std.mem.sort(render_ast.BlockSample, open_samples.items, {}, struct { + fn lt(_: void, a: render_ast.BlockSample, b: render_ast.BlockSample) bool { + return a.ns > b.ns; + } + }.lt); + var total: u64 = 0; + for (open_samples.items) |x| total += x.ns; + std.debug.print(" first frame: {d} top-level blocks, {d} us in them\n", .{ open_samples.items.len, total / 1000 }); + for (open_samples.items[0..@min(4, open_samples.items.len)]) |x| { + std.debug.print(" block {d:>3} {s:<14} {d:>6} us textlayouts={d} text={d}B\n", .{ x.index, x.kind, x.ns / 1000, x.text_layouts, x.add_text_bytes }); + } + } + std.debug.print(" {s:<30} {d:>6} us for the first {d} frames ({d} us parsing) each: {any}\n", .{ "opening the document", @divTrunc(best_ns, 1000), frames, render_ast.stats.parse_ns / 1000, frame_times }); +} + +test "bench: markdown preview frame cost" { + gpa = std.testing.allocator; + std.debug.print("\n== markdown preview frame cost — {s} ==\n", .{@tagName(@import("builtin").mode)}); + + const cases = [_]struct { name: []const u8, text: []const u8 }{ + .{ .name = "huge (docs/PLUGINS.md)", .text = sample_huge }, + .{ .name = "prose (docs/PLUGIN_MANIFEST_PLAN.md)", .text = sample_prose }, + .{ .name = "medium (CLAUDE.md)", .text = sample_medium }, + .{ .name = "small (release notes)", .text = sample_small }, + }; + + defer open_samples.deinit(std.testing.allocator); + for (cases) |c| { + std.debug.print(" {s}, {d} bytes\n", .{ c.name, c.text.len }); + profile_blocks = true; + try runOpen(c.text); + profile_blocks = false; + try run("no virtualization (baseline)", c.text, .{ .virtualize = false }); + profile_blocks = true; + try run("idle, top of document", c.text, .{}); + profile_blocks = false; + try run("idle, viewport mid-document", c.text, .{ .park_scroll_ticks = -4000 }); + try run("scrolling 3 lines/frame", c.text, .{ .scroll_lines_per_frame = 3 }); + try run("resizing the pane every frame", c.text, .{ .resizing = true }); + } +} diff --git a/tests/bench/bench_text.zig b/tests/bench/bench_text.zig index 563e96e3..c1e5e2d1 100644 --- a/tests/bench/bench_text.zig +++ b/tests/bench/bench_text.zig @@ -158,6 +158,53 @@ fn run(label: []const u8, sample: []const u8, cursor: usize) !void { std.debug.print(" {s:<34} {d:>6} us/frame\n", .{ label, per_frame_us }); } +/// A document whose only unusual feature is one pathologically long line — minified JS/CSS, a +/// one-line JSON blob, a generated data table. The short lines around it are there so the +/// viewport contains ordinary text too, the way it does in the editor. +/// +/// Built at runtime rather than embedded: the repo has no such file, and a checked-in fixture +/// of this size would be dead weight (`long_len` is the knob worth sweeping anyway). +fn buildLongLine(gpa: std.mem.Allocator, long_len: usize, trailing_lines: usize) ![]u8 { + var buf: std.ArrayListUnmanaged(u8) = .empty; + errdefer buf.deinit(gpa); + for (0..5) |i| try buf.print(gpa, "const short_{d} = {d};\n", .{ i, i }); + const unit = "const x = foo(bar, 1234); "; + while (buf.items.len < long_len) try buf.appendSlice(gpa, unit); + try buf.append(gpa, '\n'); + for (0..trailing_lines) |i| try buf.print(gpa, "const after_{d} = {d};\n", .{ i, i }); + return buf.toOwnedSlice(gpa); +} + +test "bench: long single line" { + const gpa = std.testing.allocator; + std.debug.print("\n== long single line — {s} ==\n", .{@tagName(@import("builtin").mode)}); + + for ([_]usize{ 2_000, 20_000, 200_000 }) |long_len| { + const sample = try buildLongLine(gpa, long_len, 200); + defer gpa.free(sample); + std.debug.print(" one line of {d} chars, {d} bytes total\n", .{ long_len, sample.len }); + + tree_sitter = true; + cache_layout = true; + typing = false; + scroll_lines_per_frame = 0; + park_scroll_ticks = 0; + try run("idle, long line on screen", sample, 0); + try run("idle, caret mid-long-line", sample, 100 + long_len / 2); + tree_sitter = false; + try run("idle, no highlighting", sample, 0); + tree_sitter = true; + + // Same long line with nothing after it — the minified-file shape. Worth its own case + // because it needs only one visible byte range to describe the frame, where a long line + // with text below it needs two with the line's off-screen middle between them, so this + // is the case that stays fast even if that gap handling regresses. + const trailing = try buildLongLine(gpa, long_len, 0); + defer gpa.free(trailing); + try run("idle, long line last in file", trailing, 0); + } +} + test "bench: text editor frame cost" { std.debug.print("\n== text editor frame cost — {s} ==\n", .{@tagName(@import("builtin").mode)}); diff --git a/tests/integration.zig b/tests/integration.zig index a568815a..3570c565 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -541,3 +541,116 @@ test "a center provider that disappears is not drawn for its own cross-fade" { try std.testing.expectEqual(@as(usize, 0), center_a_draws); try std.testing.expectEqual(@as(usize, 1), center_b_draws); } + +// -- markdown preview virtualization ------------------------------------------------------------ + +// The markdown preview lays out only the blocks near the viewport (`render_ast.renderTopLevel`), +// which is the difference between ~34ms and ~2.5ms per frame on docs/PLUGINS.md in Debug. The +// whole optimization rests on one claim: skipping a block changes nothing the user can see, +// because its wrapper still reports the height the block had when it was last drawn. +// +// So compare layout, not widget counts: every top-level block's height, and the scroll +// container's resulting virtual size, must come out the same whether the blocks were all laid +// out or only the on-screen ones were. If a remembered height ever drifted from the measured +// one, the document below it would shift and the scrollbar would lie — and that is exactly what +// these two numbers catch. (Comparing rendered pixels would be better still, but dvui's testing +// backend has no render targets, so `dvui.testing.capturePng` is unavailable here.) +const markdown = @import("markdown"); +const md_render_ast = markdown.render_ast; + +var md_preview: markdown.Preview = .{}; +var md_doc: []const u8 = ""; +const md_sample = @embedFile("markdown_sample"); +/// Table-heavy: one of its tables is 45KB on its own, which is what makes it the document that +/// exercises row culling inside a table rather than only block skipping around it. +const md_sample_tables = @embedFile("markdown_sample_tables"); + +fn markdownFrame() !dvui.App.Result { + var b = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both }); + defer b.deinit(); + markdown.drawPreview(&md_preview, md_doc, std.testing.allocator, .{ + .io = dvui.io, + .image_base_dir = ".", + .id_extra = 0, + }); + return .ok; +} + +/// Steps until the layout has stopped moving: every block height settled, and no off-screen table +/// row still owed a measuring pass. Takes a while by design — the preview re-measures only a few +/// off-screen blocks and a few KB of table text per frame (`render_ast.resettle_budget`, +/// `render_ast.table_measure_bytes`), and the first real width arrives on frame two, when the +/// scroll viewport is known. +/// +/// `pending_measure` is part of the condition and not just a nicety: a table block is marked +/// settled as soon as it has a height to stand on, long before its off-screen rows have been +/// measured, so waiting on block heights alone stops while the table is still hundreds of points +/// short of its real size. +fn markdownSettle() !void { + for (0..600) |_| { + _ = try dvui.testing.step(markdownFrame); + var all = md_preview.rs.block_heights.items.len > 0; + for (md_preview.rs.block_heights.items) |e| { + if (!e.settled) all = false; + } + if (all and md_render_ast.stats.pending_measure == 0) return; + } + return error.MarkdownPreviewNeverSettled; +} + +const MarkdownLayout = struct { + heights: []f32, + virtual_h: f32, + + fn deinit(self: MarkdownLayout, gpa: std.mem.Allocator) void { + gpa.free(self.heights); + } +}; + +/// Lays the document out scrolled `wheel_ticks` from the top and reports the resulting geometry. +fn markdownLayout(gpa: std.mem.Allocator, virtualize: bool, wheel_ticks: f32) !MarkdownLayout { + md_render_ast.virtualize_blocks = virtualize; + md_preview = .{}; + defer md_preview.deinit(); + + var t = try dvui.testing.init(.{ .allocator = gpa, .window_size = .{ .w = 900, .h = 700 } }); + defer t.deinit(); + + try markdownSettle(); + + if (wheel_ticks != 0) { + const cw = dvui.currentWindow(); + _ = try cw.addEventMouseMotion(.{ .pt = .{ .x = 400, .y = 300 } }); + _ = try cw.addEventMouseWheel(wheel_ticks, .vertical, null); + try markdownSettle(); + } + + const heights = try gpa.alloc(f32, md_preview.rs.block_heights.items.len); + for (md_preview.rs.block_heights.items, heights) |entry, *out| out.* = entry.h; + return .{ .heights = heights, .virtual_h = md_preview.scroll.virtual_size.h }; +} + +test "markdown preview: skipping off-screen blocks lays the document out identically" { + const gpa = std.testing.allocator; + defer md_render_ast.virtualize_blocks = true; + + // Top, a screen or so down, and far enough that most of the document is behind the viewport. + for ([_][]const u8{ md_sample, md_sample_tables }) |sample| for ([_]f32{ 0, -1200, -6000 }) |ticks| { + md_doc = sample; + const full = try markdownLayout(gpa, false, ticks); + defer full.deinit(gpa); + const virtualized = try markdownLayout(gpa, true, ticks); + defer virtualized.deinit(gpa); + + try std.testing.expect(full.heights.len > 30); // the samples really are long documents + try std.testing.expectEqualSlices(f32, full.heights, virtualized.heights); + // …and the scroll container's total is exactly those blocks plus the column's padding. + // Deliberately *not* compared against the full render's total: drawing every block lets + // each table's grid — a scroll container in its own right — ask the scroll area for more + // room than the block actually occupies, which is why that number comes out ~20% larger + // than the document really is. + var sum: f32 = 0; + for (virtualized.heights) |h| sum += h; + try std.testing.expectApproxEqAbs(sum + 16, virtualized.virtual_h, 0.01); + }; +} From 15cbc324b27be0e07fcda939b2f26408bd77a223 Mon Sep 17 00:00:00 2001 From: foxnne Date: Wed, 5 Aug 2026 12:36:40 -0500 Subject: [PATCH 10/10] import dvui only once --- CLAUDE.md | 3 ++- docs/PLUGIN_MANIFEST_PLAN.md | 1 + sdk/build.zig | 3 +++ sdk/build.zig.zon | 21 ++++++++++++--------- src/plugins/shared/build/helpers.zig | 4 +++- 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ed655b2e..a3a779b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,8 @@ Plugins depend on the **`sdk/` package** (its own `build.zig` + `build.zig.zon`) Pattern: - **Plugins** (built-in + third-party): `.fizzy = .{ .path = ".../sdk" }` locally, or the `fizzy-sdk-v*` **release asset** URL from the matching `sdk-v*` tag (not the git archive — that is the monorepo root zon with Velopack). Call `fizzy.plugin.create` / `.install` as before; `b.dependency("fizzy", .{ .plugin_sdk = true })` still works (the option is accepted and ignored — `sdk/` always exports modules). Packing: `scripts/pack-sdk.sh` / `.github/workflows/sdk-tag.yml`. -- **App**: repo-root `zig build` as usual. Velopack stays `.lazy = true` in the root zon; never `@import("velopack_zig")` — the helper surface is vendored in `build/velopack.zig` and resolved only in `build/app.zig` via `lazyDependency`. +- **App**: repo-root `zig build` as usual. The app **consumes `sdk/` as a dependency** (`.fizzy_sdk = .{ .path = "sdk/" }`), so build scripts reach `plugin`/`core_module`/`sdk_version` through `@import("fizzy_sdk")` and never by relative path into `sdk/` — a file may belong to only one module, so a path import claims it for the root build module and breaks the dependency outright. The same applies in reverse: nothing under `src/` may relative-import an `sdk/` file. Velopack stays `.lazy = true` in the root zon; never `@import("velopack_zig")` — the helper surface is vendored in `build/velopack.zig` and resolved only in `build/app.zig` via `lazyDependency`. +- **dvui is pinned in exactly one place — `sdk/build.zig.zon` — and is deliberately absent from the root zon.** The app borrows it via `build/sdk.zig`'s `dvuiDependency` (which forwards backend/target/optimize normally), and build scripts get dvui's build API from `@import("fizzy_sdk").dvui`. Do **not** "fix" the missing root dep by re-adding `.dvui`: two pins that drift make `recorded_sdk_shape_fingerprint` unsatisfiable by *both* the app and plugin-SDK builds at once, and the resulting error tells you to bump `sdk_version`, which cannot help. Bump or swap to a local checkout in `sdk/build.zig.zon` only. - Shared `core` import wiring lives in `sdk/core_module.zig` and is called from the app build *and* `sdk/plugin_sdk.zig`'s `exportModules` so the import set can't drift. Note the `with_tui = false` on the zf dependency: without it, zf's standalone terminal binary drags `libvaxis` into every plugin build. Acceptance test after any build-graph change: diff --git a/docs/PLUGIN_MANIFEST_PLAN.md b/docs/PLUGIN_MANIFEST_PLAN.md index 1bc201d4..346cf59d 100644 --- a/docs/PLUGIN_MANIFEST_PLAN.md +++ b/docs/PLUGIN_MANIFEST_PLAN.md @@ -44,6 +44,7 @@ | R16 — Store detail page: VSCode-marketplace-style header + tabs, `description` in `Manifest` | done | 2026-07-30 — the store's center-provider README view (only the center; the sidebar list is untouched) is now a full detail page. **Manifest:** `description: []const u8 = ""` added to `Manifest` (`src/sdk/manifest.zig`) — the identity-only lock from R2 is deliberately relaxed here, since the detail page needs a description for every plugin, not just ones with a registry entry; not part of `sdk_boundary_types` (never crosses the C-ABI boundary, only ever `std.zon.parse`d from `plugin.zig.zon` text), so no SDK version/fingerprint bump. All 4 built-in `plugin.zig.zon`s got real one-liners. **Description resolution** (`PluginStore.descriptionFor`): registry's own (freshest) → `Editor.builtinDescription` (built-ins read their own compiled-in `plugin_options.manifest_zon` directly, no dylib involved) → `PluginLoader.probeDescription` (new, mirrors `probeName`: opens the on-disk dylib, reads the embedded `fizzy_plugin_manifest_zon` export, parses it) for anything else. **Header** (`drawDetailHeader`): logo (same fetch-or-fallback chain the card list uses) + a stacked name (`.heading` font, matching `SettingsTree`'s root-branch style)/id (small dim mono)/author (dim)/description (wrapped) column, with the existing `drawCardControls` (install/update/uninstall) reused as-is, right-justified. **Tabs** (`drawDetailTabs`): a plain two-tab DETAILS/CHANGELOG strip — same selected/unselected color convention every other tab bar in the app uses, but no drag/drop or scroll area (there are only ever two). Reconstructing the selected plugin's `StoreEntry` for the header needed its own helper (`selectedEntry`), since the center provider draws independently of the sidebar's list-building pass and registry data is only valid while the catalog lock is held for that one frame — same acquire/release-per-frame discipline the list already follows. **Background:** the README view's old rounded `sdk.pane_layout.emptyStateCard` (meant for a genuinely empty hint screen) is now `sdk.pane_layout.mainCanvasVbox` — a plain flat fill, the same background every other content pane in the app uses. **CHANGELOG tab** is a placeholder empty state ("Changelog coming soon") — real GitHub Releases fetching (per-release notes) is out of scope for this pass, by explicit choice. **Install counts** are out of scope entirely — there is no backend/analytics service to source them from; revisit once one exists. Verified: `zig build`, `zig build test`, `zig build test-sdk-version`, `zig build check-web` all clean; a live isolated-`HOME`/`TMPDIR` run rendered the header/tabs/flat-background README correctly end-to-end (ghostty, registry description + README both showing, "No compatible build in store" control state correct for an uninstalled entry). | | R19 — `wikilink` service + `documentContentChanged` broadcast + markdown/text consumers | done | 2026-08-04 — the SDK seam for `[[wikilink]]` support, so an out-of-tree indexer (`brain`) can resolve links that the in-tree `markdown` renderer draws, without either importing the other. **New `src/sdk/services/wikilink.zig`**, split deliberately in two: (1) a **pure tokenizer** (`Token`, `tokenize`, `tokenizeAlloc`) — *what is a link* — living in the SDK rather than in either plugin, because a renderer and an indexer that disagree about the syntax produce graph edges the preview never drew (or vice versa); one implementation, one test suite (18 cases: alias/heading/block-id/embed forms, empty and unterminated targets, newline rejection, span recovery, out-buffer bounds, `tokenizeAlloc` parity). (2) an **`Api` vtable** — *which file does this link mean* — `resolve` / `generation` / `complete` / `indexing`. `complete` and `indexing` ship unused on day one on purpose: every field added later is another fingerprint bump that breaks every installed plugin. `resolve` is allocator-in/allocation-out rather than returning borrowed slices, because a background reindex can invalidate the provider's own strings between the call and the end of the frame; callers pass a frame arena. The `generation` counter is what lets a consumer memoize resolution *and* still have a link flip from broken to live when its target file appears — resolution can't be precomputed at parse time, since the linking document's bytes don't change when the target is created. Unlike `workbench`/`markdown`, both ends of this service are plugins (fizzy only stores the `*anyopaque`), so a shape mismatch would be dylib-to-dylib and invisible to the host — hence `Api`, `Api.VTable`, `Resolution`, `Candidate`, and `Token` all get explicit `sdk_boundary_types` entries (the `CompletionItem` lesson again: slices and by-value reaches aren't followed by `hashType`). **New `Plugin.VTable.documentContentChanged`** + `Host.notifyDocumentContentChanged` (a plain Host method over `plugins.items` — no `EditorAPI` vtable entry needed, so `EditorAPI`'s shape is untouched): a `[broadcast]` an owner fires when its buffer settles, letting a plugin that owns no documents see *unsaved* text at all. Owners debounce (a typing lull, plus on save); consumers treat it as an overlay on disk state. Tokenizer tests are wired as their own `addTest` root (`fizzy-sdk-wikilink-tests` in `build/app.zig`'s pure-logic list — std-only by design, so it must not sit under the SDK-rooted artifact that drags in dvui). sdk **0.1.49** (fingerprint `0x45dc3739334bebb`).

**Consumers, same pass.** `markdown` now renders wikilinks, and this turned up a real hazard the design had only flagged as a risk: `cmark_parser_finish` ends with `cmark_consolidate_text_nodes` (`blocks.c`), which merges every adjacent TEXT run into one literal — and since `handle_backslash` represents an escape as its own little text node, `\[\[A]]` and `[[A]]` arrive at the renderer as **the same literal**. Tokenizing the literal alone therefore turns deliberately-escaped text into a live link, with nothing in the AST to tell them apart. What survives is position: `make_literal` (`inlines.c`) sets `start_line`/`start_column` unconditionally (no `CMARK_OPT_SOURCEPOS` needed — that option only governs HTML *output*), and consolidation keeps the first fragment's start while extending `end_column`. New `src/md/wikilink_scan.zig` uses that to read the node's original bytes back out of the source, re-applies cmark's own escape rule to produce (bytes, was-escaped) pairs, and — **only when those bytes match the literal exactly** — drops links whose opening brackets were flagged. On any drift (smart punctuation rewrote a quote, an entity expanded) it **fails open** and the link renders: a link that appears where the author wanted literal text is visible and correctable, one that silently vanishes is an afternoon lost. Fast path is one `memchr` for a backslash. Tested against the **real vendored cmark** via a new `zig build test` step in the markdown plugin's own standalone `build.zig` (16 cases — it can't join fizzy's pure-logic list like `html_images`/`url_join`, which are std-only by design, because the whole point is a claim about what cmark does). Code spans and fenced blocks need no handling at all and now have tests pinning that: both get their own node types and never reach a TEXT node. Link *labels* do need a guard (`insideLinkOrImage`), since `[see [[A]]](url)` puts that text under a LINK parent.

Resolution is memoized per node+token against the resolver's `generation()` and explicitly **not** stored beside the parse (`RenderState.wikilinks` holds positions only): `Preview.ensureParsed` caches by content hash, so a scan-time resolution would freeze "broken" forever — the linking document's bytes don't change when its target is finally created. `tryRevealFileUri` split into `parseFileUri` + `revealPath` so a resolved wikilink reveals a path directly instead of round-tripping through a `file://` URI it would immediately re-parse (percent-encoding a path with a space or `#` is exactly where that goes wrong). `PreviewOptions.document_path` threads the source file down; empty disables wikilinks entirely, which is what keeps the store's fetched-README pane from resolving `[[Note]]` against the user's own local files. `markdown.Api.RenderOptions` deliberately untouched. `text` fires the new broadcast from `Document.tickContentChanged` (300ms typing-quiescence debounce keyed on `history.topOpId()` — already changes on exactly the right events, and comparing two integers beats hashing the buffer every frame) plus immediately in `save`, returning "still pending" up through a new `tickOpenDocuments` so the app keeps drawing until the burst settles rather than idling with a notification owed.

Verified: `zig build`, `zig build test`, `zig build test-sdk-version`, `zig build check-web`, `zig build test-integration` clean; markdown's own 16 cmark-backed tests; text's standalone build. **Live on macOS** in an isolated `HOME`/`TMPDIR` sandbox: `pixi`/`zig`/`ghostty` rebuilt against 0.1.49 all load, and a `.md` full of `[[links]]` with **no resolver installed** renders byte-identically to before — every form plain text, code span and fence untouched, ordinary markdown links still live. **Not done:** no resolver plugin exists yet, so the resolved/ambiguous/unresolved render paths are untested against a real provider; `pixi`/`zig`/`ghostty` are pinned to the local SDK path and still need a released `sdk-v0.1.49` tarball plus their own re-release before store installs work. | | R20 — host folder watch + `folderPathsChanged` broadcast | done | 2026-08-05 — the missing half of R19. `documentContentChanged` tells a plugin about buffers *this editor* has open; nothing told it about the rest of the tree, so a `[[wikilink]]` deleted from a file nobody had open stayed in `brain`'s graph indefinitely — nothing ever re-read that file. The first fix was a 15s poll inside brain, which is the wrong place for it: every plugin that cares about files would end up pinning a watcher library and standing up its own thread over the same tree. **New `src/editor/FolderWatcher.zig`** — the third nightwatch adapter in `src/editor/`, and the only one whose output leaves fizzy (`SettingsWatcher` reconciles `settings.zon`, `DocumentWatcher` reloads open tabs). Three things put it on the host side of the boundary rather than in each plugin: (1) **thread hop** — nightwatch calls its handler on its own thread, and plugins are dylibs, so a callback arriving on a thread the plugin never created, possibly mid-unload, is a crash; events are buffered and fan out from `tick` on the UI thread. (2) **ignore rules** — only fizzy knows them (`IgnoreRules`), so `.git`, build output and gitignored paths never reach a plugin instead of every plugin re-deriving the same filter through `Host.isPathIgnored` one path at a time. (3) **one watch** — three interested plugins would otherwise mean three threads and three sets of fds or event streams over one tree. **Nightwatch is deliberately not exposed**: it is an implementation detail behind `folderPathsChanged`, so it can be swapped, forked, or replaced with per-platform code without any plugin noticing — which matters, since its Windows behavior is unproven and its macOS default needs the `macos_fsevents = true` build option (wired conditionally in `build/exe.zig` and `build/app.zig`) because the kqueue fallback wants a file descriptor per directory *and* per file, and a project folder is exactly the shape that exhausts the fd limit.

**SDK surface.** `Plugin.VTable.folderPathsChanged` (`[broadcast]`) plus `PathEvent`/`PathChanges` on `Plugin`, and `Host.notifyFolderPathsChanged` over `plugins.items`. `folderWatchActive` needed a real `EditorAPI` vtable entry (unlike R19's notify, which was a plain `Host` method) because the answer lives in the editor, not the SDK — so `EditorAPI`'s shape moves and `Editor.fizzyFolderWatchActive` joins the vtable. `have_impl` is false on wasm and unsupported targets; `folderWatchActive` reports false there and with no folder open, so a plugin knows to keep its own fallback. Slices in the batch are borrowed for the call only, and `truncated` reports overflow rather than growing the buffer — a branch switch emits events by the tens of thousands, and the useful answer for a consumer at that point is "rescan", not a longer list it still has to walk. `.renamed` carries `old_path` only where the backend can pair the halves (Linux, Windows); elsewhere it arrives as delete + create, which the doc now says explicitly because a consumer has to handle that shape regardless. sdk **0.1.50** (fingerprint `0x80448d5960ab4849`).

**Threading.** The producer never allocates: two fixed ring buffers with a flat path arena (one arena rather than a slot per event, so a handful of deep paths can't crowd out everything else and no path length is a special case), swapped under the lock so the fan-out reads a buffer nothing else can touch and no plugin call ever runs with the lock held. The lock is a spin over `std.atomic.Mutex` rather than a blocking primitive, because blocking would mean `std.Io.Mutex` and therefore `dvui.io` on nightwatch's thread — precisely what the other two adapters' doc comments single out as not to be touched from a watcher callback; both critical sections are a bounded memcpy or a pointer swap. A 200ms coalesce window means one logical save arrives as one batch and a consumer reindexing a file finds it finished being written. A cheap dot-segment reject runs on the watcher thread before the lock (pure string work — no allocation, no host call) so a `git checkout` or a build churning `.zig-cache` can't fill the ring before the authoritative `IgnoreRules` pass gets to run on the UI thread. `stopWatch` tears the watcher down entirely instead of calling nightwatch's `unwatch`, which drops only the path it was given and not the subdirectories its recursive walk added — a folder switch would otherwise leak watches on the old tree.

**Testing.** The buffering and filtering are split into **`src/editor/folder_events.zig`** (`Ring`, `underDotSegment`) and wired as its own `addTest` root (`fizzy-folder-events-tests`), for the same reason `keymap.zig` and `reveal.zig` are: `FolderWatcher.zig` reaches `fizzy.zig` and dvui and can only be exercised through a live editor, while the bugs that would actually bite (an overrun on a path that doesn't fit, a filter that lets `.git` through) live in the std-only half. `Ring` is generic over the event enums rather than importing them, since `Plugin.zig` imports dvui and would drag the file back into the module whose tests never run. 8 cases, including both halves of a rename counted together against the arena, and `empty()` distinguishing nothing-happened from everything-was-dropped — `tick` leans on that, because a batch that truncated with zero surviving events still has to be broadcast.

**Consumer.** brain's `Watcher.zig` routes markdown paths straight to `Indexer.enqueue` (create/modify/delete are all "re-read this path" — the worker treats missing-on-disk as the delete) and falls back to a quiet sweep for the two things a path alone can't identify: directories (one `mv notes/ archive/` moves every note beneath it, which is the tree walk the sweep already does) and attachments (the media table is only rebuilt by a walk). `truncated` goes straight to a sweep. The periodic sweep **stays** rather than being deleted, stretched from 15s to 5min while `folderWatchActive()` — "the watcher started" and "the watcher is still delivering" are different claims, the backends differ per platform, and a silently dead one should cost a few minutes of staleness instead of a permanently wrong graph.

Verified: `zig build`, `zig build check`, `zig build test` (8 new tests), `zig build test-sdk-version`, `zig build test-integration` clean on macOS; brain rebuilt against 0.1.50 (`zig build`, 291 tests). **Not done:** Windows and Linux are untested end to end; `pixi`/`zig`/`ghostty` need a released `sdk-v0.1.50` tarball and their own re-release before store installs work. | +| R21 — app consumes `sdk/` as a package, one dvui pin | done | 2026-08-05 — dvui was pinned twice, in `build.zig.zon` and `sdk/build.zig.zon`, and the two were only kept equal by discipline. When they drifted the failure was unfixable rather than merely annoying: dvui types reachable from the plugin boundary feed `dylib.sdk_shape_fingerprint`, which the app build and the plugin-SDK build each check against the *single* `recorded_sdk_shape_fingerprint` literal in `src/sdk/version.zig` — so two different dvuis compute two different fingerprints from one literal and no value satisfies both. Every value that let fizzy build made brain fail and vice versa, and both blamed `sdk_version`, which a bump cannot repair. Nothing in the error named dvui.

**Fix: dvui is no longer a dependency of the root package at all.** `sdk/build.zig.zon` owns the only pin and the app borrows it through `build/sdk.zig`'s `dvuiDependency` (`b.dependency("fizzy_sdk", .{}).builder.dependency("dvui", args)`), which forwards `args` untouched so each of the 5 call sites keeps full control of backend/target/optimize — only *which* dvui is shared. The direction is forced, not chosen: `sdk/` ships standalone as `fizzy-sdk-v*.tar.gz` and can never reach above its own root, while the app can always reach down into it.

**What made this look impossible at first.** The obvious move — add `.fizzy_sdk = .{ .path = "sdk/" }` — fails immediately with `file exists in modules 'root.@build' and 'root.@dependencies.sdk'`, because the root build scripts reached into `sdk/` by *relative path* in seven places, which claims those files for the root's build module; a file may belong to only one module, so the same files cannot also be a dependency's. That reads like a structural prohibition but is only a spelling problem. `sdk/build.zig` now re-exports what the app needs (`plugin`, `core_module`, `sdk_version`, and dvui's *build* API, since the app can no longer `@import("dvui")` itself) and the six root-side importers (`build.zig`, `build/{app,exe,web,common}.zig`) go through `@import("fizzy_sdk")` instead. dvui's build surface turned out to be one decl deep — `AccesskitOptions` — so re-exporting cost nothing. The seventh crossing ran the other way and surfaced only after the first six were fixed: `src/plugins/shared/build/helpers.zig` read the version triplet from `../../../../sdk/sdk_version.zig`, putting an `sdk/` file into the root build module from below. Its sibling line 46 (`../../../sdk/manifest_identity.zig`) is `src/sdk/`, a different directory, and correctly left alone.

**The pin-drift guard that is no longer needed.** An earlier pass in this session built a `build/sdk_pins.zig` — semantic `.zon` pin comparison, local paths resolved from two different depths, comment-toggled URLs ignored, unit-tested, failing the configure with a message naming dvui instead of `sdk_version`. It is deleted along with its `fizzy-sdk-pins-tests` root: it made the deadlock *legible*, but one pin makes it unreachable, and a guard against a state that cannot occur is upkeep with no claim behind it. Recorded here because the diff only shows the deletion.

**Note for release.** `recorded_sdk_shape_fingerprint` is dvui-pin-dependent, so it changes when the pin is flipped between `../dvui-dev` and a release tarball — it currently reads `0xd2de25bab58a617a` (local checkout), where R20 above recorded `0x80448d5960ab4849` (tarball). That is expected and no longer ambiguous: there is one pin to flip and both builds always agree on the answer.

Verified: `zig build`, `zig build check`, `zig build check-web`, `zig build test` (264 pass), `zig build test-sdk-version` clean; brain rebuilt against the shared pin (`zig build`, 300 tests) — the fingerprint conflict that motivated this is gone; standalone `src/plugins/{text,workbench}` builds clean, confirming `helpers.zig`'s new named import doesn't reach the standalone plugin path. **Not done:** `src/plugins/markdown`'s standalone build still hits the pre-existing, unrelated module-graph conflict noted in R9. | | R17 — `tags` in `Manifest` + registry-side description/tags dedup | done | 2026-07-30 — closes the gap R16 left for `description`: `tags` couldn't be authored anywhere except a hand-typed `registry/.json` PR in the separate `fizzyedit/plugins` repo, so a plugin with no registry entry yet (or one whose author never filled tags in) had zero search surface for them. **`Manifest`** (`src/sdk/manifest.zig`): `tags: []const []const u8 = &.{}` added, same off-`sdk_boundary_types` treatment as `description` (no fingerprint bump). All 4 built-in `plugin.zig.zon`s got real tags. **Resolution chain** (`PluginStore.tagsFor`, mirrors `descriptionFor` exactly): registry's own → `Editor.builtinTags` (new, mirrors `builtinDescription`) → `PluginLoader.probeTags` (new, mirrors `probeDescription`; returns a caller-owned `[][]u8` via a small `dupeTags` helper, since a manifest's `tags` — unlike `description` — is an array, not a single string) → `tags_cache` (new, same `StringArrayHashMapUnmanaged` shape as `description_cache`, cleared at the same two call sites: `refreshDiskScan` and `deinit`). **`scoreEntry`** now calls `descriptionFor`/`tagsFor` instead of reading `entry.registry.?.{description,tags}` directly, so a built-in or locally-probed dylib's own prose/tags contribute to store search even with no registry entry at all — `author` is the one field left with no fallback, since it was never a `plugin.zig.zon` concept to begin with (attribution, not something a build declares about itself). **No new UI** — tags still have no display surface (chips, filter row) anywhere in the store; this pass is resolution-chain-only, matching what already existed for `description` before R16's header. **Registry-side dedup** (separate repos, coordinated in this pass since the whole point was "don't require authors to hand-duplicate description/tags"): `fizzyedit/plugin-build-action`'s `read_plugin_zon.py` now also reads `description`/`tags` off `plugin.zig.zon`; `build.yml`'s setup job exposes them as job outputs (routed through `env:` rather than direct `${{ }}` interpolation into the assemble-manifest shell step, since these are free-form author-controlled strings — direct interpolation would be a script-injection hole); `assemble_manifest.py` embeds `name`/`description`/`tags` at the top level of the author's `manifest.json` (previously just `{id, releases}`). `fizzyedit/plugins`'s `store/src/manifest.zig` (the *aggregator's* copy of the author-manifest shape, distinct from `sdk/manifest.zig`) gained matching `name`/`description`/`tags` fields; `ingest.zig`'s `upsertPlugin`/`upsertTags` now fall back to the fetched manifest's values when `registry/.json` leaves its own `description`/`tags` empty — registry entry still wins when both are set, so a maintainer can override the store-listed copy without waiting on a plugin release. `docs/manifest.example.json` and both repos' `README.md` updated. **Not done, left for the user:** this is an interface change to `plugin-build-action`'s `build.yml`/`assemble_manifest.py` — existing `release.yml` callers pin `uses: .../build.yml@v3`, and `build.yml`'s own auxiliary-checkout step hardcodes the matching `ref="v3"` literal for its own script checkout, so nothing picks this up until a **new `v4` tag is cut and pushed** (a shared-CI action, deliberately not done automatically) and each external plugin repo (`pixi`/`ghostty`/`zig`/`json`/`markdown`) bumps its own `release.yml` to `@v4`; no `registry/.json` PR was reauthored to drop its now-optional `description`/`tags` either (a per-plugin-author call, not this repo's to make). | --- diff --git a/sdk/build.zig b/sdk/build.zig index 620018dd..eb220010 100644 --- a/sdk/build.zig +++ b/sdk/build.zig @@ -13,6 +13,9 @@ pub const core_module = @import("core_module.zig"); /// dvui's *build* API (`AccesskitOptions` and friends), re-exported because this package owns the /// only dvui pin in the repo, so the app cannot `@import("dvui")` on its own. pub const dvui = @import("dvui"); +/// The SDK version triplet's single edit site. Built-in plugins' build glue reads it from here +/// rather than by relative path for the one-module-per-file reason above. +pub const sdk_version = @import("sdk_version.zig"); pub fn build(b: *std.Build) !void { const target = b.standardTargetOptions(.{}); diff --git a/sdk/build.zig.zon b/sdk/build.zig.zon index 66b2ef2a..faf96147 100644 --- a/sdk/build.zig.zon +++ b/sdk/build.zig.zon @@ -22,16 +22,18 @@ .hash = "icons-0.0.0-iJxA-VvGMwAgiKSXRe_Y0O7RpasdtEJhBfVx8IGGEBl_", .lazy = true, }, - // Must resolve to the *same* dvui as the repo-root zon. Boundary-reachable dvui types feed - // the SDK shape fingerprint, and `recorded_sdk_shape_fingerprint` is one literal compiled by - // both this package and the app — so if these two pins disagree, each build demands a - // different value and satisfying one breaks the other. Local path during development, for - // the same reason the root zon uses one; swap both back to the URL+hash pin together before - // tagging, since CI requires a content-addressed pin. + // The repo's ONLY dvui pin — bump or repoint it here and nowhere else. dvui is deliberately + // absent from the root zon: the app consumes this package and borrows this entry through + // `build/sdk.zig`'s `dvuiDependency`, and build scripts take dvui's build API from + // `sdk/build.zig`'s re-export. Do not add `.dvui` back to the root zon to "fix" that. + // + // It lives here rather than there because this directory ships standalone as + // `fizzy-sdk-v*.tar.gz` and cannot reach anything above its own root, while the app can + // always reach down into it. .dvui = .{ - //.url = "https://github.com/foxnne/dvui-dev/archive/ed2f1c67f0316184783c8dba7d79ed4c49d26f97.tar.gz", - //.hash = "dvui-0.5.0-dev-AQFJmX1d_QA2wHjWCweU26ZxqIrA9LwWeysGFbfVMc7y", - .path = "../../dvui-dev", + .url = "https://github.com/foxnne/dvui-dev/archive/2e8cbb81cc421c363f70079e9be6ba044200c941.tar.gz", + .hash = "dvui-0.5.0-dev-AQFJmVulAAEDws4e9-I85OM0EmRJK-IEn8gj3aVJuVJ0", + //.path = "../../dvui-dev", }, .zf = .{ .url = "git+https://github.com/natecraddock/zf#c35c421f84895193246db06c40683c1a30e616ef", @@ -39,3 +41,4 @@ }, }, } + diff --git a/src/plugins/shared/build/helpers.zig b/src/plugins/shared/build/helpers.zig index a0ddcd68..2a29a1b2 100644 --- a/src/plugins/shared/build/helpers.zig +++ b/src/plugins/shared/build/helpers.zig @@ -37,7 +37,9 @@ pub const current_sdk_version: []const u8 = std.fmt.comptimePrint("{d}.{d}.{d}", version_number.sdk_version.minor, version_number.sdk_version.patch, }); -const version_number = @import("../../../../sdk/sdk_version.zig"); +// Through the `sdk/` dependency rather than by relative path into it: the app consumes that +// directory as a package so the two share one dvui pin, and a file may belong to only one module. +const version_number = @import("fizzy_sdk").sdk_version; /// Identity read from a built-in's `plugin.zig.zon` at configure time, plus its raw source. /// Same type as `plugin_sdk.IdentityManifest` (both `@import` `manifest_identity.zig` directly)