From f0b806327074446ca3ee25873bcbc41c0741ed82 Mon Sep 17 00:00:00 2001 From: Dusk Date: Sat, 18 Apr 2026 15:16:25 +0800 Subject: [PATCH] fix(settings): always refresh folder tree on display to show newly-created folders --- .../settings/PluginSettingTab.test.ts | 39 ++++++++++- src/presentation/settings/PluginSettingTab.ts | 69 +++++++++++++++++-- 2 files changed, 99 insertions(+), 9 deletions(-) diff --git a/src/presentation/settings/PluginSettingTab.test.ts b/src/presentation/settings/PluginSettingTab.test.ts index 246e1aa..f8a6745 100644 --- a/src/presentation/settings/PluginSettingTab.test.ts +++ b/src/presentation/settings/PluginSettingTab.test.ts @@ -13,11 +13,13 @@ const { class HoistedFakeElement { public readonly children: HoistedFakeElement[] = []; public readonly dataset: Record = {}; + public readonly style: Record = {}; public checked = false; public indeterminate = false; public type = ""; public value = ""; public text = ""; + public textContent = ""; private readonly listeners = new Map void | Promise>>(); @@ -349,6 +351,28 @@ function getCheckboxByPath(containerEl: FakeContainerElInstance, folderPath: str return checkbox; } +function queryFolderToggle(containerEl: FakeContainerElInstance, folderPath: string): FakeElementInstance | undefined { + return findElement(containerEl, (element) => element.dataset.folderToggle === folderPath); +} + +function getFolderToggle(containerEl: FakeContainerElInstance, folderPath: string): FakeElementInstance { + const toggle = queryFolderToggle(containerEl, folderPath); + if (!toggle) { + throw new Error(`Toggle not found for folder path: ${folderPath}`); + } + + return toggle; +} + +function getFolderRow(containerEl: FakeContainerElInstance, folderPath: string): FakeElementInstance { + const row = findElement(containerEl, (element) => element.dataset.folderRow === folderPath); + if (!row) { + throw new Error(`Row not found for folder path: ${folderPath}`); + } + + return row; +} + function findElement( root: FakeElementInstance, predicate: (element: FakeElementInstance) => boolean, @@ -452,7 +476,7 @@ describe("AnkiHeadingSyncSettingTab", () => { expect(queryCheckboxByPath(container, "notes")).toBeUndefined(); }); - it("shows the folder tree in include mode and rehydrates saved selections after redisplay", async () => { + it("shows the folder tree as a collapsed hierarchy and reveals children after expanding a parent", async () => { const plugin = new FakePlugin(); plugin.settings = { ...plugin.settings, @@ -467,11 +491,22 @@ describe("AnkiHeadingSyncSettingTab", () => { tab.display(); const parentCheckbox = getCheckboxByPath(container, "notes"); - const childCheckbox = getCheckboxByPath(container, "notes/sub"); expect(parentCheckbox.checked).toBe(false); expect(parentCheckbox.indeterminate).toBe(true); + expect(queryCheckboxByPath(container, "notes/sub")).toBeUndefined(); + + await getFolderToggle(container, "notes").trigger("click"); + + const childCheckbox = getCheckboxByPath(container, "notes/sub"); + const parentRow = getFolderRow(container, "notes"); + const childRow = getFolderRow(container, "notes/sub"); + expect(childCheckbox.checked).toBe(true); + expect(parentRow.dataset.folderDepth).toBe("0"); + expect(childRow.dataset.folderDepth).toBe("1"); + expect(parentRow.style.paddingLeft).toBe("0px"); + expect(childRow.style.paddingLeft).toBe("18px"); expect(container.textNodes).toContain("empty"); }); diff --git a/src/presentation/settings/PluginSettingTab.ts b/src/presentation/settings/PluginSettingTab.ts index e7dc961..8cd7374 100644 --- a/src/presentation/settings/PluginSettingTab.ts +++ b/src/presentation/settings/PluginSettingTab.ts @@ -35,7 +35,9 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { private folderTree: FolderTreeNode[] = []; private folderTreeStatus = FOLDER_TREE_STATUS_LOADING; private folderTreeLoadPromise: Promise | null = null; - private hasLoadedFolderTree = false; + // removed cached 'hasLoadedFolderTree' so the settings UI can refresh + // the folder list when displayed (ensures newly-created Obsidian folders appear). + private readonly expandedFolderPaths = new Set(); constructor(plugin: AnkiHeadingSyncPlugin) { super(plugin.app, plugin); @@ -423,42 +425,86 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { const selectedFolders = settings.scopeMode === "include" ? settings.includeFolders : settings.excludeFolders; const selectionTree = buildFolderTreeSelection(this.folderTree, selectedFolders); const treeContainer = scopeContainer.createDiv(); + treeContainer.style.marginTop = "8px"; + treeContainer.style.display = "flex"; + treeContainer.style.flexDirection = "column"; + treeContainer.style.gap = "2px"; for (const node of selectionTree) { - this.renderFolderNode(treeContainer, node, settings.scopeMode); + this.renderFolderNode(treeContainer, node, settings.scopeMode, 0); } } - private renderFolderNode(containerEl: HTMLElement, node: FolderTreeSelectionNode, scopeMode: ScopeMode): void { + private renderFolderNode(containerEl: HTMLElement, node: FolderTreeSelectionNode, scopeMode: ScopeMode, depth: number): void { const row = containerEl.createDiv(); + row.dataset.folderRow = node.path; + row.dataset.folderDepth = String(depth); + row.style.display = "flex"; + row.style.alignItems = "center"; + row.style.gap = "6px"; + row.style.minHeight = "24px"; + row.style.paddingLeft = `${depth * 18}px`; + + const hasChildren = node.children.length > 0; + const expanded = hasChildren && this.expandedFolderPaths.has(node.path); + const toggleControl = row.createEl(hasChildren ? "button" : "span"); + toggleControl.dataset.folderToggle = node.path; + toggleControl.textContent = hasChildren ? (expanded ? "▾" : "▸") : ""; + toggleControl.style.width = "18px"; + toggleControl.style.display = "inline-flex"; + toggleControl.style.alignItems = "center"; + toggleControl.style.justifyContent = "center"; + toggleControl.style.flexShrink = "0"; + toggleControl.style.padding = "0"; + toggleControl.style.border = "0"; + toggleControl.style.background = "transparent"; + toggleControl.style.color = "var(--text-muted)"; + toggleControl.style.cursor = hasChildren ? "pointer" : "default"; + + if (hasChildren) { + toggleControl.setAttr("aria-label", expanded ? `收起 ${node.name}` : `展开 ${node.name}`); + toggleControl.setAttr("aria-expanded", String(expanded)); + toggleControl.addEventListener("click", () => { + this.toggleFolderExpanded(node.path); + }); + } + const checkbox = row.createEl("input") as HTMLInputElement; checkbox.type = "checkbox"; checkbox.checked = node.checked; checkbox.indeterminate = node.indeterminate; checkbox.dataset.folderPath = node.path; + checkbox.style.margin = "0"; checkbox.addEventListener("change", () => { void this.updateFolderSelection(scopeMode, node.path, checkbox.checked); }); const label = row.createEl("span", { text: node.name }); label.dataset.folderPathLabel = node.path; + label.style.userSelect = "none"; - if (node.children.length === 0) { + if (!hasChildren || !expanded) { return; } const childrenContainer = containerEl.createDiv(); + childrenContainer.dataset.folderChildren = node.path; + childrenContainer.style.display = "flex"; + childrenContainer.style.flexDirection = "column"; + childrenContainer.style.gap = "2px"; for (const child of node.children) { - this.renderFolderNode(childrenContainer, child, scopeMode); + this.renderFolderNode(childrenContainer, child, scopeMode, depth + 1); } } private ensureFolderTreeLoaded(): void { - if (this.hasLoadedFolderTree || this.folderTreeLoadPromise) { + if (this.folderTreeLoadPromise) { return; } this.folderTreeStatus = FOLDER_TREE_STATUS_LOADING; + // Always re-fetch the folder tree when requested (no permanent cache). + // Avoid concurrent loads using folderTreeLoadPromise. this.folderTreeLoadPromise = this.plugin .listFolderTree() .then((folderTree) => { @@ -470,7 +516,6 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { this.folderTreeStatus = error instanceof Error ? error.message : "读取 vault 文件夹失败。"; }) .finally(() => { - this.hasLoadedFolderTree = true; this.folderTreeLoadPromise = null; this.display(); }); @@ -488,6 +533,16 @@ export class AnkiHeadingSyncSettingTab extends PluginSettingTab { await this.plugin.updateSettings(scopeMode === "include" ? { includeFolders: nextSelection } : { excludeFolders: nextSelection }); this.display(); } + + private toggleFolderExpanded(folderPath: string): void { + if (this.expandedFolderPaths.has(folderPath)) { + this.expandedFolderPaths.delete(folderPath); + } else { + this.expandedFolderPaths.add(folderPath); + } + + this.display(); + } } function getScopeModeSummary(scopeMode: ScopeMode): string {