Skip to content

Commit c040ccc

Browse files
Leo310claude
andcommitted
feat(views): standalone .view files — leaf, embeds, agent-writable, out of the index
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: <title>)` 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>
1 parent 8999da7 commit c040ccc

12 files changed

Lines changed: 301 additions & 293 deletions

File tree

‎src/agent/tools/manageNotes.ts‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,20 @@ const editSchema = z.object({
3030
.describe("Replace all occurrences instead of requiring a single unique match. Default false."),
3131
});
3232

33+
/** What the agent may create, edit, move and delete: notes, and standalone views (`.view`, see views/gen-view). */
34+
const AGENT_WRITABLE_EXTENSIONS = [".md", ".view"];
35+
const isAgentWritablePath = (path: string): boolean => AGENT_WRITABLE_EXTENSIONS.some((ext) => path.endsWith(ext));
36+
3337
const createOperationSchema = z.object({
3438
type: z.literal("create"),
3539
path: z
3640
.string()
37-
.describe("Vault-relative path for the new markdown note. Must end in .md. Example: Notes/my-note.md"),
38-
content: z.string().describe("Full markdown content for the new note"),
41+
.describe(
42+
"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",
43+
),
44+
content: z
45+
.string()
46+
.describe("Full content for the new file (markdown for a note; a view fence's body for a .view)"),
3947
});
4048

4149
const REPLACE_PENDING_DESCRIPTION =
@@ -65,7 +73,9 @@ const deleteOperationSchema = z.object({
6573
const moveOperationSchema = z.object({
6674
type: z.literal("move"),
6775
path: z.string().describe("Current file path or wiki link reference to move"),
68-
newPath: z.string().describe("Destination vault-relative path for the markdown note. Must end in .md."),
76+
newPath: z
77+
.string()
78+
.describe("Destination vault-relative path. Must end in .md (a note) or .view (a standalone view)."),
6979
});
7080

7181
const replaceOperationSchema = z.object({
@@ -147,9 +157,9 @@ function validateExistingMarkdownFile(
147157
}
148158

149159
const file = result.file;
150-
if (!file.path.endsWith(".md")) {
160+
if (!isAgentWritablePath(file.path)) {
151161
return {
152-
error: `Error in operation ${operationNumber}: Only markdown files (.md) can be ${action}d. "${file.path}" is not a markdown file.`,
162+
error: `Error in operation ${operationNumber}: Only notes (.md) and views (.view) can be ${action}d. "${file.path}" is neither.`,
153163
};
154164
}
155165

@@ -510,8 +520,8 @@ async function stageNoteOperations(
510520
const duplicateError = ensureUniqueTarget(seenPaths, normalizedPath, operationNumber);
511521
if (duplicateError) return duplicateError;
512522

513-
if (!normalizedPath.endsWith(".md")) {
514-
return `Error in operation ${operationNumber}: Only markdown files (.md) can be created. Got: "${normalizedPath}"`;
523+
if (!isAgentWritablePath(normalizedPath)) {
524+
return `Error in operation ${operationNumber}: Only notes (.md) and views (.view) can be created. Got: "${normalizedPath}"`;
515525
}
516526

517527
if (!store.isPathAllowed(normalizedPath)) {
@@ -617,8 +627,8 @@ async function stageNoteOperations(
617627
if (sourceDuplicateError) return sourceDuplicateError;
618628

619629
const normalizedNewPath = normalizePath(operation.newPath);
620-
if (!normalizedNewPath.endsWith(".md")) {
621-
return `Error in operation ${operationNumber}: Only markdown files (.md) can be moved. Got destination "${normalizedNewPath}"`;
630+
if (!isAgentWritablePath(normalizedNewPath)) {
631+
return `Error in operation ${operationNumber}: Only notes (.md) and views (.view) can be moved. Got destination "${normalizedNewPath}"`;
622632
}
623633

624634
const destinationDuplicateError = ensureUniqueTarget(seenPaths, normalizedNewPath, operationNumber);

‎src/genview/registerViewBlocks.ts‎

Lines changed: 17 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import { type App, Notice, normalizePath, setIcon, type TFile } from "obsidian";
22
import type SecondBrainPlugin from "../main";
33
import { getData } from "../stores/dataStore.svelte";
44
import { VIEW_TYPE_CHAT } from "../views/chat/Chat";
5-
import { openGenView } from "../views/gen-view/GenView";
6-
import { viewFenceAtLine } from "./viewFences";
5+
import { VIEW_FILE_EXTENSION } from "../views/gen-view/GenView";
76
import { ViewRenderChild } from "./ViewRenderChild";
87
import { resolveViewLibs, VIEW_LIBS } from "./viewLibs";
98
import { parseViewSpec, VIEW_BLOCK_LANGUAGE, type ViewSpec, viewFileBasename, wrapViewFence } from "./viewSpec";
@@ -39,11 +38,9 @@ export function registerViewBlocks(plugin: SecondBrainPlugin): void {
3938
return;
4039
}
4140
// Inside a chat the block is a proposal the user may want to keep, so it gets a
42-
// toolbar; in a note it already is the note and only offers to open as a pane.
41+
// toolbar; in a note it already is the note's content.
4342
if (el.closest(`.workspace-leaf-content[data-type="${VIEW_TYPE_CHAT}"]`)) {
4443
renderChatToolbar(plugin, el, spec, source);
45-
} else {
46-
renderNoteAction(plugin, el, ctx.sourcePath, ctx.getSectionInfo(el));
4744
}
4845
ctx.addChild(new ViewRenderChild(el, plugin.app, spec, ctx.sourcePath));
4946
});
@@ -64,70 +61,41 @@ function renderPlaceholder(el: HTMLElement): void {
6461
}
6562

6663
/**
67-
* In a note, a hover-revealed corner button that opens this block as its own pane.
68-
* The block's index among the note's view fences comes from the section info Obsidian
69-
* hands the processor; without it (some embed contexts) there is no safe target.
64+
* Two ways to keep a view from the chat: copy it as a fence to paste inline into a
65+
* note, or save it as a standalone `.view` file — which opens as its own pane, is
66+
* embeddable with `![[name.view]]`, and stays out of the search indexes.
7067
*/
71-
function renderNoteAction(
72-
plugin: SecondBrainPlugin,
73-
el: HTMLElement,
74-
sourcePath: string,
75-
section: { text: string; lineStart: number } | null,
76-
): void {
77-
if (!section || !sourcePath) return;
78-
const fence = viewFenceAtLine(section.text, section.lineStart);
79-
if (!fence) return;
80-
iconButton(
81-
el,
82-
"maximize-2",
83-
"Open as view",
84-
() => openGenView(plugin, { path: sourcePath, index: fence.index, title: fence.spec.title }),
85-
"s2b-view-open",
86-
);
87-
}
88-
8968
function renderChatToolbar(plugin: SecondBrainPlugin, el: HTMLElement, spec: ViewSpec, source: string): void {
9069
const bar = el.createDiv({ cls: "s2b-view-toolbar" });
9170
bar.createSpan({ cls: "s2b-view-toolbar-title", text: spec.title ?? "View" });
92-
// Saving twice from the same block would create a second note: share one in-flight
93-
// save between both buttons, and forget it only if it failed.
71+
// Saving twice from the same block would create a second file: share one in-flight
72+
// save, and forget it only if it failed.
9473
let saving: Promise<TFile> | null = null;
9574
const save = (): Promise<TFile> => {
96-
saving ??= saveViewAsNote(plugin.app, getData().viewsFolder, spec, source).catch((error: unknown) => {
75+
saving ??= saveViewFile(plugin.app, getData().viewsFolder, spec, source).catch((error: unknown) => {
9776
saving = null;
9877
throw error;
9978
});
10079
return saving;
10180
};
102-
const report = (error: unknown) =>
103-
new Notice(`Could not save view: ${error instanceof Error ? error.message : String(error)}`);
10481

10582
iconButton(bar, "copy", "Copy as block to paste into a note", async () => {
10683
await navigator.clipboard.writeText(wrapViewFence(source));
10784
new Notice("View block copied. Paste it into any note.");
10885
});
109-
iconButton(bar, "save", "Save as a note in the vault", async () => {
86+
iconButton(bar, "save", "Save as a view file and open it", async () => {
11087
try {
11188
const file = await save();
112-
new Notice(`Saved view to ${file.path}`);
11389
await plugin.app.workspace.getLeaf("tab").openFile(file);
11490
} catch (error) {
115-
report(error);
116-
}
117-
});
118-
iconButton(bar, "maximize-2", "Save and open as its own view", async () => {
119-
try {
120-
const file = await save();
121-
await openGenView(plugin, { path: file.path, index: 0, title: spec.title });
122-
} catch (error) {
123-
report(error);
91+
new Notice(`Could not save view: ${error instanceof Error ? error.message : String(error)}`);
12492
}
12593
});
12694
}
12795

128-
function iconButton(parent: HTMLElement, icon: string, label: string, onClick: () => Promise<void>, cls = ""): void {
96+
function iconButton(parent: HTMLElement, icon: string, label: string, onClick: () => Promise<void>): void {
12997
const button = parent.createDiv({
130-
cls: `clickable-icon ${cls}`.trim(),
98+
cls: "clickable-icon",
13199
attr: { role: "button", tabindex: "0", "aria-label": label },
132100
});
133101
setIcon(button, icon);
@@ -140,16 +108,16 @@ function iconButton(parent: HTMLElement, icon: string, label: string, onClick: (
140108
});
141109
}
142110

143-
/** Write the view as `<folder>/<title>.md` holding the fence, suffixing the name on collision. */
144-
export async function saveViewAsNote(app: App, folder: string, spec: ViewSpec, source: string): Promise<TFile> {
111+
/** Write the view as `<folder>/<title>.view`, suffixing the name on collision. */
112+
export async function saveViewFile(app: App, folder: string, spec: ViewSpec, source: string): Promise<TFile> {
145113
const folderPath = normalizePath(folder || "Views");
146114
await ensureFolder(app, folderPath);
147115
const base = viewFileBasename(spec.title);
148-
let path = normalizePath(`${folderPath}/${base}.md`);
116+
let path = normalizePath(`${folderPath}/${base}.${VIEW_FILE_EXTENSION}`);
149117
for (let n = 2; app.vault.getAbstractFileByPath(path); n++) {
150-
path = normalizePath(`${folderPath}/${base} ${n}.md`);
118+
path = normalizePath(`${folderPath}/${base} ${n}.${VIEW_FILE_EXTENSION}`);
151119
}
152-
return app.vault.create(path, wrapViewFence(source));
120+
return app.vault.create(path, `${source.trim()}\n`);
153121
}
154122

155123
/** Create `folderPath` and any missing ancestors, one segment at a time. */

‎src/genview/viewFences.ts‎

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
2-
* Locate `s2b-view` fences in a note's markdown, so a note can be opened as a view
3-
* (`views/gen-view/GenView.ts`) and a rendered block can find its own index.
2+
* Locate `s2b-view` fences in a note's markdown. Used to keep view code out of the
3+
* search indexes: a fence's HTML and JavaScript are noise to retrieval, so
4+
* {@link stripViewFences} replaces each with a one-line marker carrying its title.
45
*
56
* Fence-aware in the CommonMark sense: a fence opened inside another fence (the views
67
* 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";
1314
export interface ViewFence {
1415
/** Position among the note's view fences, top to bottom. */
1516
index: number;
16-
/** Zero-based line of the opening marker (what `getSectionInfo` reports for the block). */
17+
/** Zero-based line of the opening marker. */
1718
lineStart: number;
1819
/** Zero-based line of the closing marker. */
1920
lineEnd: number;
@@ -62,7 +63,22 @@ export function findViewFences(markdown: string): ViewFence[] {
6263
return fences;
6364
}
6465

65-
/** The view fence whose opening marker is on `line`, or null. */
66-
export function viewFenceAtLine(markdown: string, line: number): ViewFence | null {
67-
return findViewFences(markdown).find((fence) => fence.lineStart === line) ?? null;
66+
/**
67+
* `markdown` with every view fence replaced by `(view: <title>)` — or `(view)` when it
68+
* has none — so a note that holds a dashboard is still findable by its name while its
69+
* code stays out of the index. Returns the input untouched when there is nothing to strip.
70+
*/
71+
export function stripViewFences(markdown: string): string {
72+
const fences = findViewFences(markdown);
73+
if (fences.length === 0) return markdown;
74+
const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
75+
const out: string[] = [];
76+
let cursor = 0;
77+
for (const fence of fences) {
78+
out.push(...lines.slice(cursor, fence.lineStart));
79+
out.push(fence.spec.title ? `(view: ${fence.spec.title})` : "(view)");
80+
cursor = fence.lineEnd + 1;
81+
}
82+
out.push(...lines.slice(cursor));
83+
return out.join("\n");
6884
}

‎src/main.ts‎

Lines changed: 8 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ import { onCodexSessionChange } from "./stores/providerRuntime.svelte";
3030
import { invalidateAuthState, invalidateProviderState } from "./lib/query";
3131
import { LexicalSearchService } from "./search/LexicalSearchService";
3232
import { registerViewBlocks } from "./genview/registerViewBlocks";
33-
import { findViewFences } from "./genview/viewFences";
34-
import { GenView, openGenView, VIEW_TYPE_GEN_VIEW } from "./views/gen-view/GenView";
33+
import { GenView, VIEW_FILE_EXTENSION, VIEW_TYPE_GEN_VIEW } from "./views/gen-view/GenView";
34+
import { registerViewEmbed, unregisterViewEmbed } from "./views/gen-view/viewEmbed";
3535
import { ChatView, VIEW_TYPE_CHAT } from "./views/chat/Chat";
3636
import { navigateToPendingChange } from "./lib/pendingChangeNavigation";
3737
import { registerChatEmbed, unregisterChatEmbed } from "./views/chat/chatEmbed";
@@ -481,8 +481,11 @@ export default class SecondBrainPlugin extends Plugin {
481481
// });
482482
// this.registerView(VIEW_TYPE_NOTE_CONTEXT, (leaf) => new NoteContextView(leaf, this));
483483
this.registerView(VIEW_TYPE_ONBOARDING, (leaf) => new OnboardingView(leaf, this));
484-
// A note's `s2b-view` fence as its own leaf (see views/gen-view/GenView.ts).
484+
// `.view` files: a standalone view as its own leaf, plus `![[x.view]]` embeds and
485+
// hover previews (see views/gen-view/). Torn down in onunload with the chat's.
485486
this.registerView(VIEW_TYPE_GEN_VIEW, (leaf) => new GenView(leaf, this));
487+
this.registerExtensions([VIEW_FILE_EXTENSION], VIEW_TYPE_GEN_VIEW);
488+
registerViewEmbed(this);
486489

487490
if (this.manifest.dir === undefined) {
488491
this.unload();
@@ -534,27 +537,6 @@ export default class SecondBrainPlugin extends Plugin {
534537

535538
this.registerMobileNavbarSearchOverride();
536539

537-
this.addCommand({
538-
id: "open-note-as-view",
539-
name: "Open note as view",
540-
icon: "layout-dashboard",
541-
checkCallback: (checking) => {
542-
const file = this.app.workspace.getActiveFile();
543-
if (!file || file.extension !== "md") return false;
544-
if (!checking) {
545-
void (async () => {
546-
const [first] = findViewFences(await this.app.vault.read(file));
547-
if (!first) {
548-
new Notice("This note has no view block.");
549-
return;
550-
}
551-
await openGenView(this, { path: file.path, index: 0, title: first.spec.title });
552-
})();
553-
}
554-
return true;
555-
},
556-
});
557-
558540
this.addCommand({
559541
id: "open-smart-graph",
560542
name: "Open smart graph",
@@ -904,8 +886,9 @@ export default class SecondBrainPlugin extends Plugin {
904886
// during unload.
905887
(
906888
this.app as typeof this.app & { viewRegistry?: { unregisterExtensions?: (extensions: string[]) => void } }
907-
).viewRegistry?.unregisterExtensions?.(["chat"]);
889+
).viewRegistry?.unregisterExtensions?.(["chat", VIEW_FILE_EXTENSION]);
908890
unregisterChatEmbed(this);
891+
unregisterViewEmbed(this);
909892
}
910893

911894
async createNewChat() {

‎src/skills/defaults/views/SKILL.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ fragment — markup, `<style>`, `<script>` — rendered in a sandboxed frame rig
1414
wrote it. An optional frontmatter block at the top declares a title, a fixed height, and
1515
named Dataview queries that the host runs for you and keeps live as the vault changes.
1616

17-
The user keeps a view from the toolbar above it: copy it as a block to paste into any
18-
note, save it as its own note, or save and open it as its own pane. A view in a note
19-
can be opened as a pane at any time. Do not create that note yourself unless asked; if
20-
the user does ask, stage it with `manage_notes` and put the same fence in the note body.
17+
The user keeps a view from the toolbar above it: copy it as a block to paste into a
18+
note, or save it as a standalone `.view` file, which opens as its own pane and can be
19+
embedded in any note with `![[Name.view]]`. Do not create that file yourself unless
20+
asked; if the user does ask, stage it with `manage_notes` as `<folder>/<Name>.view` whose
21+
content is the fence **body** only — frontmatter, then HTML, no fence markers.
2122

2223
## Format
2324
````markdown

‎src/styles.css‎

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1663,23 +1663,6 @@ body.is-mobile .tool-output-content pre .clickable-icon {
16631663
position: relative;
16641664
margin: var(--size-4-2) 0;
16651665
}
1666-
/* In a note: hover-revealed corner button that opens the block as its own pane. */
1667-
.s2b-view-open {
1668-
position: absolute;
1669-
top: var(--size-4-2);
1670-
right: var(--size-4-2);
1671-
opacity: 0;
1672-
transition: opacity 0.15s;
1673-
}
1674-
.s2b-view:hover .s2b-view-open,
1675-
.s2b-view-open:focus-visible {
1676-
opacity: 1;
1677-
}
1678-
@media (hover: none) {
1679-
.s2b-view-open {
1680-
opacity: 1;
1681-
}
1682-
}
16831666
/* ---- A view as its own leaf (views/gen-view) ---- */
16841667
.view-content.s2b-gen-view {
16851668
padding: 0;
@@ -1693,6 +1676,34 @@ body.is-mobile .tool-output-content pre .clickable-icon {
16931676
border: 0;
16941677
border-radius: 0;
16951678
}
1679+
.s2b-gen-view-source {
1680+
display: flex;
1681+
flex-direction: column;
1682+
gap: var(--size-4-2);
1683+
height: 100%;
1684+
padding: var(--size-4-3);
1685+
}
1686+
.s2b-gen-view-source-text {
1687+
flex: 1;
1688+
width: 100%;
1689+
resize: none;
1690+
font-family: var(--font-monospace);
1691+
font-size: var(--font-smaller);
1692+
line-height: 1.5;
1693+
}
1694+
.s2b-gen-view-source-bar {
1695+
display: flex;
1696+
align-items: center;
1697+
gap: var(--size-4-2);
1698+
}
1699+
.s2b-gen-view-source-hint {
1700+
display: flex;
1701+
align-items: center;
1702+
gap: var(--size-2-2);
1703+
margin-left: auto;
1704+
color: var(--text-muted);
1705+
font-size: var(--font-ui-smaller);
1706+
}
16961707
.s2b-view-frame {
16971708
display: block;
16981709
/* The host sets this frame's height to content + padding; the border must sit

‎src/utils/fileFiltering.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Platform, type TFile, type Vault } from "obsidian";
2+
import { stripViewFences } from "../genview/viewFences";
23
import { getAgentPathSource } from "./agentPathSource";
34
import { agentRootDir } from "./agentPaths";
45
import { THREAD_DATA_DEDUP_VERSION, inflateThreadData, sniffThreadDataVersion } from "../agent/threadDataCodec";
@@ -250,7 +251,10 @@ export function getEmbeddableVaultFiles(vault: Vault): TFile[] {
250251
* before calling this helper.
251252
*/
252253
export async function readIndexableContent(vault: Vault, file: TFile): Promise<string> {
253-
const content = await readIndexableContentRaw(vault, file);
254+
const raw = await readIndexableContentRaw(vault, file);
255+
// A view fence's HTML/JS is noise to retrieval (and would surface as chunks); keep
256+
// only a marker with its title. `.view` files themselves are not indexable at all.
257+
const content = file.extension === "md" ? stripViewFences(raw) : raw;
254258
const cap = maxIndexedTextChars(file);
255259
return content.length > cap ? content.slice(0, cap) : content;
256260
}

0 commit comments

Comments
 (0)