diff --git a/bench/lua_perf.bench.ts b/bench/lua_perf.bench.ts index 0870dddd..7186626b 100644 --- a/bench/lua_perf.bench.ts +++ b/bench/lua_perf.bench.ts @@ -14,7 +14,6 @@ import { LuaBuiltinFunction, LuaEnv, LuaNativeJSFunction, - LuaStackFrame, LuaTable, luaTypeOf, @@ -68,7 +67,7 @@ function makeApiEnv(): { global: LuaEnv; sf: LuaStackFrame } { env.setLocal( "asyncGet", new LuaNativeJSFunction((key: string) => - Promise.resolve(`value_for_${key}`) + Promise.resolve(`value_for_${key}`), ), ); @@ -79,12 +78,15 @@ function makeApiEnv(): { global: LuaEnv; sf: LuaStackFrame } { const t = new LuaTable(); void t.set("id", id); void t.set("name", `item_${id}`); - void t.set("tags", (() => { - const tags = new LuaTable(); - void tags.set(1, "tag_a"); - void tags.set(2, "tag_b"); - return tags; - })()); + void t.set( + "tags", + (() => { + const tags = new LuaTable(); + void tags.set(1, "tag_a"); + void tags.set(2, "tag_b"); + return tags; + })(), + ); return t; }), ); @@ -93,7 +95,7 @@ function makeApiEnv(): { global: LuaEnv; sf: LuaStackFrame } { env.setLocal( "asyncGetRecord", new LuaNativeJSFunction((id: number) => - Promise.resolve({ id, name: `item_${id}`, tags: ["tag_a", "tag_b"] }) + Promise.resolve({ id, name: `item_${id}`, tags: ["tag_a", "tag_b"] }), ), ); @@ -107,7 +109,7 @@ function makeApiEnv(): { global: LuaEnv; sf: LuaStackFrame } { env.setLocal( "asyncSet", new LuaNativeJSFunction((_key: string, _value: string) => - Promise.resolve(true) + Promise.resolve(true), ), ); @@ -117,9 +119,9 @@ function makeApiEnv(): { global: LuaEnv; sf: LuaStackFrame } { new LuaNativeJSFunction((id: number) => Promise.resolve( `2024-03-${String((id % 28) + 1).padStart(2, "0")} Task alpha for page ${id}\n` + - `2023-12-${String((id % 28) + 1).padStart(2, "0")} Task beta for page ${id}\n` + - `2024-01-${String((id % 28) + 1).padStart(2, "0")} Task gamma for page ${id}\n`, - ) + `2023-12-${String((id % 28) + 1).padStart(2, "0")} Task beta for page ${id}\n` + + `2024-01-${String((id % 28) + 1).padStart(2, "0")} Task gamma for page ${id}\n`, + ), ), ); @@ -979,9 +981,7 @@ bench("Perf: linked list traversal (1k nodes x100)", () => bench("Perf: table.sort with comparator (1k x20)", () => runStd(asts.tableSort), ); -bench("Perf: table.concat (1k parts x100)", () => - runStd(asts.tableConcat), -); +bench("Perf: table.concat (1k parts x100)", () => runStd(asts.tableConcat)); // --- String Manipulation --- bench("Perf: string.format (10k)", () => runStd(asts.stringFormat)); @@ -1035,9 +1035,7 @@ bench("Perf: async query pattern (fetch+filter+build, 1k)", () => // pcall bench("Perf: pcall success path (1k)", () => runApi(asts.pcallSuccess)); bench("Perf: pcall error path (1k)", () => runStd(asts.pcallError)); -bench("Perf: pcall wrapping async API (1k)", () => - runApi(asts.pcallAsyncApi), -); +bench("Perf: pcall wrapping async API (1k)", () => runApi(asts.pcallAsyncApi)); // string.match / string.gmatch bench("Perf: string.match date parsing (5k)", () => runStd(asts.stringMatchDate), @@ -1053,9 +1051,7 @@ bench("Perf: table.insert + table.concat output (1k x50)", () => runStd(asts.tableInsertConcat), ); // SB string extensions -bench("Perf: string.split hot loop (5k)", () => - runStd(asts.stringSplitHot), -); +bench("Perf: string.split hot loop (5k)", () => runStd(asts.stringSplitHot)); bench("Perf: string.trim + startsWith + endsWith (5k)", () => runStd(asts.stringTrimStartsEnds), ); @@ -1063,9 +1059,7 @@ bench("Perf: string.trim + startsWith + endsWith (5k)", () => bench("Perf: type() checking (100k)", () => runStd(asts.typeChecking)); // os.time / os.date bench("Perf: os.date formatting (2k)", () => runStd(asts.osDateFormat)); -bench("Perf: os.date('*t') table return (2k)", () => - runStd(asts.osDateTable), -); +bench("Perf: os.date('*t') table return (2k)", () => runStd(asts.osDateTable)); // tostring/tonumber bench("Perf: tostring + tonumber conversions (10k)", () => runStd(asts.typeConversions), diff --git a/build/build_client.ts b/build/build_client.ts index d1410c2e..4b6c95f9 100644 --- a/build/build_client.ts +++ b/build/build_client.ts @@ -28,43 +28,52 @@ export async function buildClient(): Promise { jsx: "automatic", jsxFragment: "Fragment", jsxImportSource: "preact", - } + }; const buildConfigs: Array<[String, esbuild.BuildOptions]> = [ - ["client", { - ...baseBuildConfig, - entryPoints: [ - { - in: "client/boot.ts", - out: ".client/client", - } - ], - splitting: true - }], - ["service worker", { - ...baseBuildConfig, - entryPoints: [ - { - in: "client/service_worker.ts", - out: "service_worker", - }, - ], - splitting: false - }], - ["admin ui", { - ...baseBuildConfig, - entryPoints: [ - { - in: "client/admin_ui/admin.tsx", - out: ".client/admin", - }, - ], - splitting: false - }] - ] + [ + "client", + { + ...baseBuildConfig, + entryPoints: [ + { + in: "client/boot.ts", + out: ".client/client", + }, + ], + splitting: true, + }, + ], + [ + "service worker", + { + ...baseBuildConfig, + entryPoints: [ + { + in: "client/service_worker.ts", + out: "service_worker", + }, + ], + splitting: false, + }, + ], + [ + "admin ui", + { + ...baseBuildConfig, + entryPoints: [ + { + in: "client/admin_ui/admin.tsx", + out: ".client/admin", + }, + ], + splitting: false, + }, + ], + ]; for (const [buildName, buildConfig] of buildConfigs) { - const result = await esbuild.build(buildConfig) + const result = await esbuild.build(buildConfig); if (result.metafile) { const text = await esbuild.analyzeMetafile(result.metafile!); @@ -85,7 +94,10 @@ async function copyAssets(dist: string) { await cp("client/images/favicon-96x96.png", `${dist}/favicon-96x96.png`); await cp("client/images/favicon.svg", `${dist}/favicon.svg`); await cp("client/images/favicon.ico", `${dist}/favicon.ico`); - await cp("client/images/apple-touch-icon.png", `${dist}/apple-touch-icon.png`); + await cp( + "client/images/apple-touch-icon.png", + `${dist}/apple-touch-icon.png`, + ); await cp("client/images/logo.png", `${dist}/logo.png`); await cp("client/images/logo-dock.png", `${dist}/logo-dock.png`); diff --git a/client/admin_ui/admin.tsx b/client/admin_ui/admin.tsx index e0bc62e1..51f0eeb9 100644 --- a/client/admin_ui/admin.tsx +++ b/client/admin_ui/admin.tsx @@ -32,7 +32,9 @@ async function api(method: string, path: string, body?: unknown): Promise { function formatApiError(e: unknown): string { if (Array.isArray(e)) { return e - .map((fe: FieldError) => (fe.field ? `${fe.field}: ${fe.message}` : fe.message)) + .map((fe: FieldError) => + fe.field ? `${fe.field}: ${fe.message}` : fe.message, + ) .join(", "); } return "Request failed"; @@ -170,8 +172,8 @@ function SpaceForm({ ); const [bindValue, setBindValue] = useState( initial?.binding.host ?? - initial?.binding.prefix ?? - String(initial?.binding.port ?? ""), + initial?.binding.prefix ?? + String(initial?.binding.port ?? ""), ); const [authMode, setAuthMode] = useState(initial?.auth.mode ?? "inherit"); const [authUser, setAuthUser] = useState(initial?.auth.user ?? ""); @@ -236,7 +238,9 @@ function SpaceForm({ { signal: AbortSignal.timeout(4000) }, ); const remote = await probe.json(); - setHostStatus(remote.instance === own.instance ? "verified" : "mismatch"); + setHostStatus( + remote.instance === own.instance ? "verified" : "mismatch", + ); } catch { setHostStatus("unreachable"); } diff --git a/client/baked_sections/bake.ts b/client/baked_sections/bake.ts index 516525f5..55f98a6a 100644 --- a/client/baked_sections/bake.ts +++ b/client/baked_sections/bake.ts @@ -34,7 +34,11 @@ async function computeBakeEdits( continue; } const body = escapeBakedBody(result.markdown).trim(); - edits.push({ from: section.bodyFrom, to: section.bodyTo, insert: `\n${body}\n` }); + edits.push({ + from: section.bodyFrom, + to: section.bodyTo, + insert: `\n${body}\n`, + }); } return { edits, skipped }; } @@ -89,9 +93,9 @@ export async function updateBakedSections(client: Client): Promise { } if (skipped.length > 0) { client.ui.flashNotification( - `Updated ${edits.length} baked section(s), skipped ${skipped.length}: ${ - skipped.join(", ") - }`, + `Updated ${edits.length} baked section(s), skipped ${skipped.length}: ${skipped.join( + ", ", + )}`, "error", ); } else { diff --git a/client/boot.ts b/client/boot.ts index db86f356..844660ac 100644 --- a/client/boot.ts +++ b/client/boot.ts @@ -170,11 +170,7 @@ safeRun(async () => { if (swDisabled && navigator.serviceWorker) { await flushCachesAndUnregisterServiceWorker(); } - if ( - !isHeadless && - !swDisabled && - navigator.serviceWorker - ) { + if (!isHeadless && !swDisabled && navigator.serviceWorker) { // Register service worker const workerURL = new URL("service_worker.js", document.baseURI); let startNotificationCount = 0; diff --git a/client/client.ts b/client/client.ts index 4f458681..f0782822 100644 --- a/client/client.ts +++ b/client/client.ts @@ -53,7 +53,10 @@ import { PathPageNavigator, parseRefFromURI } from "./navigator.ts"; import { EventHook } from "./plugos/hooks/event.ts"; import { Space } from "./space.ts"; import { evalStatement } from "./space_lua/eval.ts"; -import { parseExpressionString, parseBlock as parseLua } from "./space_lua/parse.ts"; +import { + parseExpressionString, + parseBlock as parseLua, +} from "./space_lua/parse.ts"; import type { LuaCollectionQuery } from "./space_lua/query_collection.ts"; import { LuaEnv, @@ -564,10 +567,10 @@ export class Client { const result = await evalStatement(ast, scriptEnv, sf); const returnValue = result && - typeof result === "object" && - "ctrl" in result && - result.ctrl === "return" && - Array.isArray(result.values) + typeof result === "object" && + "ctrl" in result && + result.ctrl === "return" && + Array.isArray(result.values) ? result.values[0] : result; return (await Promise.resolve(luaValueToJS(returnValue, sf))) ?? null; diff --git a/client/client_system.ts b/client/client_system.ts index 9e3fe4f8..6fbd5e24 100644 --- a/client/client_system.ts +++ b/client/client_system.ts @@ -222,13 +222,11 @@ export class ClientSystem { // Reset + collect Space Lua code widgets this.luaCodeWidgets.clear(); - for ( - const [language, def] of Object.entries( - this.client.config.get< - Record - >("codeWidgets", {}), - ) - ) { + for (const [language, def] of Object.entries( + this.client.config.get< + Record + >("codeWidgets", {}), + )) { if (def && typeof (def as any).render?.call === "function") { this.luaCodeWidgets.set(language, def); } @@ -244,11 +242,7 @@ export class ClientSystem { } async loadPlugFromPath(path: string, lastModified: number) { - await this.system.loadPlug( - WorkerSandbox.forPath(path), - path, - lastModified, - ); + await this.system.loadPlug(WorkerSandbox.forPath(path), path, lastModified); } async reloadPlugsFromSpace(space: Space) { @@ -266,11 +260,10 @@ export class ClientSystem { await Promise.all( allPlugs.map((fileMeta) => - this.loadPlugFromPath(fileMeta.name, fileMeta.lastModified).catch( - (e) => - console.error( - `Could not load plug ${fileMeta.name} error: ${e.message}`, - ), + this.loadPlugFromPath(fileMeta.name, fileMeta.lastModified).catch((e) => + console.error( + `Could not load plug ${fileMeta.name} error: ${e.message}`, + ), ), ), ); diff --git a/client/codemirror/clean.ts b/client/codemirror/clean.ts index e44fa75c..37200fe7 100644 --- a/client/codemirror/clean.ts +++ b/client/codemirror/clean.ts @@ -21,7 +21,6 @@ import { customSyntaxPlugin } from "./custom_syntax_widget.ts"; import { footnotePlugin } from "./footnote.ts"; import { htmlBlockPlugin, htmlInlinePlugin } from "./html_widget.ts"; - export function cleanModePlugins(client: Client) { const pluginsNeededEvenWhenRenderingSyntax = [ luaDirectivePlugin(client), diff --git a/client/codemirror/editor_paste.ts b/client/codemirror/editor_paste.ts index 15cc618b..a087e957 100644 --- a/client/codemirror/editor_paste.ts +++ b/client/codemirror/editor_paste.ts @@ -67,8 +67,8 @@ const urlRegexp = // CodeMirror to actually perform a DOM selection write makes WebKit re-resolve // its caret. We do that by briefly nudging the selection and restoring it once // the paste (and its decoration rebuild) has settled. -const isWebKit = typeof navigator !== "undefined" && - /Apple Computer/.test(navigator.vendor); +const isWebKit = + typeof navigator !== "undefined" && /Apple Computer/.test(navigator.vendor); function fixupWebKitCaretAfterPaste(view: EditorView): void { if (!isWebKit) return; @@ -79,7 +79,7 @@ function fixupWebKitCaretAfterPaste(view: EditorView): void { const head = sel.main.head; const docLen = view.state.doc.length; // Pick a different position to force a real DOM selection write. - const bump = head > 0 ? head - 1 : (docLen > 0 ? head + 1 : head); + const bump = head > 0 ? head - 1 : docLen > 0 ? head + 1 : head; if (bump === head) return; // empty document, nothing to re-resolve const noHistory = Transaction.addToHistory.of(false); view.dispatch({ diff --git a/client/codemirror/editor_state.ts b/client/codemirror/editor_state.ts index 7db3bfa1..89a42eb9 100644 --- a/client/codemirror/editor_state.ts +++ b/client/codemirror/editor_state.ts @@ -33,10 +33,7 @@ import { ViewPlugin, type ViewUpdate, } from "@codemirror/view"; -import { - deleteMarkupBackward, - markdown, -} from "@codemirror/lang-markdown"; +import { deleteMarkupBackward, markdown } from "@codemirror/lang-markdown"; import { customEnterCommand } from "./markdown_enter.ts"; import type { Client } from "../client.ts"; import { loadVim } from "../vim_loader.ts"; @@ -107,13 +104,13 @@ export function createEditorState( const readOnlyExtensions: Extension[] = readOnly || - client.ui.viewState.uiOptions.forcedROMode || - client.bootConfig.readOnly + client.ui.viewState.uiOptions.forcedROMode || + client.bootConfig.readOnly ? [ - EditorView.editable.of(false), - EditorState.readOnly.of(true), - readOnlyCursorActive, - ] + EditorView.editable.of(false), + EditorState.readOnly.of(true), + readOnlyCursorActive, + ] : []; return EditorState.create({ @@ -368,9 +365,7 @@ export function isValidEditor( ); } -export function createCommandKeyBindings( - client: Client, -): Extension { +export function createCommandKeyBindings(client: Client): Extension { const commandKeyBindings: KeyBinding[] = []; const vimMode = client.ui.viewState.uiOptions.vimMode; const readOnly = client.isReadOnlyMode(); @@ -450,15 +445,15 @@ export function createRegularKeyBindings(client: Client): Extension { ...closeBracketsKeymap, ...(client.ui.viewState.uiOptions.vimMode ? [ - // Workaround for https://github.com/replit/codemirror-vim/issues/182; - // without this, Enter does nothing for ordinary paragraphs in insert - // mode. - { - key: "Enter", - run: insertNewlineAndIndent, - shift: insertNewlineAndIndent, - }, - ] + // Workaround for https://github.com/replit/codemirror-vim/issues/182; + // without this, Enter does nothing for ordinary paragraphs in insert + // mode. + { + key: "Enter", + run: insertNewlineAndIndent, + shift: insertNewlineAndIndent, + }, + ] : []), ]); } diff --git a/client/codemirror/fenced_code.ts b/client/codemirror/fenced_code.ts index 83cae2e0..4c227dfd 100644 --- a/client/codemirror/fenced_code.ts +++ b/client/codemirror/fenced_code.ts @@ -81,23 +81,24 @@ export function fencedCodePlugin(client: Client) { .join("\n"); const pageName = client.currentName(); const currentPageMeta = client.currentPageMeta(); - const widget = renderMode === "loading" - ? new LoadingWidget(true) - : new LuaWidget({ - client, - cacheKey: `codewidget:${lang}:${pageName}:${bodyText}`, - expressionText: bodyText, - codeText: text, - callback: () => - renderLuaCallback( + const widget = + renderMode === "loading" + ? new LoadingWidget(true) + : new LuaWidget({ client, - luaWidgetDef.render, - [bodyText, pageName], - currentPageMeta, - ), - renderEmpty: false, - inPage: true, - }); + cacheKey: `codewidget:${lang}:${pageName}:${bodyText}`, + expressionText: bodyText, + codeText: text, + callback: () => + renderLuaCallback( + client, + luaWidgetDef.render, + [bodyText, pageName], + currentPageMeta, + ), + renderEmpty: false, + inPage: true, + }); widgets.push( Decoration.widget({ widget: widget, @@ -150,13 +151,14 @@ export function fencedCodePlugin(client: Client) { ); }); - const widget = renderMode === "loading" - ? new LoadingWidget(true) - : new IFrameWidget( - client, - lineStrings.slice(1, lineStrings.length - 1).join("\n"), - codeWidgetCallback, - ); + const widget = + renderMode === "loading" + ? new LoadingWidget(true) + : new IFrameWidget( + client, + lineStrings.slice(1, lineStrings.length - 1).join("\n"), + codeWidgetCallback, + ); widgets.push( Decoration.widget({ widget: widget, diff --git a/client/codemirror/frontmatter.test.ts b/client/codemirror/frontmatter.test.ts index 3e731502..8a4ce535 100644 --- a/client/codemirror/frontmatter.test.ts +++ b/client/codemirror/frontmatter.test.ts @@ -63,7 +63,7 @@ function countExactFrontmatterDecorations(args: { describe("frontmatter live preview policy", () => { test("does not render frontmatter links while markdown syntax rendering is enabled", () => { - const state = stateWithSelection("url: \"https://silverbullet.md\""); + const state = stateWithSelection('url: "https://silverbullet.md"'); expect( shouldRenderFrontmatterLivePreview({ @@ -76,7 +76,7 @@ describe("frontmatter live preview policy", () => { }); test("does not render the frontmatter link currently being edited", () => { - const state = stateWithSelection("url: \"https://silverbullet.md\"", 10); + const state = stateWithSelection('url: "https://silverbullet.md"', 10); expect( shouldRenderFrontmatterLivePreview({ @@ -90,7 +90,7 @@ describe("frontmatter live preview policy", () => { test("renders a frontmatter link when clean mode is enabled and the cursor is elsewhere", () => { const state = stateWithSelection( - "home: \"https://silverbullet.md\"\nother: \"https://example.com\"", + 'home: "https://silverbullet.md"\nother: "https://example.com"', 40, ); @@ -105,7 +105,7 @@ describe("frontmatter live preview policy", () => { }); test("frontmatter plugin does not replace URLs while markdown syntax rendering is enabled", () => { - const doc = "---\nurl: \"https://silverbullet.md\"\n---\n"; + const doc = '---\nurl: "https://silverbullet.md"\n---\n'; const from = doc.indexOf("https://silverbullet.md"); const to = from + "https://silverbullet.md".length; @@ -120,7 +120,7 @@ describe("frontmatter live preview policy", () => { }); test("frontmatter plugin replaces URLs in clean mode when the cursor is elsewhere", () => { - const doc = "---\nurl: \"https://silverbullet.md\"\n---\n"; + const doc = '---\nurl: "https://silverbullet.md"\n---\n'; const from = doc.indexOf("https://silverbullet.md"); const to = from + "https://silverbullet.md".length; diff --git a/client/codemirror/frontmatter.ts b/client/codemirror/frontmatter.ts index e9015d07..db1e98f6 100644 --- a/client/codemirror/frontmatter.ts +++ b/client/codemirror/frontmatter.ts @@ -6,11 +6,7 @@ import { unfoldEffect, } from "@codemirror/language"; import { Decoration, type EditorView, WidgetType } from "@codemirror/view"; -import { - decoratorStateField, - isCursorInRange, - LinkWidget, -} from "./util.ts"; +import { decoratorStateField, isCursorInRange, LinkWidget } from "./util.ts"; import type { Client } from "../client.ts"; import { frontmatterMailtoRegex, @@ -98,8 +94,10 @@ export function shouldRenderFrontmatterLivePreview({ from: number; to: number; }): boolean { - return !client.ui.viewState.uiOptions.markdownSyntaxRendering && - !isCursorInRange(state, [from, to]); + return ( + !client.ui.viewState.uiOptions.markdownSyntaxRendering && + !isCursorInRange(state, [from, to]) + ); } export function frontmatterPlugin(client: Client) { diff --git a/client/codemirror/frontmatter_folding.test.ts b/client/codemirror/frontmatter_folding.test.ts index 694f3763..7e648758 100644 --- a/client/codemirror/frontmatter_folding.test.ts +++ b/client/codemirror/frontmatter_folding.test.ts @@ -221,15 +221,16 @@ describe("frontmatter fold placeholder", () => { test("returns frontmatter line count for a matching fold range", () => { const state = stateWithDoc("---\na: 1\nb: 2\n---\nBody"); - expect(prepareFrontmatterFoldPlaceholder(state, { from: 0, to: 17 })) - .toEqual({ - type: "frontmatter", - from: 0, - to: 17, - editPos: 4, - lines: 4, - tags: [], - }); + expect( + prepareFrontmatterFoldPlaceholder(state, { from: 0, to: 17 }), + ).toEqual({ + type: "frontmatter", + from: 0, + to: 17, + editPos: 4, + lines: 4, + tags: [], + }); }); test("extracts folded frontmatter tags from scalar and list values", () => { @@ -237,8 +238,10 @@ describe("frontmatter fold placeholder", () => { "feature", "beta", ]); - expect(frontmatterFoldTags("---\ntags:\n- feature\n- beta\n---")) - .toEqual(["feature", "beta"]); + expect(frontmatterFoldTags("---\ntags:\n- feature\n- beta\n---")).toEqual([ + "feature", + "beta", + ]); expect(frontmatterFoldTags("---\ntags: [feature, beta]\n---")).toEqual([ "feature", "beta", @@ -266,8 +269,9 @@ describe("frontmatter fold placeholder", () => { test("returns generic placeholder data for non-frontmatter ranges", () => { const state = stateWithDoc("# Heading\n\nBody"); - expect(prepareFrontmatterFoldPlaceholder(state, { from: 0, to: 9 })) - .toEqual({ type: "generic" }); + expect( + prepareFrontmatterFoldPlaceholder(state, { from: 0, to: 9 }), + ).toEqual({ type: "generic" }); }); test("omits visible placeholder text for folded frontmatter", () => { @@ -294,10 +298,12 @@ describe("frontmatter fold placeholder", () => { expect(placeholder.textContent).toBe("4 frontmatter lines hidden"); expect(placeholder.classList.contains("cm-foldPlaceholder")).toBe(true); - expect(placeholder.classList.contains("cm-frontmatterFoldPlaceholder")) - .toBe(true); - expect(placeholder.querySelector(".cm-frontmatterFoldStatus")?.textContent) - .toBe("4 frontmatter lines hidden"); + expect( + placeholder.classList.contains("cm-frontmatterFoldPlaceholder"), + ).toBe(true); + expect( + placeholder.querySelector(".cm-frontmatterFoldStatus")?.textContent, + ).toBe("4 frontmatter lines hidden"); }); domTest("renders folded frontmatter tags with hashtag styling", () => { @@ -327,74 +333,82 @@ describe("frontmatter fold placeholder", () => { expect(tags[0].getAttribute("data-tag-name")).toBe("feature"); }); - domTest("navigates folded frontmatter tags without unfolding the placeholder", () => { - let unfoldClicks = 0; - const navigations: any[] = []; - const placeholder = frontmatterFoldPlaceholderDOM( - { state: { phrase: (phrase: string) => phrase } } as EditorView, - () => { - unfoldClicks++; - }, - { - type: "frontmatter", - from: 0, - to: 17, - editPos: 4, - lines: 4, - tags: ["feature"], - }, - { - config: { - get(path: unknown, defaultValue: unknown) { - expect(path).toEqual(["tags", "feature", "tagPage"]); - return defaultValue; + domTest( + "navigates folded frontmatter tags without unfolding the placeholder", + () => { + let unfoldClicks = 0; + const navigations: any[] = []; + const placeholder = frontmatterFoldPlaceholderDOM( + { state: { phrase: (phrase: string) => phrase } } as EditorView, + () => { + unfoldClicks++; + }, + { + type: "frontmatter", + from: 0, + to: 17, + editPos: 4, + lines: 4, + tags: ["feature"], + }, + { + config: { + get(path: unknown, defaultValue: unknown) { + expect(path).toEqual(["tags", "feature", "tagPage"]); + return defaultValue; + }, }, + navigate(ref: unknown, replaceState: boolean, newWindow: boolean) { + navigations.push({ ref, replaceState, newWindow }); + }, + } as any, + ); + + placeholder + .querySelector("a")! + .dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ); + + expect(unfoldClicks).toBe(0); + expect(navigations).toHaveLength(1); + expect(navigations[0].ref.path).toBe("tag:feature"); + expect(navigations[0].replaceState).toBe(false); + expect(navigations[0].newWindow).toBe(false); + }, + ); + + domTest( + "clicking folded frontmatter empty space places the cursor inside it", + () => { + const dispatches: any[] = []; + const placeholder = frontmatterFoldPlaceholderDOM( + { + state: { phrase: (phrase: string) => phrase }, + dispatch(transaction: unknown) { + dispatches.push(transaction); + }, + focus() {}, + } as EditorView, + () => {}, + { + type: "frontmatter", + from: 0, + to: 17, + editPos: 4, + lines: 4, + tags: ["feature"], }, - navigate(ref: unknown, replaceState: boolean, newWindow: boolean) { - navigations.push({ ref, replaceState, newWindow }); - }, - } as any, - ); + ); - placeholder.querySelector("a")!.dispatchEvent( - new MouseEvent("click", { bubbles: true, cancelable: true }), - ); + placeholder.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ); - expect(unfoldClicks).toBe(0); - expect(navigations).toHaveLength(1); - expect(navigations[0].ref.path).toBe("tag:feature"); - expect(navigations[0].replaceState).toBe(false); - expect(navigations[0].newWindow).toBe(false); - }); - - domTest("clicking folded frontmatter empty space places the cursor inside it", () => { - const dispatches: any[] = []; - const placeholder = frontmatterFoldPlaceholderDOM( - { - state: { phrase: (phrase: string) => phrase }, - dispatch(transaction: unknown) { - dispatches.push(transaction); - }, - focus() {}, - } as EditorView, - () => {}, - { - type: "frontmatter", - from: 0, - to: 17, - editPos: 4, - lines: 4, - tags: ["feature"], - }, - ); - - placeholder.dispatchEvent( - new MouseEvent("click", { bubbles: true, cancelable: true }), - ); - - expect(dispatches).toHaveLength(1); - expect(dispatches[0].selection).toEqual({ anchor: 4 }); - }); + expect(dispatches).toHaveLength(1); + expect(dispatches[0].selection).toEqual({ anchor: 4 }); + }, + ); domTest("renders the generic placeholder for non-frontmatter folds", () => { const placeholder = frontmatterFoldPlaceholderDOM( @@ -426,7 +440,9 @@ describe("frontmatter folding extension", () => { selection: { anchor: selection }, extensions: [ buildExtendedMarkdownLanguage(), - frontmatterFoldingExtension(clientWithFrontmatterFolding(config) as any), + frontmatterFoldingExtension( + clientWithFrontmatterFolding(config) as any, + ), ], }); } diff --git a/client/codemirror/frontmatter_folding.ts b/client/codemirror/frontmatter_folding.ts index 2248410c..2e615a57 100644 --- a/client/codemirror/frontmatter_folding.ts +++ b/client/codemirror/frontmatter_folding.ts @@ -9,7 +9,10 @@ import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; import YAML from "js-yaml"; import type { Client } from "../client.ts"; import { tagPrefix } from "../../plugs/index/constants.ts"; -import { encodePageURI, parseToRef } from "@silverbulletmd/silverbullet/lib/ref"; +import { + encodePageURI, + parseToRef, +} from "@silverbulletmd/silverbullet/lib/ref"; export type FrontmatterFoldByDefault = "never" | "long" | "always"; @@ -36,13 +39,13 @@ type FoldRange = { export type FrontmatterFoldPlaceholder = | { - type: "frontmatter"; - from: number; - to: number; - editPos: number; - lines: number; - tags: string[]; - } + type: "frontmatter"; + from: number; + to: number; + editPos: number; + lines: number; + tags: string[]; + } | { type: "generic" }; function frontmatterParseUpto(state: EditorState): number { @@ -93,8 +96,8 @@ export function findFrontmatterBlock( ): FrontmatterBlock | undefined { let block: FrontmatterBlock | undefined; - const tree = ensureSyntaxTree(state, frontmatterParseUpto(state)) ?? - syntaxTree(state); + const tree = + ensureSyntaxTree(state, frontmatterParseUpto(state)) ?? syntaxTree(state); tree.iterate({ enter(node) { @@ -179,9 +182,10 @@ function normalizeFoldTags(value: unknown): string[] { return value.map(normalizeFoldTag).filter((tag) => tag !== undefined); } if (typeof value === "string") { - return value.split(/\s+/).map(normalizeFoldTag).filter((tag) => - tag !== undefined - ); + return value + .split(/\s+/) + .map(normalizeFoldTag) + .filter((tag) => tag !== undefined); } return []; } @@ -214,8 +218,10 @@ export function frontmatterFoldTagTarget( client: Client | undefined, tag: string, ): string { - return client?.config.get(["tags", tag, "tagPage"], null) ?? - `${tagPrefix}${tag}`; + return ( + client?.config.get(["tags", tag, "tagPage"], null) ?? + `${tagPrefix}${tag}` + ); } export function frontmatterFoldPlaceholderDOM( @@ -283,7 +289,9 @@ export function frontmatterFoldPlaceholderDOM( return element; } -function clientFrontmatterFoldingConfig(client: Client): FrontmatterFoldingConfig { +function clientFrontmatterFoldingConfig( + client: Client, +): FrontmatterFoldingConfig { return normalizeFrontmatterFoldingConfig( client.config.get("frontmatterFolding", defaultFrontmatterFoldingConfig), ); diff --git a/client/codemirror/html_element.test.ts b/client/codemirror/html_element.test.ts index 2d7cc537..8891b312 100644 --- a/client/codemirror/html_element.test.ts +++ b/client/codemirror/html_element.test.ts @@ -61,9 +61,7 @@ test("parseHtmlTag - invalid text", () => { expect(parseHtmlTag("")).toBeNull(); }); -function makeTags( - ...specs: { text: string; from: number; to: number }[] -) { +function makeTags(...specs: { text: string; from: number; to: number }[]) { return specs.map((s) => { const parsed = parseHtmlTag(s.text)!; return { ...parsed, from: s.from, to: s.to, text: s.text }; @@ -132,4 +130,3 @@ test("matchHtmlTagPairs - interleaved tags", () => { expect(pairs[0].open.tagName).toBe("b"); expect(pairs[1].open.tagName).toBe("i"); }); - diff --git a/client/codemirror/html_widget.ts b/client/codemirror/html_widget.ts index baffe00c..e55c330c 100644 --- a/client/codemirror/html_widget.ts +++ b/client/codemirror/html_widget.ts @@ -94,10 +94,7 @@ export function htmlBlockPlugin(client: Client) { if (isCursorInRange(state, [from, to])) return; const sourceText = state.sliceDoc(from, to); - const parseTree = lezerToParseTree( - state.sliceDoc(0, to), - node.node, - ); + const parseTree = lezerToParseTree(state.sliceDoc(0, to), node.node); widgets.push(invisibleDecoration.range(from, to)); widgets.push( @@ -208,11 +205,12 @@ export function htmlInlinePlugin(client: Client) { // Pick out the children of the paragraph that fall within the // matched range. lezerToParseTree interleaves text fillers between // structural children, and the same [from,to] check picks both up. - const childrenInRange = (ptree.children ?? []).filter((c) => - c.from !== undefined && - c.to !== undefined && - c.from >= rangeFrom && - c.to <= rangeTo + const childrenInRange = (ptree.children ?? []).filter( + (c) => + c.from !== undefined && + c.to !== undefined && + c.from >= rangeFrom && + c.to <= rangeTo, ); if (childrenInRange.length === 0) return; diff --git a/client/codemirror/iframe_widget.ts b/client/codemirror/iframe_widget.ts index 361a0ecf..151d96fa 100644 --- a/client/codemirror/iframe_widget.ts +++ b/client/codemirror/iframe_widget.ts @@ -17,11 +17,13 @@ export class IFrameWidget extends WidgetType { super(); // Eagerly kick off the callback so the result is in flight before // CodeMirror mounts the widget. Idempotent on bodyText. - this.client.widgetCache.prewarmResult(this.bodyText, () => - this.codeWidgetCallback(this.bodyText, this.client.currentName()), - ).catch(() => { - // renderContent / iframe message handler will surface errors. - }); + this.client.widgetCache + .prewarmResult(this.bodyText, () => + this.codeWidgetCallback(this.bodyText, this.client.currentName()), + ) + .catch(() => { + // renderContent / iframe message handler will surface errors. + }); } override get estimatedHeight(): number { diff --git a/client/codemirror/inline_content.ts b/client/codemirror/inline_content.ts index 9b8880a7..a51d5e68 100644 --- a/client/codemirror/inline_content.ts +++ b/client/codemirror/inline_content.ts @@ -80,7 +80,10 @@ export function inlineContentPlugin(client: Client) { openRef: parseToRef(transclusion.url), callback: async () => { // Resolve local URLs (only for markdown links, wikilinks are absolute) - if (isLocalURL(transclusion.url) && transclusion.linktype !== "wikilink") { + if ( + isLocalURL(transclusion.url) && + transclusion.linktype !== "wikilink" + ) { transclusion.url = resolveMarkdownLink( client.currentName(), decodeURI(transclusion.url), diff --git a/client/codemirror/lint.ts b/client/codemirror/lint.ts index fa2698f1..a0652d5b 100644 --- a/client/codemirror/lint.ts +++ b/client/codemirror/lint.ts @@ -12,24 +12,27 @@ import type { export const refreshLintEffect = StateEffect.define(); export function plugLinter(client: Client) { - return linter(async (view): Promise => { - const text = view.state.sliceDoc(); - const tree = parse(extendedMarkdownLanguage, text); - const results: LintDiagnostic[] = ( - await client.dispatchAppEvent("editor:lint", { - name: client.currentName(), - pageMeta: client.currentPageMeta(), - tree, - text, - } as LintEvent) - ).flat(); - return results.map(toDiagnostic); - }, { - needsRefresh: (update) => - update.transactions.some((tr) => - tr.effects.some((e) => e.is(refreshLintEffect)) - ), - }); + return linter( + async (view): Promise => { + const text = view.state.sliceDoc(); + const tree = parse(extendedMarkdownLanguage, text); + const results: LintDiagnostic[] = ( + await client.dispatchAppEvent("editor:lint", { + name: client.currentName(), + pageMeta: client.currentPageMeta(), + tree, + text, + } as LintEvent) + ).flat(); + return results.map(toDiagnostic); + }, + { + needsRefresh: (update) => + update.transactions.some((tr) => + tr.effects.some((e) => e.is(refreshLintEffect)), + ), + }, + ); } function toDiagnostic(d: LintDiagnostic): Diagnostic { diff --git a/client/codemirror/list_indent.ts b/client/codemirror/list_indent.ts index 0c3c82b8..b913a880 100644 --- a/client/codemirror/list_indent.ts +++ b/client/codemirror/list_indent.ts @@ -23,7 +23,7 @@ export function computeMarkerWidth( // TaskState wraps `[X]` / `[done]` / etc. (length varies by status string). // Width contribution = TaskState range length + 1 for the trailing space. const taskState = listItem.getChild("Task")?.getChild("TaskState"); - const taskExtra = taskState ? (taskState.to - taskState.from) + 1 : 0; + const taskExtra = taskState ? taskState.to - taskState.from + 1 : 0; return leadingIndent + markLength + 1 + taskExtra; } @@ -49,8 +49,7 @@ export function listIndentPlugin() { const startLine = doc.lineAt(from); const width = computeMarkerWidth(node, startLine.from); if (width <= 0) return; - const styleAttr = - `padding-left:${width}ch;text-indent:-${width}ch`; + const styleAttr = `padding-left:${width}ch;text-indent:-${width}ch`; const endLine = doc.lineAt(to); for ( let lineNo = startLine.number; diff --git a/client/codemirror/loading_widget.ts b/client/codemirror/loading_widget.ts index 5715f734..3b5c1a5e 100644 --- a/client/codemirror/loading_widget.ts +++ b/client/codemirror/loading_widget.ts @@ -17,7 +17,8 @@ export class LoadingWidget extends WidgetType { override toDOM(): HTMLElement { const wrapper = document.createElement("span"); - wrapper.className = "sb-loading-widget " + + wrapper.className = + "sb-loading-widget " + (this.block ? "sb-loading-widget-block" : "sb-loading-widget-inline"); const spinner = document.createElement("span"); spinner.className = "sb-loading-spinner"; diff --git a/client/codemirror/lua_widget.ts b/client/codemirror/lua_widget.ts index 5b54b342..939cac0c 100644 --- a/client/codemirror/lua_widget.ts +++ b/client/codemirror/lua_widget.ts @@ -90,15 +90,17 @@ export class LuaWidget extends WidgetType { ...opts, }; if (this.opts.inPage) { - this.opts.client.widgetCache.prewarmResult(this.opts.cacheKey, () => - this.opts.callback( - this.opts.expressionText, - this.opts.client.currentName(), - ), - ).catch(() => { - // Ignore: renderContent re-awaits the same promise and handles - // errors via its own catch path. - }); + this.opts.client.widgetCache + .prewarmResult(this.opts.cacheKey, () => + this.opts.callback( + this.opts.expressionText, + this.opts.client.currentName(), + ), + ) + .catch(() => { + // Ignore: renderContent re-awaits the same promise and handles + // errors via its own catch path. + }); } } @@ -276,9 +278,10 @@ export class LuaWidget extends WidgetType { // diagram renders to an SVG via `html` while exposing its source block via // `markdown`). Only fall back to the html itself when no markdown is given. if (!copyContent) { - copyContent = typeof wc.html === "string" - ? (wc.markdown ?? wc.html) - : (wc.markdown ?? wc.html.outerHTML); + copyContent = + typeof wc.html === "string" + ? (wc.markdown ?? wc.html) + : (wc.markdown ?? wc.html.outerHTML); } block = wc.display === "block"; @@ -341,10 +344,7 @@ export class LuaWidget extends WidgetType { renderMarkdownToHtml( mdTree, { - shortWikiLinks: this.opts.client.config.get( - "shortWikiLinks", - true, - ), + shortWikiLinks: this.opts.client.config.get("shortWikiLinks", true), translateUrls: buildTranslateUrls(this.opts.client), }, this.opts.client.ui.viewState.allPages, @@ -353,11 +353,11 @@ export class LuaWidget extends WidgetType { } if (html) { const bakeBody = wc.html - ? (typeof wc.markdown === "string" ? wc.markdown.trim() : undefined) + ? typeof wc.markdown === "string" + ? wc.markdown.trim() + : undefined : copyContent; - div.replaceChildren( - this.wrapHtml(block, html, copyContent, bakeBody), - ); + div.replaceChildren(this.wrapHtml(block, html, copyContent, bakeBody)); div.style.minHeight = ""; attachWidgetEventHandlers( div, @@ -448,14 +448,15 @@ export class LuaWidget extends WidgetType { } if ( - this.opts.bakeable && bakeBody !== undefined && this.opts.codeText && + this.opts.bakeable && + bakeBody !== undefined && + this.opts.codeText && !this.opts.client.isReadOnlyMode() ) { buttonBar.appendChild( createButton({ title: "Bake", - icon: - '', + icon: '', listener: (e) => { e.stopPropagation(); const range = findWidgetSourceRange( @@ -470,9 +471,9 @@ export class LuaWidget extends WidgetType { ); return; } - const replacement = `\n${ - escapeBakedBody(bakeBody).trim() - }\n`; + const replacement = `\n${escapeBakedBody( + bakeBody, + ).trim()}\n`; this.opts.client.editorView.dispatch({ changes: { from: range.from, to: range.to, insert: replacement }, }); diff --git a/client/codemirror/markdown_enter.test.ts b/client/codemirror/markdown_enter.test.ts index 7dad2318..e8c47935 100644 --- a/client/codemirror/markdown_enter.test.ts +++ b/client/codemirror/markdown_enter.test.ts @@ -8,7 +8,8 @@ const CURSOR = "|^|"; function runEnter(input: string): string | false { const cursorPos = input.indexOf(CURSOR); - const doc = input.slice(0, cursorPos) + input.slice(cursorPos + CURSOR.length); + const doc = + input.slice(0, cursorPos) + input.slice(cursorPos + CURSOR.length); const state = EditorState.create({ doc, diff --git a/client/codemirror/smart_quotes.ts b/client/codemirror/smart_quotes.ts index bcf26851..6145574d 100644 --- a/client/codemirror/smart_quotes.ts +++ b/client/codemirror/smart_quotes.ts @@ -102,7 +102,15 @@ export function createSmartQuoteKeyBindings(client: Client): KeyBinding[] { return []; } return [ - keyBindingForQuote('"', smartQuotes.double!.left!, smartQuotes.double!.right!), - keyBindingForQuote("'", smartQuotes.single!.left!, smartQuotes.single!.right!), + keyBindingForQuote( + '"', + smartQuotes.double!.left!, + smartQuotes.double!.right!, + ), + keyBindingForQuote( + "'", + smartQuotes.single!.left!, + smartQuotes.single!.right!, + ), ]; } diff --git a/client/codemirror/util.ts b/client/codemirror/util.ts index e1d67a03..f0970738 100644 --- a/client/codemirror/util.ts +++ b/client/codemirror/util.ts @@ -186,8 +186,9 @@ export const readOnlyCursorActive = StateField.define({ // no-op `{ selection: currentSelection }` on render (to re-measure their // DOM) — that carries `tr.selection` but doesn't move the caret, and must // not count as a deliberate placement. - return tr.selection !== undefined && - !tr.startState.selection.eq(tr.newSelection); + return ( + tr.selection !== undefined && !tr.startState.selection.eq(tr.newSelection) + ); }, }); diff --git a/client/codemirror/widget_util.ts b/client/codemirror/widget_util.ts index 6168fc15..b5717a42 100644 --- a/client/codemirror/widget_util.ts +++ b/client/codemirror/widget_util.ts @@ -6,9 +6,7 @@ import { resolveMarkdownLink, } from "@silverbulletmd/silverbullet/lib/resolve"; -export function buildTranslateUrls( - client: Client, -): (url: string) => string { +export function buildTranslateUrls(client: Client): (url: string) => string { return (url: string) => { if (isLocalURL(url)) { return resolveMarkdownLink(client.currentName(), decodeURI(url)); diff --git a/client/codemirror/wiki_link_processor.ts b/client/codemirror/wiki_link_processor.ts index 94aa1e48..46abe9c0 100644 --- a/client/codemirror/wiki_link_processor.ts +++ b/client/codemirror/wiki_link_processor.ts @@ -20,9 +20,8 @@ import type { PageMeta } from "@silverbulletmd/silverbullet/type/index"; // pages in large spaces painfully slow. Memoize the map and only rebuild it // when the `allPages` array identity changes (i.e. when the page list is // actually replaced). -let pageByPathCache: - | { pages: PageMeta[]; map: Map } - | null = null; +let pageByPathCache: { pages: PageMeta[]; map: Map } | null = + null; function pageByPath(allPages: PageMeta[]): Map { if (pageByPathCache?.pages === allPages) { diff --git a/client/components/anything_picker.tsx b/client/components/anything_picker.tsx index b74e9ee4..84fe4930 100644 --- a/client/components/anything_picker.tsx +++ b/client/components/anything_picker.tsx @@ -1,7 +1,10 @@ import { FilterList } from "./filter.tsx"; import type { FilterOption } from "@silverbulletmd/silverbullet/type/client"; import { tagRegex as mdTagRegex } from "../markdown_parser/constants.ts"; -import { extractHashtag, isMetaTag } from "@silverbulletmd/silverbullet/lib/tags"; +import { + extractHashtag, + isMetaTag, +} from "@silverbulletmd/silverbullet/lib/tags"; import type { DocumentMeta, PageMeta, diff --git a/client/components/command_palette.tsx b/client/components/command_palette.tsx index 4ba09004..4293bf6f 100644 --- a/client/components/command_palette.tsx +++ b/client/components/command_palette.tsx @@ -47,7 +47,10 @@ export function CommandPalette({ ); } -export function keyboardHint(def: { key?: string | string[]; mac?: string | string[] }): string | undefined { +export function keyboardHint(def: { + key?: string | string[]; + mac?: string | string[]; +}): string | undefined { const shortcuts: string[] = []; if (isMacLike && def.mac) { if (Array.isArray(def.mac)) { diff --git a/client/components/top_bar.tsx b/client/components/top_bar.tsx index 99703b59..fb242d9e 100644 --- a/client/components/top_bar.tsx +++ b/client/components/top_bar.tsx @@ -228,10 +228,7 @@ export function TopBar({ readOnly: boolean; }) { return ( -
+
{lhs}
@@ -255,23 +252,19 @@ export function TopBar({ percentage={progressPercentage} type={progressType} /> - {mobileMenuStyle - ? ( - <> - - b.dropdown === false - )} - /> - - b.dropdown !== false - )} - mobileMenuStyle={mobileMenuStyle} - /> - - ) - : } + {mobileMenuStyle ? ( + <> + b.dropdown === false)} + /> + b.dropdown !== false)} + mobileMenuStyle={mobileMenuStyle} + /> + + ) : ( + + )}
diff --git a/client/content_manager.ts b/client/content_manager.ts index 1c214dc9..6fc90a99 100644 --- a/client/content_manager.ts +++ b/client/content_manager.ts @@ -47,7 +47,7 @@ export class ContentManager { .catch((e) => console.error("Error dispatching editor:updated event", e)); }, 1000); - constructor(private client: Client) { } + constructor(private client: Client) {} // Save the current page or document save(immediate = false): Promise { @@ -172,9 +172,7 @@ export class ContentManager { */ private async leaveCurrentPage(newPath: string) { const previousPath = this.client.ui.viewState.current?.path; - const loadingDifferentPath = previousPath - ? previousPath !== newPath - : true; + const loadingDifferentPath = previousPath ? previousPath !== newPath : true; if (previousPath) { this.client.space.unwatchFile(previousPath); diff --git a/client/data/mq.datastore.ts b/client/data/mq.datastore.ts index 4a8ff98e..e3f784d9 100644 --- a/client/data/mq.datastore.ts +++ b/client/data/mq.datastore.ts @@ -185,11 +185,9 @@ export class DataStoreMQ { // (luaQuery would materialize the ENTIRE queue before applying the // limit — O(queue length) per poll, quadratic over a full space reindex.) const messages: MQMessage[] = []; - for await ( - const { value } of this.ds.query({ - prefix: [...queuedPrefix, queue], - }) - ) { + for await (const { value } of this.ds.query({ + prefix: [...queuedPrefix, queue], + })) { messages.push(value); if (messages.length >= maxItems) { break; diff --git a/client/data/object_index.ts b/client/data/object_index.ts index e044ded2..4370e7d5 100644 --- a/client/data/object_index.ts +++ b/client/data/object_index.ts @@ -203,7 +203,8 @@ export class ObjectIndex { rootTaggedObjects(rootTag: string, tag?: string): LuaQueryCollection { if (tag) { return this.filteredTag( - tag, (varName) => `${varName}.tag == "${rootTag}"` + tag, + (varName) => `${varName}.tag == "${rootTag}"`, ); } else { return this.objectsWithTag(rootTag); @@ -228,12 +229,12 @@ export class ObjectIndex { const filter = parseExpressionString(buildFilterExpr(varName)); const where = query.where ? { - type: "Binary" as const, - operator: "and", - left: filter, - right: query.where, - ctx: {}, - } + type: "Binary" as const, + operator: "and", + left: filter, + right: query.where, + ctx: {}, + } : filter; return this.ds.luaQuery( ["idx", tagName], @@ -519,7 +520,7 @@ export class ObjectIndex { } } if (tag === "link") { - // Route through the virtual link collection + // Route through the virtual link collection return this.linkObjects().query(query, env, sf) as Promise< ObjectValue[] >; @@ -535,7 +536,8 @@ export class ObjectIndex { key: [indexKey, ...key, page], value, }, - { // Reverse key storage for quick deletions + { + // Reverse key storage for quick deletions key: [pageKey, page, ...key], value: true, }, diff --git a/client/editor_commands.ts b/client/editor_commands.ts index 99065dce..76834d84 100644 --- a/client/editor_commands.ts +++ b/client/editor_commands.ts @@ -538,7 +538,12 @@ export function registerEditorCommands( key: "Ctrl-Shift-k", mac: "Cmd-Shift-k", priority: 2, - menu: { location: "navigate", group: "2_picker", order: 4, label: "Meta Page..." }, + menu: { + location: "navigate", + group: "2_picker", + order: 4, + label: "Meta Page...", + }, run: async () => client.startPageNavigate("meta"), }); hook.registerCommand({ @@ -548,7 +553,12 @@ export function registerEditorCommands( priority: 2, menu: [ { location: "file", group: "1_new", order: 3, label: "Open Document..." }, - { location: "navigate", group: "2_picker", order: 2, label: "Document..." }, + { + location: "navigate", + group: "2_picker", + order: 2, + label: "Document...", + }, ], run: async () => client.startPageNavigate("document"), }); diff --git a/client/editor_ui.tsx b/client/editor_ui.tsx index 9bcad6a6..e32561a9 100644 --- a/client/editor_ui.tsx +++ b/client/editor_ui.tsx @@ -1,8 +1,5 @@ import { Confirm, Prompt } from "./components/basic_modals.tsx"; -import { - CommandPalette, - keyboardHint, -} from "./components/command_palette.tsx"; +import { CommandPalette, keyboardHint } from "./components/command_palette.tsx"; import { FilterList } from "./components/filter.tsx"; import { AnythingPicker } from "./components/anything_picker.tsx"; import { TopBar } from "./components/top_bar.tsx"; @@ -67,7 +64,8 @@ export class MainUI { // CodeMirror mini-editor. const cmd = ev.metaKey || ev.ctrlKey; const key = ev.key.toLowerCase(); - const fieldHandlesNatively = !cmd || + const fieldHandlesNatively = + !cmd || ["a", "c", "v", "x", "z", "y"].includes(key) || [ "arrowleft", @@ -570,12 +568,11 @@ export class MainUI { cssClass={(client.currentPageMeta()?.pageDecoration?.cssClasses ?? []) .join(" ") .replaceAll(/[^a-zA-Z0-9-_ ]/g, "")} - mobileMenuStyle={viewState.isMobile - ? client.config.get( - "mobileMenuStyle", - "hamburger", - ) - : undefined} + mobileMenuStyle={ + viewState.isMobile + ? client.config.get("mobileMenuStyle", "hamburger") + : undefined + } readOnly={ viewState.uiOptions.forcedROMode || client.bootConfig.readOnly } diff --git a/client/languages.ts b/client/languages.ts index c15b80a5..f2bd620e 100644 --- a/client/languages.ts +++ b/client/languages.ts @@ -49,11 +49,9 @@ export const lazyLanguages: Record Promise> = { (await import("@codemirror/legacy-modes/mode/sql")).pgSQL, ), css: async () => (await import("@codemirror/lang-css")).cssLanguage, - "space-style": async () => - (await import("@codemirror/lang-css")).cssLanguage, + "space-style": async () => (await import("@codemirror/lang-css")).cssLanguage, html: async () => (await import("@codemirror/lang-html")).htmlLanguage, - nix: async () => - (await import("@replit/codemirror-lang-nix")).nixLanguage, + nix: async () => (await import("@replit/codemirror-lang-nix")).nixLanguage, python: async () => StreamLanguage.define( (await import("@codemirror/legacy-modes/mode/python")).python, @@ -71,9 +69,7 @@ export const lazyLanguages: Record Promise> = { (await import("@codemirror/legacy-modes/mode/rust")).rust, ), r: async () => - StreamLanguage.define( - (await import("@codemirror/legacy-modes/mode/r")).r, - ), + StreamLanguage.define((await import("@codemirror/legacy-modes/mode/r")).r), shell: async () => StreamLanguage.define( (await import("@codemirror/legacy-modes/mode/shell")).shell, @@ -243,13 +239,14 @@ export const lazyLanguages: Record Promise> = { const cache: Record = {}; export function languageFor(name: string): Language | null { - return eagerLanguages[name] ?? cache[name] ?? - (name.startsWith("#") ? yamlStreamLanguage : null); + return ( + eagerLanguages[name] ?? + cache[name] ?? + (name.startsWith("#") ? yamlStreamLanguage : null) + ); } -export async function loadLanguageFor( - name: string, -): Promise { +export async function loadLanguageFor(name: string): Promise { const eager = eagerLanguages[name]; if (eager) return eager; if (name.startsWith("#")) return yamlStreamLanguage; @@ -261,8 +258,5 @@ export async function loadLanguageFor( } export const allLanguageNames = [ - ...new Set([ - ...Object.keys(eagerLanguages), - ...Object.keys(lazyLanguages), - ]), + ...new Set([...Object.keys(eagerLanguages), ...Object.keys(lazyLanguages)]), ]; diff --git a/client/lib/fuzzy_search.test.ts b/client/lib/fuzzy_search.test.ts index 86460dad..54a23ca2 100644 --- a/client/lib/fuzzy_search.test.ts +++ b/client/lib/fuzzy_search.test.ts @@ -21,9 +21,11 @@ function assertSearch( ): void { const results = fuzzySearchAndSort(corpus, query); const names = results.map((r) => r.name); - const detail = `\nQuery: ${JSON.stringify(query)}\nRanked: ${ - JSON.stringify(names, null, 2) - }`; + const detail = `\nQuery: ${JSON.stringify(query)}\nRanked: ${JSON.stringify( + names, + null, + 2, + )}`; if (expected.resultCount !== undefined) { expect(results.length, `result count${detail}`).toEqual( expected.resultCount, @@ -68,7 +70,7 @@ test("scoreToken: prefix at start scores 0.95 (always word boundary)", () => { test("scoreToken: substring scores 0.75 (plain) or 0.80 (word boundary)", () => { expect(scoreToken("ver", "Silver")).toBeCloseTo(0.75, 5); - expect(scoreToken("silver", "Some/Silver/Thing")).toBeCloseTo(0.80, 5); + expect(scoreToken("silver", "Some/Silver/Thing")).toBeCloseTo(0.8, 5); }); test("scoreToken: no match returns 0 (before tiers 4/5 implemented)", () => { @@ -176,11 +178,7 @@ test("seed 2: 'silverbullet' exact wins, all SilverBullet/* in top-3", () => { test("seed 3: 'pae' matches PageEditor via subsequence", () => { assertSearch( - [ - { name: "PageEditor" }, - { name: "PathEditor" }, - { name: "Other" }, - ], + [{ name: "PageEditor" }, { name: "PathEditor" }, { name: "Other" }], "pae", { mustIncludeInTopN: ["PageEditor", "PathEditor"], n: 2 }, ); @@ -188,11 +186,7 @@ test("seed 3: 'pae' matches PageEditor via subsequence", () => { test("seed 4: 'slverbullet' typo matches SilverBullet", () => { assertSearch( - [ - { name: "SilverBullet" }, - { name: "Silver" }, - { name: "Bullet" }, - ], + [{ name: "SilverBullet" }, { name: "Silver" }, { name: "Bullet" }], "slverbullet", { topMatch: "SilverBullet" }, ); @@ -218,11 +212,7 @@ test("seed 5: 'Co' prefers basename match", () => { test("seed 6: 'in' prefix beats subsequence; no typo tolerance on short", () => { assertSearch( - [ - { name: "Inbox" }, - { name: "In Progress" }, - { name: "Index" }, - ], + [{ name: "Inbox" }, { name: "In Progress" }, { name: "Index" }], "in", { mustIncludeInTopN: ["Inbox", "In Progress", "Index"], @@ -233,21 +223,14 @@ test("seed 6: 'in' prefix beats subsequence; no typo tolerance on short", () => test("seed 7: 'daily 02' matches Daily/2024-02-15", () => { assertSearch( - [ - { name: "Daily/2024-01-15" }, - { name: "Daily/2024-02-15" }, - ], + [{ name: "Daily/2024-01-15" }, { name: "Daily/2024-02-15" }], "daily 02", { topMatch: "Daily/2024-02-15", resultCount: 1 }, ); }); test("seed 8: no matches returns empty", () => { - assertSearch( - [{ name: "Foo" }, { name: "Bar" }], - "xyz", - { resultCount: 0 }, - ); + assertSearch([{ name: "Foo" }, { name: "Bar" }], "xyz", { resultCount: 0 }); }); test("seed 9: empty query sorts by orderId, aspiring (Infinity) last", () => { @@ -268,14 +251,10 @@ test("seed 9: empty query sorts by orderId, aspiring (Infinity) last", () => { }); test("seed 10: 'readme' case-insensitive matches both", () => { - assertSearch( - [{ name: "README" }, { name: "readme.md" }], - "readme", - { - mustIncludeInTopN: ["README", "readme.md"], - n: 2, - }, - ); + assertSearch([{ name: "README" }, { name: "readme.md" }], "readme", { + mustIncludeInTopN: ["README", "readme.md"], + n: 2, + }); }); test("seed 11: alias matching", () => { @@ -290,11 +269,7 @@ test("seed 11: alias matching", () => { }); test("seed 12: 1-2 char queries do not match via typo tolerance", () => { - assertSearch( - [{ name: "cat" }, { name: "dog" }], - "co", - { resultCount: 0 }, - ); + assertSearch([{ name: "cat" }, { name: "dog" }], "co", { resultCount: 0 }); }); test("preserves existing testFuzzyFilter behavior", () => { diff --git a/client/lib/fuzzy_search.ts b/client/lib/fuzzy_search.ts index c72880b2..a36d4a2d 100644 --- a/client/lib/fuzzy_search.ts +++ b/client/lib/fuzzy_search.ts @@ -2,11 +2,16 @@ import type { FilterOption } from "@silverbulletmd/silverbullet/type/client"; import { fileName } from "@silverbulletmd/silverbullet/lib/resolve"; function normalize(s: string): string { - return s.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase(); + return s + .normalize("NFD") + .replace(/\p{Diacritic}/gu, "") + .toLowerCase(); } function tokenize(query: string): string[] { - return normalize(query).split(/\s+/).filter((t) => t.length > 0); + return normalize(query) + .split(/\s+/) + .filter((t) => t.length > 0); } const WORD_BOUNDARY_CHARS = new Set(["/", "-", "_", " ", ".", ":"]); @@ -23,7 +28,8 @@ function isWordBoundaryAt( const origPrev = original[i - 1]; const origCur = original[i]; if ( - origPrev && origCur && + origPrev && + origCur && origPrev === origPrev.toLowerCase() && origPrev !== origPrev.toUpperCase() && origCur === origCur.toUpperCase() && @@ -34,11 +40,7 @@ function isWordBoundaryAt( return false; } -function boundedDamerauLevenshtein( - a: string, - b: string, - max: number, -): number { +function boundedDamerauLevenshtein(a: string, b: string, max: number): number { // Returns edit distance if <= max, otherwise max + 1. const al = a.length; const bl = b.length; @@ -55,16 +57,9 @@ function boundedDamerauLevenshtein( let rowMin = curr[0]; for (let j = 1; j <= bl; j++) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; - let v = Math.min( - prev[j] + 1, - curr[j - 1] + 1, - prev[j - 1] + cost, - ); + let v = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost); // Damerau transposition - if ( - i > 1 && j > 1 && - a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1] - ) { + if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) { v = Math.min(v, prevPrev[j - 2] + 1); } curr[j] = v; @@ -145,7 +140,7 @@ function subsequenceScore( // Weighted combination scaled into [0.30, 0.65] const raw = 0.5 * density + 0.3 * contiguityBonus + 0.2 * boundaryBonus; - return 0.30 + raw * 0.35; + return 0.3 + raw * 0.35; } export function scoreToken(token: string, candidate: string): number { @@ -181,7 +176,7 @@ export function scoreToken(token: string, candidate: string): number { // For very short tokens (<=2 chars), only count substring matches at // word boundaries — otherwise short tokens generate too much noise. if (t.length > 2 || bestBoundary) { - return bestBoundary ? 0.80 : 0.75; + return bestBoundary ? 0.8 : 0.75; } // fall through for short non-boundary substring matches } @@ -244,10 +239,7 @@ export function scoreCandidate( return Math.exp(logSum / perTokenBest.length); } -function compareOrderId( - a: number | undefined, - b: number | undefined, -): number { +function compareOrderId(a: number | undefined, b: number | undefined): number { const aOrder = a ?? 0; const bOrder = b ?? 0; if (aOrder === Infinity && bOrder === Infinity) return 0; diff --git a/client/lib/util.ts b/client/lib/util.ts index ae1d5eae..b9e83a0f 100644 --- a/client/lib/util.ts +++ b/client/lib/util.ts @@ -31,7 +31,8 @@ export function headersToFileMeta( size: headers.has("X-Content-Length") ? +headers.get("X-Content-Length")! : +headers.get("Content-Length")!, - contentType: headers.get("X-Content-Type") ?? headers.get("Content-type")!, + contentType: + headers.get("X-Content-Type") ?? headers.get("Content-type")!, created: +(headers.get("X-Created") || "0"), lastModified: +(headers.get("X-Last-Modified") || "0"), perm: (headers.get("X-Permission") as "rw" | "ro") || "ro", diff --git a/client/markdown_parser/html_block.test.ts b/client/markdown_parser/html_block.test.ts index b2d9441e..a2244215 100644 --- a/client/markdown_parser/html_block.test.ts +++ b/client/markdown_parser/html_block.test.ts @@ -37,9 +37,7 @@ test("simple table produces HTMLBlock with structured children", () => { const block = topNode(tree); expect(block.type).toBe("HTMLBlock"); - const childTypes = block.children! - .filter((c) => c.type) - .map((c) => c.type); + const childTypes = block.children!.filter((c) => c.type).map((c) => c.type); expect(childTypes).toEqual([ "HTMLOpenTag", "HTMLOpenTag", @@ -67,9 +65,7 @@ test("tag text is preserved in children", () => { // ── Attributes on tags ────────────────────────────────────────────── test("attributes are preserved in HTMLOpenTag text", () => { - const tree = parseHtml( - '42', - ); + const tree = parseHtml('42'); const block = topNode(tree); const openTag = block.children!.find((c) => c.type === "HTMLOpenTag")!; expect(nodeText(openTag)).toBe(''); @@ -167,7 +163,7 @@ test("table with data attributes and markdown in cells", () => { // Check data attributes are in tag text const openTags = block.children!.filter((c) => c.type === "HTMLOpenTag"); const tdWithAttr = openTags.find((t) => - nodeText(t).includes("data-table-cell-type") + nodeText(t).includes("data-table-cell-type"), ); expect(tdWithAttr).toBeDefined(); expect(nodeText(tdWithAttr!)).toContain('data-table-cell-type="string"'); @@ -256,7 +252,7 @@ test("table with empty cells", () => { }); test("data-table-empty attribute on table", () => { - const tree = parseHtml('
'); + const tree = parseHtml("
"); const block = topNode(tree); expect(block.type).toBe("HTMLBlock"); const openTag = block.children!.find((c) => c.type === "HTMLOpenTag")!; diff --git a/client/markdown_parser/html_block.ts b/client/markdown_parser/html_block.ts index 12e7b702..a4c223e6 100644 --- a/client/markdown_parser/html_block.ts +++ b/client/markdown_parser/html_block.ts @@ -64,10 +64,7 @@ function parseRawHtmlBlock( /** * Parse a structured HTML block (type 6/7) into tag + inline-content nodes. */ -function parseStructuredHtmlBlock( - cx: BlockContext, - line: Line, -): true { +function parseStructuredHtmlBlock(cx: BlockContext, line: Line): true { const startPos = cx.lineStart + line.pos; const lineText = line.text.slice(line.pos); diff --git a/client/markdown_parser/parser.test.ts b/client/markdown_parser/parser.test.ts index d26a2d53..3d8e4ba2 100644 --- a/client/markdown_parser/parser.test.ts +++ b/client/markdown_parser/parser.test.ts @@ -355,8 +355,9 @@ Bare $ alone is not an anchor. A $100 dollar bill (digit-leading is not an anchor). `, ); - const anchors = collectNodesOfType(tree, "NamedAnchor") - .map((n) => renderToText(n)); + const anchors = collectNodesOfType(tree, "NamedAnchor").map((n) => + renderToText(n), + ); expect(anchors).toEqual(["$toc1", "$tasks/7", "$work-1", "$sec1"]); }); diff --git a/client/markdown_parser/parser.ts b/client/markdown_parser/parser.ts index 1d12258c..62f1089d 100644 --- a/client/markdown_parser/parser.ts +++ b/client/markdown_parser/parser.ts @@ -13,7 +13,12 @@ import * as ct from "./customtags.ts"; import { TaskList } from "./extended_task.ts"; import { Table } from "./table_parser.ts"; import { FootnoteDefinition, FootnoteRef, InlineFootnote } from "./footnote.ts"; -import { anchorRegex, nakedUrlRegex, pWikiLinkRegex, tagRegex } from "./constants.ts"; +import { + anchorRegex, + nakedUrlRegex, + pWikiLinkRegex, + tagRegex, +} from "./constants.ts"; import { HTMLBlockParsing } from "./html_block.ts"; import { parse } from "./parse_tree.ts"; import type { ParseTree } from "@silverbulletmd/silverbullet/lib/tree"; diff --git a/client/markdown_renderer/html_render.test.ts b/client/markdown_renderer/html_render.test.ts index ed7ae173..f0d7c320 100644 --- a/client/markdown_renderer/html_render.test.ts +++ b/client/markdown_renderer/html_render.test.ts @@ -38,10 +38,7 @@ test("RawHtml with array body passes strings through unescaped", () => { expect( renderHtml({ name: RawHtml, - body: [ - "bold", - { name: "i", body: "normal" }, - ], + body: ["bold", { name: "i", body: "normal" }], }), ).toEqual("boldnormal"); }); diff --git a/client/markdown_renderer/html_render.ts b/client/markdown_renderer/html_render.ts index b831fd82..34261803 100644 --- a/client/markdown_renderer/html_render.ts +++ b/client/markdown_renderer/html_render.ts @@ -53,9 +53,9 @@ export function renderHtml(t: Tag | null): string { if (typeof t.body === "string") { return t.body; } - return t.body.map((c) => - typeof c === "string" ? c : renderHtml(c) - ).join(""); + return t.body + .map((c) => (typeof c === "string" ? c : renderHtml(c))) + .join(""); } return `<${t.name}${attrs}>${body}`; } diff --git a/client/markdown_renderer/inline.ts b/client/markdown_renderer/inline.ts index 10216791..0552f8ac 100644 --- a/client/markdown_renderer/inline.ts +++ b/client/markdown_renderer/inline.ts @@ -83,7 +83,10 @@ export async function expandMarkdown( } // Resolve local URLs (only for markdown links, wikilinks are absolute) - if (isLocalURL(transclusion.url) && transclusion.linktype !== "wikilink") { + if ( + isLocalURL(transclusion.url) && + transclusion.linktype !== "wikilink" + ) { transclusion.url = resolveMarkdownLink( pageName, decodeURI(transclusion.url), @@ -203,7 +206,11 @@ export async function expandMarkdown( console.error(`Error in ${spec.name} renderHtml:`, e); return { type: CustomSyntaxRenderedHtmlType, - children: [{ text: `Error in ${htmlEscape(spec.name)} renderHtml: ${htmlEscape(e.message)}` }], + children: [ + { + text: `Error in ${htmlEscape(spec.name)} renderHtml: ${htmlEscape(e.message)}`, + }, + ], }; } } diff --git a/client/markdown_renderer/markdown_render.test.ts b/client/markdown_renderer/markdown_render.test.ts index 0a9e15a3..dfe1f48b 100644 --- a/client/markdown_renderer/markdown_render.test.ts +++ b/client/markdown_renderer/markdown_render.test.ts @@ -126,9 +126,7 @@ And another test("Inline HTML tags render as proper elements", () => { const tree = parse(extendedMarkdownLanguage, "Hello"); const html = renderMarkdownToHtml(tree, { failOnUnknown: true }); - expect(html).toEqual( - 'Hello', - ); + expect(html).toEqual('Hello'); }); test("Inline HTML tags with attributes", () => { @@ -154,32 +152,19 @@ test("Inline HTML tags with markdown content", () => { }); test("Inline HTML mixed with text", () => { - const tree = parse( - extendedMarkdownLanguage, - "Before bold after", - ); + const tree = parse(extendedMarkdownLanguage, "Before bold after"); const html = renderMarkdownToHtml(tree, { failOnUnknown: true }); - expect(html).toEqual( - 'Before bold after', - ); + expect(html).toEqual('Before bold after'); }); test("Nested same-name HTML tags", () => { - const tree = parse( - extendedMarkdownLanguage, - "nested", - ); + const tree = parse(extendedMarkdownLanguage, "nested"); const html = renderMarkdownToHtml(tree, { failOnUnknown: true }); - expect(html).toEqual( - 'nested', - ); + expect(html).toEqual('nested'); }); test("Inline HTML with wiki link", () => { - const tree = parse( - extendedMarkdownLanguage, - "hello [[there]]", - ); + const tree = parse(extendedMarkdownLanguage, "hello [[there]]"); const html = renderMarkdownToHtml(tree, { failOnUnknown: true }); expect(html).toEqual( 'hello there', @@ -187,14 +172,9 @@ test("Inline HTML with wiki link", () => { }); test("Unmatched HTML tags render as literal text", () => { - const tree = parse( - extendedMarkdownLanguage, - "text unclosed", - ); + const tree = parse(extendedMarkdownLanguage, "text unclosed"); const html = renderMarkdownToHtml(tree, { failOnUnknown: true }); - expect(html).toEqual( - 'text <b>unclosed', - ); + expect(html).toEqual('text <b>unclosed'); }); test("CustomSyntaxRenderedHtml renders raw HTML", () => { @@ -216,9 +196,7 @@ test("CustomSyntaxRenderedHtml renders raw HTML", () => { ], }; const html = renderMarkdownToHtml(tree, { failOnUnknown: true }); - expect(html).toEqual( - 'Before rendered after', - ); + expect(html).toEqual('Before rendered after'); }); // Minimal stubs for expandMarkdown tests @@ -390,9 +368,7 @@ test("Self-closing
inside block HTML td renders as HTML", () => { "
Hello
there
", ); const html = renderMarkdownToHtml(tree, { failOnUnknown: true }); - expect(html).toBe( - "
Hello
there
", - ); + expect(html).toBe("
Hello
there
"); }); test("Wiki link inside block HTML td is rendered", () => { @@ -402,14 +378,11 @@ test("Wiki link inside block HTML td is rendered", () => { ); const html = renderMarkdownToHtml(tree, { failOnUnknown: true }); expect(html).toContain(' { - const tree = parse( - extendedMarkdownLanguage, - "


", - ); + const tree = parse(extendedMarkdownLanguage, "


"); const html = renderMarkdownToHtml(tree, { failOnUnknown: true }); expect(html).toBe("


"); }); @@ -446,9 +419,7 @@ test("Block HTML ul/li with data attributes", () => { '
  • hello
', ); const html = renderMarkdownToHtml(tree, { failOnUnknown: true }); - expect(html).toBe( - '
  • hello
', - ); + expect(html).toBe('
  • hello
'); }); test("Nested block HTML tables", () => { @@ -466,7 +437,7 @@ test("Nested block HTML tables", () => { test("Empty block HTML table", () => { const tree = parse( extendedMarkdownLanguage, - '
', + "
", ); const html = renderMarkdownToHtml(tree, { failOnUnknown: true }); expect(html).toContain("data-table-empty"); @@ -486,22 +457,19 @@ test("Whitespace between block siblings is dropped (no spurious
)", () => { const html = renderMarkdownToHtml(tree, { failOnUnknown: true }); expect(html).toBe( "

Heading

" + - '
  • item one
  • ' + - '
  • item two
' + - "

Next heading

", + '
  • item one
  • ' + + '
  • item two
' + + "

Next heading

", ); }); test("Whitespace between paragraphs is preserved as
", () => { - const tree = parse( - extendedMarkdownLanguage, - "Hello there\n\nThis is cool\n", - ); + const tree = parse(extendedMarkdownLanguage, "Hello there\n\nThis is cool\n"); const html = renderMarkdownToHtml(tree, { failOnUnknown: true }); expect(html).toBe( 'Hello there' + - "

" + - 'This is cool', + "

" + + 'This is cool', ); }); @@ -541,17 +509,14 @@ test("Whitespace between bullet list and paragraph is preserved", () => { const html = renderMarkdownToHtml(tree, { failOnUnknown: true }); expect(html).toBe( '
  • item one
  • ' + - '
  • item two
' + - "
" + - 'Other text', + '
  • item two
  • ' + + "
    " + + 'Other text', ); }); test("Multiple blank lines between blocks collapse to nothing", () => { - const tree = parse( - extendedMarkdownLanguage, - "# A\n\n\n\n# B\n", - ); + const tree = parse(extendedMarkdownLanguage, "# A\n\n\n\n# B\n"); const html = renderMarkdownToHtml(tree, { failOnUnknown: true }); expect(html).toBe("

    A

    B

    "); }); @@ -563,18 +528,18 @@ test("Heading then list then heading (transclusion shape)", () => { const tree = parse( extendedMarkdownLanguage, "# Lua Standard Library\n" + - "- one\n- two\n\n" + - "# Space Lua APIs\n" + - "- three\n- four\n", + "- one\n- two\n\n" + + "# Space Lua APIs\n" + + "- three\n- four\n", ); const html = renderMarkdownToHtml(tree, { failOnUnknown: true }); expect(html).toBe( "

    Lua Standard Library

    " + - '
    • one
    • ' + - '
    • two
    ' + - "

    Space Lua APIs

    " + - '
    • three
    • ' + - '
    • four
    ', + '
    • one
    • ' + + '
    • two
    ' + + "

    Space Lua APIs

    " + + '
    • three
    • ' + + '
    • four
    ', ); }); @@ -589,11 +554,11 @@ test("Paragraph between two blocks keeps its surrounding breaks where needed", ( const html = renderMarkdownToHtml(tree, { failOnUnknown: true }); expect(html).toBe( "

    H1

    " + - "
    " + - 'para1' + - "

    " + - 'para2' + - "
    " + - "

    H2

    ", + "
    " + + 'para1' + + "

    " + + 'para2' + + "
    " + + "

    H2

    ", ); }); diff --git a/client/markdown_renderer/markdown_render.ts b/client/markdown_renderer/markdown_render.ts index 55930a6e..9bd31738 100644 --- a/client/markdown_renderer/markdown_render.ts +++ b/client/markdown_renderer/markdown_render.ts @@ -407,8 +407,8 @@ function render(t: ParseTree, options: MarkdownRenderOptions = {}): Tag | null { case "Hashtag": { const tagText: string = t.children![0].text!; const tagName = extractHashtag(tagText); - const link = options.resolveTagHref?.(tagName) ?? - TagConstants.tagPrefix + tagName; + const link = + options.resolveTagHref?.(tagName) ?? TagConstants.tagPrefix + tagName; return { name: "a", attrs: { @@ -742,9 +742,10 @@ function renderHtmlBlock( if (parsed) { stack.push({ name: parsed.tagName, - attrs: Object.keys(parsed.parsedAttrs).length > 0 - ? parsed.parsedAttrs - : undefined, + attrs: + Object.keys(parsed.parsedAttrs).length > 0 + ? parsed.parsedAttrs + : undefined, body: [], }); } else { @@ -804,10 +805,7 @@ function renderHtmlBlock( }; } -type RenderFn = ( - t: ParseTree, - options: MarkdownRenderOptions, -) => Tag | null; +type RenderFn = (t: ParseTree, options: MarkdownRenderOptions) => Tag | null; function groupInlineHtml( children: ParseTree[], @@ -856,9 +854,10 @@ function groupInlineHtml( } if (j < children.length) { const innerChildren = children.slice(i + 1, j); - const attrs = Object.keys(parsed.parsedAttrs).length > 0 - ? parsed.parsedAttrs - : undefined; + const attrs = + Object.keys(parsed.parsedAttrs).length > 0 + ? parsed.parsedAttrs + : undefined; result.push({ name: parsed.tagName, attrs, diff --git a/client/navigator.ts b/client/navigator.ts index 8cd42bf4..222a3fb4 100644 --- a/client/navigator.ts +++ b/client/navigator.ts @@ -105,9 +105,10 @@ export class PathPageNavigator { } else { // This can e.g. happen on the first navigate. We obviously can't fall back to the same path, so fallback to the indexpage - const newRef: Ref = currentState.path === ref.path - ? this.indexRef - : { path: currentState.path }; + const newRef: Ref = + currentState.path === ref.path + ? this.indexRef + : { path: currentState.path }; globalThis.history.replaceState( newRef, @@ -150,7 +151,10 @@ export class PathPageNavigator { * Snapshot the current editor's scrollTop and selection. Only meaningful * when the editor is currently showing a markdown page. */ - private captureEditorPosition(): Pick { + private captureEditorPosition(): Pick< + LocationState, + "scrollTop" | "selection" + > { const editorView = this.client.editorView; const mainSelection = editorView.state.selection.main; return { diff --git a/client/plugos/syscalls/language.ts b/client/plugos/syscalls/language.ts index ac850c36..b762fbbe 100644 --- a/client/plugos/syscalls/language.ts +++ b/client/plugos/syscalls/language.ts @@ -14,7 +14,7 @@ export function languageSyscalls(): SysCallMapping { language: string, code: string, ): Promise => { - const lang = languageFor(language) ?? await loadLanguageFor(language); + const lang = languageFor(language) ?? (await loadLanguageFor(language)); if (!lang) { throw new Error(`Unknown language ${language}`); } diff --git a/client/plugos/syscalls/markdown.ts b/client/plugos/syscalls/markdown.ts index 7e64d5c9..dac565e1 100644 --- a/client/plugos/syscalls/markdown.ts +++ b/client/plugos/syscalls/markdown.ts @@ -63,10 +63,12 @@ export function markdownSyscalls(client: Client): SysCallMapping { } if (!options.resolveTagHref) { options.resolveTagHref = (tagName: string) => { - return client.config.get( - ["tags", tagName, "tagPage"], - null, - ) ?? TagConstants.tagPrefix + tagName; + return ( + client.config.get( + ["tags", tagName, "tagPage"], + null, + ) ?? TagConstants.tagPrefix + tagName + ); }; } return renderMarkdownToHtml(mdTree, options); diff --git a/client/reducer.ts b/client/reducer.ts index 7df1b678..d782fd25 100644 --- a/client/reducer.ts +++ b/client/reducer.ts @@ -21,8 +21,9 @@ export default function reducer( }; case "page-loaded": { const mouseDetected = globalThis.matchMedia("(pointer:fine)").matches; - const isBrowser = globalThis.matchMedia("(display-mode: browser)") - .matches; + const isBrowser = globalThis.matchMedia( + "(display-mode: browser)", + ).matches; return { ...state, isLoading: false, diff --git a/client/space.ts b/client/space.ts index e34589fd..0618036a 100644 --- a/client/space.ts +++ b/client/space.ts @@ -92,7 +92,9 @@ export class Space { } const anchorName = ref.details.name; const filterPage = ref.path - ? ref.path.endsWith(".md") ? ref.path.slice(0, -3) : ref.path + ? ref.path.endsWith(".md") + ? ref.path.slice(0, -3) + : ref.path : undefined; const result = await this.anchorResolver(anchorName, filterPage); if (!result.ok) { diff --git a/client/space_lua/lua.test.ts b/client/space_lua/lua.test.ts index 2c3a4b46..ef10ad51 100644 --- a/client/space_lua/lua.test.ts +++ b/client/space_lua/lua.test.ts @@ -24,10 +24,7 @@ for (const file of testFiles) { } // Library API Lua tests (stdlib + jsonschema + library code pre-loaded) -const libraryApisDir = resolve( - __dirname, - "../../libraries/Library/Std/APIs", -); +const libraryApisDir = resolve(__dirname, "../../libraries/Library/Std/APIs"); const libraryTestFiles = readdirSync(libraryApisDir, { recursive: true }) .filter((f): f is string => typeof f === "string" && f.endsWith("_test.lua")) .sort(); @@ -38,9 +35,7 @@ for (const file of libraryTestFiles) { // This is required for multi-word library pages that the derivation below // cannot reach. const testSource = readFileSync(resolve(libraryApisDir, file), "utf-8"); - const companionMatch = testSource.match( - /^--\s*companion:\s*(.+?)\s*$/m, - ); + const companionMatch = testSource.match(/^--\s*companion:\s*(.+?)\s*$/m); let libraryMdPath: string; if (companionMatch) { libraryMdPath = resolve(libraryApisDir, companionMatch[1]); @@ -60,10 +55,10 @@ for (const file of libraryTestFiles) { libraryMdPath = resolve(libraryApisDir, `${libraryName}.md`); } test(`[Lua] ${file}`, async () => { - await runLuaTest( - resolve(libraryApisDir, file), - { isAbsolutePath: true, preloadMdPath: libraryMdPath }, - ); + await runLuaTest(resolve(libraryApisDir, file), { + isAbsolutePath: true, + preloadMdPath: libraryMdPath, + }); }); } @@ -116,9 +111,8 @@ async function runLuaTest( if (opts.preloadMdPath) { // Provide a minimal jsonschema table so widget.new validation works const jsonschemaTbl = new LuaTable(); - await jsonschemaTbl.set( - "validateObject", - (schema: any, obj: any) => validateObject(schema, obj), + await jsonschemaTbl.set("validateObject", (schema: any, obj: any) => + validateObject(schema, obj), ); env.set("jsonschema", jsonschemaTbl); @@ -140,30 +134,22 @@ async function runLuaTest( return String(path).split(".").join("\0"); }; const configTbl = new LuaTable(); - await configTbl.set( - "set", - (path: unknown, value: unknown) => { - configStore.set(pathToKey(path), value); - }, - ); - await configTbl.set( - "get", - (path: unknown, defaultValue: unknown) => { - const key = pathToKey(path); - return configStore.has(key) - ? configStore.get(key) - : (defaultValue ?? null); - }, - ); + await configTbl.set("set", (path: unknown, value: unknown) => { + configStore.set(pathToKey(path), value); + }); + await configTbl.set("get", (path: unknown, defaultValue: unknown) => { + const key = pathToKey(path); + return configStore.has(key) + ? configStore.get(key) + : (defaultValue ?? null); + }); env.set("config", configTbl); let mdText: string; try { mdText = await readFile(opts.preloadMdPath, "utf-8"); } catch { - throw new Error( - `Library preload file not found: ${opts.preloadMdPath}`, - ); + throw new Error(`Library preload file not found: ${opts.preloadMdPath}`); } const libLua = extractSpaceLuaFromPageText(mdText); if (libLua) { diff --git a/client/space_lua/parse-lua.js b/client/space_lua/parse-lua.js index 4d33576e..71e506a6 100644 --- a/client/space_lua/parse-lua.js +++ b/client/space_lua/parse-lua.js @@ -1,22 +1,90 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. -import {LRParser} from "@lezer/lr" -const spec_identifier = {__proto__:null,break:16, goto:20, do:24, end:26, while:30, nil:32, true:34, false:36, order:81, by:83, desc:87, asc:89, using:90, function:92, nulls:99, first:101, last:103, or:106, and:108, not:130, query:142, from:149, where:155, select:161, limit:165, offset:169, group:173, having:177, filter:181, repeat:184, until:186, if:190, then:192, elseif:194, else:196, for:200, in:208, local:218, return:234} +import { LRParser } from "@lezer/lr"; +const spec_identifier = { + __proto__: null, + break: 16, + goto: 20, + do: 24, + end: 26, + while: 30, + nil: 32, + true: 34, + false: 36, + order: 81, + by: 83, + desc: 87, + asc: 89, + using: 90, + function: 92, + nulls: 99, + first: 101, + last: 103, + or: 106, + and: 108, + not: 130, + query: 142, + from: 149, + where: 155, + select: 161, + limit: 165, + offset: 169, + group: 173, + having: 177, + filter: 181, + repeat: 184, + until: 186, + if: 190, + then: 192, + elseif: 194, + else: 196, + for: 200, + in: 208, + local: 218, + return: 234, +}; export const parser = LRParser.deserialize({ version: 14, - states: "LQO!ZQPOOOOQO'#Cc'#CcO!UQPO'#CaO!bQPOOOOQO'#F]'#F]O!vQQO'#CwO$]QPO'#F[OOQO'#F['#F[O$gQPO'#F[OOQO'#Eo'#EoO%vQPO'#EnOOQO'#FW'#FWOOQO'#Ev'#EvO%{QPO'#C_OOQO'#C_'#C_QOQPOOO!UQPO'#CeO&`QPO'#CgO!vQQO'#CjO&gQPO'#EZO!vQQO'#E^O!UQPO'#EcO!UQPO'#EjO&nQPO'#EpO&vQQO'#EtO'^QPO,58{OOQO'#Cq'#CqO!UQPO,59^O!vQQO,59`O(gQQO'#C|O(nQQO'#FbOOQO'#F^'#F^OOQO,59f,59fO!UQPO,59fO(xQPO'#FYO,wQPO,59cO-OQPO'#F[OOQO'#Dp'#DpOOQO'#Dq'#DqOOQO'#Dr'#DrO!vQQO'#DnOOQO'#FY'#FYO/|QPO'#DsO0RQSO'#DtO0WQPO'#FQO0`QPO,5RQQO7+$nOOQO-E8u-E8uOOQO7+'S7+'SO!>YQPO'#DWO!>pQPO'#FdOOQO1G/Z1G/ZO!vQQO7+%yO!>xQQO'#EyO!?TQPO,5OAN>OOGnQQO'#DnO!.wQQO'#DnOGnQQO,59|O!.wQQO,59|OGnQQO,59|O!.wQQO,59|OGnQQO,59|O!.wQQO,59|OGnQQO,59|O!.wQQO,59|OGnQQO,59|O!.wQQO,59|OGnQQO,59|O!.wQQO,59|OGnQQO,59|O!.wQQO,59|OGnQQO,59|O!.wQQO,59|OGnQQO,59|O!.wQQO,59|O!.wQQO,59kO!.wQQO7+$pO#0bQPO,59rO!.wQQO'#EzO/|QPO1G/^O#0jQPO1G/UO#0oQPO,59jO!vQQO'#DO", - stateData: "#0y~O#xOS#yOSPOS~OSZOUQOWZOY`O[aO_bOlTO!OfO#OcO#RdO#WeO#agO#ihO#{PO~O#vRP~P]OgkOilOlnOoqOqmO#}jO~O`yOayObyOcyOdyOlTOqmO!OzO!cxO!i{O#{PO#}jO$[vO$`uO$fwO~Og$OXi$OXl$OXo$OXq$OX#}$OX~Ov|O$S$jX~P#wOS#zXU#zXW#zXY#zX[#zX_#zXl#zX!O#zX#O#zX#R#zX#W#zX#a#zX#i#zX#v#zX#{#zX]#zX#P#zX#T#zX#U#zX~P#wO$S!OO~O#vRX]RX#PRX#TRX#URX~P]O]RP~P]O#PRP~P]O!O!aO#{PO~OS!eO#v#hX]#hX#P#hX#T#hX#U#hX~P!vOU!gO~O`yOayObyOcyOdyOi!lOlTOqmO!OzO!c'jO!i{O#{PO#}jO$[vO$`uO$fwO~Ou!oO~P'cOm!rOx!uO~P!vOm#|X!V#|X!W#|X!X#|X!^#|X$Y#|X$Z#|X$[#|X$]#|X$^#|X$_#|X$`#|X$a#|X$b#|X$c#|X$d#|X$e#|X[#|X#S#|XS#|Xv#|X#v#|Xj#|Xx#|XU#|XW#|XY#|X_#|X!O#|X#O#|X#R#|X#W#|X#a#|X#i#|X#{#|X]#|X#P#|X{#|X|#|X}#|X!R#|X#T#|X#U#|X~P!bO!V#WO!W#XO!X#PO!^#TO$Y!xO$Z!yO$[!zO$]!{O$^!{O$_!|O$`!|O$a!}O$b!}O$c!}O$d!}O$e#OO~Om!wO~P+sO!|#YOm$OX!V$OX!W$OX!X$OX!^$OX$Y$OX$Z$OX$[$OX$]$OX$^$OX$_$OX$`$OX$a$OX$b$OX$c$OX$d$OX$e$OX[$OX#S$OXS$OXv$OX#v$OXj$OXx$OXU$OXW$OXY$OX_$OX!O$OX#O$OX#R$OX#W$OX#a$OX#i$OX#{$OX]$OX#P$OX{$OX|$OX}$OX!R$OX#T$OX#U$OX~P#wOl#[O~O$g#^O~OlTO#{PO~Ov|O$S$ja~O]#bO~O[#cO~P+sO#P#dO~O#S#eO~P+sOv#gO$S#fO#[$iX~O#[#iO~O[#jO~Og#kOo#mOl#_X~O$l#oOS#gPU#gPW#gPY#gP[#gP_#gPl#gPv#gP!O#gP#O#gP#R#gP#W#gP#a#gP#i#gP#v#gP#{#gP$S#gP]#gP#P#gP#T#gP#U#gP~Ov#qOS$kXU$kXW$kXY$kX[$kX_$kXl$kX!O$kX#O$kX#R$kX#W$kX#a$kX#i$kX#v$kX#{$kX$S$kX]$kX#P$kX#T$kX#U$kX~O$S#tOS#daU#daW#daY#da[#da_#dal#da!O#da#O#da#R#da#W#da#a#da#i#da#v#da#{#da]#da#P#da#T#da#U#da~Ov#uOS$VX#v$VXm$VXx$VXU$VXW$VXY$VX[$VX_$VXl$VX!O$VX#O$VX#R$VX#W$VX#a$VX#i$VX#{$VX]$VX#P$VX#T$VX#U$VX~P+sOS#wO#v#ha]#ha#P#ha#T#ha#U#ha~Oj#xO~P+sOu#|X!V#|X!W#|X!X#|X!^#|X$T#|X$Y#|X$Z#|X$[#|X$]#|X$^#|X$_#|X$`#|X$a#|X$b#|X$c#|X$d#|X$e#|Xx#|X!l#|X!o#|X!r#|X!t#|X!v#|X!x#|X!z#|X$h#|X~P!bOg$PXi$PXl$PXo$PXq$PX!V$PX!W$PX!X$PX!^$PX#}$PX$Y$PX$Z$PX$[$PX$]$PX$^$PX$_$PX$`$PX$a$PX$b$PX$c$PX$d$PX$e$PX~O$S#yOu$PX$T$PX~P9{O!V'zO!W'|O!X'lO!^'tO$Y!xO$Z!yO$[!zO$]!{O$^!{O$_!|O$`!|O$a!}O$b!}O$c!}O$d!}O$e#OO~OutX$TtX~P;pOu#}O$T#{O~O!|#YOu$OX!V$OX!W$OX!X$OX!^$OX$T$OX$Y$OX$Z$OX$[$OX$]$OX$^$OX$_$OX$`$OX$a$OX$b$OX$c$OX$d$OX$e$OXx$OX!l$OX!o$OX!r$OX!t$OX!v$OX!x$OX!z$OX$h$OX~P#wOm$PO~Om$POx!uO~Oy$RO~OlnOqmO#}jO~Ol$^O~O$e#OO!V!ba!W!ba!X!ba!^!ba$Y!ba$Z!ba$[!ba$]!ba$^!ba$_!ba$`!ba$a!ba$b!ba$c!ba$d!bav!bax!ba{!ba|!ba}!ba!R!ba~Om!ba[!ba#S!baS!ba#v!baj!baU!baW!baY!ba_!bal!ba!O!ba#O!ba#R!ba#W!ba#a!ba#i!ba#{!ba]!ba#P!ba#T!ba#U!ba~P?vOc$`OlTO#{POm!QP~Ox$hO!l$fO!o$gO!r$iO!t$jO!v$kO!x$lO!z$mO~O$h$nO~PCUOv#ta$S#ta~P#wO]RP#TRP#URP~P]Ov#gO#[$ia~Og#kOo$yOl#_a~Ov#qOS$kaU$kaW$kaY$ka[$ka_$kal$ka!O$ka#O$ka#R$ka#W$ka#a$ka#i$ka#v$ka#{$ka$S$ka]$ka#P$ka#T$ka#U$ka~Ov#uOS$Va#v$Vam$Vax$VaU$VaW$VaY$Va[$Va_$Val$Va!O$Va#O$Va#R$Va#W$Va#a$Va#i$Va#{$Va]$Va#P$Va#T$Va#U$Va~O`yOayObyOcyOdyOlTOqmO!OzO!c'jO!i{O#{PO#}jO$[vO$`uO$fwO~Oj%TO~P+sOu%`O~P'cOu%`O$T%aO~O$e#OO!V!ba!W!ba!X!ba!^!ba$Y!ba$Z!ba$[!ba$]!ba$^!ba$_!ba$`!ba$a!ba$b!ba$c!ba$d!bax!ba!l!ba!o!ba!r!ba!t!ba!v!ba!x!ba!z!ba$h!ba~Ou!ba$T!ba~PIVOm%cO~O!^#TO$Z!yO$[!zO$]!{O$^!{O$_!|O$`!|O$a!}O$b!}O$c!}O$d!}O$e#OOm!Ui!V!Ui!W!Ui!X!Ui[!Ui#S!UiS!Uiv!Ui#v!Uij!Uix!UiU!UiW!UiY!Ui_!Uil!Ui!O!Ui#O!Ui#R!Ui#W!Ui#a!Ui#i!Ui#{!Ui]!Ui#P!Ui{!Ui|!Ui}!Ui!R!Ui#T!Ui#U!Ui~O$Y!xO~PKVO$Y!Ui~PKVO!^#TO$]!{O$^!{O$_!|O$`!|O$a!}O$b!}O$c!}O$d!}O$e#OOm!Ui!V!Ui!W!Ui!X!Ui$Y!Ui$[!Ui[!Ui#S!UiS!Uiv!Ui#v!Uij!Uix!UiU!UiW!UiY!Ui_!Uil!Ui!O!Ui#O!Ui#R!Ui#W!Ui#a!Ui#i!Ui#{!Ui]!Ui#P!Ui{!Ui|!Ui}!Ui!R!Ui#T!Ui#U!Ui~O$Z!Ui~PN^O$Z!yO~PN^O$a!}O$b!}O$c!}O$d!}O$e#OOm!Ui!V!Ui!W!Ui!X!Ui$Y!Ui$Z!Ui$[!Ui$]!Ui$^!Ui[!Ui#S!UiS!Uiv!Ui#v!Uij!Uix!UiU!UiW!UiY!Ui_!Uil!Ui!O!Ui#O!Ui#R!Ui#W!Ui#a!Ui#i!Ui#{!Ui]!Ui#P!Ui{!Ui|!Ui}!Ui!R!Ui#T!Ui#U!Ui~O!^#TO$_!|O$`!|O~P!#eO!^!Ui$_!Ui$`!Ui~P!#eO$e#OOm!Ui!V!Ui!W!Ui[!Ui#S!UiS!Uiv!Ui#v!Uij!Uix!UiU!UiW!UiY!Ui_!Uil!Ui!O!Ui#O!Ui#R!Ui#W!Ui#a!Ui#i!Ui#{!Ui]!Ui#P!Ui{!Ui|!Ui}!Ui!R!Ui#T!Ui#U!Ui~O!X!Ui!^!Ui$Y!Ui$Z!Ui$[!Ui$]!Ui$^!Ui$_!Ui$`!Ui$a!Ui$b!Ui$c!Ui$d!Ui~P!&rO!X#PO!^#TO$Y!xO$Z!yO$[!zO$]!{O$^!{O$_!|O$`!|O$a!}O$b!}O$c!}O$d!}O$e#OOm!Ui!V!Ui[!Ui#S!UiS!Uiv!Ui#v!Uij!Uix!UiU!UiW!UiY!Ui_!Uil!Ui!O!Ui#O!Ui#R!Ui#W!Ui#a!Ui#i!Ui#{!Ui]!Ui#P!Ui{!Ui|!Ui}!Ui!R!Ui#T!Ui#U!Ui~O!W#XO~P!)rO!W!Ui~P!)rO!o%gO~Ov%hOm$XX~P#wOv%hOm$XX~Om%jO~O$h%lO~PCUO`yOayObyOcyOdyOi(VOlTOqmO!OzO!c'kO!i{O#{PO#}jO$[vO$`uO$fwO~Oy%tO~O`yOayObyOcyOdyOlTOqmO!OzO!c'kO!i{O#{PO#}jO$[vO$`uO$fwO~Oy%xO~O]%zO~OS!}qU!}qW!}qY!}q[!}q_!}ql!}q!O!}q#O!}q#R!}q#W!}q#a!}q#i!}q#v!}q#{!}q]!}q#P!}q#T!}q#U!}q~P+sO]%}O#T%|O#U&OO~Ov&PO~P+sO]&QO~O$m&SO~OS#lav#la#v#lam#lax#laU#laW#laY#la[#la_#lal#la!O#la#O#la#R#la#W#la#a#la#i#la#{#la]#la#P#la#T#la#U#la~P+sOusi$Tsi~P;pO$S&TO~O!^'tO$Z!yO$[!zO$]!{O$^!{O$_!|O$`!|O$a!}O$b!}O$c!}O$d!}O$e#OOu!Ui!V!Ui!W!Ui!X!Ui$T!Uix!Ui!l!Ui!o!Ui!r!Ui!t!Ui!v!Ui!x!Ui!z!Ui$h!Ui~O$Y!xO~P!3xO$Y!Ui~P!3xO!^'tO$]!{O$^!{O$_!|O$`!|O$a!}O$b!}O$c!}O$d!}O$e#OOu!Ui!V!Ui!W!Ui!X!Ui$T!Ui$Y!Ui$[!Uix!Ui!l!Ui!o!Ui!r!Ui!t!Ui!v!Ui!x!Ui!z!Ui$h!Ui~O$Z!Ui~P!5zO$Z!yO~P!5zO$a!}O$b!}O$c!}O$d!}O$e#OOu!Ui!V!Ui!W!Ui!X!Ui$T!Ui$Y!Ui$Z!Ui$[!Ui$]!Ui$^!Uix!Ui!l!Ui!o!Ui!r!Ui!t!Ui!v!Ui!x!Ui!z!Ui$h!Ui~O!^'tO$_!|O$`!|O~P!7|O!^!Ui$_!Ui$`!Ui~P!7|O$e#OO!V!Ui!W!Ui!X!Ui!^!Ui$Y!Ui$Z!Ui$[!Ui$]!Ui$^!Ui$_!Ui$`!Ui$a!Ui$b!Ui$c!Ui$d!Uix!Ui!l!Ui!o!Ui!r!Ui!t!Ui!v!Ui!x!Ui!z!Ui$h!Ui~Ou!Ui$T!Ui~P!:UO!X'lO!^'tO$Y!xO$Z!yO$[!zO$]!{O$^!{O$_!|O$`!|O$a!}O$b!}O$c!}O$d!}O$e#OOu!Ui!V!Ui$T!Uix!Ui!l!Ui!o!Ui!r!Ui!t!Ui!v!Ui!x!Ui!z!Ui$h!Ui~O!W'|O~P!RQQO7+$nOOQO-E8u-E8uOOQO7+'S7+'SO!>YQPO'#DWO!>pQPO'#FdOOQO1G/Z1G/ZO!vQQO7+%yO!>xQQO'#EyO!?TQPO,5OAN>OOGnQQO'#DnO!.wQQO'#DnOGnQQO,59|O!.wQQO,59|OGnQQO,59|O!.wQQO,59|OGnQQO,59|O!.wQQO,59|OGnQQO,59|O!.wQQO,59|OGnQQO,59|O!.wQQO,59|OGnQQO,59|O!.wQQO,59|OGnQQO,59|O!.wQQO,59|OGnQQO,59|O!.wQQO,59|OGnQQO,59|O!.wQQO,59|O!.wQQO,59kO!.wQQO7+$pO#0bQPO,59rO!.wQQO'#EzO/|QPO1G/^O#0jQPO1G/UO#0oQPO,59jO!vQQO'#DO", + stateData: + "#0y~O#xOS#yOSPOS~OSZOUQOWZOY`O[aO_bOlTO!OfO#OcO#RdO#WeO#agO#ihO#{PO~O#vRP~P]OgkOilOlnOoqOqmO#}jO~O`yOayObyOcyOdyOlTOqmO!OzO!cxO!i{O#{PO#}jO$[vO$`uO$fwO~Og$OXi$OXl$OXo$OXq$OX#}$OX~Ov|O$S$jX~P#wOS#zXU#zXW#zXY#zX[#zX_#zXl#zX!O#zX#O#zX#R#zX#W#zX#a#zX#i#zX#v#zX#{#zX]#zX#P#zX#T#zX#U#zX~P#wO$S!OO~O#vRX]RX#PRX#TRX#URX~P]O]RP~P]O#PRP~P]O!O!aO#{PO~OS!eO#v#hX]#hX#P#hX#T#hX#U#hX~P!vOU!gO~O`yOayObyOcyOdyOi!lOlTOqmO!OzO!c'jO!i{O#{PO#}jO$[vO$`uO$fwO~Ou!oO~P'cOm!rOx!uO~P!vOm#|X!V#|X!W#|X!X#|X!^#|X$Y#|X$Z#|X$[#|X$]#|X$^#|X$_#|X$`#|X$a#|X$b#|X$c#|X$d#|X$e#|X[#|X#S#|XS#|Xv#|X#v#|Xj#|Xx#|XU#|XW#|XY#|X_#|X!O#|X#O#|X#R#|X#W#|X#a#|X#i#|X#{#|X]#|X#P#|X{#|X|#|X}#|X!R#|X#T#|X#U#|X~P!bO!V#WO!W#XO!X#PO!^#TO$Y!xO$Z!yO$[!zO$]!{O$^!{O$_!|O$`!|O$a!}O$b!}O$c!}O$d!}O$e#OO~Om!wO~P+sO!|#YOm$OX!V$OX!W$OX!X$OX!^$OX$Y$OX$Z$OX$[$OX$]$OX$^$OX$_$OX$`$OX$a$OX$b$OX$c$OX$d$OX$e$OX[$OX#S$OXS$OXv$OX#v$OXj$OXx$OXU$OXW$OXY$OX_$OX!O$OX#O$OX#R$OX#W$OX#a$OX#i$OX#{$OX]$OX#P$OX{$OX|$OX}$OX!R$OX#T$OX#U$OX~P#wOl#[O~O$g#^O~OlTO#{PO~Ov|O$S$ja~O]#bO~O[#cO~P+sO#P#dO~O#S#eO~P+sOv#gO$S#fO#[$iX~O#[#iO~O[#jO~Og#kOo#mOl#_X~O$l#oOS#gPU#gPW#gPY#gP[#gP_#gPl#gPv#gP!O#gP#O#gP#R#gP#W#gP#a#gP#i#gP#v#gP#{#gP$S#gP]#gP#P#gP#T#gP#U#gP~Ov#qOS$kXU$kXW$kXY$kX[$kX_$kXl$kX!O$kX#O$kX#R$kX#W$kX#a$kX#i$kX#v$kX#{$kX$S$kX]$kX#P$kX#T$kX#U$kX~O$S#tOS#daU#daW#daY#da[#da_#dal#da!O#da#O#da#R#da#W#da#a#da#i#da#v#da#{#da]#da#P#da#T#da#U#da~Ov#uOS$VX#v$VXm$VXx$VXU$VXW$VXY$VX[$VX_$VXl$VX!O$VX#O$VX#R$VX#W$VX#a$VX#i$VX#{$VX]$VX#P$VX#T$VX#U$VX~P+sOS#wO#v#ha]#ha#P#ha#T#ha#U#ha~Oj#xO~P+sOu#|X!V#|X!W#|X!X#|X!^#|X$T#|X$Y#|X$Z#|X$[#|X$]#|X$^#|X$_#|X$`#|X$a#|X$b#|X$c#|X$d#|X$e#|Xx#|X!l#|X!o#|X!r#|X!t#|X!v#|X!x#|X!z#|X$h#|X~P!bOg$PXi$PXl$PXo$PXq$PX!V$PX!W$PX!X$PX!^$PX#}$PX$Y$PX$Z$PX$[$PX$]$PX$^$PX$_$PX$`$PX$a$PX$b$PX$c$PX$d$PX$e$PX~O$S#yOu$PX$T$PX~P9{O!V'zO!W'|O!X'lO!^'tO$Y!xO$Z!yO$[!zO$]!{O$^!{O$_!|O$`!|O$a!}O$b!}O$c!}O$d!}O$e#OO~OutX$TtX~P;pOu#}O$T#{O~O!|#YOu$OX!V$OX!W$OX!X$OX!^$OX$T$OX$Y$OX$Z$OX$[$OX$]$OX$^$OX$_$OX$`$OX$a$OX$b$OX$c$OX$d$OX$e$OXx$OX!l$OX!o$OX!r$OX!t$OX!v$OX!x$OX!z$OX$h$OX~P#wOm$PO~Om$POx!uO~Oy$RO~OlnOqmO#}jO~Ol$^O~O$e#OO!V!ba!W!ba!X!ba!^!ba$Y!ba$Z!ba$[!ba$]!ba$^!ba$_!ba$`!ba$a!ba$b!ba$c!ba$d!bav!bax!ba{!ba|!ba}!ba!R!ba~Om!ba[!ba#S!baS!ba#v!baj!baU!baW!baY!ba_!bal!ba!O!ba#O!ba#R!ba#W!ba#a!ba#i!ba#{!ba]!ba#P!ba#T!ba#U!ba~P?vOc$`OlTO#{POm!QP~Ox$hO!l$fO!o$gO!r$iO!t$jO!v$kO!x$lO!z$mO~O$h$nO~PCUOv#ta$S#ta~P#wO]RP#TRP#URP~P]Ov#gO#[$ia~Og#kOo$yOl#_a~Ov#qOS$kaU$kaW$kaY$ka[$ka_$kal$ka!O$ka#O$ka#R$ka#W$ka#a$ka#i$ka#v$ka#{$ka$S$ka]$ka#P$ka#T$ka#U$ka~Ov#uOS$Va#v$Vam$Vax$VaU$VaW$VaY$Va[$Va_$Val$Va!O$Va#O$Va#R$Va#W$Va#a$Va#i$Va#{$Va]$Va#P$Va#T$Va#U$Va~O`yOayObyOcyOdyOlTOqmO!OzO!c'jO!i{O#{PO#}jO$[vO$`uO$fwO~Oj%TO~P+sOu%`O~P'cOu%`O$T%aO~O$e#OO!V!ba!W!ba!X!ba!^!ba$Y!ba$Z!ba$[!ba$]!ba$^!ba$_!ba$`!ba$a!ba$b!ba$c!ba$d!bax!ba!l!ba!o!ba!r!ba!t!ba!v!ba!x!ba!z!ba$h!ba~Ou!ba$T!ba~PIVOm%cO~O!^#TO$Z!yO$[!zO$]!{O$^!{O$_!|O$`!|O$a!}O$b!}O$c!}O$d!}O$e#OOm!Ui!V!Ui!W!Ui!X!Ui[!Ui#S!UiS!Uiv!Ui#v!Uij!Uix!UiU!UiW!UiY!Ui_!Uil!Ui!O!Ui#O!Ui#R!Ui#W!Ui#a!Ui#i!Ui#{!Ui]!Ui#P!Ui{!Ui|!Ui}!Ui!R!Ui#T!Ui#U!Ui~O$Y!xO~PKVO$Y!Ui~PKVO!^#TO$]!{O$^!{O$_!|O$`!|O$a!}O$b!}O$c!}O$d!}O$e#OOm!Ui!V!Ui!W!Ui!X!Ui$Y!Ui$[!Ui[!Ui#S!UiS!Uiv!Ui#v!Uij!Uix!UiU!UiW!UiY!Ui_!Uil!Ui!O!Ui#O!Ui#R!Ui#W!Ui#a!Ui#i!Ui#{!Ui]!Ui#P!Ui{!Ui|!Ui}!Ui!R!Ui#T!Ui#U!Ui~O$Z!Ui~PN^O$Z!yO~PN^O$a!}O$b!}O$c!}O$d!}O$e#OOm!Ui!V!Ui!W!Ui!X!Ui$Y!Ui$Z!Ui$[!Ui$]!Ui$^!Ui[!Ui#S!UiS!Uiv!Ui#v!Uij!Uix!UiU!UiW!UiY!Ui_!Uil!Ui!O!Ui#O!Ui#R!Ui#W!Ui#a!Ui#i!Ui#{!Ui]!Ui#P!Ui{!Ui|!Ui}!Ui!R!Ui#T!Ui#U!Ui~O!^#TO$_!|O$`!|O~P!#eO!^!Ui$_!Ui$`!Ui~P!#eO$e#OOm!Ui!V!Ui!W!Ui[!Ui#S!UiS!Uiv!Ui#v!Uij!Uix!UiU!UiW!UiY!Ui_!Uil!Ui!O!Ui#O!Ui#R!Ui#W!Ui#a!Ui#i!Ui#{!Ui]!Ui#P!Ui{!Ui|!Ui}!Ui!R!Ui#T!Ui#U!Ui~O!X!Ui!^!Ui$Y!Ui$Z!Ui$[!Ui$]!Ui$^!Ui$_!Ui$`!Ui$a!Ui$b!Ui$c!Ui$d!Ui~P!&rO!X#PO!^#TO$Y!xO$Z!yO$[!zO$]!{O$^!{O$_!|O$`!|O$a!}O$b!}O$c!}O$d!}O$e#OOm!Ui!V!Ui[!Ui#S!UiS!Uiv!Ui#v!Uij!Uix!UiU!UiW!UiY!Ui_!Uil!Ui!O!Ui#O!Ui#R!Ui#W!Ui#a!Ui#i!Ui#{!Ui]!Ui#P!Ui{!Ui|!Ui}!Ui!R!Ui#T!Ui#U!Ui~O!W#XO~P!)rO!W!Ui~P!)rO!o%gO~Ov%hOm$XX~P#wOv%hOm$XX~Om%jO~O$h%lO~PCUO`yOayObyOcyOdyOi(VOlTOqmO!OzO!c'kO!i{O#{PO#}jO$[vO$`uO$fwO~Oy%tO~O`yOayObyOcyOdyOlTOqmO!OzO!c'kO!i{O#{PO#}jO$[vO$`uO$fwO~Oy%xO~O]%zO~OS!}qU!}qW!}qY!}q[!}q_!}ql!}q!O!}q#O!}q#R!}q#W!}q#a!}q#i!}q#v!}q#{!}q]!}q#P!}q#T!}q#U!}q~P+sO]%}O#T%|O#U&OO~Ov&PO~P+sO]&QO~O$m&SO~OS#lav#la#v#lam#lax#laU#laW#laY#la[#la_#lal#la!O#la#O#la#R#la#W#la#a#la#i#la#{#la]#la#P#la#T#la#U#la~P+sOusi$Tsi~P;pO$S&TO~O!^'tO$Z!yO$[!zO$]!{O$^!{O$_!|O$`!|O$a!}O$b!}O$c!}O$d!}O$e#OOu!Ui!V!Ui!W!Ui!X!Ui$T!Uix!Ui!l!Ui!o!Ui!r!Ui!t!Ui!v!Ui!x!Ui!z!Ui$h!Ui~O$Y!xO~P!3xO$Y!Ui~P!3xO!^'tO$]!{O$^!{O$_!|O$`!|O$a!}O$b!}O$c!}O$d!}O$e#OOu!Ui!V!Ui!W!Ui!X!Ui$T!Ui$Y!Ui$[!Uix!Ui!l!Ui!o!Ui!r!Ui!t!Ui!v!Ui!x!Ui!z!Ui$h!Ui~O$Z!Ui~P!5zO$Z!yO~P!5zO$a!}O$b!}O$c!}O$d!}O$e#OOu!Ui!V!Ui!W!Ui!X!Ui$T!Ui$Y!Ui$Z!Ui$[!Ui$]!Ui$^!Uix!Ui!l!Ui!o!Ui!r!Ui!t!Ui!v!Ui!x!Ui!z!Ui$h!Ui~O!^'tO$_!|O$`!|O~P!7|O!^!Ui$_!Ui$`!Ui~P!7|O$e#OO!V!Ui!W!Ui!X!Ui!^!Ui$Y!Ui$Z!Ui$[!Ui$]!Ui$^!Ui$_!Ui$`!Ui$a!Ui$b!Ui$c!Ui$d!Uix!Ui!l!Ui!o!Ui!r!Ui!t!Ui!v!Ui!x!Ui!z!Ui$h!Ui~Ou!Ui$T!Ui~P!:UO!X'lO!^'tO$Y!xO$Z!yO$[!zO$]!{O$^!{O$_!|O$`!|O$a!}O$b!}O$c!}O$d!}O$e#OOu!Ui!V!Ui$T!Uix!Ui!l!Ui!o!Ui!r!Ui!t!Ui!v!Ui!x!Ui!z!Ui$h!Ui~O!W'|O~P!WPBVDUFVF`PPFpFwGQGWPPPPPPPPPPPPPPPGZG^GkQ_OQ!SaQ!UcQ$o#cQ$q#eQ$v#jQ&a%jQ&n&OQ'Y&lR'c'ZgZO]ac#c#e#j%j&O&l'Z$[SOT]abcdhlnx|!O!l#P#Q#R#S#T#U#V#W#X#[#c#d#e#f#i#j#t#u#y$R$g$j$k$m%g%h%j%t%|&O&P&T&Z&h&l'Z']'j'k'l'm'n'o'p'q'r's't'u'v'w'x'y'z'{'|'}(O(P(R(VQiQQ!R`Q!WeQ![fS!^g#qQ!hkW!km#{%a&VQ!vqQ#s!aQ$s#gQ$w#kQ$z#mQ${#oW%n$f$i%x&cQ&R$yQ&s&YR'a(QYoRr!j!v%m$PyTbdhlmnx!O!l#P#Q#R#S#T#U#V#W#X#d#f#i#t#u#y#{$R$f$g$i$j$k$m%a%g%t%x%|&P&T&V&Z&c&h']'j'k'l'm'n'o'p'q'r's't'u'v'w'x'y'z'{'|'}(O(P(R(V$mSOT]abcdhlmnx|!O!l#P#Q#R#S#T#U#V#W#X#[#c#d#e#f#i#j#t#u#y#{$R$f$g$i$j$k$m%a%g%h%j%t%x%|&O&P&T&V&Z&c&h&l'Z']'j'k'l'm'n'o'p'q'r's't'u'v'w'x'y'z'{'|'}(O(P(R(V$mVOT]abcdhlmnx|!O!l#P#Q#R#S#T#U#V#W#X#[#c#d#e#f#i#j#t#u#y#{$R$f$g$i$j$k$m%a%g%h%j%t%x%|&O&P&T&V&Z&c&h&l'Z']'j'k'l'm'n'o'p'q'r's't'u'v'w'x'y'z'{'|'}(O(P(R(VUV|#[%hfWO]ac#c#e#j%j&O&l'Z!^tTbdhlnx!O!l#P#Q#R#S#T#U#V#W#X#d#f#i#t#u$R%g%|&P&Z'](Vx!qm#y#{$g$k$m%a&T&V&h'j'l'n'p'r't'v'x'z'|w%p$f$i$j%t%x&c'k'm'o'q's'u'w'y'{'}(O(P(Ra!nm#{$f$i%a%x&V&cQ!snR$Q!tQ%e$RQ&f%tT&u&Z(RQ#]zQ#n!]Q%O#sQ'`&tR'h(SR$b#[t#Qs!T!V!c!i#z$T$[$]$p$r%Q%d&]&m&o'd(Ud'n!m%S%U%]%^%s%w%y&p'Xa'o%o%v&e&y&z'R'S'gx#Rs!T!V!c!i#z$T$U$W$[$]$p$r%Q%d&]&m&o'd(Uh'p!m%S%U%V%X%]%^%s%w%y&p'Xe'q%o%v&e&y&z&{&}'R'S'gv#Ss!T!V!c!i#z$T$U$[$]$p$r%Q%d&]&m&o'd(Uf'r!m%S%U%V%]%^%s%w%y&p'Xc's%o%v&e&y&z&{'R'S'gz#Ts!T!V!c!i#z$T$U$V$W$[$]$p$r%Q%d&]&m&o'd(Uj't!m%S%U%V%W%X%]%^%s%w%y&p'Xg'u%o%v&e&y&z&{&|&}'R'S'g|#Us!T!V!c!i#z$T$U$V$W$X$[$]$p$r%Q%d&]&m&o'd(Ul'v!m%S%U%V%W%X%Y%]%^%s%w%y&p'Xi'w%o%v&e&y&z&{&|&}'O'R'S'g!O#Vs!T!V!c!i#z$T$U$V$W$X$Y$[$]$p$r%Q%d&]&m&o'd(Un'x!m%S%U%V%W%X%Y%Z%]%^%s%w%y&p'Xk'y%o%v&e&y&z&{&|&}'O'P'R'S'g!S#Vs!T!V!c!i#Z#z$T$U$V$W$X$Y$Z$[$]$p$r%Q%d&]&m&o'd(Ur'x!m$O%S%U%V%W%X%Y%Z%[%]%^%s%w%y&p'Xo'y%o%v&b&e&y&z&{&|&}'O'P'Q'R'S'g!^xTbdhlnx!O!l#P#Q#R#S#T#U#V#W#X#d#f#i#t#u$R%g%|&P&Z'](Vx'jm#y#{$g$k$m%a&T&V&h'j'l'n'p'r't'v'x'z'|w'k$f$i$j%t%x&c'k'm'o'q's'u'w'y'{'}(O(P(RT$d#^$eT$c#^$eQ%r$fQ%u$iR&i%xR!ZeR!YeQ!fhQ#a!OQ$u#iR%P#tR!]fgYO]ac#c#e#j%j&O&l'ZR!bgQ!_gR$|#qR#p!^d^Oac#c#e#j%j&O&l'ZR!Q]d]Oac#c#e#j%j&O&l'ZR!P]Q#|!pR%b#|Q#v!cR%R#vS%i$_$`R&`%iQ&[%eS&v&['WR'W&fQ$e#^R%k$eQ&d%qR'U&dQ%{$qR&j%{Q#h!WR$t#hQ#l![R$x#lQ}UR#`}Q#r!_R$}#rg[O]ac#c#e#j%j&O&l'ZQsTQ!TbQ!VdY!chn!O#i#tQ!ilW!mm#{%a&VQ#ZxQ#z!lQ$O'jQ$T#PQ$U#QQ$V#RQ$W#SQ$X#TQ$Y#UQ$Z#VQ$[#WQ$]#XQ$p#dQ$r#fQ%Q#uQ%S#yQ%U'lQ%V'nQ%W'pQ%X'rQ%Y'tQ%Z'vQ%['xQ%]'zQ%^'|S%d$R&ZW%o$f$i%x&cQ%s$gQ%v$jQ%w$kQ%y$mQ&]%gQ&b'kS&e%t(RQ&m%|Q&o&PQ&p&TQ&y(OQ&z'mQ&{'oQ&|'qQ&}'sQ'O'uQ'P'wQ'Q'yQ'R'{Q'S'}Q'X&hQ'd']Q'g(PR(U(VlRO]ac|#[#c#e#j%h%j&O&l'Z!^rTbdhlnx!O!l#P#Q#R#S#T#U#V#W#X#d#f#i#t#u$R%g%|&P&Z'](Vx!jm#y#{$g$k$m%a&T&V&h'j'l'n'p'r't'v'x'z'|w%m$f$i$j%t%x&c'k'm'o'q's'u'w'y'{'}(O(P(RfUO]ac#c#e#j%j&O&l'Z$OVTbdhlmnx!O!l#P#Q#R#S#T#U#V#W#X#d#f#i#t#u#y#{$R$f$g$i$j$k$m%a%g%t%x%|&P&T&V&Z&c&h']'j'k'l'm'n'o'p'q'r's't'u'v'w'x'y'z'{'|'}(O(P(R(VQ#_|Q$_#[R&^%hWpRr!j%mR$S!vQ!pmU%_#{%a&VU%q$f$i%xR'T&cZoRr!j!v%mW!dh!O#i#tR!tnQ%f$RR&g%tR$a#[R!XegXO]ac#c#e#j%j&O&l'ZR!`g", - nodeNames: "⚠ Comment Chunk Block ; Label :: Name break Goto goto Scope do end WhileStatement while nil true false Ellipsis Number LiteralString Property . MemberExpression [ ] Parens ( ) FunctionCall : TableConstructor { FieldDynamic FieldProp FieldExp } , AggOrderBy order by OrderBy desc asc using function FuncBody ArgList nulls first last BinaryExpression or and CompareOp BitOp BitOp BitOp BitOp Concat ArithOp ArithOp ArithOp UnaryExpression not ArithOp BitOp LenOp FunctionDef Query query QueryClause FromClause from FieldList WhereClause where OrderByClause SelectClause select LimitClause limit OffsetClause offset GroupByClause group HavingClause having FilteredCall filter RepeatStatement repeat until IfStatement if then elseif else ForStatement for ForNumeric ForGeneric NameList in ExpList Function FuncName LocalFunction local Assign VarList Local AttNameList AttName Attrib ReturnStatement return", + nodeNames: + "⚠ Comment Chunk Block ; Label :: Name break Goto goto Scope do end WhileStatement while nil true false Ellipsis Number LiteralString Property . MemberExpression [ ] Parens ( ) FunctionCall : TableConstructor { FieldDynamic FieldProp FieldExp } , AggOrderBy order by OrderBy desc asc using function FuncBody ArgList nulls first last BinaryExpression or and CompareOp BitOp BitOp BitOp BitOp Concat ArithOp ArithOp ArithOp UnaryExpression not ArithOp BitOp LenOp FunctionDef Query query QueryClause FromClause from FieldList WhereClause where OrderByClause SelectClause select LimitClause limit OffsetClause offset GroupByClause group HavingClause having FilteredCall filter RepeatStatement repeat until IfStatement if then elseif else ForStatement for ForNumeric ForGeneric NameList in ExpList Function FuncName LocalFunction local Assign VarList Local AttNameList AttName Attrib ReturnStatement return", maxTerm: 167, nodeProps: [ - ["group", -14,4,5,8,9,11,14,30,91,94,99,106,108,110,112,"Statement",-3,34,35,36,"Field"] + [ + "group", + -14, + 4, + 5, + 8, + 9, + 11, + 14, + 30, + 91, + 94, + 99, + 106, + 108, + 110, + 112, + "Statement", + -3, + 34, + 35, + 36, + "Field", + ], ], - skippedNodes: [0,1], + skippedNodes: [0, 1], repeatNodeCount: 12, - tokenData: "Dj~RuXY#fYZ$Q[]#f]^$_pq#fqr$grs$rst)`uv)evw)jwx)oxy.Wyz.]z{.b{|.g|}.l}!O.s!O!P/g!P!Q/|!Q!R0^!R![1u![!]3m!]!^3z!^!_4R!_!`4e!`!a4m!c!}5P!}#O5b#O#P#w#P#QCo#Q#RC|#R#S5P#T#o5P#o#pDR#p#qDW#q#rD]#r#sDb~#kS#y~XY#f[]#fpq#f#O#P#w~#zQYZ#f]^#f~$VP#x~]^$Y~$_O#x~~$dP#x~YZ$Ym$jP!_!`$mm$rO!Xmk$uXOY$rZ]$r^r$rrs%bs#O$r#O#P%g#P;'S$r;'S;=`'}<%lO$rk%gO#}kk%j]rs$rwx$r!Q![&c#O#P$r#T#U$r#U#V$r#Y#Z$r#b#c$r#f#g$r#h#i$r#i#j(T#l#m(v#n#o$rk&fZOY$rZ]$r^r$rrs%bs!Q$r!Q!['X![#O$r#O#P%g#P;'S$r;'S;=`'}<%lO$rk'[ZOY$rZ]$r^r$rrs%bs!Q$r!Q![$r![#O$r#O#P%g#P;'S$r;'S;=`'}<%lO$rk(QP;=`<%l$rk(WP#o#p(Zk(^R!Q![(g!c!i(g#T#Z(gk(jS!Q![(g!c!i(g#T#Z(g#q#r$rk(yR!Q![)S!c!i)S#T#Z)Sk)VR!Q![$r!c!i$r#T#Z$r~)eO$f~~)jO$c~~)oO$Z~k)rXOY)oZ])o^w)owx%bx#O)o#O#P*_#P;'S)o;'S;=`,u<%lO)ok*b]rs)owx)o!Q![+Z#O#P)o#T#U)o#U#V)o#Y#Z)o#b#c)o#f#g)o#h#i)o#i#j,{#l#m-n#n#o)ok+^ZOY)oZ])o^w)owx%bx!Q)o!Q![,P![#O)o#O#P*_#P;'S)o;'S;=`,u<%lO)ok,SZOY)oZ])o^w)owx%bx!Q)o!Q![)o![#O)o#O#P*_#P;'S)o;'S;=`,u<%lO)ok,xP;=`<%l)ok-OP#o#p-Rk-UR!Q![-_!c!i-_#T#Z-_k-bS!Q![-_!c!i-_#T#Z-_#q#r)ok-qR!Q![-z!c!i-z#T#Z-zk-}R!Q![)o!c!i)o#T#Z)o~.]Ol~~.bOm~~.gO$a~~.lO$_~o.sOvc$T[~.xP$`~}!O.{~/QTP~OY.{Z].{^;'S.{;'S;=`/a<%lO.{~/dP;=`<%l.{o/lPgi!O!P/oo/tP!^m!O!P/wQ/|OcQ~0RQ$b~!P!Q0X!_!`$m~0^O$d~~0cUd~!O!P0u!Q![1u!g!h1Z!z!{2W#X#Y1Z#l#m2W~0xP!Q![0{~1QRd~!Q![0{!g!h1Z#X#Y1Z~1^R{|1g}!O1g!Q![1m~1jP!Q![1m~1rPd~!Q![1m~1zSd~!O!P0u!Q![1u!g!h1Z#X#Y1Z~2ZR!Q![2d!c!i2d#T#Z2d~2iUd~!O!P2{!Q![2d!c!i2d!r!s1Z#T#Z2d#d#e1Z~3OR!Q![3X!c!i3X#T#Z3X~3^Td~!Q![3X!c!i3X!r!s1Z#T#Z3X#d#e1Z~3rPo~![!]3u~3zOU~_4ROSR$T[o4YQ$lQ!Xm!^!_4`!_!`$mm4eO$]m~4jP$S~!_!`$mo4tQ$mQ!Xm!_!`$m!`!a4zm5PO$^m~5US#{~!Q![5P!c!}5P#R#S5P#T#o5Po5gQik!_!`5m!}#OB_k5pQ!_!`5v!}#O=ok5yP!}#O5|k6PTO#P5|#P#Q6`#Q;'S5|;'S;=`8W<%lO5|k6cVO!_6x!_!`:s!`#P6x#P#Q;w#Q;'S6x;'S;=`=i<%lO6xk6{VO!_7b!_!`8^!`#P7b#P#Q8v#Q;'S7b;'S;=`=c<%lO7bk7eTO#P5|#P#Q7t#Q;'S5|;'S;=`8W<%lO5|k7wP!_!`7zk7}P!_!`8Qk8TP#P#Q%bk8ZP;=`<%l5|k8aTO#P8^#P#Q7t#Q;'S8^;'S;=`8p<%lO8^k8sP;=`<%l8^k8yUO!_5|!_!`9]!`#P5|#Q;'S5|;'S;=`8W<%lO5|k9`VO!_5|!_!`9u!`#P5|#P#Q6`#Q;'S5|;'S;=`8W<%lO5|k9xTO#P5|#P#Q:X#Q;'S5|;'S;=`8W<%lO5|k:^V#}kO!_6x!_!`:s!`#P6x#P#Q;w#Q;'S6x;'S;=`=i<%lO6xk:vVO!_8^!_!`;]!`#P8^#P#Q7t#Q;'S8^;'S;=`8p<%lO8^k;`TO#P8^#P#Q;o#Q;'S8^;'S;=`8p<%lO8^k;tP#}k!_!`7zk;zVO!_7b!_!`:s!`#P7b#P#QR#Q;'S=o;'S;=`>}<%lO=ok>UVO!_>k!_!`?T!`#P>k#P#Q@X#Q;'S>k;'S;=`Ao<%lO>kk>nTO#P=o#P#Q7z#Q;'S=o;'S;=`>}<%lO=ok?QP;=`<%l=ok?WTO#P?g#P#Q@P#Q;'S?g;'S;=`?y<%lO?gk?jTO#P?g#P#Q7z#Q;'S?g;'S;=`?y<%lO?gk?|P;=`<%l?gk@UP#}k!_!`8Qk@[VO!_=o!_!`@q!`#P=o#P#QAu#Q;'S=o;'S;=`>}<%lO=ok@tTO#P=o#P#QAT#Q;'S=o;'S;=`>}<%lO=okAYV#}kO!_>k!_!`?T!`#P>k#P#Q@X#Q;'S>k;'S;=`Ao<%lO>kkArP;=`<%l>kkAxVO!_?g!_!`?T!`#P?g#P#QAu#Q;'S?g;'S;=`?y<%lO?goBdT$gSO#PBs#P#QCV#Q;'SBs;'S;=`Ci<%lOBskBvTO#PBs#P#QCV#Q;'SBs;'S;=`Ci<%lOBskCYTO#PBs#P#Q%b#Q;'SBs;'S;=`Ci<%lOBskClP;=`<%lBsoCtPjP#P#QCwnC|O$hn~DRO$e~~DWOq~~D]O$Y~~DbOu~~DgP$[~!_!`$m", + tokenData: + "Dj~RuXY#fYZ$Q[]#f]^$_pq#fqr$grs$rst)`uv)evw)jwx)oxy.Wyz.]z{.b{|.g|}.l}!O.s!O!P/g!P!Q/|!Q!R0^!R![1u![!]3m!]!^3z!^!_4R!_!`4e!`!a4m!c!}5P!}#O5b#O#P#w#P#QCo#Q#RC|#R#S5P#T#o5P#o#pDR#p#qDW#q#rD]#r#sDb~#kS#y~XY#f[]#fpq#f#O#P#w~#zQYZ#f]^#f~$VP#x~]^$Y~$_O#x~~$dP#x~YZ$Ym$jP!_!`$mm$rO!Xmk$uXOY$rZ]$r^r$rrs%bs#O$r#O#P%g#P;'S$r;'S;=`'}<%lO$rk%gO#}kk%j]rs$rwx$r!Q![&c#O#P$r#T#U$r#U#V$r#Y#Z$r#b#c$r#f#g$r#h#i$r#i#j(T#l#m(v#n#o$rk&fZOY$rZ]$r^r$rrs%bs!Q$r!Q!['X![#O$r#O#P%g#P;'S$r;'S;=`'}<%lO$rk'[ZOY$rZ]$r^r$rrs%bs!Q$r!Q![$r![#O$r#O#P%g#P;'S$r;'S;=`'}<%lO$rk(QP;=`<%l$rk(WP#o#p(Zk(^R!Q![(g!c!i(g#T#Z(gk(jS!Q![(g!c!i(g#T#Z(g#q#r$rk(yR!Q![)S!c!i)S#T#Z)Sk)VR!Q![$r!c!i$r#T#Z$r~)eO$f~~)jO$c~~)oO$Z~k)rXOY)oZ])o^w)owx%bx#O)o#O#P*_#P;'S)o;'S;=`,u<%lO)ok*b]rs)owx)o!Q![+Z#O#P)o#T#U)o#U#V)o#Y#Z)o#b#c)o#f#g)o#h#i)o#i#j,{#l#m-n#n#o)ok+^ZOY)oZ])o^w)owx%bx!Q)o!Q![,P![#O)o#O#P*_#P;'S)o;'S;=`,u<%lO)ok,SZOY)oZ])o^w)owx%bx!Q)o!Q![)o![#O)o#O#P*_#P;'S)o;'S;=`,u<%lO)ok,xP;=`<%l)ok-OP#o#p-Rk-UR!Q![-_!c!i-_#T#Z-_k-bS!Q![-_!c!i-_#T#Z-_#q#r)ok-qR!Q![-z!c!i-z#T#Z-zk-}R!Q![)o!c!i)o#T#Z)o~.]Ol~~.bOm~~.gO$a~~.lO$_~o.sOvc$T[~.xP$`~}!O.{~/QTP~OY.{Z].{^;'S.{;'S;=`/a<%lO.{~/dP;=`<%l.{o/lPgi!O!P/oo/tP!^m!O!P/wQ/|OcQ~0RQ$b~!P!Q0X!_!`$m~0^O$d~~0cUd~!O!P0u!Q![1u!g!h1Z!z!{2W#X#Y1Z#l#m2W~0xP!Q![0{~1QRd~!Q![0{!g!h1Z#X#Y1Z~1^R{|1g}!O1g!Q![1m~1jP!Q![1m~1rPd~!Q![1m~1zSd~!O!P0u!Q![1u!g!h1Z#X#Y1Z~2ZR!Q![2d!c!i2d#T#Z2d~2iUd~!O!P2{!Q![2d!c!i2d!r!s1Z#T#Z2d#d#e1Z~3OR!Q![3X!c!i3X#T#Z3X~3^Td~!Q![3X!c!i3X!r!s1Z#T#Z3X#d#e1Z~3rPo~![!]3u~3zOU~_4ROSR$T[o4YQ$lQ!Xm!^!_4`!_!`$mm4eO$]m~4jP$S~!_!`$mo4tQ$mQ!Xm!_!`$m!`!a4zm5PO$^m~5US#{~!Q![5P!c!}5P#R#S5P#T#o5Po5gQik!_!`5m!}#OB_k5pQ!_!`5v!}#O=ok5yP!}#O5|k6PTO#P5|#P#Q6`#Q;'S5|;'S;=`8W<%lO5|k6cVO!_6x!_!`:s!`#P6x#P#Q;w#Q;'S6x;'S;=`=i<%lO6xk6{VO!_7b!_!`8^!`#P7b#P#Q8v#Q;'S7b;'S;=`=c<%lO7bk7eTO#P5|#P#Q7t#Q;'S5|;'S;=`8W<%lO5|k7wP!_!`7zk7}P!_!`8Qk8TP#P#Q%bk8ZP;=`<%l5|k8aTO#P8^#P#Q7t#Q;'S8^;'S;=`8p<%lO8^k8sP;=`<%l8^k8yUO!_5|!_!`9]!`#P5|#Q;'S5|;'S;=`8W<%lO5|k9`VO!_5|!_!`9u!`#P5|#P#Q6`#Q;'S5|;'S;=`8W<%lO5|k9xTO#P5|#P#Q:X#Q;'S5|;'S;=`8W<%lO5|k:^V#}kO!_6x!_!`:s!`#P6x#P#Q;w#Q;'S6x;'S;=`=i<%lO6xk:vVO!_8^!_!`;]!`#P8^#P#Q7t#Q;'S8^;'S;=`8p<%lO8^k;`TO#P8^#P#Q;o#Q;'S8^;'S;=`8p<%lO8^k;tP#}k!_!`7zk;zVO!_7b!_!`:s!`#P7b#P#QR#Q;'S=o;'S;=`>}<%lO=ok>UVO!_>k!_!`?T!`#P>k#P#Q@X#Q;'S>k;'S;=`Ao<%lO>kk>nTO#P=o#P#Q7z#Q;'S=o;'S;=`>}<%lO=ok?QP;=`<%l=ok?WTO#P?g#P#Q@P#Q;'S?g;'S;=`?y<%lO?gk?jTO#P?g#P#Q7z#Q;'S?g;'S;=`?y<%lO?gk?|P;=`<%l?gk@UP#}k!_!`8Qk@[VO!_=o!_!`@q!`#P=o#P#QAu#Q;'S=o;'S;=`>}<%lO=ok@tTO#P=o#P#QAT#Q;'S=o;'S;=`>}<%lO=okAYV#}kO!_>k!_!`?T!`#P>k#P#Q@X#Q;'S>k;'S;=`Ao<%lO>kkArP;=`<%l>kkAxVO!_?g!_!`?T!`#P?g#P#QAu#Q;'S?g;'S;=`?y<%lO?goBdT$gSO#PBs#P#QCV#Q;'SBs;'S;=`Ci<%lOBskBvTO#PBs#P#QCV#Q;'SBs;'S;=`Ci<%lOBskCYTO#PBs#P#Q%b#Q;'SBs;'S;=`Ci<%lOBskClP;=`<%lBsoCtPjP#P#QCwnC|O$hn~DRO$e~~DWOq~~D]O$Y~~DbOu~~DgP$[~!_!`$m", tokenizers: [0, 1, 2, 3, 4], - topRules: {"Chunk":[0,2]}, - dynamicPrecedences: {"144":1}, - specialized: [{term: 135, get: (value) => spec_identifier[value] || -1}], - tokenPrec: 4960 -}) + topRules: { Chunk: [0, 2] }, + dynamicPrecedences: { 144: 1 }, + specialized: [{ term: 135, get: (value) => spec_identifier[value] || -1 }], + tokenPrec: 4960, +}); diff --git a/client/space_lua/parse-lua.terms.js b/client/space_lua/parse-lua.terms.js index fc7146cd..5334f1f3 100644 --- a/client/space_lua/parse-lua.terms.js +++ b/client/space_lua/parse-lua.terms.js @@ -1,6 +1,5 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. -export const - Comment = 1, +export const Comment = 1, Chunk = 2, Block = 3, Label = 5, @@ -38,4 +37,4 @@ export const AttNameList = 113, AttName = 114, Attrib = 115, - ReturnStatement = 116 + ReturnStatement = 116; diff --git a/client/space_lua/parse.test.ts b/client/space_lua/parse.test.ts index 74dcdf21..ec94f8f9 100644 --- a/client/space_lua/parse.test.ts +++ b/client/space_lua/parse.test.ts @@ -1,5 +1,9 @@ import { expect, test } from "vitest"; -import { parseBlock, parseExpressionString, stripLuaComments } from "./parse.ts"; +import { + parseBlock, + parseExpressionString, + stripLuaComments, +} from "./parse.ts"; import type { LuaNumberLiteral } from "./ast.ts"; test("Test Lua parser", () => { @@ -60,7 +64,9 @@ test("Test Lua parser", () => { goto hello`); parseBlock(`while true do print() end`); parseBlock(`repeat print() until false`); - parseBlock(`if 1 == 2 then print() elseif 1 < 2 then print2() else print3() end`); + parseBlock( + `if 1 == 2 then print() elseif 1 < 2 then print2() else print3() end`, + ); parseBlock(`if true then print() end`); parseBlock(`if true then print() else print2() end`); parseBlock(`if true then print() elseif false then print2() end`); @@ -125,7 +131,9 @@ test("Test query parsing", () => { parseBlock( `_(query[[from p = index.tag("page") where p.name == "John" limit 10, 3]])`, ); - parseBlock(`_(query[[from index.tag("page") select {name="hello", age=10}]])`); + parseBlock( + `_(query[[from index.tag("page") select {name="hello", age=10}]])`, + ); parseBlock( `_(query[[from p = index.tag("page") order by p.lastModified desc, p.name]])`, ); @@ -133,7 +141,9 @@ test("Test query parsing", () => { // group by single key parseBlock(`_(query[[from p = index.tag("page") group by p.category]])`); // group by multiple keys - parseBlock(`_(query[[from p = index.tag("page") group by p.category, p.status]])`); + parseBlock( + `_(query[[from p = index.tag("page") group by p.category, p.status]])`, + ); // group by + having parseBlock( `_(query[[from p = index.tag("page") group by p.category having #group > 1]])`, diff --git a/client/space_lua/pretty_print.test.ts b/client/space_lua/pretty_print.test.ts index b443723a..82e4fb67 100644 --- a/client/space_lua/pretty_print.test.ts +++ b/client/space_lua/pretty_print.test.ts @@ -162,7 +162,9 @@ end`); expect(fmtBlock("for i=1,10,2 do f() end")).toBe(`for i = 1, 10, 2 do f() end`); - expect(fmtBlock("for k,v in pairs(t) do f() end")).toBe(`for k, v in pairs(t) do + expect( + fmtBlock("for k,v in pairs(t) do f() end"), + ).toBe(`for k, v in pairs(t) do f() end`); }); @@ -175,8 +177,9 @@ end`); }); test("blank line between definitions", () => { - expect(fmtBlock("function a() end\nfunction b() end\nlocal x = 1")) - .toBe(`function a() end + expect( + fmtBlock("function a() end\nfunction b() end\nlocal x = 1"), + ).toBe(`function a() end function b() end diff --git a/client/space_lua/pretty_print.ts b/client/space_lua/pretty_print.ts index 5e648d4c..3ff68d7b 100644 --- a/client/space_lua/pretty_print.ts +++ b/client/space_lua/pretty_print.ts @@ -29,9 +29,28 @@ function resolveOptions(opts?: PrintOptions): ResolvedOptions { } const RESERVED = new Set([ - "and", "break", "do", "else", "elseif", "end", "false", "for", "function", - "goto", "if", "in", "local", "nil", "not", "or", "repeat", "return", "then", - "true", "until", "while", + "and", + "break", + "do", + "else", + "elseif", + "end", + "false", + "for", + "function", + "goto", + "if", + "in", + "local", + "nil", + "not", + "or", + "repeat", + "return", + "then", + "true", + "until", + "while", ]); function isLuaIdentifier(s: string): boolean { @@ -39,16 +58,26 @@ function isLuaIdentifier(s: string): boolean { } const BINARY_PREC: Record = { - "or": 1, - "and": 2, - "<": 3, ">": 3, "<=": 3, ">=": 3, "~=": 3, "==": 3, + or: 1, + and: 2, + "<": 3, + ">": 3, + "<=": 3, + ">=": 3, + "~=": 3, + "==": 3, "|": 4, "~": 5, // binary bitwise xor "&": 6, - "<<": 7, ">>": 7, + "<<": 7, + ">>": 7, "..": 8, - "+": 9, "-": 9, - "*": 10, "/": 10, "//": 10, "%": 10, + "+": 9, + "-": 9, + "*": 10, + "/": 10, + "//": 10, + "%": 10, "^": 12, }; const UNARY_PREC = 11; @@ -62,7 +91,7 @@ function exprPrec(e: LuaExpression): number { } class Printer { - constructor(private opts: ResolvedOptions) { } + constructor(private opts: ResolvedOptions) {} private indent(depth: number): string { return " ".repeat(depth * this.opts.indentWidth); @@ -129,22 +158,27 @@ class Printer { case "TableConstructor": return this.tableConstructor(e.fields, depth); case "FunctionDefinition": - return "function" + - this.functionRest(e.body.parameters, e.body.block, depth); + return ( + "function" + this.functionRest(e.body.parameters, e.body.block, depth) + ); case "FunctionCall": return this.functionCall(e, depth); case "Query": { - const lines = e.clauses.map((c) => - this.indent(depth + 1) + this.queryClause(c, depth + 1) + const lines = e.clauses.map( + (c) => this.indent(depth + 1) + this.queryClause(c, depth + 1), ); return `query[[\n${lines.join("\n")}\n${this.indent(depth)}]]`; } case "FilteredCall": - return `${this.expression(e.call, depth)} filter ${this.expression(e.filter, depth) - }`; + return `${this.expression(e.call, depth)} filter ${this.expression( + e.filter, + depth, + )}`; case "AggregateCall": - return `${this.expression(e.call, depth)} order by ${this.orderByList(e.orderBy, depth) - }`; + return `${this.expression(e.call, depth)} order by ${this.orderByList( + e.orderBy, + depth, + )}`; default: throw new Error( `pretty_print: unsupported expression type: ${(e as any).type}`, @@ -193,8 +227,8 @@ class Printer { private tableConstructor(fields: LuaTableField[], depth: number): string { if (fields.length === 0) return "{}"; if (fields.length === 1) return `{${this.field(fields[0], depth)}}`; - const lines = fields.map((f) => - this.indent(depth + 1) + this.field(f, depth + 1) + const lines = fields.map( + (f) => this.indent(depth + 1) + this.field(f, depth + 1), ); const tail = this.opts.trailingComma ? "," : ""; return `{\n${lines.join(",\n")}${tail}\n${this.indent(depth)}}`; @@ -224,7 +258,8 @@ class Printer { ? ` order by ${this.orderByList(e.orderBy, depth)}` : ""; if ( - !e.orderBy && args.length === 1 && + !e.orderBy && + args.length === 1 && (args[0].type === "TableConstructor" || args[0].type === "String") ) { return `${base} ${this.expression(args[0], depth)}`; @@ -254,8 +289,10 @@ class Printer { case "Where": return `where ${this.expression(c.expression, depth)}`; case "Limit": - return `limit ${this.expression(c.limit, depth)}` + - (c.offset ? ` offset ${this.expression(c.offset, depth)}` : ""); + return ( + `limit ${this.expression(c.limit, depth)}` + + (c.offset ? ` offset ${this.expression(c.offset, depth)}` : "") + ); case "Offset": return `offset ${this.expression(c.offset, depth)}`; case "OrderBy": @@ -290,11 +327,14 @@ class Printer { switch (s.type) { case "Local": { const names = s.names - .map((n) => - n.name + - (n.attribute - ? ` <${n.attribute}>` - : (n.attributes?.length ? ` <${n.attributes[0]}>` : "")) + .map( + (n) => + n.name + + (n.attribute + ? ` <${n.attribute}>` + : n.attributes?.length + ? ` <${n.attributes[0]}>` + : ""), ) .join(", "); const exprs = s.expressions?.length @@ -303,12 +343,16 @@ class Printer { return `${ind}local ${names}${exprs}`; } case "Assignment": - return `${ind}${s.variables.map((v) => this.expression(v, depth)).join(", ") - } = ${s.expressions.map((e) => this.expression(e, depth)).join(", ")}`; + return `${ind}${s.variables + .map((v) => this.expression(v, depth)) + .join( + ", ", + )} = ${s.expressions.map((e) => this.expression(e, depth)).join(", ")}`; case "Return": return s.expressions.length - ? `${ind}return ${s.expressions.map((e) => this.expression(e, depth)).join(", ") - }` + ? `${ind}return ${s.expressions + .map((e) => this.expression(e, depth)) + .join(", ")}` : `${ind}return`; case "FunctionCallStatement": return ind + this.expression(s.call, depth); @@ -328,7 +372,8 @@ class Printer { const kw = i === 0 ? "if" : "elseif"; const head = i === 0 ? `${ind}${kw} ` : `\n${ind}${kw} `; const body = this.block(c.block, depth + 1); - out += `${head}${this.expression(c.condition, depth)} then` + + out += + `${head}${this.expression(c.condition, depth)} then` + (body ? `\n${body}` : ""); }); if (s.elseBlock) { @@ -340,34 +385,48 @@ class Printer { } case "While": { const body = this.block(s.block, depth + 1); - return `${ind}while ${this.expression(s.condition, depth)} do` + - (body ? `\n${body}` : "") + `\n${ind}end`; + return ( + `${ind}while ${this.expression(s.condition, depth)} do` + + (body ? `\n${body}` : "") + + `\n${ind}end` + ); } case "Repeat": { const body = this.block(s.block, depth + 1); - return `${ind}repeat` + (body ? `\n${body}` : "") + - `\n${ind}until ${this.expression(s.condition, depth)}`; + return ( + `${ind}repeat` + + (body ? `\n${body}` : "") + + `\n${ind}until ${this.expression(s.condition, depth)}` + ); } case "For": { const step = s.step ? `, ${this.expression(s.step, depth)}` : ""; const body = this.block(s.block, depth + 1); - return `${ind}for ${s.name} = ${this.expression(s.start, depth)}, ${this.expression(s.end, depth) - }${step} do${body ? `\n${body}` : ""}\n${ind}end`; + return `${ind}for ${s.name} = ${this.expression(s.start, depth)}, ${this.expression( + s.end, + depth, + )}${step} do${body ? `\n${body}` : ""}\n${ind}end`; } case "ForIn": { const body = this.block(s.block, depth + 1); - return `${ind}for ${s.names.join(", ")} in ${s.expressions.map((e) => this.expression(e, depth)).join(", ") - } do${body ? `\n${body}` : ""}\n${ind}end`; + return `${ind}for ${s.names.join(", ")} in ${s.expressions + .map((e) => this.expression(e, depth)) + .join(", ")} do${body ? `\n${body}` : ""}\n${ind}end`; } case "Function": { - const name = s.name.propNames.join(".") + + const name = + s.name.propNames.join(".") + (s.name.colonName ? `:${s.name.colonName}` : ""); - return `${ind}function ${name}` + - this.functionRest(s.body.parameters, s.body.block, depth); + return ( + `${ind}function ${name}` + + this.functionRest(s.body.parameters, s.body.block, depth) + ); } case "LocalFunction": - return `${ind}local function ${s.name}` + - this.functionRest(s.body.parameters, s.body.block, depth); + return ( + `${ind}local function ${s.name}` + + this.functionRest(s.body.parameters, s.body.block, depth) + ); default: throw new Error( `pretty_print: unsupported statement type: ${(s as any).type}`, @@ -383,9 +442,6 @@ export function prettyPrintExpression( return new Printer(resolveOptions(opts)).expression(expr, 0); } -export function prettyPrintBlock( - block: LuaBlock, - opts?: PrintOptions, -): string { +export function prettyPrintBlock(block: LuaBlock, opts?: PrintOptions): string { return new Printer(resolveOptions(opts)).block(block, 0); } diff --git a/client/space_lua/query_collection.ts b/client/space_lua/query_collection.ts index 9c1aa7d1..6b694655 100644 --- a/client/space_lua/query_collection.ts +++ b/client/space_lua/query_collection.ts @@ -193,7 +193,7 @@ export interface LuaQueryCollection { * Implements a query collection for a regular JavaScript array */ export class ArrayQueryCollection implements LuaQueryCollection { - constructor(private readonly array: T[]) { } + constructor(private readonly array: T[]) {} query( query: LuaCollectionQuery, env: LuaEnv, @@ -842,7 +842,7 @@ export async function applyQuery( const mkEnv = grouped ? (ov: string | undefined, item: any, e: LuaEnv, s: LuaStackFrame) => - buildGroupItemEnv(ov, groupByNames, item, e, s) + buildGroupItemEnv(ov, groupByNames, item, e, s) : buildItemEnvLocal; let selectResults: any[] | undefined; @@ -1002,7 +1002,7 @@ export async function queryLua( for await (let { key, value } of kv.query({ prefix })) { if (enricher) { value = enricher(key, value); - // Enrichers may return undefined to filter a row out + // Enrichers may return undefined to filter a row out if (value === undefined) continue; } results.push(value); @@ -1056,7 +1056,7 @@ export class DataStoreQueryCollection implements LuaQueryCollection { constructor( private readonly dataStore: DataStore, readonly prefix: string[], - ) { } + ) {} query( query: LuaCollectionQuery, env: LuaEnv, diff --git a/client/space_lua/render_lua_markdown.test.ts b/client/space_lua/render_lua_markdown.test.ts index 35bb5ed4..7687f813 100644 --- a/client/space_lua/render_lua_markdown.test.ts +++ b/client/space_lua/render_lua_markdown.test.ts @@ -673,9 +673,9 @@ test("clean: scalar JS array in cell is joined with
    ", async () => { }); test("clean: pipe inside scalar array cell is escaped", async () => { - expect( - await renderResultToCleanMarkdown({ vals: ["a|b", "c"] }), - ).toBe("|vals|\n|--|\n|a\\|b
    c|"); + expect(await renderResultToCleanMarkdown({ vals: ["a|b", "c"] })).toBe( + "|vals|\n|--|\n|a\\|b
    c|", + ); }); test("clean: SLIQ_NULL cell renders as empty", async () => { diff --git a/client/space_lua/render_widget.ts b/client/space_lua/render_widget.ts index de5bac39..c6856af1 100644 --- a/client/space_lua/render_widget.ts +++ b/client/space_lua/render_widget.ts @@ -65,9 +65,7 @@ export async function renderLuaWidgetResult( if (e instanceof LuaRuntimeError && e.sf?.astCtx) { const source = resolveASTReference(e.sf.astCtx); if (source) { - return `**Lua error:** ${e.message} (Origin: [[${ - encodeRef(source) - }]])`; + return `**Lua error:** ${e.message} (Origin: [[${encodeRef(source)}]])`; } } return `**Lua error:** ${e.message}`; diff --git a/client/space_lua/runtime.ts b/client/space_lua/runtime.ts index 58053832..39e8a65f 100644 --- a/client/space_lua/runtime.ts +++ b/client/space_lua/runtime.ts @@ -482,10 +482,7 @@ export class LuaFunction implements ILuaFunction { for (let i = 0; i < varargStart; i++) { env.setLocal(params[i], resolvedArgs[i] ?? null); } - env.setLocal( - "...", - new LuaMultiRes(resolvedArgs.slice(varargStart)), - ); + env.setLocal("...", new LuaMultiRes(resolvedArgs.slice(varargStart))); } else { // Non-variadic: bind all named params directly for (let i = 0; i < params.length; i++) { @@ -1313,11 +1310,9 @@ export function luaSet( const k = toNumKey(normKey); const jsVal = luaValueToJS(value, sf); if (isPromise(jsVal)) { - return (jsVal as Promise).then( - (v) => { - (obj as Record)[k] = v; - }, - ); + return (jsVal as Promise).then((v) => { + (obj as Record)[k] = v; + }); } (obj as Record)[k] = jsVal; } @@ -1492,16 +1487,18 @@ export function luaCall( export function luaEquals(a: any, b: any): boolean { // Normalize nil variants (null, undefined, SQL NULL) to null - const an = (a === null || a === undefined || isSqlNull(a)) - ? null - : isTaggedFloat(a) - ? a.value - : a; - const bn = (b === null || b === undefined || isSqlNull(b)) - ? null - : isTaggedFloat(b) - ? b.value - : b; + const an = + a === null || a === undefined || isSqlNull(a) + ? null + : isTaggedFloat(a) + ? a.value + : a; + const bn = + b === null || b === undefined || isSqlNull(b) + ? null + : isTaggedFloat(b) + ? b.value + : b; return an === bn; } diff --git a/client/spaces/evented_space_primitives.ts b/client/spaces/evented_space_primitives.ts index efa4906e..5d56aa1b 100644 --- a/client/spaces/evented_space_primitives.ts +++ b/client/spaces/evented_space_primitives.ts @@ -34,7 +34,7 @@ export class EventedSpacePrimitives implements SpacePrimitives { private eventHook: EventHook, private ds: DataStore, private snapshotKey = ["$spaceSnapshot"], - ) { } + ) {} async enable() { console.log("Loading snapshot and enabling events"); diff --git a/client/spaces/http_space_primitives.ts b/client/spaces/http_space_primitives.ts index a783cd90..80b57693 100644 --- a/client/spaces/http_space_primitives.ts +++ b/client/spaces/http_space_primitives.ts @@ -16,7 +16,8 @@ const defaultFetchTimeout = 30000; // 30 seconds // WebKit (Safari, WKWebView) strips custom response headers (X-Last-Modified, // etc.) when it recognizes a file extension in the URL. Encoding the last dot // as %2E prevents this; the server decodes it transparently. -const isWebKit = typeof navigator !== "undefined" && +const isWebKit = + typeof navigator !== "undefined" && /AppleWebKit/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent); @@ -32,8 +33,13 @@ function encodeExtensionDot(url: string): string { const rest = qIdx >= 0 ? filePart.substring(qIdx) : ""; const lastDot = path.lastIndexOf("."); if (lastDot > path.lastIndexOf("/")) { - return url.substring(0, afterFs) + - path.substring(0, lastDot) + "%2E" + path.substring(lastDot + 1) + rest; + return ( + url.substring(0, afterFs) + + path.substring(0, lastDot) + + "%2E" + + path.substring(lastDot + 1) + + rest + ); } return url; } diff --git a/client/spaces/sync.ts b/client/spaces/sync.ts index 950f0f45..e992f218 100644 --- a/client/spaces/sync.ts +++ b/client/spaces/sync.ts @@ -484,7 +484,9 @@ export class SpaceSync extends EventEmitter { snapshot.nonSyncedFiles.set(path, secondaryMeta); operations += 1; } else if ( - primaryMeta && secondaryMeta && snapshot.files.has(path) && + primaryMeta && + secondaryMeta && + snapshot.files.has(path) && primaryMeta.size !== secondaryMeta.size ) { // Sizes differ despite matching timestamps — silent content change diff --git a/client/widget_cache.ts b/client/widget_cache.ts index 15923238..6fd436d6 100644 --- a/client/widget_cache.ts +++ b/client/widget_cache.ts @@ -32,7 +32,9 @@ export class WidgetCache { setCachedWidgetMeta(key: string, meta: WidgetMeta) { const existing = this.widgetMetaCache.get(key); if ( - existing && existing.height === meta.height && existing.block === meta.block + existing && + existing.height === meta.height && + existing.block === meta.block ) { return; } diff --git a/docs/.silverbullet.auth.json b/docs/.silverbullet.auth.json index 154e711f..d6d58621 100644 --- a/docs/.silverbullet.auth.json +++ b/docs/.silverbullet.auth.json @@ -2,4 +2,4 @@ "secret": "zWPkhQnSdDTB5QDORfQGmHDVuSduNwLKroXctIU4+3Y=", "config_hash": "0c6f24ddc8da4c2f2e55d496cd30a64179b20f6ebe09621e7a0df9b35aa55cd0", "salt": "YXyeDbwXZpVBkK4XJ0NgHQ==" -} \ No newline at end of file +} diff --git a/e2e/auth-first-load.test.ts b/e2e/auth-first-load.test.ts index b9b2f9b4..049fe9cf 100644 --- a/e2e/auth-first-load.test.ts +++ b/e2e/auth-first-load.test.ts @@ -17,54 +17,56 @@ let spaceDir: string; let base: string; test.beforeAll(async () => { - spaceDir = await mkdtemp(join(tmpdir(), "sb-auth-e2e-")); - const port = await getFreePort(); - proc = spawn( - "./target/debug/silverbullet", - [spaceDir, "-p", String(port), "-L", "127.0.0.1"], - { - cwd: join(import.meta.dirname, ".."), - stdio: ["ignore", "pipe", "pipe"], - env: { - ...process.env, - SB_USER: "alice:s3cret", - SB_RUNTIME_API: "0", - SB_DISABLE_SERVICE_WORKER: "1", - }, - }, - ); - base = `http://127.0.0.1:${port}`; - // The SPA shell is served openly, so /.ping answers even without a session. - await waitForServer(`${base}/.ping`); + spaceDir = await mkdtemp(join(tmpdir(), "sb-auth-e2e-")); + const port = await getFreePort(); + proc = spawn( + "./target/debug/silverbullet", + [spaceDir, "-p", String(port), "-L", "127.0.0.1"], + { + cwd: join(import.meta.dirname, ".."), + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + SB_USER: "alice:s3cret", + SB_RUNTIME_API: "0", + SB_DISABLE_SERVICE_WORKER: "1", + }, + }, + ); + base = `http://127.0.0.1:${port}`; + // The SPA shell is served openly, so /.ping answers even without a session. + await waitForServer(`${base}/.ping`); }); test.afterAll(async () => { - proc?.kill(); - await rm(spaceDir, { recursive: true, force: true }); + proc?.kill(); + await rm(spaceDir, { recursive: true, force: true }); }); -test("first load of an authenticated space redirects to login without alerts", async ({ page }) => { - const dialogs: string[] = []; - page.on("dialog", async (dialog) => { - dialogs.push(dialog.message()); - await dialog.dismiss(); - }); +test("first load of an authenticated space redirects to login without alerts", async ({ + page, +}) => { + const dialogs: string[] = []; + page.on("dialog", async (dialog) => { + dialogs.push(dialog.message()); + await dialog.dismiss(); + }); - await page.goto(`${base}/`); - // The boot code discovers it is unauthenticated and redirects to the login - // page. - await expect(page.locator("#username")).toBeVisible({ timeout: 30_000 }); - // Give any straggling (aborted) boot fetch time to surface a dialog. - await page.waitForTimeout(1500); - expect(dialogs, `unexpected dialogs: ${dialogs.join(" | ")}`).toEqual([]); + await page.goto(`${base}/`); + // The boot code discovers it is unauthenticated and redirects to the login + // page. + await expect(page.locator("#username")).toBeVisible({ timeout: 30_000 }); + // Give any straggling (aborted) boot fetch time to surface a dialog. + await page.waitForTimeout(1500); + expect(dialogs, `unexpected dialogs: ${dialogs.join(" | ")}`).toEqual([]); - // And logging in still works end to end. - await page.locator("#username").fill("alice"); - await page.locator("#password").fill("s3cret"); - await page.getByRole("button", { name: "Login" }).click(); - await page.waitForURL(`${base}/`); - await expect(page.locator("#sb-editor .cm-editor")).toBeVisible({ - timeout: 30_000, - }); - expect(dialogs, `unexpected dialogs: ${dialogs.join(" | ")}`).toEqual([]); + // And logging in still works end to end. + await page.locator("#username").fill("alice"); + await page.locator("#password").fill("s3cret"); + await page.getByRole("button", { name: "Login" }).click(); + await page.waitForURL(`${base}/`); + await expect(page.locator("#sb-editor .cm-editor")).toBeVisible({ + timeout: 30_000, + }); + expect(dialogs, `unexpected dialogs: ${dialogs.join(" | ")}`).toEqual([]); }); diff --git a/e2e/baked-sections.test.ts b/e2e/baked-sections.test.ts index fac1fa64..46955176 100644 --- a/e2e/baked-sections.test.ts +++ b/e2e/baked-sections.test.ts @@ -2,10 +2,8 @@ import { expect, gotoSilverBulletPage, test } from "./fixtures.ts"; test.use({ spaceFiles: { - "Bake.md": - `Before\n\n\nstale\n\n\nAfter\n`, - "BakeButton.md": - "Table:\n\n${ { {name = \"a\"}, {name = \"b\"} } }\n", + "Bake.md": `Before\n\n\nstale\n\n\nAfter\n`, + "BakeButton.md": 'Table:\n\n${ { {name = "a"}, {name = "b"} } }\n', // A Lua-registered code widget that renders its body as block markdown. // Its rendered block gets a button bar, but it must NOT get a Bake button: // baking rewrites the source to `` where EXPR is a Lua @@ -15,7 +13,10 @@ test.use({ }, }); -test("'Baked Sections: Update' rewrites a section body with the evaluated result", async ({ sbServer, page }) => { +test("'Baked Sections: Update' rewrites a section body with the evaluated result", async ({ + sbServer, + page, +}) => { await gotoSilverBulletPage(page, sbServer, "Bake"); await page.waitForFunction( @@ -25,24 +26,29 @@ test("'Baked Sections: Update' rewrites a section body with the evaluated result ); await page.evaluate(() => - (globalThis as any).client.runCommandByName( - "Baked Sections: Update", - ) + (globalThis as any).client.runCommandByName("Baked Sections: Update"), ); - await page.waitForFunction(() => { - const doc = (globalThis as any).client.editorView.state.doc.toString(); - return doc.includes("\n7\n"); - }, undefined, { timeout: 10_000 }); + await page.waitForFunction( + () => { + const doc = (globalThis as any).client.editorView.state.doc.toString(); + return doc.includes("\n7\n"); + }, + undefined, + { timeout: 10_000 }, + ); const doc = await page.evaluate(() => - (globalThis as any).client.editorView.state.doc.toString() + (globalThis as any).client.editorView.state.doc.toString(), ); expect(doc).toContain("\n7\n"); expect(doc).not.toContain("stale"); }); -test("'Baked Sections: Unbake Section At Cursor' restores the section to ${…} form", async ({ sbServer, page }) => { +test("'Baked Sections: Unbake Section At Cursor' restores the section to ${…} form", async ({ + sbServer, + page, +}) => { await gotoSilverBulletPage(page, sbServer, "Bake"); await page.waitForFunction( @@ -61,16 +67,20 @@ test("'Baked Sections: Unbake Section At Cursor' restores the section to ${…} await page.evaluate(() => (globalThis as any).client.runCommandByName( "Baked Sections: Unbake Section At Cursor", - ) + ), ); - await page.waitForFunction(() => { - const doc = (globalThis as any).client.editorView.state.doc.toString(); - return doc.includes("${3 + 4}"); - }, undefined, { timeout: 10_000 }); + await page.waitForFunction( + () => { + const doc = (globalThis as any).client.editorView.state.doc.toString(); + return doc.includes("${3 + 4}"); + }, + undefined, + { timeout: 10_000 }, + ); const doc = await page.evaluate(() => - (globalThis as any).client.editorView.state.doc.toString() + (globalThis as any).client.editorView.state.doc.toString(), ); expect(doc).toContain("${3 + 4}"); expect(doc).not.toContain(""); - }, undefined, { timeout: 10_000 }); + await page.waitForFunction( + () => { + const doc = (globalThis as any).client.editorView.state.doc.toString(); + return doc.includes(""); + }, + undefined, + { timeout: 10_000 }, + ); const doc = await page.evaluate(() => - (globalThis as any).client.editorView.state.doc.toString() + (globalThis as any).client.editorView.state.doc.toString(), ); expect(doc).toContain(""); @@ -112,7 +129,10 @@ test("bake button converts a ${} directive to comment form", async ({ sbServer, // Regression guard: the Bake button is for live `${…}` Lua directives only. // Widgets that originate from fenced code blocks (e.g. ```mermaid, ```greet) // must not get one — their body is not a Lua expression to re-evaluate. -test("fenced code block widgets do not get a Bake button", async ({ sbServer, page }) => { +test("fenced code block widgets do not get a Bake button", async ({ + sbServer, + page, +}) => { await gotoSilverBulletPage(page, sbServer, "FencedNoBake"); // Wait for the `greet` code widget to render as a block widget (this only diff --git a/e2e/code-block-highlighting.test.ts b/e2e/code-block-highlighting.test.ts index 146ddbcb..19d75422 100644 --- a/e2e/code-block-highlighting.test.ts +++ b/e2e/code-block-highlighting.test.ts @@ -1,9 +1,9 @@ import { expect, gotoSilverBulletPage, test } from "./fixtures.ts"; test.describe("Code block syntax highlighting", () => { - test.use({ - spaceFiles: { - "CodeTest.md": `\ + test.use({ + spaceFiles: { + "CodeTest.md": `\ # Code Blocks \`\`\`javascript @@ -18,49 +18,60 @@ def greet(name): return f"Hello {name}" \`\`\` `, - }, - }); + }, + }); - test("javascript code block gets syntax highlighted", async ({ sbServer, page }) => { - await gotoSilverBulletPage(page, sbServer, "CodeTest"); - const editor = page.locator("#sb-editor .cm-content"); + test("javascript code block gets syntax highlighted", async ({ + sbServer, + page, + }) => { + await gotoSilverBulletPage(page, sbServer, "CodeTest"); + const editor = page.locator("#sb-editor .cm-content"); - // Verify the code block content is rendered - await expect(editor).toContainText("const greeting"); + // Verify the code block content is rendered + await expect(editor).toContainText("const greeting"); - // Wait for syntax highlighting to appear — CodeMirror applies - // highlight classes like sb-keyword, sb-string, sb-variableName - // to tokens inside code blocks. For dynamically loaded languages, - // this may take a moment. - const codeBlock = page.locator("#sb-editor .cm-editor"); + // Wait for syntax highlighting to appear — CodeMirror applies + // highlight classes like sb-keyword, sb-string, sb-variableName + // to tokens inside code blocks. For dynamically loaded languages, + // this may take a moment. + const codeBlock = page.locator("#sb-editor .cm-editor"); - // "const" should be highlighted as a keyword - await expect(codeBlock.locator(".sb-keyword", { hasText: "const" })) - .toBeVisible({ timeout: 15_000 }); + // "const" should be highlighted as a keyword + await expect( + codeBlock.locator(".sb-keyword", { hasText: "const" }), + ).toBeVisible({ timeout: 15_000 }); - // "greeting" should be highlighted as a variable name (appears multiple times) - await expect(codeBlock.locator(".sb-variableName", { hasText: "greeting" }).first()) - .toBeVisible({ timeout: 5_000 }); + // "greeting" should be highlighted as a variable name (appears multiple times) + await expect( + codeBlock.locator(".sb-variableName", { hasText: "greeting" }).first(), + ).toBeVisible({ timeout: 5_000 }); - // The string literal should be highlighted - await expect(codeBlock.locator(".sb-string", { hasText: '"hello world"' })) - .toBeVisible({ timeout: 5_000 }); - }); + // The string literal should be highlighted + await expect( + codeBlock.locator(".sb-string", { hasText: '"hello world"' }), + ).toBeVisible({ timeout: 5_000 }); + }); - test("python code block gets syntax highlighted", async ({ sbServer, page }) => { - await gotoSilverBulletPage(page, sbServer, "CodeTest"); - const editor = page.locator("#sb-editor .cm-content"); + test("python code block gets syntax highlighted", async ({ + sbServer, + page, + }) => { + await gotoSilverBulletPage(page, sbServer, "CodeTest"); + const editor = page.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("def greet"); + await expect(editor).toContainText("def greet"); - const codeBlock = page.locator("#sb-editor .cm-editor"); + const codeBlock = page.locator("#sb-editor .cm-editor"); - // "def" should be highlighted as a keyword - await expect(codeBlock.locator(".sb-keyword", { hasText: "def" })) - .toBeVisible({ timeout: 15_000 }); + // "def" should be highlighted as a keyword + await expect( + codeBlock.locator(".sb-keyword", { hasText: "def" }), + ).toBeVisible({ timeout: 15_000 }); - // "return" should be highlighted as a keyword - await expect(codeBlock.locator(".sb-keyword", { hasText: "return" })) - .toBeVisible({ timeout: 5_000 }); - }); + // "return" should be highlighted as a keyword + await expect( + codeBlock.locator(".sb-keyword", { hasText: "return" }), + ).toBeVisible({ timeout: 5_000 }); + }); }); diff --git a/e2e/command-palette.test.ts b/e2e/command-palette.test.ts index 71d5ad6d..5432c116 100644 --- a/e2e/command-palette.test.ts +++ b/e2e/command-palette.test.ts @@ -1,55 +1,57 @@ import { expect, mod, test } from "./fixtures.ts"; test.describe("Command palette", () => { - test("open and close command palette", async ({ sbPage }) => { - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Welcome"); + test("open and close command palette", async ({ sbPage }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Welcome"); - // Open command palette - await sbPage.keyboard.press(`${mod}+/`); - const modal = sbPage.locator(".sb-modal-box"); - await expect(modal).toBeVisible(); + // Open command palette + await sbPage.keyboard.press(`${mod}+/`); + const modal = sbPage.locator(".sb-modal-box"); + await expect(modal).toBeVisible(); - // Close with Escape - await sbPage.keyboard.press("Escape"); - await expect(modal).not.toBeVisible(); - }); + // Close with Escape + await sbPage.keyboard.press("Escape"); + await expect(modal).not.toBeVisible(); + }); - test("filter commands by typing", async ({ sbPage }) => { - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Welcome"); + test("filter commands by typing", async ({ sbPage }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Welcome"); - // Open command palette - await sbPage.keyboard.press(`${mod}+/`); - const modal = sbPage.locator(".sb-modal-box"); - await expect(modal).toBeVisible(); + // Open command palette + await sbPage.keyboard.press(`${mod}+/`); + const modal = sbPage.locator(".sb-modal-box"); + await expect(modal).toBeVisible(); - // Type to filter - const paletteInput = modal.locator("input.sb-input"); - await paletteInput.click(); - await sbPage.keyboard.type("Stats", { delay: 30 }); + // Type to filter + const paletteInput = modal.locator("input.sb-input"); + await paletteInput.click(); + await sbPage.keyboard.type("Stats", { delay: 30 }); - // Should show filtered results including "Stats: Show" - await expect(modal.locator(".sb-option .sb-name", { hasText: "Stats" })).toBeVisible(); + // Should show filtered results including "Stats: Show" + await expect( + modal.locator(".sb-option .sb-name", { hasText: "Stats" }), + ).toBeVisible(); - await sbPage.keyboard.press("Escape"); - }); + await sbPage.keyboard.press("Escape"); + }); - test("run a command from the palette", async ({ sbPage }) => { - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Welcome"); + test("run a command from the palette", async ({ sbPage }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Welcome"); - // Open command palette and run "Stats: Show" - await sbPage.keyboard.press(`${mod}+/`); - const modal = sbPage.locator(".sb-modal-box"); - const paletteInput = modal.locator("input.sb-input"); - await paletteInput.click(); - await sbPage.keyboard.type("Stats: Show", { delay: 30 }); + // Open command palette and run "Stats: Show" + await sbPage.keyboard.press(`${mod}+/`); + const modal = sbPage.locator(".sb-modal-box"); + const paletteInput = modal.locator("input.sb-input"); + await paletteInput.click(); + await sbPage.keyboard.type("Stats: Show", { delay: 30 }); - // Select the matching command - await sbPage.keyboard.press("Enter"); + // Select the matching command + await sbPage.keyboard.press("Enter"); - // The modal should close - await expect(modal).not.toBeVisible(); - }); + // The modal should close + await expect(modal).not.toBeVisible(); + }); }); diff --git a/e2e/cursor-reset.test.ts b/e2e/cursor-reset.test.ts index 74133248..138c1e75 100644 --- a/e2e/cursor-reset.test.ts +++ b/e2e/cursor-reset.test.ts @@ -5,8 +5,8 @@ import { expect, gotoSilverBulletPage, test } from "./fixtures.ts"; test.use({ - spaceFiles: { - "CursorTest.md": `# Title + spaceFiles: { + "CursorTest.md": `# Title Line 2 Line 3 @@ -23,25 +23,28 @@ Line 10 Line 13 Line 14 `, - }, + }, }); -test("cursor placed mid-document stays put across widget rebuild", async ({ sbServer, page }) => { - await gotoSilverBulletPage(page, sbServer, "CursorTest"); +test("cursor placed mid-document stays put across widget rebuild", async ({ + sbServer, + page, +}) => { + await gotoSilverBulletPage(page, sbServer, "CursorTest"); - await page.evaluate(() => { - const view = (globalThis as any).client.editorView; - view.dispatch({ selection: { anchor: view.state.doc.line(8).from } }); - view.focus(); - }); + await page.evaluate(() => { + const view = (globalThis as any).client.editorView; + view.dispatch({ selection: { anchor: view.state.doc.line(8).from } }); + view.focus(); + }); - // Sample a few times across the window where the rebuild could fire. - for (let i = 0; i < 10; i++) { - await page.waitForTimeout(150); - const line = await page.evaluate(() => { - const v = (globalThis as any).client.editorView; - return v.state.doc.lineAt(v.state.selection.main.head).number; - }); - expect(line).toBe(8); - } + // Sample a few times across the window where the rebuild could fire. + for (let i = 0; i < 10; i++) { + await page.waitForTimeout(150); + const line = await page.evaluate(() => { + const v = (globalThis as any).client.editorView; + return v.state.doc.lineAt(v.state.selection.main.head).number; + }); + expect(line).toBe(8); + } }); diff --git a/e2e/editor-formatting.test.ts b/e2e/editor-formatting.test.ts index 1e71c3a2..65bc5cde 100644 --- a/e2e/editor-formatting.test.ts +++ b/e2e/editor-formatting.test.ts @@ -1,87 +1,99 @@ import { expect, mod, test, waitForSaveAndReadFromServer } from "./fixtures.ts"; test.describe("Editor formatting", () => { - test("bold text with Mod+B", async ({ sbPage, sbServer }) => { - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Welcome"); + test("bold text with Mod+B", async ({ sbPage, sbServer }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Welcome"); - // Navigate to a fresh page - await sbPage.keyboard.press(`${mod}+k`); - await sbPage.locator(".sb-modal-box input.sb-input").click(); - await sbPage.keyboard.type("Formatting Test", { delay: 30 }); - await sbPage.keyboard.press("Shift+Enter"); - await expect(editor).toHaveText(""); + // Navigate to a fresh page + await sbPage.keyboard.press(`${mod}+k`); + await sbPage.locator(".sb-modal-box input.sb-input").click(); + await sbPage.keyboard.type("Formatting Test", { delay: 30 }); + await sbPage.keyboard.press("Shift+Enter"); + await expect(editor).toHaveText(""); - // Type some text - await editor.click(); - await sbPage.keyboard.type("make this bold"); + // Type some text + await editor.click(); + await sbPage.keyboard.type("make this bold"); - // Select all - await sbPage.keyboard.press(`${mod}+a`); + // Select all + await sbPage.keyboard.press(`${mod}+a`); - // Apply bold - await sbPage.keyboard.press(`${mod}+b`); + // Apply bold + await sbPage.keyboard.press(`${mod}+b`); - // Verify bold markers appear - await expect(editor).toContainText("**make this bold**"); + // Verify bold markers appear + await expect(editor).toContainText("**make this bold**"); - // Verify saved to server - const content = await waitForSaveAndReadFromServer(sbPage, sbServer, "Formatting Test.md"); - expect(content).toContain("**make this bold**"); - }); + // Verify saved to server + const content = await waitForSaveAndReadFromServer( + sbPage, + sbServer, + "Formatting Test.md", + ); + expect(content).toContain("**make this bold**"); + }); - test("italic text with Mod+I", async ({ sbPage, sbServer }) => { - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Welcome"); + test("italic text with Mod+I", async ({ sbPage, sbServer }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Welcome"); - await sbPage.keyboard.press(`${mod}+k`); - await sbPage.locator(".sb-modal-box input.sb-input").click(); - await sbPage.keyboard.type("Italic Test", { delay: 30 }); - await sbPage.keyboard.press("Shift+Enter"); - await expect(editor).toHaveText(""); + await sbPage.keyboard.press(`${mod}+k`); + await sbPage.locator(".sb-modal-box input.sb-input").click(); + await sbPage.keyboard.type("Italic Test", { delay: 30 }); + await sbPage.keyboard.press("Shift+Enter"); + await expect(editor).toHaveText(""); - await editor.click(); - await sbPage.keyboard.type("make this italic"); - await sbPage.keyboard.press(`${mod}+a`); - await sbPage.keyboard.press(`${mod}+i`); + await editor.click(); + await sbPage.keyboard.type("make this italic"); + await sbPage.keyboard.press(`${mod}+a`); + await sbPage.keyboard.press(`${mod}+i`); - await expect(editor).toContainText("_make this italic_"); + await expect(editor).toContainText("_make this italic_"); - const content = await waitForSaveAndReadFromServer(sbPage, sbServer, "Italic Test.md"); - expect(content).toContain("_make this italic_"); - }); + const content = await waitForSaveAndReadFromServer( + sbPage, + sbServer, + "Italic Test.md", + ); + expect(content).toContain("_make this italic_"); + }); - test("bullet list with Mod+Shift+8", async ({ sbPage, sbServer }) => { - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Welcome"); + test("bullet list with Mod+Shift+8", async ({ sbPage, sbServer }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Welcome"); - await sbPage.keyboard.press(`${mod}+k`); - await sbPage.locator(".sb-modal-box input.sb-input").click(); - await sbPage.keyboard.type("List Test", { delay: 30 }); - await sbPage.keyboard.press("Shift+Enter"); - await expect(editor).toHaveText(""); + await sbPage.keyboard.press(`${mod}+k`); + await sbPage.locator(".sb-modal-box input.sb-input").click(); + await sbPage.keyboard.type("List Test", { delay: 30 }); + await sbPage.keyboard.press("Shift+Enter"); + await expect(editor).toHaveText(""); - // Type multiple lines - await editor.click(); - await sbPage.keyboard.type("First item"); - await sbPage.keyboard.press("Enter"); - await sbPage.keyboard.type("Second item"); - await sbPage.keyboard.press("Enter"); - await sbPage.keyboard.type("Third item"); + // Type multiple lines + await editor.click(); + await sbPage.keyboard.type("First item"); + await sbPage.keyboard.press("Enter"); + await sbPage.keyboard.type("Second item"); + await sbPage.keyboard.press("Enter"); + await sbPage.keyboard.type("Third item"); - // Select all and make it a bullet list - await sbPage.keyboard.press(`${mod}+a`); - await sbPage.keyboard.press(`${mod}+Shift+8`); + // Select all and make it a bullet list + await sbPage.keyboard.press(`${mod}+a`); + await sbPage.keyboard.press(`${mod}+Shift+8`); - // Verify bullet markers - await expect(editor).toContainText("* First item"); - await expect(editor).toContainText("* Second item"); - await expect(editor).toContainText("* Third item"); + // Verify bullet markers + await expect(editor).toContainText("* First item"); + await expect(editor).toContainText("* Second item"); + await expect(editor).toContainText("* Third item"); - // Verify saved to server - const content = await waitForSaveAndReadFromServer(sbPage, sbServer, "List Test.md"); - expect(content).toContain("* First item"); - expect(content).toContain("* Second item"); - expect(content).toContain("* Third item"); - }); + // Verify saved to server + const content = await waitForSaveAndReadFromServer( + sbPage, + sbServer, + "List Test.md", + ); + expect(content).toContain("* First item"); + expect(content).toContain("* Second item"); + expect(content).toContain("* Third item"); + }); }); diff --git a/e2e/first-load.test.ts b/e2e/first-load.test.ts index a9d33eaf..3520fd0c 100644 --- a/e2e/first-load.test.ts +++ b/e2e/first-load.test.ts @@ -1,29 +1,40 @@ import { expect, test, waitForSaveAndReadFromServer } from "./fixtures.ts"; test.describe("First load on empty space", () => { - test("page loads and shows editor", async ({ sbPage }) => { - await expect(sbPage.locator("#sb-editor")).toBeVisible(); - await expect(sbPage.locator("#sb-editor .cm-editor")).toBeVisible(); - }); + test("page loads and shows editor", async ({ sbPage }) => { + await expect(sbPage.locator("#sb-editor")).toBeVisible(); + await expect(sbPage.locator("#sb-editor .cm-editor")).toBeVisible(); + }); - test("welcome page content appears", async ({ sbPage }) => { - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Welcome to the wondrous world of SilverBullet"); - }); + test("welcome page content appears", async ({ sbPage }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText( + "Welcome to the wondrous world of SilverBullet", + ); + }); - test("editor is editable and saves to server", async ({ sbPage, sbServer }) => { - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Welcome to the wondrous world of SilverBullet"); + test("editor is editable and saves to server", async ({ + sbPage, + sbServer, + }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText( + "Welcome to the wondrous world of SilverBullet", + ); - // Move to end of document, then type on a new line - await editor.click(); - await sbPage.keyboard.press("End"); - await sbPage.keyboard.press("Enter"); - await sbPage.keyboard.type("Hello from Playwright"); - await expect(editor).toContainText("Hello from Playwright"); + // Move to end of document, then type on a new line + await editor.click(); + await sbPage.keyboard.press("End"); + await sbPage.keyboard.press("Enter"); + await sbPage.keyboard.type("Hello from Playwright"); + await expect(editor).toContainText("Hello from Playwright"); - // Verify the edit was saved to the server - const content = await waitForSaveAndReadFromServer(sbPage, sbServer, "index.md"); - expect(content).toContain("Hello from Playwright"); - }); + // Verify the edit was saved to the server + const content = await waitForSaveAndReadFromServer( + sbPage, + sbServer, + "index.md", + ); + expect(content).toContain("Hello from Playwright"); + }); }); diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 8b9db7f3..39e1fb27 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -9,125 +9,130 @@ import { dirname, join } from "node:path"; export const mod = platform() === "darwin" ? "Meta" : "Control"; export type SBServer = { - url: string; - port: number; - spaceDir: string; - /** Stop the server process (simulates "server down"). */ - stop: () => Promise; + url: string; + port: number; + spaceDir: string; + /** Stop the server process (simulates "server down"). */ + stop: () => Promise; }; type SBFixtures = { - spaceFiles: Record; - disableServiceWorker: boolean; - sbServer: SBServer; - sbPage: Page; + spaceFiles: Record; + disableServiceWorker: boolean; + sbServer: SBServer; + sbPage: Page; }; export async function getFreePort(): Promise { - return new Promise((resolve, reject) => { - const srv = net.createServer(); - srv.listen(0, "127.0.0.1", () => { - const addr = srv.address() as net.AddressInfo; - srv.close(() => resolve(addr.port)); - }); - srv.on("error", reject); - }); + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address() as net.AddressInfo; + srv.close(() => resolve(addr.port)); + }); + srv.on("error", reject); + }); } -export async function waitForServer(url: string, timeoutMs = 30_000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const resp = await fetch(url); - if (resp.ok) return; - } catch { - // server not ready yet - } - await new Promise((r) => setTimeout(r, 200)); - } - throw new Error(`Server did not become ready at ${url} within ${timeoutMs}ms`); +export async function waitForServer( + url: string, + timeoutMs = 30_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const resp = await fetch(url); + if (resp.ok) return; + } catch { + // server not ready yet + } + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error( + `Server did not become ready at ${url} within ${timeoutMs}ms`, + ); } export const test = base.extend({ - spaceFiles: [{}, { option: true }], - disableServiceWorker: [true, { option: true }], + spaceFiles: [{}, { option: true }], + disableServiceWorker: [true, { option: true }], - sbServer: async ({ spaceFiles, disableServiceWorker }, use) => { - const spaceDir = await mkdtemp(join(tmpdir(), "sb-e2e-")); + sbServer: async ({ spaceFiles, disableServiceWorker }, use) => { + const spaceDir = await mkdtemp(join(tmpdir(), "sb-e2e-")); - // Seed space with files - for (const [path, content] of Object.entries(spaceFiles)) { - const fullPath = join(spaceDir, path); - await mkdir(dirname(fullPath), { recursive: true }); - await writeFile(fullPath, content); - } + // Seed space with files + for (const [path, content] of Object.entries(spaceFiles)) { + const fullPath = join(spaceDir, path); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, content); + } - const port = await getFreePort(); + const port = await getFreePort(); - const proc: ChildProcess = spawn( - "./target/debug/silverbullet", - [spaceDir, "-p", String(port), "-L", "127.0.0.1"], - { - cwd: join(import.meta.dirname, ".."), - stdio: ["ignore", "pipe", "pipe"], - env: { - ...process.env, - // Disable the server-side headless-Chrome runtime API: in - // `?headless=1` the client uses its own in-page runtime, so the - // e2e servers never need to spawn Chrome (and don't require it). - SB_RUNTIME_API: "0", - ...(disableServiceWorker - ? { SB_DISABLE_SERVICE_WORKER: "1" } - : {}), - }, - }, - ); + const proc: ChildProcess = spawn( + "./target/debug/silverbullet", + [spaceDir, "-p", String(port), "-L", "127.0.0.1"], + { + cwd: join(import.meta.dirname, ".."), + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + // Disable the server-side headless-Chrome runtime API: in + // `?headless=1` the client uses its own in-page runtime, so the + // e2e servers never need to spawn Chrome (and don't require it). + SB_RUNTIME_API: "0", + ...(disableServiceWorker ? { SB_DISABLE_SERVICE_WORKER: "1" } : {}), + }, + }, + ); - let serverOutput = ""; - proc.stdout?.on("data", (d: Buffer) => { - serverOutput += d.toString(); - }); - proc.stderr?.on("data", (d: Buffer) => { - serverOutput += d.toString(); - }); + let serverOutput = ""; + proc.stdout?.on("data", (d: Buffer) => { + serverOutput += d.toString(); + }); + proc.stderr?.on("data", (d: Buffer) => { + serverOutput += d.toString(); + }); - const url = `http://127.0.0.1:${port}`; + const url = `http://127.0.0.1:${port}`; - try { - await waitForServer(`${url}/.ping`); - } catch (err) { - proc.kill("SIGKILL"); - throw new Error(`Server failed to start. Output:\n${serverOutput}\n${err}`); - } + try { + await waitForServer(`${url}/.ping`); + } catch (err) { + proc.kill("SIGKILL"); + throw new Error( + `Server failed to start. Output:\n${serverOutput}\n${err}`, + ); + } - let stopped = false; - const stop = (): Promise => { - if (stopped) return Promise.resolve(); - stopped = true; - return new Promise((resolve) => { - const timer = setTimeout(() => { - proc.kill("SIGKILL"); - resolve(); - }, 5000); - proc.on("exit", () => { - clearTimeout(timer); - resolve(); - }); - proc.kill("SIGTERM"); - }); - }; + let stopped = false; + const stop = (): Promise => { + if (stopped) return Promise.resolve(); + stopped = true; + return new Promise((resolve) => { + const timer = setTimeout(() => { + proc.kill("SIGKILL"); + resolve(); + }, 5000); + proc.on("exit", () => { + clearTimeout(timer); + resolve(); + }); + proc.kill("SIGTERM"); + }); + }; - await use({ url, port, spaceDir, stop }); + await use({ url, port, spaceDir, stop }); - // Cleanup - await stop(); - await rm(spaceDir, { recursive: true, force: true }); - }, + // Cleanup + await stop(); + await rm(spaceDir, { recursive: true, force: true }); + }, - sbPage: async ({ sbServer, page }, use) => { - await gotoSilverBulletPage(page, sbServer); - await use(page); - }, + sbPage: async ({ sbServer, page }, use) => { + await gotoSilverBulletPage(page, sbServer); + await use(page); + }, }); /** @@ -140,14 +145,16 @@ export const test = base.extend({ * is URL-encoded so names with spaces or other special characters work. */ export async function gotoSilverBulletPage( - page: Page, - sbServer: SBServer, - pagePath = "", + page: Page, + sbServer: SBServer, + pagePath = "", ): Promise { - const encoded = pagePath.split("/").map(encodeURIComponent).join("/"); - await page.goto(`${sbServer.url}/${encoded}?headless=1`); - await page.locator("#sb-editor .cm-editor").waitFor({ state: "visible", timeout: 30_000 }); - await waitForEditorReady(page); + const encoded = pagePath.split("/").map(encodeURIComponent).join("/"); + await page.goto(`${sbServer.url}/${encoded}?headless=1`); + await page + .locator("#sb-editor .cm-editor") + .waitFor({ state: "visible", timeout: 30_000 }); + await waitForEditorReady(page); } /** @@ -155,33 +162,37 @@ export async function gotoSilverBulletPage( * the page content from the server's filesystem API. */ export async function waitForSaveAndReadFromServer( - page: Page, - sbServer: SBServer, - pagePath: string, + page: Page, + sbServer: SBServer, + pagePath: string, ): Promise { - const pageNameSel = "#sb-current-page"; - // Observe `sb-unsaved` first so we're past the edits the caller just made. - await page.locator(`${pageNameSel}.sb-unsaved`).waitFor({ state: "attached", timeout: 10_000 }); - // Then wait for the round-trip back to `sb-saved` — this fires only after - // `space.writePage(...)` resolves, which in HTTP mode means after the - // server's PUT response. - await page.locator(`${pageNameSel}.sb-saved`).waitFor({ state: "attached", timeout: 10_000 }); - const resp = await fetch(`${sbServer.url}/.fs/${pagePath}`); - if (!resp.ok) { - throw new Error(`Failed to read ${pagePath} from server: ${resp.status}`); - } - return resp.text(); + const pageNameSel = "#sb-current-page"; + // Observe `sb-unsaved` first so we're past the edits the caller just made. + await page + .locator(`${pageNameSel}.sb-unsaved`) + .waitFor({ state: "attached", timeout: 10_000 }); + // Then wait for the round-trip back to `sb-saved` — this fires only after + // `space.writePage(...)` resolves, which in HTTP mode means after the + // server's PUT response. + await page + .locator(`${pageNameSel}.sb-saved`) + .waitFor({ state: "attached", timeout: 10_000 }); + const resp = await fetch(`${sbServer.url}/.fs/${pagePath}`); + if (!resp.ok) { + throw new Error(`Failed to read ${pagePath} from server: ${resp.status}`); + } + return resp.text(); } /** * Wait for the SilverBullet client to finish booting widgets. */ export async function waitForEditorReady(page: Page): Promise { - await page.waitForFunction( - () => (globalThis as any).sbRuntime?.ready === true, - undefined, - { timeout: 15_000 }, - ); + await page.waitForFunction( + () => (globalThis as any).sbRuntime?.ready === true, + undefined, + { timeout: 15_000 }, + ); } export { expect } from "@playwright/test"; diff --git a/e2e/guide-journaling.test.ts b/e2e/guide-journaling.test.ts index 226b07fe..fd6741f8 100644 --- a/e2e/guide-journaling.test.ts +++ b/e2e/guide-journaling.test.ts @@ -7,11 +7,11 @@ import { expect, mod, test, waitForSaveAndReadFromServer } from "./fixtures.ts"; /** Today's date as YYYY-MM-DD, matching the default journal page name. */ function today(): string { - const d = new Date(); - const yyyy = d.getFullYear(); - const mm = String(d.getMonth() + 1).padStart(2, "0"); - const dd = String(d.getDate()).padStart(2, "0"); - return `${yyyy}-${mm}-${dd}`; + const d = new Date(); + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}`; } /** @@ -20,93 +20,108 @@ function today(): string { * "Journal: Today" command via the command palette. */ async function runJournalToday(sbPage: Page): Promise { - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Welcome"); + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Welcome"); - await sbPage.keyboard.press(`${mod}+/`); - const modal = sbPage.locator(".sb-modal-box"); - await expect(modal).toBeVisible(); + await sbPage.keyboard.press(`${mod}+/`); + const modal = sbPage.locator(".sb-modal-box"); + await expect(modal).toBeVisible(); - const paletteInput = modal.locator("input.sb-input"); - await paletteInput.click(); - await sbPage.keyboard.type("Journal: Today", { delay: 30 }); + const paletteInput = modal.locator("input.sb-input"); + await paletteInput.click(); + await sbPage.keyboard.type("Journal: Today", { delay: 30 }); - // The command defined by the Journal template should appear - await expect( - modal.locator(".sb-option .sb-name", { hasText: "Journal: Today" }), - ).toBeVisible(); + // The command defined by the Journal template should appear + await expect( + modal.locator(".sb-option .sb-name", { hasText: "Journal: Today" }), + ).toBeVisible(); - await sbPage.keyboard.press("Enter"); - await expect(modal).not.toBeVisible(); + await sbPage.keyboard.press("Enter"); + await expect(modal).not.toBeVisible(); - const expectedPage = `Journal/${today()}`; - await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue(expectedPage); + const expectedPage = `Journal/${today()}`; + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue( + expectedPage, + ); } test.describe("Guide: Journaling", () => { - test.use({ - spaceFiles: { - "index.md": "# Welcome\nA fresh space, ready to journal.", - }, - }); + test.use({ + spaceFiles: { + "index.md": "# Welcome\nA fresh space, ready to journal.", + }, + }); - test("running 'Journal: Today' from the command palette creates today's journal page", async ({ sbPage }) => { - await runJournalToday(sbPage); + test("running 'Journal: Today' from the command palette creates today's journal page", async ({ + sbPage, + }) => { + await runJournalToday(sbPage); - // The editor should show the template body — frontmatter sets - // `tags: journal` (from the built-in template at - // Library/Std/Journal/Template). - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("tags: journal"); - }); + // The editor should show the template body — frontmatter sets + // `tags: journal` (from the built-in template at + // Library/Std/Journal/Template). + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("tags: journal"); + }); - test("created journal page is tagged journal on disk", async ({ sbPage, sbServer }) => { - await runJournalToday(sbPage); + test("created journal page is tagged journal on disk", async ({ + sbPage, + sbServer, + }) => { + await runJournalToday(sbPage); - // The file on disk must contain the `journal` tag from the template - // frontmatter, so queries over `index.tag("journal")` (used by the - // built-in `Journal: Previous Day` / `Next Day` commands and the - // default index-page section) return this entry. - const expectedPage = `Journal/${today()}`; - const content = await waitForSaveAndReadFromServer( - sbPage, - sbServer, - `${expectedPage}.md`, - ); - expect(content).toContain("tags: journal"); - }); + // The file on disk must contain the `journal` tag from the template + // frontmatter, so queries over `index.tag("journal")` (used by the + // built-in `Journal: Previous Day` / `Next Day` commands and the + // default index-page section) return this entry. + const expectedPage = `Journal/${today()}`; + const content = await waitForSaveAndReadFromServer( + sbPage, + sbServer, + `${expectedPage}.md`, + ); + expect(content).toContain("tags: journal"); + }); - test("new journal entry mentioning a topic shows up under that topic's Linked Mentions", async ({ sbPage, sbServer }) => { - await runJournalToday(sbPage); + test("new journal entry mentioning a topic shows up under that topic's Linked Mentions", async ({ + sbPage, + sbServer, + }) => { + await runJournalToday(sbPage); - // Type a journal entry that links to [[Alice]] — this is the - // "watch topic pages come alive" flow from the guide. - const editor = sbPage.locator("#sb-editor .cm-content"); - await editor.click(); - // The template places the cursor at the first bullet's content. - await sbPage.keyboard.type("Reviewed the Q2 roadmap with [[Alice]]", { delay: 20 }); + // Type a journal entry that links to [[Alice]] — this is the + // "watch topic pages come alive" flow from the guide. + const editor = sbPage.locator("#sb-editor .cm-content"); + await editor.click(); + // The template places the cursor at the first bullet's content. + await sbPage.keyboard.type("Reviewed the Q2 roadmap with [[Alice]]", { + delay: 20, + }); - // Wait for the journal page to save - const expectedPage = `Journal/${today()}`; - const journalContent = await waitForSaveAndReadFromServer( - sbPage, - sbServer, - `${expectedPage}.md`, - ); - expect(journalContent).toContain("[[Alice]]"); + // Wait for the journal page to save + const expectedPage = `Journal/${today()}`; + const journalContent = await waitForSaveAndReadFromServer( + sbPage, + sbServer, + `${expectedPage}.md`, + ); + expect(journalContent).toContain("[[Alice]]"); - // Navigate to Alice's page via the wiki link - const wikiLinkText = editor.locator(".sb-wiki-link", { hasText: "Alice" }); - await expect(wikiLinkText).toBeVisible({ timeout: 10_000 }); - await wikiLinkText.click(); + // Navigate to Alice's page via the wiki link + const wikiLinkText = editor.locator(".sb-wiki-link", { hasText: "Alice" }); + await expect(wikiLinkText).toBeVisible({ timeout: 10_000 }); + await wikiLinkText.click(); - await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue("Alice"); + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue( + "Alice", + ); - // Alice's page should show a Linked Mentions section with the journal - // entry we just wrote. The widget is rendered by a built-in script. - const aliceEditor = sbPage.locator("#sb-editor .cm-content"); - await expect(aliceEditor).toContainText("Linked Mentions", { timeout: 15_000 }); - await expect(aliceEditor).toContainText("Reviewed the Q2 roadmap"); - }); + // Alice's page should show a Linked Mentions section with the journal + // entry we just wrote. The widget is rendered by a built-in script. + const aliceEditor = sbPage.locator("#sb-editor .cm-content"); + await expect(aliceEditor).toContainText("Linked Mentions", { + timeout: 15_000, + }); + await expect(aliceEditor).toContainText("Reviewed the Q2 roadmap"); + }); }); - diff --git a/e2e/guide-knowledge-base.test.ts b/e2e/guide-knowledge-base.test.ts index f49cf82b..ad4298ac 100644 --- a/e2e/guide-knowledge-base.test.ts +++ b/e2e/guide-knowledge-base.test.ts @@ -1,10 +1,10 @@ import { - expect, - gotoSilverBulletPage, - mod, - test, - waitForEditorReady, - waitForSaveAndReadFromServer, + expect, + gotoSilverBulletPage, + mod, + test, + waitForEditorReady, + waitForSaveAndReadFromServer, } from "./fixtures.ts"; // This file exercises the workflow described in docs/Guide/Knowledge Base.md @@ -29,82 +29,104 @@ const CURRENTLY_READING = `# Currently Reading `; test.describe("Guide: Knowledge Base", () => { - test.use({ - spaceFiles: { - "index.md": "# Welcome\nThis is the index.", - "Rust.md": - "# Rust\nA systems language. Rust's [[Ownership]] model prevents data races at compile time.\n", - "Ownership.md": - "# Ownership\nOwnership is Rust's compile-time memory safety mechanism.\n", - "Invisible Cities.md": - "---\ntags: book\nauthor: Italo Calvino\nstatus: reading\n---\nA short novel of imagined cities.\n", - "Currently Reading.md": CURRENTLY_READING, - }, - }); + test.use({ + spaceFiles: { + "index.md": "# Welcome\nThis is the index.", + "Rust.md": + "# Rust\nA systems language. Rust's [[Ownership]] model prevents data races at compile time.\n", + "Ownership.md": + "# Ownership\nOwnership is Rust's compile-time memory safety mechanism.\n", + "Invisible Cities.md": + "---\ntags: book\nauthor: Italo Calvino\nstatus: reading\n---\nA short novel of imagined cities.\n", + "Currently Reading.md": CURRENTLY_READING, + }, + }); - test("clicking a wiki link navigates to the linked page", async ({ sbPage, sbServer }) => { - await gotoSilverBulletPage(sbPage, sbServer, "Rust"); - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Rust"); + test("clicking a wiki link navigates to the linked page", async ({ + sbPage, + sbServer, + }) => { + await gotoSilverBulletPage(sbPage, sbServer, "Rust"); + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Rust"); - const ownershipLink = editor.locator(".sb-wiki-link", { hasText: "Ownership" }); - await expect(ownershipLink).toBeVisible({ timeout: 10_000 }); - await ownershipLink.click(); + const ownershipLink = editor.locator(".sb-wiki-link", { + hasText: "Ownership", + }); + await expect(ownershipLink).toBeVisible({ timeout: 10_000 }); + await ownershipLink.click(); - await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue("Ownership"); - await expect(editor).toContainText("compile-time memory safety"); - }); + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue( + "Ownership", + ); + await expect(editor).toContainText("compile-time memory safety"); + }); - test("Ownership page surfaces Rust as a Linked Mention", async ({ sbPage, sbServer }) => { - await gotoSilverBulletPage(sbPage, sbServer, "Ownership"); - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Ownership"); + test("Ownership page surfaces Rust as a Linked Mention", async ({ + sbPage, + sbServer, + }) => { + await gotoSilverBulletPage(sbPage, sbServer, "Ownership"); + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Ownership"); - // The Linked Mentions section should be visible and show the Rust page - // (because Rust.md links to [[Ownership]]). - await expect(editor).toContainText("Linked Mentions", { timeout: 15_000 }); - await expect(editor).toContainText("Rust"); - }); + // The Linked Mentions section should be visible and show the Rust page + // (because Rust.md links to [[Ownership]]). + await expect(editor).toContainText("Linked Mentions", { timeout: 15_000 }); + await expect(editor).toContainText("Rust"); + }); - test("hashtag at the top of a body tags the page", async ({ sbPage, sbServer }) => { - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Welcome"); + test("hashtag at the top of a body tags the page", async ({ + sbPage, + sbServer, + }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Welcome"); - // Create a fresh "Atomic Notes" page via the page picker - await sbPage.keyboard.press(`${mod}+k`); - const modal = sbPage.locator(".sb-modal-box"); - await expect(modal).toBeVisible(); - const pickerInput = modal.locator("input.sb-input"); - await pickerInput.click(); - await sbPage.keyboard.type("Atomic Notes", { delay: 30 }); - await sbPage.keyboard.press("Shift+Enter"); - await expect(modal).not.toBeVisible(); - await expect(editor).toHaveText(""); + // Create a fresh "Atomic Notes" page via the page picker + await sbPage.keyboard.press(`${mod}+k`); + const modal = sbPage.locator(".sb-modal-box"); + await expect(modal).toBeVisible(); + const pickerInput = modal.locator("input.sb-input"); + await pickerInput.click(); + await sbPage.keyboard.type("Atomic Notes", { delay: 30 }); + await sbPage.keyboard.press("Shift+Enter"); + await expect(modal).not.toBeVisible(); + await expect(editor).toHaveText(""); - // Wait for the editor to finish loading and any pageLoaded handlers - // to settle before typing. Otherwise CodeMirror reconfigures mid-type - // and the cursor jumps back to position 0, splitting input. - await waitForEditorReady(sbPage); - await editor.click(); - await sbPage.keyboard.type("#concept", { delay: 20 }); - await sbPage.keyboard.press("Escape"); - await sbPage.keyboard.press("End"); - await sbPage.keyboard.press("Enter"); - await sbPage.keyboard.press("Enter"); - await sbPage.keyboard.type("Atomic notes are focused on a single idea.", { delay: 20 }); + // Wait for the editor to finish loading and any pageLoaded handlers + // to settle before typing. Otherwise CodeMirror reconfigures mid-type + // and the cursor jumps back to position 0, splitting input. + await waitForEditorReady(sbPage); + await editor.click(); + await sbPage.keyboard.type("#concept", { delay: 20 }); + await sbPage.keyboard.press("Escape"); + await sbPage.keyboard.press("End"); + await sbPage.keyboard.press("Enter"); + await sbPage.keyboard.press("Enter"); + await sbPage.keyboard.type("Atomic notes are focused on a single idea.", { + delay: 20, + }); - // Persist and verify the file contains the body hashtag - const content = await waitForSaveAndReadFromServer(sbPage, sbServer, "Atomic Notes.md"); - expect(content).toContain("#concept"); - expect(content).toContain("Atomic notes are focused"); - }); + // Persist and verify the file contains the body hashtag + const content = await waitForSaveAndReadFromServer( + sbPage, + sbServer, + "Atomic Notes.md", + ); + expect(content).toContain("#concept"); + expect(content).toContain("Atomic notes are focused"); + }); - test("query over a frontmatter tag returns matching pages", async ({ sbPage, sbServer }) => { - await gotoSilverBulletPage(sbPage, sbServer, "Currently Reading"); - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Currently Reading"); + test("query over a frontmatter tag returns matching pages", async ({ + sbPage, + sbServer, + }) => { + await gotoSilverBulletPage(sbPage, sbServer, "Currently Reading"); + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Currently Reading"); - // The query widget should render Invisible Cities as a result - await expect(editor).toContainText("Invisible Cities", { timeout: 20_000 }); - }); + // The query widget should render Invisible Cities as a result + await expect(editor).toContainText("Invisible Cities", { timeout: 20_000 }); + }); }); diff --git a/e2e/guide-people-notes.test.ts b/e2e/guide-people-notes.test.ts index bba4396f..baddca68 100644 --- a/e2e/guide-people-notes.test.ts +++ b/e2e/guide-people-notes.test.ts @@ -40,45 +40,54 @@ const PEOPLE_PAGE = `# People `; test.describe("Guide: People Notes", () => { - test.use({ - spaceFiles: { - "index.md": "# Welcome\nThis is the index.", - "Alice.md": ALICE, - "Bob.md": BOB, - "Meeting/2026-03-04.md": MEETING_NOTE, - "People.md": PEOPLE_PAGE, - }, - }); + test.use({ + spaceFiles: { + "index.md": "# Welcome\nThis is the index.", + "Alice.md": ALICE, + "Bob.md": BOB, + "Meeting/2026-03-04.md": MEETING_NOTE, + "People.md": PEOPLE_PAGE, + }, + }); - test("person page surfaces meeting note as a Linked Mention", async ({ sbPage, sbServer }) => { - await gotoSilverBulletPage(sbPage, sbServer, "Alice"); - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Alice"); + test("person page surfaces meeting note as a Linked Mention", async ({ + sbPage, + sbServer, + }) => { + await gotoSilverBulletPage(sbPage, sbServer, "Alice"); + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Alice"); - // Linked Mentions section appears at the bottom and includes the meeting note - await expect(editor).toContainText("Linked Mentions", { timeout: 15_000 }); - await expect(editor).toContainText("Met with"); - }); + // Linked Mentions section appears at the bottom and includes the meeting note + await expect(editor).toContainText("Linked Mentions", { timeout: 15_000 }); + await expect(editor).toContainText("Met with"); + }); - test("person page surfaces incomplete tasks as Linked Tasks", async ({ sbPage, sbServer }) => { - await gotoSilverBulletPage(sbPage, sbServer, "Alice"); - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Alice"); + test("person page surfaces incomplete tasks as Linked Tasks", async ({ + sbPage, + sbServer, + }) => { + await gotoSilverBulletPage(sbPage, sbServer, "Alice"); + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Alice"); - // The Linked Tasks widget appears at the top of the page and shows - // "Send proposal to Alice" — an incomplete task from the meeting note - // that mentions [[Alice]]. - await expect(editor).toContainText("Linked Tasks", { timeout: 15_000 }); - await expect(editor).toContainText("Send proposal to"); - }); + // The Linked Tasks widget appears at the top of the page and shows + // "Send proposal to Alice" — an incomplete task from the meeting note + // that mentions [[Alice]]. + await expect(editor).toContainText("Linked Tasks", { timeout: 15_000 }); + await expect(editor).toContainText("Send proposal to"); + }); - test("query over tags.person returns all person pages", async ({ sbPage, sbServer }) => { - await gotoSilverBulletPage(sbPage, sbServer, "People"); - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("People"); + test("query over tags.person returns all person pages", async ({ + sbPage, + sbServer, + }) => { + await gotoSilverBulletPage(sbPage, sbServer, "People"); + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("People"); - // The query widget should render Alice and Bob as result items - await expect(editor).toContainText("Alice", { timeout: 20_000 }); - await expect(editor).toContainText("Bob"); - }); + // The query widget should render Alice and Bob as result items + await expect(editor).toContainText("Alice", { timeout: 20_000 }); + await expect(editor).toContainText("Bob"); + }); }); diff --git a/e2e/guide-task-management.test.ts b/e2e/guide-task-management.test.ts index 36ba69ef..2989086d 100644 --- a/e2e/guide-task-management.test.ts +++ b/e2e/guide-task-management.test.ts @@ -1,8 +1,8 @@ import { - expect, - gotoSilverBulletPage, - test, - waitForSaveAndReadFromServer, + expect, + gotoSilverBulletPage, + test, + waitForSaveAndReadFromServer, } from "./fixtures.ts"; // This file exercises the workflow described in docs/Guide/Task Management.md @@ -49,64 +49,79 @@ const DASHBOARD = `# Dashboard `; test.describe("Guide: Task Management", () => { - test.use({ - spaceFiles: { - "index.md": "# Welcome\nThis is the index.", - "Website Redesign.md": WEBSITE_REDESIGN, - "Meeting Notes/2026-03-04.md": MEETING_NOTE, - "Dashboard.md": DASHBOARD, - }, - }); + test.use({ + spaceFiles: { + "index.md": "# Welcome\nThis is the index.", + "Website Redesign.md": WEBSITE_REDESIGN, + "Meeting Notes/2026-03-04.md": MEETING_NOTE, + "Dashboard.md": DASHBOARD, + }, + }); - test("project page renders task checkboxes", async ({ sbPage, sbServer }) => { - await gotoSilverBulletPage(sbPage, sbServer, "Website Redesign"); - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Website Redesign"); + test("project page renders task checkboxes", async ({ sbPage, sbServer }) => { + await gotoSilverBulletPage(sbPage, sbServer, "Website Redesign"); + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Website Redesign"); - const checkboxes = sbPage.locator( - "#sb-editor .cm-content .sb-checkbox input[type='checkbox']", - ); - await expect(checkboxes.first()).toBeVisible({ timeout: 10_000 }); - // Three native tasks; linked tasks may add more, so just assert >= 3 - const count = await checkboxes.count(); - expect(count).toBeGreaterThanOrEqual(3); - }); + const checkboxes = sbPage.locator( + "#sb-editor .cm-content .sb-checkbox input[type='checkbox']", + ); + await expect(checkboxes.first()).toBeVisible({ timeout: 10_000 }); + // Three native tasks; linked tasks may add more, so just assert >= 3 + const count = await checkboxes.count(); + expect(count).toBeGreaterThanOrEqual(3); + }); - test("project page surfaces tasks scattered across other pages as Linked Tasks", async ({ sbPage, sbServer }) => { - await gotoSilverBulletPage(sbPage, sbServer, "Website Redesign"); - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Website Redesign"); + test("project page surfaces tasks scattered across other pages as Linked Tasks", async ({ + sbPage, + sbServer, + }) => { + await gotoSilverBulletPage(sbPage, sbServer, "Website Redesign"); + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Website Redesign"); - await expect(editor).toContainText("Linked Tasks", { timeout: 15_000 }); - // Tasks from Meeting Notes/2026-03-04 that link to [[Website Redesign]] - await expect(editor).toContainText("Send updated mockups"); - await expect(editor).toContainText("Schedule review meeting"); - }); + await expect(editor).toContainText("Linked Tasks", { timeout: 15_000 }); + // Tasks from Meeting Notes/2026-03-04 that link to [[Website Redesign]] + await expect(editor).toContainText("Send updated mockups"); + await expect(editor).toContainText("Schedule review meeting"); + }); - test("toggling a task on the project page persists to disk", async ({ sbPage, sbServer }) => { - await gotoSilverBulletPage(sbPage, sbServer, "Website Redesign"); - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Write the project proposal"); + test("toggling a task on the project page persists to disk", async ({ + sbPage, + sbServer, + }) => { + await gotoSilverBulletPage(sbPage, sbServer, "Website Redesign"); + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Write the project proposal"); - // Click the first checkbox in the editor body - const firstCheckbox = editor.locator(".sb-checkbox input[type='checkbox']").first(); - await expect(firstCheckbox).toBeVisible({ timeout: 10_000 }); - await expect(firstCheckbox).not.toBeChecked(); - await firstCheckbox.click(); - await expect(firstCheckbox).toBeChecked(); + // Click the first checkbox in the editor body + const firstCheckbox = editor + .locator(".sb-checkbox input[type='checkbox']") + .first(); + await expect(firstCheckbox).toBeVisible({ timeout: 10_000 }); + await expect(firstCheckbox).not.toBeChecked(); + await firstCheckbox.click(); + await expect(firstCheckbox).toBeChecked(); - const content = await waitForSaveAndReadFromServer(sbPage, sbServer, "Website Redesign.md"); - expect(content).toMatch(/\* \[x\] Write the project proposal/); - }); + const content = await waitForSaveAndReadFromServer( + sbPage, + sbServer, + "Website Redesign.md", + ); + expect(content).toMatch(/\* \[x\] Write the project proposal/); + }); - test("dashboard query renders active projects and open tasks", async ({ sbPage, sbServer }) => { - await gotoSilverBulletPage(sbPage, sbServer, "Dashboard"); - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Dashboard"); + test("dashboard query renders active projects and open tasks", async ({ + sbPage, + sbServer, + }) => { + await gotoSilverBulletPage(sbPage, sbServer, "Dashboard"); + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Dashboard"); - // "Active projects" section should list Website Redesign - await expect(editor).toContainText("Website Redesign", { timeout: 20_000 }); - // "Open tasks" section should include at least one of our seeded tasks - await expect(editor).toContainText("Write the project proposal"); - }); + // "Active projects" section should list Website Redesign + await expect(editor).toContainText("Website Redesign", { timeout: 20_000 }); + // "Open tasks" section should include at least one of our seeded tasks + await expect(editor).toContainText("Write the project proposal"); + }); }); diff --git a/e2e/index-upgrade.test.ts b/e2e/index-upgrade.test.ts index b5e6ce7b..dc47b8d0 100644 --- a/e2e/index-upgrade.test.ts +++ b/e2e/index-upgrade.test.ts @@ -16,51 +16,52 @@ import { expect, gotoSilverBulletPage, test } from "./fixtures.ts"; */ async function readIndexVersion(page: Page): Promise { - return await page.evaluate(async () => { - const client = (globalThis as any).client; - if (!client) return undefined; - return (await client.ds.get(["$indexVersion"])) as number | undefined; - }); + return await page.evaluate(async () => { + const client = (globalThis as any).client; + if (!client) return undefined; + return (await client.ds.get(["$indexVersion"])) as number | undefined; + }); } async function writeIndexVersion(page: Page, version: number): Promise { - await page.evaluate( - async (v) => { - const client = (globalThis as any).client; - if (!client) throw new Error("client not initialized"); - await client.ds.set(["$indexVersion"], v); - }, - version, - ); + await page.evaluate(async (v) => { + const client = (globalThis as any).client; + if (!client) throw new Error("client not initialized"); + await client.ds.set(["$indexVersion"], v); + }, version); } async function waitForIndexVersionAtLeast( - page: Page, - min: number, - timeoutMs = 30_000, + page: Page, + min: number, + timeoutMs = 30_000, ): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const v = await readIndexVersion(page); - if (typeof v === "number" && v >= min) return; - await new Promise((r) => setTimeout(r, 250)); - } - const final = await readIndexVersion(page); - throw new Error( - `Index version did not reach ${min} within ${timeoutMs}ms (last value: ${String(final)})`, - ); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const v = await readIndexVersion(page); + if (typeof v === "number" && v >= min) return; + await new Promise((r) => setTimeout(r, 250)); + } + const final = await readIndexVersion(page); + throw new Error( + `Index version did not reach ${min} within ${timeoutMs}ms (last value: ${String(final)})`, + ); } async function waitForEditor(page: Page): Promise { - await page.locator("#sb-editor .cm-editor").waitFor({ state: "visible", timeout: 30_000 }); + await page + .locator("#sb-editor .cm-editor") + .waitFor({ state: "visible", timeout: 30_000 }); } -async function readFullIndexCompleted(page: Page): Promise { - return await page.evaluate(() => { - const client = (globalThis as any).client; - if (!client) return undefined; - return client.fullIndexCompleted as boolean; - }); +async function readFullIndexCompleted( + page: Page, +): Promise { + return await page.evaluate(() => { + const client = (globalThis as any).client; + if (!client) return undefined; + return client.fullIndexCompleted as boolean; + }); } /** @@ -71,35 +72,38 @@ async function readFullIndexCompleted(page: Page): Promise * same set so a stale-version reload can prove the widgets don't drop * back into loading mode. */ -async function waitForWidgetsReady(page: Page, timeoutMs = 15_000): Promise { - const deadline = Date.now() + timeoutMs; - let last: unknown ; - while (Date.now() < deadline) { - last = await page.evaluate(() => { - const client = (globalThis as any).client; - if (!client) return null; - return { - systemReady: !!client.systemReady, - scriptsLoaded: !!client.clientSystem?.scriptsLoaded, - fullIndexCompleted: !!client.fullIndexCompleted, - pageListLoaded: !!client.pageListLoaded, - }; - }); - if ( - last && - typeof last === "object" && - (last as Record).systemReady && - (last as Record).scriptsLoaded && - (last as Record).fullIndexCompleted && - (last as Record).pageListLoaded - ) { - return; - } - await new Promise((r) => setTimeout(r, 100)); - } - throw new Error( - `Widgets did not reach the ready state within ${timeoutMs}ms (last state: ${JSON.stringify(last)})`, - ); +async function waitForWidgetsReady( + page: Page, + timeoutMs = 15_000, +): Promise { + const deadline = Date.now() + timeoutMs; + let last: unknown; + while (Date.now() < deadline) { + last = await page.evaluate(() => { + const client = (globalThis as any).client; + if (!client) return null; + return { + systemReady: !!client.systemReady, + scriptsLoaded: !!client.clientSystem?.scriptsLoaded, + fullIndexCompleted: !!client.fullIndexCompleted, + pageListLoaded: !!client.pageListLoaded, + }; + }); + if ( + last && + typeof last === "object" && + (last as Record).systemReady && + (last as Record).scriptsLoaded && + (last as Record).fullIndexCompleted && + (last as Record).pageListLoaded + ) { + return; + } + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error( + `Widgets did not reach the ready state within ${timeoutMs}ms (last state: ${JSON.stringify(last)})`, + ); } /** @@ -108,148 +112,159 @@ async function waitForWidgetsReady(page: Page, timeoutMs = 15_000): Promise { - messages.push(msg.text()); - }); - return { messages }; + const messages: string[] = []; + page.on("console", (msg) => { + messages.push(msg.text()); + }); + return { messages }; } const REINDEX_LOG = "Performing a full space reindex"; async function waitForReindexLog( - consoleState: { messages: string[] }, - timeoutMs = 30_000, + consoleState: { messages: string[] }, + timeoutMs = 30_000, ): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (consoleState.messages.some((m) => m.includes(REINDEX_LOG))) return; - await new Promise((r) => setTimeout(r, 100)); - } - const tail = consoleState.messages.slice(-50).join("\n "); - throw new Error( - `Did not observe a "${REINDEX_LOG}" log line within ${timeoutMs}ms.\nLast console output:\n ${tail}`, - ); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (consoleState.messages.some((m) => m.includes(REINDEX_LOG))) return; + await new Promise((r) => setTimeout(r, 100)); + } + const tail = consoleState.messages.slice(-50).join("\n "); + throw new Error( + `Did not observe a "${REINDEX_LOG}" log line within ${timeoutMs}ms.\nLast console output:\n ${tail}`, + ); } /** Marker we embed in a `space-style` block to look for in the DOM. */ const SPACE_STYLE_MARKER = "stale-reindex-marker-color: rebeccapurple"; async function customStylesContent(page: Page): Promise { - return (await page.locator("#custom-styles").innerHTML()) ?? ""; + return (await page.locator("#custom-styles").innerHTML()) ?? ""; } test.describe("index version upgrade (service worker disabled)", () => { - test.use({ - spaceFiles: { - "index.md": "# Index\nFirst page in the upgrade-test space.", - "Other.md": "# Other\nSee [[index]] for the entry point.", - "Styles.md": - `# Styles\n\n\`\`\`space-style\n/* ${SPACE_STYLE_MARKER} */\n\`\`\`\n`, - }, - }); + test.use({ + spaceFiles: { + "index.md": "# Index\nFirst page in the upgrade-test space.", + "Other.md": "# Other\nSee [[index]] for the entry point.", + "Styles.md": `# Styles\n\n\`\`\`space-style\n/* ${SPACE_STYLE_MARKER} */\n\`\`\`\n`, + }, + }); - test("stale index version triggers a real reindex on next boot", async ({ sbServer, page }) => { - const consoleState = captureConsole(page); + test("stale index version triggers a real reindex on next boot", async ({ + sbServer, + page, + }) => { + const consoleState = captureConsole(page); - // First boot: let the initial indexing run to completion so we know - // what value the current build considers "desired". - await gotoSilverBulletPage(page, sbServer); - await waitForEditor(page); - await waitForIndexVersionAtLeast(page, 1); + // First boot: let the initial indexing run to completion so we know + // what value the current build considers "desired". + await gotoSilverBulletPage(page, sbServer); + await waitForEditor(page); + await waitForIndexVersionAtLeast(page, 1); - const baseline = await readIndexVersion(page); - expect(typeof baseline).toBe("number"); - expect(baseline).toBeGreaterThan(0); - // First boot is a fresh install (no stored version), so there - // should be NO "Performing a full space reindex" log line yet. - expect(consoleState.messages.some((m) => m.includes(REINDEX_LOG))).toBe(false); + const baseline = await readIndexVersion(page); + expect(typeof baseline).toBe("number"); + expect(baseline).toBeGreaterThan(0); + // First boot is a fresh install (no stored version), so there + // should be NO "Performing a full space reindex" log line yet. + expect(consoleState.messages.some((m) => m.includes(REINDEX_LOG))).toBe( + false, + ); - // Simulate "this client was previously running an older build that - // stamped an older desiredIndexVersion into the datastore". - await writeIndexVersion(page, 1); - expect(await readIndexVersion(page)).toBe(1); + // Simulate "this client was previously running an older build that + // stamped an older desiredIndexVersion into the datastore". + await writeIndexVersion(page, 1); + expect(await readIndexVersion(page)).toBe(1); - // Re-navigate (the SW-disabled path runs the reindex check at the - // end of `Client.init()`). - await gotoSilverBulletPage(page, sbServer); - await waitForEditor(page); + // Re-navigate (the SW-disabled path runs the reindex check at the + // end of `Client.init()`). + await gotoSilverBulletPage(page, sbServer); + await waitForEditor(page); - // A stale-but-present index should not flip widgets into the - // loading/spinner mode at boot — the existing entries are still - // queryable until the actual reindex starts. (Before this fix the - // boot-time flag was set from `hasFullIndexCompleted()`, which is - // `stored >= desired` and therefore false during the wait, and - // `updatePageListCache` would take the fallback branch and leave - // `pageListLoaded` false.) - expect(await readFullIndexCompleted(page)).toBe(true); - await waitForWidgetsReady(page); - // Custom styles must also load from the stale-but-present index — - // before this was wired up, `loadCustomStyles` bailed out on the - // strict `hasFullIndexCompleted` check and the `#custom-styles` - // element stayed empty. - expect(await customStylesContent(page)).toContain(SPACE_STYLE_MARKER); + // A stale-but-present index should not flip widgets into the + // loading/spinner mode at boot — the existing entries are still + // queryable until the actual reindex starts. (Before this fix the + // boot-time flag was set from `hasFullIndexCompleted()`, which is + // `stored >= desired` and therefore false during the wait, and + // `updatePageListCache` would take the fallback branch and leave + // `pageListLoaded` false.) + expect(await readFullIndexCompleted(page)).toBe(true); + await waitForWidgetsReady(page); + // Custom styles must also load from the stale-but-present index — + // before this was wired up, `loadCustomStyles` bailed out on the + // strict `hasFullIndexCompleted` check and the `#custom-styles` + // element stayed empty. + expect(await customStylesContent(page)).toContain(SPACE_STYLE_MARKER); - // Critical assertion: a real reindex must actually have run, not - // just the stored version having been silently bumped. - await waitForReindexLog(consoleState); - await waitForIndexVersionAtLeast(page, baseline as number); - expect(await readIndexVersion(page)).toBe(baseline); - }); + // Critical assertion: a real reindex must actually have run, not + // just the stored version having been silently bumped. + await waitForReindexLog(consoleState); + await waitForIndexVersionAtLeast(page, baseline as number); + expect(await readIndexVersion(page)).toBe(baseline); + }); }); test.describe("index version upgrade (service worker enabled)", () => { - // SW + IndexedDB interactions are flaky in Firefox's Playwright - // implementation; mirror what pwa-offline does. - test.skip(({ browserName }) => browserName !== "chromium", "SW path only runs on Chromium"); - test.describe.configure({ retries: 2 }); + // SW + IndexedDB interactions are flaky in Firefox's Playwright + // implementation; mirror what pwa-offline does. + test.skip( + ({ browserName }) => browserName !== "chromium", + "SW path only runs on Chromium", + ); + test.describe.configure({ retries: 2 }); - test.use({ - disableServiceWorker: false, - spaceFiles: { - "index.md": "# Index\nFirst page in the SW-enabled upgrade test.", - "Other.md": "# Other\nLink back to [[index]].", - "Styles.md": - `# Styles\n\n\`\`\`space-style\n/* ${SPACE_STYLE_MARKER} */\n\`\`\`\n`, - }, - }); + test.use({ + disableServiceWorker: false, + spaceFiles: { + "index.md": "# Index\nFirst page in the SW-enabled upgrade test.", + "Other.md": "# Other\nLink back to [[index]].", + "Styles.md": `# Styles\n\n\`\`\`space-style\n/* ${SPACE_STYLE_MARKER} */\n\`\`\`\n`, + }, + }); - test("stale index version triggers a real reindex after sync + version match", async ({ sbServer, page }) => { - const consoleState = captureConsole(page); + test("stale index version triggers a real reindex after sync + version match", async ({ + sbServer, + page, + }) => { + const consoleState = captureConsole(page); - // First boot with the SW active: navigate without `?headless=1` so - // the service worker actually registers. - await page.goto(sbServer.url); - await waitForEditor(page); - await waitForIndexVersionAtLeast(page, 1); + // First boot with the SW active: navigate without `?headless=1` so + // the service worker actually registers. + await page.goto(sbServer.url); + await waitForEditor(page); + await waitForIndexVersionAtLeast(page, 1); - const baseline = await readIndexVersion(page); - expect(typeof baseline).toBe("number"); - expect(baseline).toBeGreaterThan(0); - expect(consoleState.messages.some((m) => m.includes(REINDEX_LOG))).toBe(false); + const baseline = await readIndexVersion(page); + expect(typeof baseline).toBe("number"); + expect(baseline).toBeGreaterThan(0); + expect(consoleState.messages.some((m) => m.includes(REINDEX_LOG))).toBe( + false, + ); - await writeIndexVersion(page, 1); - expect(await readIndexVersion(page)).toBe(1); + await writeIndexVersion(page, 1); + expect(await readIndexVersion(page)).toBe(1); - // Re-navigate. The SW-enabled path triggers the reindex once both - // `space-sync-complete` and a matching `server-version` message - // have been observed. - await page.goto(sbServer.url); - await waitForEditor(page); + // Re-navigate. The SW-enabled path triggers the reindex once both + // `space-sync-complete` and a matching `server-version` message + // have been observed. + await page.goto(sbServer.url); + await waitForEditor(page); - // Stale-but-present index should keep widgets in their ready state - // during the wait for sync + version-match (see the SW-disabled - // test for the rationale). - expect(await readFullIndexCompleted(page)).toBe(true); - await waitForWidgetsReady(page); - // Custom styles must also load from the stale-but-present index — - // before this was wired up, `loadCustomStyles` bailed out on the - // strict `hasFullIndexCompleted` check and the `#custom-styles` - // element stayed empty. - expect(await customStylesContent(page)).toContain(SPACE_STYLE_MARKER); + // Stale-but-present index should keep widgets in their ready state + // during the wait for sync + version-match (see the SW-disabled + // test for the rationale). + expect(await readFullIndexCompleted(page)).toBe(true); + await waitForWidgetsReady(page); + // Custom styles must also load from the stale-but-present index — + // before this was wired up, `loadCustomStyles` bailed out on the + // strict `hasFullIndexCompleted` check and the `#custom-styles` + // element stayed empty. + expect(await customStylesContent(page)).toContain(SPACE_STYLE_MARKER); - await waitForReindexLog(consoleState); - await waitForIndexVersionAtLeast(page, baseline as number); - expect(await readIndexVersion(page)).toBe(baseline); - }); + await waitForReindexLog(consoleState); + await waitForIndexVersionAtLeast(page, baseline as number); + expect(await readIndexVersion(page)).toBe(baseline); + }); }); diff --git a/e2e/inline-lua-directive.test.ts b/e2e/inline-lua-directive.test.ts index d2086472..8b3f3854 100644 --- a/e2e/inline-lua-directive.test.ts +++ b/e2e/inline-lua-directive.test.ts @@ -7,38 +7,45 @@ import { expect, gotoSilverBulletPage, test } from "./fixtures.ts"; test.use({ - spaceFiles: { - "Inline.md": `Add two numbers \${10 + 2} and continue.\n`, - }, + spaceFiles: { + "Inline.md": `Add two numbers \${10 + 2} and continue.\n`, + }, }); -test("inline lua directive stays on the same line as surrounding text", async ({ sbServer, page }) => { - await gotoSilverBulletPage(page, sbServer, "Inline"); +test("inline lua directive stays on the same line as surrounding text", async ({ + sbServer, + page, +}) => { + await gotoSilverBulletPage(page, sbServer, "Inline"); - await page.waitForFunction(() => { - return !!document.querySelector( - "#sb-editor .sb-lua-wrapper .sb-lua-directive-inline", - ); - }, undefined, { timeout: 10_000 }); + await page.waitForFunction( + () => { + return !!document.querySelector( + "#sb-editor .sb-lua-wrapper .sb-lua-directive-inline", + ); + }, + undefined, + { timeout: 10_000 }, + ); - const layout = await page.evaluate(() => { - const line = document.querySelector( - "#sb-editor .cm-content .cm-line", - ) as HTMLElement; - const wrapper = line.querySelector(".sb-lua-wrapper") as HTMLElement; - const cs = getComputedStyle(line); - return { - lineHeight: line.getBoundingClientRect().height, - defaultLineHeight: parseFloat(cs.lineHeight), - lineWidth: line.getBoundingClientRect().width, - wrapperWidth: wrapper.getBoundingClientRect().width, - }; - }); + const layout = await page.evaluate(() => { + const line = document.querySelector( + "#sb-editor .cm-content .cm-line", + ) as HTMLElement; + const wrapper = line.querySelector(".sb-lua-wrapper") as HTMLElement; + const cs = getComputedStyle(line); + return { + lineHeight: line.getBoundingClientRect().height, + defaultLineHeight: parseFloat(cs.lineHeight), + lineWidth: line.getBoundingClientRect().width, + wrapperWidth: wrapper.getBoundingClientRect().width, + }; + }); - // The cm-line must remain a single visual line. If the inline - // directive broke onto its own line, cm-line would be ≥2× the - // editor's line-height (it would contain two stacked line boxes). - expect(layout.lineHeight).toBeLessThan(layout.defaultLineHeight * 1.5); - // And the wrapper must be shrink-to-fit, not the full content width. - expect(layout.wrapperWidth).toBeLessThan(layout.lineWidth * 0.5); + // The cm-line must remain a single visual line. If the inline + // directive broke onto its own line, cm-line would be ≥2× the + // editor's line-height (it would contain two stacked line boxes). + expect(layout.lineHeight).toBeLessThan(layout.defaultLineHeight * 1.5); + // And the wrapper must be shrink-to-fit, not the full content width. + expect(layout.wrapperWidth).toBeLessThan(layout.lineWidth * 0.5); }); diff --git a/e2e/list-alignment.test.ts b/e2e/list-alignment.test.ts index 2ea843a8..e5c77a88 100644 --- a/e2e/list-alignment.test.ts +++ b/e2e/list-alignment.test.ts @@ -1,7 +1,7 @@ import { expect, gotoSilverBulletPage, test } from "./fixtures.ts"; const SPACE_FILES = { - "Outline.md": `# Outline + "Outline.md": `# Outline * plain - hyphen * [ ] task @@ -14,73 +14,77 @@ const SPACE_FILES = { }; const PROBES = [ - { text: "plain", label: "plain-asterisk" }, - { text: "hyphen", label: "plain-hyphen" }, - { text: "task", label: "task" }, - { text: "ten", label: "ordered-2-digit" }, - { text: "hundred", label: "ordered-3-digit" }, - { text: "deeper", label: "deep-nesting" }, + { text: "plain", label: "plain-asterisk" }, + { text: "hyphen", label: "plain-hyphen" }, + { text: "task", label: "task" }, + { text: "ten", label: "ordered-2-digit" }, + { text: "hundred", label: "ordered-3-digit" }, + { text: "deeper", label: "deep-nesting" }, ]; test.use({ spaceFiles: SPACE_FILES }); function measureFirstGlyphX(text: string) { - const line = Array.from(document.querySelectorAll(".cm-line")) - .find((l) => l.textContent?.includes(text)); - if (!line) return null; - const lineRect = line.getBoundingClientRect(); - const walker = document.createTreeWalker(line, NodeFilter.SHOW_TEXT); - let node: Node | null; - while ((node = walker.nextNode())) { - const t = node.textContent ?? ""; - if (!t.length) continue; - const r = document.createRange(); - r.selectNodeContents(node); - const rect = r.getBoundingClientRect(); - if (rect.width === 0 && rect.height === 0) continue; - return rect.left - lineRect.left; - } - return null; + const line = Array.from(document.querySelectorAll(".cm-line")).find((l) => + l.textContent?.includes(text), + ); + if (!line) return null; + const lineRect = line.getBoundingClientRect(); + const walker = document.createTreeWalker(line, NodeFilter.SHOW_TEXT); + let node: Node | null; + while ((node = walker.nextNode())) { + const t = node.textContent ?? ""; + if (!t.length) continue; + const r = document.createRange(); + r.selectNodeContents(node); + const rect = r.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) continue; + return rect.left - lineRect.left; + } + return null; } test("list lines: zero horizontal shift on cursor in/out", async ({ - sbPage, - sbServer, + sbPage, + sbServer, }) => { - await gotoSilverBulletPage(sbPage, sbServer, "Outline"); - await sbPage.locator(".cm-line.sb-line-li").first().waitFor({ - state: "visible", - timeout: 10_000, - }); + await gotoSilverBulletPage(sbPage, sbServer, "Outline"); + await sbPage.locator(".cm-line.sb-line-li").first().waitFor({ + state: "visible", + timeout: 10_000, + }); - const h1 = sbPage.locator(".cm-line.sb-line-h1").first(); + const h1 = sbPage.locator(".cm-line.sb-line-h1").first(); - for (const probe of PROBES) { - const lineLocator = sbPage - .locator(".cm-line", { hasText: probe.text }) - .first(); - await lineLocator.scrollIntoViewIfNeeded(); + for (const probe of PROBES) { + const lineLocator = sbPage + .locator(".cm-line", { hasText: probe.text }) + .first(); + await lineLocator.scrollIntoViewIfNeeded(); - // Cursor OUT: park on H1 (live-preview rendering for our line) - await h1.click(); - await sbPage.waitForTimeout(75); + // Cursor OUT: park on H1 (live-preview rendering for our line) + await h1.click(); + await sbPage.waitForTimeout(75); - const xOut = await sbPage.evaluate(measureFirstGlyphX, probe.text); - expect( - xOut, - `${probe.label}: should have a measurable first glyph (out)`, - ).not.toBeNull(); + const xOut = await sbPage.evaluate(measureFirstGlyphX, probe.text); + expect( + xOut, + `${probe.label}: should have a measurable first glyph (out)`, + ).not.toBeNull(); - // Cursor IN: click into this list line (raw-source rendering) - await lineLocator.click(); - await sbPage.waitForTimeout(75); + // Cursor IN: click into this list line (raw-source rendering) + await lineLocator.click(); + await sbPage.waitForTimeout(75); - const xIn = await sbPage.evaluate(measureFirstGlyphX, probe.text); - expect( - xIn, - `${probe.label}: should have a measurable first glyph (in)`, - ).not.toBeNull(); + const xIn = await sbPage.evaluate(measureFirstGlyphX, probe.text); + expect( + xIn, + `${probe.label}: should have a measurable first glyph (in)`, + ).not.toBeNull(); - expect(Math.abs((xOut as number) - (xIn as number)), `${probe.label}: |xOut - xIn| ≤ 1px`).toBeLessThanOrEqual(1); - } + expect( + Math.abs((xOut as number) - (xIn as number)), + `${probe.label}: |xOut - xIn| ≤ 1px`, + ).toBeLessThanOrEqual(1); + } }); diff --git a/e2e/multi-space-admin.test.ts b/e2e/multi-space-admin.test.ts index 4b14cd86..521917ac 100644 --- a/e2e/multi-space-admin.test.ts +++ b/e2e/multi-space-admin.test.ts @@ -10,72 +10,76 @@ let rootDir: string; let base: string; test.beforeAll(async () => { - rootDir = await mkdtemp(join(tmpdir(), "sb-multi-e2e-")); - const port = await getFreePort(); - proc = spawn( - "./target/debug/silverbullet", - [rootDir, "-p", String(port), "-L", "127.0.0.1"], - { - cwd: join(import.meta.dirname, ".."), - stdio: ["ignore", "pipe", "pipe"], - env: { - ...process.env, - SB_MULTI_SPACE: "1", - SB_USER: "admin:adminpw", - // See fixtures.ts: with the service worker disabled and - // `?headless=1` on navigation, the client uses its own in-page - // runtime, so the server never needs to spawn a headless - // Chrome for the runtime API. - SB_RUNTIME_API: "0", - SB_DISABLE_SERVICE_WORKER: "1", - }, - }, - ); - base = `http://127.0.0.1:${port}`; - await waitForServer(`${base}/.admin/`); + rootDir = await mkdtemp(join(tmpdir(), "sb-multi-e2e-")); + const port = await getFreePort(); + proc = spawn( + "./target/debug/silverbullet", + [rootDir, "-p", String(port), "-L", "127.0.0.1"], + { + cwd: join(import.meta.dirname, ".."), + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + SB_MULTI_SPACE: "1", + SB_USER: "admin:adminpw", + // See fixtures.ts: with the service worker disabled and + // `?headless=1` on navigation, the client uses its own in-page + // runtime, so the server never needs to spawn a headless + // Chrome for the runtime API. + SB_RUNTIME_API: "0", + SB_DISABLE_SERVICE_WORKER: "1", + }, + }, + ); + base = `http://127.0.0.1:${port}`; + await waitForServer(`${base}/.admin/`); }); test.afterAll(async () => { - proc?.kill(); - await rm(rootDir, { recursive: true, force: true }); + proc?.kill(); + await rm(rootDir, { recursive: true, force: true }); }); -test("first run: login, create a space, open it, edit a page", async ({ page }) => { - // Root redirects to the admin UI on a fresh server. - await page.goto(`${base}/`); - await expect(page).toHaveURL(/\/\.admin\//); +test("first run: login, create a space, open it, edit a page", async ({ + page, +}) => { + // Root redirects to the admin UI on a fresh server. + await page.goto(`${base}/`); + await expect(page).toHaveURL(/\/\.admin\//); - // Log in. - await page.getByLabel("Username").fill("admin"); - await page.getByLabel("Password").fill("adminpw"); - await page.getByRole("button", { name: "Log in" }).click(); + // Log in. + await page.getByLabel("Username").fill("admin"); + await page.getByLabel("Password").fill("adminpw"); + await page.getByRole("button", { name: "Log in" }).click(); - // Empty list -> create a space. - await expect(page.getByText("No spaces yet")).toBeVisible(); - await page.getByRole("button", { name: "Create space" }).click(); - await page.getByLabel("Name").fill("Playground"); - // The binding-value input's label is dynamic ("Prefix"/"Hostname"/"Port") - // and defaults to "Prefix" for the default "URL prefix" binding type. - await page.getByLabel("Prefix").fill("/play"); - await page.getByLabel("Authentication").selectOption("none"); - await page.getByRole("button", { name: "Create" }).click(); + // Empty list -> create a space. + await expect(page.getByText("No spaces yet")).toBeVisible(); + await page.getByRole("button", { name: "Create space" }).click(); + await page.getByLabel("Name").fill("Playground"); + // The binding-value input's label is dynamic ("Prefix"/"Hostname"/"Port") + // and defaults to "Prefix" for the default "URL prefix" binding type. + await page.getByLabel("Prefix").fill("/play"); + await page.getByLabel("Authentication").selectOption("none"); + await page.getByRole("button", { name: "Create" }).click(); - // It shows as running. - await expect(page.getByText("Playground")).toBeVisible(); - await expect(page.locator(".sb-badge.running")).toBeVisible(); + // It shows as running. + await expect(page.getByText("Playground")).toBeVisible(); + await expect(page.locator(".sb-badge.running")).toBeVisible(); - // Open the space and verify the editor loads. Mirror the boot conventions - // from e2e/first-load.test.ts / fixtures.ts: navigate with `?headless=1` - // (so the server doesn't need a headless-Chrome runtime API), wait for - // `#sb-editor .cm-editor` to be visible, then wait for the client runtime - // to report ready before interacting with the page. - await page.goto(`${base}/play/?headless=1`); - await page.locator("#sb-editor .cm-editor").waitFor({ state: "visible", timeout: 30_000 }); - await waitForEditorReady(page); + // Open the space and verify the editor loads. Mirror the boot conventions + // from e2e/first-load.test.ts / fixtures.ts: navigate with `?headless=1` + // (so the server doesn't need a headless-Chrome runtime API), wait for + // `#sb-editor .cm-editor` to be visible, then wait for the client runtime + // to report ready before interacting with the page. + await page.goto(`${base}/play/?headless=1`); + await page + .locator("#sb-editor .cm-editor") + .waitFor({ state: "visible", timeout: 30_000 }); + await waitForEditorReady(page); - // Type into the page and confirm content sticks. - const editor = page.locator("#sb-editor .cm-content"); - await editor.click(); - await page.keyboard.type("Hello from multi-space"); - await expect(editor).toContainText("Hello from multi-space"); + // Type into the page and confirm content sticks. + const editor = page.locator("#sb-editor .cm-content"); + await editor.click(); + await page.keyboard.type("Hello from multi-space"); + await expect(editor).toContainText("Hello from multi-space"); }); diff --git a/e2e/navigate-restore.test.ts b/e2e/navigate-restore.test.ts index 8aa3b4bf..aaf694dd 100644 --- a/e2e/navigate-restore.test.ts +++ b/e2e/navigate-restore.test.ts @@ -1,157 +1,214 @@ import { - expect, - gotoSilverBulletPage, - mod, - test, - waitForEditorReady, + expect, + gotoSilverBulletPage, + mod, + test, + waitForEditorReady, } from "./fixtures.ts"; // PageB is long enough to scroll and contains a "## Section" header for the // explicit-pointer regression check. Line 12 sits in the first paragraph block. -const pageB = "# Page B\n\n" + - Array.from({ length: 18 }, (_, i) => `Para line ${i + 1}`).join("\n") + - "\n\n## Section\n\n" + - Array.from({ length: 18 }, (_, i) => `More line ${i + 1}`).join("\n") + - "\n"; +const pageB = + "# Page B\n\n" + + Array.from({ length: 18 }, (_, i) => `Para line ${i + 1}`).join("\n") + + "\n\n## Section\n\n" + + Array.from({ length: 18 }, (_, i) => `More line ${i + 1}`).join("\n") + + "\n"; // PageA carries the [[PageB]] link and is long enough to park a cursor // mid-document (line 8) for the "Back restores the origin" test. -const pageA = "# Page A\n\nGo to [[PageB]]\n\n" + - Array.from({ length: 18 }, (_, i) => `A line ${i + 1}`).join("\n") + - "\n"; +const pageA = + "# Page A\n\nGo to [[PageB]]\n\n" + + Array.from({ length: 18 }, (_, i) => `A line ${i + 1}`).join("\n") + + "\n"; test.use({ - spaceFiles: { - "PageA.md": pageA, - "PageB.md": pageB, - }, + spaceFiles: { + "PageA.md": pageA, + "PageB.md": pageB, + }, }); // Read the 1-based line number of the main selection head. -async function selectionLine(page: import("@playwright/test").Page): Promise { - return await page.evaluate(() => { - const v = (globalThis as any).client.editorView; - return v.state.doc.lineAt(v.state.selection.main.head).number; - }); +async function selectionLine( + page: import("@playwright/test").Page, +): Promise { + return await page.evaluate(() => { + const v = (globalThis as any).client.editorView; + return v.state.doc.lineAt(v.state.selection.main.head).number; + }); } // Open PageB, park the cursor on line 12, then navigate away to PageA so // PageB's position is captured into openLocations. -async function parkOnPageBThenLeave(page: import("@playwright/test").Page, sbServer: any) { - await gotoSilverBulletPage(page, sbServer, "PageB"); - await waitForEditorReady(page); - await page.evaluate(() => { - const v = (globalThis as any).client.editorView; - v.dispatch({ selection: { anchor: v.state.doc.line(12).from } }); - v.scrollDOM.scrollTop = 400; - v.focus(); - }); - await page.evaluate(() => (globalThis as any).client.navigate({ path: "PageA.md" })); - await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue("PageA"); - await waitForEditorReady(page); +async function parkOnPageBThenLeave( + page: import("@playwright/test").Page, + sbServer: any, +) { + await gotoSilverBulletPage(page, sbServer, "PageB"); + await waitForEditorReady(page); + await page.evaluate(() => { + const v = (globalThis as any).client.editorView; + v.dispatch({ selection: { anchor: v.state.doc.line(12).from } }); + v.scrollDOM.scrollTop = 400; + v.focus(); + }); + await page.evaluate(() => + (globalThis as any).client.navigate({ path: "PageA.md" }), + ); + await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue( + "PageA", + ); + await waitForEditorReady(page); } -test("clean wiki-link navigation opens PageB at the top, not the remembered line", async ({ sbServer, page }) => { - await parkOnPageBThenLeave(page, sbServer); +test("clean wiki-link navigation opens PageB at the top, not the remembered line", async ({ + sbServer, + page, +}) => { + await parkOnPageBThenLeave(page, sbServer); - const editor = page.locator("#sb-editor .cm-content"); - const wikiLink = editor.locator(".sb-wiki-link", { hasText: "PageB" }); - await expect(wikiLink).toBeVisible({ timeout: 10_000 }); - await wikiLink.click(); + const editor = page.locator("#sb-editor .cm-content"); + const wikiLink = editor.locator(".sb-wiki-link", { hasText: "PageB" }); + await expect(wikiLink).toBeVisible({ timeout: 10_000 }); + await wikiLink.click(); - await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue("PageB"); - await waitForEditorReady(page); - expect(await selectionLine(page)).toBe(1); + await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue( + "PageB", + ); + await waitForEditorReady(page); + expect(await selectionLine(page)).toBe(1); }); -test("client.open restores the remembered line on PageB", async ({ sbServer, page }) => { - await parkOnPageBThenLeave(page, sbServer); +test("client.open restores the remembered line on PageB", async ({ + sbServer, + page, +}) => { + await parkOnPageBThenLeave(page, sbServer); - await page.evaluate(() => (globalThis as any).client.open({ path: "PageB.md" })); - await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue("PageB"); - await waitForEditorReady(page); - expect(await selectionLine(page)).toBe(12); + await page.evaluate(() => + (globalThis as any).client.open({ path: "PageB.md" }), + ); + await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue( + "PageB", + ); + await waitForEditorReady(page); + expect(await selectionLine(page)).toBe(12); }); -test("browser Back restores the remembered line on PageB", async ({ sbServer, page }) => { - await parkOnPageBThenLeave(page, sbServer); +test("browser Back restores the remembered line on PageB", async ({ + sbServer, + page, +}) => { + await parkOnPageBThenLeave(page, sbServer); - await page.goBack(); - await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue("PageB"); - await waitForEditorReady(page); - expect(await selectionLine(page)).toBe(12); + await page.goBack(); + await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue( + "PageB", + ); + await waitForEditorReady(page); + expect(await selectionLine(page)).toBe(12); }); -test("explicit #header pointer wins over both fresh and remembered position", async ({ sbServer, page }) => { - await parkOnPageBThenLeave(page, sbServer); +test("explicit #header pointer wins over both fresh and remembered position", async ({ + sbServer, + page, +}) => { + await parkOnPageBThenLeave(page, sbServer); - // Compute the expected line number from the pageB string constant so that - // we do not need to query the DOM while on PageA (where "## Section" would - // not be found and indexOf would return -1). - const sectionLine = pageB.substring(0, pageB.indexOf("## Section")).split("\n").length; + // Compute the expected line number from the pageB string constant so that + // we do not need to query the DOM while on PageA (where "## Section" would + // not be found and indexOf would return -1). + const sectionLine = pageB + .substring(0, pageB.indexOf("## Section")) + .split("\n").length; - await page.evaluate(() => - (globalThis as any).client.navigate({ path: "PageB.md", details: { type: "header", header: "Section" } }) - ); - await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue("PageB"); - await waitForEditorReady(page); - expect(await selectionLine(page)).toBe(sectionLine); + await page.evaluate(() => + (globalThis as any).client.navigate({ + path: "PageB.md", + details: { type: "header", header: "Section" }, + }), + ); + await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue( + "PageB", + ); + await waitForEditorReady(page); + expect(await selectionLine(page)).toBe(sectionLine); }); -test("page picker restores the remembered line on PageB", async ({ sbServer, page }) => { - await parkOnPageBThenLeave(page, sbServer); +test("page picker restores the remembered line on PageB", async ({ + sbServer, + page, +}) => { + await parkOnPageBThenLeave(page, sbServer); - // Open the page navigator and pick PageB. - await page.keyboard.press(`${mod}+k`); - const pickerInput = page.locator(".sb-modal-box input.sb-input"); - await expect(pickerInput).toBeVisible(); - await pickerInput.click(); - await page.keyboard.type("PageB", { delay: 30 }); - await page.keyboard.press("Enter"); + // Open the page navigator and pick PageB. + await page.keyboard.press(`${mod}+k`); + const pickerInput = page.locator(".sb-modal-box input.sb-input"); + await expect(pickerInput).toBeVisible(); + await pickerInput.click(); + await page.keyboard.type("PageB", { delay: 30 }); + await page.keyboard.press("Enter"); - await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue("PageB"); - await waitForEditorReady(page); - expect(await selectionLine(page)).toBe(12); + await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue( + "PageB", + ); + await waitForEditorReady(page); + expect(await selectionLine(page)).toBe(12); }); -test("editor.open syscall restores the remembered line on PageB", async ({ sbServer, page }) => { - await parkOnPageBThenLeave(page, sbServer); +test("editor.open syscall restores the remembered line on PageB", async ({ + sbServer, + page, +}) => { + await parkOnPageBThenLeave(page, sbServer); - await page.evaluate(() => - (globalThis as any).client.clientSystem.localSyscall("editor.open", ["PageB"]) - ); - await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue("PageB"); - await waitForEditorReady(page); - expect(await selectionLine(page)).toBe(12); + await page.evaluate(() => + (globalThis as any).client.clientSystem.localSyscall("editor.open", [ + "PageB", + ]), + ); + await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue( + "PageB", + ); + await waitForEditorReady(page); + expect(await selectionLine(page)).toBe(12); }); // The subtlest invariant: the restore intent is transient and never persisted // into history.state. So a fresh forward navigation (which does NOT restore) // must still leave the origin page restorable when the user presses Back. -test("fresh forward navigation, then browser Back, restores the origin page", async ({ sbServer, page }) => { - await gotoSilverBulletPage(page, sbServer, "PageA"); - await waitForEditorReady(page); +test("fresh forward navigation, then browser Back, restores the origin page", async ({ + sbServer, + page, +}) => { + await gotoSilverBulletPage(page, sbServer, "PageA"); + await waitForEditorReady(page); - // Park the cursor mid-PageA. - await page.evaluate(() => { - const v = (globalThis as any).client.editorView; - v.dispatch({ selection: { anchor: v.state.doc.line(8).from } }); - v.scrollDOM.scrollTop = 200; - v.focus(); - }); + // Park the cursor mid-PageA. + await page.evaluate(() => { + const v = (globalThis as any).client.editorView; + v.dispatch({ selection: { anchor: v.state.doc.line(8).from } }); + v.scrollDOM.scrollTop = 200; + v.focus(); + }); - // Fresh forward navigation via wiki-link click: PageB opens at the top. - const editor = page.locator("#sb-editor .cm-content"); - const wikiLink = editor.locator(".sb-wiki-link", { hasText: "PageB" }); - await expect(wikiLink).toBeVisible({ timeout: 10_000 }); - await wikiLink.click(); - await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue("PageB"); - await waitForEditorReady(page); - expect(await selectionLine(page)).toBe(1); + // Fresh forward navigation via wiki-link click: PageB opens at the top. + const editor = page.locator("#sb-editor .cm-content"); + const wikiLink = editor.locator(".sb-wiki-link", { hasText: "PageB" }); + await expect(wikiLink).toBeVisible({ timeout: 10_000 }); + await wikiLink.click(); + await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue( + "PageB", + ); + await waitForEditorReady(page); + expect(await selectionLine(page)).toBe(1); - // Back to PageA must restore line 8 even though the forward nav was fresh. - await page.goBack(); - await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue("PageA"); - await waitForEditorReady(page); - expect(await selectionLine(page)).toBe(8); + // Back to PageA must restore line 8 even though the forward nav was fresh. + await page.goBack(); + await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue( + "PageA", + ); + await waitForEditorReady(page); + expect(await selectionLine(page)).toBe(8); }); diff --git a/e2e/page-navigation.test.ts b/e2e/page-navigation.test.ts index a1315f8d..b6f13d28 100644 --- a/e2e/page-navigation.test.ts +++ b/e2e/page-navigation.test.ts @@ -1,89 +1,118 @@ import { expect, mod, test, waitForSaveAndReadFromServer } from "./fixtures.ts"; test.describe("Page navigation", () => { - test("create a new page via page picker", async ({ sbPage, sbServer }) => { - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Welcome to the wondrous world of SilverBullet"); + test("create a new page via page picker", async ({ sbPage, sbServer }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText( + "Welcome to the wondrous world of SilverBullet", + ); - // Open page picker - await sbPage.keyboard.press(`${mod}+k`); - await expect(sbPage.locator(".sb-modal-box")).toBeVisible(); + // Open page picker + await sbPage.keyboard.press(`${mod}+k`); + await expect(sbPage.locator(".sb-modal-box")).toBeVisible(); - // Type the new page name (with delay to avoid dropped keystrokes) - const pickerInput = sbPage.locator(".sb-modal-box input.sb-input"); - await pickerInput.click(); - await sbPage.keyboard.type("My New Page", { delay: 30 }); + // Type the new page name (with delay to avoid dropped keystrokes) + const pickerInput = sbPage.locator(".sb-modal-box input.sb-input"); + await pickerInput.click(); + await sbPage.keyboard.type("My New Page", { delay: 30 }); - // Should see "Create page" hint in the list - await expect(sbPage.locator(".sb-option .sb-hint", { hasText: "Create page" })).toBeVisible(); + // Should see "Create page" hint in the list + await expect( + sbPage.locator(".sb-option .sb-hint", { hasText: "Create page" }), + ).toBeVisible(); - // Shift+Enter to create new page - await sbPage.keyboard.press("Shift+Enter"); + // Shift+Enter to create new page + await sbPage.keyboard.press("Shift+Enter"); - // Modal should close - await expect(sbPage.locator(".sb-modal-box")).not.toBeVisible(); + // Modal should close + await expect(sbPage.locator(".sb-modal-box")).not.toBeVisible(); - // Page name should update in top bar - await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue("My New Page"); + // Page name should update in top bar + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue( + "My New Page", + ); - // Editor should be empty (new page) - await expect(editor).toHaveText(""); + // Editor should be empty (new page) + await expect(editor).toHaveText(""); - // Type content and verify it saves to server - await editor.click(); - await sbPage.keyboard.type("New page content"); - await expect(editor).toContainText("New page content"); + // Type content and verify it saves to server + await editor.click(); + await sbPage.keyboard.type("New page content"); + await expect(editor).toContainText("New page content"); - const content = await waitForSaveAndReadFromServer(sbPage, sbServer, "My New Page.md"); - expect(content).toContain("New page content"); - }); + const content = await waitForSaveAndReadFromServer( + sbPage, + sbServer, + "My New Page.md", + ); + expect(content).toContain("New page content"); + }); - test("navigate back to index via page picker", async ({ sbPage }) => { - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Welcome to the wondrous world of SilverBullet"); + test("navigate back to index via page picker", async ({ sbPage }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText( + "Welcome to the wondrous world of SilverBullet", + ); - // First, create and navigate to a new page - await sbPage.keyboard.press(`${mod}+k`); - const pickerInput = sbPage.locator(".sb-modal-box input.sb-input"); - await pickerInput.click(); - await sbPage.keyboard.type("Temporary Page", { delay: 30 }); - await sbPage.keyboard.press("Shift+Enter"); - await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue("Temporary Page"); + // First, create and navigate to a new page + await sbPage.keyboard.press(`${mod}+k`); + const pickerInput = sbPage.locator(".sb-modal-box input.sb-input"); + await pickerInput.click(); + await sbPage.keyboard.type("Temporary Page", { delay: 30 }); + await sbPage.keyboard.press("Shift+Enter"); + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue( + "Temporary Page", + ); - // Now navigate back to index - await sbPage.keyboard.press(`${mod}+k`); - await expect(sbPage.locator(".sb-modal-box")).toBeVisible(); - const pickerInput2 = sbPage.locator(".sb-modal-box input.sb-input"); - await pickerInput2.click(); - await sbPage.keyboard.type("index", { delay: 30 }); - await sbPage.keyboard.press("Enter"); + // Now navigate back to index + await sbPage.keyboard.press(`${mod}+k`); + await expect(sbPage.locator(".sb-modal-box")).toBeVisible(); + const pickerInput2 = sbPage.locator(".sb-modal-box input.sb-input"); + await pickerInput2.click(); + await sbPage.keyboard.type("index", { delay: 30 }); + await sbPage.keyboard.press("Enter"); - // Should be back on the index/welcome page - await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue("index"); - await expect(editor).toContainText("Welcome to the wondrous world of SilverBullet"); - }); + // Should be back on the index/welcome page + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue( + "index", + ); + await expect(editor).toContainText( + "Welcome to the wondrous world of SilverBullet", + ); + }); - test("create a page in a subfolder and verify on server", async ({ sbPage, sbServer }) => { - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Welcome to the wondrous world of SilverBullet"); + test("create a page in a subfolder and verify on server", async ({ + sbPage, + sbServer, + }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText( + "Welcome to the wondrous world of SilverBullet", + ); - // Open page picker and create a page with folder path - await sbPage.keyboard.press(`${mod}+k`); - const pickerInput = sbPage.locator(".sb-modal-box input.sb-input"); - await pickerInput.click(); - await sbPage.keyboard.type("Notes/My Subfolder Page", { delay: 30 }); - await sbPage.keyboard.press("Shift+Enter"); + // Open page picker and create a page with folder path + await sbPage.keyboard.press(`${mod}+k`); + const pickerInput = sbPage.locator(".sb-modal-box input.sb-input"); + await pickerInput.click(); + await sbPage.keyboard.type("Notes/My Subfolder Page", { delay: 30 }); + await sbPage.keyboard.press("Shift+Enter"); - // Page name should show the full path - await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue("Notes/My Subfolder Page"); - await expect(editor).toHaveText(""); + // Page name should show the full path + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue( + "Notes/My Subfolder Page", + ); + await expect(editor).toHaveText(""); - // Type something and verify it saves to server - await editor.click(); - await sbPage.keyboard.type("Content in a subfolder page"); - await expect(editor).toContainText("Content in a subfolder page"); + // Type something and verify it saves to server + await editor.click(); + await sbPage.keyboard.type("Content in a subfolder page"); + await expect(editor).toContainText("Content in a subfolder page"); - const content = await waitForSaveAndReadFromServer(sbPage, sbServer, "Notes/My Subfolder Page.md"); - expect(content).toContain("Content in a subfolder page"); - }); + const content = await waitForSaveAndReadFromServer( + sbPage, + sbServer, + "Notes/My Subfolder Page.md", + ); + expect(content).toContain("Content in a subfolder page"); + }); }); diff --git a/e2e/page-picker.test.ts b/e2e/page-picker.test.ts index 0363b45f..c1077550 100644 --- a/e2e/page-picker.test.ts +++ b/e2e/page-picker.test.ts @@ -48,7 +48,9 @@ test.describe("Page picker keyboard control", () => { await sbPage.keyboard.type("Fruit Cherry", { delay: 30 }); await sbPage.keyboard.press("Enter"); await expect(modal).not.toBeVisible(); - await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue("Fruit Cherry"); + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue( + "Fruit Cherry", + ); }); test("Alt-Space completes the next path segment", async ({ sbPage }) => { diff --git a/e2e/page-rename.test.ts b/e2e/page-rename.test.ts index 9205d3a1..575cc0a5 100644 --- a/e2e/page-rename.test.ts +++ b/e2e/page-rename.test.ts @@ -8,9 +8,14 @@ test.describe("Top-bar page rename", () => { }, }); - test("rename via Enter moves the page on the server", async ({ sbPage, sbServer }) => { + test("rename via Enter moves the page on the server", async ({ + sbPage, + sbServer, + }) => { await gotoSilverBulletPage(sbPage, sbServer, "OldName"); - await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue("OldName"); + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue( + "OldName", + ); const nameInput = sbPage.locator("#sb-current-page input.sb-input"); await nameInput.click(); @@ -18,7 +23,9 @@ test.describe("Top-bar page rename", () => { await sbPage.keyboard.type("NewName"); await sbPage.keyboard.press("Enter"); - await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue("NewName"); + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue( + "NewName", + ); // Wait for the rename round-trip to land: the client navigates to the new // page once `index.renamePageCommand` resolves on the server. @@ -39,14 +46,19 @@ test.describe("Top-bar page rename", () => { await sbPage.keyboard.press(`${mod}+a`); await sbPage.keyboard.type("BlurRenamed"); await sbPage.locator("#sb-editor .cm-content").click(); // blur the field - await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue("BlurRenamed"); + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue( + "BlurRenamed", + ); // Wait for the rename round-trip to land (client navigates to the new page). await sbPage.waitForURL(/\/BlurRenamed$/); const resp = await fetch(`${sbServer.url}/.fs/BlurRenamed.md`); expect(resp.ok).toBe(true); }); - test("editor shortcuts still fire while the page-name field is focused", async ({ sbPage, sbServer }) => { + test("editor shortcuts still fire while the page-name field is focused", async ({ + sbPage, + sbServer, + }) => { await gotoSilverBulletPage(sbPage, sbServer, "OldName"); const nameInput = sbPage.locator("#sb-current-page input.sb-input"); await nameInput.click(); diff --git a/e2e/pwa-offline.test.ts b/e2e/pwa-offline.test.ts index 829c5388..a93f9dfe 100644 --- a/e2e/pwa-offline.test.ts +++ b/e2e/pwa-offline.test.ts @@ -30,23 +30,25 @@ import { expect, test } from "./fixtures.ts"; */ /** Wait for the service worker to be active and controlling the page. */ -async function waitForServiceWorkerReady(page: import("@playwright/test").Page): Promise { - await page.evaluate(async () => { - const reg = await navigator.serviceWorker.ready; - if (!navigator.serviceWorker.controller) { - // SW is active but not yet controlling this page — wait for it to claim. - await new Promise((resolve) => { - navigator.serviceWorker.addEventListener( - "controllerchange", - () => resolve(), - { once: true }, - ); - // Safety: if the controller appears between .ready and listener setup - if (navigator.serviceWorker.controller) resolve(); - }); - } - return reg.active?.state; - }); +async function waitForServiceWorkerReady( + page: import("@playwright/test").Page, +): Promise { + await page.evaluate(async () => { + const reg = await navigator.serviceWorker.ready; + if (!navigator.serviceWorker.controller) { + // SW is active but not yet controlling this page — wait for it to claim. + await new Promise((resolve) => { + navigator.serviceWorker.addEventListener( + "controllerchange", + () => resolve(), + { once: true }, + ); + // Safety: if the controller appears between .ready and listener setup + if (navigator.serviceWorker.controller) resolve(); + }); + } + return reg.active?.state; + }); } /** @@ -58,113 +60,145 @@ async function waitForServiceWorkerReady(page: import("@playwright/test").Page): * the SW can proxy requests to the server while syncing in the background. * Going offline before sync completes means IndexedDB may be empty. */ -async function waitForSyncComplete(page: import("@playwright/test").Page): Promise { - await page.evaluate(() => { - return new Promise((resolve) => { - navigator.serviceWorker.addEventListener("message", function handler(event: MessageEvent) { - if (event.data?.type === "space-sync-complete") { - navigator.serviceWorker.removeEventListener("message", handler); - resolve(); - } - }); - }); - }); +async function waitForSyncComplete( + page: import("@playwright/test").Page, +): Promise { + await page.evaluate(() => { + return new Promise((resolve) => { + navigator.serviceWorker.addEventListener( + "message", + function handler(event: MessageEvent) { + if (event.data?.type === "space-sync-complete") { + navigator.serviceWorker.removeEventListener("message", handler); + resolve(); + } + }, + ); + }); + }); } test.describe("PWA offline support", () => { - // Only run on Chromium — Firefox's Playwright implementation doesn't - // fully support service worker + setOffline interactions. - test.skip(({ browserName }) => browserName !== "chromium", "PWA offline tests only run on Chromium"); - test.describe.configure({ retries: 2 }); + // Only run on Chromium — Firefox's Playwright implementation doesn't + // fully support service worker + setOffline interactions. + test.skip( + ({ browserName }) => browserName !== "chromium", + "PWA offline tests only run on Chromium", + ); + test.describe.configure({ retries: 2 }); - test.use({ - // This suite exercises real SW behavior, so it needs the SW running. - disableServiceWorker: false, - spaceFiles: { - "index.md": "# Offline Test Space\nThis content should survive offline.", - "TestPage.md": "# Test Page\nOffline page content here.", - }, - }); + test.use({ + // This suite exercises real SW behavior, so it needs the SW running. + disableServiceWorker: false, + spaceFiles: { + "index.md": "# Offline Test Space\nThis content should survive offline.", + "TestPage.md": "# Test Page\nOffline page content here.", + }, + }); - test("app serves content offline in airplane mode", async ({ sbServer, page }) => { - // Load the app with service worker enabled - await page.goto(sbServer.url); - const editor = page.locator("#sb-editor .cm-content"); - await editor.waitFor({ state: "visible", timeout: 30_000 }); + test("app serves content offline in airplane mode", async ({ + sbServer, + page, + }) => { + // Load the app with service worker enabled + await page.goto(sbServer.url); + const editor = page.locator("#sb-editor .cm-content"); + await editor.waitFor({ state: "visible", timeout: 30_000 }); - // Wait for the SW to be active, controlling this page, and synced - await waitForServiceWorkerReady(page); - await expect(editor).toContainText("This content should survive offline", { timeout: 30_000 }); - await waitForSyncComplete(page); + // Wait for the SW to be active, controlling this page, and synced + await waitForServiceWorkerReady(page); + await expect(editor).toContainText("This content should survive offline", { + timeout: 30_000, + }); + await waitForSyncComplete(page); - // Simulate airplane mode — all network requests from both the page - // and the service worker will fail instantly. - await page.context().setOffline(true); + // Simulate airplane mode — all network requests from both the page + // and the service worker will fail instantly. + await page.context().setOffline(true); - // Navigate to the same URL (not reload — see file header comment). - // The SW should serve the HTML shell from its pre-cache and /.fs - // requests from IndexedDB. - await page.goto(sbServer.url, { waitUntil: "domcontentloaded" }); + // Navigate to the same URL (not reload — see file header comment). + // The SW should serve the HTML shell from its pre-cache and /.fs + // requests from IndexedDB. + await page.goto(sbServer.url, { waitUntil: "domcontentloaded" }); - // Editor should re-appear with the same content from local data - await editor.waitFor({ state: "visible", timeout: 30_000 }); - await expect(editor).toContainText("This content should survive offline", { timeout: 30_000 }); - }); + // Editor should re-appear with the same content from local data + await editor.waitFor({ state: "visible", timeout: 30_000 }); + await expect(editor).toContainText("This content should survive offline", { + timeout: 30_000, + }); + }); - test("app serves content when server is down", async ({ sbServer, page }) => { - // Load the app with service worker enabled - await page.goto(sbServer.url); - const editor = page.locator("#sb-editor .cm-content"); - await editor.waitFor({ state: "visible", timeout: 30_000 }); + test("app serves content when server is down", async ({ sbServer, page }) => { + // Load the app with service worker enabled + await page.goto(sbServer.url); + const editor = page.locator("#sb-editor .cm-content"); + await editor.waitFor({ state: "visible", timeout: 30_000 }); - // Wait for the SW to be active, controlling, and fully synced. - // The sync wait is critical: content may appear from server proxy - // before sync writes to IndexedDB. Without it, IndexedDB would be - // empty when we kill the server. - await waitForServiceWorkerReady(page); - await expect(editor).toContainText("This content should survive offline", { timeout: 30_000 }); - await waitForSyncComplete(page); + // Wait for the SW to be active, controlling, and fully synced. + // The sync wait is critical: content may appear from server proxy + // before sync writes to IndexedDB. Without it, IndexedDB would be + // empty when we kill the server. + await waitForServiceWorkerReady(page); + await expect(editor).toContainText("This content should survive offline", { + timeout: 30_000, + }); + await waitForSyncComplete(page); - // Kill the server — network is still up but the server is unreachable. - // This is the scenario from issue #1923: PWA works in airplane mode - // but not when the server is specifically down. - await sbServer.stop(); + // Kill the server — network is still up but the server is unreachable. + // This is the scenario from issue #1923: PWA works in airplane mode + // but not when the server is specifically down. + await sbServer.stop(); - // Navigate to same URL. The SW's fetch() to the server will fail with - // browser native errors ("Failed to fetch", etc). With the - // isNetworkError() fix, the SW should detect this and fall through to - // local data for /.fs requests, while alwaysProxy paths (/.config) - // correctly return 503 so the client falls back to localStorage cache. - await page.goto(sbServer.url, { waitUntil: "domcontentloaded" }); + // Navigate to same URL. The SW's fetch() to the server will fail with + // browser native errors ("Failed to fetch", etc). With the + // isNetworkError() fix, the SW should detect this and fall through to + // local data for /.fs requests, while alwaysProxy paths (/.config) + // correctly return 503 so the client falls back to localStorage cache. + await page.goto(sbServer.url, { waitUntil: "domcontentloaded" }); - // Editor should re-appear with the same content from local IndexedDB - await editor.waitFor({ state: "visible", timeout: 30_000 }); - await expect(editor).toContainText("This content should survive offline", { timeout: 30_000 }); - }); + // Editor should re-appear with the same content from local IndexedDB + await editor.waitFor({ state: "visible", timeout: 30_000 }); + await expect(editor).toContainText("This content should survive offline", { + timeout: 30_000, + }); + }); - test("app navigates to another page while offline", async ({ sbServer, page }) => { - // Load the app with service worker enabled - await page.goto(sbServer.url); - const editor = page.locator("#sb-editor .cm-content"); - await editor.waitFor({ state: "visible", timeout: 30_000 }); + test("app navigates to another page while offline", async ({ + sbServer, + page, + }) => { + // Load the app with service worker enabled + await page.goto(sbServer.url); + const editor = page.locator("#sb-editor .cm-content"); + await editor.waitFor({ state: "visible", timeout: 30_000 }); - // Wait for the SW and initial sync - await waitForServiceWorkerReady(page); - await expect(editor).toContainText("This content should survive offline", { timeout: 30_000 }); - await waitForSyncComplete(page); + // Wait for the SW and initial sync + await waitForServiceWorkerReady(page); + await expect(editor).toContainText("This content should survive offline", { + timeout: 30_000, + }); + await waitForSyncComplete(page); - // Navigate to TestPage to ensure it's synced locally, then back to index - await page.goto(`${sbServer.url}/TestPage`); - await expect(editor).toContainText("Offline page content here", { timeout: 30_000 }); - await page.goto(sbServer.url); - await expect(editor).toContainText("This content should survive offline", { timeout: 30_000 }); + // Navigate to TestPage to ensure it's synced locally, then back to index + await page.goto(`${sbServer.url}/TestPage`); + await expect(editor).toContainText("Offline page content here", { + timeout: 30_000, + }); + await page.goto(sbServer.url); + await expect(editor).toContainText("This content should survive offline", { + timeout: 30_000, + }); - // Go offline - await page.context().setOffline(true); + // Go offline + await page.context().setOffline(true); - // Navigate to TestPage while offline — the SW should serve it from IndexedDB - await page.goto(`${sbServer.url}/TestPage`, { waitUntil: "domcontentloaded" }); - await editor.waitFor({ state: "visible", timeout: 30_000 }); - await expect(editor).toContainText("Offline page content here", { timeout: 30_000 }); - }); + // Navigate to TestPage while offline — the SW should serve it from IndexedDB + await page.goto(`${sbServer.url}/TestPage`, { + waitUntil: "domcontentloaded", + }); + await editor.waitFor({ state: "visible", timeout: 30_000 }); + await expect(editor).toContainText("Offline page content here", { + timeout: 30_000, + }); + }); }); diff --git a/e2e/release-embedded.test.ts b/e2e/release-embedded.test.ts index d7ea23b9..6a8335d5 100644 --- a/e2e/release-embedded.test.ts +++ b/e2e/release-embedded.test.ts @@ -9,83 +9,83 @@ import { type ReleaseServer, startReleaseServer } from "./release-fixtures.ts"; * (`make build-rs`); see the `test-e2e-release` Makefile target. */ test.describe("Release binary: embedded bundle", () => { - let server: ReleaseServer | undefined; + let server: ReleaseServer | undefined; - test.afterEach(async () => { - await server?.stop(); - server = undefined; - }); + test.afterEach(async () => { + await server?.stop(); + server = undefined; + }); - test("embedded bundle boots and the editor works", async ({ page }) => { - server = await startReleaseServer(); + test("embedded bundle boots and the editor works", async ({ page }) => { + server = await startReleaseServer(); - await page.goto(`${server.url}/index?headless=1`); - await page.locator("#sb-editor .cm-editor").waitFor({ - state: "visible", - timeout: 30_000, - }); - await page.waitForFunction( - () => (globalThis as any).sbRuntime?.ready === true, - undefined, - { timeout: 30_000 }, - ); + await page.goto(`${server.url}/index?headless=1`); + await page.locator("#sb-editor .cm-editor").waitFor({ + state: "visible", + timeout: 30_000, + }); + await page.waitForFunction( + () => (globalThis as any).sbRuntime?.ready === true, + undefined, + { timeout: 30_000 }, + ); - // The embedded bundle ships the welcome content on an empty space. - const editor = page.locator("#sb-editor .cm-content"); - await expect(editor).toBeVisible(); - await expect(editor).toContainText( - "Welcome to the wondrous world of SilverBullet", - ); + // The embedded bundle ships the welcome content on an empty space. + const editor = page.locator("#sb-editor .cm-content"); + await expect(editor).toBeVisible(); + await expect(editor).toContainText( + "Welcome to the wondrous world of SilverBullet", + ); - // Type some text and confirm it persists across a reload. - await editor.click(); - await page.keyboard.press("End"); - await page.keyboard.press("Enter"); - await page.keyboard.type("Hello from the embedded bundle"); - await expect(editor).toContainText("Hello from the embedded bundle"); + // Type some text and confirm it persists across a reload. + await editor.click(); + await page.keyboard.press("End"); + await page.keyboard.press("Enter"); + await page.keyboard.type("Hello from the embedded bundle"); + await expect(editor).toContainText("Hello from the embedded bundle"); - // Wait for the save round-trip to the server, then read it back via the - // filesystem API to prove the edit was persisted by the release binary. - const pageNameSel = "#sb-current-page"; - await page.locator(`${pageNameSel}.sb-unsaved`).waitFor({ - state: "attached", - timeout: 10_000, - }); - await page.locator(`${pageNameSel}.sb-saved`).waitFor({ - state: "attached", - timeout: 10_000, - }); + // Wait for the save round-trip to the server, then read it back via the + // filesystem API to prove the edit was persisted by the release binary. + const pageNameSel = "#sb-current-page"; + await page.locator(`${pageNameSel}.sb-unsaved`).waitFor({ + state: "attached", + timeout: 10_000, + }); + await page.locator(`${pageNameSel}.sb-saved`).waitFor({ + state: "attached", + timeout: 10_000, + }); - const resp = await fetch(`${server.url}/.fs/index.md`); - expect(resp.ok).toBe(true); - const content = await resp.text(); - expect(content).toContain("Hello from the embedded bundle"); - }); + const resp = await fetch(`${server.url}/.fs/index.md`); + expect(resp.ok).toBe(true); + const content = await resp.text(); + expect(content).toContain("Hello from the embedded bundle"); + }); - test("browser login flow", async ({ page }) => { - server = await startReleaseServer({ SB_USER: "alice:s3cret" }); + test("browser login flow", async ({ page }) => { + server = await startReleaseServer({ SB_USER: "alice:s3cret" }); - // An unauthenticated browser hitting a protected page is redirected to - // the login page. - await page.goto(`${server.url}/`); - await expect(page.locator("#username")).toBeVisible({ timeout: 30_000 }); - await expect(page.locator("#password")).toBeVisible(); + // An unauthenticated browser hitting a protected page is redirected to + // the login page. + await page.goto(`${server.url}/`); + await expect(page.locator("#username")).toBeVisible({ timeout: 30_000 }); + await expect(page.locator("#password")).toBeVisible(); - // Log in with the configured credentials. - await page.locator("#username").fill("alice"); - await page.locator("#password").fill("s3cret"); - await page.locator("#login").getByRole("button", { name: "Login" }).click(); + // Log in with the configured credentials. + await page.locator("#username").fill("alice"); + await page.locator("#password").fill("s3cret"); + await page.locator("#login").getByRole("button", { name: "Login" }).click(); - // On success the login script redirects into the app; the editor should - // become visible once the client has booted (first-load indexing of the - // bundled Library can take a few seconds). - await page.locator("#sb-editor .cm-editor").waitFor({ - state: "visible", - timeout: 45_000, - }); + // On success the login script redirects into the app; the editor should + // become visible once the client has booted (first-load indexing of the + // bundled Library can take a few seconds). + await page.locator("#sb-editor .cm-editor").waitFor({ + state: "visible", + timeout: 45_000, + }); - // Logging out lands us back on the login page. - await page.goto(`${server.url}/.logout`); - await expect(page.locator("#username")).toBeVisible({ timeout: 30_000 }); - }); + // Logging out lands us back on the login page. + await page.goto(`${server.url}/.logout`); + await expect(page.locator("#username")).toBeVisible({ timeout: 30_000 }); + }); }); diff --git a/e2e/release-fixtures.ts b/e2e/release-fixtures.ts index 289e73cd..e406f542 100644 --- a/e2e/release-fixtures.ts +++ b/e2e/release-fixtures.ts @@ -6,11 +6,11 @@ import { getFreePort, waitForServer } from "./fixtures.ts"; /** A running release-binary server instance. */ export type ReleaseServer = { - url: string; - port: number; - spaceDir: string; - /** Stop the server process and clean up its temporary space directory. */ - stop: () => Promise; + url: string; + port: number; + spaceDir: string; + /** Stop the server process and clean up its temporary space directory. */ + stop: () => Promise; }; /** @@ -31,64 +31,66 @@ export type ReleaseServer = { * `{ SB_USER: "alice:s3cret" }` to enable the browser login flow. */ export async function startReleaseServer( - extraEnv: Record = {}, + extraEnv: Record = {}, ): Promise { - const spaceDir = await mkdtemp(join(tmpdir(), "sb-e2e-release-")); - const port = await getFreePort(); + const spaceDir = await mkdtemp(join(tmpdir(), "sb-e2e-release-")); + const port = await getFreePort(); - // `import.meta.dirname` is `/e2e`; the release binary lives at - // `/target/release/silverbullet`. - const repoRoot = join(import.meta.dirname, ".."); - const binaryPath = join(repoRoot, "target", "release", "silverbullet"); + // `import.meta.dirname` is `/e2e`; the release binary lives at + // `/target/release/silverbullet`. + const repoRoot = join(import.meta.dirname, ".."); + const binaryPath = join(repoRoot, "target", "release", "silverbullet"); - const proc: ChildProcess = spawn( - binaryPath, - [spaceDir, "-p", String(port), "-L", "127.0.0.1"], - { - cwd: repoRoot, - stdio: ["ignore", "pipe", "pipe"], - env: { - ...process.env, - SB_DISABLE_SERVICE_WORKER: "1", - SB_RUNTIME_API: "0", - ...extraEnv, - }, - }, - ); + const proc: ChildProcess = spawn( + binaryPath, + [spaceDir, "-p", String(port), "-L", "127.0.0.1"], + { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + SB_DISABLE_SERVICE_WORKER: "1", + SB_RUNTIME_API: "0", + ...extraEnv, + }, + }, + ); - let serverOutput = ""; - proc.stdout?.on("data", (d: Buffer) => { - serverOutput += d.toString(); - }); - proc.stderr?.on("data", (d: Buffer) => { - serverOutput += d.toString(); - }); + let serverOutput = ""; + proc.stdout?.on("data", (d: Buffer) => { + serverOutput += d.toString(); + }); + proc.stderr?.on("data", (d: Buffer) => { + serverOutput += d.toString(); + }); - const url = `http://127.0.0.1:${port}`; + const url = `http://127.0.0.1:${port}`; - let stopped = false; - const stop = (): Promise => { - if (stopped) return Promise.resolve(); - stopped = true; - return new Promise((resolve) => { - const timer = setTimeout(() => { - proc.kill("SIGKILL"); - resolve(); - }, 5000); - proc.on("exit", () => { - clearTimeout(timer); - resolve(); - }); - proc.kill("SIGTERM"); - }).then(() => rm(spaceDir, { recursive: true, force: true })); - }; + let stopped = false; + const stop = (): Promise => { + if (stopped) return Promise.resolve(); + stopped = true; + return new Promise((resolve) => { + const timer = setTimeout(() => { + proc.kill("SIGKILL"); + resolve(); + }, 5000); + proc.on("exit", () => { + clearTimeout(timer); + resolve(); + }); + proc.kill("SIGTERM"); + }).then(() => rm(spaceDir, { recursive: true, force: true })); + }; - try { - await waitForServer(`${url}/.ping`); - } catch (err) { - await stop(); - throw new Error(`Release server failed to start. Output:\n${serverOutput}\n${err}`); - } + try { + await waitForServer(`${url}/.ping`); + } catch (err) { + await stop(); + throw new Error( + `Release server failed to start. Output:\n${serverOutput}\n${err}`, + ); + } - return { url, port, spaceDir, stop }; + return { url, port, spaceDir, stop }; } diff --git a/e2e/rich-text-paste.test.ts b/e2e/rich-text-paste.test.ts index dcff870a..6183b334 100644 --- a/e2e/rich-text-paste.test.ts +++ b/e2e/rich-text-paste.test.ts @@ -1,84 +1,96 @@ import { expect, mod, test, waitForSaveAndReadFromServer } from "./fixtures.ts"; test.describe("Rich text paste", () => { - test("first paste after load converts HTML to markdown", async ({ sbPage, sbServer }) => { - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Welcome"); + test("first paste after load converts HTML to markdown", async ({ + sbPage, + sbServer, + }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Welcome"); - // Navigate to a fresh page - await sbPage.keyboard.press(`${mod}+k`); - await sbPage.locator(".sb-modal-box input.sb-input").click(); - await sbPage.keyboard.type("Paste Test", { delay: 30 }); - await sbPage.keyboard.press("Shift+Enter"); - await expect(editor).toHaveText(""); - await editor.click(); + // Navigate to a fresh page + await sbPage.keyboard.press(`${mod}+k`); + await sbPage.locator(".sb-modal-box input.sb-input").click(); + await sbPage.keyboard.type("Paste Test", { delay: 30 }); + await sbPage.keyboard.press("Shift+Enter"); + await expect(editor).toHaveText(""); + await editor.click(); - // Simulate pasting rich text (HTML) via a synthetic ClipboardEvent. - // This is the very first paste after page load, which used to fail - // when turndown was loaded via dynamic import(). - const html = "Hello world"; - const plain = "Hello world"; - await sbPage.evaluate( - ({ html, plain }) => { - const clipboardData = new DataTransfer(); - clipboardData.setData("text/html", html); - clipboardData.setData("text/plain", plain); - const event = new ClipboardEvent("paste", { - bubbles: true, - cancelable: true, - }); - // Firefox ignores clipboardData in the constructor, so set it via defineProperty - Object.defineProperty(event, "clipboardData", { value: clipboardData }); - document.querySelector("#sb-editor .cm-content")! - .dispatchEvent(event); - }, - { html, plain }, - ); + // Simulate pasting rich text (HTML) via a synthetic ClipboardEvent. + // This is the very first paste after page load, which used to fail + // when turndown was loaded via dynamic import(). + const html = "Hello world"; + const plain = "Hello world"; + await sbPage.evaluate( + ({ html, plain }) => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/html", html); + clipboardData.setData("text/plain", plain); + const event = new ClipboardEvent("paste", { + bubbles: true, + cancelable: true, + }); + // Firefox ignores clipboardData in the constructor, so set it via defineProperty + Object.defineProperty(event, "clipboardData", { value: clipboardData }); + document.querySelector("#sb-editor .cm-content")!.dispatchEvent(event); + }, + { html, plain }, + ); - // Verify the pasted content appears in the editor (bold markers are - // hidden by clean mode, so check for the visible rendered text) - await expect(editor).toContainText("Hello"); - await expect(editor).toContainText("world"); + // Verify the pasted content appears in the editor (bold markers are + // hidden by clean mode, so check for the visible rendered text) + await expect(editor).toContainText("Hello"); + await expect(editor).toContainText("world"); - // Verify the raw markdown saved to the server has proper formatting - const content = await waitForSaveAndReadFromServer(sbPage, sbServer, "Paste Test.md"); - expect(content).toContain("**Hello**"); - expect(content).toContain("*world*"); - }); + // Verify the raw markdown saved to the server has proper formatting + const content = await waitForSaveAndReadFromServer( + sbPage, + sbServer, + "Paste Test.md", + ); + expect(content).toContain("**Hello**"); + expect(content).toContain("*world*"); + }); - test("paste with link converts to markdown link", async ({ sbPage, sbServer }) => { - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Welcome"); + test("paste with link converts to markdown link", async ({ + sbPage, + sbServer, + }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Welcome"); - await sbPage.keyboard.press(`${mod}+k`); - await sbPage.locator(".sb-modal-box input.sb-input").click(); - await sbPage.keyboard.type("Paste Link Test", { delay: 30 }); - await sbPage.keyboard.press("Shift+Enter"); - await expect(editor).toHaveText(""); - await editor.click(); + await sbPage.keyboard.press(`${mod}+k`); + await sbPage.locator(".sb-modal-box input.sb-input").click(); + await sbPage.keyboard.type("Paste Link Test", { delay: 30 }); + await sbPage.keyboard.press("Shift+Enter"); + await expect(editor).toHaveText(""); + await editor.click(); - const html = '
    Example'; - const plain = "Example"; - await sbPage.evaluate( - ({ html, plain }) => { - const clipboardData = new DataTransfer(); - clipboardData.setData("text/html", html); - clipboardData.setData("text/plain", plain); - const event = new ClipboardEvent("paste", { - bubbles: true, - cancelable: true, - }); - // Firefox ignores clipboardData in the constructor, so set it via defineProperty - Object.defineProperty(event, "clipboardData", { value: clipboardData }); - document.querySelector("#sb-editor .cm-content")! - .dispatchEvent(event); - }, - { html, plain }, - ); + const html = 'Example'; + const plain = "Example"; + await sbPage.evaluate( + ({ html, plain }) => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/html", html); + clipboardData.setData("text/plain", plain); + const event = new ClipboardEvent("paste", { + bubbles: true, + cancelable: true, + }); + // Firefox ignores clipboardData in the constructor, so set it via defineProperty + Object.defineProperty(event, "clipboardData", { value: clipboardData }); + document.querySelector("#sb-editor .cm-content")!.dispatchEvent(event); + }, + { html, plain }, + ); - await expect(editor).toContainText("[Example](https://example.com)"); + await expect(editor).toContainText("[Example](https://example.com)"); - const content = await waitForSaveAndReadFromServer(sbPage, sbServer, "Paste Link Test.md"); - expect(content).toContain("[Example](https://example.com)"); - }); + const content = await waitForSaveAndReadFromServer( + sbPage, + sbServer, + "Paste Link Test.md", + ); + expect(content).toContain("[Example](https://example.com)"); + }); }); diff --git a/e2e/system-reboot.test.ts b/e2e/system-reboot.test.ts index 32083026..a30ebdc4 100644 --- a/e2e/system-reboot.test.ts +++ b/e2e/system-reboot.test.ts @@ -5,219 +5,279 @@ import { expect, gotoSilverBulletPage, test } from "./fixtures.ts"; /** Run a Space Lua script in the live in-page runtime (headless mode). */ async function runLua(page: Page, script: string): Promise { - return await page.evaluate( - (s) => (globalThis as any).sbRuntime.evalLuaScript(s), - script, - ); + return await page.evaluate( + (s) => (globalThis as any).sbRuntime.evalLuaScript(s), + script, + ); } /** Invoke system.reboot() via the in-page runtime. Resolves when ready. */ async function reboot(page: Page): Promise { - await runLua(page, "system.reboot()"); + await runLua(page, "system.reboot()"); } /** Run a SLIQ expression and return the resulting array. */ async function query(page: Page, sliq: string): Promise { - const result = await runLua(page, `return query[[${sliq}]]`); - if (result === null || result === undefined) return []; - if (Array.isArray(result)) return result; - // An empty Lua table has length 0, so luaValueToJS serializes it as {} not - // []. Treat any empty plain object as an empty result set. - if ( - typeof result === "object" && - Object.keys(result as object).length === 0 - ) { - return []; - } - return result as unknown[]; + const result = await runLua(page, `return query[[${sliq}]]`); + if (result === null || result === undefined) return []; + if (Array.isArray(result)) return result; + // An empty Lua table has length 0, so luaValueToJS serializes it as {} not + // []. Treat any empty plain object as an empty result set. + if ( + typeof result === "object" && + Object.keys(result as object).length === 0 + ) { + return []; + } + return result as unknown[]; } /** Capture all console messages the page emits (live-updating array). */ function captureConsole(page: Page): { - messages: { type: string; text: string }[]; + messages: { type: string; text: string }[]; } { - const messages: { type: string; text: string }[] = []; - page.on("console", (msg) => messages.push({ type: msg.type(), text: msg.text() })); - return { messages }; + const messages: { type: string; text: string }[] = []; + page.on("console", (msg) => + messages.push({ type: msg.type(), text: msg.text() }), + ); + return { messages }; } /** Poll until `predicate()` is true or the timeout elapses. */ async function waitFor( - predicate: () => boolean, - timeoutMs = 15_000, + predicate: () => boolean, + timeoutMs = 15_000, ): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (predicate()) return; - await new Promise((r) => setTimeout(r, 100)); - } - throw new Error(`Condition not met within ${timeoutMs}ms`); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error(`Condition not met within ${timeoutMs}ms`); } test.describe("system.reboot()", () => { - test.use({ spaceFiles: { "index.md": "# Index\nEntry point.\n" } }); + test.use({ spaceFiles: { "index.md": "# Index\nEntry point.\n" } }); - test("out-of-band create: a new on-disk page is indexed after reboot", async ({ sbServer, page }) => { - await gotoSilverBulletPage(page, sbServer); + test("out-of-band create: a new on-disk page is indexed after reboot", async ({ + sbServer, + page, + }) => { + await gotoSilverBulletPage(page, sbServer); - // The page does not exist yet. - expect( - await query(page, `from index.tag "page" where name == "OutOfBand" select name`), - ).toEqual([]); + // The page does not exist yet. + expect( + await query( + page, + `from index.tag "page" where name == "OutOfBand" select name`, + ), + ).toEqual([]); - // Create a markdown file directly on disk — no editor, no index event. - await writeFile( - join(sbServer.spaceDir, "OutOfBand.md"), - "# Out Of Band\nCreated directly on disk.\n", - ); + // Create a markdown file directly on disk — no editor, no index event. + await writeFile( + join(sbServer.spaceDir, "OutOfBand.md"), + "# Out Of Band\nCreated directly on disk.\n", + ); - await reboot(page); + await reboot(page); - // No sleep: the change must already be indexed. - expect( - await query(page, `from index.tag "page" where name == "OutOfBand" select name`), - ).toEqual(["OutOfBand"]); - }); + // No sleep: the change must already be indexed. + expect( + await query( + page, + `from index.tag "page" where name == "OutOfBand" select name`, + ), + ).toEqual(["OutOfBand"]); + }); - test("out-of-band modify: index reflects new on-disk content after reboot", async ({ sbServer, page }) => { - const notePath = join(sbServer.spaceDir, "Note.md"); - await writeFile(notePath, "# Note\noriginal content\n"); - await gotoSilverBulletPage(page, sbServer); + test("out-of-band modify: index reflects new on-disk content after reboot", async ({ + sbServer, + page, + }) => { + const notePath = join(sbServer.spaceDir, "Note.md"); + await writeFile(notePath, "# Note\noriginal content\n"); + await gotoSilverBulletPage(page, sbServer); - // Original is indexed; the new tag is not present yet. - expect( - await query(page, `from index.tag "page" where name == "Note" select name`), - ).toEqual(["Note"]); - expect(await query(page, `from index.tag "rebootmodify" select name`)).toEqual([]); + // Original is indexed; the new tag is not present yet. + expect( + await query( + page, + `from index.tag "page" where name == "Note" select name`, + ), + ).toEqual(["Note"]); + expect( + await query(page, `from index.tag "rebootmodify" select name`), + ).toEqual([]); - // Rewrite the file on disk, adding a frontmatter tag. - await writeFile( - notePath, - "---\ntags: rebootmodify\n---\n# Note\nupdated content\n", - ); - await reboot(page); + // Rewrite the file on disk, adding a frontmatter tag. + await writeFile( + notePath, + "---\ntags: rebootmodify\n---\n# Note\nupdated content\n", + ); + await reboot(page); - // The page is now tagged from its updated on-disk content. - expect(await query(page, `from index.tag "rebootmodify" select name`)).toEqual(["Note"]); - }); + // The page is now tagged from its updated on-disk content. + expect( + await query(page, `from index.tag "rebootmodify" select name`), + ).toEqual(["Note"]); + }); - test("out-of-band delete: removed file's objects are gone after reboot", async ({ sbServer, page }) => { - const tempPath = join(sbServer.spaceDir, "Temp.md"); - await writeFile(tempPath, "# Temp\ntemporary page\n"); - await gotoSilverBulletPage(page, sbServer); + test("out-of-band delete: removed file's objects are gone after reboot", async ({ + sbServer, + page, + }) => { + const tempPath = join(sbServer.spaceDir, "Temp.md"); + await writeFile(tempPath, "# Temp\ntemporary page\n"); + await gotoSilverBulletPage(page, sbServer); - expect( - await query(page, `from index.tag "page" where name == "Temp" select name`), - ).toEqual(["Temp"]); + expect( + await query( + page, + `from index.tag "page" where name == "Temp" select name`, + ), + ).toEqual(["Temp"]); - // Remove the file directly on disk. - await rm(tempPath); - await reboot(page); + // Remove the file directly on disk. + await rm(tempPath); + await reboot(page); - expect( - await query(page, `from index.tag "page" where name == "Temp" select name`), - ).toEqual([]); - }); + expect( + await query( + page, + `from index.tag "page" where name == "Temp" select name`, + ), + ).toEqual([]); + }); - test("drain-before-return: query immediately after reboot sees the change (no sleep)", async ({ sbServer, page }) => { - await gotoSilverBulletPage(page, sbServer); - await writeFile(join(sbServer.spaceDir, "Racy.md"), "# Racy\nrace check\n"); + test("drain-before-return: query immediately after reboot sees the change (no sleep)", async ({ + sbServer, + page, + }) => { + await gotoSilverBulletPage(page, sbServer); + await writeFile(join(sbServer.spaceDir, "Racy.md"), "# Racy\nrace check\n"); - // Before reboot the index must NOT yet know about the new file — - // proving the post-reboot result is caused by reboot, not by the - // background fetchFileList interval. (Queried well within the 10s - // interval to keep this deterministic.) - expect( - await query(page, `from index.tag "page" where name == "Racy" select name`), - ).toEqual([]); + // Before reboot the index must NOT yet know about the new file — + // proving the post-reboot result is caused by reboot, not by the + // background fetchFileList interval. (Queried well within the 10s + // interval to keep this deterministic.) + expect( + await query( + page, + `from index.tag "page" where name == "Racy" select name`, + ), + ).toEqual([]); - await reboot(page); + await reboot(page); - // No sleep between reboot resolving and this query: awaitEmptyQueue - // must have blocked until indexing finished. - expect( - await query(page, `from index.tag "page" where name == "Racy" select name`), - ).toEqual(["Racy"]); - }); + // No sleep between reboot resolving and this query: awaitEmptyQueue + // must have blocked until indexing finished. + expect( + await query( + page, + `from index.tag "page" where name == "Racy" select name`, + ), + ).toEqual(["Racy"]); + }); - test("script edits applied: a new Library Space Lua function is live after reboot", async ({ sbServer, page }) => { - await gotoSilverBulletPage(page, sbServer); + test("script edits applied: a new Library Space Lua function is live after reboot", async ({ + sbServer, + page, + }) => { + await gotoSilverBulletPage(page, sbServer); - // Function is not defined yet. - expect(await runLua(page, "return type(myRebootProbe)")).toBe("nil"); + // Function is not defined yet. + expect(await runLua(page, "return type(myRebootProbe)")).toBe("nil"); - // Add a Library space-lua script directly on disk. - await mkdir(join(sbServer.spaceDir, "Library"), { recursive: true }); - await writeFile( - join(sbServer.spaceDir, "Library", "Probe.md"), - '```space-lua\nfunction myRebootProbe()\n return "probe-ok"\nend\n```\n', - ); + // Add a Library space-lua script directly on disk. + await mkdir(join(sbServer.spaceDir, "Library"), { recursive: true }); + await writeFile( + join(sbServer.spaceDir, "Library", "Probe.md"), + '```space-lua\nfunction myRebootProbe()\n return "probe-ok"\nend\n```\n', + ); - await reboot(page); + await reboot(page); - // The function is indexed and applied to the live Space Lua env. - expect(await runLua(page, "return myRebootProbe()")).toBe("probe-ok"); - }); + // The function is indexed and applied to the live Space Lua env. + expect(await runLua(page, "return myRebootProbe()")).toBe("probe-ok"); + }); - test("error surfacing: a Lua load error appears in the console after reboot", async ({ sbServer, page }) => { - const consoleState = captureConsole(page); - await gotoSilverBulletPage(page, sbServer); + test("error surfacing: a Lua load error appears in the console after reboot", async ({ + sbServer, + page, + }) => { + const consoleState = captureConsole(page); + await gotoSilverBulletPage(page, sbServer); - // Add a Library space-lua script with a syntax error. - await mkdir(join(sbServer.spaceDir, "Library"), { recursive: true }); - await writeFile( - join(sbServer.spaceDir, "Library", "Broken.md"), - "```space-lua\nfunction broken( this is not valid lua\n```\n", - ); + // Add a Library space-lua script with a syntax error. + await mkdir(join(sbServer.spaceDir, "Library"), { recursive: true }); + await writeFile( + join(sbServer.spaceDir, "Library", "Broken.md"), + "```space-lua\nfunction broken( this is not valid lua\n```\n", + ); - // reboot itself resolves cleanly — the failure is only in the logs. - await reboot(page); + // reboot itself resolves cleanly — the failure is only in the logs. + await reboot(page); - await waitFor(() => - consoleState.messages.some( - (m) => - m.type === "error" && - /Error (loading|evaluating|reloading) (Lua )?script/i.test(m.text), - ), - ); - }); + await waitFor(() => + consoleState.messages.some( + (m) => + m.type === "error" && + /Error (loading|evaluating|reloading) (Lua )?script/i.test(m.text), + ), + ); + }); - test("no-op: reboot with nothing changed completes promptly and keeps the index intact", async ({ sbServer, page }) => { - await gotoSilverBulletPage(page, sbServer); + test("no-op: reboot with nothing changed completes promptly and keeps the index intact", async ({ + sbServer, + page, + }) => { + await gotoSilverBulletPage(page, sbServer); - const start = Date.now(); - await reboot(page); - expect(Date.now() - start).toBeLessThan(15_000); + const start = Date.now(); + await reboot(page); + expect(Date.now() - start).toBeLessThan(15_000); - // The pre-existing index is still intact. - expect( - await query(page, `from index.tag "page" where name == "index" select name`), - ).toEqual(["index"]); - }); + // The pre-existing index is still intact. + expect( + await query( + page, + `from index.tag "page" where name == "index" select name`, + ), + ).toEqual(["index"]); + }); - test("in-flight concurrency: reboot converges while file operations are in flight", async ({ sbServer, page }) => { - await gotoSilverBulletPage(page, sbServer); - await writeFile(join(sbServer.spaceDir, "Concurrent.md"), "# Concurrent\nconcurrent check\n"); + test("in-flight concurrency: reboot converges while file operations are in flight", async ({ + sbServer, + page, + }) => { + await gotoSilverBulletPage(page, sbServer); + await writeFile( + join(sbServer.spaceDir, "Concurrent.md"), + "# Concurrent\nconcurrent check\n", + ); - // Drive operationCount > 0 (the deferred-fetchFileList path) by firing a - // burst of concurrent reads, and invoke system.reboot in the same batch. - await page.evaluate(async () => { - const client = (globalThis as any).client; - const esp = client.eventedSpacePrimitives; - const reads: Promise[] = []; - for (let i = 0; i < 50; i++) { - reads.push(esp.readFile("index.md").catch(() => {})); - } - await Promise.all([ - ...reads, - client.clientSystem.localSyscall("system.reboot", []), - ]); - }); + // Drive operationCount > 0 (the deferred-fetchFileList path) by firing a + // burst of concurrent reads, and invoke system.reboot in the same batch. + await page.evaluate(async () => { + const client = (globalThis as any).client; + const esp = client.eventedSpacePrimitives; + const reads: Promise[] = []; + for (let i = 0; i < 50; i++) { + reads.push(esp.readFile("index.md").catch(() => {})); + } + await Promise.all([ + ...reads, + client.clientSystem.localSyscall("system.reboot", []), + ]); + }); - // No sleep: despite the in-flight reads, the change must be indexed by - // the time reboot resolved. - expect( - await query(page, `from index.tag "page" where name == "Concurrent" select name`), - ).toEqual(["Concurrent"]); - }); + // No sleep: despite the in-flight reads, the change must be indexed by + // the time reboot resolved. + expect( + await query( + page, + `from index.tag "page" where name == "Concurrent" select name`, + ), + ).toEqual(["Concurrent"]); + }); }); diff --git a/e2e/tasks.test.ts b/e2e/tasks.test.ts index 04ed372d..5964cac7 100644 --- a/e2e/tasks.test.ts +++ b/e2e/tasks.test.ts @@ -1,55 +1,64 @@ import { - expect, - gotoSilverBulletPage, - test, - waitForSaveAndReadFromServer, + expect, + gotoSilverBulletPage, + test, + waitForSaveAndReadFromServer, } from "./fixtures.ts"; test.describe("Task management", () => { - test.use({ - spaceFiles: { - "Tasks.md": "# My Tasks\n* [ ] Buy groceries\n* [ ] Write tests\n* [x] Already done", - }, - }); + test.use({ + spaceFiles: { + "Tasks.md": + "# My Tasks\n* [ ] Buy groceries\n* [ ] Write tests\n* [x] Already done", + }, + }); - test("task checkboxes render", async ({ sbServer, page }) => { - await gotoSilverBulletPage(page, sbServer, "Tasks"); - const editor = page.locator("#sb-editor .cm-content"); + test("task checkboxes render", async ({ sbServer, page }) => { + await gotoSilverBulletPage(page, sbServer, "Tasks"); + const editor = page.locator("#sb-editor .cm-content"); - await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue("Tasks"); - await expect(editor).toContainText("Buy groceries"); + await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue( + "Tasks", + ); + await expect(editor).toContainText("Buy groceries"); - // Task checkboxes render as - const checkboxes = page.locator(".sb-checkbox input[type='checkbox']"); - await expect(checkboxes.first()).toBeVisible({ timeout: 10_000 }); - await expect(checkboxes).toHaveCount(3); + // Task checkboxes render as + const checkboxes = page.locator(".sb-checkbox input[type='checkbox']"); + await expect(checkboxes.first()).toBeVisible({ timeout: 10_000 }); + await expect(checkboxes).toHaveCount(3); - // Third checkbox ("Already done") should be checked - await expect(checkboxes.nth(2)).toBeChecked(); - // First two should be unchecked - await expect(checkboxes.nth(0)).not.toBeChecked(); - await expect(checkboxes.nth(1)).not.toBeChecked(); - }); + // Third checkbox ("Already done") should be checked + await expect(checkboxes.nth(2)).toBeChecked(); + // First two should be unchecked + await expect(checkboxes.nth(0)).not.toBeChecked(); + await expect(checkboxes.nth(1)).not.toBeChecked(); + }); - test("toggle task state saves to server", async ({ sbServer, page }) => { - await gotoSilverBulletPage(page, sbServer, "Tasks"); - const editor = page.locator("#sb-editor .cm-content"); + test("toggle task state saves to server", async ({ sbServer, page }) => { + await gotoSilverBulletPage(page, sbServer, "Tasks"); + const editor = page.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Buy groceries"); + await expect(editor).toContainText("Buy groceries"); - // Wait for checkboxes to render - const firstCheckbox = page.locator(".sb-checkbox input[type='checkbox']").first(); - await expect(firstCheckbox).toBeVisible({ timeout: 10_000 }); - await expect(firstCheckbox).not.toBeChecked(); + // Wait for checkboxes to render + const firstCheckbox = page + .locator(".sb-checkbox input[type='checkbox']") + .first(); + await expect(firstCheckbox).toBeVisible({ timeout: 10_000 }); + await expect(firstCheckbox).not.toBeChecked(); - // Click the first checkbox to toggle it - await firstCheckbox.click(); + // Click the first checkbox to toggle it + await firstCheckbox.click(); - // Should now be checked - await expect(firstCheckbox).toBeChecked(); + // Should now be checked + await expect(firstCheckbox).toBeChecked(); - // Verify the change is saved to the server - const content = await waitForSaveAndReadFromServer(page, sbServer, "Tasks.md"); - expect(content).toMatch(/\* \[x\] Buy groceries/); - }); + // Verify the change is saved to the server + const content = await waitForSaveAndReadFromServer( + page, + sbServer, + "Tasks.md", + ); + expect(content).toMatch(/\* \[x\] Buy groceries/); + }); }); diff --git a/e2e/widget-arrow-key-entry.test.ts b/e2e/widget-arrow-key-entry.test.ts index 40884695..83f94e2b 100644 --- a/e2e/widget-arrow-key-entry.test.ts +++ b/e2e/widget-arrow-key-entry.test.ts @@ -7,8 +7,8 @@ import { expect, gotoSilverBulletPage, test } from "./fixtures.ts"; test.use({ - spaceFiles: { - "QueryEntry.md": `Some intro text. + spaceFiles: { + "QueryEntry.md": `Some intro text. \${query[[ from p = index.pages() @@ -18,7 +18,7 @@ test.use({ Trailing text. `, - "TableEntry.md": `Some intro text. + "TableEntry.md": `Some intro text. | Header A | Header B | |----------|----------| @@ -29,102 +29,100 @@ Trailing text. Trailing text. `, - }, + }, }); async function waitForBlockWidget( - page: import("@playwright/test").Page, - docLine: number, + page: import("@playwright/test").Page, + docLine: number, ) { - await page.waitForFunction( - (line) => { - const v = (globalThis as any).client?.editorView; - if (!v || v.state.doc.lines < line) return false; - return v.lineBlockAt(v.state.doc.line(line).from).height > 10; - }, - docLine, - { timeout: 10_000 }, - ); + await page.waitForFunction( + (line) => { + const v = (globalThis as any).client?.editorView; + if (!v || v.state.doc.lines < line) return false; + return v.lineBlockAt(v.state.doc.line(line).from).height > 10; + }, + docLine, + { timeout: 10_000 }, + ); } async function walk( - page: import("@playwright/test").Page, - startLine: number, - key: "ArrowUp" | "ArrowDown", - steps: number, + page: import("@playwright/test").Page, + startLine: number, + key: "ArrowUp" | "ArrowDown", + steps: number, ) { - return await page.evaluate( - async (args) => { - const v = (globalThis as any).client.editorView; - v.focus(); - v.dispatch({ - selection: { anchor: v.state.doc.line(args.startLine).from }, - }); - await new Promise((r) => setTimeout(r, 50)); - const trail: number[] = [ - v.state.doc.lineAt(v.state.selection.main.head).number, - ]; - for (let i = 0; i < args.steps; i++) { - const ev = new KeyboardEvent("keydown", { - key: args.key, - bubbles: true, - cancelable: true, - }); - v.contentDOM.dispatchEvent(ev); - await new Promise((r) => setTimeout(r, 20)); - trail.push( - v.state.doc.lineAt(v.state.selection.main.head).number, - ); - } - return trail; - }, - { startLine, key, steps }, - ); + return await page.evaluate( + async (args) => { + const v = (globalThis as any).client.editorView; + v.focus(); + v.dispatch({ + selection: { anchor: v.state.doc.line(args.startLine).from }, + }); + await new Promise((r) => setTimeout(r, 50)); + const trail: number[] = [ + v.state.doc.lineAt(v.state.selection.main.head).number, + ]; + for (let i = 0; i < args.steps; i++) { + const ev = new KeyboardEvent("keydown", { + key: args.key, + bubbles: true, + cancelable: true, + }); + v.contentDOM.dispatchEvent(ev); + await new Promise((r) => setTimeout(r, 20)); + trail.push(v.state.doc.lineAt(v.state.selection.main.head).number); + } + return trail; + }, + { startLine, key, steps }, + ); } test("ArrowDown enters multi-line query block at its first source line", async ({ - sbServer, - page, + sbServer, + page, }) => { - await gotoSilverBulletPage(page, sbServer, "QueryEntry"); - await waitForBlockWidget(page, 3); // `${query[[` line + await gotoSilverBulletPage(page, sbServer, "QueryEntry"); + await waitForBlockWidget(page, 3); // `${query[[` line - const trail = await walk(page, 2, "ArrowDown", 5); - // From the blank line (2) descend into lines 3..7 one at a time. - expect(trail).toEqual([2, 3, 4, 5, 6, 7]); + const trail = await walk(page, 2, "ArrowDown", 5); + // From the blank line (2) descend into lines 3..7 one at a time. + expect(trail).toEqual([2, 3, 4, 5, 6, 7]); }); test("ArrowUp enters multi-line query block at its last source line", async ({ - sbServer, - page, + sbServer, + page, }) => { - await gotoSilverBulletPage(page, sbServer, "QueryEntry"); - await waitForBlockWidget(page, 3); + await gotoSilverBulletPage(page, sbServer, "QueryEntry"); + await waitForBlockWidget(page, 3); - // Line 9 is the blank line after `]]}` (line 8). - const trail = await walk(page, 9, "ArrowUp", 6); - expect(trail).toEqual([9, 8, 7, 6, 5, 4, 3]); + // Line 9 is the blank line after `]]}` (line 8). + const trail = await walk(page, 9, "ArrowUp", 6); + expect(trail).toEqual([9, 8, 7, 6, 5, 4, 3]); }); test("ArrowUp enters multi-line table at its last row", async ({ - sbServer, - page, + sbServer, + page, }) => { - await gotoSilverBulletPage(page, sbServer, "TableEntry"); - await waitForBlockWidget(page, 3); // table header line + await gotoSilverBulletPage(page, sbServer, "TableEntry"); + await waitForBlockWidget(page, 3); // table header line - // Line 9 is blank line after the table body (lines 3-8). - const trail = await walk(page, 9, "ArrowUp", 6); - expect(trail).toEqual([9, 8, 7, 6, 5, 4, 3]); + // Line 9 is blank line after the table body (lines 3-8). + const trail = await walk(page, 9, "ArrowUp", 6); + expect(trail).toEqual([9, 8, 7, 6, 5, 4, 3]); }); test("ArrowDown enters multi-line table at its first row", async ({ - sbServer, - page, + sbServer, + page, }) => { - await gotoSilverBulletPage(page, sbServer, "TableEntry"); - await waitForBlockWidget(page, 3); + await gotoSilverBulletPage(page, sbServer, "TableEntry"); + await waitForBlockWidget(page, 3); - const trail = await walk(page, 2, "ArrowDown", 5); - expect(trail).toEqual([2, 3, 4, 5, 6, 7]); + const trail = await walk(page, 2, "ArrowDown", 5); + expect(trail).toEqual([2, 3, 4, 5, 6, 7]); }); diff --git a/e2e/widget-offscreen-arrow-skip.test.ts b/e2e/widget-offscreen-arrow-skip.test.ts index d2890440..032d4ead 100644 --- a/e2e/widget-offscreen-arrow-skip.test.ts +++ b/e2e/widget-offscreen-arrow-skip.test.ts @@ -5,17 +5,17 @@ import { expect, gotoSilverBulletPage, test } from "./fixtures.ts"; function manyPageSeed(n: number): Record { - const out: Record = {}; - for (let i = 1; i <= n; i++) { - out[`Page${i}.md`] = `#page\n\nThis is page ${i}\n`; - } - return out; + const out: Record = {}; + for (let i = 1; i <= n; i++) { + out[`Page${i}.md`] = `#page\n\nThis is page ${i}\n`; + } + return out; } test.use({ - spaceFiles: { - ...manyPageSeed(20), - "CursorBug.md": `Hello there [[aspiring page]] + spaceFiles: { + ...manyPageSeed(20), + "CursorBug.md": `Hello there [[aspiring page]] * Hello * There @@ -29,52 +29,59 @@ Sup yo * This is it * Sup `, - }, - viewport: { width: 900, height: 500 }, + }, + viewport: { width: 900, height: 500 }, }); -test("arrow keys advance one line at a time below a tall widget", async ({ sbServer, page }) => { - await gotoSilverBulletPage(page, sbServer, "CursorBug"); +test("arrow keys advance one line at a time below a tall widget", async ({ + sbServer, + page, +}) => { + await gotoSilverBulletPage(page, sbServer, "CursorBug"); - // Wait until the query widget has rendered tall (height map > 100px - // means it picked up the widget's actual size, not the one-line - // fallback). - await page.waitForFunction(() => { - const v = (globalThis as any).client?.editorView; - return v && v.lineBlockAt(v.state.doc.line(8).from).height > 100; - }, undefined, { timeout: 10_000 }); + // Wait until the query widget has rendered tall (height map > 100px + // means it picked up the widget's actual size, not the one-line + // fallback). + await page.waitForFunction( + () => { + const v = (globalThis as any).client?.editorView; + return v && v.lineBlockAt(v.state.doc.line(8).from).height > 100; + }, + undefined, + { timeout: 10_000 }, + ); - const result = await page.evaluate(() => { - const v = (globalThis as any).client.editorView; - v.scrollDOM.scrollTop = v.scrollDOM.scrollHeight; - const line13 = v.state.doc.line(13); - v.dispatch({ selection: { anchor: line13.from + line13.text.length } }); + const result = await page.evaluate(() => { + const v = (globalThis as any).client.editorView; + v.scrollDOM.scrollTop = v.scrollDOM.scrollHeight; + const line13 = v.state.doc.line(13); + v.dispatch({ selection: { anchor: line13.from + line13.text.length } }); - // Sweep up from the bottom list through the widget. - const visited = [v.state.doc.lineAt(v.state.selection.main.head).number]; - let range = v.state.selection.main; - for (let i = 0; i < 5; i++) { - range = v.moveVertically(range, false); - visited.push(v.state.doc.lineAt(range.head).number); - } + // Sweep up from the bottom list through the widget. + const visited = [v.state.doc.lineAt(v.state.selection.main.head).number]; + let range = v.state.selection.main; + for (let i = 0; i < 5; i++) { + range = v.moveVertically(range, false); + visited.push(v.state.doc.lineAt(range.head).number); + } - // Click resolution: posAtCoords at each rendered line's vertical - // center must resolve to that same line. - const clicks: Array<{ line: number; resolvedLine: number | null }> = []; - for (let i = 9; i <= 13; i++) { - const ln = v.state.doc.line(i); - const c = v.coordsAtPos(ln.from); - const p = v.posAtCoords({ x: c.left + 20, y: (c.top + c.bottom) / 2 }); - clicks.push({ - line: i, - resolvedLine: p != null ? v.state.doc.lineAt(p).number : null, - }); - } - return { visited, clicks }; - }); + // Click resolution: posAtCoords at each rendered line's vertical + // center must resolve to that same line. + const clicks: Array<{ line: number; resolvedLine: number | null }> = []; + for (let i = 9; i <= 13; i++) { + const ln = v.state.doc.line(i); + const c = v.coordsAtPos(ln.from); + const p = v.posAtCoords({ x: c.left + 20, y: (c.top + c.bottom) / 2 }); + clicks.push({ + line: i, + resolvedLine: p != null ? v.state.doc.lineAt(p).number : null, + }); + } + return { visited, clicks }; + }); - expect(result.visited).toEqual([13, 12, 11, 10, 9, 8]); - for (const { line, resolvedLine } of result.clicks) { - expect(resolvedLine).toBe(line); - } + expect(result.visited).toEqual([13, 12, 11, 10, 9, 8]); + for (const { line, resolvedLine } of result.clicks) { + expect(resolvedLine).toBe(line); + } }); diff --git a/e2e/wiki-links.test.ts b/e2e/wiki-links.test.ts index fd4d0078..41b971a1 100644 --- a/e2e/wiki-links.test.ts +++ b/e2e/wiki-links.test.ts @@ -1,69 +1,89 @@ import { - expect, - gotoSilverBulletPage, - mod, - test, - waitForEditorReady, - waitForSaveAndReadFromServer, + expect, + gotoSilverBulletPage, + mod, + test, + waitForEditorReady, + waitForSaveAndReadFromServer, } from "./fixtures.ts"; test.describe("Wiki links", () => { - test.describe("cross-page navigation", () => { - test.use({ - spaceFiles: { - "PageA.md": "# Page A\nLink to [[PageB]] here", - "PageB.md": "# Page B\nThis is page B content", - }, - }); + test.describe("cross-page navigation", () => { + test.use({ + spaceFiles: { + "PageA.md": "# Page A\nLink to [[PageB]] here", + "PageB.md": "# Page B\nThis is page B content", + }, + }); - test("navigate between pages via wiki link click", async ({ sbServer, page }) => { - await gotoSilverBulletPage(page, sbServer, "PageA"); - const editor = page.locator("#sb-editor .cm-content"); + test("navigate between pages via wiki link click", async ({ + sbServer, + page, + }) => { + await gotoSilverBulletPage(page, sbServer, "PageA"); + const editor = page.locator("#sb-editor .cm-content"); - await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue("PageA"); - await expect(editor).toContainText("Page A"); + await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue( + "PageA", + ); + await expect(editor).toContainText("Page A"); - // Click on the "PageB" text inside the wiki link - const wikiLinkText = editor.locator(".sb-wiki-link", { hasText: "PageB" }); - await expect(wikiLinkText).toBeVisible({ timeout: 10_000 }); - await wikiLinkText.click(); + // Click on the "PageB" text inside the wiki link + const wikiLinkText = editor.locator(".sb-wiki-link", { + hasText: "PageB", + }); + await expect(wikiLinkText).toBeVisible({ timeout: 10_000 }); + await wikiLinkText.click(); - // Should navigate to PageB - await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue("PageB"); - await expect(editor).toContainText("Page B"); - }); - }); + // Should navigate to PageB + await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue( + "PageB", + ); + await expect(editor).toContainText("Page B"); + }); + }); - test("wiki link to non-existent page creates it on click and saves", async ({ sbPage, sbServer }) => { - const editor = sbPage.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("Welcome"); + test("wiki link to non-existent page creates it on click and saves", async ({ + sbPage, + sbServer, + }) => { + const editor = sbPage.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("Welcome"); - // Navigate to a fresh page - await sbPage.keyboard.press(`${mod}+k`); - await sbPage.locator(".sb-modal-box input.sb-input").click(); - await sbPage.keyboard.type("Link Source", { delay: 30 }); - await sbPage.keyboard.press("Shift+Enter"); - await expect(editor).toHaveText(""); + // Navigate to a fresh page + await sbPage.keyboard.press(`${mod}+k`); + await sbPage.locator(".sb-modal-box input.sb-input").click(); + await sbPage.keyboard.type("Link Source", { delay: 30 }); + await sbPage.keyboard.press("Shift+Enter"); + await expect(editor).toHaveText(""); - // Wait for the editor to finish loading and any pageLoaded handlers - // to settle before typing. Otherwise CodeMirror reconfigures mid-type - // and the cursor jumps back to position 0, splitting input. - await waitForEditorReady(sbPage); - await editor.click(); - await sbPage.keyboard.type("Check out [[Brand New Page]]", { delay: 20 }); + // Wait for the editor to finish loading and any pageLoaded handlers + // to settle before typing. Otherwise CodeMirror reconfigures mid-type + // and the cursor jumps back to position 0, splitting input. + await waitForEditorReady(sbPage); + await editor.click(); + await sbPage.keyboard.type("Check out [[Brand New Page]]", { delay: 20 }); - // Verify the source page with the wiki link is saved to server - const sourceContent = await waitForSaveAndReadFromServer(sbPage, sbServer, "Link Source.md"); - expect(sourceContent).toContain("[[Brand New Page]]"); + // Verify the source page with the wiki link is saved to server + const sourceContent = await waitForSaveAndReadFromServer( + sbPage, + sbServer, + "Link Source.md", + ); + expect(sourceContent).toContain("[[Brand New Page]]"); - // Move cursor away from the link so it renders - await sbPage.keyboard.press("Home"); + // Move cursor away from the link so it renders + await sbPage.keyboard.press("Home"); - // Click on the wiki link text to navigate/create - const wikiLinkText = editor.locator(".sb-wiki-link", { hasText: "Brand New Page" }); - await expect(wikiLinkText).toBeVisible({ timeout: 10_000 }); - await wikiLinkText.click(); + // Click on the wiki link text to navigate/create + const wikiLinkText = editor.locator(".sb-wiki-link", { + hasText: "Brand New Page", + }); + await expect(wikiLinkText).toBeVisible({ timeout: 10_000 }); + await wikiLinkText.click(); - await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue("Brand New Page"); - }); + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue( + "Brand New Page", + ); + }); }); diff --git a/e2e/xray.test.ts b/e2e/xray.test.ts index 7f67d890..6c01b4d7 100644 --- a/e2e/xray.test.ts +++ b/e2e/xray.test.ts @@ -1,9 +1,9 @@ import { - expect, - gotoSilverBulletPage, - mod, - test, - waitForEditorReady, + expect, + gotoSilverBulletPage, + mod, + test, + waitForEditorReady, } from "./fixtures.ts"; // Content designed so that indexing produces overlapping ranged objects at the @@ -30,83 +30,85 @@ This paragraph links to [[Other]] and has a tag. #alpha `; test.describe("X-Ray lens", () => { - test.use({ - spaceFiles: { - "XRayTest.md": XRAY_PAGE, - // Seed the link target so it doesn't become an aspiring page - "Other.md": "# Other\nSome content.", - }, - }); + test.use({ + spaceFiles: { + "XRayTest.md": XRAY_PAGE, + // Seed the link target so it doesn't become an aspiring page + "Other.md": "# Other\nSome content.", + }, + }); - test("underlines indexed ranges and shows stacked tooltip on wikilink hover, toggles off cleanly", async ({ - sbServer, - page, - }) => { - await gotoSilverBulletPage(page, sbServer, "XRayTest"); - const editor = page.locator("#sb-editor .cm-content"); - await expect(editor).toContainText("XRay Test"); + test("underlines indexed ranges and shows stacked tooltip on wikilink hover, toggles off cleanly", async ({ + sbServer, + page, + }) => { + await gotoSilverBulletPage(page, sbServer, "XRayTest"); + const editor = page.locator("#sb-editor .cm-content"); + await expect(editor).toContainText("XRay Test"); - // Wait for the initial indexer pass so all ranged objects are available - // when we activate X-Ray. - await waitForEditorReady(page); + // Wait for the initial indexer pass so all ranged objects are available + // when we activate X-Ray. + await waitForEditorReady(page); - const runToggle = async () => { - await page.keyboard.press(`${mod}+/`); - const modal = page.locator(".sb-modal-box"); - await expect(modal).toBeVisible(); - const paletteInput = modal.locator("input.sb-input"); - await paletteInput.click(); - await page.keyboard.type("Toggle X-Ray", { delay: 30 }); - const option = modal.locator(".sb-option .sb-name", { - hasText: "Toggle X-Ray", - }).first(); - await expect(option).toBeVisible(); - await option.click(); - await expect(modal).not.toBeVisible(); - }; + const runToggle = async () => { + await page.keyboard.press(`${mod}+/`); + const modal = page.locator(".sb-modal-box"); + await expect(modal).toBeVisible(); + const paletteInput = modal.locator("input.sb-input"); + await paletteInput.click(); + await page.keyboard.type("Toggle X-Ray", { delay: 30 }); + const option = modal + .locator(".sb-option .sb-name", { + hasText: "Toggle X-Ray", + }) + .first(); + await expect(option).toBeVisible(); + await option.click(); + await expect(modal).not.toBeVisible(); + }; - // ── 1. Toggle X-Ray on via the command palette ──────────────────────── - await runToggle(); + // ── 1. Toggle X-Ray on via the command palette ──────────────────────── + await runToggle(); - // ── 2. At least one .sb-xray-range decoration must appear ───────────── - await expect(page.locator(".sb-xray-range").first()).toBeVisible({ - timeout: 10_000, - }); + // ── 2. At least one .sb-xray-range decoration must appear ───────────── + await expect(page.locator(".sb-xray-range").first()).toBeVisible({ + timeout: 10_000, + }); - // ── 3. Hover the wikilink and assert the stacked tooltip ────────────── - // CodeMirror renders [[Other]] as a widget anchor (). - // Hovering it fires hoverTooltip at the underlying source position, which - // falls inside both the `link` range and the outer `paragraph` range — - // producing a stacked tooltip with entries for both objects. - const wikiLink = page - .locator(".sb-wiki-link", { hasText: "Other" }) - .first(); - await expect(wikiLink).toBeVisible({ timeout: 5_000 }); - await wikiLink.hover({ force: true }); + // ── 3. Hover the wikilink and assert the stacked tooltip ────────────── + // CodeMirror renders [[Other]] as a widget anchor (). + // Hovering it fires hoverTooltip at the underlying source position, which + // falls inside both the `link` range and the outer `paragraph` range — + // producing a stacked tooltip with entries for both objects. + const wikiLink = page + .locator(".sb-wiki-link", { hasText: "Other" }) + .first(); + await expect(wikiLink).toBeVisible({ timeout: 5_000 }); + await wikiLink.hover({ force: true }); - // X-Ray runs through CodeMirror's lint hover tooltip - // (`.cm-tooltip-lint`); CM's hover has a built-in dwell delay, so wait - // up to 5 s for it to appear. - const tooltip = page.locator(".cm-tooltip-lint"); - await expect(tooltip).toBeVisible({ timeout: 5_000 }); - // Body assertions: the `link` and `paragraph` objects must appear in - // the YAML rendered for their respective cards. - await expect(tooltip).toContainText("tag: relation"); - await expect(tooltip).toContainText("tag: paragraph"); - // Multi-tag expansion: the paragraph carries `#alpha`, so the index - // pipeline emits the same paragraph object once under `paragraph` and - // once under `alpha`. Same-object entries collapse into one card whose - // header joins the tag names — exactly one `paragraph, alpha` header. - await expect( - tooltip.locator(".sb-xray-tooltip-tag", { - hasText: /^paragraph, alpha$/, - }), - ).toBeVisible(); + // X-Ray runs through CodeMirror's lint hover tooltip + // (`.cm-tooltip-lint`); CM's hover has a built-in dwell delay, so wait + // up to 5 s for it to appear. + const tooltip = page.locator(".cm-tooltip-lint"); + await expect(tooltip).toBeVisible({ timeout: 5_000 }); + // Body assertions: the `link` and `paragraph` objects must appear in + // the YAML rendered for their respective cards. + await expect(tooltip).toContainText("tag: relation"); + await expect(tooltip).toContainText("tag: paragraph"); + // Multi-tag expansion: the paragraph carries `#alpha`, so the index + // pipeline emits the same paragraph object once under `paragraph` and + // once under `alpha`. Same-object entries collapse into one card whose + // header joins the tag names — exactly one `paragraph, alpha` header. + await expect( + tooltip.locator(".sb-xray-tooltip-tag", { + hasText: /^paragraph, alpha$/, + }), + ).toBeVisible(); - // ── 4. Toggle X-Ray off; all decorations must disappear ─────────────── - await runToggle(); - await expect(page.locator(".sb-xray-range")).toHaveCount(0, { - timeout: 15_000, - }); - }); + // ── 4. Toggle X-Ray off; all decorations must disappear ─────────────── + await runToggle(); + await expect(page.locator(".sb-xray-range")).toHaveCount(0, { + timeout: 15_000, + }); + }); }); diff --git a/playwright.config.ts b/playwright.config.ts index 5495af07..36d0a422 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,44 +1,44 @@ import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ - testDir: "./e2e", - timeout: 60_000, - expect: { timeout: 30_000 }, - fullyParallel: false, - // Tests are fully isolated (unique temp space dir + dynamic port each), so - // they run in parallel across workers. "50%" leaves a core per worker for - // that worker's spawned server process; on CI it adapts to the runner size. - workers: process.env.CI ? "50%" : 1, - // Browsers occasionally crash at the process level in CI (notably a chromium - // renderer SEGV); retry there so a stray crash doesn't fail the whole gate. - retries: process.env.CI ? 2 : 0, - reporter: "list", - use: { - ...devices["Desktop Chrome"], - screenshot: "only-on-failure", - trace: "retain-on-failure", - video: "retain-on-failure", - }, - projects: [ - { - name: "chromium", - // The embedded-bundle test needs the release binary; it runs as its - // own `release` project (see `make test-e2e-release`). - testIgnore: "**/release-embedded.test.ts", - use: { - ...devices["Desktop Chrome"], - // CI runners have a small /dev/shm, which crashes the chromium - // renderer (SEGV); route shared memory to /tmp instead. - launchOptions: { args: ["--disable-dev-shm-usage"] }, - }, - }, - { - name: "release", - testMatch: "**/release-embedded.test.ts", - use: { - ...devices["Desktop Chrome"], - launchOptions: { args: ["--disable-dev-shm-usage"] }, - }, - }, - ], + testDir: "./e2e", + timeout: 60_000, + expect: { timeout: 30_000 }, + fullyParallel: false, + // Tests are fully isolated (unique temp space dir + dynamic port each), so + // they run in parallel across workers. "50%" leaves a core per worker for + // that worker's spawned server process; on CI it adapts to the runner size. + workers: process.env.CI ? "50%" : 1, + // Browsers occasionally crash at the process level in CI (notably a chromium + // renderer SEGV); retry there so a stray crash doesn't fail the whole gate. + retries: process.env.CI ? 2 : 0, + reporter: "list", + use: { + ...devices["Desktop Chrome"], + screenshot: "only-on-failure", + trace: "retain-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + // The embedded-bundle test needs the release binary; it runs as its + // own `release` project (see `make test-e2e-release`). + testIgnore: "**/release-embedded.test.ts", + use: { + ...devices["Desktop Chrome"], + // CI runners have a small /dev/shm, which crashes the chromium + // renderer (SEGV); route shared memory to /tmp instead. + launchOptions: { args: ["--disable-dev-shm-usage"] }, + }, + }, + { + name: "release", + testMatch: "**/release-embedded.test.ts", + use: { + ...devices["Desktop Chrome"], + launchOptions: { args: ["--disable-dev-shm-usage"] }, + }, + }, + ], }); diff --git a/plug-api/lib/ref.test.ts b/plug-api/lib/ref.test.ts index 24e4fa0b..b00bf795 100644 --- a/plug-api/lib/ref.test.ts +++ b/plug-api/lib/ref.test.ts @@ -147,8 +147,9 @@ describe("anchor refs", () => { }); test("encodeRef round-trips bare anchor", () => { - expect(encodeRef({ path: "", details: { type: "anchor", name: "pete" } })) - .toBe("$pete"); + expect( + encodeRef({ path: "", details: { type: "anchor", name: "pete" } }), + ).toBe("$pete"); }); test("encodeRef round-trips page-qualified anchor", () => { diff --git a/plug-api/lib/ref.ts b/plug-api/lib/ref.ts index 5fb2f325..05bef0c7 100644 --- a/plug-api/lib/ref.ts +++ b/plug-api/lib/ref.ts @@ -211,10 +211,7 @@ export function coerceAndValidateRef(ref: Ref | string): Ref { ); } - if ( - ref.details?.type === "position" && - typeof ref.details.pos !== "number" - ) { + if (ref.details?.type === "position" && typeof ref.details.pos !== "number") { throw new Error("ref.details.pos has to be of type `number`"); } else if ( ref.details?.type === "header" && diff --git a/plug-api/lib/shortcut.ts b/plug-api/lib/shortcut.ts index d351b88d..dafc2c8c 100644 --- a/plug-api/lib/shortcut.ts +++ b/plug-api/lib/shortcut.ts @@ -1,4 +1,5 @@ -export const isMacLike = typeof navigator !== "undefined" && +export const isMacLike = + typeof navigator !== "undefined" && /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform); export function prettifyShortcut(shortcut: string): string { diff --git a/plug-api/lib/tree.test.ts b/plug-api/lib/tree.test.ts index 188b3017..14e97180 100644 --- a/plug-api/lib/tree.test.ts +++ b/plug-api/lib/tree.test.ts @@ -115,15 +115,19 @@ test("traverseTree isolates visitor errors per-node when opted in", () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { const seenHeadings: string[] = []; - traverseTree(mdTree, (n) => { - if (n.type === "ATXHeading1") { - throw new Error("boom on first heading"); - } - if (n.type === "ATXHeading2") { - seenHeadings.push(n.type); - } - return false; - }, true); + traverseTree( + mdTree, + (n) => { + if (n.type === "ATXHeading1") { + throw new Error("boom on first heading"); + } + if (n.type === "ATXHeading2") { + seenHeadings.push(n.type); + } + return false; + }, + true, + ); expect(seenHeadings).toContain("ATXHeading2"); expect(errorSpy).toHaveBeenCalled(); } finally { @@ -139,7 +143,7 @@ test("traverseTree propagates visitor errors by default", () => { throw new Error("boom on first heading"); } return false; - }) + }), ).toThrow("boom on first heading"); }); @@ -148,15 +152,19 @@ test("traverseTreeAsync isolates visitor errors per-node when opted in", async ( const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); try { const seenHeadings: string[] = []; - await traverseTreeAsync(mdTree, (n) => { - if (n.type === "ATXHeading1") { - return Promise.reject(new Error("boom on first heading")); - } - if (n.type === "ATXHeading2") { - seenHeadings.push(n.type); - } - return Promise.resolve(false); - }, true); + await traverseTreeAsync( + mdTree, + (n) => { + if (n.type === "ATXHeading1") { + return Promise.reject(new Error("boom on first heading")); + } + if (n.type === "ATXHeading2") { + seenHeadings.push(n.type); + } + return Promise.resolve(false); + }, + true, + ); expect(seenHeadings).toContain("ATXHeading2"); expect(errorSpy).toHaveBeenCalled(); } finally { @@ -204,10 +212,7 @@ test("findNodeOfType finds first node of given type", () => { test("findNodeMatching finds first matching node", () => { const mdTree = parse(extendedMarkdownLanguage, mdTest1); - const found = findNodeMatching( - mdTree, - (n) => n.type === "ATXHeading2", - ); + const found = findNodeMatching(mdTree, (n) => n.type === "ATXHeading2"); expect(found).not.toBeNull(); expect(found!.type).toBe("ATXHeading2"); diff --git a/plug-api/syscalls/config.ts b/plug-api/syscalls/config.ts index 40946181..dac24539 100644 --- a/plug-api/syscalls/config.ts +++ b/plug-api/syscalls/config.ts @@ -60,9 +60,11 @@ export function define(key: string, schema: any): Promise { * ascending `order` (default 0); unknown categories fall to the bottom in * alphabetical order. */ -export function defineCategory( - definition: { name: string; description?: string; order?: number }, -): Promise { +export function defineCategory(definition: { + name: string; + description?: string; + order?: number; +}): Promise { return syscall("config.defineCategory", definition); } diff --git a/plug-api/syscalls/shell.ts b/plug-api/syscalls/shell.ts index 5c9fc689..365ad651 100644 --- a/plug-api/syscalls/shell.ts +++ b/plug-api/syscalls/shell.ts @@ -14,4 +14,3 @@ export function run( ): Promise<{ stdout: string; stderr: string; code: number }> { return syscall("shell.run", cmd, args, stdin); } - diff --git a/plug-api/types/manifest.ts b/plug-api/types/manifest.ts index 24aa22ca..86ebedb9 100644 --- a/plug-api/types/manifest.ts +++ b/plug-api/types/manifest.ts @@ -12,10 +12,23 @@ export type CodeWidgetT = { * Quit, About, etc.) instead of a command-dispatching item. */ export type PredefinedMenuKind = - | "quit" | "about" | "services" | "hide" | "hideOthers" | "showAll" - | "closeWindow" | "minimize" | "maximize" | "fullscreen" + | "quit" + | "about" + | "services" + | "hide" + | "hideOthers" + | "showAll" + | "closeWindow" + | "minimize" + | "maximize" + | "fullscreen" | "separator" - | "undo" | "redo" | "cut" | "copy" | "paste" | "selectAll"; + | "undo" + | "redo" + | "cut" + | "copy" + | "paste" + | "selectAll"; /** * A single native-menu placement for a command or submenu. diff --git a/plug-api/ui/button.tsx b/plug-api/ui/button.tsx index 733c17a1..291277bb 100644 --- a/plug-api/ui/button.tsx +++ b/plug-api/ui/button.tsx @@ -10,32 +10,31 @@ const VARIANT_CLASS: Record = { icon: "sb-button-icon", }; -export type ButtonProps = - & Omit - & { - variant?: ButtonVariant; - class?: string; - /** Ref to the underlying
    - +
    @@ -392,10 +397,7 @@ function MarkdownDescription({ text }: { text: string }) { }; }, [text]); return ( -
    +
    ); } @@ -550,9 +552,7 @@ function RepoRow({ repo }: { repo: RepositoryInfo }) { {repo.uri} )} - {!actionable && ( - built-in - )} + {!actionable && built-in}
    {repo.uri && /^https?:\/\//.test(repo.uri) && ( @@ -635,11 +635,7 @@ function RepoUpdateAllButton() { @@ -157,8 +161,7 @@ export function ShortcutsTab() { type="text" placeholder="Filter commands..." value={search} - onInput={(e) => - setSearch((e.currentTarget as HTMLInputElement).value)} + onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)} /> diff --git a/plugs/configuration-manager/ui/keys.ts b/plugs/configuration-manager/ui/keys.ts index c6178b9a..e5a4586c 100644 --- a/plugs/configuration-manager/ui/keys.ts +++ b/plugs/configuration-manager/ui/keys.ts @@ -232,5 +232,10 @@ export function seedOverrideFromManifest( ): PendingShortcuts { if (pendingShortcuts[name]) return pendingShortcuts; const next = { ...pendingShortcuts, [name]: {} }; - return writeBindings(next, name, manifestBindings(name, commands, isMac), isMac); + return writeBindings( + next, + name, + manifestBindings(name, commands, isMac), + isMac, + ); } diff --git a/plugs/configuration-manager/ui/schema.ts b/plugs/configuration-manager/ui/schema.ts index 3dd3dc24..21f02cdf 100644 --- a/plugs/configuration-manager/ui/schema.ts +++ b/plugs/configuration-manager/ui/schema.ts @@ -19,10 +19,7 @@ export function getValueAtPath(obj: any, path: string): any { return current; } -export function getSchemaAtPath( - schemas: any, - path: string, -): any { +export function getSchemaAtPath(schemas: any, path: string): any { const parts = path.split("."); let current: any = schemas; for (const part of parts) { @@ -34,11 +31,7 @@ export function getSchemaAtPath( return current; } -function collectUiSchemas( - schemaNode: any, - path: string, - results: UiSchema[], -) { +function collectUiSchemas(schemaNode: any, path: string, results: UiSchema[]) { if (!schemaNode || !schemaNode.properties) return; for (const [key, prop] of Object.entries(schemaNode.properties)) { const fullPath = path ? `${path}.${key}` : key; @@ -66,7 +59,10 @@ export function buildSchemaIndex(cfg: ConfigurationViewModel): SchemaIndex { } const cat = item.schema.ui.category; (categoryMap[cat] ||= []).push(item); - initialConfig[item.path] = Object.hasOwn(cfg.configOverrides || {}, item.path) + initialConfig[item.path] = Object.hasOwn( + cfg.configOverrides || {}, + item.path, + ) ? cfg.configOverrides[item.path] : getValueAtPath(cfg.values, item.path); } diff --git a/plugs/configuration-manager/ui/use_config_editor.ts b/plugs/configuration-manager/ui/use_config_editor.ts index 9252e441..490e029f 100644 --- a/plugs/configuration-manager/ui/use_config_editor.ts +++ b/plugs/configuration-manager/ui/use_config_editor.ts @@ -19,37 +19,44 @@ export function useConfigEditor(): ConfigEditor { () => new Set(schemaIndex.initialModifiedPaths), ); - const setField = useCallback((path: string, value: any) => { - setPendingConfig((prev) => { - if (prev[path] === value) return prev; - return { ...prev, [path]: value }; - }); - setModifiedPaths((prev) => { - const schema = getSchemaAtPath(cfg.schemas, path); - const def = schema?.default; - const normDef = schema?.type === "boolean" && def == null ? false : def; - const normCur = schema?.type === "boolean" && value == null ? false : value; - const next = new Set(prev); - if (normCur === normDef) next.delete(path); - else next.add(path); - // Preserve identity if nothing changed. - if (next.size === prev.size && [...next].every((p) => prev.has(p))) { - return prev; - } - return next; - }); - }, [cfg.schemas]); + const setField = useCallback( + (path: string, value: any) => { + setPendingConfig((prev) => { + if (prev[path] === value) return prev; + return { ...prev, [path]: value }; + }); + setModifiedPaths((prev) => { + const schema = getSchemaAtPath(cfg.schemas, path); + const def = schema?.default; + const normDef = schema?.type === "boolean" && def == null ? false : def; + const normCur = + schema?.type === "boolean" && value == null ? false : value; + const next = new Set(prev); + if (normCur === normDef) next.delete(path); + else next.add(path); + // Preserve identity if nothing changed. + if (next.size === prev.size && [...next].every((p) => prev.has(p))) { + return prev; + } + return next; + }); + }, + [cfg.schemas], + ); - const resetField = useCallback((path: string) => { - const schema = getSchemaAtPath(cfg.schemas, path); - setPendingConfig((prev) => ({ ...prev, [path]: schema?.default })); - setModifiedPaths((prev) => { - if (!prev.has(path)) return prev; - const next = new Set(prev); - next.delete(path); - return next; - }); - }, [cfg.schemas]); + const resetField = useCallback( + (path: string) => { + const schema = getSchemaAtPath(cfg.schemas, path); + setPendingConfig((prev) => ({ ...prev, [path]: schema?.default })); + setModifiedPaths((prev) => { + if (!prev.has(path)) return prev; + const next = new Set(prev); + next.delete(path); + return next; + }); + }, + [cfg.schemas], + ); const isModified = useCallback( (path: string) => modifiedPaths.has(path), diff --git a/plugs/editor/complete.test.ts b/plugs/editor/complete.test.ts index 06a96730..8dbdeddc 100644 --- a/plugs/editor/complete.test.ts +++ b/plugs/editor/complete.test.ts @@ -16,10 +16,7 @@ function makeCompleteEvent( }; } -async function indexPageMeta( - name: string, - tags: string[] = [], -): Promise { +async function indexPageMeta(name: string, tags: string[] = []): Promise { const obj: PageMeta = { ref: name, tag: "page", diff --git a/plugs/editor/complete.ts b/plugs/editor/complete.ts index a8550019..e831c766 100644 --- a/plugs/editor/complete.ts +++ b/plugs/editor/complete.ts @@ -1,5 +1,10 @@ import { folderName } from "@silverbulletmd/silverbullet/lib/resolve"; -import { editor, index, language, lua } from "@silverbulletmd/silverbullet/syscalls"; +import { + editor, + index, + language, + lua, +} from "@silverbulletmd/silverbullet/syscalls"; import type { DocumentMeta, PageMeta, diff --git a/plugs/editor/editor.ts b/plugs/editor/editor.ts index 87dfb814..5412372c 100644 --- a/plugs/editor/editor.ts +++ b/plugs/editor/editor.ts @@ -66,10 +66,7 @@ export async function openTagNavigator() { if (!selectedTag) { return; } - const tagPage = await config.get( - ["tags", selectedTag.name, "tagPage"], - null, - ); + const tagPage = await config.get(["tags", selectedTag.name, "tagPage"], null); await editor.navigate(tagPage ?? `tag:${selectedTag.name}`); } @@ -159,4 +156,3 @@ export async function reloadSystem() { await codeWidget.refreshAll(); await editor.flashNotification("System and widgets reloaded!"); } - diff --git a/plugs/index/anchor.test.ts b/plugs/index/anchor.test.ts index 5cb317f2..36316d54 100644 --- a/plugs/index/anchor.test.ts +++ b/plugs/index/anchor.test.ts @@ -1,10 +1,6 @@ import { describe, expect, test } from "vitest"; import { parseMarkdown } from "../../client/markdown_parser/parser.ts"; -import { - cleanAnchor, - collectAnchor, - isValidAnchorName, -} from "./anchor.ts"; +import { cleanAnchor, collectAnchor, isValidAnchorName } from "./anchor.ts"; import { renderToText } from "@silverbulletmd/silverbullet/lib/tree"; describe("anchor helpers", () => { diff --git a/plugs/index/attribute.ts b/plugs/index/attribute.ts index 4f3700c6..1225837e 100644 --- a/plugs/index/attribute.ts +++ b/plugs/index/attribute.ts @@ -17,27 +17,31 @@ import YAML from "js-yaml"; export function collectAttributes(tree: ParseTree): Record { const attributes: Record = {}; - traverseTree(tree, (n) => { - if (tree !== n && n.type === "ListItem") { - // Find top-level only, no nested lists - return true; - } - if (n.type === "Attribute") { - const nameNode = findNodeOfType(n, "AttributeName"); - const valueNode = findNodeOfType(n, "AttributeValue"); - if (nameNode && valueNode) { - const name = nameNode.children![0].text!; - const val = valueNode.children![0].text!; - try { - attributes[name] = cleanupJSON(YAML.load(val)); - } catch (e: any) { - console.error("Error parsing attribute value as YAML", val, e); - } + traverseTree( + tree, + (n) => { + if (tree !== n && n.type === "ListItem") { + // Find top-level only, no nested lists + return true; } - return true; - } - return false; - }, true); + if (n.type === "Attribute") { + const nameNode = findNodeOfType(n, "AttributeName"); + const valueNode = findNodeOfType(n, "AttributeValue"); + if (nameNode && valueNode) { + const name = nameNode.children![0].text!; + const val = valueNode.children![0].text!; + try { + attributes[name] = cleanupJSON(YAML.load(val)); + } catch (e: any) { + console.error("Error parsing attribute value as YAML", val, e); + } + } + return true; + } + return false; + }, + true, + ); return attributes; } diff --git a/plugs/index/complete.test.ts b/plugs/index/complete.test.ts index f7dbd574..51fe3a77 100644 --- a/plugs/index/complete.test.ts +++ b/plugs/index/complete.test.ts @@ -42,9 +42,7 @@ describe("anchorComplete", () => { await indexPage(`Another $people here.\n`, "PageB"); await indexPage(`Unrelated $qux.\n`, "PageC"); - const result = await anchorComplete( - makeCompleteEvent("Go to [["), - ); + const result = await anchorComplete(makeCompleteEvent("Go to [[")); // No $ typed yet — should not trigger expect(result).toBeNull(); }); @@ -54,9 +52,7 @@ describe("anchorComplete", () => { await indexPage(`Para $alpha here.\n`, "PageA"); await indexPage(`Para $beta here.\n`, "PageB"); - const result = await anchorComplete( - makeCompleteEvent("[[$"), - ); + const result = await anchorComplete(makeCompleteEvent("[[$")); expect(result).toBeTruthy(); const labels = result!.options.map((o) => o.label); expect(labels).toContain("$alpha"); @@ -69,9 +65,7 @@ describe("anchorComplete", () => { await indexPage(`Para $people here.\n`, "PageB"); await indexPage(`Para $qux here.\n`, "PageC"); - const result = await anchorComplete( - makeCompleteEvent("[[$p"), - ); + const result = await anchorComplete(makeCompleteEvent("[[$p")); expect(result).toBeTruthy(); const labels = result!.options.map((o) => o.label); expect(labels).toContain("$pete"); @@ -97,9 +91,7 @@ describe("anchorComplete", () => { await indexPage(`Para $pete here.\n`, "PageA"); await indexPage(`Para $pete here.\n`, "PageB"); // same anchor on different page - const result = await anchorComplete( - makeCompleteEvent("[[PageA$p"), - ); + const result = await anchorComplete(makeCompleteEvent("[[PageA$p")); expect(result).toBeTruthy(); const options = result!.options; // All returned options should have detail mentioning PageA only @@ -113,9 +105,7 @@ describe("anchorComplete", () => { createMockSystem(); await indexPage(`Para $sec1 here.\n`, "SomePage"); - const result = await anchorComplete( - makeCompleteEvent("[[SomePage$"), - ); + const result = await anchorComplete(makeCompleteEvent("[[SomePage$")); expect(result).toBeTruthy(); const labels = result!.options.map((o) => o.label); // label should be "SomePage$sec1" (page prefix + $ + anchor name) @@ -126,9 +116,7 @@ describe("anchorComplete", () => { createMockSystem(); await indexPage(`Para $alpha.\n`, "PageA"); - const result = await anchorComplete( - makeCompleteEvent("[[SomePage"), - ); + const result = await anchorComplete(makeCompleteEvent("[[SomePage")); expect(result).toBeNull(); }); diff --git a/plugs/index/complete.ts b/plugs/index/complete.ts index 209a35f8..13ec64b1 100644 --- a/plugs/index/complete.ts +++ b/plugs/index/complete.ts @@ -88,11 +88,7 @@ export async function anchorComplete(completeEvent: CompleteEvent) { { p: page }, ); } else { - anchors = await index.queryLuaObjects( - "anchor", - {}, - {}, - ); + anchors = await index.queryLuaObjects("anchor", {}, {}); } const filtered = anchors diff --git a/plugs/index/indexer.test.ts b/plugs/index/indexer.test.ts index 748ed9f2..b7fb6127 100644 --- a/plugs/index/indexer.test.ts +++ b/plugs/index/indexer.test.ts @@ -30,10 +30,10 @@ describe("anchor records", () => { test("emits no anchor records when no anchors present", async () => { createMockSystem(); - const objects = await indexMarkdown( - `Plain paragraph #tag.\n`, - { ...defaultPageMeta, name: "Plain" }, - ); + const objects = await indexMarkdown(`Plain paragraph #tag.\n`, { + ...defaultPageMeta, + name: "Plain", + }); const anchors = objects.filter((o: any) => o.tag === "anchor"); expect(anchors).toEqual([]); }); @@ -42,10 +42,11 @@ describe("anchor records", () => { createMockSystem(); // Page name "MyPage" passes isValidAnchorName; a naive filter would // incorrectly emit an anchor record pointing at the page itself. - const objects = await indexMarkdown( - `Paragraph #atag.\n`, - { ...defaultPageMeta, ref: "MyPage", name: "MyPage" }, - ); + const objects = await indexMarkdown(`Paragraph #atag.\n`, { + ...defaultPageMeta, + ref: "MyPage", + name: "MyPage", + }); const anchors = objects.filter((o: any) => o.tag === "anchor"); expect(anchors).toEqual([]); }); diff --git a/plugs/index/indexer.ts b/plugs/index/indexer.ts index d0ea17b9..2af7d89d 100644 --- a/plugs/index/indexer.ts +++ b/plugs/index/indexer.ts @@ -109,8 +109,5 @@ export async function indexPage({ name, tree, meta, text }: IndexTreeEvent) { const indexResults = await Promise.all( allIndexers.map((indexer) => indexer(meta, frontmatter, tree, text)), ); - await index.indexObjects( - name, - appendAnchorRecords(indexResults.flat()), - ); + await index.indexObjects(name, appendAnchorRecords(indexResults.flat())); } diff --git a/plugs/index/item.ts b/plugs/index/item.ts index 720b4548..b710a5cb 100644 --- a/plugs/index/item.ts +++ b/plugs/index/item.ts @@ -63,31 +63,35 @@ export async function indexItems( // Cache extracted items by node position to avoid re-extracting parents const itemCache = new Map(); - traverseTree(tree, (n) => { - if (n.type !== "ListItem") { + traverseTree( + tree, + (n) => { + if (n.type !== "ListItem") { + return false; + } + + if (!n.children) { + // Weird, let's jump out + return true; + } + + items.push( + extractItemFromNode( + pageMeta.name, + n, + frontmatter, + true, + allCompleteStates, + itemCache, + pageMeta.lastModified, + ), + ); + + // Traversal continue into child items (potentially) return false; - } - - if (!n.children) { - // Weird, let's jump out - return true; - } - - items.push( - extractItemFromNode( - pageMeta.name, - n, - frontmatter, - true, - allCompleteStates, - itemCache, - pageMeta.lastModified, - ), - ); - - // Traversal continue into child items (potentially) - return false; - }, true); + }, + true, + ); if (!shouldIndexAllItems) { items = items.filter((item) => item.tag !== "item" || item.tags?.length); diff --git a/plugs/index/link.ts b/plugs/index/link.ts index f09ec03d..7e648453 100644 --- a/plugs/index/link.ts +++ b/plugs/index/link.ts @@ -30,11 +30,8 @@ export function relationToLink(rel: any): LinkObject | undefined { if (!rel || typeof rel !== "object") return undefined; if (rel.kind === "co-mention") return undefined; - const type: LinkObject["type"] = rel.toTag === "url" - ? "url" - : rel.toTag === "document" - ? "file" - : "page"; + const type: LinkObject["type"] = + rel.toTag === "url" ? "url" : rel.toTag === "document" ? "file" : "page"; const pos: number | undefined = Array.isArray(rel.range) ? rel.range[0] diff --git a/plugs/index/link_virtual.test.ts b/plugs/index/link_virtual.test.ts index 0f638eac..fe8be210 100644 --- a/plugs/index/link_virtual.test.ts +++ b/plugs/index/link_virtual.test.ts @@ -95,15 +95,17 @@ test("virtual link inclusion: every classified target appears", async () => { expect(links).toHaveLength(expected.length); - const actual = links.map((l) => ({ - type: l.type, - target: targetOf(l), - alias: l.alias, - })).sort((a, b) => a.target.localeCompare(b.target)); + const actual = links + .map((l) => ({ + type: l.type, + target: targetOf(l), + alias: l.alias, + })) + .sort((a, b) => a.target.localeCompare(b.target)); - const expectedSorted = expected.slice().sort((a, b) => - a.target.localeCompare(b.target) - ); + const expectedSorted = expected + .slice() + .sort((a, b) => a.target.localeCompare(b.target)); expect(actual).toEqual(expectedSorted); }); @@ -122,8 +124,8 @@ test("virtual link shape: each record carries the legacy fields", async () => { expect(Array.isArray(l.range)).toBe(true); expect(l.range![0]).toBe(l.pos); // Exactly one of the to{Page,File,URL} fields populated. - const populated = [l.toPage, l.toFile, l.toURL].filter((x) => - x !== undefined + const populated = [l.toPage, l.toFile, l.toURL].filter( + (x) => x !== undefined, ); expect(populated).toHaveLength(1); } @@ -145,9 +147,7 @@ spouse: "[[Jack]]" // as their `kind` (e.g. `attr`, `spouse`), which projects into the // legacy `link` index. Co-mentions still have no `link` representation. const kinds = new Set( - relations - .filter((o: any) => o.tag === "relation") - .map((o: any) => o.kind), + relations.filter((o: any) => o.tag === "relation").map((o: any) => o.kind), ); expect(kinds.has("attr")).toBe(true); expect(kinds.has("spouse")).toBe(true); diff --git a/plugs/index/lint.test.ts b/plugs/index/lint.test.ts index 185ea1be..5f7844e9 100644 --- a/plugs/index/lint.test.ts +++ b/plugs/index/lint.test.ts @@ -41,7 +41,11 @@ async function runLintForTest( perm: "rw", }; const objects = await indexMarkdown(pageText, pm); - await (globalThis as any).syscall("index.indexObjects", pageName, objects); + await (globalThis as any).syscall( + "index.indexObjects", + pageName, + objects, + ); } } @@ -72,9 +76,7 @@ describe("lintAnchors", () => { }); test("lint: single valid anchor — no diagnostics", async () => { - const diagnostics = await runLintForTest( - `This paragraph has $pete here.`, - ); + const diagnostics = await runLintForTest(`This paragraph has $pete here.`); expect(diagnostics).toEqual([]); }); @@ -88,9 +90,7 @@ describe("lintAnchors", () => { }); test("lint: multiple anchors in one paragraph (rule B)", async () => { - const diagnostics = await runLintForTest( - `Para $first and $second here.`, - ); + const diagnostics = await runLintForTest(`Para $first and $second here.`); // Exactly one diagnostic, pointing at the second anchor expect(diagnostics).toHaveLength(1); expect(diagnostics[0].message).toMatch(/multiple anchors/i); @@ -109,18 +109,14 @@ describe("lintAnchors", () => { }); test("lint: multiple anchors in a list item (rule B)", async () => { - const diagnostics = await runLintForTest( - `- Item $one and $two`, - ); + const diagnostics = await runLintForTest(`- Item $one and $two`); expect(diagnostics).toHaveLength(1); expect(diagnostics[0].message).toMatch(/multiple anchors/i); }); test("lint: anchors in sibling list items do not cross-contaminate (rule B)", async () => { // Two separate items, each with exactly one anchor — no error - const diagnostics = await runLintForTest( - `- Item $one\n- Item $two`, - ); + const diagnostics = await runLintForTest(`- Item $one\n- Item $two`); expect(diagnostics).toEqual([]); }); @@ -149,10 +145,9 @@ describe("lintAnchors", () => { }); test("lint: duplicate anchor across pages (rule C)", async () => { - const diagnostics = await runLintForTest( - `Para $pete here.`, - { otherPages: { Other: `Other para $pete here` } }, - ); + const diagnostics = await runLintForTest(`Para $pete here.`, { + otherPages: { Other: `Other para $pete here` }, + }); // One NamedAnchor on this page, and it's a duplicate — one diagnostic expect(diagnostics).toHaveLength(1); expect(diagnostics[0].message).toMatch(/duplicate/i); @@ -160,10 +155,9 @@ describe("lintAnchors", () => { }); test("lint: unique anchor across pages — no duplicate diagnostic (rule C)", async () => { - const diagnostics = await runLintForTest( - `Para $unique here.`, - { otherPages: { Other: `Other para $different.` } }, - ); + const diagnostics = await runLintForTest(`Para $unique here.`, { + otherPages: { Other: `Other para $different.` }, + }); // No duplicate, no diagnostics expect(diagnostics).toEqual([]); }); @@ -183,36 +177,32 @@ describe("lintAnchors", () => { ); // The anchor is defined, no broken-link error const brokenLinks = diagnostics.filter((d) => - /anchor not found/i.test(d.message) + /anchor not found/i.test(d.message), ); expect(brokenLinks).toHaveLength(0); }); test("lint: ambiguous bare anchor link (rule E)", async () => { // $shared exists on two different pages; [[$shared]] is ambiguous - const diagnostics = await runLintForTest( - `See [[$shared]].`, - { - otherPages: { - Other: `First $shared here`, - Another: `Second $shared here`, - }, + const diagnostics = await runLintForTest(`See [[$shared]].`, { + otherPages: { + Other: `First $shared here`, + Another: `Second $shared here`, }, - ); + }); // At least one diagnostic about ambiguity/duplicate const ambiguousDiags = diagnostics.filter((d) => - /ambiguous|duplicate/i.test(d.message) + /ambiguous|duplicate/i.test(d.message), ); expect(ambiguousDiags.length).toBeGreaterThanOrEqual(1); }); test("lint: page-qualified link to existing anchor — no diagnostic (rule D/E)", async () => { - const diagnostics = await runLintForTest( - `See [[Other$pete]].`, - { otherPages: { Other: `Anchor $pete here` } }, - ); + const diagnostics = await runLintForTest(`See [[Other$pete]].`, { + otherPages: { Other: `Anchor $pete here` }, + }); const anchorDiags = diagnostics.filter((d) => - /anchor not found|ambiguous|duplicate/i.test(d.message) + /anchor not found|ambiguous|duplicate/i.test(d.message), ); expect(anchorDiags).toHaveLength(0); }); diff --git a/plugs/index/lint.ts b/plugs/index/lint.ts index a1df17f1..f5f95e7c 100644 --- a/plugs/index/lint.ts +++ b/plugs/index/lint.ts @@ -317,8 +317,7 @@ export async function lintAnchors({ from: extra.from, to: extra.to, severity: "error", - message: - `Multiple anchors in the same block: "$${extra.name}" is the ${i + 1}st anchor here. A block may only carry one anchor.`, + message: `Multiple anchors in the same block: "$${extra.name}" is the ${i + 1}st anchor here. A block may only carry one anchor.`, }); } @@ -346,9 +345,7 @@ export async function lintAnchors({ const url = wikiLinkPage.children![0].text!; const ref = parseToRef(url); if (ref?.details?.type === "anchor") { - const linkedPage = ref.path - ? getNameFromPath(ref.path) - : undefined; + const linkedPage = ref.path ? getNameFromPath(ref.path) : undefined; anchorLinks.push({ name: ref.details.name, page: linkedPage || undefined, @@ -400,8 +397,7 @@ export async function lintAnchors({ from: node.from, to: node.to, severity: "error", - message: - `Duplicate anchor "$${anchorName}" — defined ${nodes.length} times on this page.`, + message: `Duplicate anchor "$${anchorName}" — defined ${nodes.length} times on this page.`, }); } } @@ -421,8 +417,7 @@ export async function lintAnchors({ from: node.from, to: node.to, severity: "error", - message: - `Duplicate anchor "$${anchorName}" — also defined on: ${otherPages.join(", ")}`, + message: `Duplicate anchor "$${anchorName}" — also defined on: ${otherPages.join(", ")}`, }); } } @@ -430,9 +425,7 @@ export async function lintAnchors({ // Broken / ambiguous anchor links for (const link of anchorLinks) { - const key: LookupKey = link.page - ? `${link.page}\0${link.name}` - : link.name; + const key: LookupKey = link.page ? `${link.page}\0${link.name}` : link.name; const result = resolved.get(key); if (!result || result.ok) { continue; // resolved fine — no diagnostic @@ -452,8 +445,7 @@ export async function lintAnchors({ from: link.from, to: link.to, severity: "error", - message: - `Ambiguous anchor "$${link.name}" — found on multiple pages: ${pages}`, + message: `Ambiguous anchor "$${link.name}" — found on multiple pages: ${pages}`, }); } } diff --git a/plugs/index/paragraph.test.ts b/plugs/index/paragraph.test.ts index 21ea1efc..602b5171 100644 --- a/plugs/index/paragraph.test.ts +++ b/plugs/index/paragraph.test.ts @@ -5,10 +5,7 @@ import type { PageMeta } from "@silverbulletmd/silverbullet/type/index"; import { extractFrontMatter } from "./frontmatter.ts"; import { indexParagraphs } from "./paragraph.ts"; -async function indexParagraphsForTest( - text: string, - pageName = "TestPage", -) { +async function indexParagraphsForTest(text: string, pageName = "TestPage") { const { config } = createMockSystem(); config.set("index.paragraph.all", false); const tree = parseMarkdown(text); diff --git a/plugs/index/paragraph.ts b/plugs/index/paragraph.ts index 86237515..2fcb4ce2 100644 --- a/plugs/index/paragraph.ts +++ b/plugs/index/paragraph.ts @@ -33,65 +33,69 @@ export async function indexParagraphs( const objects: ParagraphObject[] = []; - traverseTree(tree, (p) => { - if (p.type !== "Paragraph") { - return false; - } + traverseTree( + tree, + (p) => { + if (p.type !== "Paragraph") { + return false; + } - if (findParentMatching(p, (n) => n.type === "ListItem")) { - // Not looking at paragraphs nested in a list + if (findParentMatching(p, (n) => n.type === "ListItem")) { + // Not looking at paragraphs nested in a list + return true; + } + + const fullText = renderToText(p); + + // Collect tags + const tags = collectTags(p); + const anchor = collectAnchor(p); + + if (tags.length === 0 && !anchor && !shouldIndexAll) { + // Don't index paragraphs without a hashtag or anchor + return true; + } + + // Extract attributes + const attrs = collectAttributes(p); + + // Clean tree, just to check if it's effectively empty or not + const pClone = cloneTree(p); + cleanTags(pClone); + cleanAttributes(pClone); + cleanAnchor(pClone); + const cleanedText = renderToText(pClone); + + if (!cleanedText.trim() && !anchor) { + // Empty paragraph, just tags, attributes, and/or anchor maybe + return true; + } + + const pos = p.from!; + const paragraph: ParagraphObject = { + tag: "paragraph", + // "First anchor wins" anchor.duplicateInHost is intentionally ignored + // here. lint flags multi-anchor hosts at edit time. + ref: anchor ? anchor.name : `${pageMeta.name}@${pos}`, + text: anchor ? cleanedText : fullText, + page: pageMeta.name, + pos, + range: [p.from!, p.to!], + ...attrs, + }; + if (tags.length > 0) { + paragraph.tags = [...tags]; + paragraph.itags = [...tags]; + } + + updateITags(paragraph, frontmatter); + objects.push(paragraph); + + // stop on every element except document, including paragraphs return true; - } - - const fullText = renderToText(p); - - // Collect tags - const tags = collectTags(p); - const anchor = collectAnchor(p); - - if (tags.length === 0 && !anchor && !shouldIndexAll) { - // Don't index paragraphs without a hashtag or anchor - return true; - } - - // Extract attributes - const attrs = collectAttributes(p); - - // Clean tree, just to check if it's effectively empty or not - const pClone = cloneTree(p); - cleanTags(pClone); - cleanAttributes(pClone); - cleanAnchor(pClone); - const cleanedText = renderToText(pClone); - - if (!cleanedText.trim() && !anchor) { - // Empty paragraph, just tags, attributes, and/or anchor maybe - return true; - } - - const pos = p.from!; - const paragraph: ParagraphObject = { - tag: "paragraph", - // "First anchor wins" anchor.duplicateInHost is intentionally ignored - // here. lint flags multi-anchor hosts at edit time. - ref: anchor ? anchor.name : `${pageMeta.name}@${pos}`, - text: anchor ? cleanedText : fullText, - page: pageMeta.name, - pos, - range: [p.from!, p.to!], - ...attrs, - }; - if (tags.length > 0) { - paragraph.tags = [...tags]; - paragraph.itags = [...tags]; - } - - updateITags(paragraph, frontmatter); - objects.push(paragraph); - - // stop on every element except document, including paragraphs - return true; - }, true); + }, + true, + ); return objects; } diff --git a/plugs/index/refactor.ts b/plugs/index/refactor.ts index 5f394516..ddc8f5b1 100644 --- a/plugs/index/refactor.ts +++ b/plugs/index/refactor.ts @@ -6,10 +6,7 @@ import { mq, space, } from "@silverbulletmd/silverbullet/syscalls"; -import { - getTextualBackRelations, - type RelationObject, -} from "./relation.ts"; +import { getTextualBackRelations, type RelationObject } from "./relation.ts"; import { spliceReference } from "./refactor_splice.ts"; import { absoluteToRelativePath, @@ -238,10 +235,9 @@ async function renamePage(oldName: string, newName: string) { if (text.substring(pos, pos + 2) === "[[") continue; const newLink = absoluteToRelativePath(newName, rel.to); - let newTail = text.substring(pos).replace( - /^.*?(?=@\d*|#|\$|\))/, - newLink, - ); + let newTail = text + .substring(pos) + .replace(/^.*?(?=@\d*|#|\$|\))/, newLink); if (newLink.includes(" ")) { newTail = `<${newTail.replace(")", ">)")}`; } diff --git a/plugs/index/refactor_splice.test.ts b/plugs/index/refactor_splice.test.ts index bb44ca47..1b2d778a 100644 --- a/plugs/index/refactor_splice.test.ts +++ b/plugs/index/refactor_splice.test.ts @@ -29,14 +29,19 @@ function splice( describe("wikilinks", () => { test("bare wikilink", () => { - expect( - splice("Before [[Old]] after.", "[[Old]]", "Old", "New"), - ).toBe("Before [[New]] after."); + expect(splice("Before [[Old]] after.", "[[Old]]", "Old", "New")).toBe( + "Before [[New]] after.", + ); }); test("wikilink with alias", () => { expect( - splice("Before [[Old|the alias]] after.", "[[Old|the alias]]", "Old", "New"), + splice( + "Before [[Old|the alias]] after.", + "[[Old|the alias]]", + "Old", + "New", + ), ).toBe("Before [[New|the alias]] after."); }); @@ -47,9 +52,9 @@ describe("wikilinks", () => { }); test("wikilink with position detail", () => { - expect( - splice("See [[Old@142]].", "[[Old@142]]", "Old", "New"), - ).toBe("See [[New@142]]."); + expect(splice("See [[Old@142]].", "[[Old@142]]", "Old", "New")).toBe( + "See [[New@142]].", + ); }); test("wikilink with anchor detail", () => { @@ -65,31 +70,31 @@ describe("wikilinks", () => { }); test("wikilink at start of line", () => { - expect( - splice("[[Old]] starts the line.", "[[Old]]", "Old", "New"), - ).toBe("[[New]] starts the line."); + expect(splice("[[Old]] starts the line.", "[[Old]]", "Old", "New")).toBe( + "[[New]] starts the line.", + ); }); test("wikilink at end of file", () => { - expect( - splice("Trailing [[Old]]", "[[Old]]", "Old", "New"), - ).toBe("Trailing [[New]]"); + expect(splice("Trailing [[Old]]", "[[Old]]", "Old", "New")).toBe( + "Trailing [[New]]", + ); }); test("wikilink with `.md` suffix is normalized", () => { // Conventionally wikilinks omit `.md`; the legacy refactor produced // a bare name and we preserve that behavior. - expect( - splice("See [[Old.md]].", "[[Old.md]]", "Old", "New"), - ).toBe("See [[New]]."); + expect(splice("See [[Old.md]].", "[[Old.md]]", "Old", "New")).toBe( + "See [[New]].", + ); }); test("wikilink whose target doesn't match oldName is left alone", () => { // Defensive — index says this links to Old, but the source text // doesn't agree. Don't rewrite. - expect( - splice("See [[Different]].", "[[Different]]", "Old", "New"), - ).toBe("See [[Different]]."); + expect(splice("See [[Different]].", "[[Different]]", "Old", "New")).toBe( + "See [[Different]].", + ); }); }); @@ -121,17 +126,17 @@ describe("markdown links (relative)", () => { }); test("markdown link preserves [text] empty", () => { - expect( - splice("[](Old)", "[](Old)", "Old", "New", "Editor"), - ).toBe("[](New)"); + expect(splice("[](Old)", "[](Old)", "Old", "New", "Editor")).toBe( + "[](New)", + ); }); }); describe("markdown links (absolute)", () => { test("absolute path leading slash preserved", () => { - expect( - splice("See [t](/Old).", "[t](/Old)", "Old", "New", "Editor"), - ).toBe("See [t](/New)."); + expect(splice("See [t](/Old).", "[t](/Old)", "Old", "New", "Editor")).toBe( + "See [t](/New).", + ); }); test("absolute path with detail", () => { @@ -143,21 +148,21 @@ describe("markdown links (absolute)", () => { describe("markdown links (angle-wrapped)", () => { test("preserves angle wrapping when present", () => { - expect( - splice("[t]()", "[t]()", "Old", "New", "Editor"), - ).toBe("[t]()"); + expect(splice("[t]()", "[t]()", "Old", "New", "Editor")).toBe( + "[t]()", + ); }); test("auto-wraps when the new name contains spaces", () => { - expect( - splice("[t](Old).", "[t](Old)", "Old", "New Name", "Editor"), - ).toBe("[t]()."); + expect(splice("[t](Old).", "[t](Old)", "Old", "New Name", "Editor")).toBe( + "[t]().", + ); }); test("auto-wraps absolute path with spaces", () => { - expect( - splice("[t](/Old).", "[t](/Old)", "Old", "New Name", "Editor"), - ).toBe("[t]()."); + expect(splice("[t](/Old).", "[t](/Old)", "Old", "New Name", "Editor")).toBe( + "[t]().", + ); }); }); diff --git a/plugs/index/refactor_splice.ts b/plugs/index/refactor_splice.ts index db0a0f76..4c6cec02 100644 --- a/plugs/index/refactor_splice.ts +++ b/plugs/index/refactor_splice.ts @@ -3,7 +3,7 @@ // Given the source text of a page, a single relation's `range`, the // old reference name, the new reference name, and the editing page's // name (for relative-path resolution), returns the source text with -// that one reference rewritten in place. +// that one reference rewritten in place. // Forms handled: // @@ -64,9 +64,7 @@ function spliceWikilink( // Strip a trailing `.md` if the user wrote one — wikilinks // conventionally omit the extension and the legacy refactor did // this too (the `link.toPage` was always without `.md`). - const stripped = basename.endsWith(".md") - ? basename.slice(0, -3) - : basename; + const stripped = basename.endsWith(".md") ? basename.slice(0, -3) : basename; if (stripped !== oldName) return text; const replacement = `[[${newName}${detail ?? ""}${aliasPart}]]`; @@ -103,9 +101,13 @@ function spliceMarkdownLink( const needsWrap = !!openAngle || newUrl.includes(" "); const wrappedUrl = needsWrap ? `<${newUrl}>` : newUrl; - return text.substring(0, start) + - leftBracketText + wrappedUrl + closeParen + - text.substring(end); + return ( + text.substring(0, start) + + leftBracketText + + wrappedUrl + + closeParen + + text.substring(end) + ); } // Matches a trailing `@123` / `@L4C5` / `#header` / `$anchor` ref diff --git a/plugs/index/relation.test.ts b/plugs/index/relation.test.ts index f69a9458..6bc13554 100644 --- a/plugs/index/relation.test.ts +++ b/plugs/index/relation.test.ts @@ -85,8 +85,8 @@ test("external markdown link emits url relation", async () => { const fm = extractFrontMatter(tree); const objects = await indexRelations(pageMeta("Source"), fm, tree, text); - const r = objects.find((o) => - o.tag === "relation" && o.to === "https://example.com" + const r = objects.find( + (o) => o.tag === "relation" && o.to === "https://example.com", ); expect(r).toBeDefined(); expect(r!.kind).toEqual("mention"); @@ -105,9 +105,7 @@ Body.`; const fm = extractFrontMatter(tree); const objects = await indexRelations(pageMeta("Linda"), fm, tree, text); - const r = objects.find((o) => - o.tag === "relation" && o.kind === "spouse" - ); + const r = objects.find((o) => o.tag === "relation" && o.kind === "spouse"); expect(r).toBeDefined(); expect(r!.to).toEqual("Jack"); expect(r!.from).toEqual("Linda"); @@ -124,9 +122,7 @@ test("inline attribute with wikilink value emits typed attribute relation", asyn const fm = extractFrontMatter(tree); const objects = await indexRelations(pageMeta("Linda"), fm, tree, text); - const r = objects.find((o) => - o.tag === "relation" && o.kind === "spouse" - ); + const r = objects.find((o) => o.tag === "relation" && o.kind === "spouse"); expect(r).toBeDefined(); expect(r!.to).toEqual("Jack"); expect(r!.range).toBeDefined(); @@ -154,9 +150,7 @@ test("mention to a markdown page has toTag=page", async () => { const fm = extractFrontMatter(tree); const objects = await indexRelations(pageMeta("Src"), fm, tree, text); - const r = objects.find((o) => - o.tag === "relation" && o.kind === "mention" - ); + const r = objects.find((o) => o.tag === "relation" && o.kind === "mention"); expect(r!.toTag).toEqual("page"); }); @@ -167,9 +161,7 @@ test("url relation has toTag=url", async () => { const fm = extractFrontMatter(tree); const objects = await indexRelations(pageMeta("Src"), fm, tree, text); - const r = objects.find((o) => - o.tag === "relation" && o.toTag === "url" - ); + const r = objects.find((o) => o.tag === "relation" && o.toTag === "url"); expect(r).toBeDefined(); expect(r!.kind).toEqual("mention"); expect(r!.toTag).toEqual("url"); @@ -185,8 +177,8 @@ test("two refs in same item emit co-mention edges in both directions", async () const fm = extractFrontMatter(tree); const objects = await indexRelations(pageMeta("Diary"), fm, tree, text); - const coments = objects.filter((o) => - o.tag === "relation" && o.kind === "co-mention" + const coments = objects.filter( + (o) => o.tag === "relation" && o.kind === "co-mention", ); expect(coments).toHaveLength(2); const pairs = new Set(coments.map((r) => `${r.from}->${r.to}`)); @@ -205,8 +197,8 @@ test("nested-child refs co-mention with parent item", async () => { const fm = extractFrontMatter(tree); const objects = await indexRelations(pageMeta("Page"), fm, tree, text); - const coments = objects.filter((o) => - o.tag === "relation" && o.kind === "co-mention" + const coments = objects.filter( + (o) => o.tag === "relation" && o.kind === "co-mention", ); expect(coments).toHaveLength(2); }); @@ -221,8 +213,8 @@ test("two refs in same paragraph (no list) emit co-mention", async () => { const fm = extractFrontMatter(tree); const objects = await indexRelations(pageMeta("Page"), fm, tree, text); - const coments = objects.filter((o) => - o.tag === "relation" && o.kind === "co-mention" + const coments = objects.filter( + (o) => o.tag === "relation" && o.kind === "co-mention", ); expect(coments).toHaveLength(2); }); @@ -237,8 +229,8 @@ test("two refs in different paragraphs emit no co-mention", async () => { const fm = extractFrontMatter(tree); const objects = await indexRelations(pageMeta("Page"), fm, tree, text); - const coments = objects.filter((o) => - o.tag === "relation" && o.kind === "co-mention" + const coments = objects.filter( + (o) => o.tag === "relation" && o.kind === "co-mention", ); expect(coments).toHaveLength(0); }); @@ -252,9 +244,7 @@ test("wikilink inside list item: from = item ref, fromTag = item", async () => { const fm = extractFrontMatter(tree); const objects = await indexRelations(pageMeta("Diary"), fm, tree, text); - const r = objects.find((o) => - o.tag === "relation" && o.kind === "mention" - ); + const r = objects.find((o) => o.tag === "relation" && o.kind === "mention"); expect(r).toBeDefined(); expect(r!.from).toMatch(/^Diary@\d+$/); expect(r!.fromTag).toEqual("item"); @@ -265,14 +255,13 @@ test("co-mention pairs attribute targets in the same item", async () => { await space.writePage("Angela", ""); await space.writePage("Super Team", ""); - const text = - `* #contact $pete Pete [spouse: "[[Angela]]"] [team: "[[Super Team]]"]`; + const text = `* #contact $pete Pete [spouse: "[[Angela]]"] [team: "[[Super Team]]"]`; const tree = parseMarkdown(text); const fm = extractFrontMatter(tree); const objects = await indexRelations(pageMeta("People"), fm, tree, text); - const coms = objects.filter((o) => - o.tag === "relation" && o.kind === "co-mention" + const coms = objects.filter( + (o) => o.tag === "relation" && o.kind === "co-mention", ); const pairs = new Set(coms.map((r) => `${r.from}->${r.to}`)); expect(pairs.has("Angela->Super Team")).toBe(true); @@ -286,13 +275,12 @@ test("co-mention emits unique refs when target appears multiple times in differe // [[A]] shares a (nested) scope with both occurrences of [[B]]; without // ref-granularity dedupe, both j-iterations would emit the same ref // (A's position + B's name) but with different `via` scopes. - const text = - "* outer\n * [[A]]\n * [[B]]\n * also [[B]] here\n"; + const text = "* outer\n * [[A]]\n * [[B]]\n * also [[B]] here\n"; const tree = parseMarkdown(text); const fm = extractFrontMatter(tree); const objects = await indexRelations(pageMeta("P"), fm, tree, text); - const coms = objects.filter((o) => - o.tag === "relation" && o.kind === "co-mention" + const coms = objects.filter( + (o) => o.tag === "relation" && o.kind === "co-mention", ); const refs = coms.map((r) => r.ref); expect(new Set(refs).size).toEqual(refs.length); @@ -306,8 +294,8 @@ test("co-mention carries fromTag/toTag from target relations", async () => { const tree = parseMarkdown(text); const fm = extractFrontMatter(tree); const objects = await indexRelations(pageMeta("Diary"), fm, tree, text); - const coms = objects.filter((o) => - o.tag === "relation" && o.kind === "co-mention" + const coms = objects.filter( + (o) => o.tag === "relation" && o.kind === "co-mention", ); expect(coms).toHaveLength(2); for (const r of coms) { @@ -326,14 +314,13 @@ test("tagged item with $anchor: from = anchor name", async () => { await space.writePage("Angela", ""); await space.writePage("Super Team", ""); - const text = - `* #contact $pete Pete [spouse: "[[Angela]]"] [team: "[[Super Team]]"]`; + const text = `* #contact $pete Pete [spouse: "[[Angela]]"] [team: "[[Super Team]]"]`; const tree = parseMarkdown(text); const fm = extractFrontMatter(tree); const objects = await indexRelations(pageMeta("People"), fm, tree, text); - const attrs = objects.filter((o) => - o.tag === "relation" && (o.kind === "spouse" || o.kind === "team") + const attrs = objects.filter( + (o) => o.tag === "relation" && (o.kind === "spouse" || o.kind === "team"), ); expect(attrs).toHaveLength(2); for (const r of attrs) { @@ -344,13 +331,12 @@ test("tagged item with $anchor: from = anchor name", async () => { test("same-page anchor wikilink: to = anchor name, kind = mention", async () => { createMockSystem(); - const text = - `* #contact $pete-ref Pete\n\nSee also [[$pete-ref]].\n`; + const text = `* #contact $pete-ref Pete\n\nSee also [[$pete-ref]].\n`; const tree = parseMarkdown(text); const fm = extractFrontMatter(tree); const objects = await indexRelations(pageMeta("People"), fm, tree, text); - const mentions = objects.filter((o) => - o.tag === "relation" && o.kind === "mention" + const mentions = objects.filter( + (o) => o.tag === "relation" && o.kind === "mention", ); expect(mentions).toHaveLength(1); expect(mentions[0].to).toEqual("pete-ref"); @@ -363,14 +349,15 @@ test("same-page anchor wikilink: to = anchor name, kind = mention", async () => test("anchor wikilinks: toTag = 'anchor' regardless of host block type", async () => { createMockSystem(); - const text = - `# $intro Intro\n\n* [ ] $todo Do something\n\nLinks: [[$intro]] [[$todo]] [[$elsewhere]]\n`; + const text = `# $intro Intro\n\n* [ ] $todo Do something\n\nLinks: [[$intro]] [[$todo]] [[$elsewhere]]\n`; const tree = parseMarkdown(text); const fm = extractFrontMatter(tree); const objects = await indexRelations(pageMeta("Notes"), fm, tree, text); - const anchorMentions = objects.filter((o) => - o.tag === "relation" && o.kind === "mention" && - ["intro", "todo", "elsewhere"].includes(o.to) + const anchorMentions = objects.filter( + (o) => + o.tag === "relation" && + o.kind === "mention" && + ["intro", "todo", "elsewhere"].includes(o.to), ); expect(anchorMentions).toHaveLength(3); for (const r of anchorMentions) { @@ -395,9 +382,7 @@ test("cross-page anchor wikilink: to = page (anchor segment is UI nav)", async ( const tree = parseMarkdown(text); const fm = extractFrontMatter(tree); const objects = await indexRelations(pageMeta("Notes"), fm, tree, text); - const r = objects.find((o) => - o.tag === "relation" && o.kind === "mention" - ); + const r = objects.find((o) => o.tag === "relation" && o.kind === "mention"); expect(r).toBeDefined(); expect(r!.to).toEqual("Other"); expect(r!.toTag).toEqual("page"); @@ -405,14 +390,11 @@ test("cross-page anchor wikilink: to = page (anchor segment is UI nav)", async ( test("anchor wikilink inside attribute value: to = anchor name", async () => { createMockSystem(); - const text = - `* $a Alice [friend: "[[$b]]"]\n* $b Bob\n`; + const text = `* $a Alice [friend: "[[$b]]"]\n* $b Bob\n`; const tree = parseMarkdown(text); const fm = extractFrontMatter(tree); const objects = await indexRelations(pageMeta("People"), fm, tree, text); - const attr = objects.find((o) => - o.tag === "relation" && o.kind === "friend" - ); + const attr = objects.find((o) => o.tag === "relation" && o.kind === "friend"); expect(attr).toBeDefined(); expect(attr!.to).toEqual("b"); expect(attr!.from).toEqual("a"); @@ -429,9 +411,7 @@ test("anchor on sub-list item does not bleed into parent item ref", async () => const fm = extractFrontMatter(tree); const objects = await indexRelations(pageMeta("Diary"), fm, tree, text); - const r = objects.find((o) => - o.tag === "relation" && o.kind === "mention" - ); + const r = objects.find((o) => o.tag === "relation" && o.kind === "mention"); expect(r).toBeDefined(); expect(r!.from).toMatch(/^Diary@\d+$/); }); @@ -445,9 +425,7 @@ test("wikilink inside task: fromTag = task", async () => { const fm = extractFrontMatter(tree); const objects = await indexRelations(pageMeta("Today"), fm, tree, text); - const r = objects.find((o) => - o.tag === "relation" && o.kind === "mention" - ); + const r = objects.find((o) => o.tag === "relation" && o.kind === "mention"); expect(r).toBeDefined(); expect(r!.fromTag).toEqual("task"); expect(r!.from).toMatch(/^Today@\d+$/); @@ -457,15 +435,12 @@ test("fenced #tag data block with wikilink value emits attribute relation", asyn const { space } = createMockSystem(); await space.writePage("Jack", ""); - const text = - "Header\n\n```#person\nname: Linda\nspouse: \"[[Jack]]\"\n```\n"; + const text = 'Header\n\n```#person\nname: Linda\nspouse: "[[Jack]]"\n```\n'; const tree = parseMarkdown(text); const fm = extractFrontMatter(tree); const objects = await indexRelations(pageMeta("People"), fm, tree, text); - const r = objects.find((o) => - o.tag === "relation" && o.kind === "spouse" - ); + const r = objects.find((o) => o.tag === "relation" && o.kind === "spouse"); expect(r).toBeDefined(); expect(r!.to).toEqual("Jack"); // `from` / `fromTag` still encode the data-block provenance. @@ -481,9 +456,7 @@ test("document markdown link emits document relation", async () => { const fm = extractFrontMatter(tree); const objects = await indexRelations(pageMeta("Source"), fm, tree, text); - const r = objects.find((o) => - o.tag === "relation" && o.toTag === "document" - ); + const r = objects.find((o) => o.tag === "relation" && o.toTag === "document"); expect(r).toBeDefined(); expect(r!.kind).toEqual("mention"); expect(r!.toTag).toEqual("document"); diff --git a/plugs/index/relation.ts b/plugs/index/relation.ts index 1476f19e..071d9ec2 100644 --- a/plugs/index/relation.ts +++ b/plugs/index/relation.ts @@ -222,172 +222,191 @@ export async function indexRelations( const pageFrom = pageMeta.name; const pageFromTag = "page"; - traverseTree(tree, (n) => { - if (n.type === "WikiLink") { - const wikiLinkPage = findNodeOfType(n, "WikiLinkPage"); - if (!wikiLinkPage) return true; - const ref = parseToRef(wikiLinkPage.children![0].text!); - if (!ref) return true; - const { from, fromTag } = innermostContainer(n, pageMeta.name); - const alias = findNodeOfType(n, "WikiLinkAlias")?.children?.[0].text; - // Same-page wikilinks (`[[#Heading]]`, `[[@123]]`, `[[$anchor]]`). - // Only `$anchor` points at an indexed object — items and headers - // with `$name` are stored under `ref = name`. The header/position - // forms are intra-page UI nav and aren't worth recording as edges. - if (ref.path === "") { - if (ref.details?.type === "anchor") { - emitTextualEdge(ctx, { - kind: "mention", - from, - fromTag, - to: ref.details.name, - toTag: ANCHOR_TARGET_TAG, - range: [n.from!, n.to!], - alias, - }); - } - return true; - } - const isPage = isMarkdownPath(ref.path); - emitTextualEdge(ctx, { - // `[[X.jpg]]` (or any non-markdown target) is a document edge, - // matching the legacy `link.type = "file"` classification. - kind: "mention", - from, - fromTag, - to: isPage ? getNameFromPath(ref.path) : ref.path, - toTag: isPage ? "page" : "document", - range: [n.from!, n.to!], - alias, - }); - return true; - } - - if (n.type === "Link" || n.type === "Image") { - mdLinkRegex.lastIndex = 0; - const match = mdLinkRegex.exec(renderToText(n)); - if (!match) return false; - const { title: alias, url } = match.groups as { - url: string; - title: string; - }; - const { from, fromTag } = innermostContainer(n, pageMeta.name); - const base = { - from, - fromTag, - range: [n.from!, n.to!] as [number, number], - alias, - }; - if (!isLocalURL(url)) { - emitTextualEdge(ctx, { ...base, kind: "mention", to: url, toTag: "url" }); - return true; - } - const ref = parseToRef( - resolveMarkdownLink(pageMeta.name, decodeURI(url)), - ); - if (!ref) return true; - if (isMarkdownPath(ref.path)) { - emitTextualEdge(ctx, { - ...base, - kind: "mention", - to: getNameFromPath(ref.path), - toTag: "page", - }); - } else { - emitTextualEdge(ctx, { - ...base, - kind: "mention", - to: ref.path, - toTag: "document", - }); - } - return true; - } - - if (n.type === "FencedCode") { - const codeInfoNode = findNodeOfType(n, "CodeInfo"); - if (!codeInfoNode) return true; - const fenceType = codeInfoNode.children![0].text!; - if (!fenceType.startsWith("#")) return true; - const dataType = fenceType.substring(1); - const codeTextNode = findNodeOfType(n, "CodeText"); - if (!codeTextNode) return true; - const codeText = codeTextNode.children![0].text!; - const blockRef = `${pageMeta.name}@${n.from!}`; - - const lineKeyRegex = /^(\s*)([\w$][\w$\- ]*)\s*:\s*(.*)$/; - let cursor = 0; - for (const line of codeText.split("\n")) { - const m = lineKeyRegex.exec(line); - if (m) { - const valueOffset = m[0].length - m[3].length; - emitWikiLinksInRange(ctx, m[3], codeTextNode.from! + cursor + valueOffset, { - kind: m[2].trim(), - from: blockRef, - fromTag: dataType, - }); - } - cursor += line.length + 1; - } - return true; - } - - if (n.type === "Attribute") { - const nameNode = findNodeOfType(n, "AttributeName"); - const valueNode = findNodeOfType(n, "AttributeValue"); - if (!nameNode || !valueNode) return true; - const { from, fromTag } = innermostContainer(n, pageMeta.name); - emitWikiLinksInRange(ctx, valueNode.children![0].text!, valueNode.from!, { - kind: nameNode.children![0].text!, - from, - fromTag, - }); - return true; - } - - if (n.type === "FrontMatter") { - for (const { key, valueNode } of frontmatterStringEntries(n)) { - const text = valueNode.children![0].text!; - const trimmed = text.replace(/^["'\s]*/, "").replace(/["'\s]*$/, ""); - wikiLinkRegex.lastIndex = 0; - const match = wikiLinkRegex.exec(text); - if (!match?.groups || match[0] !== trimmed) continue; - const { stringRef, alias } = match.groups as { - stringRef: string; - alias?: string; - }; - const ref = parseToRef(stringRef); - if (!ref) continue; - const start = valueNode.from! + match.index!; - const range: [number, number] = [start, start + match[0].length]; - const base = { - kind: key, - from: pageFrom, - fromTag: pageFromTag, - range, - alias, - }; + traverseTree( + tree, + (n) => { + if (n.type === "WikiLink") { + const wikiLinkPage = findNodeOfType(n, "WikiLinkPage"); + if (!wikiLinkPage) return true; + const ref = parseToRef(wikiLinkPage.children![0].text!); + if (!ref) return true; + const { from, fromTag } = innermostContainer(n, pageMeta.name); + const alias = findNodeOfType(n, "WikiLinkAlias")?.children?.[0].text; + // Same-page wikilinks (`[[#Heading]]`, `[[@123]]`, `[[$anchor]]`). + // Only `$anchor` points at an indexed object — items and headers + // with `$name` are stored under `ref = name`. The header/position + // forms are intra-page UI nav and aren't worth recording as edges. if (ref.path === "") { if (ref.details?.type === "anchor") { emitTextualEdge(ctx, { - ...base, + kind: "mention", + from, + fromTag, to: ref.details.name, toTag: ANCHOR_TARGET_TAG, + range: [n.from!, n.to!], + alias, }); } - continue; + return true; } const isPage = isMarkdownPath(ref.path); emitTextualEdge(ctx, { - ...base, + // `[[X.jpg]]` (or any non-markdown target) is a document edge, + // matching the legacy `link.type = "file"` classification. + kind: "mention", + from, + fromTag, to: isPage ? getNameFromPath(ref.path) : ref.path, toTag: isPage ? "page" : "document", + range: [n.from!, n.to!], + alias, }); + return true; } - return true; - } - return false; - }, true); + + if (n.type === "Link" || n.type === "Image") { + mdLinkRegex.lastIndex = 0; + const match = mdLinkRegex.exec(renderToText(n)); + if (!match) return false; + const { title: alias, url } = match.groups as { + url: string; + title: string; + }; + const { from, fromTag } = innermostContainer(n, pageMeta.name); + const base = { + from, + fromTag, + range: [n.from!, n.to!] as [number, number], + alias, + }; + if (!isLocalURL(url)) { + emitTextualEdge(ctx, { + ...base, + kind: "mention", + to: url, + toTag: "url", + }); + return true; + } + const ref = parseToRef( + resolveMarkdownLink(pageMeta.name, decodeURI(url)), + ); + if (!ref) return true; + if (isMarkdownPath(ref.path)) { + emitTextualEdge(ctx, { + ...base, + kind: "mention", + to: getNameFromPath(ref.path), + toTag: "page", + }); + } else { + emitTextualEdge(ctx, { + ...base, + kind: "mention", + to: ref.path, + toTag: "document", + }); + } + return true; + } + + if (n.type === "FencedCode") { + const codeInfoNode = findNodeOfType(n, "CodeInfo"); + if (!codeInfoNode) return true; + const fenceType = codeInfoNode.children![0].text!; + if (!fenceType.startsWith("#")) return true; + const dataType = fenceType.substring(1); + const codeTextNode = findNodeOfType(n, "CodeText"); + if (!codeTextNode) return true; + const codeText = codeTextNode.children![0].text!; + const blockRef = `${pageMeta.name}@${n.from!}`; + + const lineKeyRegex = /^(\s*)([\w$][\w$\- ]*)\s*:\s*(.*)$/; + let cursor = 0; + for (const line of codeText.split("\n")) { + const m = lineKeyRegex.exec(line); + if (m) { + const valueOffset = m[0].length - m[3].length; + emitWikiLinksInRange( + ctx, + m[3], + codeTextNode.from! + cursor + valueOffset, + { + kind: m[2].trim(), + from: blockRef, + fromTag: dataType, + }, + ); + } + cursor += line.length + 1; + } + return true; + } + + if (n.type === "Attribute") { + const nameNode = findNodeOfType(n, "AttributeName"); + const valueNode = findNodeOfType(n, "AttributeValue"); + if (!nameNode || !valueNode) return true; + const { from, fromTag } = innermostContainer(n, pageMeta.name); + emitWikiLinksInRange( + ctx, + valueNode.children![0].text!, + valueNode.from!, + { + kind: nameNode.children![0].text!, + from, + fromTag, + }, + ); + return true; + } + + if (n.type === "FrontMatter") { + for (const { key, valueNode } of frontmatterStringEntries(n)) { + const text = valueNode.children![0].text!; + const trimmed = text.replace(/^["'\s]*/, "").replace(/["'\s]*$/, ""); + wikiLinkRegex.lastIndex = 0; + const match = wikiLinkRegex.exec(text); + if (!match?.groups || match[0] !== trimmed) continue; + const { stringRef, alias } = match.groups as { + stringRef: string; + alias?: string; + }; + const ref = parseToRef(stringRef); + if (!ref) continue; + const start = valueNode.from! + match.index!; + const range: [number, number] = [start, start + match[0].length]; + const base = { + kind: key, + from: pageFrom, + fromTag: pageFromTag, + range, + alias, + }; + if (ref.path === "") { + if (ref.details?.type === "anchor") { + emitTextualEdge(ctx, { + ...base, + to: ref.details.name, + toTag: ANCHOR_TARGET_TAG, + }); + } + continue; + } + const isPage = isMarkdownPath(ref.path); + emitTextualEdge(ctx, { + ...base, + to: isPage ? getNameFromPath(ref.path) : ref.path, + toTag: isPage ? "page" : "document", + }); + } + return true; + } + return false; + }, + true, + ); emitCoMentions(ctx, tree); await emitAspiringPages(ctx); @@ -402,9 +421,7 @@ export async function indexRelations( async function emitAspiringPages(ctx: EmitCtx): Promise { const candidates = ctx.out.filter( (r): r is RelationObject & { range: [number, number] } => - r.kind !== "co-mention" && - r.toTag === "page" && - Array.isArray(r.range), + r.kind !== "co-mention" && r.toTag === "page" && Array.isArray(r.range), ); if (candidates.length === 0) return; @@ -449,11 +466,15 @@ function emitCoMentions(ctx: EmitCtx, tree: ParseTree): void { type Scope = { from: number; to: number }; const items: Scope[] = []; const paragraphs: Scope[] = []; - traverseTree(tree, (n) => { - if (n.type === "ListItem") items.push({ from: n.from!, to: n.to! }); - if (n.type === "Paragraph") paragraphs.push({ from: n.from!, to: n.to! }); - return false; - }, true); + traverseTree( + tree, + (n) => { + if (n.type === "ListItem") items.push({ from: n.from!, to: n.to! }); + if (n.type === "Paragraph") paragraphs.push({ from: n.from!, to: n.to! }); + return false; + }, + true, + ); // Innermost first by sorting descending on `from` (smaller scopes // appear later in document order than their enclosing scopes only // when nested — for siblings any order is fine because each relation @@ -461,9 +482,15 @@ function emitCoMentions(ctx: EmitCtx, tree: ParseTree): void { items.sort((a, b) => b.from - a.from); paragraphs.sort((a, b) => b.from - a.from); - const ancestorsFor = (pos: number): { items: number[]; paragraphs: number[] } => { - const it = items.filter((s) => s.from <= pos && pos < s.to).map((s) => s.from); - const pa = paragraphs.filter((s) => s.from <= pos && pos < s.to).map((s) => s.from); + const ancestorsFor = ( + pos: number, + ): { items: number[]; paragraphs: number[] } => { + const it = items + .filter((s) => s.from <= pos && pos < s.to) + .map((s) => s.from); + const pa = paragraphs + .filter((s) => s.from <= pos && pos < s.to) + .map((s) => s.from); return { items: it, paragraphs: pa }; }; @@ -497,9 +524,10 @@ function emitCoMentions(ctx: EmitCtx, tree: ParseTree): void { const b = mentions[j]; if (a.rec.to === b.rec.to) continue; const sharedItem = a.items.find((p) => b.itemSet.has(p)); - const sharedPara = sharedItem === undefined - ? a.paragraphs.find((p) => b.paraSet.has(p)) - : undefined; + const sharedPara = + sharedItem === undefined + ? a.paragraphs.find((p) => b.paraSet.has(p)) + : undefined; const sharedPos = sharedItem ?? sharedPara; if (sharedPos === undefined) continue; @@ -536,16 +564,20 @@ function emitCoMentions(ctx: EmitCtx, tree: ParseTree): void { */ export function collectPageLinks(n: ParseTree): string[] { const links = new Set(); - traverseTree(n, (n) => { - if (n.type === "WikiLink") { - links.add(findNodeOfType(n, "WikiLinkPage")!.children![0].text!); - return true; - } else if (n.type === "OrderedList" || n.type === "BulletList") { - // Don't traverse into sub-lists - return true; - } - return false; - }, true); + traverseTree( + n, + (n) => { + if (n.type === "WikiLink") { + links.add(findNodeOfType(n, "WikiLinkPage")!.children![0].text!); + return true; + } else if (n.type === "OrderedList" || n.type === "BulletList") { + // Don't traverse into sub-lists + return true; + } + return false; + }, + true, + ); return [...links]; } diff --git a/plugs/index/tags.ts b/plugs/index/tags.ts index 5e7d871a..40eea62b 100644 --- a/plugs/index/tags.ts +++ b/plugs/index/tags.ts @@ -101,7 +101,7 @@ export async function tagComplete(completeEvent: CompleteEvent) { export async function frontmatterTagComplete(completeEvent: CompleteEvent) { // Only trigger inside frontmatter const frontmatterNode = completeEvent.parentNodes.find((n) => - n.startsWith("FrontMatter:") + n.startsWith("FrontMatter:"), ); if (!frontmatterNode) { return null; @@ -111,16 +111,19 @@ export async function frontmatterTagComplete(completeEvent: CompleteEvent) { // Determine if the cursor line is within a tags section // Pattern 1: tags: value or tags: [v1, v2, partial (comma or space separated) - const tagsLineMatch = /tags:\s+\[?(?:.*[,\s]\s*)?([^\s!@$%^&*(),.?":{}|<>\\[\]]*)$/.exec( - completeEvent.linePrefix, - ); + const tagsLineMatch = + /tags:\s+\[?(?:.*[,\s]\s*)?([^\s!@$%^&*(),.?":{}|<>\\[\]]*)$/.exec( + completeEvent.linePrefix, + ); let prefix = ""; if (tagsLineMatch) { prefix = tagsLineMatch[1]; } else { // Pattern 2: list item under a tags: key (e.g. " - partial") - const listItemMatch = /^\s+-\s+([^\s!@$%^&*(),.?":{}|<>\\[\]]*)$/.exec(completeEvent.linePrefix); + const listItemMatch = /^\s+-\s+([^\s!@$%^&*(),.?":{}|<>\\[\]]*)$/.exec( + completeEvent.linePrefix, + ); if (!listItemMatch) { return null; } @@ -190,16 +193,20 @@ export function renderHashtag(name: string): string { */ export function collectTags(n: ParseTree): string[] { const tags = new Set(); - traverseTree(n, (n) => { - if (n.type === "Hashtag") { - tags.add(extractHashtag(n.children![0].text!)); - return true; - } else if (n.type === "OrderedList" || n.type === "BulletList") { - // Don't traverse into sub-lists - return true; - } - return false; - }, true); + traverseTree( + n, + (n) => { + if (n.type === "Hashtag") { + tags.add(extractHashtag(n.children![0].text!)); + return true; + } else if (n.type === "OrderedList" || n.type === "BulletList") { + // Don't traverse into sub-lists + return true; + } + return false; + }, + true, + ); return [...tags]; } diff --git a/plugs/index/task.ts b/plugs/index/task.ts index 17940a43..cdfcb1c7 100644 --- a/plugs/index/task.ts +++ b/plugs/index/task.ts @@ -166,7 +166,9 @@ export async function updateTaskState( // edit the task marker. if (ref.details?.type === "anchor") { const pageFilter = ref.path - ? ref.path.endsWith(".md") ? ref.path.slice(0, -3) : ref.path + ? ref.path.endsWith(".md") + ? ref.path.slice(0, -3) + : ref.path : undefined; const result = await index.resolveAnchor(ref.details.name, pageFilter); if (!result.ok) { diff --git a/plugs/index/xray.test.ts b/plugs/index/xray.test.ts index 763dce6a..643e14d4 100644 --- a/plugs/index/xray.test.ts +++ b/plugs/index/xray.test.ts @@ -28,10 +28,7 @@ describe("groupByObject", () => { { tag: "athing", object: { ref: "p1@0", range: [0, 40] } }, ]; const groups = groupByObject(stack as any); - expect(groups.map((g) => g.tags)).toEqual([ - ["link"], - ["item", "athing"], - ]); + expect(groups.map((g) => g.tags)).toEqual([["link"], ["item", "athing"]]); }); it("does not merge non-adjacent entries even when ref+range match", () => { diff --git a/plugs/index/xray.ts b/plugs/index/xray.ts index 93e1ac51..572b1b90 100644 --- a/plugs/index/xray.ts +++ b/plugs/index/xray.ts @@ -79,19 +79,18 @@ export function renderObjectYaml(obj: ObjectValue): string { } function escapeHtml(s: string): string { - return s - .replace(/&/g, "&") - .replace(//g, ">"); + return s.replace(/&/g, "&").replace(//g, ">"); } function renderGroupHtml(group: ObjectGroup): string { const head = escapeHtml(group.tags.join(", ")); const body = escapeHtml(renderObjectYaml(group.object)); - return `
    ` + + return ( + `
    ` + `

    ${head}

    ` + `
    ${body}
    ` + - `
    `; + `
    ` + ); } function renderGroupPlain(group: ObjectGroup): string { @@ -106,10 +105,13 @@ function renderGroupPlain(group: ObjectGroup): string { * fills the lint hover tooltip with a card showing the indexing tag(s) * and the object's attributes as YAML. */ -export async function xrayInfo( - { tree, name, pageMeta, text }: LintEvent, -): Promise { - if (!await clientStore.get(STORE_KEY)) return []; +export async function xrayInfo({ + tree, + name, + pageMeta, + text, +}: LintEvent): Promise { + if (!(await clientStore.get(STORE_KEY))) return []; if (!pageMeta) return []; const frontmatter = extractFrontMatter(tree); @@ -132,14 +134,16 @@ export async function xrayInfo( const ranged = filterRangedEntries(entries); const groups = groupByObject(ranged); - return groups.map((group): LintDiagnostic => ({ - from: group.object.range[0], - to: group.object.range[1], - severity: "hint", - message: renderGroupPlain(group), - messageHtml: renderGroupHtml(group), - markClass: "sb-xray-range", - })); + return groups.map( + (group): LintDiagnostic => ({ + from: group.object.range[0], + to: group.object.range[1], + severity: "hint", + message: renderGroupPlain(group), + messageHtml: renderGroupHtml(group), + markClass: "sb-xray-range", + }), + ); } /** diff --git a/plugs/object-graph/src/graph_builder.test.ts b/plugs/object-graph/src/graph_builder.test.ts index 3383072a..d250464a 100644 --- a/plugs/object-graph/src/graph_builder.test.ts +++ b/plugs/object-graph/src/graph_builder.test.ts @@ -1,178 +1,178 @@ import { describe, expect, it } from "vitest"; import { - classifyKind, - collapseEdges, - deriveTagFields, - endpointMatchesRef, - isCoreTag, - type RelationRow, - stripPagePos, + classifyKind, + collapseEdges, + deriveTagFields, + endpointMatchesRef, + isCoreTag, + type RelationRow, + stripPagePos, } from "./graph_builder.ts"; describe("stripPagePos", () => { - it("returns bare refs unchanged", () => { - expect(stripPagePos("Page")).toBe("Page"); - expect(stripPagePos("Drivers/Max Verstappen")).toBe( - "Drivers/Max Verstappen", - ); - }); + it("returns bare refs unchanged", () => { + expect(stripPagePos("Page")).toBe("Page"); + expect(stripPagePos("Drivers/Max Verstappen")).toBe( + "Drivers/Max Verstappen", + ); + }); - it("strips @pos, #header, $anchor", () => { - expect(stripPagePos("Page@744")).toBe("Page"); - expect(stripPagePos("Page#header")).toBe("Page"); - expect(stripPagePos("Page$anchor")).toBe("Page"); - }); + it("strips @pos, #header, $anchor", () => { + expect(stripPagePos("Page@744")).toBe("Page"); + expect(stripPagePos("Page#header")).toBe("Page"); + expect(stripPagePos("Page$anchor")).toBe("Page"); + }); - it("leaves url and file refs untouched", () => { - expect(stripPagePos("https://example.com/path#x")).toBe( - "https://example.com/path#x", - ); - expect(stripPagePos("docs.pdf")).toBe("docs.pdf"); - }); + it("leaves url and file refs untouched", () => { + expect(stripPagePos("https://example.com/path#x")).toBe( + "https://example.com/path#x", + ); + expect(stripPagePos("docs.pdf")).toBe("docs.pdf"); + }); }); describe("endpointMatchesRef", () => { - it("matches exact refs", () => { - expect(endpointMatchesRef("Page", "Page")).toBe(true); - }); + it("matches exact refs", () => { + expect(endpointMatchesRef("Page", "Page")).toBe(true); + }); - it("matches positional / header / anchor variants", () => { - expect(endpointMatchesRef("Page@123", "Page")).toBe(true); - expect(endpointMatchesRef("Page#h1", "Page")).toBe(true); - expect(endpointMatchesRef("Page$anchor", "Page")).toBe(true); - }); + it("matches positional / header / anchor variants", () => { + expect(endpointMatchesRef("Page@123", "Page")).toBe(true); + expect(endpointMatchesRef("Page#h1", "Page")).toBe(true); + expect(endpointMatchesRef("Page$anchor", "Page")).toBe(true); + }); - it("rejects prefix overlap that isn't a real boundary", () => { - expect(endpointMatchesRef("Pages", "Page")).toBe(false); - expect(endpointMatchesRef("Page/sub", "Page")).toBe(false); - expect(endpointMatchesRef("Page-x", "Page")).toBe(false); - }); + it("rejects prefix overlap that isn't a real boundary", () => { + expect(endpointMatchesRef("Pages", "Page")).toBe(false); + expect(endpointMatchesRef("Page/sub", "Page")).toBe(false); + expect(endpointMatchesRef("Page-x", "Page")).toBe(false); + }); - it("rejects unrelated endpoints", () => { - expect(endpointMatchesRef("Other", "Page")).toBe(false); - }); + it("rejects unrelated endpoints", () => { + expect(endpointMatchesRef("Other", "Page")).toBe(false); + }); }); describe("classifyKind", () => { - it("identifies URLs", () => { - expect(classifyKind("https://example.com")).toBe("url"); - expect(classifyKind("http://localhost:3000")).toBe("url"); - }); + it("identifies URLs", () => { + expect(classifyKind("https://example.com")).toBe("url"); + expect(classifyKind("http://localhost:3000")).toBe("url"); + }); - it("identifies files (single dot, non-md)", () => { - expect(classifyKind("doc.pdf")).toBe("file"); - expect(classifyKind("notes.txt")).toBe("file"); - }); + it("identifies files (single dot, non-md)", () => { + expect(classifyKind("doc.pdf")).toBe("file"); + expect(classifyKind("notes.txt")).toBe("file"); + }); - it("treats .md and pathy refs as pages", () => { - expect(classifyKind("Notes")).toBe("page"); - expect(classifyKind("Notes/2025")).toBe("page"); - expect(classifyKind("Notes.md")).toBe("page"); - }); + it("treats .md and pathy refs as pages", () => { + expect(classifyKind("Notes")).toBe("page"); + expect(classifyKind("Notes/2025")).toBe("page"); + expect(classifyKind("Notes.md")).toBe("page"); + }); }); describe("isCoreTag", () => { - it("recognises structural tags", () => { - expect(isCoreTag("page")).toBe(true); - expect(isCoreTag("item")).toBe(true); - expect(isCoreTag("task")).toBe(true); - expect(isCoreTag("block")).toBe(true); - }); + it("recognises structural tags", () => { + expect(isCoreTag("page")).toBe(true); + expect(isCoreTag("item")).toBe(true); + expect(isCoreTag("task")).toBe(true); + expect(isCoreTag("block")).toBe(true); + }); - it("recognises meta and meta/* tags", () => { - expect(isCoreTag("meta")).toBe(true); - expect(isCoreTag("meta/library")).toBe(true); - }); + it("recognises meta and meta/* tags", () => { + expect(isCoreTag("meta")).toBe(true); + expect(isCoreTag("meta/library")).toBe(true); + }); - it("rejects user tags", () => { - expect(isCoreTag("driver")).toBe(false); - expect(isCoreTag("project")).toBe(false); - }); + it("rejects user tags", () => { + expect(isCoreTag("driver")).toBe(false); + expect(isCoreTag("project")).toBe(false); + }); }); describe("deriveTagFields", () => { - it("uses hostTag as rootTag for ordinary structural objects", () => { - const r = deriveTagFields(["page", "driver"], "page"); - expect(r.rootTag).toBe("page"); - expect(r.primaryTag).toBe("driver"); - expect(r.tags).toEqual(["driver"]); - }); + it("uses hostTag as rootTag for ordinary structural objects", () => { + const r = deriveTagFields(["page", "driver"], "page"); + expect(r.rootTag).toBe("page"); + expect(r.primaryTag).toBe("driver"); + expect(r.tags).toEqual(["driver"]); + }); - it("promotes item to task when the task tag is present", () => { - const r = deriveTagFields(["item", "task", "todo"], "item"); - expect(r.rootTag).toBe("task"); - expect(r.primaryTag).toBe("todo"); - }); + it("promotes item to task when the task tag is present", () => { + const r = deriveTagFields(["item", "task", "todo"], "item"); + expect(r.rootTag).toBe("task"); + expect(r.primaryTag).toBe("todo"); + }); - it("filters core + meta tags out of the user-tag list", () => { - const r = deriveTagFields( - ["page", "meta", "meta/template", "research"], - "page", - ); - expect(r.tags).toEqual(["research"]); - expect(r.primaryTag).toBe("research"); - }); + it("filters core + meta tags out of the user-tag list", () => { + const r = deriveTagFields( + ["page", "meta", "meta/template", "research"], + "page", + ); + expect(r.tags).toEqual(["research"]); + expect(r.primaryTag).toBe("research"); + }); - it("returns null rootTag for non-core host tags", () => { - expect(deriveTagFields(["foo"], "foo").rootTag).toBeNull(); - }); + it("returns null rootTag for non-core host tags", () => { + expect(deriveTagFields(["foo"], "foo").rootTag).toBeNull(); + }); }); describe("collapseEdges", () => { - const baseRow = (overrides: Partial): RelationRow => ({ - from: "A", - to: "B", - kind: "mention", - page: "A", - ...overrides, - }); + const baseRow = (overrides: Partial): RelationRow => ({ + from: "A", + to: "B", + kind: "mention", + page: "A", + ...overrides, + }); - it("passes through a single mention as-is", () => { - const out = collapseEdges([baseRow({})]); - expect(out).toHaveLength(1); - expect(out[0].source).toBe("A"); - expect(out[0].target).toBe("B"); - expect(out[0].label).toBe("mention"); - expect(out[0].undirected).toBe(false); - }); + it("passes through a single mention as-is", () => { + const out = collapseEdges([baseRow({})]); + expect(out).toHaveLength(1); + expect(out[0].source).toBe("A"); + expect(out[0].target).toBe("B"); + expect(out[0].label).toBe("mention"); + expect(out[0].undirected).toBe(false); + }); - it("collapses reciprocal co-mention pairs into one undirected edge", () => { - const rows: RelationRow[] = [ - baseRow({ kind: "co-mention", via: "page1" }), - baseRow({ - from: "B", - to: "A", - kind: "co-mention", - via: "page1", - page: "page1", - }), - ]; - const out = collapseEdges(rows); - expect(out).toHaveLength(1); - expect(out[0].undirected).toBe(true); - expect(out[0].refs).toHaveLength(2); - }); + it("collapses reciprocal co-mention pairs into one undirected edge", () => { + const rows: RelationRow[] = [ + baseRow({ kind: "co-mention", via: "page1" }), + baseRow({ + from: "B", + to: "A", + kind: "co-mention", + via: "page1", + page: "page1", + }), + ]; + const out = collapseEdges(rows); + expect(out).toHaveLength(1); + expect(out[0].undirected).toBe(true); + expect(out[0].refs).toHaveLength(2); + }); - it("keeps distinct co-mention pairs separate when 'via' differs", () => { - const rows: RelationRow[] = [ - baseRow({ kind: "co-mention", via: "p1" }), - baseRow({ - from: "B", - to: "A", - kind: "co-mention", - via: "p2", - }), - ]; - expect(collapseEdges(rows)).toHaveLength(2); - }); + it("keeps distinct co-mention pairs separate when 'via' differs", () => { + const rows: RelationRow[] = [ + baseRow({ kind: "co-mention", via: "p1" }), + baseRow({ + from: "B", + to: "A", + kind: "co-mention", + via: "p2", + }), + ]; + expect(collapseEdges(rows)).toHaveLength(2); + }); - it("uses the relation kind as the edge label", () => { - const out = collapseEdges([baseRow({ kind: "principal" })]); - expect(out[0].label).toBe("principal"); - }); + it("uses the relation kind as the edge label", () => { + const out = collapseEdges([baseRow({ kind: "principal" })]); + expect(out[0].label).toBe("principal"); + }); - it("preserves provenance positions from the range tuple", () => { - const out = collapseEdges([baseRow({ range: [42, 60], snippet: "hello" })]); - expect(out[0].refs[0]).toEqual({ page: "A", pos: 42, snippet: "hello" }); - }); + it("preserves provenance positions from the range tuple", () => { + const out = collapseEdges([baseRow({ range: [42, 60], snippet: "hello" })]); + expect(out[0].refs[0]).toEqual({ page: "A", pos: 42, snippet: "hello" }); + }); }); diff --git a/plugs/object-graph/src/graph_builder.ts b/plugs/object-graph/src/graph_builder.ts index c4cb44ef..c14a8ec4 100644 --- a/plugs/object-graph/src/graph_builder.ts +++ b/plugs/object-graph/src/graph_builder.ts @@ -1,11 +1,11 @@ import { index, lua } from "@silverbulletmd/silverbullet/syscalls"; import type { - Edge, - EdgeProvenance, - ExpansionResult, - GraphUniverse, - ObjectKind, - ObjectNode, + Edge, + EdgeProvenance, + ExpansionResult, + GraphUniverse, + ObjectKind, + ObjectNode, } from "./model.ts"; /** @@ -15,41 +15,41 @@ import type { * `rootTag`. */ const CORE_TAGS = new Set([ - "page", - "item", - "task", - "header", - "paragraph", - "block", - "meta", - "aspiring-page", - "document", - "url", + "page", + "item", + "task", + "header", + "paragraph", + "block", + "meta", + "aspiring-page", + "document", + "url", ]); export function isCoreTag(t: string): boolean { - return CORE_TAGS.has(t) || t.startsWith("meta/"); + return CORE_TAGS.has(t) || t.startsWith("meta/"); } export type RelationRow = { - from: string; - to: string; - fromTag?: string; - toTag?: string; - kind: string; - via?: string; - page: string; - range?: [number, number]; - snippet?: string; + from: string; + to: string; + fromTag?: string; + toTag?: string; + kind: string; + via?: string; + page: string; + range?: [number, number]; + snippet?: string; }; type IndexedObject = { - ref?: string; - name?: string; - tag?: string; - tags?: string[]; - pageDecoration?: { prefix?: string }; - [k: string]: unknown; + ref?: string; + name?: string; + tag?: string; + tags?: string[]; + pageDecoration?: { prefix?: string }; + [k: string]: unknown; }; /** @@ -58,8 +58,8 @@ type IndexedObject = { * `Page` node. URL / file refs are untouched. */ export function stripPagePos(ref: string): string { - if (classifyKind(ref) !== "page") return ref; - return ref.split(/[@#$]/)[0] || ref; + if (classifyKind(ref) !== "page") return ref; + return ref.split(/[@#$]/)[0] || ref; } /** @@ -71,28 +71,28 @@ export function stripPagePos(ref: string): string { * freshly opened modal sees fresh data. */ type GraphCache = { - relations: RelationRow[]; - pagesByName: Map; + relations: RelationRow[]; + pagesByName: Map; }; let cache: GraphCache | null = null; export function invalidateGraphCache(): void { - cache = null; + cache = null; } async function ensureCache(): Promise { - if (cache) return cache; - const [relations, pages] = (await Promise.all([ - index.queryLuaObjects("relation", { objectVariable: "r" }), - index.queryLuaObjects("page", { objectVariable: "p" }), - ])) as [RelationRow[], IndexedObject[]]; - const pagesByName = new Map(); - for (const p of pages) { - const name = String(p.name ?? ""); - if (name) pagesByName.set(name, p); - } - cache = { relations, pagesByName }; - return cache; + if (cache) return cache; + const [relations, pages] = (await Promise.all([ + index.queryLuaObjects("relation", { objectVariable: "r" }), + index.queryLuaObjects("page", { objectVariable: "p" }), + ])) as [RelationRow[], IndexedObject[]]; + const pagesByName = new Map(); + for (const p of pages) { + const name = String(p.name ?? ""); + if (name) pagesByName.set(name, p); + } + cache = { relations, pagesByName }; + return cache; } /** @@ -100,17 +100,17 @@ async function ensureCache(): Promise { * header / anchor variants (`Page@123`, `Page#header`, `Page$anchor`). */ export function endpointMatchesRef(endpoint: string, ref: string): boolean { - if (endpoint === ref) return true; - if (!endpoint.startsWith(ref)) return false; - const c = endpoint.charAt(ref.length); - return c === "@" || c === "#" || c === "$"; + if (endpoint === ref) return true; + if (!endpoint.startsWith(ref)) return false; + const c = endpoint.charAt(ref.length); + return c === "@" || c === "#" || c === "$"; } async function queryRelations(ref: string): Promise { - const { relations } = await ensureCache(); - return relations.filter( - (r) => endpointMatchesRef(r.from, ref) || endpointMatchesRef(r.to, ref), - ); + const { relations } = await ensureCache(); + return relations.filter( + (r) => endpointMatchesRef(r.from, ref) || endpointMatchesRef(r.to, ref), + ); } /** @@ -119,48 +119,48 @@ async function queryRelations(ref: string): Promise { * remain distinct. */ export function collapseEdges(rows: RelationRow[]): Edge[] { - const out: Edge[] = []; - const seenCoMention = new Map(); // key -> out index + const out: Edge[] = []; + const seenCoMention = new Map(); // key -> out index - for (const r of rows) { - const label = r.kind; - const prov: EdgeProvenance = { - page: r.page, - pos: r.range?.[0], - snippet: r.snippet, - }; + for (const r of rows) { + const label = r.kind; + const prov: EdgeProvenance = { + page: r.page, + pos: r.range?.[0], + snippet: r.snippet, + }; - if (r.kind === "co-mention") { - const k = [r.from, r.to, r.via ?? ""].sort().join(" "); - const existing = seenCoMention.get(k); - if (existing !== undefined) { - out[existing].refs.push(prov); - out[existing].undirected = true; - continue; - } - seenCoMention.set(k, out.length); - out.push({ - source: r.from, - target: r.to, - label, - kind: r.kind, - refs: [prov], - undirected: false, // becomes true when the reverse pair lands - }); - continue; - } + if (r.kind === "co-mention") { + const k = [r.from, r.to, r.via ?? ""].sort().join(" "); + const existing = seenCoMention.get(k); + if (existing !== undefined) { + out[existing].refs.push(prov); + out[existing].undirected = true; + continue; + } + seenCoMention.set(k, out.length); + out.push({ + source: r.from, + target: r.to, + label, + kind: r.kind, + refs: [prov], + undirected: false, // becomes true when the reverse pair lands + }); + continue; + } - out.push({ - source: r.from, - target: r.to, - label, - kind: r.kind, - refs: [prov], - undirected: false, - }); - } + out.push({ + source: r.from, + target: r.to, + label, + kind: r.kind, + refs: [prov], + undirected: false, + }); + } - return out; + return out; } /** @@ -170,147 +170,147 @@ export function collapseEdges(rows: RelationRow[]): Edge[] { * pre-loading all of them would cost more than the occasional miss. */ async function findIndexed( - ref: string, + ref: string, ): Promise<{ tag: string; obj: IndexedObject } | null> { - const { pagesByName } = await ensureCache(); - const page = pagesByName.get(ref); - if (page) return { tag: "page", obj: page }; - const byRefExpr = await lua.parseExpression("o.ref == ref"); - const queryFor = (tag: string) => - index.queryLuaObjects( - tag, - { objectVariable: "o", where: byRefExpr }, - { ref }, - ) as Promise; - const [items, blocks] = await Promise.all([ - queryFor("item"), - queryFor("block"), - ]); - if (items.length) return { tag: "item", obj: items[0] }; - if (blocks.length) return { tag: "block", obj: blocks[0] }; - return null; + const { pagesByName } = await ensureCache(); + const page = pagesByName.get(ref); + if (page) return { tag: "page", obj: page }; + const byRefExpr = await lua.parseExpression("o.ref == ref"); + const queryFor = (tag: string) => + index.queryLuaObjects( + tag, + { objectVariable: "o", where: byRefExpr }, + { ref }, + ) as Promise; + const [items, blocks] = await Promise.all([ + queryFor("item"), + queryFor("block"), + ]); + if (items.length) return { tag: "item", obj: items[0] }; + if (blocks.length) return { tag: "block", obj: blocks[0] }; + return null; } export function classifyKind(ref: string): ObjectKind { - if (/^https?:\/\//.test(ref)) return "url"; - if (/^[^/]+\.[^/]+$/.test(ref) && !ref.endsWith(".md")) return "file"; - return "page"; + if (/^https?:\/\//.test(ref)) return "url"; + if (/^[^/]+\.[^/]+$/.test(ref) && !ref.endsWith(".md")) return "file"; + return "page"; } function deriveTitle( - ref: string, - kind: ObjectKind, - obj: IndexedObject | null, + ref: string, + kind: ObjectKind, + obj: IndexedObject | null, ): string { - if (obj?.name) return String(obj.name); - if (kind === "url" || kind === "file") return ref.split(/[\\/]/).pop() ?? ref; - return ref; + if (obj?.name) return String(obj.name); + if (kind === "url" || kind === "file") return ref.split(/[\\/]/).pop() ?? ref; + return ref; } export function deriveTagFields( - allTags: string[], - hostTag: string, + allTags: string[], + hostTag: string, ): { - rootTag: string | null; - primaryTag: string | null; - tags: string[]; + rootTag: string | null; + primaryTag: string | null; + tags: string[]; } { - // `hostTag` is the structural tag the indexer keyed on (page/item/block). - // SilverBullet's indexer also exposes additional user tags via `tags`. - const hasTask = allTags.includes("task"); - let rootTag: string | null; - if (hostTag === "item" && hasTask) rootTag = "task"; - else if (CORE_TAGS.has(hostTag)) rootTag = hostTag; - else rootTag = null; + // `hostTag` is the structural tag the indexer keyed on (page/item/block). + // SilverBullet's indexer also exposes additional user tags via `tags`. + const hasTask = allTags.includes("task"); + let rootTag: string | null; + if (hostTag === "item" && hasTask) rootTag = "task"; + else if (CORE_TAGS.has(hostTag)) rootTag = hostTag; + else rootTag = null; - const userTags = allTags.filter((t) => !isCoreTag(t)); - return { - rootTag, - primaryTag: userTags[0] ?? null, - tags: userTags, - }; + const userTags = allTags.filter((t) => !isCoreTag(t)); + return { + rootTag, + primaryTag: userTags[0] ?? null, + tags: userTags, + }; } // Project an already-fetched indexed object into the panel's ObjectNode shape. function projectIndexed( - ref: string, - structuralTag: string, - obj: IndexedObject, + ref: string, + structuralTag: string, + obj: IndexedObject, ): ObjectNode { - const tagsField = Array.isArray(obj.tags) ? obj.tags.map(String) : []; - const allTags = tagsField.includes(structuralTag) - ? tagsField - : [structuralTag, ...tagsField]; - const { rootTag, primaryTag, tags } = deriveTagFields(allTags, structuralTag); - const kind: ObjectKind = - structuralTag === "page" - ? "page" - : structuralTag === "item" || structuralTag === "task" - ? "item" - : "block"; - return { - ref, - kind, - title: deriveTitle(ref, kind, obj), - rootTag, - primaryTag, - tags, - dangling: false, - prefix: obj.pageDecoration?.prefix, - attributes: obj as Record, - }; + const tagsField = Array.isArray(obj.tags) ? obj.tags.map(String) : []; + const allTags = tagsField.includes(structuralTag) + ? tagsField + : [structuralTag, ...tagsField]; + const { rootTag, primaryTag, tags } = deriveTagFields(allTags, structuralTag); + const kind: ObjectKind = + structuralTag === "page" + ? "page" + : structuralTag === "item" || structuralTag === "task" + ? "item" + : "block"; + return { + ref, + kind, + title: deriveTitle(ref, kind, obj), + rootTag, + primaryTag, + tags, + dangling: false, + prefix: obj.pageDecoration?.prefix, + attributes: obj as Record, + }; } function stubNode( - ref: string, - kind: ObjectKind, - dangling: boolean, + ref: string, + kind: ObjectKind, + dangling: boolean, ): ObjectNode { - return { - ref, - kind, - title: deriveTitle(ref, kind, null), - rootTag: null, - primaryTag: null, - tags: [], - dangling, - attributes: { ref, ...(dangling ? { dangling: true } : {}) }, - }; + return { + ref, + kind, + title: deriveTitle(ref, kind, null), + rootTag: null, + primaryTag: null, + tags: [], + dangling, + attributes: { ref, ...(dangling ? { dangling: true } : {}) }, + }; } async function resolveObject(ref: string): Promise { - const kindHint = classifyKind(ref); - if (kindHint === "url" || kindHint === "file") { - return stubNode(ref, kindHint, false); - } - const found = await findIndexed(ref); - if (!found) return stubNode(ref, "page", true); - return projectIndexed(ref, found.tag, found.obj); + const kindHint = classifyKind(ref); + if (kindHint === "url" || kindHint === "file") { + return stubNode(ref, kindHint, false); + } + const found = await findIndexed(ref); + if (!found) return stubNode(ref, "page", true); + return projectIndexed(ref, found.tag, found.obj); } export async function expandObject(ref: string): Promise { - const normRef = stripPagePos(ref); - const rows = await queryRelations(normRef); - // Normalize endpoints so positional variants (`Page@123`) collapse to the - // bare page ref across the whole object graph. - const edges = collapseEdges(rows).map((e) => ({ - ...e, - source: stripPagePos(e.source), - target: stripPagePos(e.target), - })); + const normRef = stripPagePos(ref); + const rows = await queryRelations(normRef); + // Normalize endpoints so positional variants (`Page@123`) collapse to the + // bare page ref across the whole object graph. + const edges = collapseEdges(rows).map((e) => ({ + ...e, + source: stripPagePos(e.source), + target: stripPagePos(e.target), + })); - const neighborRefs = new Set(); - for (const e of edges) { - if (e.source !== normRef) neighborRefs.add(e.source); - if (e.target !== normRef) neighborRefs.add(e.target); - } + const neighborRefs = new Set(); + for (const e of edges) { + if (e.source !== normRef) neighborRefs.add(e.source); + if (e.target !== normRef) neighborRefs.add(e.target); + } - const [object, neighbors] = await Promise.all([ - resolveObject(normRef), - Promise.all([...neighborRefs].map(resolveObject)), - ]); + const [object, neighbors] = await Promise.all([ + resolveObject(normRef), + Promise.all([...neighborRefs].map(resolveObject)), + ]); - return { object, neighbors, edges }; + return { object, neighbors, edges }; } /** @@ -320,57 +320,57 @@ export async function expandObject(ref: string): Promise { * without a refetch. `rootRef` picks the anchor (current page if available). */ export async function buildGlobalGraph( - rootRef: string | null, + rootRef: string | null, ): Promise { - const { relations, pagesByName } = await ensureCache(); - const allPages = [...pagesByName.values()]; - // Equivalent of `index.contentPages()`: drop meta-tagged pages. - const isMeta = (p: IndexedObject) => { - const tags = Array.isArray(p.tags) ? (p.tags as string[]) : []; - return tags.some((t) => t === "meta" || t.startsWith("meta/")); - }; - const pages = allPages.filter((p) => !isMeta(p)); - const metaRefs = new Set( - allPages.filter(isMeta).map((p) => String(p.name ?? "")), - ); - if (pages.length === 0) return null; - const rows = relations; + const { relations, pagesByName } = await ensureCache(); + const allPages = [...pagesByName.values()]; + // Equivalent of `index.contentPages()`: drop meta-tagged pages. + const isMeta = (p: IndexedObject) => { + const tags = Array.isArray(p.tags) ? (p.tags as string[]) : []; + return tags.some((t) => t === "meta" || t.startsWith("meta/")); + }; + const pages = allPages.filter((p) => !isMeta(p)); + const metaRefs = new Set( + allPages.filter(isMeta).map((p) => String(p.name ?? "")), + ); + if (pages.length === 0) return null; + const rows = relations; - const nodeByRef = new Map(); - for (const p of pages) { - const ref = String(p.name ?? p.ref ?? ""); - if (!ref) continue; - nodeByRef.set(ref, projectIndexed(ref, "page", p)); - } + const nodeByRef = new Map(); + for (const p of pages) { + const ref = String(p.name ?? p.ref ?? ""); + if (!ref) continue; + nodeByRef.set(ref, projectIndexed(ref, "page", p)); + } - // Drop edges that touch meta pages, and constrain the global view to - // page-to-page edges only: URL / file / item / block endpoints are - // out of scope here. They'd otherwise drag in non-page nodes (random - // URLs and so on) that the Global Page Map promises not to show. - const edges = collapseEdges(rows) - .map((e) => ({ - ...e, - source: stripPagePos(e.source), - target: stripPagePos(e.target), - })) - .filter( - (e) => - !metaRefs.has(e.source) && - !metaRefs.has(e.target) && - nodeByRef.has(e.source) && - nodeByRef.has(e.target), - ); + // Drop edges that touch meta pages, and constrain the global view to + // page-to-page edges only: URL / file / item / block endpoints are + // out of scope here. They'd otherwise drag in non-page nodes (random + // URLs and so on) that the Global Page Map promises not to show. + const edges = collapseEdges(rows) + .map((e) => ({ + ...e, + source: stripPagePos(e.source), + target: stripPagePos(e.target), + })) + .filter( + (e) => + !metaRefs.has(e.source) && + !metaRefs.has(e.target) && + nodeByRef.has(e.source) && + nodeByRef.has(e.target), + ); - // Choose the root: prefer the caller-supplied ref when available; - // otherwise pick an arbitrary page (first by name). - const anchor = - rootRef && nodeByRef.has(rootRef) - ? rootRef - : [...nodeByRef.keys()].sort()[0]; - const object = nodeByRef.get(anchor)!; - const neighbors = [...nodeByRef.values()].filter((n) => n.ref !== anchor); + // Choose the root: prefer the caller-supplied ref when available; + // otherwise pick an arbitrary page (first by name). + const anchor = + rootRef && nodeByRef.has(rootRef) + ? rootRef + : [...nodeByRef.keys()].sort()[0]; + const object = nodeByRef.get(anchor)!; + const neighbors = [...nodeByRef.values()].filter((n) => n.ref !== anchor); - return { object, neighbors, edges }; + return { object, neighbors, edges }; } /** @@ -379,23 +379,23 @@ export async function buildGlobalGraph( * user might want to toggle, not just what's in the current explored set. */ export async function buildUniverse(): Promise { - const { relations, pagesByName } = await ensureCache(); + const { relations, pagesByName } = await ensureCache(); - // User tags: every non-core tag attached to any page in the space. - const tagSet = new Set(); - for (const p of pagesByName.values()) { - const tags = Array.isArray(p.tags) ? (p.tags as string[]) : []; - for (const t of tags) { - if (isCoreTag(t)) continue; - tagSet.add(t); - } - } - const tags = [...tagSet].sort(); + // User tags: every non-core tag attached to any page in the space. + const tagSet = new Set(); + for (const p of pagesByName.values()) { + const tags = Array.isArray(p.tags) ? (p.tags as string[]) : []; + for (const t of tags) { + if (isCoreTag(t)) continue; + tagSet.add(t); + } + } + const tags = [...tagSet].sort(); - // Relation labels: distinct `kind` across all relations. - const labelSet = new Set(); - for (const r of relations) labelSet.add(r.kind); - const labels = [...labelSet].sort(); + // Relation labels: distinct `kind` across all relations. + const labelSet = new Set(); + for (const r of relations) labelSet.add(r.kind); + const labels = [...labelSet].sort(); - return { tags, labels }; + return { tags, labels }; } diff --git a/plugs/object-graph/src/graph_html.ts b/plugs/object-graph/src/graph_html.ts index 10aebb9c..b0d9dcb2 100644 --- a/plugs/object-graph/src/graph_html.ts +++ b/plugs/object-graph/src/graph_html.ts @@ -5,23 +5,22 @@ import type { RootViewModel } from "./model.ts"; const PLUG_NAME = "object-graph"; export async function buildGraphHtml( - view: RootViewModel, + view: RootViewModel, ): Promise<{ html: string; script: string }> { - const [preamble, css, jsTemplate] = await Promise.all([ - // Shared component stylesheet (`.sb-*`) + the user's space styles, so the - // standard UI components render themed; our own CSS is appended after so - // it stays highest-precedence for graph-specific styling. - panelStyles(), - asset.readAsset(PLUG_NAME, "assets/object-graph.css"), - asset.readAsset(PLUG_NAME, "assets/object-graph.js"), - ]); + const [preamble, css, jsTemplate] = await Promise.all([ + // Shared component stylesheet (`.sb-*`) + the user's space styles, so the + // standard UI components render themed; our own CSS is appended after so + // it stays highest-precedence for graph-specific styling. + panelStyles(), + asset.readAsset(PLUG_NAME, "assets/object-graph.css"), + asset.readAsset(PLUG_NAME, "assets/object-graph.js"), + ]); - const html = - `${preamble}
    `; + const html = `${preamble}
    `; - // `var` (not `const`) so the declaration hoists into the eval'd scope and - // is lexically visible to the IIFE the bundled script wraps us in. - const script = `var __GRAPH = ${JSON.stringify(view)};\n${jsTemplate}`; + // `var` (not `const`) so the declaration hoists into the eval'd scope and + // is lexically visible to the IIFE the bundled script wraps us in. + const script = `var __GRAPH = ${JSON.stringify(view)};\n${jsTemplate}`; - return { html, script }; + return { html, script }; } diff --git a/plugs/object-graph/src/model.ts b/plugs/object-graph/src/model.ts index 7a390239..54b6b027 100644 --- a/plugs/object-graph/src/model.ts +++ b/plugs/object-graph/src/model.ts @@ -9,26 +9,26 @@ export type ObjectKind = "page" | "item" | "block" | "url" | "file"; * the node, drive filters, and populate the right-panel object view. */ export type ObjectNode = { - ref: string; - kind: ObjectKind; - title: string; - // Single core tag (page/item/task/block); drives the Root-tags filter. - rootTag: string | null; - // First non-core tag; drives node color. null when none. - primaryTag: string | null; - // All non-core tags; drives the Tags filter. - tags: string[]; - dangling: boolean; - // pageDecoration.prefix passthrough (e.g. emoji prefix). - prefix?: string; - // Full indexed payload, rendered verbatim into the object view. - attributes: Record; + ref: string; + kind: ObjectKind; + title: string; + // Single core tag (page/item/task/block); drives the Root-tags filter. + rootTag: string | null; + // First non-core tag; drives node color. null when none. + primaryTag: string | null; + // All non-core tags; drives the Tags filter. + tags: string[]; + dangling: boolean; + // pageDecoration.prefix passthrough (e.g. emoji prefix). + prefix?: string; + // Full indexed payload, rendered verbatim into the object view. + attributes: Record; }; export type EdgeProvenance = { - page: string; - pos?: number; - snippet?: string; + page: string; + pos?: number; + snippet?: string; }; /** @@ -37,50 +37,50 @@ export type EdgeProvenance = { * into one record with `undirected: true`. */ export type Edge = { - source: string; - target: string; - label: string; // == kind - kind: string; - refs: EdgeProvenance[]; - undirected: boolean; + source: string; + target: string; + label: string; // == kind + kind: string; + refs: EdgeProvenance[]; + undirected: boolean; }; export type ExpansionResult = { - object: ObjectNode; - neighbors: ObjectNode[]; - edges: Edge[]; + object: ObjectNode; + neighbors: ObjectNode[]; + edges: Edge[]; }; export type Filters = { - hiddenTags: string[]; - hiddenLabels: string[]; - // When true, edge labels on the canvas are suppressed entirely. - hideEdgeLabels: boolean; - // When true, nodes with no visible incoming or outgoing relation in - // the current filter set are hidden (except the root). - hideOrphans: boolean; + hiddenTags: string[]; + hiddenLabels: string[]; + // When true, edge labels on the canvas are suppressed entirely. + hideEdgeLabels: boolean; + // When true, nodes with no visible incoming or outgoing relation in + // the current filter set are hidden (except the root). + hideOrphans: boolean; }; // Tunable force-simulation knobs exposed as sliders in the sidebar. export type ForceSettings = { - centerStrength: number; - chargeStrength: number; - linkDistance: number; - linkStrength: number; + centerStrength: number; + chargeStrength: number; + linkDistance: number; + linkStrength: number; }; export const defaultForceSettings: ForceSettings = { - centerStrength: 0.18, - chargeStrength: -430, - linkDistance: 223, - linkStrength: 0.1, + centerStrength: 0.18, + chargeStrength: -430, + linkDistance: 223, + linkStrength: 0.1, }; export const defaultFilters: Filters = { - hiddenTags: [], - hiddenLabels: [], - hideEdgeLabels: false, - hideOrphans: true, + hiddenTags: [], + hiddenLabels: [], + hideEdgeLabels: false, + hideOrphans: true, }; /** @@ -90,25 +90,25 @@ export const defaultFilters: Filters = { * Counts in the sidebar still reflect the explored subgraph. */ export type GraphUniverse = { - tags: string[]; - labels: string[]; + tags: string[]; + labels: string[]; }; export type RootViewModel = { - root: ExpansionResult; - universe: GraphUniverse; - filters: Filters; - forces: ForceSettings; - /** - * When true, the panel marks all of `root.neighbors` as already-expanded - * (instead of the default ghost state). Used by the global view, which - * ships every page at once and lets the user trim down rather than walk out. - */ - initialAllExpanded?: boolean; - /** - * When false, the panel does not persist in-session filter changes back - * to the datastore. Used by transient views like the global view so they - * don't pollute the local-view filter preferences. - */ - persistFilters?: boolean; + root: ExpansionResult; + universe: GraphUniverse; + filters: Filters; + forces: ForceSettings; + /** + * When true, the panel marks all of `root.neighbors` as already-expanded + * (instead of the default ghost state). Used by the global view, which + * ships every page at once and lets the user trim down rather than walk out. + */ + initialAllExpanded?: boolean; + /** + * When false, the panel does not persist in-session filter changes back + * to the datastore. Used by transient views like the global view so they + * don't pollute the local-view filter preferences. + */ + persistFilters?: boolean; }; diff --git a/plugs/object-graph/src/object_graph.ts b/plugs/object-graph/src/object_graph.ts index 361c7ed0..211ec453 100644 --- a/plugs/object-graph/src/object_graph.ts +++ b/plugs/object-graph/src/object_graph.ts @@ -1,57 +1,57 @@ import { datastore, editor } from "@silverbulletmd/silverbullet/syscalls"; import { - buildGlobalGraph, - buildUniverse, - expandObject as expandObjectImpl, - invalidateGraphCache, + buildGlobalGraph, + buildUniverse, + expandObject as expandObjectImpl, + invalidateGraphCache, } from "./graph_builder.ts"; import { buildGraphHtml } from "./graph_html.ts"; import { - defaultFilters, - defaultForceSettings, - type ExpansionResult, - type Filters, - type ForceSettings, - type RootViewModel, + defaultFilters, + defaultForceSettings, + type ExpansionResult, + type Filters, + type ForceSettings, + type RootViewModel, } from "./model.ts"; const FILTERS_KEY = ["object-graph", "filters"]; const FORCES_KEY = ["object-graph", "forces"]; async function loadFilters(): Promise { - const raw = (await datastore.get(FILTERS_KEY)) as - | Partial - | undefined; - if (!raw) return defaultFilters; - return { ...defaultFilters, ...raw } as Filters; + const raw = (await datastore.get(FILTERS_KEY)) as + | Partial + | undefined; + if (!raw) return defaultFilters; + return { ...defaultFilters, ...raw } as Filters; } async function loadForces(): Promise { - const raw = (await datastore.get(FORCES_KEY)) as - | Partial - | undefined; - if (!raw) return defaultForceSettings; - return { ...defaultForceSettings, ...raw } as ForceSettings; + const raw = (await datastore.get(FORCES_KEY)) as + | Partial + | undefined; + if (!raw) return defaultForceSettings; + return { ...defaultForceSettings, ...raw } as ForceSettings; } export async function showGraph() { - const currentPage = await editor.getCurrentPage(); - if (!currentPage) { - await editor.flashNotification("Graph: no current page", "error"); - return; - } - // Each top-level open of the modal gets fresh data. Subsequent - // `expandObject` round-trips from the panel reuse the populated cache. - invalidateGraphCache(); - const [root, universe, filters, forces] = await Promise.all([ - expandObjectImpl(currentPage), - buildUniverse(), - loadFilters(), - loadForces(), - ]); - const view: RootViewModel = { root, universe, filters, forces }; - const { html, script } = await buildGraphHtml(view); - await editor.showPanel("modal", 100, html, script); + const currentPage = await editor.getCurrentPage(); + if (!currentPage) { + await editor.flashNotification("Graph: no current page", "error"); + return; + } + // Each top-level open of the modal gets fresh data. Subsequent + // `expandObject` round-trips from the panel reuse the populated cache. + invalidateGraphCache(); + const [root, universe, filters, forces] = await Promise.all([ + expandObjectImpl(currentPage), + buildUniverse(), + loadFilters(), + loadForces(), + ]); + const view: RootViewModel = { root, universe, filters, forces }; + const { html, script } = await buildGraphHtml(view); + await editor.showPanel("modal", 100, html, script); } /** @@ -59,7 +59,7 @@ export async function showGraph() { * Pure: takes a ref, returns an ExpansionResult, no side effects. */ export async function expandObject(ref: string): Promise { - return expandObjectImpl(ref); + return expandObjectImpl(ref); } /** @@ -68,32 +68,32 @@ export async function expandObject(ref: string): Promise { * persisted (so the local view's saved filters aren't disturbed). */ export async function showGlobalGraph() { - invalidateGraphCache(); - const [root, universe] = await Promise.all([ - buildGlobalGraph((await editor.getCurrentPage()) ?? null), - buildUniverse(), - ]); - if (!root) { - await editor.flashNotification("Graph: no pages in space", "error"); - return; - } - // Default the global view to "mention only" — every other relation - // label in the universe is hidden but can be toggled on without a - // refetch (the full relation set is already loaded). - const hiddenLabels = universe.labels.filter((l) => l !== "mention"); - const view: RootViewModel = { - root, - universe, - filters: { - hiddenTags: [], - hiddenLabels, - hideEdgeLabels: true, - hideOrphans: false, - }, - forces: await loadForces(), - initialAllExpanded: true, - persistFilters: false, - }; - const { html, script } = await buildGraphHtml(view); - await editor.showPanel("modal", 100, html, script); + invalidateGraphCache(); + const [root, universe] = await Promise.all([ + buildGlobalGraph((await editor.getCurrentPage()) ?? null), + buildUniverse(), + ]); + if (!root) { + await editor.flashNotification("Graph: no pages in space", "error"); + return; + } + // Default the global view to "mention only" — every other relation + // label in the universe is hidden but can be toggled on without a + // refetch (the full relation set is already loaded). + const hiddenLabels = universe.labels.filter((l) => l !== "mention"); + const view: RootViewModel = { + root, + universe, + filters: { + hiddenTags: [], + hiddenLabels, + hideEdgeLabels: true, + hideOrphans: false, + }, + forces: await loadForces(), + initialAllExpanded: true, + persistFilters: false, + }; + const { html, script } = await buildGraphHtml(view); + await editor.showPanel("modal", 100, html, script); } diff --git a/plugs/object-graph/ui/colors.test.ts b/plugs/object-graph/ui/colors.test.ts index c6f6ecb1..0e3c7d4d 100644 --- a/plugs/object-graph/ui/colors.test.ts +++ b/plugs/object-graph/ui/colors.test.ts @@ -2,22 +2,22 @@ import { describe, expect, it } from "vitest"; import { colorForTag, UNTAGGED_COLOR_VAR } from "./colors.ts"; describe("colorForTag", () => { - it("returns the untagged CSS variable for null", () => { - expect(colorForTag(null)).toBe(UNTAGGED_COLOR_VAR); - }); + it("returns the untagged CSS variable for null", () => { + expect(colorForTag(null)).toBe(UNTAGGED_COLOR_VAR); + }); - it("is deterministic — same tag yields the same color", () => { - expect(colorForTag("driver")).toBe(colorForTag("driver")); - expect(colorForTag("research")).toBe(colorForTag("research")); - }); + it("is deterministic — same tag yields the same color", () => { + expect(colorForTag("driver")).toBe(colorForTag("driver")); + expect(colorForTag("research")).toBe(colorForTag("research")); + }); - it("produces different colors for distinct tags (typically)", () => { - // Hash collisions are possible in principle; the F1 demo tags do not collide. - expect(colorForTag("driver")).not.toBe(colorForTag("team")); - expect(colorForTag("driver")).not.toBe(colorForTag("person")); - }); + it("produces different colors for distinct tags (typically)", () => { + // Hash collisions are possible in principle; the F1 demo tags do not collide. + expect(colorForTag("driver")).not.toBe(colorForTag("team")); + expect(colorForTag("driver")).not.toBe(colorForTag("person")); + }); - it("returns hsl() strings", () => { - expect(colorForTag("anything")).toMatch(/^hsl\(\d+ \d+% \d+%\)$/); - }); + it("returns hsl() strings", () => { + expect(colorForTag("anything")).toMatch(/^hsl\(\d+ \d+% \d+%\)$/); + }); }); diff --git a/plugs/object-graph/ui/colors.ts b/plugs/object-graph/ui/colors.ts index 53d7470a..9da16baa 100644 --- a/plugs/object-graph/ui/colors.ts +++ b/plugs/object-graph/ui/colors.ts @@ -6,10 +6,10 @@ export const UNTAGGED_COLOR_VAR = "var(--gv-untagged, #9e9e9e)"; // Deterministic color per user tag: stable hash → HSL hue with fixed // saturation and lightness. `null` (no tag) returns the untagged fallback. export function colorForTag(tag: string | null): string { - if (!tag) return UNTAGGED_COLOR_VAR; - let h = 0; - for (let i = 0; i < tag.length; i++) { - h = (h * 31 + tag.charCodeAt(i)) >>> 0; - } - return `hsl(${h % 360} 60% 55%)`; + if (!tag) return UNTAGGED_COLOR_VAR; + let h = 0; + for (let i = 0; i < tag.length; i++) { + h = (h * 31 + tag.charCodeAt(i)) >>> 0; + } + return `hsl(${h % 360} 60% 55%)`; } diff --git a/plugs/object-graph/ui/components/app.tsx b/plugs/object-graph/ui/components/app.tsx index 31f665db..5863ea46 100644 --- a/plugs/object-graph/ui/components/app.tsx +++ b/plugs/object-graph/ui/components/app.tsx @@ -1,18 +1,18 @@ import { datastore, system } from "@silverbulletmd/silverbullet/syscalls"; import { - useCallback, - useEffect, - useMemo, - useRef, - useState, + useCallback, + useEffect, + useMemo, + useRef, + useState, } from "preact/hooks"; import type { - Edge, - ExpansionResult, - Filters, - ForceSettings, - ObjectNode, - RootViewModel, + Edge, + ExpansionResult, + Filters, + ForceSettings, + ObjectNode, + RootViewModel, } from "../../src/model.ts"; import { GraphCanvas } from "./graph_canvas.tsx"; import { Header } from "./header.tsx"; @@ -23,346 +23,346 @@ const FILTERS_KEY = ["plug", "object-graph", "filters"]; const FORCES_KEY = ["plug", "object-graph", "forces"]; function edgeKey(e: Edge): string { - return `${e.source} ${e.target} ${e.label} ${e.kind}`; + return `${e.source} ${e.target} ${e.label} ${e.kind}`; } export function App({ vm }: { vm: RootViewModel }) { - const [nodes, setNodes] = useState>(() => { - const m = new Map(); - const initialStatus: NodeState["status"] = vm.initialAllExpanded - ? "expanded" - : "ghost"; - m.set(vm.root.object.ref, { node: vm.root.object, status: "expanded" }); - for (const n of vm.root.neighbors) { - if (!m.has(n.ref)) m.set(n.ref, { node: n, status: initialStatus }); - } - return m; - }); - const [edges, setEdges] = useState(() => [...vm.root.edges]); - // Derived dedupe index kept in lock-step with `edges`. Lives in a ref - // (not state) because we mutate it in place and never want it to be a - // trigger for re-renders. - const edgeKeysRef = useRef>(new Set(vm.root.edges.map(edgeKey))); - const [selectedRef, setSelectedRef] = useState( - vm.root.object.ref, - ); - const [filters, setFilters] = useState(vm.filters); - const [sidebarWidth, setSidebarWidth] = useState(230); - const [forces, setForces] = useState(vm.forces); - const [cache] = useState>(() => { - const m = new Map(); - m.set(vm.root.object.ref, vm.root); - return m; - }); + const [nodes, setNodes] = useState>(() => { + const m = new Map(); + const initialStatus: NodeState["status"] = vm.initialAllExpanded + ? "expanded" + : "ghost"; + m.set(vm.root.object.ref, { node: vm.root.object, status: "expanded" }); + for (const n of vm.root.neighbors) { + if (!m.has(n.ref)) m.set(n.ref, { node: n, status: initialStatus }); + } + return m; + }); + const [edges, setEdges] = useState(() => [...vm.root.edges]); + // Derived dedupe index kept in lock-step with `edges`. Lives in a ref + // (not state) because we mutate it in place and never want it to be a + // trigger for re-renders. + const edgeKeysRef = useRef>(new Set(vm.root.edges.map(edgeKey))); + const [selectedRef, setSelectedRef] = useState( + vm.root.object.ref, + ); + const [filters, setFilters] = useState(vm.filters); + const [sidebarWidth, setSidebarWidth] = useState(230); + const [forces, setForces] = useState(vm.forces); + const [cache] = useState>(() => { + const m = new Map(); + m.set(vm.root.object.ref, vm.root); + return m; + }); - // Persist filters whenever they change. Disabled for transient modes - // (e.g. the global view) so they don't trample the local view's saved - // preferences. Skip the initial mount — those values came FROM the - // datastore (or the view's preset) and writing them back is pointless. - const persistFilters = vm.persistFilters !== false; - const didMountRef = useRef(false); - useEffect(() => { - if (!persistFilters) return; - if (!didMountRef.current) { - didMountRef.current = true; - return; - } - void datastore.set(FILTERS_KEY, filters); - }, [filters, persistFilters]); + // Persist filters whenever they change. Disabled for transient modes + // (e.g. the global view) so they don't trample the local view's saved + // preferences. Skip the initial mount — those values came FROM the + // datastore (or the view's preset) and writing them back is pointless. + const persistFilters = vm.persistFilters !== false; + const didMountRef = useRef(false); + useEffect(() => { + if (!persistFilters) return; + if (!didMountRef.current) { + didMountRef.current = true; + return; + } + void datastore.set(FILTERS_KEY, filters); + }, [filters, persistFilters]); - // Forces persist regardless of view (global and explore share them). - const didMountForcesRef = useRef(false); - useEffect(() => { - if (!didMountForcesRef.current) { - didMountForcesRef.current = true; - return; - } - void datastore.set(FORCES_KEY, forces); - }, [forces]); + // Forces persist regardless of view (global and explore share them). + const didMountForcesRef = useRef(false); + useEffect(() => { + if (!didMountForcesRef.current) { + didMountForcesRef.current = true; + return; + } + void datastore.set(FORCES_KEY, forces); + }, [forces]); - const fetchExpansion = useCallback( - async (ref: string): Promise => { - const cached = cache.get(ref); - if (cached) return cached; - const result = (await system.invokeFunction( - "object-graph.expandObject", - ref, - )) as ExpansionResult; - cache.set(ref, result); - return result; - }, - [cache], - ); + const fetchExpansion = useCallback( + async (ref: string): Promise => { + const cached = cache.get(ref); + if (cached) return cached; + const result = (await system.invokeFunction( + "object-graph.expandObject", + ref, + )) as ExpansionResult; + cache.set(ref, result); + return result; + }, + [cache], + ); - const applyExpansions = useCallback((results: ExpansionResult[]) => { - if (results.length === 0) return; - setNodes((prev) => { - const next = new Map(prev); - for (const r of results) { - next.set(r.object.ref, { node: r.object, status: "expanded" }); - for (const n of r.neighbors) { - if (!next.has(n.ref)) next.set(n.ref, { node: n, status: "ghost" }); - } - } - return next; - }); - setEdges((prev) => { - const additions: Edge[] = []; - const keys = edgeKeysRef.current; - for (const r of results) { - for (const e of r.edges) { - const k = edgeKey(e); - if (!keys.has(k)) { - keys.add(k); - additions.push(e); - } - } - } - return additions.length ? [...prev, ...additions] : prev; - }); - }, []); + const applyExpansions = useCallback((results: ExpansionResult[]) => { + if (results.length === 0) return; + setNodes((prev) => { + const next = new Map(prev); + for (const r of results) { + next.set(r.object.ref, { node: r.object, status: "expanded" }); + for (const n of r.neighbors) { + if (!next.has(n.ref)) next.set(n.ref, { node: n, status: "ghost" }); + } + } + return next; + }); + setEdges((prev) => { + const additions: Edge[] = []; + const keys = edgeKeysRef.current; + for (const r of results) { + for (const e of r.edges) { + const k = edgeKey(e); + if (!keys.has(k)) { + keys.add(k); + additions.push(e); + } + } + } + return additions.length ? [...prev, ...additions] : prev; + }); + }, []); - const expandRef = useCallback( - async (ref: string) => { - applyExpansions([await fetchExpansion(ref)]); - }, - [applyExpansions, fetchExpansion], - ); + const expandRef = useCallback( + async (ref: string) => { + applyExpansions([await fetchExpansion(ref)]); + }, + [applyExpansions, fetchExpansion], + ); - const onNodeClick = useCallback( - (ref: string) => { - const state = nodes.get(ref); - setSelectedRef(ref); - if (state && state.status === "ghost") { - void expandRef(ref); - } - }, - [nodes, expandRef], - ); + const onNodeClick = useCallback( + (ref: string) => { + const state = nodes.get(ref); + setSelectedRef(ref); + if (state && state.status === "ghost") { + void expandRef(ref); + } + }, + [nodes, expandRef], + ); - const removeNode = useCallback((ref: string) => { - setNodes((prev) => { - if (!prev.has(ref)) return prev; - const next = new Map(prev); - next.delete(ref); - return next; - }); - setEdges((prev) => { - const remaining: Edge[] = []; - const keys = edgeKeysRef.current; - for (const e of prev) { - if (e.source === ref || e.target === ref) { - keys.delete(edgeKey(e)); - continue; - } - remaining.push(e); - } - return remaining.length === prev.length ? prev : remaining; - }); - setSelectedRef((sel) => (sel === ref ? null : sel)); - }, []); + const removeNode = useCallback((ref: string) => { + setNodes((prev) => { + if (!prev.has(ref)) return prev; + const next = new Map(prev); + next.delete(ref); + return next; + }); + setEdges((prev) => { + const remaining: Edge[] = []; + const keys = edgeKeysRef.current; + for (const e of prev) { + if (e.source === ref || e.target === ref) { + keys.delete(edgeKey(e)); + continue; + } + remaining.push(e); + } + return remaining.length === prev.length ? prev : remaining; + }); + setSelectedRef((sel) => (sel === ref ? null : sel)); + }, []); - const selected: ObjectNode | null = selectedRef - ? (nodes.get(selectedRef)?.node ?? null) - : null; + const selected: ObjectNode | null = selectedRef + ? (nodes.get(selectedRef)?.node ?? null) + : null; - // Stable ref to the current visibleNodes so callbacks always see the latest - // set without forcing the callback identity to churn on every filter tweak. - const visibleNodesRef = useRef([]); + // Stable ref to the current visibleNodes so callbacks always see the latest + // set without forcing the callback identity to churn on every filter tweak. + const visibleNodesRef = useRef([]); - // Transitive expansion: follow every relation outward, round by round, - // until no new ghosts remain along allowed labels. Hidden-label edges - // are recorded (so toggling them on later reveals them) but they - // don't seed further exploration. Safety-capped at 5000 nodes. - const expandAllVisibleGhosts = useCallback(async () => { - const SAFETY_CAP = 5000; - const hiddenLabels = new Set(filters.hiddenLabels); - const expanded = new Set(); - const pending = new Set(); - for (const ns of visibleNodesRef.current) { - if (ns.status === "expanded") expanded.add(ns.node.ref); - else pending.add(ns.node.ref); - } - while (pending.size > 0) { - if (expanded.size + pending.size > SAFETY_CAP) break; - const refs = [...pending]; - pending.clear(); - const results = await Promise.all(refs.map(fetchExpansion)); - applyExpansions(results); - for (const r of results) { - expanded.add(r.object.ref); - // Only follow edges whose label is currently visible. - for (const e of r.edges) { - if (hiddenLabels.has(e.label)) continue; - for (const ref of [e.source, e.target]) { - if (!expanded.has(ref) && !pending.has(ref)) pending.add(ref); - } - } - } - } - }, [fetchExpansion, applyExpansions, filters.hiddenLabels]); + // Transitive expansion: follow every relation outward, round by round, + // until no new ghosts remain along allowed labels. Hidden-label edges + // are recorded (so toggling them on later reveals them) but they + // don't seed further exploration. Safety-capped at 5000 nodes. + const expandAllVisibleGhosts = useCallback(async () => { + const SAFETY_CAP = 5000; + const hiddenLabels = new Set(filters.hiddenLabels); + const expanded = new Set(); + const pending = new Set(); + for (const ns of visibleNodesRef.current) { + if (ns.status === "expanded") expanded.add(ns.node.ref); + else pending.add(ns.node.ref); + } + while (pending.size > 0) { + if (expanded.size + pending.size > SAFETY_CAP) break; + const refs = [...pending]; + pending.clear(); + const results = await Promise.all(refs.map(fetchExpansion)); + applyExpansions(results); + for (const r of results) { + expanded.add(r.object.ref); + // Only follow edges whose label is currently visible. + for (const e of r.edges) { + if (hiddenLabels.has(e.label)) continue; + for (const ref of [e.source, e.target]) { + if (!expanded.has(ref) && !pending.has(ref)) pending.add(ref); + } + } + } + } + }, [fetchExpansion, applyExpansions, filters.hiddenLabels]); - /** - * Reset the explored set to "currently-selected object + its 1-hop - * ghosts". The selection stays put; only the broader exploration is - * collapsed back to that node's immediate neighborhood. - * - * Always issues a fresh worker call (skipping the cache) because the - * cache may hold a wide expansion for this ref — notably, in the - * Global Page Map view the cache entry for the anchor IS the global - * graph, and returning that here would defeat the whole point of Focus. - */ - const collapseAll = useCallback(async () => { - const anchor = selectedRef ?? vm.root.object.ref; - const result = (await system.invokeFunction( - "object-graph.expandObject", - anchor, - )) as ExpansionResult; - cache.set(anchor, result); - const m = new Map(); - m.set(result.object.ref, { node: result.object, status: "expanded" }); - for (const n of result.neighbors) { - if (!m.has(n.ref)) m.set(n.ref, { node: n, status: "ghost" }); - } - setNodes(m); - setEdges([...result.edges]); - const keys = edgeKeysRef.current; - keys.clear(); - for (const e of result.edges) keys.add(edgeKey(e)); - setSelectedRef(result.object.ref); - }, [selectedRef, cache, vm.root.object.ref]); + /** + * Reset the explored set to "currently-selected object + its 1-hop + * ghosts". The selection stays put; only the broader exploration is + * collapsed back to that node's immediate neighborhood. + * + * Always issues a fresh worker call (skipping the cache) because the + * cache may hold a wide expansion for this ref — notably, in the + * Global Page Map view the cache entry for the anchor IS the global + * graph, and returning that here would defeat the whole point of Focus. + */ + const collapseAll = useCallback(async () => { + const anchor = selectedRef ?? vm.root.object.ref; + const result = (await system.invokeFunction( + "object-graph.expandObject", + anchor, + )) as ExpansionResult; + cache.set(anchor, result); + const m = new Map(); + m.set(result.object.ref, { node: result.object, status: "expanded" }); + for (const n of result.neighbors) { + if (!m.has(n.ref)) m.set(n.ref, { node: n, status: "ghost" }); + } + setNodes(m); + setEdges([...result.edges]); + const keys = edgeKeysRef.current; + keys.clear(); + for (const e of result.edges) keys.add(edgeKey(e)); + setSelectedRef(result.object.ref); + }, [selectedRef, cache, vm.root.object.ref]); - // Keyboard: Backspace / Delete removes selected, unless focused in an input. - useEffect(() => { - const handler = (ev: KeyboardEvent) => { - if (ev.key !== "Backspace" && ev.key !== "Delete") return; - const tag = (ev.target as HTMLElement | null)?.tagName?.toLowerCase(); - if (tag === "input" || tag === "textarea" || tag === "select") return; - if (!selectedRef) return; - ev.preventDefault(); - removeNode(selectedRef); - }; - globalThis.addEventListener("keydown", handler); - return () => globalThis.removeEventListener("keydown", handler); - }, [selectedRef, removeNode]); + // Keyboard: Backspace / Delete removes selected, unless focused in an input. + useEffect(() => { + const handler = (ev: KeyboardEvent) => { + if (ev.key !== "Backspace" && ev.key !== "Delete") return; + const tag = (ev.target as HTMLElement | null)?.tagName?.toLowerCase(); + if (tag === "input" || tag === "textarea" || tag === "select") return; + if (!selectedRef) return; + ev.preventDefault(); + removeNode(selectedRef); + }; + globalThis.addEventListener("keydown", handler); + return () => globalThis.removeEventListener("keydown", handler); + }, [selectedRef, removeNode]); - // Visible subgraph (view-layer only). Filter rules: - // • The root is always visible — it's the user's anchor into the graph. - // • Every other node (expanded or ghost) must pass the tag filter. - // • Edges must pass the label filter and connect two visible nodes. - // • A ghost that has no remaining edges to a visible neighbor is - // hidden — otherwise unchecking an edge label leaves orphan ghosts - // floating with no visible reason to be there. - const rootRef = vm.root.object.ref; - const { visibleNodes, visibleEdges } = useMemo( - () => computeVisible(nodes.values(), edges, filters, rootRef), - [nodes, edges, filters, rootRef], - ); + // Visible subgraph (view-layer only). Filter rules: + // • The root is always visible — it's the user's anchor into the graph. + // • Every other node (expanded or ghost) must pass the tag filter. + // • Edges must pass the label filter and connect two visible nodes. + // • A ghost that has no remaining edges to a visible neighbor is + // hidden — otherwise unchecking an edge label leaves orphan ghosts + // floating with no visible reason to be there. + const rootRef = vm.root.object.ref; + const { visibleNodes, visibleEdges } = useMemo( + () => computeVisible(nodes.values(), edges, filters, rootRef), + [nodes, edges, filters, rootRef], + ); - // Keep the ref in sync so expandAllVisibleGhosts sees the latest set. - visibleNodesRef.current = visibleNodes; + // Keep the ref in sync so expandAllVisibleGhosts sees the latest set. + visibleNodesRef.current = visibleNodes; - const ghostCount = visibleNodes.reduce( - (a, ns) => a + (ns.status === "ghost" ? 1 : 0), - 0, - ); + const ghostCount = visibleNodes.reduce( + (a, ns) => a + (ns.status === "ghost" ? 1 : 0), + 0, + ); - // Stable arrays for the sidebar's tally inputs (the underlying state - // changes only when nodes/edges actually change; without these, every - // App render would produce fresh array identities and re-tally everything). - const allObjectNodes = useMemo( - () => [...nodes.values()].map((ns) => ns.node), - [nodes], - ); - const visibleObjectNodes = useMemo( - () => visibleNodes.map((ns) => ns.node), - [visibleNodes], - ); + // Stable arrays for the sidebar's tally inputs (the underlying state + // changes only when nodes/edges actually change; without these, every + // App render would produce fresh array identities and re-tally everything). + const allObjectNodes = useMemo( + () => [...nodes.values()].map((ns) => ns.node), + [nodes], + ); + const visibleObjectNodes = useMemo( + () => visibleNodes.map((ns) => ns.node), + [visibleNodes], + ); - return ( -
    -
    - setFilters({ ...filters, hideEdgeLabels: v }) - } - hideOrphans={filters.hideOrphans} - onToggleHideOrphans={(v) => setFilters({ ...filters, hideOrphans: v })} - /> -
    - - - -
    -
    - ); + return ( +
    +
    + setFilters({ ...filters, hideEdgeLabels: v }) + } + hideOrphans={filters.hideOrphans} + onToggleHideOrphans={(v) => setFilters({ ...filters, hideOrphans: v })} + /> +
    + + + +
    +
    + ); } const MIN_SIDEBAR_WIDTH = 160; const MAX_SIDEBAR_WIDTH = 600; function SidebarResizer({ - width, - onResize, + width, + onResize, }: { - width: number; - onResize: (w: number) => void; + width: number; + onResize: (w: number) => void; }) { - const [dragging, setDragging] = useState(false); - const startRef = useRef<{ x: number; w: number } | null>(null); + const [dragging, setDragging] = useState(false); + const startRef = useRef<{ x: number; w: number } | null>(null); - useEffect(() => { - if (!dragging) return; - const onMove = (e: MouseEvent) => { - const start = startRef.current; - if (!start) return; - const next = Math.min( - MAX_SIDEBAR_WIDTH, - Math.max(MIN_SIDEBAR_WIDTH, start.w + (e.clientX - start.x)), - ); - onResize(next); - }; - const onUp = () => setDragging(false); - globalThis.addEventListener("mousemove", onMove); - globalThis.addEventListener("mouseup", onUp); - return () => { - globalThis.removeEventListener("mousemove", onMove); - globalThis.removeEventListener("mouseup", onUp); - }; - }, [dragging, onResize]); + useEffect(() => { + if (!dragging) return; + const onMove = (e: MouseEvent) => { + const start = startRef.current; + if (!start) return; + const next = Math.min( + MAX_SIDEBAR_WIDTH, + Math.max(MIN_SIDEBAR_WIDTH, start.w + (e.clientX - start.x)), + ); + onResize(next); + }; + const onUp = () => setDragging(false); + globalThis.addEventListener("mousemove", onMove); + globalThis.addEventListener("mouseup", onUp); + return () => { + globalThis.removeEventListener("mousemove", onMove); + globalThis.removeEventListener("mouseup", onUp); + }; + }, [dragging, onResize]); - return ( -
    { - startRef.current = { x: e.clientX, w: width }; - setDragging(true); - e.preventDefault(); - }} - /> - ); + return ( +
    { + startRef.current = { x: e.clientX, w: width }; + setDragging(true); + e.preventDefault(); + }} + /> + ); } diff --git a/plugs/object-graph/ui/components/graph_canvas.tsx b/plugs/object-graph/ui/components/graph_canvas.tsx index bb2d045d..54b9d749 100644 --- a/plugs/object-graph/ui/components/graph_canvas.tsx +++ b/plugs/object-graph/ui/components/graph_canvas.tsx @@ -23,30 +23,30 @@ const REFIT_COOLDOWN_TICKS = 60; type NodeStatus = "expanded" | "ghost"; type Props = { - nodes: { node: ObjectNode; status: NodeStatus }[]; - edges: Edge[]; - selectedRef: string | null; - onNodeClick: (ref: string) => void; - hideEdgeLabels: boolean; - forces: ForceSettings; + nodes: { node: ObjectNode; status: NodeStatus }[]; + edges: Edge[]; + selectedRef: string | null; + onNodeClick: (ref: string) => void; + hideEdgeLabels: boolean; + forces: ForceSettings; }; type FGNode = { - id: string; - node: ObjectNode; - status: NodeStatus; - kind: ObjectNode["kind"]; - dangling: boolean; - title: string; - prefix?: string; - degree: number; - color: string; - x?: number; - y?: number; - // Pin coordinates set on drag. Kept as `number` (never null) so FGNode - // satisfies both force-graph's `NodeObject` and d3's `SimulationNodeDatum`. - fx?: number; - fy?: number; + id: string; + node: ObjectNode; + status: NodeStatus; + kind: ObjectNode["kind"]; + dangling: boolean; + title: string; + prefix?: string; + degree: number; + color: string; + x?: number; + y?: number; + // Pin coordinates set on drag. Kept as `number` (never null) so FGNode + // satisfies both force-graph's `NodeObject` and d3's `SimulationNodeDatum`. + fx?: number; + fy?: number; }; // Panel-side merged edge — collapses parallel edges between the same pair @@ -55,9 +55,9 @@ type FGNode = { type MergedEdge = Edge & { bidirectional: boolean }; type FGLink = { - source: string | FGNode; - target: string | FGNode; - edge: MergedEdge; + source: string | FGNode; + target: string | FGNode; + edge: MergedEdge; }; // force-graph 1.51 ships class-style typings (`new ForceGraph(el)`), but the @@ -67,63 +67,63 @@ type FGLink = { // below type-check) while treating the import as the runtime factory. type ForceGraphInstance = ForceGraphImpl; const ForceGraph = ForceGraphImpl as unknown as () => ( - element: HTMLElement, + element: HTMLElement, ) => ForceGraphInstance; type Theme = { - bg: string; - nodeDim: string; - label: string; - labelDim: string; - labelHalo: string; - link: string; - linkDim: string; - linkHot: string; - accent: string; + bg: string; + nodeDim: string; + label: string; + labelDim: string; + labelHalo: string; + link: string; + linkDim: string; + linkHot: string; + accent: string; }; type State = { - edgeHover: { edge: MergedEdge; x: number; y: number } | null; - ghostHover: { title: string; x: number; y: number } | null; + edgeHover: { edge: MergedEdge; x: number; y: number } | null; + ghostHover: { title: string; x: number; y: number } | null; }; type ComputedGraph = { - nodes: FGNode[]; - links: FGLink[]; - adjacency: Map>; - radii: Map; + nodes: FGNode[]; + links: FGLink[]; + adjacency: Map>; + radii: Map; }; function readTheme(): Theme { - const cs = getComputedStyle(document.documentElement); - const v = (n: string, fallback: string) => - cs.getPropertyValue(n).trim() || fallback; - return { - bg: v("--gv-bg", "#ffffff"), - nodeDim: v("--gv-node-dim", "#9e4705"), - label: v("--gv-label", "#333"), - labelDim: v("--gv-label-dim", "#676767"), - labelHalo: v("--gv-label-halo", "rgba(255,255,255,0.78)"), - link: v("--gv-link", "#c8c8c8"), - linkDim: v("--gv-link-dim", "#e6e6e6"), - linkHot: v("--gv-link-hot", "#464cfc"), - accent: v("--gv-accent", "#650007"), - }; + const cs = getComputedStyle(document.documentElement); + const v = (n: string, fallback: string) => + cs.getPropertyValue(n).trim() || fallback; + return { + bg: v("--gv-bg", "#ffffff"), + nodeDim: v("--gv-node-dim", "#9e4705"), + label: v("--gv-label", "#333"), + labelDim: v("--gv-label-dim", "#676767"), + labelHalo: v("--gv-label-halo", "rgba(255,255,255,0.78)"), + link: v("--gv-link", "#c8c8c8"), + linkDim: v("--gv-link-dim", "#e6e6e6"), + linkHot: v("--gv-link-hot", "#464cfc"), + accent: v("--gv-accent", "#650007"), + }; } function getId(end: string | FGNode): string { - return typeof end === "string" ? end : end.id; + return typeof end === "string" ? end : end.id; } function nodeRadius(degree: number): number { - // Modest growth with degree. Hubs go a bit larger but stay readable. - return 6 + Math.min(degree, 10) * 0.5; + // Modest growth with degree. Hubs go a bit larger but stay readable. + return 6 + Math.min(degree, 10) * 0.5; } // Strip directory prefix; show just the last path segment for graph labels. function displayName(title: string): string { - const i = title.lastIndexOf("/"); - return i === -1 ? title : title.slice(i + 1); + const i = title.lastIndexOf("/"); + return i === -1 ? title : title.slice(i + 1); } // Label visibility thresholds expressed in zoom (graph→screen) scale. @@ -131,875 +131,875 @@ const NODE_LABEL_MIN_SCALE = 0.6; const EDGE_LABEL_MIN_SCALE = 1.8; function computeGraph( - inputNodes: Props["nodes"], - edges: Edge[], + inputNodes: Props["nodes"], + edges: Edge[], ): ComputedGraph { - const degree = new Map(); - const adj = new Map>(); - for (const ns of inputNodes) { - degree.set(ns.node.ref, 0); - adj.set(ns.node.ref, new Set()); - } - for (const e of edges) { - degree.set(e.source, (degree.get(e.source) ?? 0) + 1); - degree.set(e.target, (degree.get(e.target) ?? 0) + 1); - adj.get(e.source)?.add(e.target); - adj.get(e.target)?.add(e.source); - } - const fgNodes: FGNode[] = inputNodes.map(({ node, status }) => ({ - id: node.ref, - node, - status, - kind: node.kind, - dangling: node.dangling, - title: node.title, - prefix: node.prefix, - degree: degree.get(node.ref) ?? 0, - color: colorForTag(node.primaryTag), - })); - // Merge all edges between the same pair of nodes (unordered) into a - // single visual edge with a comma-joined label, combined provenance, - // and a `bidirectional` flag when both directions were present. - type Accum = MergedEdge & { labels: Set }; - const merged = new Map(); - for (const e of edges) { - const [a, b] = - e.source < e.target ? [e.source, e.target] : [e.target, e.source]; - const key = `${a}\x00${b}`; - const existing = merged.get(key); - if (!existing) { - merged.set(key, { - ...e, - refs: [...e.refs], - labels: new Set([e.label]), - bidirectional: false, - }); - continue; - } - existing.labels.add(e.label); - existing.refs.push(...e.refs); - if (e.undirected) existing.undirected = true; - if (e.source !== existing.source) existing.bidirectional = true; - // Non-mention kinds beat `mention` for styling purposes. - if (existing.kind === "mention" && e.kind !== "mention") { - existing.kind = e.kind; - } - } - const fgLinks: FGLink[] = [...merged.values()].map((acc) => { - const { labels, ...edge } = acc; - edge.label = [...labels].join(", "); - return { source: edge.source, target: edge.target, edge }; - }); - const rmap = new Map(); - for (const n of fgNodes) rmap.set(n.id, nodeRadius(n.degree)); - return { - nodes: fgNodes, - links: fgLinks, - adjacency: adj, - radii: rmap, - }; + const degree = new Map(); + const adj = new Map>(); + for (const ns of inputNodes) { + degree.set(ns.node.ref, 0); + adj.set(ns.node.ref, new Set()); + } + for (const e of edges) { + degree.set(e.source, (degree.get(e.source) ?? 0) + 1); + degree.set(e.target, (degree.get(e.target) ?? 0) + 1); + adj.get(e.source)?.add(e.target); + adj.get(e.target)?.add(e.source); + } + const fgNodes: FGNode[] = inputNodes.map(({ node, status }) => ({ + id: node.ref, + node, + status, + kind: node.kind, + dangling: node.dangling, + title: node.title, + prefix: node.prefix, + degree: degree.get(node.ref) ?? 0, + color: colorForTag(node.primaryTag), + })); + // Merge all edges between the same pair of nodes (unordered) into a + // single visual edge with a comma-joined label, combined provenance, + // and a `bidirectional` flag when both directions were present. + type Accum = MergedEdge & { labels: Set }; + const merged = new Map(); + for (const e of edges) { + const [a, b] = + e.source < e.target ? [e.source, e.target] : [e.target, e.source]; + const key = `${a}\x00${b}`; + const existing = merged.get(key); + if (!existing) { + merged.set(key, { + ...e, + refs: [...e.refs], + labels: new Set([e.label]), + bidirectional: false, + }); + continue; + } + existing.labels.add(e.label); + existing.refs.push(...e.refs); + if (e.undirected) existing.undirected = true; + if (e.source !== existing.source) existing.bidirectional = true; + // Non-mention kinds beat `mention` for styling purposes. + if (existing.kind === "mention" && e.kind !== "mention") { + existing.kind = e.kind; + } + } + const fgLinks: FGLink[] = [...merged.values()].map((acc) => { + const { labels, ...edge } = acc; + edge.label = [...labels].join(", "); + return { source: edge.source, target: edge.target, edge }; + }); + const rmap = new Map(); + for (const n of fgNodes) rmap.set(n.id, nodeRadius(n.degree)); + return { + nodes: fgNodes, + links: fgLinks, + adjacency: adj, + radii: rmap, + }; } export class GraphCanvas extends Component { - state: State = { edgeHover: null, ghostHover: null }; + state: State = { edgeHover: null, ghostHover: null }; - // DOM + force-graph handles. - private containerRef: HTMLDivElement | null = null; - private fg: ForceGraphInstance | null = null; + // DOM + force-graph handles. + private containerRef: HTMLDivElement | null = null; + private fg: ForceGraphInstance | null = null; - // Hover/adjacency state read by draw callbacks every frame. - private hoveredId: string | null = null; - private neighbors: Set = new Set(); - private adjacency: Map> = new Map(); - private theme: Theme = readTheme(); - private radiusMap: Map = new Map(); + // Hover/adjacency state read by draw callbacks every frame. + private hoveredId: string | null = null; + private neighbors: Set = new Set(); + private adjacency: Map> = new Map(); + private theme: Theme = readTheme(); + private radiusMap: Map = new Map(); - // Mouse position relative to the canvas container; drives the edge tooltip. - private mousePos: { x: number; y: number } = { x: 0, y: 0 }; + // Mouse position relative to the canvas container; drives the edge tooltip. + private mousePos: { x: number; y: number } = { x: 0, y: 0 }; - // Click-cancel timers for distinguishing single vs. double click. - private clickTimer: number | null = null; - private edgeClickTimer: number | null = null; + // Click-cancel timers for distinguishing single vs. double click. + private clickTimer: number | null = null; + private edgeClickTimer: number | null = null; - // Whether we've fed data once already. Both the first feed and later ones - // (expansions/collapses) arm `pendingFit` and auto-fit once the simulation - // settles (see onEngineStop); only the cooldown budget differs. - private fedOnce = false; + // Whether we've fed data once already. Both the first feed and later ones + // (expansions/collapses) arm `pendingFit` and auto-fit once the simulation + // settles (see onEngineStop); only the cooldown budget differs. + private fedOnce = false; - // Set when a data-driven feed wants the camera re-fitted after the - // simulation next settles. Gated so node drags (which also re-heat and - // fire onEngineStop) don't trigger camera jumps. - private pendingFit = false; + // Set when a data-driven feed wants the camera re-fitted after the + // simulation next settles. Gated so node drags (which also re-heat and + // fire onEngineStop) don't trigger camera jumps. + private pendingFit = false; - // Cached computation of nodes/links/adjacency/radii; recomputed in - // componentDidUpdate when props.nodes or props.edges identity changes. - private computed: ComputedGraph; + // Cached computation of nodes/links/adjacency/radii; recomputed in + // componentDidUpdate when props.nodes or props.edges identity changes. + private computed: ComputedGraph; - // Listeners/observers we own and must tear down. - private resizeObserver: ResizeObserver | null = null; - private mql: MediaQueryList | null = null; + // Listeners/observers we own and must tear down. + private resizeObserver: ResizeObserver | null = null; + private mql: MediaQueryList | null = null; - constructor(props: Props) { - super(props); - this.computed = computeGraph(props.nodes, props.edges); - // Bind handlers used as listener references / bare JSX onClicks so - // `this` resolves correctly and identities stay stable across - // add/removeEventListener pairs. - this.onMouseMove = this.onMouseMove.bind(this); - this.onThemeChange = this.onThemeChange.bind(this); - this.onKeyDown = this.onKeyDown.bind(this); - this.zoomIn = this.zoomIn.bind(this); - this.zoomOut = this.zoomOut.bind(this); - this.pan = this.pan.bind(this); - this.recenter = this.recenter.bind(this); - } + constructor(props: Props) { + super(props); + this.computed = computeGraph(props.nodes, props.edges); + // Bind handlers used as listener references / bare JSX onClicks so + // `this` resolves correctly and identities stay stable across + // add/removeEventListener pairs. + this.onMouseMove = this.onMouseMove.bind(this); + this.onThemeChange = this.onThemeChange.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.zoomIn = this.zoomIn.bind(this); + this.zoomOut = this.zoomOut.bind(this); + this.pan = this.pan.bind(this); + this.recenter = this.recenter.bind(this); + } - private onMouseMove(ev: MouseEvent) { - if (!this.containerRef) return; - const rect = this.containerRef.getBoundingClientRect(); - this.mousePos = { - x: ev.clientX - rect.left, - y: ev.clientY - rect.top, - }; - } + private onMouseMove(ev: MouseEvent) { + if (!this.containerRef) return; + const rect = this.containerRef.getBoundingClientRect(); + this.mousePos = { + x: ev.clientX - rect.left, + y: ev.clientY - rect.top, + }; + } - private onThemeChange() { - this.theme = readTheme(); - this.rerender(); - } + private onThemeChange() { + this.theme = readTheme(); + this.rerender(); + } - private onKeyDown(e: KeyboardEvent) { - const tgt = e.target as HTMLElement | null; - if ( - tgt && - (tgt.tagName === "INPUT" || - tgt.tagName === "TEXTAREA" || - (tgt as HTMLElement).isContentEditable) - ) - return; + private onKeyDown(e: KeyboardEvent) { + const tgt = e.target as HTMLElement | null; + if ( + tgt && + (tgt.tagName === "INPUT" || + tgt.tagName === "TEXTAREA" || + (tgt as HTMLElement).isContentEditable) + ) + return; - switch (e.key) { - case "ArrowUp": - this.pan(0, -1); - break; - case "ArrowDown": - this.pan(0, 1); - break; - case "ArrowLeft": - this.pan(-1, 0); - break; - case "ArrowRight": - this.pan(1, 0); - break; - case "PageUp": - case "+": - case "=": - this.zoomIn(); - break; - case "PageDown": - case "-": - case "_": - this.zoomOut(); - break; - case "f": - case "F": - this.recenter(); - break; - default: - return; - } - e.preventDefault(); - } + switch (e.key) { + case "ArrowUp": + this.pan(0, -1); + break; + case "ArrowDown": + this.pan(0, 1); + break; + case "ArrowLeft": + this.pan(-1, 0); + break; + case "ArrowRight": + this.pan(1, 0); + break; + case "PageUp": + case "+": + case "=": + this.zoomIn(); + break; + case "PageDown": + case "-": + case "_": + this.zoomOut(); + break; + case "f": + case "F": + this.recenter(); + break; + default: + return; + } + e.preventDefault(); + } - // Configure the link force's strength + distance accessors. The link - // set itself is owned by force-graph (rebound from graphData on every - // update), so we only set the per-link knobs. Multi-edge pairs get a - // proportionally stronger spring for Obsidian-style cluster pull. - private configureLinkForce() { - const fg = this.fg; - if (!fg) return; - const f = this.props.forces; - const linkForce = fg.d3Force("link") as d3.ForceLink | null; - if (!linkForce) return; - const mult = (l: FGLink) => Math.min(l.edge.refs.length, 6); - linkForce.strength((l: FGLink) => f.linkStrength * mult(l)); - linkForce.distance(f.linkDistance); - } + // Configure the link force's strength + distance accessors. The link + // set itself is owned by force-graph (rebound from graphData on every + // update), so we only set the per-link knobs. Multi-edge pairs get a + // proportionally stronger spring for Obsidian-style cluster pull. + private configureLinkForce() { + const fg = this.fg; + if (!fg) return; + const f = this.props.forces; + const linkForce = fg.d3Force("link") as d3.ForceLink | null; + if (!linkForce) return; + const mult = (l: FGLink) => Math.min(l.edge.refs.length, 6); + linkForce.strength((l: FGLink) => f.linkStrength * mult(l)); + linkForce.distance(f.linkDistance); + } - private applyForces() { - const fg = this.fg; - if (!fg) return; - const f = this.props.forces; - const centerStrength = (n: FGNode) => - n.degree === 0 ? f.centerStrength * 6 : f.centerStrength; - (fg.d3Force("x") as d3.ForceX | null)?.strength(centerStrength); - (fg.d3Force("y") as d3.ForceY | null)?.strength(centerStrength); - (fg.d3Force("charge") as d3.ForceManyBody | null)?.strength( - f.chargeStrength, - ); - this.configureLinkForce(); - // Re-heat softly so the new force field has a chance to settle. - fg.d3ReheatSimulation(); - } + private applyForces() { + const fg = this.fg; + if (!fg) return; + const f = this.props.forces; + const centerStrength = (n: FGNode) => + n.degree === 0 ? f.centerStrength * 6 : f.centerStrength; + (fg.d3Force("x") as d3.ForceX | null)?.strength(centerStrength); + (fg.d3Force("y") as d3.ForceY | null)?.strength(centerStrength); + (fg.d3Force("charge") as d3.ForceManyBody | null)?.strength( + f.chargeStrength, + ); + this.configureLinkForce(); + // Re-heat softly so the new force field has a chance to settle. + fg.d3ReheatSimulation(); + } - componentDidMount() { - if (!this.containerRef) return; - const fg = ForceGraph()(this.containerRef); - this.fg = fg; + componentDidMount() { + if (!this.containerRef) return; + const fg = ForceGraph()(this.containerRef); + this.fg = fg; - fg.backgroundColor("transparent") - .nodeId("id") - .nodeCanvasObjectMode(() => "replace") - .nodeCanvasObject( - (node: FGNode, ctx: CanvasRenderingContext2D, scale: number) => - this.drawNode(node, ctx, scale), - ) - .nodePointerAreaPaint( - (node: FGNode, color: string, ctx: CanvasRenderingContext2D) => { - ctx.fillStyle = color; - ctx.beginPath(); - const r = (this.radiusMap.get(node.id) ?? 12) + 4; - ctx.arc(node.x ?? 0, node.y ?? 0, r, 0, 2 * Math.PI); - ctx.fill(); - }, - ) - .linkCanvasObjectMode(() => "replace") - .linkCanvasObject( - (link: FGLink, ctx: CanvasRenderingContext2D, scale: number) => - this.drawLink(link, ctx, scale), - ) - .onNodeHover((node: FGNode | null) => { - const id = node?.id ?? null; - this.hoveredId = id; - this.neighbors = id ? (this.adjacency.get(id) ?? new Set()) : new Set(); - if (node && node.status === "ghost") { - this.setState({ - ghostHover: { - title: node.title, - x: this.mousePos.x, - y: this.mousePos.y, - }, - }); - } else if (this.state.ghostHover) { - this.setState({ ghostHover: null }); - } - this.rerender(); - }) - .onNodeClick((node: FGNode) => this.handleNodeClick(node)) - .onLinkHover((link: FGLink | null) => { - if (!link) { - this.setState({ edgeHover: null }); - return; - } - this.setState({ - edgeHover: { - edge: link.edge, - x: this.mousePos.x, - y: this.mousePos.y, - }, - }); - }) - .onLinkClick((link: FGLink) => this.handleLinkClick(link)) - .onNodeDrag((node: FGNode) => { - // Pin node where it's dragged. - node.fx = node.x; - node.fy = node.y; - }) - .onNodeDragEnd((node: FGNode) => { - node.fx = node.x; - node.fy = node.y; - }) - .onEngineStop(() => { - // After a data-driven feed re-heats the simulation and it settles, - // re-fit the camera so newly added/removed nodes stay in view. - // Gated by pendingFit so drag-induced settles don't move the camera. - if (this.pendingFit) { - this.pendingFit = false; - this.recenter(); - } - }); + fg.backgroundColor("transparent") + .nodeId("id") + .nodeCanvasObjectMode(() => "replace") + .nodeCanvasObject( + (node: FGNode, ctx: CanvasRenderingContext2D, scale: number) => + this.drawNode(node, ctx, scale), + ) + .nodePointerAreaPaint( + (node: FGNode, color: string, ctx: CanvasRenderingContext2D) => { + ctx.fillStyle = color; + ctx.beginPath(); + const r = (this.radiusMap.get(node.id) ?? 12) + 4; + ctx.arc(node.x ?? 0, node.y ?? 0, r, 0, 2 * Math.PI); + ctx.fill(); + }, + ) + .linkCanvasObjectMode(() => "replace") + .linkCanvasObject( + (link: FGLink, ctx: CanvasRenderingContext2D, scale: number) => + this.drawLink(link, ctx, scale), + ) + .onNodeHover((node: FGNode | null) => { + const id = node?.id ?? null; + this.hoveredId = id; + this.neighbors = id ? (this.adjacency.get(id) ?? new Set()) : new Set(); + if (node && node.status === "ghost") { + this.setState({ + ghostHover: { + title: node.title, + x: this.mousePos.x, + y: this.mousePos.y, + }, + }); + } else if (this.state.ghostHover) { + this.setState({ ghostHover: null }); + } + this.rerender(); + }) + .onNodeClick((node: FGNode) => this.handleNodeClick(node)) + .onLinkHover((link: FGLink | null) => { + if (!link) { + this.setState({ edgeHover: null }); + return; + } + this.setState({ + edgeHover: { + edge: link.edge, + x: this.mousePos.x, + y: this.mousePos.y, + }, + }); + }) + .onLinkClick((link: FGLink) => this.handleLinkClick(link)) + .onNodeDrag((node: FGNode) => { + // Pin node where it's dragged. + node.fx = node.x; + node.fy = node.y; + }) + .onNodeDragEnd((node: FGNode) => { + node.fx = node.x; + node.fy = node.y; + }) + .onEngineStop(() => { + // After a data-driven feed re-heats the simulation and it settles, + // re-fit the camera so newly added/removed nodes stay in view. + // Gated by pendingFit so drag-induced settles don't move the camera. + if (this.pendingFit) { + this.pendingFit = false; + this.recenter(); + } + }); - const f = this.props.forces; - // Center pull, degree-aware: isolated nodes (degree 0) get a much - // stronger pull so they don't drift off into the void; well-connected - // nodes use the slider's base strength so clusters can still spread. - const centerStrength = (n: FGNode) => - n.degree === 0 ? f.centerStrength * 6 : f.centerStrength; - fg.d3Force("x", d3.forceX(0).strength(centerStrength)); - fg.d3Force("y", d3.forceY(0).strength(centerStrength)); - // Generous collide padding so node labels (rendered below each node) - // have room to breathe and don't overlap with neighbors. - fg.d3Force( - "collide", - d3 - .forceCollide((n) => (this.radiusMap.get(n.id) ?? 12) + 16) - .strength(0.85), - ); - (fg.d3Force("charge") as d3.ForceManyBody | null)?.strength( - f.chargeStrength, - ); - // Force-graph maintains the link force itself: its `update()` runs - // after each graphData change and rebinds the link set from the - // merged links we pass in. We only override the per-link strength - // accessor (Obsidian-style cluster pull: pairs joined by multiple - // relations get a proportionally stronger spring) and the rest - // length, plus the radial center pull above. - this.configureLinkForce(); + const f = this.props.forces; + // Center pull, degree-aware: isolated nodes (degree 0) get a much + // stronger pull so they don't drift off into the void; well-connected + // nodes use the slider's base strength so clusters can still spread. + const centerStrength = (n: FGNode) => + n.degree === 0 ? f.centerStrength * 6 : f.centerStrength; + fg.d3Force("x", d3.forceX(0).strength(centerStrength)); + fg.d3Force("y", d3.forceY(0).strength(centerStrength)); + // Generous collide padding so node labels (rendered below each node) + // have room to breathe and don't overlap with neighbors. + fg.d3Force( + "collide", + d3 + .forceCollide((n) => (this.radiusMap.get(n.id) ?? 12) + 16) + .strength(0.85), + ); + (fg.d3Force("charge") as d3.ForceManyBody | null)?.strength( + f.chargeStrength, + ); + // Force-graph maintains the link force itself: its `update()` runs + // after each graphData change and rebinds the link set from the + // merged links we pass in. We only override the per-link strength + // accessor (Obsidian-style cluster pull: pairs joined by multiple + // relations get a proportionally stronger spring) and the rest + // length, plus the radial center pull above. + this.configureLinkForce(); - const resize = () => { - if (!this.containerRef) return; - fg.width(this.containerRef.clientWidth); - fg.height(this.containerRef.clientHeight); - }; - resize(); - this.resizeObserver = new ResizeObserver(resize); - this.resizeObserver.observe(this.containerRef); + const resize = () => { + if (!this.containerRef) return; + fg.width(this.containerRef.clientWidth); + fg.height(this.containerRef.clientHeight); + }; + resize(); + this.resizeObserver = new ResizeObserver(resize); + this.resizeObserver.observe(this.containerRef); - this.containerRef.addEventListener("mousemove", this.onMouseMove); + this.containerRef.addEventListener("mousemove", this.onMouseMove); - this.mql = window.matchMedia("(prefers-color-scheme: dark)"); - this.mql.addEventListener?.("change", this.onThemeChange); + this.mql = window.matchMedia("(prefers-color-scheme: dark)"); + this.mql.addEventListener?.("change", this.onThemeChange); - window.addEventListener("keydown", this.onKeyDown); + window.addEventListener("keydown", this.onKeyDown); - // Initial data feed. - this.feedData(); - } + // Initial data feed. + this.feedData(); + } - componentDidUpdate(prevProps: Props) { - // Recompute derived graph data if either input identity changed. - const inputsChanged = - prevProps.nodes !== this.props.nodes || - prevProps.edges !== this.props.edges; - if (inputsChanged) { - this.computed = computeGraph(this.props.nodes, this.props.edges); - this.feedData(); - } + componentDidUpdate(prevProps: Props) { + // Recompute derived graph data if either input identity changed. + const inputsChanged = + prevProps.nodes !== this.props.nodes || + prevProps.edges !== this.props.edges; + if (inputsChanged) { + this.computed = computeGraph(this.props.nodes, this.props.edges); + this.feedData(); + } - if (prevProps.hideEdgeLabels !== this.props.hideEdgeLabels) { - // Edge-label visibility flipped: force a repaint via no-op accessor swap. - const fg = this.fg; - if (fg) fg.linkCanvasObject(fg.linkCanvasObject()); - } + if (prevProps.hideEdgeLabels !== this.props.hideEdgeLabels) { + // Edge-label visibility flipped: force a repaint via no-op accessor swap. + const fg = this.fg; + if (fg) fg.linkCanvasObject(fg.linkCanvasObject()); + } - if (prevProps.selectedRef !== this.props.selectedRef) { - // Selected-node ring change: force a repaint. - const fg = this.fg; - if (fg) fg.nodeCanvasObject(fg.nodeCanvasObject()); - } + if (prevProps.selectedRef !== this.props.selectedRef) { + // Selected-node ring change: force a repaint. + const fg = this.fg; + if (fg) fg.nodeCanvasObject(fg.nodeCanvasObject()); + } - if (prevProps.forces !== this.props.forces) { - this.applyForces(); - } - } + if (prevProps.forces !== this.props.forces) { + this.applyForces(); + } + } - componentWillUnmount() { - this.cancelClickTimer(); - this.cancelEdgeClickTimer(); - if (this.containerRef) { - this.containerRef.removeEventListener("mousemove", this.onMouseMove); - } - this.resizeObserver?.disconnect(); - this.resizeObserver = null; - this.mql?.removeEventListener?.("change", this.onThemeChange); - this.mql = null; - window.removeEventListener("keydown", this.onKeyDown); - if (this.fg) { - this.fg.pauseAnimation(); - (this.fg as any)._destructor?.(); - } - if (this.containerRef) this.containerRef.innerHTML = ""; - this.fg = null; - } + componentWillUnmount() { + this.cancelClickTimer(); + this.cancelEdgeClickTimer(); + if (this.containerRef) { + this.containerRef.removeEventListener("mousemove", this.onMouseMove); + } + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + this.mql?.removeEventListener?.("change", this.onThemeChange); + this.mql = null; + window.removeEventListener("keydown", this.onKeyDown); + if (this.fg) { + this.fg.pauseAnimation(); + (this.fg as any)._destructor?.(); + } + if (this.containerRef) this.containerRef.innerHTML = ""; + this.fg = null; + } - private rerender() { - // No-op accessor swap forces force-graph to repaint immediately. - const fg = this.fg; - if (!fg) return; - fg.nodeCanvasObject(fg.nodeCanvasObject()); - } + private rerender() { + // No-op accessor swap forces force-graph to repaint immediately. + const fg = this.fg; + if (!fg) return; + fg.nodeCanvasObject(fg.nodeCanvasObject()); + } - // Feed nodes/links into force-graph, preserving positions and softly - // re-heating the simulation. The first feed auto-fits the camera after a - // short delay; later feeds re-fit once the simulation settles. - private feedData() { - const fg = this.fg; - if (!fg) return; - const { nodes, links, adjacency, radii } = this.computed; - this.adjacency = adjacency; - this.radiusMap = radii; + // Feed nodes/links into force-graph, preserving positions and softly + // re-heating the simulation. The first feed auto-fits the camera after a + // short delay; later feeds re-fit once the simulation settles. + private feedData() { + const fg = this.fg; + if (!fg) return; + const { nodes, links, adjacency, radii } = this.computed; + this.adjacency = adjacency; + this.radiusMap = radii; - // Carry over positions from the previous data so existing nodes stay put - // and new ghosts emerge from the position of an already-placed neighbor - // (rather than dropping in from random coordinates). - const prev = fg.graphData().nodes as FGNode[]; - const prevById = new Map(); - for (const p of prev) prevById.set(p.id, p); + // Carry over positions from the previous data so existing nodes stay put + // and new ghosts emerge from the position of an already-placed neighbor + // (rather than dropping in from random coordinates). + const prev = fg.graphData().nodes as FGNode[]; + const prevById = new Map(); + for (const p of prev) prevById.set(p.id, p); - for (const n of nodes) { - const old = prevById.get(n.id); - if (old) { - // Existing node — preserve its current physics state. - n.x = old.x; - n.y = old.y; - n.fx = old.fx; - n.fy = old.fy; - continue; - } - // New node — start at an already-placed connected neighbor. - const neighbors = adjacency.get(n.id); - if (!neighbors) continue; - for (const nbId of neighbors) { - const placed = prevById.get(nbId); - if (placed && placed.x !== undefined && placed.y !== undefined) { - // Tiny jitter avoids stacking new ghosts perfectly on top of each other. - n.x = placed.x + (Math.random() - 0.5) * 2; - n.y = placed.y + (Math.random() - 0.5) * 2; - break; - } - } - } + for (const n of nodes) { + const old = prevById.get(n.id); + if (old) { + // Existing node — preserve its current physics state. + n.x = old.x; + n.y = old.y; + n.fx = old.fx; + n.fy = old.fy; + continue; + } + // New node — start at an already-placed connected neighbor. + const neighbors = adjacency.get(n.id); + if (!neighbors) continue; + for (const nbId of neighbors) { + const placed = prevById.get(nbId); + if (placed && placed.x !== undefined && placed.y !== undefined) { + // Tiny jitter avoids stacking new ghosts perfectly on top of each other. + n.x = placed.x + (Math.random() - 0.5) * 2; + n.y = placed.y + (Math.random() - 0.5) * 2; + break; + } + } + } - fg.graphData({ nodes, links }); - // Re-apply our link-force tuning. force-graph's update() runs after - // graphData and rebinds the link set from the merged links, but - // doesn't touch strength/distance accessors. Re-set them now so the - // per-link multiplicity factor still applies after the rebind. - this.configureLinkForce(); - this.hoveredId = null; - this.neighbors = new Set(); + fg.graphData({ nodes, links }); + // Re-apply our link-force tuning. force-graph's update() runs after + // graphData and rebinds the link set from the merged links, but + // doesn't touch strength/distance accessors. Re-set them now so the + // per-link multiplicity factor still applies after the rebind. + this.configureLinkForce(); + this.hoveredId = null; + this.neighbors = new Set(); - // Auto-fit the camera once the simulation cools (onEngineStop → - // recenter), never on a fixed timer — fitting mid-layout leaves nodes - // half off-screen. Bound the run with cooldownTicks so the first - // from-scratch layout settles in a few hundred ms instead of the - // engine's multi-second default. recenter() handles the single-node and - // MAX_AUTO_ZOOM cases. - this.pendingFit = true; - fg.cooldownTicks( - this.fedOnce ? REFIT_COOLDOWN_TICKS : FIRST_FIT_COOLDOWN_TICKS, - ); - this.fedOnce = true; - } + // Auto-fit the camera once the simulation cools (onEngineStop → + // recenter), never on a fixed timer — fitting mid-layout leaves nodes + // half off-screen. Bound the run with cooldownTicks so the first + // from-scratch layout settles in a few hundred ms instead of the + // engine's multi-second default. recenter() handles the single-node and + // MAX_AUTO_ZOOM cases. + this.pendingFit = true; + fg.cooldownTicks( + this.fedOnce ? REFIT_COOLDOWN_TICKS : FIRST_FIT_COOLDOWN_TICKS, + ); + this.fedOnce = true; + } - private cancelClickTimer() { - if (this.clickTimer !== null) { - clearTimeout(this.clickTimer); - this.clickTimer = null; - } - } + private cancelClickTimer() { + if (this.clickTimer !== null) { + clearTimeout(this.clickTimer); + this.clickTimer = null; + } + } - private handleNodeClick(node: FGNode) { - if (this.clickTimer !== null) { - // Second click within window → treat as double-click. - this.cancelClickTimer(); - void this.handleDoubleClick(node); - return; - } - this.clickTimer = window.setTimeout(() => { - this.clickTimer = null; - this.props.onNodeClick(node.id); - }, CLICK_DELAY_MS); - } + private handleNodeClick(node: FGNode) { + if (this.clickTimer !== null) { + // Second click within window → treat as double-click. + this.cancelClickTimer(); + void this.handleDoubleClick(node); + return; + } + this.clickTimer = window.setTimeout(() => { + this.clickTimer = null; + this.props.onNodeClick(node.id); + }, CLICK_DELAY_MS); + } - private cancelEdgeClickTimer() { - if (this.edgeClickTimer !== null) { - clearTimeout(this.edgeClickTimer); - this.edgeClickTimer = null; - } - } + private cancelEdgeClickTimer() { + if (this.edgeClickTimer !== null) { + clearTimeout(this.edgeClickTimer); + this.edgeClickTimer = null; + } + } - private handleLinkClick(link: FGLink) { - if (this.edgeClickTimer !== null) { - this.cancelEdgeClickTimer(); - void this.handleLinkDoubleClick(link); - return; - } - this.edgeClickTimer = window.setTimeout(() => { - this.edgeClickTimer = null; - // Single click on an edge: no-op for now (no edge selection model). - }, CLICK_DELAY_MS); - } + private handleLinkClick(link: FGLink) { + if (this.edgeClickTimer !== null) { + this.cancelEdgeClickTimer(); + void this.handleLinkDoubleClick(link); + return; + } + this.edgeClickTimer = window.setTimeout(() => { + this.edgeClickTimer = null; + // Single click on an edge: no-op for now (no edge selection model). + }, CLICK_DELAY_MS); + } - private async handleLinkDoubleClick(link: FGLink) { - const prov = link.edge.refs[0]; - if (!prov) return; - try { - const ref = - prov.pos !== undefined ? `${prov.page}@${prov.pos}` : prov.page; - await editor.navigate(ref); - await editor.hidePanel("modal"); - } catch (err) { - console.error("object-graph: edge navigation failed", err); - } - } + private async handleLinkDoubleClick(link: FGLink) { + const prov = link.edge.refs[0]; + if (!prov) return; + try { + const ref = + prov.pos !== undefined ? `${prov.page}@${prov.pos}` : prov.page; + await editor.navigate(ref); + await editor.hidePanel("modal"); + } catch (err) { + console.error("object-graph: edge navigation failed", err); + } + } - private async handleDoubleClick(node: FGNode) { - try { - if (node.kind === "url") { - await editor.openUrl(node.id); - return; - } - // An item/block node whose ref carries no `@pos` holds a bare - // `$anchor` name (e.g. an item tagged `$pete-ref`). Navigate via the - // anchor (`$name`, empty page path) so SB resolves it through the - // index to wherever the anchor currently lives, instead of treating - // the bare name as a page. Positional refs (`page@pos`), pages and - // files navigate directly. - const isAnchorRef = - (node.kind === "item" || node.kind === "block") && - !node.id.includes("@"); - const ref = isAnchorRef ? `$${node.id}` : node.id; - await editor.navigate(ref); - await editor.hidePanel("modal"); - } catch (err) { - console.error("object-graph: navigation failed", err); - } - } + private async handleDoubleClick(node: FGNode) { + try { + if (node.kind === "url") { + await editor.openUrl(node.id); + return; + } + // An item/block node whose ref carries no `@pos` holds a bare + // `$anchor` name (e.g. an item tagged `$pete-ref`). Navigate via the + // anchor (`$name`, empty page path) so SB resolves it through the + // index to wherever the anchor currently lives, instead of treating + // the bare name as a page. Positional refs (`page@pos`), pages and + // files navigate directly. + const isAnchorRef = + (node.kind === "item" || node.kind === "block") && + !node.id.includes("@"); + const ref = isAnchorRef ? `$${node.id}` : node.id; + await editor.navigate(ref); + await editor.hidePanel("modal"); + } catch (err) { + console.error("object-graph: navigation failed", err); + } + } - private isHighlighted(id: string): boolean { - const hov = this.hoveredId; - if (!hov) return true; - return id === hov || this.neighbors.has(id); - } + private isHighlighted(id: string): boolean { + const hov = this.hoveredId; + if (!hov) return true; + return id === hov || this.neighbors.has(id); + } - private linkTouchesHover(link: FGLink): boolean { - const hov = this.hoveredId; - if (!hov) return false; - return getId(link.source) === hov || getId(link.target) === hov; - } + private linkTouchesHover(link: FGLink): boolean { + const hov = this.hoveredId; + if (!hov) return false; + return getId(link.source) === hov || getId(link.target) === hov; + } - private drawNode(node: FGNode, ctx: CanvasRenderingContext2D, scale: number) { - const t = this.theme; - const radius = this.radiusMap.get(node.id) ?? nodeRadius(node.degree); - const x = node.x ?? 0; - const y = node.y ?? 0; - const highlighted = this.isHighlighted(node.id); - const isSelected = node.id === this.props.selectedRef; + private drawNode(node: FGNode, ctx: CanvasRenderingContext2D, scale: number) { + const t = this.theme; + const radius = this.radiusMap.get(node.id) ?? nodeRadius(node.degree); + const x = node.x ?? 0; + const y = node.y ?? 0; + const highlighted = this.isHighlighted(node.id); + const isSelected = node.id === this.props.selectedRef; - ctx.save(); - let alpha = node.status === "ghost" ? 0.45 : 1; - if (!highlighted) alpha *= 0.35; - ctx.globalAlpha = alpha; + ctx.save(); + let alpha = node.status === "ghost" ? 0.45 : 1; + if (!highlighted) alpha *= 0.35; + ctx.globalAlpha = alpha; - if (node.dangling) { - ctx.beginPath(); - ctx.arc(x, y, radius, 0, Math.PI * 2); - ctx.fillStyle = t.bg || "#fff"; - ctx.fill(); - ctx.setLineDash([3 / scale, 2 / scale]); - ctx.lineWidth = 1.5 / scale; - ctx.strokeStyle = t.nodeDim; - ctx.stroke(); - ctx.setLineDash([]); - } else { - ctx.beginPath(); - ctx.arc(x, y, radius, 0, Math.PI * 2); - ctx.fillStyle = node.color; - ctx.fill(); - } + if (node.dangling) { + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fillStyle = t.bg || "#fff"; + ctx.fill(); + ctx.setLineDash([3 / scale, 2 / scale]); + ctx.lineWidth = 1.5 / scale; + ctx.strokeStyle = t.nodeDim; + ctx.stroke(); + ctx.setLineDash([]); + } else { + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fillStyle = node.color; + ctx.fill(); + } - if (isSelected) { - ctx.beginPath(); - ctx.arc(x, y, radius + 2 / scale, 0, Math.PI * 2); - ctx.lineWidth = 2 / scale; - ctx.strokeStyle = t.accent; - ctx.stroke(); - } + if (isSelected) { + ctx.beginPath(); + ctx.arc(x, y, radius + 2 / scale, 0, Math.PI * 2); + ctx.lineWidth = 2 / scale; + ctx.strokeStyle = t.accent; + ctx.stroke(); + } - // Label rendered BELOW the node, scaled to constant screen pixels. - // Hide when zoomed way out OR when this node is dimmed by hover. - const showLabel = - scale >= NODE_LABEL_MIN_SCALE && (highlighted || this.hoveredId === null); - if (showLabel) { - const fontPx = isSelected ? 13 : 11; - const fontSize = fontPx / scale; - ctx.font = `${isSelected ? "600 " : ""}${fontSize}px sans-serif`; - ctx.textAlign = "center"; - ctx.textBaseline = "top"; - const name = displayName(node.title); - const label = node.prefix ? `${node.prefix}${name}` : name; - const gap = 4 / scale; - // Halo behind the label for legibility over edges. - ctx.lineWidth = 3 / scale; - ctx.strokeStyle = t.labelHalo; - ctx.strokeText(label, x, y + radius + gap); - ctx.fillStyle = node.dangling ? t.nodeDim : t.label; - ctx.fillText(label, x, y + radius + gap); - } - ctx.restore(); - } + // Label rendered BELOW the node, scaled to constant screen pixels. + // Hide when zoomed way out OR when this node is dimmed by hover. + const showLabel = + scale >= NODE_LABEL_MIN_SCALE && (highlighted || this.hoveredId === null); + if (showLabel) { + const fontPx = isSelected ? 13 : 11; + const fontSize = fontPx / scale; + ctx.font = `${isSelected ? "600 " : ""}${fontSize}px sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + const name = displayName(node.title); + const label = node.prefix ? `${node.prefix}${name}` : name; + const gap = 4 / scale; + // Halo behind the label for legibility over edges. + ctx.lineWidth = 3 / scale; + ctx.strokeStyle = t.labelHalo; + ctx.strokeText(label, x, y + radius + gap); + ctx.fillStyle = node.dangling ? t.nodeDim : t.label; + ctx.fillText(label, x, y + radius + gap); + } + ctx.restore(); + } - private drawLink(link: FGLink, ctx: CanvasRenderingContext2D, scale: number) { - const t = this.theme; - const source = link.source as FGNode; - const target = link.target as FGNode; - if ( - typeof source !== "object" || - typeof target !== "object" || - source.x === undefined || - target.x === undefined - ) - return; + private drawLink(link: FGLink, ctx: CanvasRenderingContext2D, scale: number) { + const t = this.theme; + const source = link.source as FGNode; + const target = link.target as FGNode; + if ( + typeof source !== "object" || + typeof target !== "object" || + source.x === undefined || + target.x === undefined + ) + return; - const sx = source.x!; - const sy = source.y!; - const tx = target.x!; - const ty = target.y!; - const dx = tx - sx; - const dy = ty - sy; - const len = Math.hypot(dx, dy) || 1; - const ux = dx / len; - const uy = dy / len; - const sR = this.radiusMap.get(source.id) ?? 12; - const tR = this.radiusMap.get(target.id) ?? 12; - const ax = sx + sR * ux; - const ay = sy + sR * uy; - const bx = tx - tR * ux; - const by = ty - tR * uy; + const sx = source.x!; + const sy = source.y!; + const tx = target.x!; + const ty = target.y!; + const dx = tx - sx; + const dy = ty - sy; + const len = Math.hypot(dx, dy) || 1; + const ux = dx / len; + const uy = dy / len; + const sR = this.radiusMap.get(source.id) ?? 12; + const tR = this.radiusMap.get(target.id) ?? 12; + const ax = sx + sR * ux; + const ay = sy + sR * uy; + const bx = tx - tR * ux; + const by = ty - tR * uy; - const kind: string = link.edge.kind; - const dimmed = STRUCTURAL_KINDS.has(kind); - const hov = this.hoveredId; - const touches = this.linkTouchesHover(link); + const kind: string = link.edge.kind; + const dimmed = STRUCTURAL_KINDS.has(kind); + const hov = this.hoveredId; + const touches = this.linkTouchesHover(link); - let strokeStyle: string; - let alpha = 1; - if (hov) { - if (touches) { - strokeStyle = t.linkHot; - } else { - strokeStyle = t.linkDim; - alpha = 0.5; - } - } else { - strokeStyle = dimmed ? t.linkDim : t.link; - } + let strokeStyle: string; + let alpha = 1; + if (hov) { + if (touches) { + strokeStyle = t.linkHot; + } else { + strokeStyle = t.linkDim; + alpha = 0.5; + } + } else { + strokeStyle = dimmed ? t.linkDim : t.link; + } - ctx.save(); - ctx.globalAlpha = alpha; - ctx.strokeStyle = strokeStyle; - ctx.lineWidth = (touches ? 1.8 : 1.2) / scale; - if (dimmed) ctx.setLineDash([3 / scale, 3 / scale]); - else ctx.setLineDash([]); - ctx.beginPath(); - ctx.moveTo(ax, ay); - ctx.lineTo(bx, by); - ctx.stroke(); - ctx.setLineDash([]); + ctx.save(); + ctx.globalAlpha = alpha; + ctx.strokeStyle = strokeStyle; + ctx.lineWidth = (touches ? 1.8 : 1.2) / scale; + if (dimmed) ctx.setLineDash([3 / scale, 3 / scale]); + else ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(ax, ay); + ctx.lineTo(bx, by); + ctx.stroke(); + ctx.setLineDash([]); - // Arrowheads — skip on undirected co-mention collapses. - if (!link.edge.undirected) { - const headLen = 7 / scale; - const drawHead = (hx: number, hy: number, angle: number) => { - ctx.beginPath(); - ctx.moveTo(hx, hy); - ctx.lineTo( - hx - headLen * Math.cos(angle - Math.PI / 6), - hy - headLen * Math.sin(angle - Math.PI / 6), - ); - ctx.lineTo( - hx - headLen * Math.cos(angle + Math.PI / 6), - hy - headLen * Math.sin(angle + Math.PI / 6), - ); - ctx.closePath(); - ctx.fillStyle = strokeStyle; - ctx.fill(); - }; - // Target-end arrow. - drawHead(bx, by, Math.atan2(uy, ux)); - // Source-end arrow when both directions exist between this pair. - if (link.edge.bidirectional) drawHead(ax, ay, Math.atan2(-uy, -ux)); - } + // Arrowheads — skip on undirected co-mention collapses. + if (!link.edge.undirected) { + const headLen = 7 / scale; + const drawHead = (hx: number, hy: number, angle: number) => { + ctx.beginPath(); + ctx.moveTo(hx, hy); + ctx.lineTo( + hx - headLen * Math.cos(angle - Math.PI / 6), + hy - headLen * Math.sin(angle - Math.PI / 6), + ); + ctx.lineTo( + hx - headLen * Math.cos(angle + Math.PI / 6), + hy - headLen * Math.sin(angle + Math.PI / 6), + ); + ctx.closePath(); + ctx.fillStyle = strokeStyle; + ctx.fill(); + }; + // Target-end arrow. + drawHead(bx, by, Math.atan2(uy, ux)); + // Source-end arrow when both directions exist between this pair. + if (link.edge.bidirectional) drawHead(ax, ay, Math.atan2(-uy, -ux)); + } - // Rotated edge label riding above the midpoint. - // Suppressed entirely when the global "hide labels" toggle is on, - // unless the edge is incident to the hovered node. - const label = link.edge.label; - const labelsAllowed = !this.props.hideEdgeLabels || touches; - const showLabel = - label && labelsAllowed && (touches || scale >= EDGE_LABEL_MIN_SCALE); - if (showLabel) { - const mx = (ax + bx) / 2; - const my = (ay + by) / 2; - let angle = Math.atan2(by - ay, bx - ax); - if (angle > Math.PI / 2) angle -= Math.PI; - if (angle < -Math.PI / 2) angle += Math.PI; - ctx.translate(mx, my); - ctx.rotate(angle); - const fontSize = 10 / scale; - ctx.font = `${fontSize}px sans-serif`; - ctx.textAlign = "center"; - ctx.textBaseline = "bottom"; - ctx.lineWidth = 3 / scale; - ctx.strokeStyle = t.labelHalo; - ctx.strokeText(label, 0, -2 / scale); - ctx.fillStyle = dimmed ? t.labelDim : t.label; - ctx.fillText(label, 0, -2 / scale); - } - ctx.restore(); - } + // Rotated edge label riding above the midpoint. + // Suppressed entirely when the global "hide labels" toggle is on, + // unless the edge is incident to the hovered node. + const label = link.edge.label; + const labelsAllowed = !this.props.hideEdgeLabels || touches; + const showLabel = + label && labelsAllowed && (touches || scale >= EDGE_LABEL_MIN_SCALE); + if (showLabel) { + const mx = (ax + bx) / 2; + const my = (ay + by) / 2; + let angle = Math.atan2(by - ay, bx - ax); + if (angle > Math.PI / 2) angle -= Math.PI; + if (angle < -Math.PI / 2) angle += Math.PI; + ctx.translate(mx, my); + ctx.rotate(angle); + const fontSize = 10 / scale; + ctx.font = `${fontSize}px sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.lineWidth = 3 / scale; + ctx.strokeStyle = t.labelHalo; + ctx.strokeText(label, 0, -2 / scale); + ctx.fillStyle = dimmed ? t.labelDim : t.label; + ctx.fillText(label, 0, -2 / scale); + } + ctx.restore(); + } - private zoomIn() { - const fg = this.fg; - if (!fg) return; - fg.zoom(fg.zoom() * 1.4, 250); - } + private zoomIn() { + const fg = this.fg; + if (!fg) return; + fg.zoom(fg.zoom() * 1.4, 250); + } - private zoomOut() { - const fg = this.fg; - if (!fg) return; - fg.zoom(fg.zoom() / 1.4, 250); - } + private zoomOut() { + const fg = this.fg; + if (!fg) return; + fg.zoom(fg.zoom() / 1.4, 250); + } - // dx/dy in {-1, 0, 1} — step scales inversely with zoom so the visual - // pan distance stays constant. - private pan(dx: number, dy: number) { - const fg = this.fg; - if (!fg) return; - const step = 80 / fg.zoom(); - const c = fg.centerAt(); - if (!c) return; - fg.centerAt(c.x + dx * step, c.y + dy * step, 250); - } + // dx/dy in {-1, 0, 1} — step scales inversely with zoom so the visual + // pan distance stays constant. + private pan(dx: number, dy: number) { + const fg = this.fg; + if (!fg) return; + const step = 80 / fg.zoom(); + const c = fg.centerAt(); + if (!c) return; + fg.centerAt(c.x + dx * step, c.y + dy * step, 250); + } - private recenter() { - const fg = this.fg; - if (!fg) return; - if (this.computed.nodes.length <= 1) { - fg.centerAt(0, 0, 400); - fg.zoom(2, 400); - return; - } - fg.zoomToFit(400, 40); - if (fg.zoom() > MAX_AUTO_ZOOM) fg.zoom(MAX_AUTO_ZOOM, 400); - } + private recenter() { + const fg = this.fg; + if (!fg) return; + if (this.computed.nodes.length <= 1) { + fg.centerAt(0, 0, 400); + fg.zoom(2, 400); + return; + } + fg.zoomToFit(400, 40); + if (fg.zoom() > MAX_AUTO_ZOOM) fg.zoom(MAX_AUTO_ZOOM, 400); + } - render() { - const { edgeHover, ghostHover } = this.state; - const isEmpty = this.computed.nodes.length === 0; - return ( -
    -
    { - this.containerRef = el; - }} - class="graph-canvas" - /> - {isEmpty &&
    Graph is empty
    } - {edgeHover && } - {ghostHover && !edgeHover && } -
    -
    - - - - - -
    -
    - - -
    -
    -
    - ); - } + render() { + const { edgeHover, ghostHover } = this.state; + const isEmpty = this.computed.nodes.length === 0; + return ( +
    +
    { + this.containerRef = el; + }} + class="graph-canvas" + /> + {isEmpty &&
    Graph is empty
    } + {edgeHover && } + {ghostHover && !edgeHover && } +
    +
    + + + + + +
    +
    + + +
    +
    +
    + ); + } } function GhostTooltip({ - title, - x, - y, + title, + x, + y, }: { - title: string; - x: number; - y: number; + title: string; + x: number; + y: number; }) { - return ( -
    - Click to add {title} to the graph -
    - ); + return ( +
    + Click to add {title} to the graph +
    + ); } function EdgeTooltip({ - edge, - x, - y, + edge, + x, + y, }: { - edge: MergedEdge; - x: number; - y: number; + edge: MergedEdge; + x: number; + y: number; }) { - // Collect distinct, trimmed snippets across all merged provenance refs. - const snippets: string[] = []; - const seen = new Set(); - for (const r of edge.refs) { - const s = r.snippet?.trim(); - if (!s || seen.has(s)) continue; - seen.add(s); - snippets.push(s); - } - return ( -
    -
    {edge.label}
    - {snippets.length === 0 ? ( -
    - (no snippet) -
    - ) : ( - snippets.map((s, i) => ( -
    - {s} -
    - )) - )} -
    - ); + // Collect distinct, trimmed snippets across all merged provenance refs. + const snippets: string[] = []; + const seen = new Set(); + for (const r of edge.refs) { + const s = r.snippet?.trim(); + if (!s || seen.has(s)) continue; + seen.add(s); + snippets.push(s); + } + return ( +
    +
    {edge.label}
    + {snippets.length === 0 ? ( +
    + (no snippet) +
    + ) : ( + snippets.map((s, i) => ( +
    + {s} +
    + )) + )} +
    + ); } diff --git a/plugs/object-graph/ui/components/header.tsx b/plugs/object-graph/ui/components/header.tsx index 859d40fb..cb53f3a5 100644 --- a/plugs/object-graph/ui/components/header.tsx +++ b/plugs/object-graph/ui/components/header.tsx @@ -2,74 +2,76 @@ import { editor } from "@silverbulletmd/silverbullet/syscalls"; import { Button, Checkbox } from "@silverbulletmd/silverbullet/ui"; type Props = { - ghostCount: number; - onExpandAll: () => void; - onCollapseAll: () => void; - hideEdgeLabels: boolean; - onToggleHideEdgeLabels: (v: boolean) => void; - hideOrphans: boolean; - onToggleHideOrphans: (v: boolean) => void; + ghostCount: number; + onExpandAll: () => void; + onCollapseAll: () => void; + hideEdgeLabels: boolean; + onToggleHideEdgeLabels: (v: boolean) => void; + hideOrphans: boolean; + onToggleHideOrphans: (v: boolean) => void; }; export function Header({ - ghostCount, - onExpandAll, - onCollapseAll, - hideEdgeLabels, - onToggleHideEdgeLabels, - hideOrphans, - onToggleHideOrphans, + ghostCount, + onExpandAll, + onCollapseAll, + hideEdgeLabels, + onToggleHideEdgeLabels, + hideOrphans, + onToggleHideOrphans, }: Props) { - return ( -
    -

    Object Graph

    -
    - - - - - -
    -
    - ); + return ( +
    +

    Object Graph

    +
    + + + + + +
    +
    + ); } diff --git a/plugs/object-graph/ui/components/sidebar.tsx b/plugs/object-graph/ui/components/sidebar.tsx index 8c9d1930..7c1db64c 100644 --- a/plugs/object-graph/ui/components/sidebar.tsx +++ b/plugs/object-graph/ui/components/sidebar.tsx @@ -3,30 +3,30 @@ import { editor } from "@silverbulletmd/silverbullet/syscalls"; import { Checkbox, Input } from "@silverbulletmd/silverbullet/ui"; import { useEffect, useMemo, useState } from "preact/hooks"; import { - defaultForceSettings, - type Edge, - type Filters, - type ForceSettings, - type GraphUniverse, - type ObjectNode, + defaultForceSettings, + type Edge, + type Filters, + type ForceSettings, + type GraphUniverse, + type ObjectNode, } from "../../src/model.ts"; import { colorForTag } from "../colors.ts"; type Props = { - // Currently-visible nodes (used to compute counts for present-only filter UX). - nodes: ObjectNode[]; - edges: Edge[]; - // Full set; used so the filter list still shows recently-hidden entries. - allNodes: ObjectNode[]; - allEdges: Edge[]; - // Full option lists derived from the entire space. - universe: GraphUniverse; - filters: Filters; - onFiltersChange: (f: Filters) => void; - forces: ForceSettings; - onForcesChange: (f: ForceSettings) => void; - // Selected node; rendered as an object-detail section below the filters. - selected: ObjectNode | null; + // Currently-visible nodes (used to compute counts for present-only filter UX). + nodes: ObjectNode[]; + edges: Edge[]; + // Full set; used so the filter list still shows recently-hidden entries. + allNodes: ObjectNode[]; + allEdges: Edge[]; + // Full option lists derived from the entire space. + universe: GraphUniverse; + filters: Filters; + onFiltersChange: (f: Filters) => void; + forces: ForceSettings; + onForcesChange: (f: ForceSettings) => void; + // Selected node; rendered as an object-detail section below the filters. + selected: ObjectNode | null; }; const LONG_LIST_THRESHOLD = 10; @@ -35,217 +35,219 @@ const MAX_VISIBLE = 50; type Counted = { key: string; count: number }; export function Sidebar(props: Props) { - return ( - - ); + return ( + + ); } function ForcesSection({ - forces, - onForcesChange, + forces, + onForcesChange, }: { - forces: ForceSettings; - onForcesChange: (f: ForceSettings) => void; + forces: ForceSettings; + onForcesChange: (f: ForceSettings) => void; }) { - const [open, setOpen] = useState(false); - const update = (patch: Partial) => - onForcesChange({ ...forces, ...patch }); - const sliders: { - label: string; - key: keyof ForceSettings; - min: number; - max: number; - step: number; - }[] = [ - { - label: "Center pull", - key: "centerStrength", - min: 0, - max: 0.3, - step: 0.005, - }, - { - label: "Repulsion", - key: "chargeStrength", - min: -800, - max: 0, - step: 10, - }, - { label: "Link distance", key: "linkDistance", min: 10, max: 600, step: 1 }, - { - label: "Link strength", - key: "linkStrength", - min: 0.01, - max: 1, - step: 0.01, - }, - ]; - return ( -
    -
    setOpen(!open)}> - - ▸ - Forces - - e.stopPropagation()}> - onForcesChange(defaultForceSettings)}>reset - -
    - {open && ( -
    - {sliders.map((s) => ( - - ))} -
    - )} -
    - ); + const [open, setOpen] = useState(false); + const update = (patch: Partial) => + onForcesChange({ ...forces, ...patch }); + const sliders: { + label: string; + key: keyof ForceSettings; + min: number; + max: number; + step: number; + }[] = [ + { + label: "Center pull", + key: "centerStrength", + min: 0, + max: 0.3, + step: 0.005, + }, + { + label: "Repulsion", + key: "chargeStrength", + min: -800, + max: 0, + step: 10, + }, + { label: "Link distance", key: "linkDistance", min: 10, max: 600, step: 1 }, + { + label: "Link strength", + key: "linkStrength", + min: 0.01, + max: 1, + step: 0.01, + }, + ]; + return ( +
    +
    setOpen(!open)}> + + ▸ + Forces + + e.stopPropagation()}> + onForcesChange(defaultForceSettings)}>reset + +
    + {open && ( +
    + {sliders.map((s) => ( + + ))} +
    + )} +
    + ); } function ObjectSection({ selected }: { selected: ObjectNode | null }) { - const [yamlText, setYamlText] = useState(""); - useEffect(() => { - if (!selected) return; - let cancelled = false; - // Always lead with the object's `tag`. Real page/item/block objects - // carry it in their indexed attributes; stub nodes (dangling refs, - // URL/file targets) don't, so fall back to the node's structural tag - // (or kind). Spreading `attributes` last keeps the real value when set. - const display = { - tag: selected.rootTag ?? selected.kind, - ...selected.attributes, - }; - void syscall("yaml.stringify", display).then((text: string) => { - if (!cancelled) setYamlText(text); - }); - return () => { - cancelled = true; - }; - }, [selected]); - if (!selected) return null; - const swatch = colorForTag(selected.primaryTag); - const navigate = async () => { - try { - await editor.navigate(selected.ref); - await editor.hidePanel("modal"); - } catch (err) { - console.error("object-graph: object navigation failed", err); - } - }; + const [yamlText, setYamlText] = useState(""); + useEffect(() => { + if (!selected) return; + let cancelled = false; + // Always lead with the object's `tag`. Real page/item/block objects + // carry it in their indexed attributes; stub nodes (dangling refs, + // URL/file targets) don't, so fall back to the node's structural tag + // (or kind). Spreading `attributes` last keeps the real value when set. + const display = { + tag: selected.rootTag ?? selected.kind, + ...selected.attributes, + }; + void syscall("yaml.stringify", display).then((text: string) => { + if (!cancelled) setYamlText(text); + }); + return () => { + cancelled = true; + }; + }, [selected]); + if (!selected) return null; + const swatch = colorForTag(selected.primaryTag); + const navigate = async () => { + try { + await editor.navigate(selected.ref); + await editor.hidePanel("modal"); + } catch (err) { + console.error("object-graph: object navigation failed", err); + } + }; - return ( -
    -
    - - - {selected.title} - -
    -
    {yamlText}
    -
    - ); + return ( +
    +
    + + + {selected.title} + +
    +
    {yamlText}
    +
    + ); } function Section({ - title, - rows, - hidden, - onToggle, - onAll, - onNone, - renderRow, + title, + rows, + hidden, + onToggle, + onAll, + onNone, + renderRow, }: { - title: string; - rows: Counted[]; - hidden: string[]; - onToggle: (key: string) => void; - onAll: () => void; - onNone: () => void; - renderRow: (r: Counted) => preact.ComponentChildren; + title: string; + rows: Counted[]; + hidden: string[]; + onToggle: (key: string) => void; + onAll: () => void; + onNone: () => void; + renderRow: (r: Counted) => preact.ComponentChildren; }) { - const [open, setOpen] = useState(true); - const [query, setQuery] = useState(""); - const [showAll, setShowAll] = useState(false); - const filtered = query - ? rows.filter((r) => r.key.toLowerCase().includes(query.toLowerCase())) - : rows; - const display = showAll ? filtered : filtered.slice(0, MAX_VISIBLE); - const overflow = filtered.length - display.length; + const [open, setOpen] = useState(true); + const [query, setQuery] = useState(""); + const [showAll, setShowAll] = useState(false); + const filtered = query + ? rows.filter((r) => r.key.toLowerCase().includes(query.toLowerCase())) + : rows; + const display = showAll ? filtered : filtered.slice(0, MAX_VISIBLE); + const overflow = filtered.length - display.length; - return ( -
    -
    setOpen(!open)}> - - ▸ - {title} - - e.stopPropagation()}> - all - {" · "} - none - -
    - {open && ( -
    - {rows.length > LONG_LIST_THRESHOLD && ( - setQuery((e.currentTarget as HTMLInputElement).value)} - /> - )} - {display.map((r) => ( - - ))} - {overflow > 0 && ( - setShowAll(true)}> - show {overflow} more - - )} -
    - )} -
    - ); + return ( +
    +
    setOpen(!open)}> + + ▸ + {title} + + e.stopPropagation()}> + all + {" · "} + none + +
    + {open && ( +
    + {rows.length > LONG_LIST_THRESHOLD && ( + + setQuery((e.currentTarget as HTMLInputElement).value) + } + /> + )} + {display.map((r) => ( + + ))} + {overflow > 0 && ( + setShowAll(true)}> + show {overflow} more + + )} +
    + )} +
    + ); } /** @@ -254,101 +256,101 @@ function Section({ * entries first (by count desc), then absent entries alphabetically. */ function universeRows(keys: string[], counts: Map): Counted[] { - const rows: Counted[] = keys.map((key) => ({ - key, - count: counts.get(key) ?? 0, - })); - rows.sort((a, b) => { - if (a.count !== b.count) return b.count - a.count; - return a.key.localeCompare(b.key); - }); - return rows; + const rows: Counted[] = keys.map((key) => ({ + key, + count: counts.get(key) ?? 0, + })); + rows.sort((a, b) => { + if (a.count !== b.count) return b.count - a.count; + return a.key.localeCompare(b.key); + }); + return rows; } function TagsSection({ allNodes, universe, filters, onFiltersChange }: Props) { - const rows = useMemo(() => { - const counts = new Map(); - for (const n of allNodes) { - if (n.tags.length === 0) { - counts.set("(untagged)", (counts.get("(untagged)") ?? 0) + 1); - continue; - } - for (const t of n.tags) { - counts.set(t, (counts.get(t) ?? 0) + 1); - } - } - // "(untagged)" is always presented at the bottom of the universe list - // — it's a bucket, not a real tag. - return universeRows([...universe.tags, "(untagged)"], counts); - }, [allNodes, universe.tags]); - const toggle = (key: string) => - onFiltersChange({ - ...filters, - hiddenTags: filters.hiddenTags.includes(key) - ? filters.hiddenTags.filter((x) => x !== key) - : [...filters.hiddenTags, key], - }); - return ( -