From 991c60df657404c37d24280394712bff6efaa521 Mon Sep 17 00:00:00 2001 From: Rostyslav Hnatyshyn Date: Tue, 16 Jun 2026 20:27:50 -0700 Subject: [PATCH] debouncing, update list logic, fix bug with removed tags causing list to disappear --- main.js | 134 ++++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 86 insertions(+), 48 deletions(-) diff --git a/main.js b/main.js index ad8850f..70190ab 100644 --- a/main.js +++ b/main.js @@ -1,4 +1,4 @@ -const { ItemView, Plugin, TFile, MarkdownView } = require("obsidian"); +const { ItemView, Plugin, TFile, debounce, MarkdownView } = require("obsidian"); const VIEW_TYPE = "org-todo-list-view"; @@ -58,9 +58,27 @@ class OrgTodoListView extends ItemView { } async onOpen() { - this.registerEvent(this.app.vault.on("modify", () => this.refresh())); - this.registerEvent(this.app.vault.on("delete", () => this.refresh())); - this.registerEvent(this.app.vault.on("rename", () => this.refresh())); + this.debouncedUpdate = debounce( + (file) => this.updateFile(file), + 400, + true + ); + + // Fires AFTER the metadata cache re-parses — cache is fresh here. + this.registerEvent( + this.app.metadataCache.on("changed", (file) => this.debouncedUpdate(file)) + ); + // Deletions: just drop the file's tasks, nothing to reparse. + this.registerEvent( + this.app.vault.on("delete", (file) => this.removeFile(file.path)) + ); + // Rename: remove old-path entries, rescan under the new path. + this.registerEvent( + this.app.vault.on("rename", (file, oldPath) => { + this.removeFile(oldPath); + this.updateFile(file); + }) + ); this.buildChrome(); await this.refresh(); @@ -103,60 +121,80 @@ class OrgTodoListView extends ItemView { this.renderList(); } - async scanVault() { + async scanFile(file) { + if (!(file instanceof TFile) || file.extension !== "md") return []; + + const cache = this.app.metadataCache.getFileCache(file); + const taskItems = cache?.listItems?.filter((li) => li.task !== undefined) ?? []; + if (taskItems.length === 0) return []; + + const tagsByLine = new Map(); + for (const t of cache?.tags ?? []) { + const ln = t.position.start.line; + if (!tagsByLine.has(ln)) tagsByLine.set(ln, []); + tagsByLine.get(ln).push(t.tag.replace(/^#/, "")); + } + + const content = await this.app.vault.cachedRead(file); + const lines = content.split("\n"); const out = []; - const files = this.app.vault.getMarkdownFiles(); - for (const file of files) { - const cache = this.app.metadataCache.getFileCache(file); - const taskItems = cache?.listItems?.filter((li) => li.task !== undefined) ?? []; - - // Cache-gating: no task list items means we never read the file. - if (taskItems.length === 0) continue; - - // Build a quick lookup of tags by line, from the cache. tag includes the '#'. - const tagsByLine = new Map(); - for (const t of cache?.tags ?? []) { - const ln = t.position.start.line; - if (!tagsByLine.has(ln)) tagsByLine.set(ln, []); - // store without the leading '#' - tagsByLine.get(ln).push(t.tag.replace(/^#/, "")); - } - - // Read the file once (only because dates + display text need the raw line). - const content = await this.app.vault.cachedRead(file); - const lines = content.split("\n"); - - for (const li of taskItems) { - const line = li.position.start.line; - const raw = lines[line] ?? ""; - const m = raw.match(TASK_RE); - const done = (li.task ?? " ") !== " "; - const body = m ? m[2] : raw; - - // Tags straight from the cache for this line — no regex needed. - const tags = tagsByLine.get(line) ?? []; - - const doneDate = body.match(DONE_DATE_RE)?.[1] ?? null; - const dueDate = body.match(DUE_DATE_RE)?.[1] ?? null; - - const text = body - .replace(HASHTAG_RE, "") - .replace(DONE_DATE_RE, "") - .replace(DUE_DATE_RE, "") - .replace(/\s+/g, " ") - .trim(); - - out.push({ text, raw, done, tags, doneDate, dueDate, file, line }); - } + for (const li of taskItems) { + const line = li.position.start.line; + const raw = lines[line] ?? ""; + const m = raw.match(TASK_RE); + const done = (li.task ?? " ") !== " "; + const body = m ? m[2] : raw; + const tags = tagsByLine.get(line) ?? []; + const doneDate = body.match(DONE_DATE_RE)?.[1] ?? null; + const dueDate = body.match(DUE_DATE_RE)?.[1] ?? null; + const text = body + .replace(HASHTAG_RE, "") + .replace(DONE_DATE_RE, "") + .replace(DUE_DATE_RE, "") + .replace(/\s+/g, " ") + .trim(); + out.push({ text, raw, done, tags, doneDate, dueDate, file, line }); } return out; } + async scanVault() { + const out = []; + for (const file of this.app.vault.getMarkdownFiles()) { + const fileTasks = await this.scanFile(file); + for (const t of fileTasks) out.push(t); + } + return out; + } + + // Remove all tasks belonging to a given path, then re-render. + removeFile(path) { + this.tasks = this.tasks.filter((t) => t.file.path !== path); + this.renderTagBar(); + this.renderList(); + } + + // Rescan one file and splice its tasks in, replacing its previous entries. + async updateFile(file) { + if (!(file instanceof TFile) || file.extension !== "md") return; + const fresh = await this.scanFile(file); + this.tasks = this.tasks.filter((t) => t.file.path !== file.path); + for (const t of fresh) this.tasks.push(t); + this.renderTagBar(); + this.renderList(); + } + renderTagBar() { this.tagBarEl.empty(); const all = new Set(); for (const t of this.tasks) t.tags.forEach((x) => all.add(x)); + + // Drop any active filters whose tag no longer exists anywhere. + for (const tag of [...this.activeTags]) { + if (!all.has(tag)) this.activeTags.delete(tag); + } + const sorted = [...all].sort(); for (const tag of sorted) { const chip = this.tagBarEl.createEl("button", { text: "#" + tag });