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
This commit is contained in:
Amit Haridas
2026-09-30 19:56:50 +05:30
parent de7ebecad4
commit 33fa14c11a
4 changed files with 315 additions and 0 deletions
+17
View File
@@ -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);
+10
View File
@@ -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'),
});
+111
View File
@@ -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,
};
+177
View File
@@ -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);
});
});