diff --git a/docs/archive/2026-05-10-smart-explorer-development-plan.md b/docs/archive/2026-05-10-smart-explorer-development-plan.md deleted file mode 100644 index 02dd17e..0000000 --- a/docs/archive/2026-05-10-smart-explorer-development-plan.md +++ /dev/null @@ -1,516 +0,0 @@ -# Smart Explorer Development Plan - -> Historical plan. This file records the original implementation direction and intentionally includes ideas that are no longer in the current product, such as preview panels and saved views. Use `README.md` for user-facing behavior and `AGENTS.md` / `CLAUDE.md` for the current code map. - -## 1. Positioning - -**Plugin name:** Smart Explorer - -**One-line goal:** Provide an alternative Obsidian side-pane file explorer with custom sorting, grouping, filtering, and lightweight previews without patching Obsidian's built-in File Explorer internals. - -**Target users:** - -- Users with large vaults who need faster navigation than the default file tree. -- Writers who organize notes by project, status, date, tag, or property. -- PKM users who want saved file views such as "recent drafts", "large notes", "untagged notes", or "research PDFs". - -**Core product principle:** Build a separate, reversible explorer view first. Do not replace or monkey-patch Obsidian's native File Explorer in the first release. - -## 2. Relationship To `obsidian-releases` - -Smart Explorer should live in its own plugin repository. This `obsidian-releases` repository is only relevant when the plugin is ready for community listing: - -- Add one entry to `community-plugins.json`. -- The entry `id` must match the plugin repository's `manifest.json`. -- GitHub releases must include `main.js`, `manifest.json`, and `styles.css` if styles are used. - -## 3. MVP Scope - -### Included In v0.1 - -- A new ribbon icon and command to open "Smart Explorer". -- A custom `ItemView` side pane that lists vault files and folders. -- Sort modes: - - Name A-Z - - Name Z-A - - Modified newest first - - Modified oldest first - - Created newest first - - Created oldest first - - File extension - - File size -- Group modes: - - None - - Folder - - File extension - - Modified month - - Top-level folder -- Filter modes: - - Search by file name/path - - Extension filter - - Markdown-only toggle - - Attachments-only toggle - - Recently modified range: 1 day, 7 days, 30 days -- Lightweight preview panel: - - Markdown: first heading, first non-empty paragraph, tags/properties if available - - Images: thumbnail - - PDFs and other binary files: metadata-only preview -- Persisted settings: - - Default sort mode - - Default group mode - - Preview enabled - - Hidden file extensions - -### Explicitly Out Of v0.1 - -- Replacing Obsidian's built-in File Explorer. -- Drag-and-drop file movement. -- Inline rename/delete/move. -- Custom manual ordering. -- Syncing custom ordering across devices. -- Full-text search inside file contents. -- Canvas graph previews. - -The first release should earn trust by being read-heavy and low-risk. - -## 4. Architecture - -### Obsidian APIs To Use - -- `Plugin` for lifecycle. -- `ItemView` and `WorkspaceLeaf` for the side-pane view. -- `Vault` for file enumeration and file metadata. -- `TFile` and `TFolder` for vault entities. -- `MetadataCache` for headings, frontmatter, links, tags, and cached metadata. -- `PluginSettingTab` for settings. - -Reference docs: - -- Obsidian plugin development: https://docs.obsidian.md/Plugins/Getting%20started/Build%20a%20plugin -- Obsidian plugin publishing: https://docs.obsidian.md/Plugins/Releasing/Submit%20your%20plugin -- Obsidian developer policies: https://docs.obsidian.md/Developer+policies - -### Proposed File Structure - -```text -smart-explorer/ - manifest.json - package.json - tsconfig.json - esbuild.config.mjs - src/ - main.ts - constants.ts - settings/ - settings.ts - settings-tab.ts - explorer/ - SmartExplorerView.ts - FileIndex.ts - FileTreeModel.ts - sorters.ts - groupers.ts - filters.ts - preview.ts - render.ts - ui/ - icons.ts - controls.ts - empty-state.ts - tests/ - sorters.test.ts - groupers.test.ts - filters.test.ts - preview.test.ts - styles.css - README.md - LICENSE -``` - -### Module Responsibilities - -- `main.ts`: plugin lifecycle, command registration, ribbon icon, view registration. -- `settings.ts`: typed settings schema, defaults, migration helpers. -- `settings-tab.ts`: settings UI. -- `SmartExplorerView.ts`: Obsidian `ItemView`, event wiring, render lifecycle. -- `FileIndex.ts`: creates normalized file records from vault files and metadata. -- `FileTreeModel.ts`: transforms flat records into grouped display sections. -- `sorters.ts`: pure sorting functions. -- `groupers.ts`: pure grouping functions. -- `filters.ts`: pure filtering functions. -- `preview.ts`: creates preview data from cached metadata and file type. -- `render.ts`: DOM rendering for rows, sections, preview panel. - -Keep sorting, grouping, filtering, and preview extraction pure enough to test outside Obsidian. - -## 5. Data Model - -```ts -type SmartExplorerSettings = { - defaultSort: SortMode; - defaultGroup: GroupMode; - previewEnabled: boolean; - hiddenExtensions: string[]; - markdownOnly: boolean; - attachmentsOnly: boolean; -}; - -type FileRecord = { - path: string; - basename: string; - extension: string; - parentPath: string; - size: number; - ctime: number; - mtime: number; - isMarkdown: boolean; - isAttachment: boolean; - frontmatter?: Record; - tags: string[]; - firstHeading?: string; -}; - -type ExplorerQuery = { - searchText: string; - sort: SortMode; - group: GroupMode; - extension: string | null; - markdownOnly: boolean; - attachmentsOnly: boolean; - modifiedWithinDays: number | null; -}; - -type ExplorerSection = { - id: string; - title: string; - records: FileRecord[]; -}; -``` - -## 6. UX Flow - -1. User opens Command Palette and runs `Smart Explorer: Open`. -2. Obsidian opens a right-side leaf with the Smart Explorer view. -3. Top toolbar shows: - - Search input - - Sort dropdown - - Group dropdown - - Filter button - - Preview toggle -4. Main list shows grouped file rows. -5. Clicking a row opens the file in the active editor leaf. -6. Hovering or selecting a row updates the preview panel. -7. Settings persist defaults, not transient search text. - -## 7. Development Milestones - -### Milestone 0: Repository Bootstrap - -Goal: create a minimal, buildable Obsidian plugin. - -Tasks: - -- Scaffold from the official Obsidian sample plugin structure. -- Add TypeScript, esbuild, ESLint or the existing starter lint setup. -- Create `manifest.json` with: - - `id`: `smart-explorer` - - `name`: `Smart Explorer` - - `version`: `0.1.0` - - `minAppVersion`: choose a current stable baseline after testing -- Add README with local development instructions. -- Add MIT license unless another license is intentionally chosen. - -Commit: - -```bash -git commit -m "chore: scaffold smart explorer plugin" -``` - -Verification: - -- `npm install` -- `npm run build` -- Plugin loads in a test vault without console errors. - -### Milestone 1: View Registration And Shell - -Goal: open a stable side-pane view. - -Tasks: - -- Register `SMART_EXPLORER_VIEW_TYPE`. -- Add ribbon icon and command. -- Implement `SmartExplorerView`. -- Render static toolbar and empty list. -- Add unload cleanup. - -Commit: - -```bash -git commit -m "feat: add smart explorer view shell" -``` - -Verification: - -- Command opens the view. -- Ribbon icon opens the view. -- Closing/reopening the pane does not duplicate DOM or commands. - -### Milestone 2: File Index - -Goal: produce normalized records for all vault files. - -Tasks: - -- Implement `FileIndex.build()`. -- Read all files via `app.vault.getFiles()`. -- Normalize basename, extension, parent path, size, ctime, mtime. -- Pull cached metadata for Markdown files via `metadataCache.getFileCache(file)`. -- Extract tags and first heading from metadata. -- Add tests for record normalization. - -Commit: - -```bash -git commit -m "feat: index vault files for explorer" -``` - -Verification: - -- Unit tests pass. -- Test vault with Markdown, images, PDFs, and nested folders renders expected records in console/debug output. - -### Milestone 3: Sorting And Grouping - -Goal: implement deterministic display ordering. - -Tasks: - -- Implement sort modes. -- Implement group modes. -- Add stable tie-breaker by path. -- Add tests for every sort and group mode. -- Render grouped sections in the view. - -Commit: - -```bash -git commit -m "feat: add file sorting and grouping" -``` - -Verification: - -- Unit tests cover all sort and group modes. -- Manual vault check confirms sections stay stable after refresh. - -### Milestone 4: Filtering Toolbar - -Goal: make the explorer useful for large vaults. - -Tasks: - -- Add search input with debounce. -- Add extension dropdown. -- Add Markdown-only toggle. -- Add attachments-only toggle. -- Add modified range dropdown. -- Add empty state that explains active filters. -- Add tests for filter combinations. - -Commit: - -```bash -git commit -m "feat: add explorer filters" -``` - -Verification: - -- Typing search does not lag on a vault with at least 2,000 files. -- Filters combine predictably. -- Clearing filters restores all files. - -### Milestone 5: Preview Panel - -Goal: show enough context to choose a file without opening it. - -Tasks: - -- Add selected-row state. -- For Markdown, show first heading, tags, and first paragraph. -- For images, show thumbnail using Obsidian resource paths. -- For other files, show extension, size, and modified time. -- Add preview toggle. -- Add tests for preview extraction. - -Commit: - -```bash -git commit -m "feat: add file preview panel" -``` - -Verification: - -- Preview updates on selection. -- Large binary files are not read into memory. -- Missing metadata does not throw errors. - -### Milestone 6: Settings - -Goal: persist defaults without overcomplicating the first release. - -Tasks: - -- Add settings schema and defaults. -- Add settings tab. -- Persist default sort/group and preview enabled. -- Persist hidden extensions. -- Add migration guard for missing settings keys. - -Commit: - -```bash -git commit -m "feat: persist smart explorer settings" -``` - -Verification: - -- Change settings, reload Obsidian, confirm persistence. -- Old settings file with missing keys falls back safely. - -### Milestone 7: Performance Pass - -Goal: avoid turning navigation into a slow plugin. - -Tasks: - -- Debounce rebuilds on vault changes. -- Listen to create/delete/rename/modify events. -- Rebuild index incrementally where simple; otherwise batch rebuild after short delay. -- Avoid reading full file contents except for limited Markdown preview. -- Add a visible "indexing" state for large vaults. - -Commit: - -```bash -git commit -m "perf: batch smart explorer index updates" -``` - -Verification: - -- Test with a large vault. -- Rapid file changes do not create repeated full renders. -- UI remains responsive while typing in search. - -### Milestone 8: Release Readiness - -Goal: prepare for public review. - -Tasks: - -- Write README with screenshots, features, limitations, privacy notes. -- Add release checklist. -- Confirm no network requests. -- Confirm plugin does not alter files. -- Build release assets. - -Commit: - -```bash -git commit -m "docs: prepare smart explorer release" -``` - -Verification: - -- Install from release assets into a clean test vault. -- Confirm `manifest.json` version matches release tag. -- Confirm required files are individual GitHub release assets. - -## 8. Testing Strategy - -### Unit Tests - -- Sorting: - - name ascending/descending - - modified ascending/descending - - size tie-breakers -- Grouping: - - extension - - folder - - modified month -- Filtering: - - query text - - extension - - markdown-only - - attachments-only - - modified range -- Preview: - - Markdown with heading - - Markdown without heading - - image - - binary file - -### Manual Tests - -- Fresh vault with 10 files. -- Real vault with 1,000+ files. -- Files with non-ASCII names. -- Deep folder paths. -- Empty vault. -- Vault with only attachments. - -### Regression Checklist - -- Plugin unload removes event listeners. -- Opening a file from the explorer respects Obsidian workspace behavior. -- Settings survive reload. -- No unexpected file writes. -- No network access. - -## 9. Recommended Commit Sequence - -1. `chore: scaffold smart explorer plugin` -2. `feat: add smart explorer view shell` -3. `feat: index vault files for explorer` -4. `feat: add file sorting and grouping` -5. `feat: add explorer filters` -6. `feat: add file preview panel` -7. `feat: persist smart explorer settings` -8. `perf: batch smart explorer index updates` -9. `docs: prepare smart explorer release` - -Each commit should build and should keep the plugin loadable. Avoid bundling UI, indexing, and settings into one commit. - -## 10. Risks And Mitigations - -- **Risk:** Obsidian internal File Explorer changes break plugin behavior. - - **Mitigation:** Use a separate `ItemView`; do not patch native File Explorer. -- **Risk:** Large vault performance degrades. - - **Mitigation:** Use cached metadata, debounce rebuilds, and avoid reading full files. -- **Risk:** Feature creep toward full file manager. - - **Mitigation:** No write actions in v0.1 except settings. -- **Risk:** Preview reads too much data. - - **Mitigation:** Use metadata cache first; cap Markdown preview reads. - -## 11. Future Roadmap - -### v0.2 - -- Saved views. -- Property-based grouping. -- Tag-based grouping. -- Pin favorite filters. - -### v0.3 - -- Manual custom order per folder. -- Optional drag-and-drop reordering inside Smart Explorer. -- Keyboard navigation. - -### v0.4 - -- File operations with confirmation: - - rename - - move - - reveal in native explorer - -Do not add destructive operations before the read-only explorer is stable and trusted. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index d9a4517..f7d48bb 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -1,6 +1,6 @@ # 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. +Record results against the exact candidate commit and asset hashes. The 1.0.0 acceptance evidence is recorded in the [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 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 deleted file mode 100644 index 27593a1..0000000 --- a/docs/superpowers/plans/2026-07-29-smart-explorer-reliability-fixes.md +++ /dev/null @@ -1,1417 +0,0 @@ -# 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. - -**Architecture:** Keep `FileIndex` as the complete vault truth and treat hidden extensions/search/type/date filters only as display projections. Manual order remains a complete persisted permutation of all indexed files; drag operations map visible anchors back into that permutation. Small stateful UI boundaries get focused tests, tree sorting uses path-indexed lookup, and CI plus release share one local `verify` command. - -**Tech Stack:** TypeScript, Obsidian API, Jest with ts-jest, esbuild, ESLint, Node.js built-in test runner, GitHub Actions. - ---- - -## Scope and file map - -### Files to modify - -- `src/explorer/manualOrder.ts` - - Reconcile a complete, unique manual-order permutation. - - Map filtered visible drag positions back to global order. - - Rewrite file and folder paths after rename. -- `src/explorer/SmartExplorerView.ts` - - Initialize manual order from the complete `FileIndex`. - - Persist renamed manual-order paths. - - Replace the unsafe search timer with a cancellable scheduler. - - Clear blocking query filters before revealing the active file. -- `src/explorer/TreeModel.ts` - - Replace repeated linear node lookup with a path map. -- `src/explorer/__tests__/manualOrder.test.ts` - - Cover filtered drag, hidden files, incomplete seeds, duplicate paths, and rename rewriting. -- `src/explorer/__tests__/TreeModel.test.ts` - - Cover the path-indexed file-node sorter. -- `src/explorer/__tests__/SmartExplorerView.test.ts` - - Cover rename-save scheduling, search-clear cancellation, and reveal-state behavior. -- `package.json` - - Add shared `verify` and release-validator test scripts. -- `README.md`, `AGENTS.md`, `CLAUDE.md` - - Document the shared verification command. -- `.github/workflows/ci.yml` - - Run the shared verification gate, including lint. -- `.github/workflows/release.yml` - - Validate release metadata and main ancestry before publishing. - -### Files to create - -- `src/explorer/searchRenderScheduler.ts` - - Own the single cancellable 200ms search-render debounce. -- `src/explorer/__tests__/searchRenderScheduler.test.ts` - - Verify cancellation and latest-input behavior with fake timers. -- `scripts/validate-release.mjs` - - Validate semantic tag, package/manifest equality, and `versions.json`. -- `scripts/__tests__/validate-release.test.mjs` - - Exercise release metadata validation with Node's built-in test runner. - -### Deliberately unchanged - -- `src/explorer/DragSortManager.ts` - - It should continue reporting a visible insertion boundary; global mapping belongs in `manualOrder.ts`. -- `src/explorer/filters.ts` - - Filter semantics are correct; only their interaction with persisted manual order is wrong. -- `manifest.json`, `versions.json` - - No release is being cut as part of these fixes. - ---- - -### Task 0: Create an isolated implementation branch - -**Files:** - -- No source files - -- [ ] **Step 1: Confirm the starting point is clean and current** - -Run: - -```bash -git status --short --branch -git fetch origin main -git rev-parse HEAD -git rev-parse origin/main -``` - -Expected: the two revisions match and there are no unrelated changes. The untracked plan file created during planning is expected; any other user change must be preserved and isolated with `superpowers:using-git-worktrees` before editing. - -- [ ] **Step 2: Create the implementation branch before any commits** - -Run: - -```bash -git switch -c fix/reliability-and-release-guards -``` - -Expected: the current branch is `fix/reliability-and-release-guards`. - -- [ ] **Step 3: Commit the reviewed implementation plan on the branch** - -Run: - -```bash -git add docs/superpowers/plans/2026-07-29-smart-explorer-reliability-fixes.md -git commit -m "docs: add reliability fix plan" -``` - -Expected: the implementation branch contains the plan and the worktree is clean before source changes begin. - ---- - -### Task 1: Make manual reordering correct for filtered visible rows - -**Files:** - -- Modify: `src/explorer/manualOrder.ts:1-102` -- Modify: `src/explorer/__tests__/manualOrder.test.ts:19-83` - -- [ ] **Step 1: Replace grouped-only test coverage with filtered/global-order regressions** - -Add these tests under `describe("reorderManualOrder")`: - -```ts -it("maps a filtered drop target back into the global order", () => { - const order = ["a.md", "b.md", "c.md", "d.md"]; - const sections = [{ - id: "all", - records: ["c.md", "d.md"].map(makeRecord), - }]; - - const result = reorderManualOrder(order, "d.md", 0, sections); - - expect(result).toEqual(["a.md", "b.md", "d.md", "c.md"]); -}); - -it("moves a visible item after the last visible anchor without moving trailing hidden files", () => { - const order = ["a.md", "hidden-1.md", "b.md", "hidden-2.md"]; - const sections = [{ - id: "all", - records: ["a.md", "b.md"].map(makeRecord), - }]; - - const result = reorderManualOrder(order, "a.md", 2, sections); - - expect(result).toEqual(["hidden-1.md", "b.md", "a.md", "hidden-2.md"]); -}); - -it("does not move the only visible item", () => { - const order = ["hidden-a.md", "visible.md", "hidden-b.md"]; - const sections = [{ - id: "all", - records: [makeRecord("visible.md")], - }]; - - const result = reorderManualOrder(order, "visible.md", 1, sections); - - expect(result).toEqual(order); -}); -``` - -Update existing calls to remove the unreachable `group` and `sectionId` arguments. Manual sort already forces `group: "none"` in `viewMode.ts` and `SmartExplorerView.ts`, so grouped manual ordering is not part of the live product surface. - -- [ ] **Step 2: Run the focused tests and confirm the filtered cases fail** - -Run: - -```bash -npm test -- --runInBand src/explorer/__tests__/manualOrder.test.ts -``` - -Expected: the filtered/global-order assertions fail because visible `toIndex` is still treated as a global array index. - -- [ ] **Step 3: Replace index arithmetic with visible-anchor mapping** - -Replace `reorderManualOrder` and remove `GroupMode` plus `clampToGroupEnd`: - -```ts -export function reorderManualOrder( - currentOrder: string[], - draggedPath: string, - toIndex: number, - sections: ManualOrderSection[], -): string[] { - const visiblePaths = sections.flatMap((section) => - section.records.map((record) => record.path), - ); - const fromVisible = visiblePaths.indexOf(draggedPath); - if (fromVisible < 0) return [...currentOrder]; - - const visibleWithoutDragged = visiblePaths.filter((path) => path !== draggedPath); - if (visibleWithoutDragged.length === 0) return [...currentOrder]; - - const nextOrder = currentOrder.filter((path) => path !== draggedPath); - if (nextOrder.length === currentOrder.length) return [...currentOrder]; - - const adjustedVisibleIndex = fromVisible < toIndex ? toIndex - 1 : toIndex; - const targetVisibleIndex = Math.max( - 0, - Math.min(adjustedVisibleIndex, visibleWithoutDragged.length), - ); - const targetPath = visibleWithoutDragged[targetVisibleIndex]; - - let targetGlobalIndex: number; - if (targetPath !== undefined) { - targetGlobalIndex = nextOrder.indexOf(targetPath); - if (targetGlobalIndex < 0) return [...currentOrder]; - } else { - const lastVisiblePath = visibleWithoutDragged[ - visibleWithoutDragged.length - 1 - ]; - const lastVisibleIndex = lastVisiblePath === undefined - ? -1 - : nextOrder.indexOf(lastVisiblePath); - targetGlobalIndex = lastVisibleIndex < 0 - ? nextOrder.length - : lastVisibleIndex + 1; - } - - nextOrder.splice(targetGlobalIndex, 0, draggedPath); - return nextOrder; -} -``` - -- [ ] **Step 4: Run the focused tests** - -Run: - -```bash -npm test -- --runInBand src/explorer/__tests__/manualOrder.test.ts -``` - -Expected: all manual-order tests pass, including own-slot, front, filtered-front, filtered-end, and non-mutating cases. - -- [ ] **Step 5: Commit the pure reorder fix** - -```bash -git add src/explorer/manualOrder.ts src/explorer/__tests__/manualOrder.test.ts -git commit -m "fix: map filtered drag positions to manual order" -``` - ---- - -### Task 2: Keep manual order complete and persist rename updates - -**Files:** - -- Modify: `src/explorer/manualOrder.ts` -- Modify: `src/explorer/SmartExplorerView.ts:220-245,588-613,1218-1265` -- Modify: `src/explorer/__tests__/manualOrder.test.ts` -- Create: `src/explorer/__tests__/SmartExplorerView.test.ts` - -- [ ] **Step 1: Add reconciliation and rename regression tests** - -Add to `manualOrder.test.ts`: - -```ts -it("keeps every known file when the fallback order is partial", () => { - const records = ["a.md", "b.md", "c.md"].map(makeRecord); - - const result = reconcileManualOrder([], records, ["b.md"]); - - expect(result).toEqual(["b.md", "a.md", "c.md"]); -}); - -it("deduplicates saved paths while preserving the first occurrence", () => { - const records = ["a.md", "b.md"].map(makeRecord); - - const result = reconcileManualOrder( - ["a.md", "a.md", "b.md"], - records, - ["b.md", "a.md"], - ); - - expect(result).toEqual(["a.md", "b.md"]); -}); - -it("rewrites a renamed file path without changing its position", () => { - expect(renameManualOrderPaths( - ["a.md", "folder/old.md", "b.md"], - "folder/old.md", - "folder/new.md", - )).toEqual(["a.md", "folder/new.md", "b.md"]); -}); - -it("rewrites every child path after a folder rename", () => { - expect(renameManualOrderPaths( - ["a.md", "old/x.md", "old/nested/y.md", "b.md"], - "old", - "new", - )).toEqual(["a.md", "new/x.md", "new/nested/y.md", "b.md"]); -}); - -it("returns the same reference when a rename does not affect the order", () => { - const order = ["a.md", "b.md"]; - - expect(renameManualOrderPaths(order, "missing", "new")).toBe(order); -}); -``` - -Import `renameManualOrderPaths` with the other manual-order helpers. - -- [ ] **Step 2: Add a narrow view test proving rename schedules persistence** - -Create `SmartExplorerView.test.ts` with a local Obsidian mock and a prototype-only view: - -```ts -jest.mock( - "obsidian", - () => ({ - ItemView: class {}, - Menu: class {}, - Modal: class {}, - Notice: class {}, - Platform: { isMobile: false }, - Setting: class {}, - setIcon: jest.fn(), - TFile: class {}, - TFolder: class {}, - WorkspaceLeaf: class {}, - }), - { virtual: true }, -); - -import { SmartExplorerView } from "../SmartExplorerView"; - -function makeBareView(order: string[]) { - const view = Object.create(SmartExplorerView.prototype) as any; - view.plugin = { settings: { manualOrder: order } }; - view.buildManualOrderIndex = jest.fn(); - view.scheduleSaveOrder = jest.fn(); - return view; -} - -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"); - - expect(view.scheduleSaveOrder).not.toHaveBeenCalled(); - }); -}); -``` - -- [ ] **Step 3: Run both tests and confirm they fail** - -Run: - -```bash -npm test -- --runInBand \ - src/explorer/__tests__/manualOrder.test.ts \ - src/explorer/__tests__/SmartExplorerView.test.ts -``` - -Expected: imports/methods for rename rewriting are missing, and partial fallback still omits known files. - -- [ ] **Step 4: Make reconciliation return a complete unique permutation** - -Replace `reconcileManualOrder` with: - -```ts -export function reconcileManualOrder( - currentOrder: string[], - records: FileRecord[], - fallbackOrder: string[] = records.map((record) => record.path), -): string[] { - const knownPaths = records.map((record) => record.path); - const known = new Set(knownPaths); - const seen = new Set(); - const nextOrder: string[] = []; - - const appendKnown = (paths: string[]) => { - for (const path of paths) { - if (!known.has(path) || seen.has(path)) continue; - seen.add(path); - nextOrder.push(path); - } - }; - - appendKnown(currentOrder); - appendKnown(fallbackOrder); - appendKnown(knownPaths); - - if ( - nextOrder.length === currentOrder.length && - nextOrder.every((path, index) => path === currentOrder[index]) - ) { - return currentOrder; - } - return nextOrder; -} -``` - -Add the rename helper: - -```ts -export function renameManualOrderPaths( - currentOrder: string[], - oldPath: string, - newPath: string, -): string[] { - let changed = false; - const oldPrefix = `${oldPath}/`; - const nextOrder = currentOrder.map((path) => { - if (path === oldPath) { - changed = true; - return newPath; - } - if (path.startsWith(oldPrefix)) { - changed = true; - return `${newPath}/${path.slice(oldPrefix.length)}`; - } - return path; - }); - return changed ? nextOrder : currentOrder; -} -``` - -- [ ] **Step 5: Initialize from the complete index, not the display subset** - -In `renderListContent`, preserve the complete record set: - -```ts -const allRecords = this.fileIndex.getAll(); -let records = allRecords; -if (hiddenExts.size > 0) { - records = allRecords.filter((record) => !hiddenExts.has(record.extension)); -} - -if (this.query.sort === "manual") { - this.initializeManualOrder(allRecords); -} -``` - -Remove the old later call that passed filtered `records`. - -In `initializeManualOrder`, build a complete seed independent of display filters: - -```ts -private initializeManualOrder(allRecords: FileRecord[]) { - const order = this.plugin.settings.manualOrder; - const seeded = buildSections(allRecords, { - ...this.query, - searchText: "", - group: "none", - extension: null, - fileKind: "all", - modifiedWithinDays: null, - sort: this.manualSeedSort, - }); - const fallbackOrder = seeded[0]?.records.map((record) => record.path) ?? []; - const reconciled = reconcileManualOrder(order, allRecords, fallbackOrder); - if (reconciled !== order) { - this.plugin.settings.manualOrder = reconciled; - this.scheduleSaveOrder(); - } - this.buildManualOrderIndex(); -} -``` - -Update `handleManualReorder` to call the new four-argument `reorderManualOrder`: - -```ts -const nextOrder = reorderManualOrder( - order, - draggedPath, - toIndex, - sections, -); -``` - -Remove its unused `group` and `sectionId` parameters, and simplify the `DragSortManager` callback accordingly. - -- [ ] **Step 6: Route both file and folder renames through one persistence method** - -Import `renameManualOrderPaths`, then add: - -```ts -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(); -} -``` - -In the rename event: - -```ts -if (file instanceof TFile) { - this.fileIndex.removeFile(oldPath); - this.fileIndex.addFile(file); - if (this.selectedPath === oldPath) { - this.selectedPath = file.path; - } - this.updateManualOrderAfterRename(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.scheduleRebuild(); -``` - -Delete the duplicated in-place manual-order mutations. Retain the local `renameNestedPath` helper because `updateFolderPathState` still uses it for expanded and selected folder UI state. - -- [ ] **Step 7: Run focused and full manual-order tests** - -Run: - -```bash -npm test -- --runInBand \ - src/explorer/__tests__/manualOrder.test.ts \ - src/explorer/__tests__/SmartExplorerView.test.ts \ - src/explorer/__tests__/viewMode.test.ts -``` - -Expected: all tests pass. The saved order contains every indexed file exactly once, hidden extensions retain their position, and rename schedules persistence. - -- [ ] **Step 8: Commit the complete-order and rename fix** - -```bash -git add \ - src/explorer/manualOrder.ts \ - src/explorer/SmartExplorerView.ts \ - src/explorer/__tests__/manualOrder.test.ts \ - src/explorer/__tests__/SmartExplorerView.test.ts -git commit -m "fix: preserve manual order across filters and renames" -``` - ---- - -### Task 3: Remove the search-clear debounce race - -**Files:** - -- Create: `src/explorer/searchRenderScheduler.ts` -- Create: `src/explorer/__tests__/searchRenderScheduler.test.ts` -- Modify: `src/explorer/SmartExplorerView.ts:80,153-189,326-331,437-515` -- Modify: `src/explorer/__tests__/SmartExplorerView.test.ts` - -- [ ] **Step 1: Write scheduler tests with fake timers** - -Create `searchRenderScheduler.test.ts`: - -```ts -import { SearchRenderScheduler } from "../searchRenderScheduler"; - -describe("SearchRenderScheduler", () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it("runs only the latest scheduled render", () => { - const scheduler = new SearchRenderScheduler(); - const first = jest.fn(); - const second = jest.fn(); - - scheduler.schedule(first); - scheduler.schedule(second); - jest.advanceTimersByTime(200); - - expect(first).not.toHaveBeenCalled(); - expect(second).toHaveBeenCalledTimes(1); - }); - - it("does not run a cancelled render", () => { - const scheduler = new SearchRenderScheduler(); - const render = jest.fn(); - - scheduler.schedule(render); - scheduler.cancel(); - jest.advanceTimersByTime(200); - - expect(render).not.toHaveBeenCalled(); - }); -}); -``` - -- [ ] **Step 2: Add a view-level clear-state regression** - -Extend `SmartExplorerView.test.ts`: - -```ts -describe("SmartExplorerView search state", () => { - it("cancels a pending search render before clearing filters", () => { - const view = Object.create(SmartExplorerView.prototype) as any; - view.query = { - searchText: "stale", - sort: "name-asc", - group: "none", - extension: "md", - fileKind: "markdown", - modifiedWithinDays: 7, - }; - view.searchRenderScheduler = { cancel: jest.fn() }; - view.rebuildView = jest.fn(); - - view.clearSearchAndFilters(); - - expect(view.searchRenderScheduler.cancel).toHaveBeenCalledTimes(1); - expect(view.query).toMatchObject({ - searchText: "", - extension: null, - fileKind: "all", - modifiedWithinDays: null, - }); - expect(view.rebuildView).toHaveBeenCalledTimes(1); - }); -}); -``` - -- [ ] **Step 3: Run the new tests and confirm they fail** - -Run: - -```bash -npm test -- --runInBand \ - src/explorer/__tests__/searchRenderScheduler.test.ts \ - src/explorer/__tests__/SmartExplorerView.test.ts -``` - -Expected: `SearchRenderScheduler` and the new view field do not exist. - -- [ ] **Step 4: Implement the cancellable scheduler** - -Create `searchRenderScheduler.ts`: - -```ts -const SEARCH_RENDER_DELAY_MS = 200; - -export class SearchRenderScheduler { - private timer: ReturnType | null = null; - - schedule(render: () => void) { - this.cancel(); - this.timer = globalThis.setTimeout(() => { - this.timer = null; - render(); - }, SEARCH_RENDER_DELAY_MS); - } - - cancel() { - if (this.timer === null) return; - globalThis.clearTimeout(this.timer); - this.timer = null; - } -} -``` - -- [ ] **Step 5: Make query state immediate and debounce only rendering** - -In `SmartExplorerView`: - -```ts -private searchRenderScheduler = new SearchRenderScheduler(); -``` - -Remove `searchTimeout`. Replace the search input handler with: - -```ts -searchInput.addEventListener("input", () => { - this.query.searchText = searchInput.value; - this.searchRenderScheduler.schedule(() => this.renderList()); -}); -``` - -At the start of the clear path: - -```ts -private clearSearchAndFilters() { - this.searchRenderScheduler.cancel(); - this.query = clearSearchAndFilters(this.query); - this.rebuildView(); -} -``` - -In the Escape branch, cancel before clearing: - -```ts -if (this.query.searchText) { - e.preventDefault(); - this.searchRenderScheduler.cancel(); - this.query.searchText = ""; - if (this.searchInput) this.searchInput.value = ""; - this.renderList(); - return; -} -``` - -In `onClose`, call: - -```ts -this.searchRenderScheduler.cancel(); -``` - -and remove the old `searchTimeout` cleanup. - -- [ ] **Step 6: Run the search-focused tests** - -Run: - -```bash -npm test -- --runInBand \ - src/explorer/__tests__/searchRenderScheduler.test.ts \ - src/explorer/__tests__/SmartExplorerView.test.ts \ - src/explorer/__tests__/filterState.test.ts -``` - -Expected: all pass; clearing or closing cannot allow an old search callback to restore query text. - -- [ ] **Step 7: Commit the search-state fix** - -```bash -git add \ - src/explorer/searchRenderScheduler.ts \ - src/explorer/SmartExplorerView.ts \ - src/explorer/__tests__/searchRenderScheduler.test.ts \ - src/explorer/__tests__/SmartExplorerView.test.ts -git commit -m "fix: cancel stale search renders" -``` - ---- - -### Task 4: Make “Reveal active file” honor its goal - -**Files:** - -- Modify: `src/explorer/SmartExplorerView.ts:1078-1090` -- Modify: `src/explorer/__tests__/SmartExplorerView.test.ts` - -- [ ] **Step 1: Add a filtered-reveal regression test** - -Extend `SmartExplorerView.test.ts`: - -```ts -describe("SmartExplorerView reveal state", () => { - it("clears blocking filters and switches to tree mode before reveal", () => { - const view = Object.create(SmartExplorerView.prototype) as any; - view.app = { - workspace: { - getActiveFile: () => ({ path: "notes/active.md" }), - }, - }; - view.query = { - searchText: "other", - sort: "modified-new", - group: "folder", - extension: null, - fileKind: "images", - modifiedWithinDays: 1, - }; - view.viewMode = "list"; - view.selectedPath = null; - view.selectedFolderPath = "notes"; - view.treeExpandedPaths = new Set(); - view.searchRenderScheduler = { cancel: jest.fn() }; - view.rebuildView = jest.fn(); - view.listContainer = null; - - view.revealActiveFile(); - - expect(view.searchRenderScheduler.cancel).toHaveBeenCalledTimes(1); - expect(view.query).toMatchObject({ - searchText: "", - sort: "modified-new", - group: "folder", - extension: null, - fileKind: "all", - modifiedWithinDays: null, - }); - expect(view.viewMode).toBe("tree"); - expect(view.selectedPath).toBe("notes/active.md"); - expect(view.selectedFolderPath).toBeNull(); - expect(view.treeExpandedPaths).toContain("notes"); - expect(view.rebuildView).toHaveBeenCalledTimes(1); - }); -}); -``` - -- [ ] **Step 2: Run the regression and confirm it fails** - -Run: - -```bash -npm test -- --runInBand src/explorer/__tests__/SmartExplorerView.test.ts -``` - -Expected: query filters remain active and `rebuildView` is not called. - -- [ ] **Step 3: Clear display filters and rebuild once before scrolling** - -Replace `revealActiveFile` with: - -```ts -revealActiveFile() { - const activeFile = this.app.workspace.getActiveFile(); - if (!activeFile) return; - - this.searchRenderScheduler.cancel(); - this.query = clearSearchAndFilters(this.query); - this.selectedPath = activeFile.path; - this.selectedFolderPath = null; - this.expandFolderAncestors(getParentFolderPath(activeFile.path)); - this.viewMode = "tree"; - this.rebuildView(); - - if ( - this.listContainer && - !revealPathInContainer(this.listContainer, activeFile.path) - ) { - new Notice("Active file is hidden by the explorer settings."); - } -} -``` - -This preserves sort/group defaults, clears only transient search/type/date/extension filters, and gives feedback if the persistent hidden-extension setting still excludes the file. - -- [ ] **Step 4: Run reveal, filter, and path tests** - -Run: - -```bash -npm test -- --runInBand \ - src/explorer/__tests__/SmartExplorerView.test.ts \ - src/explorer/__tests__/filterState.test.ts \ - src/explorer/__tests__/revealPath.test.ts -``` - -Expected: all pass. - -- [ ] **Step 5: Commit the reveal fix** - -```bash -git add \ - src/explorer/SmartExplorerView.ts \ - src/explorer/__tests__/SmartExplorerView.test.ts -git commit -m "fix: clear blocking filters when revealing files" -``` - ---- - -### Task 5: Remove quadratic tree-node lookup - -**Files:** - -- Modify: `src/explorer/TreeModel.ts:102-118` -- Modify: `src/explorer/__tests__/TreeModel.test.ts` - -- [ ] **Step 1: Add a focused sorter test** - -Export a new `sortTreeFileNodes` helper and add this test: - -```ts -import { - buildTree, - sortTreeFileNodes, -} from "../TreeModel"; -import type { - ExplorerTreeFileNode, - ExplorerTreeFolderNode, - ExplorerTreeNode, -} from "../TreeModel"; - -it("sorts file nodes without losing node identity", () => { - const oldNode: ExplorerTreeFileNode = { - type: "file", - id: "notes/old.md", - name: "old", - path: "notes/old.md", - record: makeRecord("notes/old.md", { mtime: 1000 }), - depth: 1, - }; - const newNode: ExplorerTreeFileNode = { - type: "file", - id: "notes/new.md", - name: "new", - path: "notes/new.md", - record: makeRecord("notes/new.md", { mtime: 3000 }), - depth: 1, - }; - - const result = sortTreeFileNodes( - [oldNode, newNode], - "modified-new", - ); - - expect(result).toEqual([newNode, oldNode]); - expect(result[0]).toBe(newNode); - expect(result[1]).toBe(oldNode); -}); -``` - -- [ ] **Step 2: Run the focused test and confirm the helper is missing** - -Run: - -```bash -npm test -- --runInBand src/explorer/__tests__/TreeModel.test.ts -``` - -Expected: compilation fails because `sortTreeFileNodes` is not exported. - -- [ ] **Step 3: Implement path-indexed lookup** - -Add: - -```ts -export function sortTreeFileNodes( - nodes: ExplorerTreeFileNode[], - sort: Exclude, - manualOrderIndex?: Map, -): ExplorerTreeFileNode[] { - const nodesByPath = new Map(nodes.map((node) => [node.path, node])); - return sortRecords( - nodes.map((node) => node.record), - sort, - manualOrderIndex, - ).map((record) => nodesByPath.get(record.path)!); -} -``` - -Then replace the repeated `children.find` expression: - -```ts -const fileNodes = children.filter( - (child): child is ExplorerTreeFileNode => child.type === "file", -); -const files = sortTreeFileNodes(fileNodes, sort, manualOrderIndex); -``` - -The lookup phase becomes `O(n)` after the existing `O(n log n)` sort. - -- [ ] **Step 4: Run tree-model tests** - -Run: - -```bash -npm test -- --runInBand src/explorer/__tests__/TreeModel.test.ts -``` - -Expected: all tree construction, filtering, empty-folder, and sorting tests pass. - -- [ ] **Step 5: Run a non-gating local performance comparison** - -Build the source helper and measure large flat folders: - -```bash -node_modules/.bin/esbuild \ - src/explorer/TreeModel.ts \ - --bundle \ - --platform=node \ - --format=cjs \ - --outfile=/tmp/smart-explorer-tree-model.cjs - -node -e 'const {buildTree}=require("/tmp/smart-explorer-tree-model.cjs"); const q={searchText:"",sort:"name-asc",group:"none",extension:null,fileKind:"all",modifiedWithinDays:null}; for(const n of [8000,16000,32000]){const records=Array.from({length:n},(_,i)=>({path:`folder/file-${String(i).padStart(5,"0")}.md`,basename:`file-${String(i).padStart(5,"0")}`,extension:"md",parentPath:"folder",size:1,ctime:1,mtime:1,isMarkdown:true,isAttachment:false,tags:[]})); const start=performance.now(); buildTree(records,q); console.log(`${n}: ${(performance.now()-start).toFixed(1)}ms`);}' -``` - -Expected: growth is dominated by sorting rather than the previous quadratic lookup; 32,000 records should no longer take multiple seconds on the same machine used for the baseline. - -- [ ] **Step 6: Commit the performance fix** - -```bash -git add src/explorer/TreeModel.ts src/explorer/__tests__/TreeModel.test.ts -git commit -m "perf: avoid quadratic tree node lookup" -``` - ---- - -### Task 6: Make lint, build, and tests one required verification gate - -**Files:** - -- Modify: `package.json:7-13` -- Modify: `.github/workflows/ci.yml:20-24` -- Modify: `README.md:68-76` -- Modify: `AGENTS.md:9-16` -- Modify: `CLAUDE.md:9-16` - -- [ ] **Step 1: Add the shared verify script** - -Update `package.json` scripts: - -```json -{ - "dev": "node esbuild.config.mjs", - "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", - "version": "node version-bump.mjs && git add manifest.json versions.json", - "lint": "eslint .", - "test": "node --experimental-vm-modules node_modules/.bin/jest", - "verify": "npm run lint && npm run build && npm test -- --runInBand" -} -``` - -- [ ] **Step 2: Use `verify` in CI** - -Replace separate build/test steps in `ci.yml`: - -```yaml - - run: npm ci - - - run: npm run verify -``` - -This makes the required `verify` job enforce the Obsidian ESLint rules as well as compilation and tests. - -- [ ] **Step 3: Document the command** - -Add this line alongside the existing development commands in `README.md`, `AGENTS.md`, and `CLAUDE.md`: - -```bash -npm run verify # lint + production build + all tests -``` - -- [ ] **Step 4: Run the shared gate** - -Run: - -```bash -npm run verify -``` - -Expected: lint exits with zero errors, production build succeeds, and all Jest suites pass. - -- [ ] **Step 5: Commit the unified verification gate** - -```bash -git add \ - package.json \ - .github/workflows/ci.yml \ - README.md \ - AGENTS.md \ - CLAUDE.md -git commit -m "chore: enforce lint in the verification gate" -``` - ---- - -### Task 7: Prevent accidental or off-main releases - -**Files:** - -- Create: `scripts/validate-release.mjs` -- Create: `scripts/__tests__/validate-release.test.mjs` -- Modify: `package.json` -- Modify: `.github/workflows/release.yml:3-49` - -- [ ] **Step 1: Write release metadata validator tests** - -Create `scripts/__tests__/validate-release.test.mjs`: - -```js -import assert from "node:assert/strict"; -import test from "node:test"; -import { validateReleaseMetadata } from "../validate-release.mjs"; - -const valid = { - packageVersion: "0.5.1", - manifestVersion: "0.5.1", - minAppVersion: "1.7.2", - versions: { "0.5.1": "1.7.2" }, -}; - -test("accepts matching release metadata", () => { - assert.doesNotThrow(() => validateReleaseMetadata("0.5.1", valid)); -}); - -test("rejects a non-semver tag", () => { - assert.throws( - () => validateReleaseMetadata("test-0.5.1", valid), - /semantic version/, - ); -}); - -test("rejects package and manifest version mismatches", () => { - assert.throws( - () => validateReleaseMetadata("0.5.1", { - ...valid, - manifestVersion: "0.5.0", - }), - /package.json and manifest.json/, - ); -}); - -test("rejects a missing versions entry", () => { - assert.throws( - () => validateReleaseMetadata("0.5.1", { - ...valid, - versions: {}, - }), - /versions.json/, - ); -}); - -test("rejects a mismatched minimum app version", () => { - assert.throws( - () => validateReleaseMetadata("0.5.1", { - ...valid, - versions: { "0.5.1": "1.8.0" }, - }), - /minimum app version/, - ); -}); -``` - -- [ ] **Step 2: Run the Node test and confirm the module is missing** - -Run: - -```bash -node --test scripts/__tests__/validate-release.test.mjs -``` - -Expected: failure with `ERR_MODULE_NOT_FOUND` for `validate-release.mjs`. - -- [ ] **Step 3: Implement the release metadata validator** - -Create `scripts/validate-release.mjs`: - -```js -import { readFileSync } from "node:fs"; -import { pathToFileURL } from "node:url"; - -export function validateReleaseMetadata(tag, metadata) { - if (!/^\d+\.\d+\.\d+$/.test(tag)) { - throw new Error(`Release tag "${tag}" must be a semantic version.`); - } - if ( - tag !== metadata.packageVersion || - tag !== metadata.manifestVersion - ) { - throw new Error( - "Release tag, package.json and manifest.json versions must match.", - ); - } - if (!(tag in metadata.versions)) { - throw new Error(`versions.json is missing release "${tag}".`); - } - if (metadata.versions[tag] !== metadata.minAppVersion) { - throw new Error( - "versions.json minimum app version must match manifest.json.", - ); - } -} - -function readJson(path) { - return JSON.parse(readFileSync(path, "utf8")); -} - -export function readReleaseMetadata() { - const packageJson = readJson("package.json"); - const manifest = readJson("manifest.json"); - return { - packageVersion: packageJson.version, - manifestVersion: manifest.version, - minAppVersion: manifest.minAppVersion, - versions: readJson("versions.json"), - }; -} - -const isMainModule = - process.argv[1] !== undefined && - import.meta.url === pathToFileURL(process.argv[1]).href; - -if (isMainModule) { - const tag = process.argv[2] ?? process.env.GITHUB_REF_NAME ?? ""; - try { - validateReleaseMetadata(tag, readReleaseMetadata()); - console.log(`Release metadata validated for ${tag}.`); - } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; - } -} -``` - -- [ ] **Step 4: Add validator tests to the shared gate** - -Add scripts: - -```json -"test:release": "node --test scripts/__tests__/validate-release.test.mjs", -"verify": "npm run lint && npm run build && npm test -- --runInBand && npm run test:release" -``` - -- [ ] **Step 5: Harden the release workflow before publication** - -Change checkout to: - -```yaml - - uses: actions/checkout@v4 - with: - fetch-depth: 0 -``` - -Replace the current build step with: - -```yaml - - name: Validate release metadata - run: node scripts/validate-release.mjs "$GITHUB_REF_NAME" - - - name: Verify tagged commit belongs to main - run: | - git fetch --no-tags origin main:refs/remotes/origin/main - if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then - echo "::error::Tagged commit is not contained in origin/main." - exit 1 - fi - - - name: Install dependencies - run: npm ci - - - name: Verify plugin - run: npm run verify -``` - -Keep publication and attestation after these gates. Continue using `GITHUB_REF_NAME` as the release title/tag instead of re-parsing `GITHUB_REF`. - -- [ ] **Step 6: Run validator and full gate locally** - -Run: - -```bash -node scripts/validate-release.mjs 0.5.1 -npm run verify -``` - -Expected: - -```text -Release metadata validated for 0.5.1. -``` - -followed by successful lint, build, Jest, and release-validator tests. - -Also prove invalid input fails: - -```bash -node scripts/validate-release.mjs test-0.5.1 -``` - -Expected: non-zero exit and `must be a semantic version`. - -- [ ] **Step 7: Commit release safety** - -```bash -git add \ - scripts/validate-release.mjs \ - scripts/__tests__/validate-release.test.mjs \ - package.json \ - .github/workflows/release.yml -git commit -m "chore: validate releases before publishing" -``` - ---- - -### Task 8: Full verification and manual Obsidian QA - -**Files:** - -- Review all changed files -- Do not add unrelated refactors - -- [ ] **Step 1: Run the complete automated gate from a clean dependency state** - -Run: - -```bash -npm ci -npm run verify -``` - -Expected: - -- ESLint: zero errors. -- TypeScript/esbuild production build: exit zero. -- Jest: all suites and tests pass. -- Node release-validator tests: all pass. - -- [ ] **Step 2: Confirm no generated or unrelated files entered the diff** - -Run: - -```bash -git status --short -git diff --check -git diff --stat main... -``` - -Expected: - -- Only files listed in this plan are changed. -- `git diff --check` prints nothing. -- `main.js` remains untracked/ignored as intended and is not committed. - -- [ ] **Step 3: Manually verify manual-order invariants in Obsidian** - -Use a test vault containing: - -```text -a.md -b.md -c.md -d.md -config.json -folder/old.md -``` - -Verify: - -1. Set manual order to `a, b, c, d, config`. -2. Search so only `c` and `d` are visible. -3. Drag `d` before `c`. -4. Clear search. -5. Confirm global order is `a, b, d, c, config`. -6. Hide `json`, enter manual mode, leave manual mode, then unhide `json`. -7. Confirm `config.json` retains its previous global position. -8. Rename `folder/old.md` to `folder/new.md`. -9. Reload the plugin. -10. Confirm the renamed file retains its manual position. - -- [ ] **Step 4: Manually verify search and reveal behavior** - -Verify: - -1. Search for `a`. -2. Change it to `ab` and immediately click clear. -3. Wait at least 300ms. -4. Confirm the search box and actual query both remain empty. -5. Apply a query that excludes the active file. -6. Run `Smart Explorer: Reveal active file`. -7. Confirm transient filters clear, tree mode opens, ancestors expand, and the active row scrolls into view. -8. Hide the active file's extension in settings and repeat reveal. -9. Confirm the command shows the explicit hidden-setting notice instead of silently doing nothing. - -- [ ] **Step 5: Review workflow logic in the pull request** - -Confirm the PR's `verify` check runs lint, build, Jest, and release-validator tests. Do not test the release workflow by pushing a disposable tag; metadata and ancestry checks are covered by local tests plus workflow review, and actual tag creation is reserved for the normal release process. - -- [ ] **Step 6: Push the branch and open a focused PR** - -Push the branch created in Task 0: - -```bash -git push -u origin fix/reliability-and-release-guards -``` - -PR title: - -```text -fix: harden explorer state and release verification -``` - -PR body: - -```markdown -## Summary - -- preserve global manual order through filters, hidden extensions, and renames -- cancel stale search renders and make reveal active file clear blocking filters -- remove quadratic tree-node lookup -- enforce lint/build/tests in CI and validate releases before publication - -## Verification - -- `npm ci` -- `npm run verify` -- manual Obsidian checks for filtered drag, hidden extensions, rename persistence, search clearing, and reveal active file -``` - -Do not include AI attribution, generated-by text, or co-author trailers. - ---- - -## Commit sequence - -1. `docs: add reliability fix plan` -2. `fix: map filtered drag positions to manual order` -3. `fix: preserve manual order across filters and renames` -4. `fix: cancel stale search renders` -5. `fix: clear blocking filters when revealing files` -6. `perf: avoid quadratic tree node lookup` -7. `chore: enforce lint in the verification gate` -8. `chore: validate releases before publishing` - -Each commit must pass its focused tests. The final branch must pass `npm run verify`. - -## Completion criteria - -- Manual order always contains each current `FileIndex` path exactly once. -- Hidden extensions and transient filters never remove paths from persisted order. -- Filtered drag changes visible relative order without moving across unrelated hidden anchors. -- File and folder rename updates are saved before reload. -- Clearing search cannot be undone by a stale timer. -- Reveal active file either shows the file or gives an explicit hidden-setting notice. -- Tree file-node lookup is path-indexed rather than quadratic. -- Required CI includes lint, build, Jest, and release-validator tests. -- Release publication rejects malformed/mismatched tags and commits not contained in `origin/main`. -- No unrelated behavior, settings, dependencies, or release version changes are introduced. 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 deleted file mode 100644 index 67060bd..0000000 --- a/docs/superpowers/plans/2026-08-22-smart-explorer-product-ux-optimization.md +++ /dev/null @@ -1,1918 +0,0 @@ -# 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. - -**Architecture:** Keep `FileIndex` as the single vault truth, move query/settings normalization into pure helpers, and split rendering into a lazy tree path and a keyed fixed-row-height list path. Use container focus plus `aria-activedescendant` so keyboard state survives list windowing, and run DOM-dependent tests only in explicitly marked jsdom suites with Obsidian DOM shims. Preserve the current product boundary: explicit create/rename/trash actions remain the only vault writes, manual order remains list-only, and no content database, preview system, network service, or full file-manager behavior is added. - -**Tech Stack:** TypeScript, Obsidian API 1.13.x, DOM/ARIA, Jest with ts-jest, esbuild, ESLint, Node.js scripts, CSS container queries. - ---- - -## Delivery strategy - -Ship this work as four independently testable pull requests. Do not combine them into one release-sized diff. - -Planning baseline on 2026-08-22: `main` is clean at version `0.5.4`; `npm run verify` passes 21 Jest suites / 132 tests and 6 release-validator tests. - -| PR | Outcome | Release gate | -|---|---|---| -| 1. Correctness and narrow-pane clarity | DOM test foundation, search/filter semantics, truthful non-Markdown filtering, list path context, settings validation, live settings refresh | `npm run verify`; desktop light/dark QA in `/Users/Roger/my-vault` | -| 2. Keyboard and assistive technology | Correct list/tree semantics, container focus, truthful expanded states, keyboard manual reorder | Unit tests plus keyboard-only and VoiceOver smoke test | -| 3. Large-vault performance | Keyed windowed list, lazy tree DOM, cached tree counts, cheaper drag geometry | 1k/10k synthetic tests and 5k-file test-vault QA | -| 4. Lifecycle and integration hardening | Serialized saves, surfaced async failures, active-file sync, folder subtree deletion, integration harness | Event-to-index-to-DOM tests and close-flush failure tests | - -Do not start PR 2 until PR 1 is merged. Do not start PR 3 until the final row structure and row heights from PR 1 are stable. PR 4 can start after PR 1, but should merge after PR 3 so its integration tests exercise the final renderers. - -## Scope and file map - -### Files to create - -- `src/explorer/queryNormalization.ts` - - Normalize search input once for both filter evaluation and active-state detection. -- `src/explorer/__tests__/queryNormalization.test.ts` - - Cover whitespace, case folding, and non-mutating behavior. -- `src/test-utils/obsidianDom.ts` - - Shim Obsidian DOM helpers and deterministic layout/rAF behavior for explicitly jsdom-based tests. -- `src/explorer/__tests__/SmartExplorerView.dom.test.ts` - - Cover toolbar controls, tree mounting, composite focus, and DOM state without changing the default Node test environment. -- `src/explorer/__tests__/DragSortManager.dom.test.ts` - - Cover cached layout geometry with deterministic element metrics. -- `src/settings/settings-normalization.ts` - - Validate persisted settings and migrate missing `lastViewMode` safely. -- `src/settings/__tests__/settings-normalization.test.ts` - - Cover corrupt enums, non-array manual order, duplicates, and extension normalization. -- `src/explorer/focusNavigation.ts` - - Pure key-to-focus/action resolution for list and tree rows. -- `src/explorer/__tests__/focusNavigation.test.ts` - - Cover Arrow/Home/End, folder open/close, activation, and keyboard reorder intent. -- `src/explorer/__tests__/VirtualList.test.ts` - - Prove bounded DOM nodes, node reuse, scroll restoration, and cleanup. -- `src/explorer/__tests__/SmartExplorerView.integration.test.ts` - - Exercise fake vault/workspace events through index, view state, and DOM. -- `scripts/prepare-large-vault-fixture.mjs` - - Create and remove a marker-protected synthetic fixture inside an explicitly supplied test vault. -- `scripts/__tests__/prepare-large-vault-fixture.test.mjs` - - Verify path guards and marker-protected cleanup. - -### Files to modify - -- `src/types.ts` - - Remove unused stale metadata fields; retain the existing extension query. -- `src/explorer/FileIndex.ts` - - Remove the stale attachment allowlist and unused metadata projection, maintain folder paths incrementally, and purge folder subtrees explicitly. -- `src/explorer/filters.ts`, `src/explorer/filterState.ts` - - Share normalized search semantics. -- `src/explorer/fileRow.ts` - - Add singular/plural file-count formatting. -- `src/explorer/TreeModel.ts` - - Store file counts on folder/root nodes. -- `src/explorer/VirtualList.ts` - - Replace rebuild-per-window rendering with keyed node reuse and a reachable threshold. -- `src/explorer/DragSortManager.ts` - - Cache row offsets at drag start instead of measuring every row on every pointer event. -- `src/explorer/SmartExplorerView.ts` - - Add the extension control, consistent empty states, list context rows, lazy tree children, ARIA/keyboard behavior, direct drag registration, settings refresh, active-file sync, and async error feedback. -- `src/settings/settings.ts`, `src/settings/settings-tab.ts`, `src/main.ts` - - Add validated loading, `lastViewMode`, serialized persistence, and live view refresh. -- `styles.css` - - Style two-line list rows, selected folders, focus-visible states, live-region-safe UI, and windowed content. -- `package.json`, `package-lock.json` - - Add the Jest 29 jsdom environment while keeping Node as the default for existing pure tests. -- Existing tests under `src/explorer/__tests__`, `src/settings/__tests__`, and `src/__tests__` - - Extend focused regression coverage without replacing current tests. -- `README.md`, `AGENTS.md`, `CLAUDE.md` - - Document the final renderer behavior, test-vault boundary, and verification commands after implementation. - -### Deliberately deferred - -- Content preview, backlinks, graph features, full-text indexing, and AI search. -- Saved views and a query DSL. -- Cross-folder move, bulk edit, tag editing, trash management, and other full file-manager features. -- Tree manual ordering and grouped manual ordering. -- Tag/heading search until P0/P1 are shipped and a live `metadataCache.on("changed")` design has its own reviewed plan. - ---- - -## PR 1 — Correctness and narrow-pane clarity - -### Task 0: Add explicit DOM test infrastructure - -**Files:** - -- Create: `src/test-utils/obsidianDom.ts` -- Create: `src/explorer/__tests__/SmartExplorerView.dom.test.ts` -- Modify: `package.json` -- Modify: `package-lock.json` - -- [x] **Step 1: Install the Jest 29 jsdom environment without changing the global environment** - -Run: - -```bash -npm install --save-dev jest-environment-jsdom@^29.7.0 -``` - -Expected: `package.json` and `package-lock.json` add `jest-environment-jsdom`; `jest.config.cjs` remains `testEnvironment: "node"` so existing pure tests keep their current runtime boundary. - -- [x] **Step 2: Add Obsidian DOM and deterministic-layout shims** - -Create `src/test-utils/obsidianDom.ts`: - -```ts -type TestElementInfo = { - cls?: string | string[]; - text?: string; - attr?: Record; -}; - -function applyInfo(element: HTMLElement, info?: TestElementInfo | string): void { - if (typeof info === "string") { - element.className = info; - return; - } - if (!info) return; - if (info.cls) { - const classes = Array.isArray(info.cls) ? info.cls : info.cls.split(/\s+/); - element.classList.add(...classes.filter(Boolean)); - } - if (info.text !== undefined) element.textContent = info.text; - for (const [name, value] of Object.entries(info.attr ?? {})) { - element.setAttribute(name, value); - } -} - -function createTestElement( - tag: K, - info?: TestElementInfo | string, -): HTMLElementTagNameMap[K] { - const element = document.createElement(tag); - applyInfo(element, info); - return element; -} - -Object.defineProperties(HTMLElement.prototype, { - empty: { - configurable: true, - value(this: HTMLElement) { this.replaceChildren(); }, - }, - setText: { - configurable: true, - value(this: HTMLElement, text: string) { this.textContent = text; }, - }, - createEl: { - configurable: true, - value( - this: HTMLElement, - tag: K, - info?: TestElementInfo | string, - ) { - const element = createTestElement(tag, info); - this.appendChild(element); - return element; - }, - }, - createDiv: { - configurable: true, - value(this: HTMLElement, info?: TestElementInfo | string) { - return this.createEl("div", info); - }, - }, - createSpan: { - configurable: true, - value(this: HTMLElement, info?: TestElementInfo | string) { - return this.createEl("span", info); - }, - }, -}); - -Object.assign(globalThis, { - activeDocument: document, - createEl: createTestElement, - createDiv: (info?: TestElementInfo | string) => createTestElement("div", info), - createSpan: (info?: TestElementInfo | string) => createTestElement("span", info), -}); - -if (!window.requestAnimationFrame) { - window.requestAnimationFrame = (callback) => window.setTimeout( - () => callback(performance.now()), - 0, - ); - window.cancelAnimationFrame = (id) => window.clearTimeout(id); -} - -export function mockElementBox( - element: HTMLElement, - box: { top?: number; left?: number; width?: number; height?: number }, -): void { - const top = box.top ?? 0; - const left = box.left ?? 0; - const width = box.width ?? 0; - const height = box.height ?? 0; - Object.defineProperties(element, { - clientHeight: { configurable: true, value: height }, - offsetHeight: { configurable: true, value: height }, - offsetTop: { configurable: true, value: top }, - }); - element.getBoundingClientRect = () => ({ - x: left, - y: top, - top, - left, - right: left + width, - bottom: top + height, - width, - height, - toJSON: () => ({}), - }); -} -``` - -- [x] **Step 3: Add a DOM-environment smoke test** - -Start `SmartExplorerView.dom.test.ts` with: - -```ts -/** @jest-environment jsdom */ - -import "../../test-utils/obsidianDom"; -import { mockElementBox } from "../../test-utils/obsidianDom"; - -describe("Obsidian DOM test foundation", () => { - it("provides Obsidian helpers and deterministic layout metrics", () => { - const parent = document.createElement("div"); - const child = parent.createDiv({ cls: "child", text: "Hello" }); - mockElementBox(child, { top: 44, width: 300, height: 44 }); - - expect(parent.querySelector(".child")?.textContent).toBe("Hello"); - expect(child.offsetTop).toBe(44); - expect(child.getBoundingClientRect().bottom).toBe(88); - - parent.empty(); - expect(parent.childElementCount).toBe(0); - }); -}); -``` - -- [x] **Step 4: Run Node and jsdom suites together** - -Run: - -```bash -npm test -- --runInBand src/explorer/__tests__/filters.test.ts src/explorer/__tests__/SmartExplorerView.dom.test.ts -``` - -Expected: the existing Node suite and new jsdom suite both pass without changing `jest.config.cjs`. - -- [x] **Step 5: Commit the test foundation** - -```bash -git add package.json package-lock.json src/test-utils/obsidianDom.ts src/explorer/__tests__/SmartExplorerView.dom.test.ts -git commit -m "test: add explorer DOM test foundation" -``` - -### Task 1: Normalize search and replace ambiguous attachment semantics - -**Files:** - -- Create: `src/explorer/queryNormalization.ts` -- Create: `src/explorer/__tests__/queryNormalization.test.ts` -- Modify: `src/explorer/filters.ts:1-36` -- Modify: `src/explorer/filterState.ts:1-20` -- Modify: `src/explorer/FileIndex.ts:1-52` -- Modify: `src/explorer/__tests__/filters.test.ts` -- Modify: `src/explorer/__tests__/FileIndex.test.ts` -- Modify: `src/types.ts:19-45` - -- [x] **Step 1: Add failing normalization and non-Markdown filter tests** - -Create `queryNormalization.test.ts`: - -```ts -import { normalizeSearchText } from "../queryNormalization"; - -describe("normalizeSearchText", () => { - it("trims and case-folds once at the query boundary", () => { - expect(normalizeSearchText(" Projects/ALPHA ")).toBe("projects/alpha"); - }); - - it("normalizes whitespace-only input to an empty query", () => { - expect(normalizeSearchText(" \t ")).toBe(""); - }); -}); -``` - -Add to `filters.test.ts`: - -```ts -it("does not hide every file for whitespace-only search", () => { - const query = { ...baseQuery, searchText: " " }; - expect(applyFilters(records, query)).toEqual(records); -}); -``` - -Add to `filters.test.ts`: - -```ts -it("treats every non-Markdown format as Non-Markdown without calling it an attachment", () => { - const records = ["note.md", "board.canvas", "table.base", "document.docx", "data.csv"] - .map(makeRecord); - const result = applyFilters(records, { ...baseQuery, fileKind: "non-markdown" }); - expect(result.map((record) => record.path)).toEqual([ - "board.canvas", - "table.base", - "document.docx", - "data.csv", - ]); -}); -``` - -- [x] **Step 2: Run the focused tests and verify the regressions fail** - -Run: - -```bash -npm test -- --runInBand src/explorer/__tests__/queryNormalization.test.ts src/explorer/__tests__/filters.test.ts src/explorer/__tests__/FileIndex.test.ts -``` - -Expected: the new module and `non-markdown` kind are missing, and whitespace search returns no records. - -- [x] **Step 3: Add the shared normalization helper** - -Create `queryNormalization.ts`: - -```ts -export function normalizeSearchText(value: string): string { - return value.trim().toLowerCase(); -} -``` - -Technical-path search uses locale-independent case folding so the same ASCII path matches in every system locale. - -Use it in both `filters.ts` and `filterState.ts`: - -```ts -const searchText = normalizeSearchText(query.searchText); -if (searchText) { - result = result.filter((record) => - record.basename.toLowerCase().includes(searchText) || - record.path.toLowerCase().includes(searchText), - ); -} -``` - -```ts -normalizeSearchText(query.searchText).length > 0 -``` - -- [x] **Step 4: Remove attachment classification and the unused metadata projection** - -Change `FileKind` and remove `isAttachment` from `FileRecord`: - -```ts -export type FileKind = "all" | "markdown" | "non-markdown" | "images"; -``` - -Delete `ATTACHMENT_EXTENSIONS`, `isAttachment`, and the `isAttachment` assignment from `FileIndex.ts`. Filter the new kind directly from the canonical Markdown flag: - -```ts -if (query.fileKind === "non-markdown") { - result = result.filter((record) => !record.isMarkdown); -} -``` - -Use the UI label `Non-Markdown`; `.canvas` and `.base` are intentionally included because the filter describes file format rather than claiming those Obsidian document formats are attachments. - -Remove `frontmatter`, `tags`, and `firstHeading` from `FileRecord`, remove their population from `normalizeFileRecord`, and keep the `MetadataCache | null` parameter temporarily so this change does not widen the call-site diff. Rename it to `_cache` to satisfy lint: - -```ts -export function normalizeFileRecord( - file: TFile, - _cache: MetadataCache | null, -): FileRecord { -``` - -- [x] **Step 5: Run focused tests and commit** - -Run: - -```bash -npm test -- --runInBand src/explorer/__tests__/queryNormalization.test.ts src/explorer/__tests__/filters.test.ts src/explorer/__tests__/filterState.test.ts src/explorer/__tests__/FileIndex.test.ts -npm run lint -``` - -Expected: all focused tests and lint pass. `normalizeFileRecord` no longer calls `metadataCache.getFileCache`, which becomes the first measurable indexing improvement for large vaults. - -Commit: - -```bash -git add src/types.ts src/explorer/FileIndex.ts src/explorer/filters.ts src/explorer/filterState.ts src/explorer/queryNormalization.ts src/explorer/__tests__ -git commit -m "fix: normalize explorer filters" -``` - -### Task 2: Expose the existing extension filter and make filter state truthful - -**Files:** - -- Modify: `src/explorer/SmartExplorerView.ts:65-76,252-456,563-671` -- Modify: `src/explorer/__tests__/SmartExplorerView.dom.test.ts` -- Modify: `styles.css:17-151,318-327` - -- [x] **Step 1: Add failing tests for dynamic extension options and toggle state** - -Add to the jsdom-backed `SmartExplorerView.dom.test.ts`; do not move the existing Node tests into jsdom. Use a prototype-only view with a real select element: - -```ts -it("builds sorted extension options from visible records", () => { - const view = Object.create(SmartExplorerView.prototype) as any; - view.extensionSelect = document.createElement("select"); - view.query = { extension: "pdf" }; - view.syncExtensionOptions([ - makeRecord("note.md"), - makeRecord("image.png"), - makeRecord("document.pdf"), - ]); - -expect(Array.from(view.extensionSelect.options).map((option: HTMLOptionElement) => [ - option.value, - option.text, -])).toEqual([ - ["", "All extensions"], - ["md", ".md"], - ["pdf", ".pdf"], - ["png", ".png"], -]); -}); - -it("keeps filter disclosure name and expanded state truthful", () => { - const view = Object.create(SmartExplorerView.prototype) as any; - const button = document.createElement("button"); - const panel = document.createElement("div"); - panel.classList.add("is-collapsed"); - - view.updateDisclosureButton(button, panel, "filters", false); - expect(button.getAttribute("aria-expanded")).toBe("false"); - expect(button.getAttribute("aria-label")).toBe("Show filters"); - - panel.classList.remove("is-collapsed"); - view.updateDisclosureButton(button, panel, "filters", false); - expect(button.getAttribute("aria-expanded")).toBe("true"); - expect(button.getAttribute("aria-label")).toBe("Hide filters"); -}); -``` - -- [x] **Step 2: Verify the tests fail** - -Run: - -```bash -npm test -- --runInBand src/explorer/__tests__/SmartExplorerView.dom.test.ts -``` - -Expected: `extensionSelect` and truthful expanded-state behavior do not exist. - -- [x] **Step 3: Add a labeled dynamic extension select** - -Add the property: - -```ts -private extensionSelect: HTMLSelectElement | null = null; -``` - -Extend `createSelect` with an explicit label: - -```ts -private createSelect( - parent: HTMLElement, - options: { value: string; text: string }[], - cls: string, - ariaLabel: string, - onChange: (value: string) => void, - value?: string, -) { - const select = parent.createEl("select", { cls }); - select.setAttribute("aria-label", ariaLabel); - for (const option of options) { - select.createEl("option", { value: option.value, text: option.text }); - } - if (value !== undefined) select.value = value; - select.addEventListener("change", () => onChange(select.value)); - return select; -} -``` - -Create the extension control after file kind: - -```ts -this.extensionSelect = this.createSelect( - filterRow, - [{ value: "", text: "All extensions" }], - "smart-explorer-extension", - "File extension", - (value) => { - this.query.extension = value || null; - this.renderList(); - }, - this.query.extension ?? "", -); -``` - -Update existing select calls with `Sort order`, `Group files`, `File kind`, and `Modified date` labels. - -- [x] **Step 4: Synchronize extension options from the current visible projection** - -Add: - -```ts -private syncExtensionOptions(records: FileRecord[]): void { - if (!this.extensionSelect) return; - const selected = this.query.extension ?? ""; - const extensions = Array.from(new Set(records.map((record) => record.extension))) - .filter(Boolean) - .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); - this.extensionSelect.replaceChildren(); - this.extensionSelect.createEl("option", { value: "", text: "All extensions" }); - for (const extension of extensions) { - this.extensionSelect.createEl("option", { value: extension, text: `.${extension}` }); - } - if (selected && !extensions.includes(selected)) { - this.query.extension = null; - } - this.extensionSelect.value = this.query.extension ?? ""; -} -``` - -Call `syncExtensionOptions(records)` after applying hidden extensions and before building sections. Do not reset `query.extension` when file kind changes; the two filters should compose. - -- [x] **Step 5: Centralize search/filter toggle state** - -Add: - -```ts -private updateDisclosureButton( - button: HTMLButtonElement | null, - panel: HTMLElement | null, - label: string, - active: boolean, -): void { - if (!button || !panel) return; - const expanded = !panel.classList.contains("is-collapsed"); - button.setAttribute("aria-expanded", String(expanded)); - button.setAttribute("aria-label", `${expanded ? "Hide" : "Show"} ${label}`); - button.classList.toggle("is-active", expanded || active); -} -``` - -Give every view instance a unique ID prefix rather than fixed document IDs: - -```ts -let nextExplorerViewInstanceId = 0; - -export class SmartExplorerView extends ItemView { - private readonly domIdPrefix = `smart-explorer-${++nextExplorerViewInstanceId}`; - - private get searchPanelId(): string { - return `${this.domIdPrefix}-search`; - } - - private get filterPanelId(): string { - return `${this.domIdPrefix}-filters`; - } -} -``` - -Assign those IDs to the panels, set matching `aria-controls` on the buttons, and add a two-view DOM test asserting all four panel IDs are unique. Call `updateDisclosureButton` from toggle handlers, `updateFileCount`, Escape handling, and `rebuildView`. - -- [x] **Step 6: Verify and commit** - -Run: - -```bash -npm test -- --runInBand src/explorer/__tests__/SmartExplorerView.dom.test.ts src/explorer/__tests__/filters.test.ts -npm run build -``` - -Expected: extension selection composes with kind/date/search filters, and both disclosure buttons expose truthful labels and expanded states. - -Commit: - -```bash -git add src/explorer/SmartExplorerView.ts src/explorer/__tests__/SmartExplorerView.dom.test.ts styles.css -git commit -m "feat: expose extension filtering" -``` - -### Task 3: Preserve file context in narrow list view and fix visible state details - -**Files:** - -- Modify: `src/explorer/fileRow.ts` -- Modify: `src/explorer/__tests__/fileRow.test.ts` -- Modify: `src/explorer/SmartExplorerView.ts:563-681,790-904,931-944,1489-1500` -- Modify: `styles.css:160-323,329-368` - -- [x] **Step 1: Add failing count-format tests** - -```ts -import { formatFileCount, formatVisibleFileCount } from "../fileRow"; - -it("uses singular grammar", () => { - expect(formatFileCount(1)).toBe("1 file"); -}); - -it("formats filtered totals", () => { - expect(formatVisibleFileCount(2, 10)).toBe("2 of 10 files"); -}); -``` - -- [x] **Step 2: Implement count helpers** - -```ts -export function formatFileCount(count: number): string { - return `${count} ${count === 1 ? "file" : "files"}`; -} - -export function formatVisibleFileCount(displayed: number, total: number): string { - return displayed === total - ? formatFileCount(total) - : `${displayed} of ${formatFileCount(total)}`; -} -``` - -Use `formatFileCount(node.fileCount)` for folders and `formatVisibleFileCount` for the toolbar count. - -- [x] **Step 3: Render a stable two-line identity block in list mode** - -Replace the name/meta construction inside `createRowElement` with: - -```ts -const identity = row.createSpan({ cls: "smart-explorer-row-identity" }); -if (this.inlineEdit?.kind === "rename-file" && this.inlineEdit.path === record.path) { - identity.appendChild(this.createInlineEditInput(this.inlineEdit.value, "File name")); -} else { - identity.createSpan({ cls: "smart-explorer-row-name", text: record.basename }); -} -const meta = identity.createSpan({ cls: "smart-explorer-row-meta" }); -meta.createSpan({ cls: "smart-explorer-row-parent", text: formatFileParent(record.parentPath) }); -meta.createSpan({ cls: "smart-explorer-row-date", text: formatFileModifiedDate(record.mtime) }); -``` - -In `renderListContent`, toggle `.is-tree-view` and `.is-list-view` on `listContainer` from the resolved mode. - -- [x] **Step 4: Add narrow-pane CSS and selected-folder feedback** - -```css -.smart-explorer-row-identity { - display: flex; - flex: 1 1 auto; - min-width: 0; -} - -.smart-explorer-list.is-list-view .smart-explorer-row { - min-height: 44px; -} - -.smart-explorer-list.is-list-view .smart-explorer-row-identity { - flex-direction: column; - gap: 1px; -} - -.smart-explorer-list.is-list-view .smart-explorer-row-meta { - display: flex; - min-width: 0; -} - -.smart-explorer-list.is-list-view .smart-explorer-row-parent { - flex: 1 1 auto; - min-width: 0; -} - -.smart-explorer-list.is-list-view .smart-explorer-row-date { - display: none; -} - -body.is-phone .smart-explorer-list.is-list-view .smart-explorer-row, -body.is-tablet .smart-explorer-list.is-list-view .smart-explorer-row { - min-height: 52px; -} - -@container (min-width: 420px) { - .smart-explorer-list.is-list-view .smart-explorer-row-date { - display: inline; - } -} - -.smart-explorer-tree-folder-summary.is-selected { - background: var(--interactive-accent); - color: var(--text-on-accent); -} - -.smart-explorer-row:focus-visible, -.smart-explorer-tree-folder-summary:focus-visible { - outline: 2px solid var(--interactive-accent); - outline-offset: -2px; -} -``` - -Remove the old rule that hides all row metadata below 420px. - -- [x] **Step 5: Distinguish empty-vault, hidden-all, and no-match states** - -Implement these exact messages: - -```ts -if (allRecords.length === 0 && folderPaths.length === 0 && !hasInlineCreate) { - this.renderEmptyState("No files in vault."); - return; -} -if (records.length === 0 && allRecords.length > 0 && !hasInlineCreate) { - this.renderEmptyState("All files are hidden by extension settings."); - return; -} -``` - -Keep the clear action only for `No files match the current search or filters.` Add `role="status"` to all empty-state containers. - -- [x] **Step 6: Verify and commit** - -Run: - -```bash -npm test -- --runInBand src/explorer/__tests__/fileRow.test.ts src/explorer/__tests__/SmartExplorerView.test.ts -npm run build -``` - -Manually verify tree and list modes at 300px, 420px, and 600px sidebar widths in both light and dark themes. - -Commit: - -```bash -git add src/explorer/fileRow.ts src/explorer/__tests__/fileRow.test.ts src/explorer/SmartExplorerView.ts src/explorer/__tests__/SmartExplorerView.test.ts styles.css -git commit -m "fix: preserve list path context" -``` - -### Task 4: Validate settings, refresh live views, and remember view mode - -**Files:** - -- Create: `src/settings/settings-normalization.ts` -- Create: `src/settings/__tests__/settings-normalization.test.ts` -- Modify: `src/settings/settings.ts` -- Modify: `src/settings/settings-tab.ts:50-121` -- Modify: `src/settings/__tests__/settings-tab.test.ts` -- Modify: `src/main.ts:8-84` -- Modify: `src/__tests__/main.test.ts` -- Modify: `src/explorer/SmartExplorerView.ts:56-109,252-264` - -- [x] **Step 1: Write failing normalization tests** - -```ts -import { normalizeSettings } from "../settings-normalization"; - -describe("normalizeSettings", () => { - it("rejects corrupt enums and arrays", () => { - expect(normalizeSettings({ - defaultSort: "random", - defaultGroup: 42, - hiddenExtensions: "png", - manualOrder: null, - lastViewMode: "grid", - })).toEqual({ - defaultSort: "name-asc", - defaultGroup: "none", - hiddenExtensions: [], - manualOrder: [], - lastViewMode: "tree", - }); - }); - - it("normalizes and deduplicates string arrays", () => { - expect(normalizeSettings({ - hiddenExtensions: [".PNG", " png ", "CSS", 9], - manualOrder: ["b.md", "a.md", "b.md", 9], - })).toMatchObject({ - hiddenExtensions: ["png", "css"], - manualOrder: ["b.md", "a.md"], - }); - }); -}); -``` - -- [x] **Step 2: Implement strict normalization** - -Add `lastViewMode` to settings: - -```ts -export type SmartExplorerSettings = { - defaultSort: SortMode; - defaultGroup: GroupMode; - hiddenExtensions: string[]; - manualOrder: string[]; - lastViewMode: ViewMode; -}; -``` - -Create `settings-normalization.ts` with enum sets and: - -```ts -const SORT_MODES = new Set([ - "name-asc", "name-desc", "modified-new", "modified-old", - "created-new", "created-old", "extension", "size", "manual", -]); -const GROUP_MODES = new Set([ - "none", "folder", "extension", "modified-month", "top-folder", -]); -const VIEW_MODES = new Set(["tree", "list"]); - -function uniqueStrings(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return Array.from(new Set(value.filter((item): item is string => typeof item === "string"))); -} - -function normalizeExtensions(value: unknown): string[] { - return Array.from(new Set( - uniqueStrings(value) - .map((extension) => extension.trim().toLocaleLowerCase().replace(/^\.+/, "")) - .filter(Boolean), - )); -} - -export function normalizeSettings(value: unknown): SmartExplorerSettings { - const saved = value && typeof value === "object" - ? value as Record - : {}; - return { - defaultSort: SORT_MODES.has(saved.defaultSort as SortMode) - ? saved.defaultSort as SortMode - : DEFAULT_SETTINGS.defaultSort, - defaultGroup: GROUP_MODES.has(saved.defaultGroup as GroupMode) - ? saved.defaultGroup as GroupMode - : DEFAULT_SETTINGS.defaultGroup, - hiddenExtensions: normalizeExtensions(saved.hiddenExtensions), - manualOrder: uniqueStrings(saved.manualOrder), - lastViewMode: VIEW_MODES.has(saved.lastViewMode as ViewMode) - ? saved.lastViewMode as ViewMode - : DEFAULT_SETTINGS.lastViewMode, - }; -} -``` - -- [x] **Step 3: Load normalized settings and expose live refresh** - -In `main.ts`: - -```ts -async loadSettings() { - this.settings = normalizeSettings(await this.loadData()); -} - -refreshExplorerViews(): void { - for (const leaf of this.app.workspace.getLeavesOfType(SMART_EXPLORER_VIEW_TYPE)) { - if (leaf.view instanceof SmartExplorerView) leaf.view.refreshSettingsProjection(); - } -} -``` - -In `SmartExplorerView`, initialize `viewMode` from `settings.lastViewMode` only in the constructor. Persist a new value after that view's user-triggered toggle, but do not broadcast the mode change to other open leaves: - -```ts -private setViewMode(viewMode: ViewMode): void { - this.viewMode = viewMode; - this.plugin.settings.lastViewMode = viewMode; - void this.plugin.saveSettings().catch((error) => { - new Notice(`Could not save view mode: ${error instanceof Error ? error.message : String(error)}`); - }); - this.renderList(); -} - -refreshSettingsProjection(): void { - this.renderList(); -} -``` - -Do not replace the current view mode, query sort, or query group when settings refresh. `lastViewMode` is the default for the next view instance; already-open leaves remain independent. Hidden extensions and reset manual order update live because they change the shared displayed projection. - -- [x] **Step 4: Refresh open views after relevant settings changes** - -After hidden-extension save: - -```ts -await this.plugin.saveSettings(); -this.plugin.refreshExplorerViews(); -``` - -After manual-order reset, save successfully and call `resetExplorerManualOrderViews()`. That helper refreshes open leaves and clears each view's undo stack so a reset cannot be undone from stale view state. - -Change the default sort/group descriptions to `Used when a new Smart Explorer view opens.` so users understand that existing views are unchanged. - -- [x] **Step 5: Verify and commit PR 1** - -Run: - -```bash -npm test -- --runInBand src/settings/__tests__ src/__tests__/main.test.ts src/explorer/__tests__/SmartExplorerView.test.ts src/explorer/__tests__/SmartExplorerView.dom.test.ts -npm run verify -``` - -Expected baseline plus new tests: all Jest suites, lint, build, and release tests pass. - -Commit: - -```bash -git add src/settings src/main.ts src/__tests__/main.test.ts src/explorer/SmartExplorerView.ts src/explorer/__tests__/SmartExplorerView.test.ts src/explorer/__tests__/SmartExplorerView.dom.test.ts -git commit -m "fix: validate and refresh explorer settings" -``` - -PR title: `fix: improve explorer correctness and narrow-pane clarity` - -Execution note (2026-08-22): `npm run verify` passed (25 Jest suites / 181 tests, plus 6 release tests). Real Obsidian validation completed in dark theme at 300px, approximately 520px, and 600px widths, plus a light-theme pass for control, text, and selected-row contrast. The Non-Markdown filter returned 19 of 238 files, combining it with `.canvas` returned the expected single canvas file, and whitespace-only search preserved the filtered result. The test vault was returned to its original dark theme and cleared filter state after verification. - ---- - -## PR 2 — Keyboard and assistive technology - -### Task 5: Establish correct semantics with container-managed focus - -**Files:** - -- Create: `src/explorer/focusNavigation.ts` -- Create: `src/explorer/__tests__/focusNavigation.test.ts` -- Modify: `src/explorer/SmartExplorerView.ts:563-920,1489-1500` -- Modify: `src/explorer/__tests__/SmartExplorerView.test.ts` -- Modify: `src/explorer/__tests__/SmartExplorerView.dom.test.ts` -- Modify: `styles.css:236-275` - -- [ ] **Step 1: Write failing pure navigation tests** - -```ts -import { resolveFocusNavigation } from "../focusNavigation"; - -describe("resolveFocusNavigation", () => { - it.each([ - ["ArrowDown", 2, 5, 3], - ["ArrowUp", 2, 5, 1], - ["Home", 2, 5, 0], - ["End", 2, 5, 4], - ])("maps %s to the expected visible index", (key, current, count, index) => { - expect(resolveFocusNavigation({ key, current, count, folderExpanded: null })) - .toEqual({ type: "focus", index }); - }); - - it("closes an expanded folder on ArrowLeft", () => { - expect(resolveFocusNavigation({ key: "ArrowLeft", current: 1, count: 4, folderExpanded: true })) - .toEqual({ type: "collapse" }); - }); - - it("opens a collapsed folder on ArrowRight", () => { - expect(resolveFocusNavigation({ key: "ArrowRight", current: 1, count: 4, folderExpanded: false })) - .toEqual({ type: "expand" }); - }); -}); -``` - -- [ ] **Step 2: Implement the pure resolver** - -```ts -export type FocusNavigationAction = - | { type: "focus"; index: number } - | { type: "expand" } - | { type: "collapse" } - | { type: "activate" } - | { type: "none" }; - -export function resolveFocusNavigation(input: { - key: string; - current: number; - count: number; - folderExpanded: boolean | null; -}): FocusNavigationAction { - if (input.key === "Home") return { type: "focus", index: 0 }; - if (input.key === "End") return { type: "focus", index: Math.max(0, input.count - 1) }; - if (input.key === "ArrowDown") return { type: "focus", index: Math.min(input.count - 1, input.current + 1) }; - if (input.key === "ArrowUp") return { type: "focus", index: Math.max(0, input.current - 1) }; - if (input.key === "ArrowRight" && input.folderExpanded === false) return { type: "expand" }; - if (input.key === "ArrowLeft" && input.folderExpanded === true) return { type: "collapse" }; - if (input.key === "Enter" || input.key === " ") return { type: "activate" }; - return { type: "none" }; -} -``` - -- [ ] **Step 3: Apply mode-specific roles** - -At render start: - -```ts -const treeMode = mode === "tree"; -this.listContainer.setAttribute("role", treeMode ? "tree" : "listbox"); -this.listContainer.setAttribute("aria-label", treeMode ? "Vault files" : "Vault file list"); -``` - -For list files use `role="option"`; for tree folders/files use `role="treeitem"`, `aria-level`, and `aria-selected`. Give each `.smart-explorer-tree-children` `role="group"`. Set `aria-expanded` on folder summaries whenever the details state changes. - -The composite container receives `tabindex="0"`; rows receive stable unique IDs but no tab stop. Track logical keyboard state independently from file selection: - -```ts -private activeItemPath: string | null = null; - -private getItemDomId(path: string): string { - return `${this.domIdPrefix}-item-${encodeURIComponent(path)}`; -} -``` - -Each list option and tree item gets `id=getItemDomId(path)` plus `data-nav-path=path`. Set `aria-activedescendant` on the container only when the active item is mounted. This focus model is intentional: the container retains DOM focus when PR 3 windows list rows. - -- [ ] **Step 4: Replace sibling-only Arrow navigation with visible-row navigation** - -Add: - -```ts -private getVisibleNavigationItems(): HTMLElement[] { - if (!this.listContainer) return []; - return Array.from(this.listContainer.querySelectorAll( - '[role="option"], [role="treeitem"]', - )); -} -``` - -Tree lazy mounting guarantees that this collection contains only visible branches. Add: - -```ts -private setActiveItem(path: string | null): void { - this.activeItemPath = path; - if (!this.listContainer) return; - const id = path ? this.getItemDomId(path) : null; - const mounted = id ? activeDocument.getElementById(id) : null; - if (mounted && this.listContainer.contains(mounted)) { - this.listContainer.setAttribute("aria-activedescendant", id!); - } else { - this.listContainer.removeAttribute("aria-activedescendant"); - } - for (const item of this.getVisibleNavigationItems()) { - item.classList.toggle("is-keyboard-active", item.dataset.navPath === path); - } -} -``` - -Handle Arrow/Home/End on the container, resolve the next logical index, call `setActiveItem`, and use `element.scrollIntoView({ block: "nearest" })`. For folder expand/collapse, set `details.open`; for activation, run the existing row/folder action. When closing a folder whose descendant is active, move `activeItemPath` to the folder before unmounting its children. - -- [ ] **Step 5: Keep selection attributes synchronized** - -Extend `highlightSelected`: - -```ts -const selected = row.dataset.path === this.selectedPath; -row.classList.toggle("is-selected", selected); -row.setAttribute("aria-selected", String(selected)); -``` - -Do the same for folder summaries using `selectedFolderPath`. - -Selection and active keyboard position are separate: `aria-selected` follows the opened/selected item, while `.is-keyboard-active` and `aria-activedescendant` follow keyboard navigation. - -- [ ] **Step 6: Verify semantics and commit** - -Run: - -```bash -npm test -- --runInBand src/explorer/__tests__/focusNavigation.test.ts src/explorer/__tests__/SmartExplorerView.test.ts src/explorer/__tests__/SmartExplorerView.dom.test.ts -npm run build -``` - -Manual keyboard acceptance: - -1. Tab enters the explorer once, not once per row. -2. Arrow Up/Down moves through every currently visible row. -3. Arrow Right opens a folder; Arrow Left closes it. -4. Enter/Space opens a file. -5. `document.activeElement` remains the list/tree container while `aria-activedescendant` changes. -6. Focus remains visible in light and dark themes. - -Commit: - -```bash -git add src/explorer/focusNavigation.ts src/explorer/__tests__/focusNavigation.test.ts src/explorer/SmartExplorerView.ts src/explorer/__tests__/SmartExplorerView.test.ts src/explorer/__tests__/SmartExplorerView.dom.test.ts styles.css -git commit -m "fix: add accessible explorer navigation" -``` - -### Task 6: Add keyboard manual reorder and live feedback - -**Files:** - -- Modify: `src/explorer/manualOrder.ts` -- Modify: `src/explorer/__tests__/manualOrder.test.ts` -- Modify: `src/explorer/SmartExplorerView.ts:252-405,855-904,1251-1284` -- Modify: `styles.css:508-515` - -- [ ] **Step 1: Add failing pure reorder-intent tests** - -```ts -it("moves a visible manual item one position by keyboard", () => { - const sections = [{ id: "all", records: ["a.md", "b.md", "c.md"].map(makeRecord) }]; - expect(reorderManualOrderByDelta(["a.md", "b.md", "c.md"], "b.md", -1, sections)) - .toEqual(["b.md", "a.md", "c.md"]); - expect(reorderManualOrderByDelta(["a.md", "b.md", "c.md"], "b.md", 1, sections)) - .toEqual(["a.md", "c.md", "b.md"]); -}); -``` - -- [ ] **Step 2: Add the wrapper around existing reorder semantics** - -```ts -export function reorderManualOrderByDelta( - currentOrder: string[], - draggedPath: string, - delta: -1 | 1, - sections: ManualOrderSection[], -): string[] { - const visible = sections.flatMap((section) => section.records.map((record) => record.path)); - const index = visible.indexOf(draggedPath); - if (index < 0) return currentOrder; - const target = Math.max(0, Math.min(visible.length - 1, index + delta)); - if (target === index) return currentOrder; - const dropBoundary = delta < 0 ? target : target + 1; - return reorderManualOrder(currentOrder, draggedPath, dropBoundary, sections); -} -``` - -- [ ] **Step 3: Add a polite live region and keyboard shortcut** - -Create once in the toolbar: - -```ts -this.liveRegion = toolbar.createDiv({ cls: "smart-explorer-sr-only" }); -this.liveRegion.setAttribute("aria-live", "polite"); -this.liveRegion.setAttribute("aria-atomic", "true"); -``` - -In manual mode, handle `Alt+ArrowUp` and `Alt+ArrowDown` on the composite container, call the delta helper for `activeItemPath`, persist through the existing undo/save path, keep focus on `listContainer`, restore `aria-activedescendant` to the moved path after render, and announce `Moved to position of .` - -- [ ] **Step 4: Add screen-reader-only CSS** - -```css -.smart-explorer-sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} -``` - -- [ ] **Step 5: Verify and commit PR 2** - -Run: - -```bash -npm test -- --runInBand src/explorer/__tests__/manualOrder.test.ts src/explorer/__tests__/focusNavigation.test.ts src/explorer/__tests__/SmartExplorerView.test.ts -npm run verify -``` - -Complete a VoiceOver smoke test: list/tree role announced once, file/folder names announced, folder expanded state announced, selection announced, and keyboard reorder feedback announced. - -Commit: - -```bash -git add src/explorer/manualOrder.ts src/explorer/__tests__/manualOrder.test.ts src/explorer/SmartExplorerView.ts src/explorer/__tests__/SmartExplorerView.test.ts styles.css -git commit -m "feat: add keyboard manual reordering" -``` - -PR title: `fix: make explorer navigation accessible` - ---- - -## PR 3 — Large-vault performance - -### Task 7: Store tree counts and lazily mount closed folders - -**Files:** - -- Modify: `src/explorer/TreeModel.ts:5-131` -- Modify: `src/explorer/__tests__/TreeModel.test.ts` -- Modify: `src/explorer/SmartExplorerView.ts:601-623,790-846,1515-1524` -- Modify: `src/explorer/__tests__/SmartExplorerView.test.ts` - -- [ ] **Step 1: Add failing tree-count tests** - -```ts -it("stores recursive file counts on every folder", () => { - const tree = buildTree([ - makeRecord("a/one.md"), - makeRecord("a/b/two.md"), - makeRecord("a/b/three.md"), - ], baseQuery); - const a = tree.children[0] as ExplorerTreeFolderNode; - const b = a.children.find((node) => node.type === "folder") as ExplorerTreeFolderNode; - expect(tree.fileCount).toBe(3); - expect(a.fileCount).toBe(3); - expect(b.fileCount).toBe(2); -}); -``` - -- [ ] **Step 2: Add `fileCount` to tree nodes and compute it once** - -Add `fileCount: number` to root and folder types, initialize it to zero, and run one post-order pass after sorting: - -```ts -function populateFileCounts(node: ExplorerTreeRoot | ExplorerTreeFolderNode): number { - const count = node.children.reduce((total, child) => - total + (child.type === "file" ? 1 : populateFileCounts(child)), 0); - node.fileCount = count; - return count; -} -``` - -Delete `countTreeFiles` from `SmartExplorerView.ts` and render `formatFileCount(node.fileCount)`. - -- [ ] **Step 3: Mount folder children only when the folder is open** - -Extract: - -```ts -private mountTreeChildren( - container: HTMLElement, - node: ExplorerTreeFolderNode, -): void { - container.empty(); - const inlineCreate = this.createInlineCreateElement(node.path, node.depth + 1); - if (inlineCreate) container.appendChild(inlineCreate); - for (const child of node.children) { - container.appendChild(this.createTreeNodeElement(child)); - } -} -``` - -When creating a folder, create the children container but call `mountTreeChildren` only when `details.open` is true. On toggle open, mount; on toggle closed, `children.empty()`. Search/reveal correctness is preserved because `shouldOpenTreeFolder` already opens filter matches and selected ancestors. - -- [ ] **Step 4: Remove the duplicate tree-mode filter/sort/group pipeline** - -Split `renderListContent` immediately after `effectiveQuery`: - -```ts -if (mode === "tree") { - this.syncSelectedPathFromActiveFile(); - const tree = buildTree(records, effectiveQuery, this.manualOrderIndex, folderPaths); - const displayed = tree.fileCount; - if (displayed === 0 && folderPaths.length === 0 && !hasInlineCreate) { - this.renderNoMatches(); - return; - } - this.visibleTreeFolderPaths = collectTreeFolderPaths(tree.children); - const rootCreate = this.createInlineCreateElement("", 0); - if (rootCreate) this.listContainer.appendChild(rootCreate); - for (const node of tree.children) { - this.listContainer.appendChild(this.createTreeNodeElement(node)); - } - this.updateFileCount(displayed, records.length); - this.updateViewModeControl(); - this.updateManualOrderControls(); - return; -} - -const sections = buildSections(records, effectiveQuery, this.manualOrderIndex); -const displayed = sections.reduce((total, section) => total + section.records.length, 0); -``` - -Tree mode now filters and sorts exactly once through `buildTree`; list mode continues to use `buildSections`. - -- [ ] **Step 5: Guard global expansion in large vaults** - -Add: - -```ts -const EAGER_EXPAND_FILE_LIMIT = 2000; -``` - -If the user requests `Open all folders` above that limit, leave existing expansion state unchanged and show `Open folders individually in vaults over 2,000 files.` This limit prevents a toolbar action from intentionally defeating lazy mounting. Keep `Close all folders` available at every size. - -- [ ] **Step 6: Prove closed folders do not create descendant DOM** - -```ts -it("does not mount descendants of a closed folder", () => { - const view = Object.create(SmartExplorerView.prototype) as any; - view.query = baseQuery; - view.treeExpandedPaths = new Set(); - view.selectedPath = null; - view.selectedFolderPath = null; - view.inlineEdit = null; - view.updateTreeToggleControl = jest.fn(); - view.showTooltip = jest.fn(); - view.hideTooltip = jest.fn(); - view.attachLongPressMenu = jest.fn(); - view.createRowElement = (record: FileRecord) => { - const row = document.createElement("div"); - row.className = "smart-explorer-row"; - row.dataset.path = record.path; - return row; - }; - const tree = buildTree( - Array.from({ length: 1000 }, (_, index) => makeRecord(`closed/file-${index}.md`)), - baseQuery, - ); - const folder = tree.children[0] as ExplorerTreeFolderNode; - - const details = view.createTreeNodeElement(folder) as HTMLDetailsElement; - expect(details.querySelectorAll(".smart-explorer-row")).toHaveLength(0); - - details.open = true; - details.dispatchEvent(new Event("toggle")); - expect(details.querySelectorAll(".smart-explorer-row")).toHaveLength(1000); -}); -``` - -- [ ] **Step 7: Verify and commit** - -Run: - -```bash -npm test -- --runInBand src/explorer/__tests__/TreeModel.test.ts src/explorer/__tests__/SmartExplorerView.dom.test.ts -npm run build -``` - -Commit: - -```bash -git add src/explorer/TreeModel.ts src/explorer/__tests__/TreeModel.test.ts src/explorer/SmartExplorerView.ts src/explorer/__tests__/SmartExplorerView.dom.test.ts -git commit -m "perf: lazily render tree folders" -``` - -### Task 8: Replace disabled virtualization with keyed windowed rendering - -**Files:** - -- Modify: `src/explorer/VirtualList.ts:1-86` -- Create: `src/explorer/__tests__/VirtualList.test.ts` -- Modify: `src/explorer/SmartExplorerView.ts:637-655` -- Modify: `src/explorer/__tests__/SmartExplorerView.dom.test.ts` -- Modify: `styles.css:539-546` - -- [ ] **Step 1: Add failing bounded-DOM and reuse tests** - -Start the file with the jsdom directive and shared shims: - -```ts -/** @jest-environment jsdom */ - -import "../../test-utils/obsidianDom"; -import { mockElementBox } from "../../test-utils/obsidianDom"; -``` - -Create a 10,000-item list with a 440px viewport and 44px rows. Keep container focus and pin item `0`, then scroll well past it. Assert bounded DOM, node reuse, active-item retention, and virtual collection metadata: - -```ts -expect(container.querySelectorAll(".test-row").length).toBeLessThanOrEqual(30); -const reused = container.querySelector('[data-key="1"]'); -container.tabIndex = 0; -container.focus(); -list.setPinnedKey("0"); -container.scrollTop = 44; -container.dispatchEvent(new Event("scroll")); -jest.runOnlyPendingTimers(); -expect(container.querySelector('[data-key="1"]')).toBe(reused); - -container.scrollTop = 4400; -container.dispatchEvent(new Event("scroll")); -jest.runOnlyPendingTimers(); -expect(document.activeElement).toBe(container); -expect(container.querySelector('[data-key="0"]')).not.toBeNull(); -expect(container.querySelectorAll(".test-row").length).toBeLessThanOrEqual(31); -expect(container.querySelector('[data-key="100"]')?.getAttribute("aria-posinset")).toBe("101"); -expect(container.querySelector('[data-key="100"]')?.getAttribute("aria-setsize")).toBe("10000"); -``` - -Also assert `scrollToIndex(9999)` mounts the final item and `destroy()` removes the scroll listener, pending animation frame, and all mounted nodes. - -- [ ] **Step 2: Replace factory-only items with keyed items** - -Use this public contract: - -```ts -export type VirtualListItem = { - key: string; - render: () => HTMLElement; -}; - -constructor(container: HTMLElement, rowHeight: number) -setItems(items: VirtualListItem[]): void -setPinnedKey(key: string | null): void -scrollTo(top: number): void -scrollToIndex(index: number): void -destroy(): void -static shouldVirtualize(count: number): boolean -``` - -Maintain `mounted = new Map()`. On each animation-frame render: - -1. Compute buffered `[start, end)` indexes. -2. Build the wanted-key set from the visible window plus `pinnedKey` when it still exists. -3. Remove only mounted keys outside that wanted set. -4. Reuse nodes whose keys remain wanted. -5. Create only newly wanted keys. -6. Set each node to `position:absolute; left:0; right:0; transform:translateY(index * rowHeight)`. -7. Set `aria-posinset=index + 1` and `aria-setsize=items.length` on every mounted option. -8. Set the content height to `items.length * rowHeight`. - -Set `VIRTUAL_THRESHOLD = 200`; this keeps small lists simple while bounding large-list DOM. - -- [ ] **Step 3: Throttle scrolling with one animation frame** - -```ts -private scheduleRender = () => { - if (this.frame !== null) return; - this.frame = window.requestAnimationFrame(() => { - this.frame = null; - this.renderWindow(); - }); -}; -``` - -Cancel `frame` during `destroy()`. - -- [ ] **Step 4: Integrate the final row height** - -Use 44px desktop and 52px mobile for list rows: - -```ts -const rowHeight = Platform.isMobile ? 52 : 44; -this.virtualList = new VirtualList(this.listContainer, rowHeight); -this.virtualList.setItems(sections[0]!.records.map((record) => ({ - key: record.path, - render: () => this.createRowElement(record), -}))); -this.virtualList.setPinnedKey(this.activeItemPath); -``` - -When keyboard navigation changes `activeItemPath`, call `setPinnedKey(path)` before updating `aria-activedescendant`. If the target is outside the window, call `scrollToIndex(index)` first, render the window, then update the active descendant. Mouse/trackpad scrolling may move the active option outside the visible window, but the single pinned option remains mounted so the container never references a missing ID. - -This is the accepted virtual-list accessibility boundary: only visible options plus the active option are mounted, while `aria-setsize`/`aria-posinset` expose logical collection position. Keep grouped and manual lists non-windowed in this PR. Record a follow-up only if real-vault profiling shows grouped lists need section-aware virtualization. - -- [ ] **Step 5: Verify and commit** - -Run: - -```bash -npm test -- --runInBand src/explorer/__tests__/VirtualList.test.ts src/explorer/__tests__/SmartExplorerView.dom.test.ts -npm run build -``` - -Expected: 10,000 items produce no more than the viewport plus 20 buffer rows and one pinned active row; scrolling reuses overlapping nodes; container focus and `aria-activedescendant` remain valid. - -Commit: - -```bash -git add src/explorer/VirtualList.ts src/explorer/__tests__/VirtualList.test.ts src/explorer/SmartExplorerView.ts src/explorer/__tests__/SmartExplorerView.dom.test.ts styles.css -git commit -m "perf: add keyed windowed list rendering" -``` - -### Task 9: Remove manual-sort hot-path scans and repeated reconciliation - -**Files:** - -- Modify: `src/explorer/DragSortManager.ts:14-346` -- Modify: `src/explorer/__tests__/DragSortManager.test.ts` -- Modify: `src/explorer/__tests__/DragSortManager.dom.test.ts` -- Modify: `src/explorer/SmartExplorerView.ts:192-250,597-599,637-700,1225-1273` - -- [ ] **Step 1: Add failing drag-geometry tests** - -Start `DragSortManager.dom.test.ts` with `@jest-environment jsdom` and import `obsidianDom`. Use `mockElementBox` to assign the container and ten rows deterministic offsets. Spy on every row's `getBoundingClientRect`, start a drag, and dispatch ten dragover events: - -```ts -function createDragEvent(type: string, clientY: number): DragEvent { - const event = new MouseEvent(type, { bubbles: true, clientY }) as DragEvent; - Object.defineProperty(event, "dataTransfer", { - value: { - effectAllowed: "none", - dropEffect: "none", - setData: jest.fn(), - }, - }); - return event; -} - -for (let index = 0; index < rows.length; index++) { - mockElementBox(rows[index]!, { top: index * 44, width: 300, height: 44 }); -} -const rowRectSpies = rows.map((row) => jest.spyOn(row, "getBoundingClientRect")); - -handle.dispatchEvent(createDragEvent("dragstart", 20)); -for (let index = 0; index < 10; index++) { - container.dispatchEvent(createDragEvent("dragover", 20 + index * 10)); -} - -for (const spy of rowRectSpies) expect(spy).not.toHaveBeenCalled(); -``` - -Add a second test that advances the auto-scroll timer, changes `container.scrollTop`, and proves the drop index changes without row geometry calls. - -- [ ] **Step 2: Cache row offsets at drag start** - -Add: - -```ts -private rowBounds: { top: number; bottom: number }[] = []; - -private refreshRowBounds(): void { - this.rowBounds = this.rows.map((row) => ({ - top: row.el.offsetTop, - bottom: row.el.offsetTop + row.el.offsetHeight, - })); -} -``` - -Call it from desktop `dragstart` and `startTouchDrag`. Convert pointer coordinates once with `clientY - containerRect.top + container.scrollTop`, then pass cached offsets to `calculateDropIndexFromRowBounds`. - -Delete `getRowHeight` from `DragSortOptions`. Manual sort is intentionally non-windowed, so drop indicators use cached real `offsetTop/offsetHeight` values. This removes the stale 28px constant instead of duplicating the new 44/52px list-row contract. - -- [ ] **Step 3: Register manual rows during creation** - -Create `DragSortManager` before rendering manual rows. Extend `createRowElement(record, sectionId?)`; when a manual handle is created and the manager exists, call `attachRow(row, record.path, sectionId, handle)` immediately. Delete `attachManualDragRows` and its `querySelector` pass. - -- [ ] **Step 4: Reconcile manual order only when the indexed path set changes** - -Add: - -```ts -private manualOrderNeedsReconcile = true; -``` - -Set it to true on create, delete, and rename events. Set it to false after `initializeManualOrder`. During ordinary manual renders, rebuild only `manualOrderIndex`; do not seed-sort and reconcile again. - -Replace newline joins with a pure array comparison: - -```ts -function sameOrder(a: string[], b: string[]): boolean { - return a.length === b.length && a.every((path, index) => path === b[index]); -} -``` - -- [ ] **Step 5: Verify and commit PR 3** - -Run: - -```bash -npm test -- --runInBand src/explorer/__tests__/DragSortManager.test.ts src/explorer/__tests__/DragSortManager.dom.test.ts src/explorer/__tests__/manualOrder.test.ts src/explorer/__tests__/SmartExplorerView.test.ts src/explorer/__tests__/SmartExplorerView.dom.test.ts src/explorer/__tests__/VirtualList.test.ts src/explorer/__tests__/TreeModel.test.ts -npm run verify -``` - -Commit: - -```bash -git add src/explorer/DragSortManager.ts src/explorer/__tests__/DragSortManager.test.ts src/explorer/__tests__/DragSortManager.dom.test.ts src/explorer/SmartExplorerView.ts src/explorer/__tests__/SmartExplorerView.test.ts src/explorer/__tests__/SmartExplorerView.dom.test.ts -git commit -m "perf: reduce manual sort layout work" -``` - -PR title: `perf: scale explorer rendering for large vaults` - ---- - -## PR 4 — Lifecycle and integration hardening - -### Task 10: Serialize settings saves and surface async failures - -**Files:** - -- Modify: `src/main.ts:8-62` -- Modify: `src/__tests__/main.test.ts` -- Modify: `src/explorer/SmartExplorerView.ts:154-190,969-1004,1100-1117,1503-1511` -- Modify: `src/explorer/__tests__/SmartExplorerView.test.ts` -- Modify: `src/settings/settings-tab.ts:50-121` -- Modify: `src/settings/__tests__/settings-tab.test.ts` - -- [ ] **Step 1: Add failing save-order tests** - -Add a serial-order test and a failure-recovery test to `main.test.ts`: - -```ts -it("serializes immutable settings snapshots", async () => { - let resolveFirst!: () => void; - const firstWrite = new Promise((resolve) => { resolveFirst = resolve; }); - const plugin = makePlugin(); - plugin.saveData = jest.fn() - .mockReturnValueOnce(firstWrite) - .mockResolvedValueOnce(undefined); - - plugin.settings.hiddenExtensions = ["png"]; - const first = plugin.saveSettings(); - plugin.settings.hiddenExtensions = ["pdf"]; - const second = plugin.saveSettings(); - - expect(plugin.saveData).toHaveBeenCalledTimes(1); - resolveFirst(); - await first; - await second; - expect(plugin.saveData).toHaveBeenNthCalledWith(1, expect.objectContaining({ hiddenExtensions: ["png"] })); - expect(plugin.saveData).toHaveBeenNthCalledWith(2, expect.objectContaining({ hiddenExtensions: ["pdf"] })); -}); - -it("continues saving after one write rejects", async () => { - const plugin = makePlugin(); - plugin.saveData = jest.fn() - .mockRejectedValueOnce(new Error("disk full")) - .mockResolvedValueOnce(undefined); - - await expect(plugin.saveSettings()).rejects.toThrow("disk full"); - await expect(plugin.saveSettings()).resolves.toBeUndefined(); - expect(plugin.saveData).toHaveBeenCalledTimes(2); -}); -``` - -Add a view-close test that schedules a manual save, calls `await onClose()`, and asserts the final save/flush has resolved before `onClose` resolves. - -- [ ] **Step 2: Serialize immutable settings snapshots** - -In the plugin: - -```ts -private settingsSaveQueue: Promise = Promise.resolve(); - -saveSettings(): Promise { - const snapshot: SmartExplorerSettings = { - ...this.settings, - hiddenExtensions: [...this.settings.hiddenExtensions], - manualOrder: [...this.settings.manualOrder], - }; - const operation = this.settingsSaveQueue.then(() => this.saveData(snapshot)); - this.settingsSaveQueue = operation.catch(() => undefined); - return operation; -} - -async flushSettings(): Promise { - await this.settingsSaveQueue; -} - -async saveSettingsWithNotice(failure: string): Promise { - try { - await this.saveSettings(); - return true; - } catch (error) { - new Notice(`${failure}: ${error instanceof Error ? error.message : String(error)}`); - return false; - } -} -``` - -`operation` rejects to the current caller, while `settingsSaveQueue` catches that failure solely to keep the next queued write runnable. The attached catch also prevents an ignored returned operation from becoming an unhandled rejection. - -- [ ] **Step 3: Await pending manual-order persistence on close** - -When `saveOrderTimeout` exists, clear it and `await this.plugin.saveSettingsWithNotice("Could not save manual order")`, then `await this.plugin.flushSettings()`. Keep `onClose` async and do not use a detached promise. - -- [ ] **Step 4: Route user actions through one error boundary** - -Add: - -```ts -private async runAction(action: () => Promise, failure: string): Promise { - try { - await action(); - } catch (error) { - new Notice(`${failure}: ${error instanceof Error ? error.message : String(error)}`); - } -} -``` - -Use it for ordinary file opening, open-in-leaf actions, clipboard writes, and Finder/default-app actions. Provide lightweight `Copied path.` feedback after a successful clipboard write. - -Use `saveSettingsWithNotice` from `settings-tab.ts`, view-mode persistence, reset manual order, hidden-extension changes, and debounced manual-order persistence. Add settings-tab tests asserting a rejected save displays one Notice and a later change still saves successfully. Finish the task with: - -```bash -rg -n "saveSettings\(" src -``` - -Expected: every result either uses `await` inside `try/catch`, is passed through `runAction`, or is replaced by `saveSettingsWithNotice`; no detached raw save remains. - -- [ ] **Step 5: Verify and commit** - -Run: - -```bash -npm test -- --runInBand src/__tests__/main.test.ts src/explorer/__tests__/SmartExplorerView.test.ts src/settings/__tests__/settings-tab.test.ts -npm run build -``` - -Commit: - -```bash -git add src/main.ts src/__tests__/main.test.ts src/explorer/SmartExplorerView.ts src/explorer/__tests__/SmartExplorerView.test.ts src/settings/settings-tab.ts src/settings/__tests__/settings-tab.test.ts -git commit -m "fix: serialize explorer persistence" -``` - -### Task 11: Synchronize workspace selection and purge folder subtrees - -**Files:** - -- Modify: `src/explorer/FileIndex.ts:55-141` -- Modify: `src/explorer/__tests__/FileIndex.test.ts` -- Modify: `src/explorer/SmartExplorerView.ts:192-243,946-951` -- Modify: `src/explorer/__tests__/SmartExplorerView.test.ts` - -- [ ] **Step 1: Add failing subtree and selection tests** - -```ts -it("removes every indexed child for a deleted folder", () => { - const index = buildIndex(["keep.md", "gone/a.md", "gone/nested/b.md"]); - index.removeFolder("gone"); - expect(index.getAll().map((record) => record.path)).toEqual(["keep.md"]); -}); -``` - -Add a view test that emits `file-open` with a file, expects only selected DOM state to update, then emits `file-open` with `null` and expects the old selection to clear. Assert neither event changes scroll position or expands folders. - -- [ ] **Step 2: Implement explicit folder removal and incremental folder paths** - -```ts -removeFolder(folderPath: string): void { - const prefix = `${folderPath}/`; - for (const path of this.records.keys()) { - if (path.startsWith(prefix)) this.records.delete(path); - } - for (const path of this.folderPaths) { - if (path === folderPath || path.startsWith(prefix)) this.folderPaths.delete(path); - } -} -``` - -Maintain `folderPaths` during build, folder create/delete, and folder rename so `getFolderPaths()` returns a sorted copy without scanning `getAllLoadedFiles()` on every tree render. - -- [ ] **Step 3: Listen to active-file changes without automatic reveal** - -Register: - -```ts -this.registerEvent(this.app.workspace.on("file-open", (file) => { - this.selectedPath = file?.path ?? null; - this.selectedFolderPath = null; - this.highlightSelected(); -})); -``` - -Keep reveal explicit. Do not expand ancestors or scroll when users switch tabs elsewhere. - -- [ ] **Step 4: Verify and commit** - -Run: - -```bash -npm test -- --runInBand src/explorer/__tests__/FileIndex.test.ts src/explorer/__tests__/SmartExplorerView.test.ts -``` - -Commit: - -```bash -git add src/explorer/FileIndex.ts src/explorer/__tests__/FileIndex.test.ts src/explorer/SmartExplorerView.ts src/explorer/__tests__/SmartExplorerView.test.ts -git commit -m "fix: synchronize vault lifecycle state" -``` - -### Task 12: Add lifecycle integration tests and a guarded large-vault fixture - -**Files:** - -- Create: `src/explorer/__tests__/SmartExplorerView.integration.test.ts` -- Create: `scripts/prepare-large-vault-fixture.mjs` -- Create: `scripts/__tests__/prepare-large-vault-fixture.test.mjs` -- Modify: `package.json` - -- [ ] **Step 1: Build one minimal fake App integration harness** - -The fake must provide event emitters for `vault.create/delete/rename/modify` and `workspace.file-open`, real `TFile`/`TFolder` test classes, a DOM container, and controllable `saveData` promises. Reuse it for these exact cases: - -1. create file → index grows → debounced DOM count changes; -2. delete folder → every child leaves index and DOM; -3. rename folder → child paths and manual order rewrite; -4. event burst → one render after 300ms; -5. hidden-extension setting change → open view refreshes; -6. open failure → Notice contains the error; -7. close during pending manual save → save resolves before close completes. - -- [ ] **Step 2: Add a marker-protected fixture script** - -The script must accept only: - -```bash -node scripts/prepare-large-vault-fixture.mjs --vault /Users/Roger/my-vault --files 5000 -node scripts/prepare-large-vault-fixture.mjs --vault /Users/Roger/my-vault --remove -``` - -It may create/delete only `/.smart-explorer-large-vault-fixture`. Creation writes a `.smart-explorer-fixture-marker` before file generation. Removal must refuse unless both the directory basename and marker match. Generate 100 folders with evenly distributed Markdown files plus representative PNG/PDF/DOCX placeholder files; content should be a single heading or a zero-byte non-Markdown placeholder so the fixture stays small. - -- [ ] **Step 3: Test the guards before using the script** - -Tests must prove: - -- missing `--vault` fails; -- `--files` outside 100–50000 fails; -- cleanup refuses an unmarked directory; -- cleanup never deletes the vault root; -- a temp-directory fixture creates and removes exactly its own subtree. - -Add: - -```json -"test:fixture": "node --test scripts/__tests__/prepare-large-vault-fixture.test.mjs" -``` - -Append `npm run test:fixture` to `verify`. - -- [ ] **Step 4: Run automated verification** - -```bash -npm run verify -``` - -Expected: lint, build, all Jest tests, release tests, and fixture safety tests pass. - -- [ ] **Step 5: Run the authorized real-vault acceptance matrix** - -Use `/Users/Roger/my-vault`, which is the dedicated Obsidian plugin development/test vault. Capture results for: - -| Scenario | Acceptance | -|---|---| -| 370-file existing vault | No behavior regression; tree/list/search/manual create/rename/trash work | -| 5,000-file fixture, indexing | `metadataCache.getFileCache` is not called; median of three cold index builds stays under 1,000ms and the duration is recorded | -| 5,000-file fixture, list | Initial usable render under 500ms after indexing; rendered rows stay under 60; smooth wheel/trackpad scroll | -| 5,000-file fixture, closed tree | DOM contains folder summaries and only open-branch descendants; no eager 5,000-row subtree | -| Search | Whitespace query equals empty query; visible results/count/clear state agree | -| Non-Markdown filter | `.canvas`, `.base`, `.docx`, `.csv`, and other non-`.md` files appear; Markdown files do not | -| Narrow list at 300px | Every row shows parent path; duplicate names are distinguishable | -| Keyboard | One Tab entry, complete arrow navigation, folder expand/collapse, file activation, manual reorder | -| Settings | Hidden extensions and reset order refresh all open Smart Explorer views | -| Lifecycle | Switching active tabs updates highlight without scroll/reveal jumps | -| Mobile/tablet | 44px+ controls, long press menu, long press drag, safe-area padding | -| Themes | Selected file/folder and focus ring readable in light and dark themes | - -After testing, run the marker-protected `--remove` command and verify the fixture directory is gone while all other test-vault files remain. - -- [ ] **Step 6: Commit PR 4** - -```bash -git add src/explorer/__tests__/SmartExplorerView.integration.test.ts scripts/prepare-large-vault-fixture.mjs scripts/__tests__/prepare-large-vault-fixture.test.mjs package.json -git commit -m "test: add explorer lifecycle coverage" -``` - -PR title: `test: harden explorer lifecycle and large-vault verification` - ---- - -## Final documentation and release gate - -### Task 13: Update live documentation and close the milestone - -**Files:** - -- Modify: `README.md` -- Modify: `AGENTS.md` -- Modify: `CLAUDE.md` -- Modify: `docs/release-checklist.md` - -- [ ] **Step 1: Update only shipped behavior** - -Document: - -- extension filtering; -- remembered tree/list mode; -- keyboard navigation and `Alt+Arrow` manual reorder; -- lazy tree rendering and keyed list windowing; -- `/Users/Roger/my-vault` as the local development/test-vault convention in agent-facing docs only, not the public README; -- fixture creation/removal commands in the release checklist. - -Do not mention removed metadata fields, abandoned virtualization, or deferred features in README feature copy. - -- [ ] **Step 2: Run the final gate from a clean worktree** - -```bash -git status --short -npm run verify -git diff --check -``` - -Expected: only intended documentation changes remain before commit; all verification passes; `git diff --check` is silent. - -- [ ] **Step 3: Commit documentation** - -```bash -git add README.md AGENTS.md CLAUDE.md docs/release-checklist.md -git commit -m "docs: document explorer optimization behavior" -``` - -- [ ] **Step 4: Review milestone evidence before release** - -The milestone is complete only when all four PRs are merged, CI `verify` is green, the 5,000-file test-vault matrix is recorded, the fixture is removed, and no deferred feature entered the release diff. - -Do not bump the version in any optimization PR. After the milestone is accepted, create a separate release PR that updates `package.json`, `manifest.json`, and `versions.json` from `0.5.4` to `0.6.0`; merge it before creating and pushing the tag so the existing CI release workflow remains the only release publisher. - ---- - -## Success metrics - -- **Correctness:** whitespace search, non-Markdown filtering, extension filtering, settings migration, folder subtree deletion, and active-file state all have automated regressions. -- **Accessibility:** all interactive controls have stable names; disclosure controls expose `aria-expanded`; list/tree roles are valid; each explorer composite is one Tab stop with a valid `aria-activedescendant`; virtualized rows expose `aria-posinset` and `aria-setsize`; every core browse/reorder action is keyboard reachable. -- **Clarity:** parent path remains visible at 300px; selected folders have visible feedback; singular/plural counts and empty states describe the real state. -- **Scale:** a 5,000-file cold index build stays under 1,000ms without metadata-cache reads; a flat list keeps fewer than 60 file-row DOM nodes plus at most one pinned active row; a closed tree does not mount descendant file rows; dragover does not measure every row per frame. -- **Reliability:** settings saves are serialized, view close awaits pending persistence, async failures produce a Notice, and lifecycle integration tests cover event bursts and subtree changes. -- **Scope:** no preview, saved-view, full-text/AI, network, bulk file-management, or tree-manual-order code is added. 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 deleted file mode 100644 index 54a4035..0000000 --- a/docs/superpowers/plans/2026-09-12-smart-explorer-1.0-release-readiness.md +++ /dev/null @@ -1,426 +0,0 @@ -# Smart Explorer 1.0 Release Readiness Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Independent verification may use superpowers:subagent-driven-development; do not edit the shared view concurrently. - -**Goal:** Release the existing explorer feature set as 1.0.0 after correcting rename/order reliability, proving upgrade and runtime compatibility, and aligning release documentation with shipped behavior. - -**Architecture:** Preserve FileIndex as the view's file-data source and keep the current renderers and settings schema. Move shared manual-order rename maintenance to plugin lifetime, keep view-specific selection and Undo state in the view, and use Obsidian FileManager for user-initiated rename. Reuse existing reconciliation, serialized settings saves, and release workflows. - -**Tech Stack:** TypeScript, Obsidian API, Jest/ts-jest with Node and jsdom suites, esbuild, ESLint, Node release/fixture tests, GitHub Actions. - ---- - - -## Execution status — 2026-09-13 - -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 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. -- Implementation and documentation are delivered together on `fix/1.0-order-reliability` for the user-requested PR, without release metadata changes. - -## 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. - -Rules: - -- Read repository `AGENTS.md` and `CLAUDE.md` before changes. Follow their current rules if paths or commands have changed. -- Work on a feature branch, never commit implementation directly to main. Preserve unrelated changes. Use a worktree if the checkout is occupied. -- Keep this plan's checkboxes current. Record observations in `docs/verification/1.0.0-readiness.md`; distinguish PASS, FAIL, and BLOCKED. An unchecked legacy plan is not evidence that code is absent. -- For each bug: add its regression, observe the relevant failure, implement the narrow fix, rerun focused tests, then run the complete gate at the integration boundary. -- Do not expand into tree manual ordering, grouped manual ordering, full-text search, previews, saved views, bulk moves, tags, localization, or a new persistence schema. -- Runtime acceptance must use the exact candidate commit/build. A jsdom pass, mobile emulation, a fixture-generation test, or an API typing check is not real-device acceptance. -- Missing hardware, app versions, credentials, or publication authority blocks only the dependent steps. Finish independent work and provide precise remaining actions. Never claim release readiness while a mandatory gate is BLOCKED. -- The supported guarantee covers renames while the plugin is enabled, including when every explorer pane is closed. Renames while the plugin is disabled or Obsidian is not running cannot reliably preserve path-based identity; document this limitation rather than inventing a content database. -- Commit messages, PRs, release notes, and repository documentation remain normal English engineering content without tool/model attribution. - -### Delivery boundaries - -| Delivery | Scope | Gate | -|---|---|---| -| PR A: Reliability | Tasks 1–4; no version bump | Regression tests, full verify, desktop rename/order smoke test | -| PR B: Acceptance and documentation | Tasks 5–7; no version bump | Evidence matrix and docs consistent with actual results | -| PR C: Release metadata | Task 8 | All mandatory acceptance gates PASS; version metadata validates | -| Publication | Task 9 | PR C merged, CI green, explicit publication authorization | - -These are logical boundaries. Prepare A first; do not queue code changes on an unmerged main that lacks A. If PR operations are outside the execution request, deliver the same boundaries as local commits and report their status. - -## 2. Verified planning baseline - -Observed on 2026-09-12: - -- Local HEAD: `d11a4d3dfd3100f317ba58d29bc1438cb0643118`; package/manifest version `0.6.1`; minimum app version `1.7.2`; mobile support declared. -- `npm run verify` passed: 29 Jest suites / 244 tests, 6 release tests, 7 fixture tests, lint and production build. -- GitHub latest release was `0.6.1`; its release workflow and main CI succeeded; no open PRs or issues were returned. Recheck at execution time. -- `renameItemToName` calls `vault.rename`, bypassing FileManager's link-maintenance contract. -- Reorder → rename → Undo was reproduced by invoking the actual methods with UI stubs: an old path returned to the order and the renamed file could no longer be dragged. -- Shared rename maintenance currently lives inside `SmartExplorerView.registerVaultEvents`, leaving a gap when no view exists. -- The August plan records some desktop visual acceptance, but does not contain a completed full mobile/VoiceOver/5,000-file evidence matrix. -- `AGENTS.md` and `CLAUDE.md` still state version `0.5.4`; privacy/write-scope text omits existing rename/trash behavior. - -These observations are a starting point, not permission to skip fresh baseline checks. - -## 3. File map - -| File | Responsibility/change | -|---|---| -| `src/main.ts` | Register one plugin-lifetime rename listener; persist shared renamed paths through the existing queue | -| `src/explorer/SmartExplorerView.ts` | FileManager rename, view-local Undo path migration, complete Undo reconciliation; remove duplicate shared rename writes | -| `src/explorer/manualOrder.ts` | Reuse `renameManualOrderPaths` and `reconcileManualOrder`; no replacement algorithm | -| `src/explorer/__tests__/SmartExplorerView.test.ts` | Rename API contract and Undo structural-change regressions | -| `src/__tests__/main.test.ts` | Plugin lifetime, no-pane rename, subtree rename, save failure/recovery tests | -| `src/explorer/__tests__/SmartExplorerView.integration.test.ts` | Realistic multi-listener event harness; combined index/order/Undo/persistence checks | -| `src/settings/__tests__/settings-normalization.test.ts` | Explicit old-version settings and corrupt-data regression cases | -| `docs/verification/1.0.0-readiness.md` | Candidate-specific automated/runtime evidence and gate decision | -| `docs/release-notes/1.0.0.md` | User-facing release notes with supported scope and limitations | -| `README.md`, `AGENTS.md`, `CLAUDE.md`, `docs/release-checklist.md` | Correct product/write-scope/version/testing guidance | -| Existing July/August plan documents | Add concise status pointers; preserve historical instructions | -| `package.json`, `package-lock.json`, `manifest.json`, `versions.json` | Separate final version change | - -Do not refactor the large view class or introduce a general event framework as part of these fixes. - -## Task 1: Refresh baseline and establish evidence - -- [x] Read the repository rules and inspect the branch/worktree. - -```bash -git status --short -git branch --show-current -git log -1 --format='%H %s' -cat package.json manifest.json -``` - -- [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 | -|---|---|---|---|---|---|---|---| -``` - -Record unavailable checks as BLOCKED with the specific missing device/version/access. Do not populate expected values into the observed column. - -## Task 2: Preserve links during inline rename - -**Files:** `src/explorer/SmartExplorerView.ts`, `src/explorer/__tests__/SmartExplorerView.test.ts`. - -- [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 () => { - const file = Object.assign(new TFile(), { - path: "notes/old.md", 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("notes/old.md", "new"); - expect(view.app.fileManager.renameFile).toHaveBeenCalledWith(file, "notes/new.md"); - expect(view.app.vault.rename).not.toHaveBeenCalled(); - expect(view.selectedPath).toBe("notes/new.md"); -}); -``` - -- [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); -``` - -- [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 - -**Files:** `src/main.ts`, `src/explorer/SmartExplorerView.ts`, `src/__tests__/main.test.ts`, `src/explorer/__tests__/SmartExplorerView.integration.test.ts`. - -### Ownership decision - -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. - -- [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 () => { - const plugin = new SmartExplorerPlugin({} as any, {} as any); - const listeners: Record void> = {}; - (plugin as any).app = { - vault: { on: (event: string, callback: (...args: any[]) => void) => { - listeners[event] = callback; - return { event, callback }; - } }, - workspace: { getLeavesOfType: () => [] }, - }; - plugin.loadData = jest.fn().mockResolvedValue({ - manualOrder: ["b.md", "old/a.md", "older/a.md", "c.md"], - }); - plugin.saveData = jest.fn().mockResolvedValue(undefined); - await plugin.onload(); - expect(listeners.rename).toBeDefined(); - listeners.rename!({ path: "new" }, "old"); - await plugin.flushSettings(); - expect(plugin.settings.manualOrder).toEqual([ - "b.md", "new/a.md", "older/a.md", "c.md", - ]); - expect(plugin.saveData).toHaveBeenLastCalledWith(expect.objectContaining({ - manualOrder: ["b.md", "new/a.md", "older/a.md", "c.md"], - })); -}); -``` -- [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) => { - 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 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. - -- [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) { - this.manualOrderUndoStack = this.manualOrderUndoStack.map((order) => - renameManualOrderPaths(order, oldPath, newPath), - ); -} -``` - -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 = []`. - -- [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>>(); -const onVault = (name: string, callback: (file: any, oldPath?: string) => void) => { - const callbacks = vaultHandlers.get(name) ?? []; - callbacks.push(callback); - vaultHandlers.set(name, callbacks); - return { name, callback }; -}; -const emitVault = (name: string, file: unknown, oldPath?: string) => { - for (const callback of [...(vaultHandlers.get(name) ?? [])]) callback(file, oldPath); -}; -``` - -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. - -- [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 - -**Files:** `src/explorer/SmartExplorerView.ts`, `src/explorer/__tests__/SmartExplorerView.test.ts`, `src/explorer/__tests__/SmartExplorerView.integration.test.ts`. - -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. - -- [x] Add table-driven regressions for these exact histories: - -| Saved history / current order | Structural change | Expected after Undo | -|---|---|---| -| history `[a,b]`, current `[b,a]` | rename `a` to `renamed` | `[renamed,b]` | -| history `[a,b]`, current `[b,a]` | create `c` | `[a,b,c]` | -| history `[a,b,c]`, current `[b,a,c]` | delete `a` | `[b,c]` | -| history `[old/a,old/b,z]` | rename folder `old` to `new` | `[new/a,new/b,z]` | -| history `[a,b]`, current `[b,a]` | no structural change | `[a,b]` | - -Use `.md` suffixes in fixtures. Build full FileRecord values (`path`, `basename`, `extension`, `parentPath`, `size`, `ctime`, `mtime`, `isMarkdown`). Set `view.query` to a complete manual query, `manualSeedSort` to `name-asc`, and `fileIndex.getAll` to the latest complete records. Stub rendering, save scheduling, and control updates only; do not stub the reconciliation method under test. - -Add this concrete regression to the existing view test file and import `reorderManualOrder` from `../manualOrder`: - -```ts -it("keeps a newly created file draggable after Undo", () => { - const records = ["a.md", "b.md", "c.md"].map((path) => ({ - path, basename: path.slice(0, -3), extension: "md", parentPath: "", - size: 0, ctime: 0, mtime: 0, isMarkdown: true, - })); - const view = Object.create(SmartExplorerView.prototype) as any; - view.plugin = { settings: { manualOrder: ["b.md", "a.md", "c.md"] } }; - view.query = { - sort: "manual", group: "none", searchText: "", extension: null, - fileKind: "all", modifiedWithinDays: null, - }; - view.manualSeedSort = "name-asc"; - view.manualOrderUndoStack = [["a.md", "b.md"]]; - view.manualOrderNeedsReconcile = false; - view.fileIndex = { getAll: () => records }; - view.renderList = jest.fn(); - view.scheduleSaveOrder = jest.fn(); - view.updateManualOrderControls = jest.fn(); - view.undoManualReorder(); - expect(view.plugin.settings.manualOrder).toEqual(["a.md", "b.md", "c.md"]); - expect(reorderManualOrder( - view.plugin.settings.manualOrder, "c.md", 0, [{ id: "all", records }], - )).toEqual(["c.md", "a.md", "b.md"]); -}); -``` - -- [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() { - if (this.query.sort !== "manual") return; - const previousOrder = this.manualOrderUndoStack.pop(); - if (!previousOrder) return; - this.plugin.settings.manualOrder = previousOrder; - this.initializeManualOrder(this.fileIndex.getAll()); - this.manualOrderNeedsReconcile = false; - this.renderList(); - this.scheduleSaveOrder(); - this.updateManualOrderControls(); -} -``` - -`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. - -- [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. - -- [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. -- [x] Run the repository's protected fixture commands: - -```bash -node scripts/prepare-large-vault-fixture.mjs --vault /Users/Roger/my-vault --files 5000 -``` - -Measure three fresh `FileIndex.build()` operations in a real Obsidian session, not Node mocks; record all durations and median. Use the debugger or temporary local instrumentation around the actual build and render boundaries. Restore instrumentation before final verification. Record host/environment so timing is interpretable. - -| Performance case | Pass criterion | -|---|---| -| Cold index, 5,000 fixture files plus recorded baseline vault | Median of three runs < 1,000ms; no metadata-cache reads | -| Initial flat list after indexing | Usable render < 500ms; fewer than 60 file rows plus at most one pinned active row at the measured viewport | -| Closed tree | No mounted file descendants under closed branches | -| Flat list scrolling | Bounded row nodes throughout; no missing/duplicate visible rows or broken active descendant | -| Manual drag | Drop reaches intended position, survives scroll and saves; no full-row geometry measurement on every pointer move | -| Large expanded folder and expand-all | Record node count and responsiveness; no freeze/crash; do not claim tree virtualization because only lazy mounting exists | - -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. - -- [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 -``` - -- [ ] Restore theme/settings/test files changed for acceptance. Record pass/fail per scenario with actual timings or screenshots. If a timing gate fails, profile the measured path and fix that bottleneck; do not implement tree virtualization speculatively. - -## Task 6: Compatibility, real mobile devices, and upgrade safety - -**Files:** `src/settings/__tests__/settings-normalization.test.ts`, `docs/verification/1.0.0-readiness.md`. - -- [x] Add explicit normalization regression fixtures for the existing schema: - -```ts -const saved = { - defaultSort: "manual", defaultGroup: "folder", lastViewMode: "list", - hiddenExtensions: ["png"], manualOrder: ["b.md", "a.md"], -}; -expect(normalizeSettings(saved)).toEqual(saved); -expect(normalizeSettings({ ...saved, lastViewMode: undefined }).lastViewMode).toBe("tree"); -expect(normalizeSettings({ ...saved, manualOrder: ["b.md", "b.md", 7, "a.md"] }).manualOrder) - .toEqual(["b.md", "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`. - -- [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. -- [ ] If Windows/Linux are available, run the same desktop smoke test; at minimum document which desktop OS was actually tested and inspect Unicode/case-only rename behavior on the tested filesystem. Do not claim universal desktop verification from one OS. -- [ ] Stop release promotion on any supported-platform data-integrity failure or missing required mobile/minimum-version evidence. Continue docs and automation tasks; report the exact device/action needed to clear each blocker. Do not automatically remove mobile support merely because no device is connected. - -## Task 7: Align product and release documentation - -**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. - -- [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. -``` - -- [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 - -Prerequisite: reliability fixes merged (or explicitly accepted in the execution workflow), every mandatory acceptance row PASS, no unresolved data-integrity issue, and all runtime evidence maps to the candidate code. Tests performed before later runtime code changes must be rerun for the affected paths. - -- [ ] Refresh remote main and inspect open PRs/issues and CI. Ensure intended fixes are included. Create `chore/release-1.0.0` from the verified main commit. -- [ ] Bump without creating an automatic commit or tag: - -```bash -npm version 1.0.0 --no-git-tag-version -node scripts/validate-release.mjs 1.0.0 -``` - -The existing version script updates/stages manifest and versions metadata; inspect the index and lockfile. Expect package and manifest 1.0.0, lockfile package version 1.0.0, and `versions.json['1.0.0']` equal to the verified minimum app version. Do not remove older compatibility entries. - -- [ ] Update current-version documentation to 1.0.0. Finalize release notes and the gate decision. If only metadata changed, record that fact rather than rerunning unrelated exploratory analysis. -- [ ] Run `npm run verify`, `node scripts/validate-release.mjs 1.0.0`, `git diff --check`, and record asset hashes. Verify no fixture files, local settings, logs, instrumentation, or generated unrelated artifacts entered the diff. -- [ ] Commit as `chore: release 1.0.0`. If PR publication is authorized, push and open PR C with the change summary, evidence link, verification counts, and supported-version statement. Require CI `verify` success. Do not merge or tag unless that action is included in the execution authorization. - -## Task 9: Publish and verify downloadable artifacts - -Prerequisite: explicit publication authorization, merged release PR, successful CI on the release commit, and all gates PASS. - -- [ ] Fetch main and confirm the release commit is contained in main. Verify `1.0.0` is not already a local or remote tag. If it exists, inspect its target and release state; never force-move it. -- [ ] Create `git tag 1.0.0` on the verified merged commit and push with `git push origin 1.0.0`. The tag is `1.0.0`, not `v1.0.0`; the validator rejects prefixes and prerelease suffixes. Do not call `gh release create` manually. -- [ ] Monitor the tag's release workflow to completion. Inspect failed logs before fixing anything; never assume an existing release page proves success. -- [ ] Confirm exactly the required assets are downloadable: `main.js`, `manifest.json`, `styles.css`. Download into a new temporary directory, validate manifest 1.0.0 and minimum version, record hashes and workflow commit, and install these downloaded assets into a clean test vault. -- [ ] Run fresh-install browse/search/create/rename/link-update/manual-order/reload smoke tests using downloaded assets. Local build success is not a substitute for this check. -- [ ] If authorized to edit release notes, replace generated notes with the prepared user-facing notes using a body file while retaining the CI-created release. Otherwise report that the notes are prepared and the release currently uses generated notes. -- [ ] Record release URL, tag commit, workflow result, asset checks, and installation observations in the evidence report through a follow-up documentation PR. Do not amend or move the released tag to include post-release evidence. -- [ ] If publication or artifact-install verification fails, record the release as failed/unverified and prepare a correction through the normal PR/version flow. Do not silently mark it complete or overwrite a published tag. - -## Final completion checklist - -- [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. -- [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. - -Final handoff must contain: completed tasks, commit/PR references, exact checks run, remaining FAIL/BLOCKED rows with next actions, and one unambiguous state: `implementation complete; acceptance blocked`, `release candidate ready; publication pending`, or `1.0.0 published and verified`. Never collapse these states into a generic “done.”