From 164b5019a2e1ec73a08784da051d6d7a4ebd6266 Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:07:52 +0800 Subject: [PATCH 1/5] fix: preserve links and manual order across vault changes --- src/__tests__/main.test.ts | 32 ++- src/explorer/SmartExplorerView.ts | 21 +- .../SmartExplorerView.integration.test.ts | 208 +++++++++++++++--- .../__tests__/SmartExplorerView.test.ts | 108 +++++++-- src/main.ts | 9 + 5 files changed, 309 insertions(+), 69 deletions(-) diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index 2264916..766dd25 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -7,6 +7,7 @@ jest.mock( return null; } async saveData(_data: unknown) {} + registerEvent() {} registerView() {} addRibbonIcon() {} addCommand() {} @@ -19,7 +20,7 @@ jest.mock( open() {} close() {} }, - Notice: class {}, + Notice: jest.fn(), PluginSettingTab: class {}, Setting: class {}, Platform: { isMobile: false }, @@ -103,7 +104,7 @@ describe("SmartExplorerPlugin", () => { it("registers command palette actions for core explorer workflows", async () => { const commands: { id: string; name: string }[] = []; const plugin = new SmartExplorerPlugin({} as any, {} as any); - (plugin as any).app = { workspace: {} }; + (plugin as any).app = { workspace: {}, vault: { on: jest.fn() } }; (plugin as any).registerView = jest.fn(); (plugin as any).addRibbonIcon = jest.fn(); (plugin as any).addCommand = jest.fn((command) => { @@ -204,3 +205,30 @@ describe("SmartExplorerPlugin", () => { }); }); + + +describe("plugin lifetime rename maintenance", () => { + it("preserves subtree positions with no panes and recovers after a failed save", async () => { + const handlers: Record void> = {}; + const plugin = new SmartExplorerPlugin({} as any, {} as any); + (plugin as any).app = { vault: { on: (name: string, cb: typeof handlers[string]) => { handlers[name] = cb; } }, workspace: { getLeavesOfType: () => [] } }; + await plugin.onload(); + plugin.settings.manualOrder = ["b.md", "old/a.md", "older/a.md", "c.md"]; + plugin.saveData = jest.fn().mockRejectedValueOnce(new Error("disk full")).mockResolvedValue(undefined); + expect(handlers.rename).toBeDefined(); + handlers.rename!({ path: "new" }, "old"); + await plugin.flushSettings(); + expect(plugin.settings.manualOrder).toEqual(["b.md", "new/a.md", "older/a.md", "c.md"]); + expect(jest.requireMock("obsidian").Notice).toHaveBeenCalledWith(expect.stringContaining("disk full")); + handlers.rename!({ path: "renamed.md" }, "b.md"); + await plugin.flushSettings(); + expect(plugin.saveData).toHaveBeenLastCalledWith(expect.objectContaining({ manualOrder: ["renamed.md", "new/a.md", "older/a.md", "c.md"] })); + handlers.rename!({ path: "irrelevant.md" }, "missing.md"); + await plugin.flushSettings(); + expect(plugin.saveData).toHaveBeenCalledTimes(2); + plugin.settings.manualOrder = []; + handlers.rename!({ path: "other.md" }, "renamed.md"); + expect(plugin.settings.manualOrder).toEqual([]); + expect(plugin.saveData).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/explorer/SmartExplorerView.ts b/src/explorer/SmartExplorerView.ts index c42d022..7360f5a 100644 --- a/src/explorer/SmartExplorerView.ts +++ b/src/explorer/SmartExplorerView.ts @@ -264,11 +264,11 @@ export class SmartExplorerView extends ItemView { if (this.selectedPath === oldPath) { this.selectedPath = file.path; } - this.updateManualOrderAfterRename(oldPath, file.path); + this.updateManualOrderUndoAfterRename(oldPath, file.path); } else if (file instanceof TFolder) { this.updateFolderPathState(oldPath, file.path); this.fileIndex.renameFolder(oldPath, file.path); - this.updateManualOrderAfterRename(oldPath, file.path); + this.updateManualOrderUndoAfterRename(oldPath, file.path); } this.scheduleRebuild(); })); @@ -1448,14 +1448,10 @@ export class SmartExplorerView extends ItemView { } } - private updateManualOrderAfterRename(oldPath: string, newPath: string) { - const order = this.plugin.settings.manualOrder; - const nextOrder = renameManualOrderPaths(order, oldPath, newPath); - if (nextOrder === order) return; - - this.plugin.settings.manualOrder = nextOrder; - this.buildManualOrderIndex(); - this.scheduleSaveOrder(); + private updateManualOrderUndoAfterRename(oldPath: string, newPath: string) { + this.manualOrderUndoStack = this.manualOrderUndoStack.map((order) => + renameManualOrderPaths(order, oldPath, newPath), + ); } revealActiveFile() { @@ -1568,7 +1564,7 @@ export class SmartExplorerView extends ItemView { return; } try { - await this.app.vault.rename(file, nextPath); + await this.app.fileManager.renameFile(file, nextPath); this.inlineEdit = null; this.selectedPath = file instanceof TFile ? nextPath : null; this.selectedFolderPath = file instanceof TFolder ? nextPath : null; @@ -1711,7 +1707,8 @@ export class SmartExplorerView extends ItemView { const previousOrder = this.manualOrderUndoStack.pop(); if (!previousOrder) return; this.plugin.settings.manualOrder = previousOrder; - this.buildManualOrderIndex(); + this.initializeManualOrder(this.fileIndex.getAll()); + this.manualOrderNeedsReconcile = false; this.renderList(); this.scheduleSaveOrder(); this.updateManualOrderControls(); diff --git a/src/explorer/__tests__/SmartExplorerView.integration.test.ts b/src/explorer/__tests__/SmartExplorerView.integration.test.ts index fa63068..294bd90 100644 --- a/src/explorer/__tests__/SmartExplorerView.integration.test.ts +++ b/src/explorer/__tests__/SmartExplorerView.integration.test.ts @@ -16,6 +16,9 @@ jest.mock( return null; } async saveData(_data: unknown) {} + eventRefs: Array<{ off: () => void }> = []; + registerEvent(ref: { off: () => void }) { this.eventRefs.push(ref); } + unloadEvents() { this.eventRefs.splice(0).forEach((ref) => ref.off()); } registerView() {} addRibbonIcon() {} addCommand() {} @@ -31,7 +34,9 @@ jest.mock( this.containerEl.append(document.createElement("div"), document.createElement("div")); } - registerEvent() {} + eventRefs: Array<{ off: () => void }> = []; + registerEvent(ref: { off: () => void }) { this.eventRefs.push(ref); } + unloadEvents() { this.eventRefs.splice(0).forEach((ref) => ref.off()); } }, Menu: class {}, PluginSettingTab: class {}, @@ -58,7 +63,6 @@ import { TFile, TFolder } from "obsidian"; (globalThis as typeof globalThis & { activeWindow: Window }).activeWindow = window; import SmartExplorerPlugin from "../../main"; -import { normalizeSettings } from "../../settings/settings-normalization"; import { SmartExplorerView } from "../SmartExplorerView"; function makeTFile(path: string): TFile & { path: string } { @@ -80,10 +84,19 @@ function makeTFolder(path: string): TFolder & { path: string } { return folder; } -function makeHarness() { +async function makeHarness() { const files = new Map>(); const folders = new Set(); - const vaultHandlers: Record void> = {}; + const vaultHandlers = new Map void>>(); + const onVault = (name: string, cb: (file: any, oldPath?: string) => void) => { + const handlers = vaultHandlers.get(name) ?? []; + handlers.push(cb); + vaultHandlers.set(name, handlers); + return { off: () => { handlers.splice(handlers.indexOf(cb), 1); } }; + }; + const emitVault = (name: string, file: unknown, oldPath?: string) => { + for (const cb of [...(vaultHandlers.get(name) ?? [])]) cb(file, oldPath); + }; const workspaceHandlers: Record void> = {}; const workspace: any = { @@ -92,6 +105,7 @@ function makeHarness() { getLeavesOfType: () => [], on: (name: string, cb: (file: unknown) => void) => { workspaceHandlers[name] = cb; + return { off: () => { if (workspaceHandlers[name] === cb) delete workspaceHandlers[name]; } }; }, }; const app = { @@ -102,31 +116,41 @@ function makeHarness() { ...Array.from(folders).map((path) => makeTFolder(path)), ], getAbstractFileByPath: (path: string) => files.get(path) ?? null, - on: (name: string, cb: (file: unknown, oldPath?: string) => void) => { - vaultHandlers[name] = cb; - }, + on: onVault, }, metadataCache: null, workspace, }; const plugin = new SmartExplorerPlugin(app as never, { id: "test" } as never); - plugin.settings = normalizeSettings(null); + await plugin.onload(); plugin.saveData = jest.fn(async () => {}); - const view = new SmartExplorerView({ app } as never, plugin as never) as any; - workspace.getLeavesOfType = () => [{ view }]; - const container = view.containerEl.children[1] as HTMLElement; - document.body.appendChild(container); - view.renderShell(container); - view.fileIndex.build(); - view.renderList(); - view.registerVaultEvents(); + const views: any[] = []; + workspace.getLeavesOfType = () => views.map((view) => ({ view })); + const openView = () => { + const view = new SmartExplorerView({ app } as never, plugin as never) as any; + views.push(view); + const container = view.containerEl.children[1] as HTMLElement; + document.body.appendChild(container); + view.renderShell(container); + view.fileIndex.build(); + view.renderList(); + view.registerVaultEvents(); + return view; + }; + const closeView = async (view: any) => { + await view.onClose(); + view.unloadEvents(); // Component event cleanup follows ItemView.onClose in the host. + views.splice(views.indexOf(view), 1); + }; + const view = openView(); + const container = document.body.lastElementChild as HTMLElement; const notices = (jest.requireMock("obsidian") as { __notices: string[] }).__notices; return { - view, plugin, container, files, folders, notices, workspace, workspaceHandlers, vaultHandlers, + view, plugin, container, files, folders, notices, workspace, workspaceHandlers, vaultHandlers, emitVault, openView, closeView, add(path: string) { const file = makeTFile(path); files.set(path, file); @@ -150,8 +174,8 @@ describe("SmartExplorerView lifecycle integration", () => { document.body.innerHTML = ""; }); - it("create file grows the index and refreshes the debounced DOM count", () => { - const harness = makeHarness(); + it("create file grows the index and refreshes the debounced DOM count", async () => { + const harness = await makeHarness(); harness.view.viewMode = "list"; harness.add("existing.md"); harness.view.fileIndex.build(); @@ -159,7 +183,7 @@ describe("SmartExplorerView lifecycle integration", () => { const countBefore = harness.container.querySelector(".smart-explorer-file-count")!.textContent; const file = harness.add("notes/created.md"); - harness.vaultHandlers.create!(file); + harness.emitVault("create", file); jest.advanceTimersByTime(300); expect(countBefore).toBe("1 file"); @@ -167,11 +191,11 @@ describe("SmartExplorerView lifecycle integration", () => { expect(harness.container.querySelector('[data-path="notes/created.md"]')).not.toBeNull(); }); - it("delete folder removes every child from the index and the DOM", () => { - const harness = makeHarness(); + it("delete folder removes every child from the index and the DOM", async () => { + const harness = await makeHarness(); for (const path of ["keep.md", "gone/a.md", "gone/nested/b.md"]) { const file = harness.add(path); - harness.vaultHandlers.create!(file); + harness.emitVault("create", file); jest.advanceTimersByTime(300); } expect(harness.view.fileIndex.getAll()).toHaveLength(3); @@ -179,7 +203,7 @@ describe("SmartExplorerView lifecycle integration", () => { harness.remove("gone/a.md"); harness.remove("gone/nested/b.md"); - harness.vaultHandlers.delete!(makeTFolder("gone")); + harness.emitVault("delete", makeTFolder("gone")); jest.advanceTimersByTime(300); expect(harness.view.fileIndex.getAll().map((record: any) => record.path)).toEqual(["keep.md"]); @@ -188,15 +212,20 @@ describe("SmartExplorerView lifecycle integration", () => { expect(harness.view.selectedPath).toBeNull(); }); - it("rename folder rewrites child paths and manual order", () => { - const harness = makeHarness(); + it("rename folder rewrites child paths and manual order", async () => { + const harness = await makeHarness(); harness.plugin.settings.manualOrder = ["keep.md", "old/a.md", "old/nested/b.md"]; for (const path of ["keep.md", "old/a.md", "old/nested/b.md"]) { harness.add(path); } harness.view.fileIndex.build(); harness.view.selectedPath = "old/nested/b.md"; - harness.vaultHandlers.rename!(makeTFolder("new"), "old"); + harness.remove("old/a.md"); + harness.remove("old/nested/b.md"); + harness.folders.delete("old"); + harness.add("new/a.md"); + harness.add("new/nested/b.md"); + harness.emitVault("rename", makeTFolder("new"), "old"); jest.advanceTimersByTime(300); expect(harness.plugin.settings.manualOrder).toEqual(["keep.md", "new/a.md", "new/nested/b.md"]); @@ -204,19 +233,19 @@ describe("SmartExplorerView lifecycle integration", () => { expect(harness.view.selectedPath).toBe("new/nested/b.md"); }); - it("coalesces an event burst into one render", () => { - const harness = makeHarness(); + it("coalesces an event burst into one render", async () => { + const harness = await makeHarness(); const renderSpy = jest.spyOn(harness.view, "renderList"); for (let index = 0; index < 5; index++) { - harness.vaultHandlers.create!(harness.add(`burst-${index}.md`)); + harness.emitVault("create", harness.add(`burst-${index}.md`)); } jest.advanceTimersByTime(300); expect(renderSpy).toHaveBeenCalledTimes(1); }); - it("refreshes an open view after a hidden-extension settings change", () => { - const harness = makeHarness(); + it("refreshes an open view after a hidden-extension settings change", async () => { + const harness = await makeHarness(); harness.add("a.md"); harness.add("b.pdf"); harness.view.fileIndex.build(); @@ -231,7 +260,7 @@ describe("SmartExplorerView lifecycle integration", () => { }); it("shows a Notice containing the error when opening a file fails", async () => { - const harness = makeHarness(); + const harness = await makeHarness(); const file = harness.add("broken.md"); harness.view.app = { ...harness.view.app, @@ -256,7 +285,7 @@ describe("SmartExplorerView lifecycle integration", () => { }); it("reports Electron shell failures instead of rejecting or throwing", async () => { - const harness = makeHarness(); + const harness = await makeHarness(); (harness.view.app.vault as any).adapter = { getBasePath: () => "/vault" }; const originalRequire = (window as Window & { require?: unknown }).require; (window as Window & { require?: unknown }).require = () => ({ @@ -283,7 +312,7 @@ describe("SmartExplorerView lifecycle integration", () => { }); it("resolves a pending manual-order save before close completes", async () => { - const harness = makeHarness(); + const harness = await makeHarness(); harness.view.plugin.settings.manualOrder = ["a.md"]; harness.view.scheduleSaveOrder(); expect(harness.view.saveOrderTimeout).not.toBeNull(); @@ -303,3 +332,112 @@ describe("SmartExplorerView lifecycle integration", () => { expect(closed).toBe(true); }); }); + + +describe("manual order structural event integration", () => { + beforeEach(() => { jest.useFakeTimers(); }); + afterEach(() => { jest.useRealTimers(); document.body.innerHTML = ""; }); + + it.each(["rename", "create", "delete", "folder"])("Undo and drag survive %s before and after rebuild", async (event) => { + for (const waitForRebuild of [false, true]) { + const h = await makeHarness(); + const initial = event === "folder" ? ["old/a.md", "old/b.md", "z.md"] : ["a.md", "b.md", "c.md"]; + initial.forEach((path) => h.add(path)); + h.view.fileIndex.build(); + h.view.query.sort = "manual"; + h.plugin.settings.manualOrder = [...initial]; + h.view.renderList(); + h.view.handleManualReorder(initial[0], 2, h.view.currentSections); + let expected = [...initial]; + if (event === "rename") { + h.remove("a.md"); + h.emitVault("rename", h.add("renamed.md"), "a.md"); + expected[0] = "renamed.md"; + } else if (event === "create") { + h.emitVault("create", h.add("new.md")); + expected.push("new.md"); + } else if (event === "delete") { + const file = h.files.get("a.md"); + h.remove("a.md"); + h.emitVault("delete", file); + expected = ["b.md", "c.md"]; + } else { + for (const path of initial.slice(0, 2)) { h.remove(path); h.add(path.replace("old/", "new/")); } + h.folders.delete("old"); + h.emitVault("rename", makeTFolder("new"), "old"); + expected = ["new/a.md", "new/b.md", "z.md"]; + } + if (waitForRebuild) jest.advanceTimersByTime(300); + h.view.undoManualReorder(); + expect(h.plugin.settings.manualOrder).toEqual(expected); + const dragPath = event === "create" ? "new.md" : expected[0]; + h.view.handleManualReorder(dragPath, event === "create" ? 0 : expected.length, h.view.currentSections); + expect(h.plugin.settings.manualOrder).not.toEqual(expected); + jest.advanceTimersByTime(500); + await h.plugin.flushSettings(); + const saved = (h.plugin.saveData as jest.Mock).mock.calls.slice(-1)[0][0].manualOrder; + expect([...saved].sort()).toEqual([...h.files.keys()].sort()); + expect(new Set(saved).size).toBe(saved.length); + await h.closeView(h.view); + } + }); + + it("pending save timers in two panes retain paths migrated by the plugin", async () => { + const h = await makeHarness(); + h.add("a.md"); h.add("b.md"); + h.plugin.settings.manualOrder = ["a.md", "b.md"]; + h.view.fileIndex.build(); + const second = h.openView(); + for (const view of [h.view, second]) { + view.query.sort = "manual"; + view.renderList(); + view.scheduleSaveOrder(); + } + jest.advanceTimersByTime(450); + h.remove("a.md"); + h.emitVault("rename", h.add("renamed.md"), "a.md"); + jest.advanceTimersByTime(50); // Saves run before the 300ms redraw. + await h.plugin.flushSettings(); + expect(h.plugin.settings.manualOrder).toEqual(["renamed.md", "b.md"]); + for (const [snapshot] of (h.plugin.saveData as jest.Mock).mock.calls) { + expect(snapshot.manualOrder).toEqual(["renamed.md", "b.md"]); + } + await h.closeView(h.view); + await h.closeView(second); + }); + + it("migrates both panes, saves once and continues tracking after every pane closes", async () => { + const h = await makeHarness(); + h.add("old/a.md"); h.add("b.md"); + h.plugin.settings.manualOrder = ["b.md", "old/a.md"]; + h.view.fileIndex.build(); + const second = h.openView(); + for (const view of [h.view, second]) { + view.query.sort = "manual"; + view.renderList(); + view.manualOrderUndoStack = [["old/a.md", "b.md"]]; + } + (h.plugin.saveData as jest.Mock).mockClear(); + h.remove("old/a.md"); h.add("new/a.md"); + h.emitVault("rename", makeTFolder("new"), "old"); + jest.advanceTimersByTime(500); + await h.plugin.flushSettings(); + expect(h.plugin.saveData).toHaveBeenCalledTimes(1); + for (const view of [h.view, second]) { + expect(view.manualOrderUndoStack).toEqual([["new/a.md", "b.md"]]); + expect(view.fileIndex.get("new/a.md")).toBeDefined(); + await h.closeView(view); + } + expect(h.vaultHandlers.get("rename")).toHaveLength(1); + h.remove("new/a.md"); + h.emitVault("rename", h.add("renamed.md"), "new/a.md"); + await h.plugin.flushSettings(); + const reopened = h.openView(); + expect(h.plugin.settings.manualOrder).toEqual(["b.md", "renamed.md"]); + expect(h.vaultHandlers.get("rename")).toHaveLength(2); + await h.closeView(reopened); + expect(h.vaultHandlers.get("rename")).toHaveLength(1); + (h.plugin as any).unloadEvents(); + expect(h.vaultHandlers.get("rename")).toHaveLength(0); + }); +}); diff --git a/src/explorer/__tests__/SmartExplorerView.test.ts b/src/explorer/__tests__/SmartExplorerView.test.ts index f55e031..e9b7a2a 100644 --- a/src/explorer/__tests__/SmartExplorerView.test.ts +++ b/src/explorer/__tests__/SmartExplorerView.test.ts @@ -4,7 +4,7 @@ jest.mock( ItemView: class {}, Menu: class {}, Modal: class {}, - Notice: class {}, + Notice: jest.fn(), Platform: { isMobile: false }, Setting: class {}, setIcon: jest.fn(), @@ -15,8 +15,14 @@ jest.mock( { virtual: true }, ); +import { Notice, TFile, TFolder } from "obsidian"; +import { reorderManualOrder } from "../manualOrder"; import { SmartExplorerView } from "../SmartExplorerView"; +beforeEach(() => { + jest.mocked(Notice).mockClear(); +}); + function makeBareView(order: string[]) { const view = Object.create(SmartExplorerView.prototype) as any; view.plugin = { settings: { manualOrder: order } }; @@ -26,25 +32,12 @@ function makeBareView(order: string[]) { } describe("SmartExplorerView manual-order state", () => { - it("updates the order index and schedules a save after rename", () => { - const view = makeBareView(["a.md", "old/x.md", "b.md"]); - - view.updateManualOrderAfterRename("old", "new"); - - expect(view.plugin.settings.manualOrder).toEqual([ - "a.md", - "new/x.md", - "b.md", - ]); - expect(view.buildManualOrderIndex).toHaveBeenCalledTimes(1); - expect(view.scheduleSaveOrder).toHaveBeenCalledTimes(1); - }); - - it("does not schedule a save when no ordered path changed", () => { - const view = makeBareView(["a.md", "b.md"]); - - view.updateManualOrderAfterRename("missing", "new"); - + it("migrates every Undo snapshot without changing or saving shared order", () => { + const view = makeBareView(["new/a.md", "b.md"]); + view.manualOrderUndoStack = [["old/a.md", "b.md"], ["b.md", "old/a.md"]]; + view.updateManualOrderUndoAfterRename("old", "new"); + expect(view.manualOrderUndoStack).toEqual([["new/a.md", "b.md"], ["b.md", "new/a.md"]]); + expect(view.plugin.settings.manualOrder).toEqual(["new/a.md", "b.md"]); expect(view.scheduleSaveOrder).not.toHaveBeenCalled(); }); @@ -190,3 +183,78 @@ describe("SmartExplorerView reveal state", () => { }); }); + + +describe("rename API contract", () => { + it.each([TFile, TFolder])("uses FileManager for %p", async (Kind) => { + const isFile = Kind === TFile; + const file = Object.assign(new Kind(), { path: isFile ? "notes/old.md" : "notes/old", basename: "old", extension: "md" }); + const view = Object.create(SmartExplorerView.prototype) as any; + view.app = { + vault: { getAbstractFileByPath: (path: string) => path === file.path ? file : null, rename: jest.fn() }, + fileManager: { renameFile: jest.fn().mockResolvedValue(undefined) }, + }; + view.renderList = jest.fn(); + await view.renameItemToName(file.path, "new"); + expect(view.app.fileManager.renameFile).toHaveBeenCalledWith(file, isFile ? "notes/new.md" : "notes/new"); + expect(view.app.vault.rename).not.toHaveBeenCalled(); + expect(view.selectedPath).toBe(isFile ? "notes/new.md" : null); + expect(view.selectedFolderPath).toBe(isFile ? null : "notes/new"); + expect(Notice).not.toHaveBeenCalled(); + }); +}); + +describe("Undo after vault changes", () => { + it.each([ + ["create", ["a.md", "b.md"], ["a.md", "b.md", "c.md"]], + ["delete", ["a.md", "b.md", "c.md"], ["b.md", "c.md"]], + ["unchanged", ["a.md", "b.md"], ["a.md", "b.md"]], + ["hidden and filtered", ["a.md", "b.md"], ["a.md", "b.md", "c.png"]], + ])("reconciles %s against the full index", (_name, history, paths) => { + const records = (paths as string[]).map((path) => ({ path, basename: path.split(".")[0]!, extension: path.split(".")[1]!, parentPath: "", size: 0, ctime: 0, mtime: 0, isMarkdown: path.endsWith(".md") })); + const view = makeBareView([...(paths as string[])].reverse()); + view.plugin.settings.hiddenExtensions = ["png"]; + view.query = { sort: "manual", group: "none", searchText: "a", extension: "md", fileKind: "markdown", modifiedWithinDays: 1 }; + view.manualSeedSort = "name-asc"; + view.manualOrderUndoStack = [history]; + view.manualOrderNeedsReconcile = false; + view.fileIndex = { getAll: () => records }; + view.renderList = jest.fn(); + view.updateManualOrderControls = jest.fn(); + view.undoManualReorder(); + expect(view.plugin.settings.manualOrder).toEqual(paths); + const last = (paths as string[])[(paths as string[]).length - 1]!; + expect(reorderManualOrder(view.plugin.settings.manualOrder, last, 0, [{ id: "all", records }])[0]).toBe(last); + }); +}); + +describe("rename guards", () => { + it.each(["collision", "unchanged", "missing", "rejected"])("preserves inline state for %s", async (scenario) => { + const file = Object.assign(new TFile(), { path: "old.md", basename: "old", extension: "md" }); + const view = Object.create(SmartExplorerView.prototype) as any; + view.inlineEdit = { kind: "rename-file", path: "old.md", value: "new" }; + view.selectedPath = "old.md"; + view.renderList = jest.fn(); + view.cancelInlineEdit = jest.fn(); + view.app = { + vault: { getAbstractFileByPath: (path: string) => scenario === "missing" ? null : path === "old.md" ? file : scenario === "collision" ? new TFile() : null }, + fileManager: { renameFile: jest.fn().mockRejectedValue(new Error("disk full")) }, + }; + await expect(view.renameItemToName("old.md", scenario === "unchanged" ? "old" : "new")).resolves.toBeUndefined(); + expect(view.selectedPath).toBe("old.md"); + if (scenario === "rejected") { + expect(view.app.fileManager.renameFile).toHaveBeenCalledTimes(1); + expect(view.inlineEdit).not.toBeNull(); + expect(Notice).toHaveBeenCalledTimes(1); + expect(Notice).toHaveBeenCalledWith("Could not rename item: disk full"); + } else { + expect(view.app.fileManager.renameFile).not.toHaveBeenCalled(); + if (scenario === "collision") { + expect(Notice).toHaveBeenCalledTimes(1); + expect(Notice).toHaveBeenCalledWith("An item with that name already exists."); + } else { + expect(Notice).not.toHaveBeenCalled(); + } + } + }); +}); diff --git a/src/main.ts b/src/main.ts index a35c5b2..dc63284 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,3 +1,4 @@ +import { renameManualOrderPaths } from "./explorer/manualOrder"; import { Notice, Plugin } from "obsidian"; import { SMART_EXPLORER_VIEW_TYPE } from "./constants"; import { SmartExplorerView } from "./explorer/SmartExplorerView"; @@ -11,6 +12,14 @@ export default class SmartExplorerPlugin extends Plugin { async onload() { await this.loadSettings(); + this.registerEvent(this.app.vault.on("rename", (file, oldPath) => { + const order = this.settings.manualOrder; + const nextOrder = renameManualOrderPaths(order, oldPath, file.path); + if (nextOrder === order) return; + this.settings.manualOrder = nextOrder; + void this.saveSettingsWithNotice("Could not save manual order after rename"); + })); + this.registerView(SMART_EXPLORER_VIEW_TYPE, (leaf) => new SmartExplorerView(leaf, this)); this.addRibbonIcon("compass", "Smart explorer", () => { From 05a02148f9023692592b49979e2d549fdc2e6685 Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:07:55 +0800 Subject: [PATCH 2/5] fix: make large-vault fixtures visible to Obsidian --- scripts/__tests__/prepare-large-vault-fixture.test.mjs | 6 +++++- scripts/prepare-large-vault-fixture.mjs | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/scripts/__tests__/prepare-large-vault-fixture.test.mjs b/scripts/__tests__/prepare-large-vault-fixture.test.mjs index e72f562..f596444 100644 --- a/scripts/__tests__/prepare-large-vault-fixture.test.mjs +++ b/scripts/__tests__/prepare-large-vault-fixture.test.mjs @@ -25,6 +25,10 @@ test("missing --vault fails", () => { expectFailure(() => validateOptions(parsed({ files: "5000" })), "missing --vault"); }); +test("fixture content lives in a visible directory that Obsidian can index", () => { + assert.equal(path.basename(resolveFixturePath("/tmp/vault")), "smart-explorer-large-vault-fixture"); +}); + test("missing --files fails without --remove", () => { expectFailure(() => validateOptions(parsed({ vault: "/tmp/x" })), "missing --files"); }); @@ -66,7 +70,7 @@ test("a temp-directory fixture creates and removes exactly its own subtree", asy const fixture = resolveFixturePath(vault); const entries = await readdir(vault); - assert.ok(entries.includes(".smart-explorer-large-vault-fixture")); + assert.ok(entries.includes("smart-explorer-large-vault-fixture")); assert.ok(entries.includes("untouched.md")); const fixtureEntries = await readdir(fixture); diff --git a/scripts/prepare-large-vault-fixture.mjs b/scripts/prepare-large-vault-fixture.mjs index efbf7aa..49814db 100644 --- a/scripts/prepare-large-vault-fixture.mjs +++ b/scripts/prepare-large-vault-fixture.mjs @@ -2,7 +2,8 @@ /** * Guarded synthetic-fixture generator for large-vault testing. * - * May only create or delete `/.smart-explorer-large-vault-fixture`. + * May only create or delete `/smart-explorer-large-vault-fixture`. + * The content directory must be visible so Obsidian includes it in its index. * A marker file is written before any file generation; removal refuses to * run unless the directory name and marker both match, so an unmarked or * mistyped path can never be deleted. @@ -12,7 +13,7 @@ import { parseArgs } from "node:util"; import { mkdir, rm, writeFile, stat, readFile } from "node:fs/promises"; import path from "node:path"; -const FIXTURE_DIR_NAME = ".smart-explorer-large-vault-fixture"; +const FIXTURE_DIR_NAME = "smart-explorer-large-vault-fixture"; const MARKER_FILE_NAME = ".smart-explorer-fixture-marker"; const MIN_FILES = 100; const MAX_FILES = 50000; From daf77b0cf162b7054bbf1e810302137bca3506ce Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:07:59 +0800 Subject: [PATCH 3/5] test: verify legacy explorer settings migration --- .../__tests__/settings-normalization.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/settings/__tests__/settings-normalization.test.ts b/src/settings/__tests__/settings-normalization.test.ts index 8fd03be..621f34f 100644 --- a/src/settings/__tests__/settings-normalization.test.ts +++ b/src/settings/__tests__/settings-normalization.test.ts @@ -1,6 +1,26 @@ import { normalizeSettings } from "../settings-normalization"; describe("normalizeSettings", () => { + it("migrates the 0.5.4 schema without losing saved preferences or manual order", () => { + // Fields verified against tag 0.5.4:src/settings/settings.ts. + const saved = { + defaultSort: "manual", defaultGroup: "folder", + hiddenExtensions: ["png"], manualOrder: ["b.md", "a.md"], + }; + expect(normalizeSettings(saved)).toEqual({ ...saved, lastViewMode: "tree" }); + expect(saved.manualOrder).toEqual(["b.md", "a.md"]); + }); + + it("preserves the 0.6.1 schema including list mode across a save/load round trip", () => { + const saved = { + defaultSort: "manual", defaultGroup: "folder", lastViewMode: "list", + hiddenExtensions: ["png"], manualOrder: ["b.md", "a.md"], + }; + const loaded = normalizeSettings(saved); + expect(loaded).toEqual(saved); + expect(normalizeSettings(JSON.parse(JSON.stringify(loaded)))).toEqual(saved); + }); + it("falls back to defaults for corrupt enum values", () => { expect(normalizeSettings({ defaultSort: "random", From 9de90bdf08fe387360968d870fec8e4e77d4b234 Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:14:17 +0800 Subject: [PATCH 4/5] docs: record 1.0 readiness verification and release gates --- AGENTS.md | 19 +-- CLAUDE.md | 19 +-- README.md | 27 ++-- docs/release-checklist.md | 82 ++++++----- docs/release-notes/1.0.0.md | 29 ++++ ...-07-29-smart-explorer-reliability-fixes.md | 2 + ...-smart-explorer-product-ux-optimization.md | 2 + ...12-smart-explorer-1.0-release-readiness.md | 101 ++++++++------ docs/verification/1.0.0-readiness.md | 132 ++++++++++++++++++ 9 files changed, 305 insertions(+), 108 deletions(-) create mode 100644 docs/release-notes/1.0.0.md create mode 100644 docs/verification/1.0.0-readiness.md diff --git a/AGENTS.md b/AGENTS.md index d58208f..fc9b5ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ Obsidian plugin — alternative side-pane file explorer with tree/list browsing, sorting, grouping, filtering, and manual order. - Plugin ID: `smart-explorer` -- Current version: `0.5.4` +- Current version: `0.6.1` - Min Obsidian version: `1.7.2` ## Commands @@ -13,8 +13,9 @@ npm run dev # esbuild watch mode npm run build # tsc check + esbuild production npm test # jest with ts-jest (node + jsdom suites) npm run lint # eslint +npm run test:release # release-validator and workflow tests (node --test) npm run test:fixture # fixture-script safety tests (node --test) -npm run verify # lint + production build + all tests + fixture tests +npm run verify # lint + production build + Jest + release + fixture tests ``` ## Architecture @@ -57,20 +58,20 @@ src/explorer/__tests__/*.test.ts Unit/DOM/integration tests for explorer help scripts/prepare-large-vault-fixture.mjs Marker-protected synthetic fixture generator ``` -**List data flow:** `FileIndex.build()` → hidden-extension filter → `buildSections(records, query)` → filter → sort → group → direct render, or keyed windowed render via `VirtualList` above 200 rows +**List data flow:** `FileIndex.build()` → hidden-extension filter → `buildSections(records, query)` → filter → sort → group → direct render, or keyed windowed render via `VirtualList` above 200 rows for ungrouped, non-manual lists **Keyboard model:** the list container holds the single tab stop and DOM focus; the active row is tracked via `aria-activedescendant` (pinned across windowed renders). Selection highlight follows `workspace.file-open` without auto-reveal. **Tree data flow:** `FileIndex.build()` → hidden-extension filter → `buildTree(records, query)` → filter → folder tree sort → recursive tree render -**Manual sort flow:** Manual sort resolves to list mode, initializes `settings.manualOrder`, attaches `DragSortManager` to row handles, and persists reordered paths through plugin settings. +**Manual sort flow:** Manual sort resolves to ungrouped list mode, initializes `settings.manualOrder`, attaches `DragSortManager` to row handles, and persists reordered paths through plugin settings. The plugin owns one lifetime vault-rename listener that migrates shared manual-order paths even with no explorer panes open. Each view migrates its own Undo snapshots and reconciles Undo against its complete FileIndex. Undo reverses ordering only; it does not undo file operations. Renames while the plugin is disabled or Obsidian is not running cannot reliably preserve path-based order. ## Conventions - Sorters, groupers, filters, tree models, view-mode helpers, filter-state helpers, and manual-order helpers are pure functions — testable without Obsidian - FileIndex is the single source of truth for vault file data -- Vault events (create/delete/rename/modify) update FileIndex incrementally, debounced at 300ms -- No network requests. Vault writes are limited to explicit user actions: creating notes/folders and saving plugin settings/manual order. +- Vault events (create/delete/rename/modify) update FileIndex incrementally; view rebuilds are debounced at 300ms +- No network requests. Explicit user actions can create notes or folders, rename files or folders, and move items to the configured trash. Renaming follows Obsidian's internal-link update preference. Plugin settings and manual order are saved locally, including path maintenance after vault renames while the plugin is enabled. - Obsidian CSS variables for theming, prefixed with `.smart-explorer-` - Tests use Jest with ts-jest, `__tests__` subdirectory per module @@ -83,7 +84,7 @@ node scripts/prepare-large-vault-fixture.mjs --vault /Users/Roger/my-vault --fil node scripts/prepare-large-vault-fixture.mjs --vault /Users/Roger/my-vault --remove # remove ``` -The script only touches `/.smart-explorer-large-vault-fixture` and refuses to delete anything without its marker file. +The script only touches `/smart-explorer-large-vault-fixture` and refuses to delete anything without its marker file. ## Git workflow @@ -106,9 +107,9 @@ The script only touches `/.smart-explorer-large-vault-fixture` and refuse | Add group mode | `groupers.ts` + `types.ts` (GroupMode union) + `settings-helpers.ts` | | Add filter | `filters.ts` + `types.ts` (ExplorerQuery) + `SmartExplorerView.ts` (toolbar) | | Change tree view | `TreeModel.ts` / `treeFolderInfo.ts` + `SmartExplorerView.ts` | -| Change manual ordering | `manualOrder.ts` + `DragSortManager.ts` + `SmartExplorerView.ts` | +| Change manual ordering | `manualOrder.ts` + `DragSortManager.ts` + `SmartExplorerView.ts` + `main.ts` (shared rename tracking) | | Change create actions | `creationPath.ts` + `SmartExplorerView.ts` | | Change toolbar layout | `SmartExplorerView.ts` (renderToolbar) + `styles.css` | | Add settings | `settings.ts` + `settings-tab.ts` + `main.ts` (load/save) | | Fix rendering | `SmartExplorerView.ts` + `styles.css` | -| Add vault event handling | `SmartExplorerView.ts` (registerVaultEvents) | +| Add vault event handling | `SmartExplorerView.ts` (view index/UI events); `main.ts` (shared rename tracking) | diff --git a/CLAUDE.md b/CLAUDE.md index d58208f..fc9b5ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ Obsidian plugin — alternative side-pane file explorer with tree/list browsing, sorting, grouping, filtering, and manual order. - Plugin ID: `smart-explorer` -- Current version: `0.5.4` +- Current version: `0.6.1` - Min Obsidian version: `1.7.2` ## Commands @@ -13,8 +13,9 @@ npm run dev # esbuild watch mode npm run build # tsc check + esbuild production npm test # jest with ts-jest (node + jsdom suites) npm run lint # eslint +npm run test:release # release-validator and workflow tests (node --test) npm run test:fixture # fixture-script safety tests (node --test) -npm run verify # lint + production build + all tests + fixture tests +npm run verify # lint + production build + Jest + release + fixture tests ``` ## Architecture @@ -57,20 +58,20 @@ src/explorer/__tests__/*.test.ts Unit/DOM/integration tests for explorer help scripts/prepare-large-vault-fixture.mjs Marker-protected synthetic fixture generator ``` -**List data flow:** `FileIndex.build()` → hidden-extension filter → `buildSections(records, query)` → filter → sort → group → direct render, or keyed windowed render via `VirtualList` above 200 rows +**List data flow:** `FileIndex.build()` → hidden-extension filter → `buildSections(records, query)` → filter → sort → group → direct render, or keyed windowed render via `VirtualList` above 200 rows for ungrouped, non-manual lists **Keyboard model:** the list container holds the single tab stop and DOM focus; the active row is tracked via `aria-activedescendant` (pinned across windowed renders). Selection highlight follows `workspace.file-open` without auto-reveal. **Tree data flow:** `FileIndex.build()` → hidden-extension filter → `buildTree(records, query)` → filter → folder tree sort → recursive tree render -**Manual sort flow:** Manual sort resolves to list mode, initializes `settings.manualOrder`, attaches `DragSortManager` to row handles, and persists reordered paths through plugin settings. +**Manual sort flow:** Manual sort resolves to ungrouped list mode, initializes `settings.manualOrder`, attaches `DragSortManager` to row handles, and persists reordered paths through plugin settings. The plugin owns one lifetime vault-rename listener that migrates shared manual-order paths even with no explorer panes open. Each view migrates its own Undo snapshots and reconciles Undo against its complete FileIndex. Undo reverses ordering only; it does not undo file operations. Renames while the plugin is disabled or Obsidian is not running cannot reliably preserve path-based order. ## Conventions - Sorters, groupers, filters, tree models, view-mode helpers, filter-state helpers, and manual-order helpers are pure functions — testable without Obsidian - FileIndex is the single source of truth for vault file data -- Vault events (create/delete/rename/modify) update FileIndex incrementally, debounced at 300ms -- No network requests. Vault writes are limited to explicit user actions: creating notes/folders and saving plugin settings/manual order. +- Vault events (create/delete/rename/modify) update FileIndex incrementally; view rebuilds are debounced at 300ms +- No network requests. Explicit user actions can create notes or folders, rename files or folders, and move items to the configured trash. Renaming follows Obsidian's internal-link update preference. Plugin settings and manual order are saved locally, including path maintenance after vault renames while the plugin is enabled. - Obsidian CSS variables for theming, prefixed with `.smart-explorer-` - Tests use Jest with ts-jest, `__tests__` subdirectory per module @@ -83,7 +84,7 @@ node scripts/prepare-large-vault-fixture.mjs --vault /Users/Roger/my-vault --fil node scripts/prepare-large-vault-fixture.mjs --vault /Users/Roger/my-vault --remove # remove ``` -The script only touches `/.smart-explorer-large-vault-fixture` and refuses to delete anything without its marker file. +The script only touches `/smart-explorer-large-vault-fixture` and refuses to delete anything without its marker file. ## Git workflow @@ -106,9 +107,9 @@ The script only touches `/.smart-explorer-large-vault-fixture` and refuse | Add group mode | `groupers.ts` + `types.ts` (GroupMode union) + `settings-helpers.ts` | | Add filter | `filters.ts` + `types.ts` (ExplorerQuery) + `SmartExplorerView.ts` (toolbar) | | Change tree view | `TreeModel.ts` / `treeFolderInfo.ts` + `SmartExplorerView.ts` | -| Change manual ordering | `manualOrder.ts` + `DragSortManager.ts` + `SmartExplorerView.ts` | +| Change manual ordering | `manualOrder.ts` + `DragSortManager.ts` + `SmartExplorerView.ts` + `main.ts` (shared rename tracking) | | Change create actions | `creationPath.ts` + `SmartExplorerView.ts` | | Change toolbar layout | `SmartExplorerView.ts` (renderToolbar) + `styles.css` | | Add settings | `settings.ts` + `settings-tab.ts` + `main.ts` (load/save) | | Fix rendering | `SmartExplorerView.ts` + `styles.css` | -| Add vault event handling | `SmartExplorerView.ts` (registerVaultEvents) | +| Add vault event handling | `SmartExplorerView.ts` (view index/UI events); `main.ts` (shared rename tracking) | diff --git a/README.md b/README.md index 18ba5a9..db02743 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ Built for vaults with hundreds or thousands of notes where the default file tree | Category | Options | |----------|---------| -| **Browse** | Folder tree by default, with folder counts and compact hover details; closed folders render lazily and long flat lists use windowed rendering, so large vaults stay fast | +| **Browse** | Folder tree by default, with folder counts and compact hover details; closed folders render lazily and long ungrouped, non-manual flat lists use windowed rendering | | **Create** | Create notes and folders via toolbar, context menu, or command palette — with inline name editing | -| **Edit** | Rename files inline; extensions stay fixed so only the name changes | +| **Edit** | Rename files and folders inline (file extensions stay fixed); move items to the configured trash from the context menu | | **Sort** | Name (A-Z / Z-A), modified date, created date, extension, file size, manual drag order | | **Filter** | Search by name/path, extension, file kind (all / markdown / non-markdown / images), date range (1d / 7d / 30d) | | **View** | Tree/list toggle — the mode is remembered between sessions; Manual sort automatically uses list mode for direct drag-and-drop | @@ -22,7 +22,9 @@ Built for vaults with hundreds or thousands of notes where the default file tree ### Manual Drag-and-Drop Sorting -Switch to **Manual** sort mode to drag the handle beside a file and reorder it, or keep your hands on the keyboard and use `Alt+ArrowUp` / `Alt+ArrowDown` on the selected file. The starting order matches whatever sort you were viewing ("what you see is what you drag"), shown in a toolbar hint. Use **Undo** to revert the last reorder. The custom order is saved per vault, keeps new files draggable, and persists across sessions. Works on both desktop and mobile. +Switch to **Manual** sort mode to drag the handle beside a file and reorder it, or keep your hands on the keyboard and use `Alt+ArrowUp` / `Alt+ArrowDown` on the selected file. The starting order matches whatever sort you were viewing ("what you see is what you drag"), shown in a toolbar hint. Manual ordering uses an ungrouped list; tree and grouped manual ordering are not supported. Use **Undo** to revert the last reorder. Undo changes order only: it does not reverse creates, renames, or deletions. Renamed files retain their historical positions, deleted files stay removed, and new files remain sortable, including after Undo. The custom order is saved per vault and persists across sessions. + +Rename tracking continues while all Smart Explorer panes are closed, provided the plugin remains enabled. Renames made while the plugin is disabled or Obsidian is not running cannot reliably retain positions because order is stored by path. ## Installation @@ -61,21 +63,24 @@ Switch to **Manual** sort mode to drag the handle beside a file and reorder it, ## Compatibility - Obsidian ≥ 1.7.2 -- Desktop and mobile +- Desktop and mobile are declared supported; candidate-specific runtime verification is tracked in the [1.0 readiness report](docs/verification/1.0.0-readiness.md). +- The 1.0 candidate is not release-approved until required desktop, real iOS/Android, minimum-version, upgrade, accessibility, and performance gates pass. ## Privacy -No network requests. File writes only happen when you explicitly create a note or folder. +No network requests. Explicit user actions can create notes or folders, rename files or folders, and move items to the configured trash. Renaming follows Obsidian's internal-link update preference. Plugin settings and manual order are saved locally, including path maintenance after vault renames while the plugin is enabled. ## Development ```bash -npm install # install dependencies -npm run dev # watch mode -npm run build # type-check + production build -npm test # unit tests -npm run lint # eslint -npm run verify # lint + production build + all tests +npm install # install dependencies +npm run dev # watch mode +npm run build # type-check + production build +npm test # unit, DOM, and integration tests +npm run lint # eslint +npm run test:release # release-validator and workflow tests +npm run test:fixture # fixture-script safety tests +npm run verify # lint + production build + Jest + release + fixture tests ``` ## License diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 3035a4f..d9a4517 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -1,42 +1,54 @@ # Release Checklist +Record results against the exact candidate commit and asset hashes. For 1.0.0, use the [release-readiness plan](superpowers/plans/2026-09-12-smart-explorer-1.0-release-readiness.md) and [evidence report](verification/1.0.0-readiness.md). A mandatory FAIL or BLOCKED row prevents release promotion. Automated tests, emulation, and API typings do not replace native runtime acceptance. + ## Pre-release -- [ ] All tests pass: `npm test` -- [ ] Build succeeds: `npm run build` -- [ ] Version bumped in `package.json`, `manifest.json`, `versions.json` -- [ ] `manifest.json` version matches `versions.json` entry -- [ ] Changelog / release notes drafted -- [ ] No network requests in codebase (`grep -rn "fetch\|XMLHttpRequest" src/`) -- [ ] Vault writes are limited to explicit create-note/create-folder actions and plugin settings -- [ ] Large-vault check (optional but recommended before perf-touching releases): - - [ ] Create the synthetic fixture: `node scripts/prepare-large-vault-fixture.mjs --vault --files 5000` - - [ ] Closed tree shows folder summaries only; flat list stays smooth and bounded - - [ ] Remove the fixture afterwards: `node scripts/prepare-large-vault-fixture.mjs --vault --remove`; confirm the vault is otherwise untouched -- [ ] Plugin tested in Obsidian vault: - - [ ] Loads without console errors - - [ ] File list displays correctly - - [ ] Sort, group, filter controls work - - [ ] Tree/list toggle works - - [ ] New note and new folder actions create items in the expected folder - - [ ] Collapse all and reveal active file work in tree mode - - [ ] Manual drag handles and undo work in Manual sort mode - - [ ] Keyboard navigation works: Tab enters once, arrows move, ArrowRight/Left open/close folders, Enter opens files, Alt+Arrow reorders in Manual mode - - [ ] Settings persist after reload - - [ ] Vault events (create/delete/rename/modify) trigger reindex - -## Create Release - -- [ ] Create git tag: `git tag ` -- [ ] Push tag: `git push origin ` -- [ ] Confirm the release workflow created or updated the GitHub Release -- [ ] Confirm release assets are attached: - - [ ] `main.js` - - [ ] `manifest.json` - - [ ] `styles.css` +- [ ] `npm run verify` passes (lint, production build, Jest, release-validator/workflow tests, fixture safety tests). +- [ ] Record candidate commit, OS/app versions, and SHA-256 of `main.js`, `manifest.json`, and `styles.css`; map every acceptance result to those assets. +- [ ] Draft release notes describe actual behavior and tested compatibility; identify unresolved gates. +- [ ] Confirm no network requests; inspect relevant APIs with `rg -n 'fetch|XMLHttpRequest|requestUrl' src/` and review the results. +- [ ] Confirm write scope: explicit note/folder creation, file/folder rename through FileManager, configured trash, and local plugin settings/manual order including rename path maintenance while enabled. +- [ ] Basic native smoke checks pass: load without console errors; tree/list browsing; sort/group/filter; create in the expected folder; collapse/reveal; settings persistence; vault create/delete/rename/modify updates. + +## Mandatory 1.0 acceptance + +Complete the matrix below before the 1.0 metadata bump. For later releases, select runtime checks according to changed behavior; documentation-only patches do not require rerunning this entire matrix. Preserve previous evidence and explain which checks apply. + +- [ ] Rename files and folders inline with Obsidian's automatic link updates both enabled and disabled. Check wiki links, Markdown links, and embeds against native preference behavior; restore the preference. +- [ ] Verify invalid names, collisions, fixed file extensions, cancellation, Unicode/case-only names, missing targets, and surfaced rename/save errors. Verify trash follows host settings and cancellation leaves content intact. +- [ ] Reorder, then rename/create/delete, then Undo and drag again. Confirm all current files remain sortable, including after clearing filters/hidden extensions. Undo reverses ordering only. +- [ ] Close every explorer pane with the plugin enabled, rename an ordered file and folder in the native explorer, reopen and verify positions. Repeat reload and two-pane cases without duplicate persistence. +- [ ] Upgrade separately from installed 0.5.4 and 0.6.1 assets with captured settings and nonalphabetical order. Replace only assets, retain `data.json`, and verify preferences, rename/reorder, and reload. +- [ ] Fresh install with no `data.json` loads defaults and completes basic smoke checks. +- [ ] Run smoke checks on Obsidian 1.7.2 and the current stable app; record actual versions. Record which desktop operating systems were tested. +- [ ] Test actual iOS and Android devices: tree/list, touch controls, long-press/menu/drag versus scrolling, scroll during reorder, Undo, editing/cancel/collision, orientation/safe areas, persistence, and trash. Missing devices are BLOCKED. +- [ ] Verify narrow and wide panes in light/dark themes, distinguishable duplicate basenames, selection/focus visibility, filters, and active-file highlighting without unexpected reveal. +- [ ] Keyboard-only checks pass: one Tab entry, arrows/Home/End, folder expansion, Enter/Space, search/Escape, Alt+Arrow reorder, and Undo. +- [ ] Separately record real VoiceOver announcements for name, role, expanded state, position, and reorder result. +- [ ] Run the protected 5,000-file fixture in a real Obsidian test vault: + - [ ] Create with `node scripts/prepare-large-vault-fixture.mjs --vault --files 5000` and record baseline vault size/environment. Confirm `app.vault.getFiles()` indexes exactly 5,000 files beneath the visible `smart-explorer-large-vault-fixture/` folder before measuring; 5,000 files on disk alone do not establish a valid runtime fixture. + - [ ] Record three cold index runs and median; median is below 1,000ms without metadata-cache reads. + - [ ] Initial flat list is usable within 500ms, with fewer than 60 file rows plus at most one pinned active row at the recorded viewport. + - [ ] Scrolling keeps bounded rows without missing/duplicate rows or broken active descendants; closed tree branches mount no file descendants. + - [ ] Manual drag reaches and saves the intended position through scrolling without full-row geometry measurement on every pointer move. + - [ ] Record large expanded-folder and expand-all responsiveness/node counts without freeze or crash. Tree lazy mounting does not establish tree virtualization. + - [ ] Remove with `node scripts/prepare-large-vault-fixture.mjs --vault --remove`; confirm unrelated content is untouched. +- [ ] Restore acceptance settings/theme/test files and remove instrumentation. Evidence records observed outcomes, screenshots/timings where relevant, and all remaining blockers. + +## Release metadata and publication + +- [ ] All mandatory acceptance rows PASS; reliability changes are included in the candidate. +- [ ] Bump using `npm version --no-git-tag-version`; inspect `package.json`, `package-lock.json`, `manifest.json`, and `versions.json` for matching versions and verified minimum app version. +- [ ] Finalize current-version documentation and release notes. +- [ ] Run `npm run verify`, `node scripts/validate-release.mjs `, and `git diff --check`; record final asset hashes. +- [ ] Release PR is merged and CI `verify` passes on its commit; publication is explicitly authorized. +- [ ] Confirm the version tag does not already exist locally/remotely. Create `git tag ` on the verified merged commit and push with `git push origin `; use no `v` prefix and never move a released tag. +- [ ] Confirm the tag's release workflow succeeds and creates the GitHub Release. Do not run `gh release create` manually. +- [ ] Confirm `main.js`, `manifest.json`, and `styles.css` are attached and downloadable. ## Post-release -- [ ] Verify release assets are downloadable -- [ ] Install from release assets into a clean test vault -- [ ] Confirm plugin works on fresh install +- [ ] Download the three release assets into a new temporary directory; validate version/minimum app version, hashes, and workflow commit. +- [ ] Install those downloaded assets into a clean test vault and verify browse/search/create/rename/link-update/manual-order/reload. Local build checks do not substitute for artifact installation. +- [ ] Record release URL, tag commit, workflow result, asset verification, and installation observations through a follow-up documentation PR; do not amend the released tag. diff --git a/docs/release-notes/1.0.0.md b/docs/release-notes/1.0.0.md new file mode 100644 index 0000000..a7482a5 --- /dev/null +++ b/docs/release-notes/1.0.0.md @@ -0,0 +1,29 @@ +# Smart Explorer 1.0.0 — DRAFT + +Status: unreleased. Repository metadata remains at 0.6.1. This draft describes the candidate's intended stable behavior; it is not release approval. Required runtime acceptance remains incomplete. See the [readiness evidence](../verification/1.0.0-readiness.md) for observed results and blockers. + +## Stable feature set + +Smart Explorer provides a tree-first side-pane explorer with remembered tree/list views, name/path search, sorting, grouping, filters, note/folder creation, inline file/folder rename, and configured-trash actions. Keyboard navigation, manual drag/keyboard ordering, lazy closed-tree rendering, and windowed long ungrouped non-manual lists are part of the existing feature set. + +## Reliability changes + +- Inline rename uses Obsidian's FileManager so the host can apply its internal-link update preference. +- Manual-order paths follow file and folder renames while the plugin is enabled, including when every Smart Explorer pane is closed. +- Undo history follows renamed paths and reconciles with current vault contents. Deleted paths stay removed and newly created files remain sortable after Undo. + +## Compatibility and acceptance + +The declared minimum remains Obsidian 1.7.2, with desktop and mobile support declared. Candidate-specific native desktop, VoiceOver, 5,000-file performance, old-version upgrade, fresh-install, minimum-version, and real iOS/Android acceptance must be recorded before these notes are finalized. Automated checks do not establish those runtime results. No complete compatibility matrix is claimed by this draft. + +## Known limits + +- Manual ordering uses an ungrouped list; tree and grouped manual ordering are not supported. +- Undo reverses ordering, not file creation, rename, or deletion. +- Order is stored by path. Renames while the plugin is disabled or Obsidian is not running cannot reliably preserve the previous positions. +- Tree rendering lazily mounts closed branches; large expanded trees are not virtualized. +- Search matches names and paths; full-text search and bulk file management are outside this release's scope. + +## Privacy and local writes + +No network requests. Explicit user actions can create notes or folders, rename files or folders, and move items to the configured trash. Renaming follows Obsidian's internal-link update preference. Plugin settings and manual order are saved locally, including path maintenance after vault renames while the plugin is enabled. diff --git a/docs/superpowers/plans/2026-07-29-smart-explorer-reliability-fixes.md b/docs/superpowers/plans/2026-07-29-smart-explorer-reliability-fixes.md index c82afeb..27593a1 100644 --- a/docs/superpowers/plans/2026-07-29-smart-explorer-reliability-fixes.md +++ b/docs/superpowers/plans/2026-07-29-smart-explorer-reliability-fixes.md @@ -1,5 +1,7 @@ # Smart Explorer Reliability Fixes Implementation Plan +> **Status (2026-09-13):** This is a historical implementation plan. Its checkboxes are not the current delivery ledger and do not establish runtime acceptance. Follow the [1.0 release-readiness plan](2026-09-12-smart-explorer-1.0-release-readiness.md) and [candidate evidence report](../../verification/1.0.0-readiness.md) for current scope, results, and remaining gates. + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Fix all confirmed manual-order, search/reveal, tree-performance, CI, and release-safety problems without expanding Smart Explorer's product scope. diff --git a/docs/superpowers/plans/2026-08-22-smart-explorer-product-ux-optimization.md b/docs/superpowers/plans/2026-08-22-smart-explorer-product-ux-optimization.md index 2f847c0..67060bd 100644 --- a/docs/superpowers/plans/2026-08-22-smart-explorer-product-ux-optimization.md +++ b/docs/superpowers/plans/2026-08-22-smart-explorer-product-ux-optimization.md @@ -1,5 +1,7 @@ # Smart Explorer Product and UX Optimization Implementation Plan +> **Status (2026-09-13):** This is a historical implementation plan. Its checkboxes are not the current delivery ledger and do not establish runtime acceptance. Follow the [1.0 release-readiness plan](2026-09-12-smart-explorer-1.0-release-readiness.md) and [candidate evidence report](../../verification/1.0.0-readiness.md) for current scope, results, and remaining gates. + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make Smart Explorer fast, unambiguous, accessible, and reliable for large Obsidian vaults before adding narrowly scoped discovery features. diff --git a/docs/superpowers/plans/2026-09-12-smart-explorer-1.0-release-readiness.md b/docs/superpowers/plans/2026-09-12-smart-explorer-1.0-release-readiness.md index 9f68a06..6070c9e 100644 --- a/docs/superpowers/plans/2026-09-12-smart-explorer-1.0-release-readiness.md +++ b/docs/superpowers/plans/2026-09-12-smart-explorer-1.0-release-readiness.md @@ -10,6 +10,19 @@ --- + +## Execution status — 2026-09-13 + +Implementation is complete; release acceptance remains blocked. See [candidate evidence](../../verification/1.0.0-readiness.md) for exact runtime observations, asset hashes, cleanup, and remaining gates. + +- Tasks 1–4: completed. Core changes share one tightly coupled commit (`164b501`) because plugin ownership, view history, and integration-harness changes must be tested together. +- Task 5: desktop rename/link/Undo/no-pane/reload, targeted native creation/keyboard, and real 5,000-file performance checks completed; full gestures/keyboard/width matrix still pending. +- Task 6: schema regression, actual 0.5.4 and 0.6.1 upgrades, and no-data loading completed. Mobile, minimum-version, and final downloaded-release install gates remain blocked. +- Task 7: documentation and draft notes completed; version remains 0.6.1. +- Tasks 8–9: not started; prerequisites are not satisfied. No publication authorization is inferred. +- Evidence-driven adjustment: hidden fixture content was invisible to Obsidian (0 indexed files). The generator now uses `smart-explorer-large-vault-fixture`, with the marker guard retained (`05a0214`). Do not reuse the former hidden path for future performance acceptance. +- Local commits preserve the implementation and documentation boundaries; no implementation PR has been opened by this execution. + ## 1. Execution contract This document is executable without the preceding conversation. The default execution scope is implementation, automated verification, available local acceptance, documentation, and a reviewable delivery. Writing this plan does not itself authorize executing it, merging PRs, or publishing a release. Once instructed to execute, proceed through the authorized scope without requesting routine implementation decisions. If merge/tag publication is also explicitly authorized, continue through the final publication phase after all gates pass. @@ -73,7 +86,7 @@ Do not refactor the large view class or introduce a general event framework as p ## Task 1: Refresh baseline and establish evidence -- [ ] Read the repository rules and inspect the branch/worktree. +- [x] Read the repository rules and inspect the branch/worktree. ```bash git status --short @@ -82,10 +95,10 @@ git log -1 --format='%H %s' cat package.json manifest.json ``` -- [ ] Create branch `fix/1.0-order-reliability` from current main after fetching and inspecting divergence. Do not reset or overwrite existing changes. If this branch already exists, inspect and resume it rather than recreate it. -- [ ] Run `npm ci` when dependencies are absent or the lockfile/environment changed, then `npm run verify`. Record exit codes and counts. A dependency/network failure is an environment blocker, not a regression result. -- [ ] Create `docs/verification/1.0.0-readiness.md` with these sections: candidate commit and asset hashes; environment versions; automated checks; bug reproductions; desktop matrix; mobile matrix; compatibility; upgrade/install; performance; accessibility; outstanding blockers; final gate decision. -- [ ] Use this row schema for all acceptance observations: +- [x] Create branch `fix/1.0-order-reliability` from current main after fetching and inspecting divergence. Do not reset or overwrite existing changes. If this branch already exists, inspect and resume it rather than recreate it. +- [x] Run `npm ci` when dependencies are absent or the lockfile/environment changed, then `npm run verify`. Record exit codes and counts. A dependency/network failure is an environment blocker, not a regression result. +- [x] Create `docs/verification/1.0.0-readiness.md` with these sections: candidate commit and asset hashes; environment versions; automated checks; bug reproductions; desktop matrix; mobile matrix; compatibility; upgrade/install; performance; accessibility; outstanding blockers; final gate decision. +- [x] Use this row schema for all acceptance observations: ```markdown | ID | Candidate | Environment | Action/input | Expected | Observed | Evidence | Status | @@ -98,7 +111,7 @@ Record unavailable checks as BLOCKED with the specific missing device/version/ac **Files:** `src/explorer/SmartExplorerView.ts`, `src/explorer/__tests__/SmartExplorerView.test.ts`. -- [ ] Add `TFile` to the existing test imports and add this regression in the existing mocked-Obsidian test file: +- [x] Add `TFile` to the existing test imports and add this regression in the existing mocked-Obsidian test file: ```ts it("renames through FileManager so host link preferences are respected", async () => { @@ -121,15 +134,15 @@ it("renames through FileManager so host link preferences are respected", async ( }); ``` -- [ ] Run `npm test -- --runInBand src/explorer/__tests__/SmartExplorerView.test.ts`; confirm the new test fails because FileManager was not called. -- [ ] In `renameItemToName`, replace only the mutation call, retaining collision checks, extension preservation, success selection, and Notice error handling: +- [x] Run `npm test -- --runInBand src/explorer/__tests__/SmartExplorerView.test.ts`; confirm the new test fails because FileManager was not called. +- [x] In `renameItemToName`, replace only the mutation call, retaining collision checks, extension preservation, success selection, and Notice error handling: ```ts await this.app.fileManager.renameFile(file, nextPath); ``` -- [ ] Extend the regression table with folder rename (`old/x.md` remains under renamed folder), collision (neither API called), unchanged basename (no mutation), and rejected FileManager promise (Notice, no success selection). Use `TFolder` from the same mock for folder identity. A mock cannot establish that actual backlinks changed; reserve that assertion for Task 5. -- [ ] Run the focused test file and `npm run build`. Commit as `fix: preserve internal links during explorer rename`. +- [x] Extend the regression table with folder rename (`old/x.md` remains under renamed folder), collision (neither API called), unchanged basename (no mutation), and rejected FileManager promise (Notice, no success selection). Use `TFolder` from the same mock for folder identity. A mock cannot establish that actual backlinks changed; reserve that assertion for Task 5. +- [x] Run the focused test file and `npm run build`. Commit as `fix: preserve internal links during explorer rename`. ## Task 3: Make shared rename maintenance independent of open panes @@ -139,8 +152,8 @@ await this.app.fileManager.renameFile(file, nextPath); The plugin owns exactly one transformation of `settings.manualOrder` per vault rename. Each view still updates its FileIndex, selected paths, expanded folders, reconcile flag, and its own Undo snapshots. The plugin listener must not synchronously render views before their indexes consume the same event. Reuse each view's existing scheduled rebuild. -- [ ] Add `registerEvent() {}` to the mock Plugin classes used by tests that call `onload`. Provide `app.vault.on` in those test apps. Inspect all `onload` tests with `rg -n 'onload|registerEvent' src/__tests__ src/explorer/__tests__`. -- [ ] In `main.test.ts`, add a callback-capture test with no leaves and saved order `['b.md', 'old/a.md', 'c.md']`. Call `onload`, emit rename with `{path:'new'}` and old path `old`, await `flushSettings`, and expect `['b.md','new/a.md','c.md']` in memory and the last `saveData` snapshot. Cover exact-file rename and unrelated-prefix `older/a.md` as separate cases. The current code must fail this no-pane test. +- [x] Add `registerEvent() {}` to the mock Plugin classes used by tests that call `onload`. Provide `app.vault.on` in those test apps. Inspect all `onload` tests with `rg -n 'onload|registerEvent' src/__tests__ src/explorer/__tests__`. +- [x] In `main.test.ts`, add a callback-capture test with no leaves and saved order `['b.md', 'old/a.md', 'c.md']`. Call `onload`, emit rename with `{path:'new'}` and old path `old`, await `flushSettings`, and expect `['b.md','new/a.md','c.md']` in memory and the last `saveData` snapshot. Cover exact-file rename and unrelated-prefix `older/a.md` as separate cases. The current code must fail this no-pane test. ```ts it("persists folder renames without an explorer pane", async () => { @@ -169,7 +182,7 @@ it("persists folder renames without an explorer pane", async () => { })); }); ``` -- [ ] Import `renameManualOrderPaths` in `src/main.ts`. After `await this.loadSettings()` and before view registration, add: +- [x] Import `renameManualOrderPaths` in `src/main.ts`. After `await this.loadSettings()` and before view registration, add: ```ts this.registerEvent(this.app.vault.on("rename", (file, oldPath) => { @@ -183,7 +196,7 @@ this.registerEvent(this.app.vault.on("rename", (file, oldPath) => { This intentionally uses the existing serialized immutable-snapshot save queue. An empty order stays empty. Do not introduce a separate timer, write directly through `saveData`, or infer file identity from content. -- [ ] Replace the view's `updateManualOrderAfterRename` method with a view-local method and replace its two call sites in the rename listener: +- [x] Replace the view's `updateManualOrderAfterRename` method with a view-local method and replace its two call sites in the rename listener: ```ts private updateManualOrderUndoAfterRename(oldPath: string, newPath: string) { @@ -195,7 +208,7 @@ private updateManualOrderUndoAfterRename(oldPath: string, newPath: string) { Delete the old shared mutation/save method. Move its shared-order tests to `main.test.ts`; retain view tests for history migration. Production views initialize their stack; bare test views must explicitly set `manualOrderUndoStack = []`. -- [ ] Correct the integration harness: its current `vaultHandlers[name] = cb` overwrites multiple listeners. Store an array per event, append in `on`, and dispatch all listeners from an `emitVault` helper. Use the same event argument shape as the real API. Register plugin listeners by calling and awaiting `plugin.onload()` before registering view listeners. Update `makeHarness` to async and await it at every test call site. Remove test-only preloading that `onload` now handles. +- [x] Correct the integration harness: its current `vaultHandlers[name] = cb` overwrites multiple listeners. Store an array per event, append in `on`, and dispatch all listeners from an `emitVault` helper. Use the same event argument shape as the real API. Register plugin listeners by calling and awaiting `plugin.onload()` before registering view listeners. Update `makeHarness` to async and await it at every test call site. Remove test-only preloading that `onload` now handles. ```ts const vaultHandlers = new Map void>>(); @@ -212,8 +225,8 @@ const emitVault = (name: string, file: unknown, oldPath?: string) => { Use `on: onVault` in the fake vault and replace direct `vaultHandlers.rename!(...)` calls with `emitVault('rename', ...)`. Where lifecycle cleanup is tested, implement fake `offref` and mock `registerEvent`/unload cleanup rather than claiming the no-op mock proves cleanup. -- [ ] Add tests for zero views; one and two open views; folder subtree rename; an unrelated rename causing no save; failed save producing Notice followed by a successful later rename/save. Verify both views consume the event and their histories migrate without a second transformation of shared order. Event tests must update the fake vault map to match the event before emission. -- [ ] Run `npm test -- --runInBand src/__tests__/main.test.ts src/explorer/__tests__/SmartExplorerView.test.ts src/explorer/__tests__/SmartExplorerView.integration.test.ts`, then `npm run build`. Commit as `fix: preserve manual order when explorer panes are closed`. +- [x] Add tests for zero views; one and two open views; folder subtree rename; an unrelated rename causing no save; failed save producing Notice followed by a successful later rename/save. Verify both views consume the event and their histories migrate without a second transformation of shared order. Event tests must update the fake vault map to match the event before emission. +- [x] Run `npm test -- --runInBand src/__tests__/main.test.ts src/explorer/__tests__/SmartExplorerView.test.ts src/explorer/__tests__/SmartExplorerView.integration.test.ts`, then `npm run build`. Commit as `fix: preserve manual order when explorer panes are closed`. ## Task 4: Reconcile Undo against current vault contents @@ -221,7 +234,7 @@ Use `on: onVault` in the fake vault and replace direct `vaultHandlers.rename!(.. Contract: Undo reverts ordering, never file-system operations. Renamed paths retain their historical position. Deleted paths cannot return. New and hidden files remain in the complete order and remain draggable after filters are cleared. New paths append using the existing seed sort. An unchanged vault still gets the normal one-step order reversal. -- [ ] Add table-driven regressions for these exact histories: +- [x] Add table-driven regressions for these exact histories: | Saved history / current order | Structural change | Expected after Undo | |---|---|---| @@ -262,8 +275,8 @@ it("keeps a newly created file draggable after Undo", () => { }); ``` -- [ ] Confirm create → Undo fails on current code; rename history migration from Task 3 may already make the rename case pass. After each Undo, call the real `reorderManualOrder` with the resulting array and full visible section, and prove a newly created/renamed file can change position. Add hidden-extension and active-filter cases with the full index still supplied. -- [ ] Replace `undoManualReorder` with: +- [x] Confirm create → Undo fails on current code; rename history migration from Task 3 may already make the rename case pass. After each Undo, call the real `reorderManualOrder` with the resulting array and full visible section, and prove a newly created/renamed file can change position. Add hidden-extension and active-filter cases with the full index still supplied. +- [x] Replace `undoManualReorder` with: ```ts private undoManualReorder() { @@ -281,22 +294,22 @@ private undoManualReorder() { `initializeManualOrder` already clears display filters for seed sorting, reconciles against the full index, and rebuilds the order index. Keep its behavior; the final scheduled save is necessary even when reconciliation returns the same reference. -- [ ] Add an integration regression: actual reorder → actual vault rename event → advance the 300ms rebuild → Undo → drag renamed row → advance the 500ms save → await `flushSettings`. Assert the saved array is a unique permutation of current file paths and contains no old name. Repeat create/delete cases, and an Undo before the scheduled rebuild (the index is updated synchronously by the event). -- [ ] Run the three focused files from Task 3 plus `src/explorer/__tests__/manualOrder.test.ts`. Run `npm run verify`. Record counts and candidate commit. Commit as `fix: reconcile manual order history with vault changes`. -- [ ] Review PR A for ownership duplication, stale-index pruning, unhandled save failures, and unintended schema changes. Run the desktop rename/order smoke cases from Task 5 before declaring A ready. If publishing PRs is authorized, open PR A with regression details and actual validation results. +- [x] Add an integration regression: actual reorder → actual vault rename event → advance the 300ms rebuild → Undo → drag renamed row → advance the 500ms save → await `flushSettings`. Assert the saved array is a unique permutation of current file paths and contains no old name. Repeat create/delete cases, and an Undo before the scheduled rebuild (the index is updated synchronously by the event). +- [x] Run the three focused files from Task 3 plus `src/explorer/__tests__/manualOrder.test.ts`. Run `npm run verify`. Record counts and candidate commit. Commit as `fix: reconcile manual order history with vault changes`. +- [x] Review PR A for ownership duplication, stale-index pruning, unhandled save failures, and unintended schema changes. Run the desktop rename/order smoke cases from Task 5 before declaring A ready. If publishing PRs is authorized, open PR A with regression details and actual validation results. ## Task 5: Desktop, accessibility, and performance acceptance **Output:** `docs/verification/1.0.0-readiness.md`. No production change unless a concrete regression is found; each found regression gets a failing test where feasible and a focused fix. -- [ ] Record OS, Obsidian app/installer version, theme, candidate commit, Node version, and SHA-256 of `main.js`, `manifest.json`, and `styles.css` (`shasum -a 256 main.js manifest.json styles.css`). Confirm `/Users/Roger/my-vault/.obsidian/plugins/smart-explorer` resolves to the candidate checkout before building. Do not silently replace an unrelated plugin installation. -- [ ] Keep acceptance files within a uniquely named test subtree. Record its original nonexistence and created paths. Never bulk-delete existing vault content; remove only the files created by this run. -- [ ] Create `se-1.0-acceptance/old/Target.md` and `se-1.0-acceptance/Links.md` with `[[old/Target]]`, `[Target](old/Target.md)`, and `![[old/Target]]`. With automatic link updates enabled, rename Target inline and then rename its parent folder. Inspect all three references and open their destinations. Repeat with automatic link updates disabled and verify native host preference behavior. Restore the original preference. +- [x] Record OS, Obsidian app/installer version, theme, candidate commit, Node version, and SHA-256 of `main.js`, `manifest.json`, and `styles.css` (`shasum -a 256 main.js manifest.json styles.css`). Confirm `/Users/Roger/my-vault/.obsidian/plugins/smart-explorer` resolves to the candidate checkout before building. Do not silently replace an unrelated plugin installation. +- [x] Keep acceptance files within a uniquely named test subtree. Record its original nonexistence and created paths. Never bulk-delete existing vault content; remove only the files created by this run. +- [x] Create `se-1.0-acceptance/old/Target.md` and `se-1.0-acceptance/Links.md` with `[[old/Target]]`, `[Target](old/Target.md)`, and `![[old/Target]]`. With automatic link updates enabled, rename Target inline and then rename its parent folder. Inspect all three references and open their destinations. Repeat with automatic link updates disabled and verify native host preference behavior. Restore the original preference. - [ ] Test create note/folder at root and selected folder; blank/invalid names; collision; Unicode names; fixed extension; cancel; missing target after external deletion; rejected rename/save surfaces a useful error. Verify delete uses the configured trash destination and cancellation leaves contents untouched. - [ ] Reproduce every Task 4 history through the UI. Close every Smart Explorer leaf while leaving the plugin enabled, rename a manually ordered file in the native explorer, reopen and verify position. Repeat folder rename, reload, and two open panes. Verify repeated open/close does not duplicate reactions. - [ ] At 300px and a wider pane, in light and dark themes, verify duplicate basenames show distinguishable paths, selected/focused rows are visible, filter controls remain usable, and switching active files highlights without unexpected scroll/reveal. - [ ] Keyboard-only: one Tab stop enters the composite; arrows/Home/End navigate; left/right collapse/expand folders; Enter/Space activate; search and Escape work; Alt+Arrow reorder and Undo work. With VoiceOver, record announced name, role, expanded state, position, and reorder result. Keyboard tests and VoiceOver are separate rows. -- [ ] Run the repository's protected fixture commands: +- [x] Run the repository's protected fixture commands: ```bash node scripts/prepare-large-vault-fixture.mjs --vault /Users/Roger/my-vault --files 5000 @@ -315,7 +328,7 @@ Measure three fresh `FileIndex.build()` operations in a real Obsidian session, n Use an additional dedicated flat-directory fixture only if the standard fixture does not exercise many siblings; create/remove it with the same ownership safeguards. Do not treat the fixture's own Node safety test as a runtime performance test. -- [ ] Remove the standard fixture using its marker guard and verify unrelated files remain: +- [x] Remove the standard fixture using its marker guard and verify unrelated files remain: ```bash node scripts/prepare-large-vault-fixture.mjs --vault /Users/Roger/my-vault --remove @@ -327,7 +340,7 @@ node scripts/prepare-large-vault-fixture.mjs --vault /Users/Roger/my-vault --rem **Files:** `src/settings/__tests__/settings-normalization.test.ts`, `docs/verification/1.0.0-readiness.md`. -- [ ] Add explicit normalization regression fixtures for the existing schema: +- [x] Add explicit normalization regression fixtures for the existing schema: ```ts const saved = { @@ -342,8 +355,8 @@ expect(normalizeSettings({ ...saved, manualOrder: ["b.md", "b.md", 7, "a.md"] }) Use existing imports and tests to avoid duplicate coverage. Add null/non-object load data only if absent. Run `npm test -- --runInBand src/settings/__tests__/settings-normalization.test.ts src/__tests__/main.test.ts`. -- [ ] Inspect actual 0.5.4 and 0.6.1 tagged settings definitions with `git show 0.5.4:src/settings/settings.ts` and `git show 0.6.1:src/settings/settings.ts`. If tags are unavailable, fetch them without changing the checkout. Adapt legacy fixtures to observed historical fields; do not label invented JSON as captured old-version data. -- [ ] In a separate test vault, install each old release, set a nonalphabetical manual order, hidden extensions, default sort/group, and view mode where supported. Record `data.json`, then replace only the three plugin assets with the candidate and reload. Verify preferences/order persist, missing settings get defaults, and subsequent rename/reorder/reload still work. Do not overwrite `data.json` during asset replacement. +- [x] Inspect actual 0.5.4 and 0.6.1 tagged settings definitions with `git show 0.5.4:src/settings/settings.ts` and `git show 0.6.1:src/settings/settings.ts`. If tags are unavailable, fetch them without changing the checkout. Adapt legacy fixtures to observed historical fields; do not label invented JSON as captured old-version data. +- [x] In a separate test vault, install each old release, set a nonalphabetical manual order, hidden extensions, default sort/group, and view mode where supported. Record `data.json`, then replace only the three plugin assets with the candidate and reload. Verify preferences/order persist, missing settings get defaults, and subsequent rename/reorder/reload still work. Do not overwrite `data.json` during asset replacement. - [ ] Test a fresh install with no `data.json`; ensure defaults load, no console errors occur, and basic operations work. This is a separate check from upgrade. - [ ] On Obsidian 1.7.2 and the current stable release, run load/browse/search/create/rename/trash/manual-order/reload smoke checks. Record actual versions; API package version alone proves neither. If 1.7.2 is unavailable, mark BLOCKED. If an API or runtime feature fails, use a narrow compatible approach where practical; otherwise propose and document a tested minimum-version increase before metadata publication. - [ ] On an actual iOS device and Android device, test tree/list, 44px-or-larger touch controls, long-press menu versus scrolling, long-press drag versus menu, scroll during reorder, Undo, soft-keyboard editing/cancel, collision feedback, portrait/landscape, safe areas, persistence and trash behavior. Record OS/app/device and exact observations. Emulation is useful for development but cannot mark these rows PASS. @@ -354,19 +367,19 @@ Use existing imports and tests to avoid duplicate coverage. Add null/non-object **Files:** `README.md`, `AGENTS.md`, `CLAUDE.md`, `docs/release-checklist.md`, both active historical plan documents, `docs/release-notes/1.0.0.md`, evidence report. -- [ ] Replace the README privacy paragraph and equivalent write-scope statements with this accurate scope: +- [x] Replace the README privacy paragraph and equivalent write-scope statements with this accurate scope: ```text No network requests. Explicit user actions can create notes or folders, rename files or folders, and move items to the configured trash. Renaming follows Obsidian's internal-link update preference. Plugin settings and manual order are saved locally, including path maintenance after vault renames while the plugin is enabled. ``` -- [ ] Document Manual as list-only/ungrouped; Undo reverses ordering, not file operations; new files remain sortable; no-pane rename tracking requires the plugin to remain enabled. Describe existing file/folder rename and trash actions without implying bulk file management. -- [ ] Update `AGENTS.md` and `CLAUDE.md` to the current version at this phase (do not claim 1.0 before Task 8), actual script list including release/fixture tests, and plugin-lifetime rename ownership. Preserve unrelated conventions. -- [ ] Update the release checklist to require `npm run verify`, candidate-specific evidence, old-version upgrade/fresh install, rename links, Undo after structural events, no-pane rename, mobile/minimum-version checks, performance results, and artifact-install verification. Make large-vault acceptance mandatory for 1.0; do not impose this full matrix on every later documentation-only patch. -- [ ] Add a status note at the top of the July reliability and August UX plans pointing to this plan and the evidence report. State that historical checkboxes are not the current delivery ledger. Mark only individually verified historical steps complete; do not blanket-check unexecuted manual acceptance. -- [ ] Write user-facing 1.0 release notes: stable scope; link-safe rename; resilient manual ordering; tested compatibility; known limitations. Avoid claims such as “all platforms tested” unless the evidence supports them. Refer to existing features as the stable feature set, not all newly introduced in 1.0. -- [ ] Search for drift with `rg -n '0\.5\.4|File writes|Vault writes|optional|npm test|1\.7\.2' README.md AGENTS.md CLAUDE.md docs/release-checklist.md docs/release-notes/1.0.0.md`. Preserve genuine historical references and update only stale current claims. -- [ ] Run `git diff --check`, inspect relative links, and reconcile every PASS with observed evidence. Commit as `docs: define stable explorer behavior and release acceptance`. PR B must clearly state any BLOCKED device checks; it must not imply release approval. +- [x] Document Manual as list-only/ungrouped; Undo reverses ordering, not file operations; new files remain sortable; no-pane rename tracking requires the plugin to remain enabled. Describe existing file/folder rename and trash actions without implying bulk file management. +- [x] Update `AGENTS.md` and `CLAUDE.md` to the current version at this phase (do not claim 1.0 before Task 8), actual script list including release/fixture tests, and plugin-lifetime rename ownership. Preserve unrelated conventions. +- [x] Update the release checklist to require `npm run verify`, candidate-specific evidence, old-version upgrade/fresh install, rename links, Undo after structural events, no-pane rename, mobile/minimum-version checks, performance results, and artifact-install verification. Make large-vault acceptance mandatory for 1.0; do not impose this full matrix on every later documentation-only patch. +- [x] Add a status note at the top of the July reliability and August UX plans pointing to this plan and the evidence report. State that historical checkboxes are not the current delivery ledger. Mark only individually verified historical steps complete; do not blanket-check unexecuted manual acceptance. +- [x] Write user-facing 1.0 release notes: stable scope; link-safe rename; resilient manual ordering; tested compatibility; known limitations. Avoid claims such as “all platforms tested” unless the evidence supports them. Refer to existing features as the stable feature set, not all newly introduced in 1.0. +- [x] Search for drift with `rg -n '0\.5\.4|File writes|Vault writes|optional|npm test|1\.7\.2' README.md AGENTS.md CLAUDE.md docs/release-checklist.md docs/release-notes/1.0.0.md`. Preserve genuine historical references and update only stale current claims. +- [x] Run `git diff --check`, inspect relative links, and reconcile every PASS with observed evidence. Commit as `docs: define stable explorer behavior and release acceptance`. PR B must clearly state any BLOCKED device checks; it must not imply release approval. ## Task 8: Prepare the 1.0.0 release candidate @@ -401,12 +414,12 @@ Prerequisite: explicit publication authorization, merged release PR, successful ## Final completion checklist -- [ ] File/folder rename uses FileManager and real link-update preferences were verified. -- [ ] Undo survives rename/create/delete and leaves every current file sortable. -- [ ] Shared manual-order paths stay correct with no explorer panes open while the plugin remains enabled. -- [ ] Automated gate passes on the delivered code; runtime and upgrade evidence names that code. +- [x] File/folder rename uses FileManager and real link-update preferences were verified. +- [x] Undo survives rename/create/delete and leaves every current file sortable. +- [x] Shared manual-order paths stay correct with no explorer panes open while the plugin remains enabled. +- [x] Automated gate passes on the delivered code; runtime and upgrade evidence names that code. - [ ] Required desktop, mobile, minimum-version, accessibility, and performance rows PASS. -- [ ] Documentation matches behavior and clearly states limitations. +- [x] Documentation matches behavior and clearly states limitations. - [ ] Release metadata is consistent; publication only occurred within authorization. - [ ] If published, downloaded assets were installed and verified. diff --git a/docs/verification/1.0.0-readiness.md b/docs/verification/1.0.0-readiness.md new file mode 100644 index 0000000..ad6e101 --- /dev/null +++ b/docs/verification/1.0.0-readiness.md @@ -0,0 +1,132 @@ +# 1.0.0 Readiness Evidence + +## Gate decision + +**Implementation complete; acceptance blocked.** No 1.0.0 metadata, tag, or release has been created. Mobile, minimum-version, VoiceOver, and the remaining desktop gesture checks below must pass before release promotion. + +Execution date: 2026-09-13. Branch: `fix/1.0-order-reliability`, based on `afe652caa9b4c6a4141c364ccdc01a9cc91cc717`. + +## Candidate and environment + +Runtime code is commit `164b501`; the visible-fixture correction is `05a0214`; settings compatibility tests are `daf77b0`. The final documentation commit does not change these assets. Metadata remains 0.6.1. + +| Asset | SHA-256 | +|---|---| +| main.js | `99332cae08282a3e745d939bffbb8086aebda1c1f1901f0161da8c75b469c9c9` | +| manifest.json | `c6061fbfbd40a98dd6f8dff3cff7f32267510bb013d9fb71f25e9515bcc38add` | +| styles.css | `b856e328992c7099e2a54ea9c83c4cc2fb29b6777287c8ab5a7ee41ca6c6d26b` | + +- macOS 26.6.2 (25G83), Node v24.16.0, installed Obsidian 1.13.7. +- The installed Obsidian version was observed, not assumed to be the latest stable release. +- Dedicated vault: `/Users/Roger/my-vault`; its plugin directory symlinks to this checkout. The candidate was explicitly disabled/enabled after building, and its loaded rename method was checked for the FileManager call. +- Runtime methods were invoked through the real Obsidian developer console; these are host integration checks, not mocks. Native clicks, typing, keyboard actions, and scrolling are identified separately below. +- Temporary upgrade vault: `/private/tmp/se-1.0-upgrade-20260913/from-0.5.4`. The same isolated vault was reused sequentially for both release paths; each old plugin version was verified in the running instance before it saved its test settings. + +## Automated verification and review + +| ID | Candidate | Environment | Action/input | Expected | Observed | Evidence | Status | +|---|---|---|---|---|---|---|---| +| BASELINE | afe652c | Node/macOS | npm run verify | All gates pass | 29 suites / 244 Jest tests; 6 release / 7 fixture tests; lint/build pass | Exit 0 | PASS | +| CORE-RED | Pre-fix | Jest | New rename/lifetime/Undo regressions | Reproduce missing behavior | 7 expected failures: FileManager calls, lifetime listener/history migration, stale Undo membership | Focused tests before production changes | PASS | +| CORE-GREEN | 164b501 | Jest | main, view unit/integration, manualOrder suites | No regressions | 4 suites / 61 tests pass; additional Notice/selection assertions pass | Focused run and type check | PASS | +| FIXTURE-RED | Pre-05a0214 | Node | Visible-path regression and owned-subtree test | Reject hidden content directory | 2 expected failures for leading dot in fixture path | Node test output | PASS | +| FINAL | daf77b0 | Node/macOS | npm run verify | All gates pass | 29 suites / 262 Jest tests; 6 release / 8 fixture tests; lint/build pass | Exit 0; no runtime code changed afterwards | PASS | +| REVIEW | Candidate diff | Source review | Spec compliance, then code-quality review | No actionable regression | Notice/success assertions strengthened; ownership/timer/Undo changes approved | Independent reviews completed | PASS | + +Legacy schema tests are regression coverage of existing correct normalization, not evidence of a newly fixed migration defect. Tagged `0.5.4:src/settings/settings.ts` lacks `lastViewMode`; tag 0.6.1 includes it. Fixtures were constructed from those definitions and actual runtime saves were checked separately. + +## Core desktop runtime matrix + +Candidate asset hashes above, Obsidian 1.13.7, macOS. + +| ID | Action/input | Expected | Observed | Evidence | Status | +|---|---|---|---|---|---| +| RENAME-LINKS | Rename Target through the actual inline-rename method with automatic updates enabled | Update Wiki, Markdown, and embedded links | Host reported 3 updated links in 1 file; content became `[[Renamed]]`, `[Renamed](Renamed.md)`, `![[Renamed]]` | Read source content through vault API after mutation | PASS | +| RENAME-FOLDER | Rename containing folder through the same method | Existing references resolve to renamed subtree | `getFirstLinkpathDest` resolved to `.../new/Renamed.md` | Real metadata cache and filesystem | PASS | +| LINK-PREFERENCE | Disable automatic updates, rename, choose native “Do not update” | Host preference/choice respected | Host displayed its update prompt; source content remained byte-for-byte unchanged; new path existed | Native confirmation plus before/after read; preference restored | PASS | +| UNDO-RENAME | Actual reorder → rename → Undo in real view | No stale old path; complete unique membership | oldAbsent/newPresent/unique/allFiles all true | Actual view and full 5,274-file index | PASS | +| KEYBOARD-REORDER | Native Alt+Up on renamed row after Undo | Reorder remains usable | Row moved from position 3 to 2; live message said “Moved AfterUndo.md to position 2 of 3.” | Native key and rendered rows | PASS | +| NO-PANES | Detach every `smart-explorer` leaf, rename file, flush | Preserve position and save new path | Pane count 0, positionPreserved true, saved true | Real plugin lifetime listener and loadData | PASS | +| NO-PANES-FOLDER | Rename parent with no panes, then disable/enable plugin and reopen | Migrate all children; retain saved order | 3 paths migrated, old prefix absent, complete order identical after reload | Actual plugin reinitialization | PASS | +| DELETE-CANCEL | Invoke delete on owned test directory, cancel | Files remain | Directory still available for second delete prompt | Native cancel | PASS | +| DELETE-TRASH | Confirm deletion of owned test directory | Configured trash, no unrelated deletion | `trashOption` was `system`; vault returned to 270 original files | Actual FileManager path and indexed paths | PASS | +| CREATE-ROOT | Click New note; enter Unicode name | Create and open new note | `验收-note.md` created and opened | Native toolbar/editing in isolated vault | PASS | +| CREATE-CHILD | Click New folder; select Nested; New note → Child | Create within selected folder | `Nested/Child.md` created and opened | Native toolbar and displayed breadcrumb | PASS | +| NARROW-PATHS | Two Dup.md notes; list search at 276px content width | Distinguish identical names | Parent labels `/` and `Nested` both present | Actual DOM and light-theme visual inspection | PASS | +| THEMES-SMOKE | Primary dark view and isolated light view | Readable controls and selection | Both visually inspected; selected row and controls visible | Native screenshots observed | PASS | + +These are targeted smoke checks. They do not establish the entire Task 5 UI matrix, exact 300px and wider-width coverage for every control, every validation-error path, or the complete keyboard model. Unit/DOM regressions cover collision, rejection Notice, extension preservation, structural event combinations and multiple panes; actual mouse/touch gestures remain separate. + +### Drag gesture limitation + +Two native automation drags did not reorder. A temporary capture listener observed only `dragstart` and `dragend`, with no `dragover` or `drop`. This does not prove a plugin drop-handler defect: the expected input never reached it. Listener instrumentation was removed. **Mouse drag/drop and drag-with-scroll acceptance remain BLOCKED on a gesture driver or manual pass that delivers the full event sequence.** Keyboard reorder passed; it is not a substitute for mouse/touch acceptance. + +## Performance + +The original generator created 5,000 disk files under `.smart-explorer-large-vault-fixture`, but real `app.vault.getFiles()` saw **zero** of them (274 total files including four owned notes). That fixture was removed using the original marker guard before changing the script. + +The generator now uses visible `smart-explorer-large-vault-fixture`, retaining its hidden marker and refusal to remove unmarked directories. Real Obsidian then indexed **5,000 fixture files**, **5,274 total**. Tests and current instructions use the visible root. Do not infer indexed count from disk count in future acceptance. + +| Measurement | Result | Boundary | Status | +|---|---|---|---| +| Three fresh FileIndex instances, build() | 6.7ms, 5.8ms, 3.9ms; median 5.8ms | New in-memory indexes over the already-loaded real vault; not cold OS disk/startup time | PASS | +| Metadata-cache reads during builds | 0 | Instrumented getFileCache restored in finally | PASS | +| Flat name-sorted ungrouped list usable render | 246ms | renderList through two animation frames; 352×586 content viewport | PASS | +| Initial list row nodes | 25 | Includes any pinned active row | PASS | +| Native scrolling | 35 row nodes at scrollTop 16,258px | Eight-page native scroll; paths changed correctly | PASS | +| Closed fixture tree | 0 mounted descendants | Active unrelated note can keep its own branch open | PASS | +| Expanded fixture tree | 5,000 file rows; 37.3ms measured expand call through next animation frame | All fixture branches; not a claim of tree virtualization or cold startup | PASS | + +The single-directory many-sibling fixture was not additionally exercised; the standard fixture has 100 folders. Full drag-with-scroll and maximum expanded-tree interaction remain unverified. A disk-cold Obsidian startup benchmark was not performed. + +Reproduction entry point in the actual desktop console after opening Smart Explorer: + +```js +const view = app.workspace.getLeavesOfType("smart-explorer")[0].view; +const indexedFixtureCount = app.vault.getFiles() + .filter(file => file.path.startsWith("smart-explorer-large-vault-fixture/")).length; +const samples = []; +for (let i = 0; i < 3; i++) { + const index = new view.fileIndex.constructor(app); + const start = performance.now(); + index.build(); + samples.push(performance.now() - start); +} +console.log({ indexedFixtureCount, samples }); +``` + +## Actual upgrade and no-data loading + +Downloaded the three assets from the project's official 0.5.4 and 0.6.1 GitHub releases. Installed them in the isolated temporary vault, enabled the old plugin, verified its runtime manifest version, saved non-default settings via that instance, disabled it, and replaced only `main.js`, `manifest.json`, and `styles.css` with candidate assets. `data.json` was preserved. Refreshed manifests, re-enabled, and inspected the actual view and saved settings. + +| Source | Before upgrade | Candidate result | Continued operations | Status | +|---|---|---|---|---| +| 0.5.4 | Manual/folder, hidden png, order b/a/c/hidden.png; no lastViewMode | Same preferences/order; lastViewMode defaulted to tree; visible rows b/a/c | Rename a→renamed; keyboard reorder; disable/enable retained renamed/b/c/hidden.png | PASS | +| 0.6.1 | Manual/folder, list mode, hidden png, order c/renamed/b/hidden.png | All fields preserved; rendered list c/renamed/b | Rename b→after-upgrade, reorder, reload retained c/after-upgrade/renamed/hidden.png | PASS | +| No data.json | Disabled plugin; moved saved data outside plugin folder; candidate assets unchanged | name-asc/none/tree, no hidden extensions, empty manual order; all four fixture files visible | Native Unicode note and nested folder/note creation passed | PASS | + +Local saved JSON evidence remains under `/private/tmp/se-1.0-upgrade-20260913/` (`saved-0.5.4.json`, `upgraded-0.5.4.json`, `saved-0.6.1.json`, `upgraded-0.6.1.json`). The separate pre-created `from-0.6.1` directory was unused; version 0.6.1 was tested sequentially in the already-isolated vault. No-data loading in a reused vault is not the final clean-vault installation test of downloaded 1.0.0 release assets; that remains gated on publication. + +## Cleanup + +- Visible fixture removed through the marker-protected script; earlier hidden fixture also removed before the path change. +- Four primary-vault acceptance notes removed through configured system trash. No trash was emptied. +- Runtime verification reported: 270 files; every original path present; zero extra indexed paths; saved plugin settings equal the pre-test snapshot; automatic-link setting restored; fixture absent. +- Primary test-vault workspace layout restored. Developer tools closed; foreground returned to the original MainVault window. +- Temporary upgrade vault closed and removed from the vault picker; its isolated files retained for inspection. A mistakenly selected parent-directory picker entry was closed/removed without editing its notes. +- No runtime instrumentation or test data entered the repository diff. + +## Remaining acceptance blockers + +| Gate | Status | What is still needed | +|---|---|---| +| iOS real device | BLOCKED | Actual device touch menu/drag/scroll, keyboard, safe areas, persistence and trash matrix | +| Android real device | BLOCKED | Same matrix on actual Android device | +| Obsidian 1.7.2 | BLOCKED | Compatible isolated installation and runtime smoke; API typing is insufficient | +| Latest stable version claim | BLOCKED | Verify current official stable version and test it if different from installed 1.13.7 | +| VoiceOver | BLOCKED | Spoken role/name/state/position and reorder-feedback acceptance; no speech evidence captured | +| Complete desktop input matrix | BLOCKED | Full keyboard-only navigation/Tab traversal, mouse drop with actual dragover/drop, drag-with-scroll, full width/error-case matrix | +| One large flat folder | BLOCKED | Additional owned many-sibling fixture and responsive interaction check | +| Published 1.0 assets | BLOCKED | Complete prior gates, authorized release, download and clean-vault installation verification | + +Windows/Linux were not available in this run; do not claim they were tested. These missing checks are release gates, not proof of a defect. Runtime code is ready for code review; **1.0.0 is not yet approved for publication**. From 0470f459910877f2445f52f21cea2c8f3f5bae7b Mon Sep 17 00:00:00 2001 From: Roger Deng <13251150+rogerdigital@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:30:19 +0800 Subject: [PATCH 5/5] docs: record confirmed runtime acceptance --- docs/release-notes/1.0.0.md | 2 +- ...12-smart-explorer-1.0-release-readiness.md | 8 +++---- docs/verification/1.0.0-readiness.md | 21 +++++++++++-------- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/docs/release-notes/1.0.0.md b/docs/release-notes/1.0.0.md index a7482a5..85c5acd 100644 --- a/docs/release-notes/1.0.0.md +++ b/docs/release-notes/1.0.0.md @@ -1,6 +1,6 @@ # Smart Explorer 1.0.0 — DRAFT -Status: unreleased. Repository metadata remains at 0.6.1. This draft describes the candidate's intended stable behavior; it is not release approval. Required runtime acceptance remains incomplete. See the [readiness evidence](../verification/1.0.0-readiness.md) for observed results and blockers. +Status: unreleased. Repository metadata remains at 0.6.1. This draft describes the candidate's intended stable behavior; it is not release approval. The user confirmed the outstanding mobile, minimum-version, VoiceOver, and keyboard/drag checks; remaining release checks are tracked separately. See the [readiness evidence](../verification/1.0.0-readiness.md) for observed results and blockers. ## Stable feature set diff --git a/docs/superpowers/plans/2026-09-12-smart-explorer-1.0-release-readiness.md b/docs/superpowers/plans/2026-09-12-smart-explorer-1.0-release-readiness.md index 6070c9e..54a4035 100644 --- a/docs/superpowers/plans/2026-09-12-smart-explorer-1.0-release-readiness.md +++ b/docs/superpowers/plans/2026-09-12-smart-explorer-1.0-release-readiness.md @@ -13,15 +13,15 @@ ## Execution status — 2026-09-13 -Implementation is complete; release acceptance remains blocked. See [candidate evidence](../../verification/1.0.0-readiness.md) for exact runtime observations, asset hashes, cleanup, and remaining gates. +Implementation is complete. The user confirmed the previously reported mobile, Obsidian 1.7.2, VoiceOver, and full keyboard/drag acceptance items. Other release checks remain tracked separately. See [candidate evidence](../../verification/1.0.0-readiness.md) for exact runtime observations, asset hashes, cleanup, and remaining gates. - Tasks 1–4: completed. Core changes share one tightly coupled commit (`164b501`) because plugin ownership, view history, and integration-harness changes must be tested together. -- Task 5: desktop rename/link/Undo/no-pane/reload, targeted native creation/keyboard, and real 5,000-file performance checks completed; full gestures/keyboard/width matrix still pending. -- Task 6: schema regression, actual 0.5.4 and 0.6.1 upgrades, and no-data loading completed. Mobile, minimum-version, and final downloaded-release install gates remain blocked. +- Task 5: desktop rename/link/Undo/no-pane/reload, targeted native creation/keyboard, and real 5,000-file performance checks completed; full gestures/keyboard and VoiceOver subsequently confirmed by the user; remaining width/error cases are tracked in the evidence. +- Task 6: schema regression, actual 0.5.4 and 0.6.1 upgrades, and no-data loading completed. Mobile and minimum-version checks subsequently confirmed by the user; final downloaded-release installation remains pending. - Task 7: documentation and draft notes completed; version remains 0.6.1. - Tasks 8–9: not started; prerequisites are not satisfied. No publication authorization is inferred. - Evidence-driven adjustment: hidden fixture content was invisible to Obsidian (0 indexed files). The generator now uses `smart-explorer-large-vault-fixture`, with the marker guard retained (`05a0214`). Do not reuse the former hidden path for future performance acceptance. -- Local commits preserve the implementation and documentation boundaries; no implementation PR has been opened by this execution. +- Implementation and documentation are delivered together on `fix/1.0-order-reliability` for the user-requested PR, without release metadata changes. ## 1. Execution contract diff --git a/docs/verification/1.0.0-readiness.md b/docs/verification/1.0.0-readiness.md index ad6e101..3be4d62 100644 --- a/docs/verification/1.0.0-readiness.md +++ b/docs/verification/1.0.0-readiness.md @@ -2,7 +2,7 @@ ## Gate decision -**Implementation complete; acceptance blocked.** No 1.0.0 metadata, tag, or release has been created. Mobile, minimum-version, VoiceOver, and the remaining desktop gesture checks below must pass before release promotion. +**Implementation complete; user-confirmed acceptance recorded; publication pending.** No 1.0.0 metadata, tag, or release has been created. The user confirmed completion of the previously reported iOS/Android, Obsidian 1.7.2, VoiceOver, and full keyboard/drag checks. Remaining release checks are tracked below. Execution date: 2026-09-13. Branch: `fix/1.0-order-reliability`, based on `afe652caa9b4c6a4141c364ccdc01a9cc91cc717`. @@ -59,7 +59,7 @@ These are targeted smoke checks. They do not establish the entire Task 5 UI matr ### Drag gesture limitation -Two native automation drags did not reorder. A temporary capture listener observed only `dragstart` and `dragend`, with no `dragover` or `drop`. This does not prove a plugin drop-handler defect: the expected input never reached it. Listener instrumentation was removed. **Mouse drag/drop and drag-with-scroll acceptance remain BLOCKED on a gesture driver or manual pass that delivers the full event sequence.** Keyboard reorder passed; it is not a substitute for mouse/touch acceptance. +Two native automation drags did not reorder. A temporary capture listener observed only `dragstart` and `dragend`, with no `dragover` or `drop`. This does not prove a plugin drop-handler defect: the expected input never reached it. Listener instrumentation was removed. **This run could not establish mouse drag/drop and drag-with-scroll acceptance. The user subsequently confirmed these checks passed; no additional captured event trace was supplied.** Keyboard reorder passed; it is not a substitute for mouse/touch acceptance. ## Performance @@ -116,16 +116,19 @@ Local saved JSON evidence remains under `/private/tmp/se-1.0-upgrade-20260913/` - Temporary upgrade vault closed and removed from the vault picker; its isolated files retained for inspection. A mistakenly selected parent-directory picker entry was closed/removed without editing its notes. - No runtime instrumentation or test data entered the repository diff. -## Remaining acceptance blockers +## Acceptance follow-up and remaining release checks -| Gate | Status | What is still needed | +The user subsequently confirmed that the outstanding items listed in the implementation handoff were verified: actual iOS/Android devices, Obsidian 1.7.2, VoiceOver, and full keyboard/drag acceptance. PASS (user-confirmed) records that confirmation separately from the directly observed results above. Device models, OS versions, screenshots, and detailed transcripts were not supplied. It does not imply verification of other cases that were only listed in this document. + +| Gate | Status | Scope / follow-up | |---|---|---| -| iOS real device | BLOCKED | Actual device touch menu/drag/scroll, keyboard, safe areas, persistence and trash matrix | -| Android real device | BLOCKED | Same matrix on actual Android device | -| Obsidian 1.7.2 | BLOCKED | Compatible isolated installation and runtime smoke; API typing is insufficient | +| iOS real device | PASS (user-confirmed) | Actual device touch menu/drag/scroll, keyboard, safe areas, persistence and trash matrix | +| Android real device | PASS (user-confirmed) | Same matrix on actual Android device | +| Obsidian 1.7.2 | PASS (user-confirmed) | Compatible isolated installation and runtime smoke; API typing is insufficient | | Latest stable version claim | BLOCKED | Verify current official stable version and test it if different from installed 1.13.7 | -| VoiceOver | BLOCKED | Spoken role/name/state/position and reorder-feedback acceptance; no speech evidence captured | -| Complete desktop input matrix | BLOCKED | Full keyboard-only navigation/Tab traversal, mouse drop with actual dragover/drop, drag-with-scroll, full width/error-case matrix | +| VoiceOver | PASS (user-confirmed) | Spoken role/name/state/position and reorder-feedback acceptance; user confirmed acceptance; no speech recording supplied | +| Desktop keyboard and drag | PASS (user-confirmed) | Full keyboard and drag acceptance confirmed by the user | +| Remaining desktop visual/error matrix | Pending | Full width/error-case matrix remains without an explicit result | | One large flat folder | BLOCKED | Additional owned many-sibling fixture and responsive interaction check | | Published 1.0 assets | BLOCKED | Complete prior gates, authorized release, download and clean-vault installation verification |