mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
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:
+17
@@ -9,6 +9,7 @@ const ImageOperations = require('./main/ImageOperations');
|
|||||||
const AudioOperations = require('./main/AudioOperations');
|
const AudioOperations = require('./main/AudioOperations');
|
||||||
const VideoOperations = require('./main/VideoOperations');
|
const VideoOperations = require('./main/VideoOperations');
|
||||||
const { collectFilesByExtension } = require('./main/collectFilesByExtension');
|
const { collectFilesByExtension } = require('./main/collectFilesByExtension');
|
||||||
|
const { listWorkspaceFiles } = require('./quick-switcher/workspace-file-lister');
|
||||||
const { runPDFBatchOperation } = require('./main/PDFBatchOperations');
|
const { runPDFBatchOperation } = require('./main/PDFBatchOperations');
|
||||||
const GitOperations = require('./main/GitOperations');
|
const GitOperations = require('./main/GitOperations');
|
||||||
const PandocArgs = require('./main/PandocArgs');
|
const PandocArgs = require('./main/PandocArgs');
|
||||||
@@ -2063,6 +2064,22 @@ ipcMain.handle('delete-export-preset', async (event, presetId) =>
|
|||||||
ExportPresets.deletePreset(store, 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
|
// Get current page settings
|
||||||
ipcMain.on('get-page-settings', (event) => {
|
ipcMain.on('get-page-settings', (event) => {
|
||||||
event.reply('page-settings-data', pageSettings);
|
event.reply('page-settings-data', pageSettings);
|
||||||
|
|||||||
@@ -189,6 +189,9 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
// Quick Note scratchpad
|
// Quick Note scratchpad
|
||||||
'quick-note:save',
|
'quick-note:save',
|
||||||
|
|
||||||
|
// Quick-switcher (Cmd+P workspace file picker)
|
||||||
|
'quick-switcher:list-files',
|
||||||
|
|
||||||
// Document version history
|
// Document version history
|
||||||
'version-history:list',
|
'version-history:list',
|
||||||
'version-history:read',
|
'version-history:read',
|
||||||
@@ -549,6 +552,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
insertAtCursor: (text) => ipcRenderer.send('insert-content', text),
|
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'),
|
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user