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 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);
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user