From 33fa14c11aff2c35e297c5330acc3da5abb7a1e6 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Wed, 30 Sep 2026 19:56:50 +0530 Subject: [PATCH] feat(quick-switcher): IPC handler for workspace file listing listWorkspaceFiles() walks a directory recursively, returning [{path, name}] for files matching a configurable extension allowlist. Skips hidden dirs, node_modules, dist/build/coverage, and walks past permission-denied subdirs instead of throwing. Default allowlist is markdown variants + .txt; default maxResults caps pathological workspaces at 2000 entries. Wired through 'quick-switcher:list-files' invoke channel + a quickSwitcher.listFiles() convenience method on the preload bridge. Main-process errors collapse to [] rather than crashing the overlay. 16 new tests covering flat dirs, recursion, skip lists, custom extensions, maxResults cap, and validation errors. Amit Haridas --- src/main.js | 17 ++ src/preload.js | 10 ++ src/quick-switcher/workspace-file-lister.js | 111 ++++++++++++ tests/workspace-file-lister.test.js | 177 ++++++++++++++++++++ 4 files changed, 315 insertions(+) create mode 100644 src/quick-switcher/workspace-file-lister.js create mode 100644 tests/workspace-file-lister.test.js diff --git a/src/main.js b/src/main.js index e360070..59a8225 100644 --- a/src/main.js +++ b/src/main.js @@ -9,6 +9,7 @@ const ImageOperations = require('./main/ImageOperations'); const AudioOperations = require('./main/AudioOperations'); const VideoOperations = require('./main/VideoOperations'); const { collectFilesByExtension } = require('./main/collectFilesByExtension'); +const { listWorkspaceFiles } = require('./quick-switcher/workspace-file-lister'); const { runPDFBatchOperation } = require('./main/PDFBatchOperations'); const GitOperations = require('./main/GitOperations'); const PandocArgs = require('./main/PandocArgs'); @@ -2063,6 +2064,22 @@ ipcMain.handle('delete-export-preset', async (event, presetId) => ExportPresets.deletePreset(store, presetId) ); +// Quick-switcher workspace file listing (v4.13.0). Used by the Cmd+P overlay +// in the renderer to fuzzy-match across the active workspace. Pure logic +// lives in src/quick-switcher/workspace-file-lister.js; this is a thin IPC +// wrapper that converts main-process errors into an empty result so a flaky +// FS read can't crash the overlay. +ipcMain.handle('quick-switcher:list-files', async (_event, args = {}) => { + const { dir, extensions } = args || {}; + if (typeof dir !== 'string' || dir.length === 0) return []; + try { + return listWorkspaceFiles(dir, { extensions }); + } catch (err) { + console.warn(`quick-switcher:list-files failed for ${dir}: ${err.message}`); + return []; + } +}); + // Get current page settings ipcMain.on('get-page-settings', (event) => { event.reply('page-settings-data', pageSettings); diff --git a/src/preload.js b/src/preload.js index 9521e05..feb9a72 100644 --- a/src/preload.js +++ b/src/preload.js @@ -189,6 +189,9 @@ const ALLOWED_SEND_CHANNELS = [ // Quick Note scratchpad 'quick-note:save', + // Quick-switcher (Cmd+P workspace file picker) + 'quick-switcher:list-files', + // Document version history 'version-history:list', 'version-history:read', @@ -549,6 +552,13 @@ contextBridge.exposeInMainWorld('electronAPI', { insertAtCursor: (text) => ipcRenderer.send('insert-content', text), }, + // v4.13.0 — Quick-switcher workspace file listing for the Cmd+P overlay. + // Returns [{ path, name }] sorted by directory walk order. The renderer's + // fuzzy matcher (src/quick-switcher/fuzzy-matcher.js) does the ranking. + quickSwitcher: { + listFiles: (dir, options) => ipcRenderer.invoke('quick-switcher:list-files', { dir, options }), + }, + getAppVersion: () => ipcRenderer.invoke('get-app-version'), }); diff --git a/src/quick-switcher/workspace-file-lister.js b/src/quick-switcher/workspace-file-lister.js new file mode 100644 index 0000000..dfcfd2b --- /dev/null +++ b/src/quick-switcher/workspace-file-lister.js @@ -0,0 +1,111 @@ +/** + * Workspace file lister for the quick-switcher. + * + * Walks a directory tree and returns every entry whose extension matches a + * configurable allowlist. Skips: + * - hidden directories (leading '.') + * - `node_modules` and other known non-content dirs (dist, build, .next, …) + * + * The walk is injectable so tests use real fs via `mkdtempSync`. A cap + * (`maxResults`) prevents pathological workspaces (50k+ files) from + * blowing the IPC payload or the renderer's filter loop. + * + * @module workspace-file-lister + */ + +const path = require('path'); + +const DEFAULT_EXTENSIONS = ['.md', '.markdown', '.mdx', '.txt']; +const DEFAULT_MAX_RESULTS = 2000; + +// Known large/output directories that shouldn't appear in a file picker +const SKIP_DIRS = new Set([ + 'node_modules', + 'dist', + 'build', + 'out', + '.next', + '.nuxt', + '.cache', + '.parcel-cache', + '.turbo', + 'coverage', + '__snapshots__', +]); + +function isHiddenDir(name) { + return name.startsWith('.'); +} + +function isSkippedDir(name) { + return isHiddenDir(name) || SKIP_DIRS.has(name); +} + +/** + * @param {string} dir - Workspace root (absolute path) + * @param {object} [options] + * @param {string[]} [options.extensions] - Extensions to match (with leading dot) + * @param {number} [options.maxResults] - Hard cap on results + * @param {object} [options.io] - Injectable IO `{ fs, pathUtil }`. Defaults + * to the real `node:fs` and `node:path`. + * @returns {Array<{path:string, name:string}>} + */ +function listWorkspaceFiles(dir, options = {}) { + if (typeof dir !== 'string' || dir.length === 0) { + throw new Error('listWorkspaceFiles: dir must be a non-empty string'); + } + const { + extensions = DEFAULT_EXTENSIONS, + maxResults = DEFAULT_MAX_RESULTS, + io = { fs: require('fs'), pathUtil: path }, + } = options; + + if (!Array.isArray(extensions) || extensions.length === 0) { + throw new Error('listWorkspaceFiles: extensions must be a non-empty array'); + } + if (typeof maxResults !== 'number' || maxResults <= 0) { + throw new Error('listWorkspaceFiles: maxResults must be a positive number'); + } + + const normalizedExts = new Set(extensions.map((e) => String(e).toLowerCase())); + const results = []; + + walk(dir, normalizedExts, results, maxResults, io.fs, io.pathUtil); + return results; +} + +function walk(currentDir, exts, results, maxResults, fs, pathUtil) { + if (results.length >= maxResults) return; + let entries; + try { + entries = fs.readdirSync(currentDir, { withFileTypes: true }); + } catch (err) { + // Permission denied / ENOENT at a subdir shouldn't kill the walk. + // Real failures propagate when the root itself is unreadable — main + // process callers treat that as an empty result. + if (err && (err.code === 'EACCES' || err.code === 'ENOENT')) return; + throw err; + } + for (const entry of entries) { + if (results.length >= maxResults) return; + if (entry.isDirectory()) { + if (isSkippedDir(entry.name)) continue; + walk(pathUtil.join(currentDir, entry.name), exts, results, maxResults, fs, pathUtil); + } else if (entry.isFile()) { + const ext = pathUtil.extname(entry.name).toLowerCase(); + if (exts.has(ext)) { + results.push({ + path: pathUtil.join(currentDir, entry.name), + name: entry.name, + }); + } + } + } +} + +module.exports = { + listWorkspaceFiles, + DEFAULT_EXTENSIONS, + DEFAULT_MAX_RESULTS, + SKIP_DIRS, +}; diff --git a/tests/workspace-file-lister.test.js b/tests/workspace-file-lister.test.js new file mode 100644 index 0000000..d989b9f --- /dev/null +++ b/tests/workspace-file-lister.test.js @@ -0,0 +1,177 @@ +/** + * @jest-environment node + * + * Workspace file lister. + * + * Creates a real temp directory per test using mkdtempSync, writes a + * representative tree, and asserts the returned list. Pattern follows + * AutosaveBuffer.test.js. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { + listWorkspaceFiles, + DEFAULT_EXTENSIONS, + DEFAULT_MAX_RESULTS, +} = require('../src/quick-switcher/workspace-file-lister'); + +function makeTree(files) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'qswalker-')); + for (const [rel, content = ''] of Object.entries(files)) { + const full = path.join(root, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content, 'utf-8'); + } + return root; +} + +describe('listWorkspaceFiles', () => { + test('lists markdown files in a flat directory', () => { + const root = makeTree({ + 'readme.md': '', + 'notes.md': '', + 'task.md': '', + }); + const out = listWorkspaceFiles(root); + expect(out.map((f) => f.name).sort()).toEqual(['notes.md', 'readme.md', 'task.md']); + out.forEach((f) => expect(f.path).toMatch(/qswalker-/)); + }); + + test('returns {path, name} objects', () => { + const root = makeTree({ 'a.md': '' }); + const out = listWorkspaceFiles(root); + expect(out).toHaveLength(1); + expect(out[0]).toEqual({ path: expect.stringMatching(/a\.md$/), name: 'a.md' }); + }); + + test('recurses into subdirectories', () => { + const root = makeTree({ + 'top.md': '', + 'sub/nested.md': '', + 'sub/deeper/leaf.md': '', + }); + const out = listWorkspaceFiles(root); + expect(out.map((f) => f.name).sort()).toEqual(['leaf.md', 'nested.md', 'top.md']); + }); + + test('skips node_modules', () => { + const root = makeTree({ + 'readme.md': '', + 'node_modules/some-pkg/index.md': '', + 'node_modules/some-pkg/lib/util.md': '', + }); + const out = listWorkspaceFiles(root); + expect(out.map((f) => f.name)).toEqual(['readme.md']); + }); + + test('skips hidden directories (.git, .vscode)', () => { + const root = makeTree({ + 'readme.md': '', + '.git/config.md': '', + '.vscode/settings.md': '', + '.idea/workspace.md': '', + }); + const out = listWorkspaceFiles(root); + expect(out.map((f) => f.name)).toEqual(['readme.md']); + }); + + test('skips known non-content dirs (dist, build, coverage)', () => { + const root = makeTree({ + 'src/real.md': '', + 'dist/bundle.md': '', + 'build/output.md': '', + 'coverage/report.md': '', + }); + const out = listWorkspaceFiles(root); + expect(out.map((f) => f.name)).toEqual(['real.md']); + }); + + test('honors custom extensions', () => { + const root = makeTree({ + 'a.md': '', + 'b.txt': '', + 'c.rst': '', + 'd.json': '', + }); + const out = listWorkspaceFiles(root, { extensions: ['.txt', '.rst'] }); + expect(out.map((f) => f.name).sort()).toEqual(['b.txt', 'c.rst']); + }); + + test('default extensions are markdown variants + txt', () => { + const root = makeTree({ + 'a.md': '', + 'b.markdown': '', + 'c.mdx': '', + 'd.txt': '', + 'e.rst': '', // not in default set + 'f.json': '', // not in default set + }); + const out = listWorkspaceFiles(root); + expect(out.map((f) => f.name).sort()).toEqual(['a.md', 'b.markdown', 'c.mdx', 'd.txt']); + expect(DEFAULT_EXTENSIONS).toContain('.md'); + expect(DEFAULT_EXTENSIONS).toContain('.markdown'); + }); + + test('caps results at maxResults', () => { + const files = {}; + for (let i = 0; i < 50; i++) { + files[`f${String(i).padStart(3, '0')}.md`] = ''; + } + const root = makeTree(files); + const out = listWorkspaceFiles(root, { maxResults: 10 }); + expect(out).toHaveLength(10); + }); + + test('default maxResults is a positive number (sanity)', () => { + expect(typeof DEFAULT_MAX_RESULTS).toBe('number'); + expect(DEFAULT_MAX_RESULTS).toBeGreaterThan(0); + }); + + test('returns empty array for empty directory', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'qswalker-empty-')); + const out = listWorkspaceFiles(root); + expect(out).toEqual([]); + }); + + test('returns empty array (does not throw) for unreadable subdir', () => { + // We can't easily simulate EACCES portably; instead confirm that + // removing the directory mid-walk or pointing at a missing root + // returns [] (rather than blowing up the IPC handler). + const root = makeTree({ + 'a.md': '', + 'will-vanish/b.md': '', + }); + // Remove the subdirectory after creation so walk encounters ENOENT + fs.rmSync(path.join(root, 'will-vanish'), { recursive: true, force: true }); + const out = listWorkspaceFiles(root); + expect(out.map((f) => f.name)).toEqual(['a.md']); + }); + + test('throws on missing/invalid dir', () => { + expect(() => listWorkspaceFiles('')).toThrow(/non-empty/); + expect(() => listWorkspaceFiles(null)).toThrow(/non-empty/); + expect(() => listWorkspaceFiles(undefined)).toThrow(/non-empty/); + expect(() => listWorkspaceFiles(42)).toThrow(/non-empty/); + }); + + test('throws on empty extensions array', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'qswalker-')); + expect(() => listWorkspaceFiles(root, { extensions: [] })).toThrow(/extensions/); + }); + + test('throws on invalid maxResults', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'qswalker-')); + expect(() => listWorkspaceFiles(root, { maxResults: 0 })).toThrow(/maxResults/); + expect(() => listWorkspaceFiles(root, { maxResults: -1 })).toThrow(/maxResults/); + expect(() => listWorkspaceFiles(root, { maxResults: 'lots' })).toThrow(/maxResults/); + }); + + test('paths are absolute and joined under root', () => { + const root = makeTree({ 'sub/note.md': '' }); + const out = listWorkspaceFiles(root); + expect(out[0].path.startsWith(root)).toBe(true); + expect(path.isAbsolute(out[0].path)).toBe(true); + }); +});