Obsidian Core Workflow B: Advanced Plugin Features
Overview
Add production UI to an existing Obsidian plugin: custom sidebar views, modal dialogs with forms, fuzzy-search suggestion popups, editor commands that manipulate selections, status bar widgets, context menus, and programmatic file creation via the Vault API. Every snippet is a complete, copy-pasteable class.
Prerequisites
- A working plugin built with
obsidian-core-workflow-a(or equivalent) npm install --save-dev obsidianalready done- Familiarity with the Plugin lifecycle (
onload/onunload)
Instructions
Step 1: Custom sidebar view (ItemView)
A custom view registers a new panel type that can live in the left or right sidebar.
// src/views/StatsView.ts
import { ItemView, WorkspaceLeaf, TFile } from "obsidian";
export const STATS_VIEW_TYPE = "vault-stats-view";
export class StatsView extends ItemView {
constructor(leaf: WorkspaceLeaf) {
super(leaf);
}
getViewType(): string {
return STATS_VIEW_TYPE;
}
getDisplayText(): string {
return "Vault Stats";
}
getIcon(): string {
return "bar-chart-2";
}
async onOpen() {
const container = this.containerEl.children[1];
container.empty();
container.addClass("stats-view");
container.createEl("h4", { text: "Vault Statistics" });
const listEl = container.createEl("ul");
const files = this.app.vault.getMarkdownFiles();
let totalWords = 0;
for (const file of files) {
const content = await this.app.vault.cachedRead(file);
totalWords += content.split(/\s+/).filter(Boolean).length;
}
listEl.createEl("li", { text: `Notes: ${files.length}` });
listEl.createEl("li", { text: `Total words: ${totalWords.toLocaleString()}` });
listEl.createEl("li", {
text: `Avg words/note: ${files.length ? Math.round(totalWords / files.length) : 0}`,
});
// Refresh button
const btn = container.createEl("button", { text: "Refresh" });
btn.addEventListener("click", () => this.onOpen());
}
async onClose() {
// cleanup if needed
}
}
Register and open it from your main plugin:
// In your Plugin's onload():
import { StatsView, STATS_VIEW_TYPE } from "./views/StatsView";
this.registerView(STATS_VIEW_TYPE, (leaf) => new StatsView(leaf));
this.addCommand({
id: "open-stats-view",
name: "Open vault stats",
callback: () => this.activateStatsView(),
});
this.addRibbonIcon("bar-chart-2", "Vault Stats", () => this.activateStatsView());
// Helper to open or reveal the view
async activateStatsView() {
const { workspace } = this.app;
let leaf = workspace.getLeavesOfType(STATS_VIEW_TYPE)[0];
if (!leaf) {
const rightLeaf = workspace.getRightLeaf(false);
if (rightLeaf) {
await rightLeaf.setViewState({ type: STATS_VIEW_TYPE, active: true });
leaf = rightLeaf;
}
}
if (leaf) workspace.revealLeaf(leaf);
}
// In onunload():
this.app.workspace.detachLeavesOfType(STATS_VIEW_TYPE);
Step 2: Modal dialogs
Confirmation modal -- returns a boolean via callback:
// src/modals/ConfirmModal.ts
import { App, Modal, Setting } from "obsidian";
export class ConfirmModal extends Modal {
private resolved = false;
constructor(
app: App,
private message: string,
private onResult: (confirmed: boolean) => void
) {
super(app);
}
onOpen() {
const { contentEl } = this;
contentEl.createEl("h3", { text: "Confirm" });
contentEl.createEl("p", { text: this.message });
new Setting(contentEl)
.addButton((btn) =>
btn.setButtonText("Cancel").onClick(() => this.close())
)
.addButton((btn) =>
btn
.setButtonText("Confirm")
.setCta()
.onClick(() => {
this.resolved = true;
this.close();
})
);
}
onClose() {
this.onResult(this.resolved);
this.contentEl.empty();
}
}
Text input modal -- collects a single string:
// src/modals/InputModal.ts
import { App, Modal, Setting } from "obsidian";
export class InputModal extends Modal {
private value = "";
constructor(
app: App,
private title: string,
private placeholder: string,
private onSubmit: (value: string | null) => void
) {
super(app);
}
onOpen() {
const { contentEl } = this;
contentEl.createEl("h3", { text: this.title });
new Setting(contentEl).addText((text) =>
text
.setPlaceholder(this.placeholder)
.onChange((v) => (this.value = v))
);
new Setting(contentEl)
.addButton((btn) =>
btn.setButtonText("Cancel").onClick(() => {
this.onSubmit(null);
this.close();
})
)
.addButton((btn) =>
btn
.setButtonText("OK")
.setCta()
.onClick(() => {
this.onSubmit(this.value);
this.close();
})
);
}
onClose() {
this.contentEl.empty();
}
}
Step 3: Fuzzy suggestion modal (SuggestModal)
Opens a searchable list. Users type to filter, then pick an item.
// src/modals/NotePicker.ts
import { App, FuzzySuggestModal, TFile } from "obsidian";
export class NotePicker extends FuzzySuggestModal<TFile> {
constructor(app: App, private onPick: (file: TFile) => void) {
super(app);
}
getItems(): TFile[] {
return this.app.vault.getMarkdownFiles();
}
getItemText(file: TFile): string {
return file.path;
}
onChooseItem(file: TFile): void {
this.onPick(file);
}
}
// Usage in a command:
this.addCommand({
id: "pick-note",
name: "Pick a note",
callback: () => {
new NotePicker(this.app, (file) => {
new Notice(`Selected: ${file.basename}`);
}).open();
},
});
Step 4: Editor commands with selection manipulation
editorCallback gives you the CodeMirror Editor and the active MarkdownView.
// Wrap selection in callout
this.addCommand({
id: "wrap-callout",
name: "Wrap selection in callout",
editorCallback: (editor, view) => {
const selection = editor.getSelection();
if (!selection) {
new Notice("Select text first");
return;
}
const callout = `> [!note]\n> ${selection.split("\n").join("\n> ")}`;
editor.replaceSelection(callout);
},
});
// Insert ISO timestamp at cursor
this.addCommand({
id: "insert-timestamp",
name: "Insert timestamp",
editorCallback: (editor) => {
const now = new Date().toISOString().slice(0, 19).replace("T", " ");
editor.replaceSelection(now);
},
});
// Sort selected lines alphabetically
this.addCommand({
id: "sort-lines",
name: "Sort selected lines",
editorCallback: (editor) => {
const selection = editor.getSelection();
if (!selection) return;
const sorted = selection.split("\n").sort((a, b) => a.localeCompare(b)).join("\n");
editor.replaceSelection(sorted);
},
});
Step 5: Status bar items
Status bar items sit at the bottom of the Obsidian window.
// In onload():
const statusEl = this.addStatusBarItem();
statusEl.setText("Words: --");
// Update word count when active file changes
this.registerEvent(
this.app.workspace.on("active-leaf-change", async () => {
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (view) {
const content = view.editor.getValue();
const count = content.split(/\s+/).filter(Boolean).length;
statusEl.setText(`Words: ${count}`);
} else {
statusEl.setText("Words: --");
}
})
);
Step 6: Context menus
Add items to the file explorer right-click menu and the editor right-click menu.
// File explorer context menu -- only on markdown files
this.registerEvent(
this.app.workspace.on("file-menu", (menu, file) => {
if (file instanceof TFile && file.extension === "md") {
menu.addItem((item) => {
item
.setTitle("Copy note title")
.setIcon("clipboard-copy")
.onClick(async () => {
await navigator.clipboard.writeText(file.basename);
new Notice(`Copied: ${file.basename}`);
});
});
}
})
);
// Editor context menu -- insert current date
this.registerEvent(
this.app.workspace.on("editor-menu", (menu, editor) => {
menu.addItem((item) => {
item
.setTitle("Insert today's date")
.setIcon("calendar")
.onClick(() => {
const today = new Date().toISOString().slice(0, 10);
editor.replaceSelection(today);
});
});
})
);
Step 7: File creation and modification via Vault API
Programmatically create, read, and modify notes.
// Create a daily note if it doesn't exist
async ensureDailyNote(): Promise<TFile> {
const today = new Date().toISOString().slice(0, 10);
const path = `Daily/${today}.md`;
const existing = this.app.vault.getAbstractFileByPath(path);
if (existing instanceof TFile) return existing;
// Ensure folder
const folder = this.app.vault.getAbstractFileByPath("Daily");
if (!folder) await this.app.vault.createFolder("Daily");
const content = `# ${today}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
return this.app.vault.create(path, content);
}
// Append text to the end of a note
async appendToNote(file: TFile, text: string): Promise<void> {
const current = await this.app.vault.read(file);
await this.app.vault.modify(file, current + "\n" + text);
}
// Batch-update frontmatter tag across files
async addTagToFolder(folder: string, tag: string): Promise<number> {
const files = this.app.vault.getMarkdownFiles()
.filter((f) => f.path.startsWith(folder + "/"));
let count = 0;
for (const file of files) {
let content = await this.app.vault.read(file);
if (content.startsWith("---")) {
// Has frontmatter -- insert tag
content = content.replace(
/^(---\n[\s\S]*?)(---)$/m,
`$1tags:\n - ${tag}\n$2`
);
} else {
// No frontmatter -- add it
content = `---\ntags:\n - ${tag}\n---\n${content}`;
}
await this.app.vault.modify(file, content);
count++;
}
return count;
}
Output
After applying these patterns your plugin gains:
- A sidebar panel (ItemView) with live vault statistics
- Confirmation and text-input modal dialogs
- A fuzzy-search note picker
- Editor commands that transform selected text
- A live word-count status bar widget
- Right-click context menu extensions
- Vault API helpers for file creation and batch modification
Error Handling
| Error | Cause | Fix |
|-------|-------|-----|
| View not appearing | Forgot registerView in onload | Must register before opening |
| getRightLeaf returns null | No sidebar available | Guard with if (leaf) |
| Modal closes without callback | Event order issue | Set result before calling this.close() |
| editorCallback greyed out | No active markdown editor | Use callback instead for non-editor commands |
| Context menu item missing | Wrong event name | file-menu for explorer, editor-menu for editor |
| vault.create throws | File already exists at path | Check with getAbstractFileByPath first |
| Stale cachedRead data | Cache not yet updated | Use vault.read when freshness matters |
Examples
Open a view in a new tab instead of sidebar:
const leaf = this.app.workspace.getLeaf("tab");
await leaf.setViewState({ type: STATS_VIEW_TYPE, active: true });
Promise-based confirm modal:
function confirm(app: App, msg: string): Promise<boolean> {
return new Promise((resolve) => {
new ConfirmModal(app, msg, resolve).open();
});
}
// Usage:
if (await confirm(this.app, "Delete all empty notes?")) {
// proceed
}
Resources
Next Steps
- Set up hot-reload development: see
obsidian-local-dev-loop - Apply production safety patterns: see
obsidian-sdk-patterns - Handle common errors: see
obsidian-common-errors