fix: list-directory returns flat array, drop defensive fallback

The list-directory handler returned { path, entries } while the
renderer typed result.data as FileEntry[]; the file-store had to fall
back to raw.entries via Array.isArray. Extract the entries builder into
src/main/files/list-directory.js so it can be unit-tested, return a
plain array, and skip entries that can't be stat'd (broken symlinks,
permission errors) instead of throwing.

Also tighten jest config so dist/*.snap electron-builder artifacts are
not matched as test suites.
This commit is contained in:
2026-07-23 09:34:57 +05:30
parent 772a791d9a
commit f1f8a16a79
5 changed files with 128 additions and 23 deletions
+16 -3
View File
@@ -10,10 +10,23 @@ module.exports = {
// Root directory // Root directory
rootDir: '.', rootDir: '.',
// Test file patterns // Test file patterns — scoped to ./tests/ only so dist/ artifacts like
// .snap packages don't get matched as test suites.
testMatch: [ testMatch: [
'**/tests/**/*.test.js', '<rootDir>/tests/**/*.test.js',
'**/tests/**/*.spec.js' '<rootDir>/tests/**/*.spec.js'
],
// Ignore build outputs so .snap packages and bundled .asar contents
// never enter jest's file discovery.
testPathIgnorePatterns: [
'/node_modules/',
'/dist/',
'/\\.git/'
],
modulePathIgnorePatterns: [
'/node_modules/',
'/dist/'
], ],
// Coverage configuration // Coverage configuration
+45
View File
@@ -0,0 +1,45 @@
'use strict';
const fs = require('fs');
const path = require('path');
/**
* List a directory's entries (excluding dotfiles), sorted directories-first
* then alphabetically. Returns a flat array of FileEntry-shaped objects,
* matching the renderer type declaration in `src/renderer/lib/ipc.ts`.
*
* Skips entries that cannot be stat'd (permission errors, broken symlinks)
* instead of throwing — keeps the UI responsive on partially-readable dirs.
*/
function listDirectoryEntries(dirPath) {
const dirents = fs.readdirSync(dirPath, { withFileTypes: true });
const entries = [];
for (const d of dirents) {
if (d.name.startsWith('.')) continue;
const full = path.join(dirPath, d.name);
let size = 0;
let modified = 0;
try {
const s = fs.statSync(full);
size = d.isDirectory() ? 0 : s.size;
modified = s.mtimeMs;
} catch (_err) {
continue;
}
entries.push({
name: d.name,
isDirectory: d.isDirectory(),
size,
modified,
path: full,
});
}
entries.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name);
});
return entries;
}
module.exports = { listDirectoryEntries };
+2 -16
View File
@@ -13,6 +13,7 @@ const WordTemplateExporter = require('./word-template');
const PDFOperations = require('./PDFOperations'); const PDFOperations = require('./PDFOperations');
const { validatePath, resolveWritablePath, isPathAccessible } = require('./utils/paths'); const { validatePath, resolveWritablePath, isPathAccessible } = require('./utils/paths');
const fileOps = require('./files'); const fileOps = require('./files');
const { listDirectoryEntries } = require('./files/list-directory');
const menu = require('./menu'); const menu = require('./menu');
const { createMainWindow } = require('./window'); const { createMainWindow } = require('./window');
const MonospaceFontConfig = require('./MonospaceFontConfig'); const MonospaceFontConfig = require('./MonospaceFontConfig');
@@ -3522,22 +3523,7 @@ ipcMain.handle('list-directory', async (event, dirPath) => {
return null; return null;
} }
const entries = fs return listDirectoryEntries(validation.resolved);
.readdirSync(validation.resolved, { withFileTypes: true })
.filter((e) => !e.name.startsWith('.'))
.sort((a, b) => {
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.localeCompare(b.name);
})
.map((e) => ({
name: e.name,
isDirectory: e.isDirectory(),
size: e.isDirectory() ? 0 : fs.statSync(path.join(validation.resolved, e.name)).size,
modified: fs.statSync(path.join(validation.resolved, e.name)).mtimeMs,
path: path.join(validation.resolved, e.name),
}));
return { path: validation.resolved, entries };
} catch (err) { } catch (err) {
console.error('list-directory error:', err); console.error('list-directory error:', err);
return null; return null;
+2 -4
View File
@@ -86,8 +86,7 @@ export const useFileStore = create<FileState>()(
return; return;
} }
const raw: any = result.data!; const entries = result.data ?? [];
const entries: any[] = Array.isArray(raw) ? raw : raw.entries;
const children: FileNode[] = entries.map(entryToNode); const children: FileNode[] = entries.map(entryToNode);
set((s) => { set((s) => {
@@ -119,8 +118,7 @@ export const useFileStore = create<FileState>()(
set((s) => { set((s) => {
updateNode(s.tree!, dirPath, (node) => { updateNode(s.tree!, dirPath, (node) => {
const raw: any = result.data!; const items = result.data ?? [];
const items: any[] = Array.isArray(raw) ? raw : raw.entries;
node.children = items.map(entryToNode); node.children = items.map(entryToNode);
node.loaded = true; node.loaded = true;
}); });
@@ -0,0 +1,63 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { listDirectoryEntries } = require('../../../../src/main/files/list-directory');
describe('listDirectoryEntries', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'list-dir-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('returns a flat array (not wrapped in { entries })', () => {
fs.writeFileSync(path.join(tmpDir, 'a.md'), 'hello');
const result = listDirectoryEntries(tmpDir);
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBe(1);
expect(result[0]).toMatchObject({
name: 'a.md',
isDirectory: false,
size: 5,
path: path.join(tmpDir, 'a.md'),
});
});
test('excludes dotfiles', () => {
fs.writeFileSync(path.join(tmpDir, '.hidden'), 'x');
fs.writeFileSync(path.join(tmpDir, 'visible.md'), 'y');
const result = listDirectoryEntries(tmpDir);
expect(result.map((e) => e.name)).toEqual(['visible.md']);
});
test('sorts directories first then alphabetically', () => {
fs.writeFileSync(path.join(tmpDir, 'zebra.md'), 'z');
fs.mkdirSync(path.join(tmpDir, 'aaa'));
fs.mkdirSync(path.join(tmpDir, 'bbb'));
fs.writeFileSync(path.join(tmpDir, 'apple.md'), 'a');
const result = listDirectoryEntries(tmpDir);
expect(result.map((e) => e.name)).toEqual(['aaa', 'bbb', 'apple.md', 'zebra.md']);
});
test('marks directories with isDirectory: true and size: 0', () => {
fs.mkdirSync(path.join(tmpDir, 'sub'));
const result = listDirectoryEntries(tmpDir);
expect(result[0]).toMatchObject({ name: 'sub', isDirectory: true, size: 0 });
});
test('skips entries that cannot be stat\'d (broken symlinks)', () => {
fs.writeFileSync(path.join(tmpDir, 'good.md'), 'ok');
try {
fs.symlinkSync(path.join(tmpDir, 'nonexistent'), path.join(tmpDir, 'broken-link'));
} catch (_) {
// symlinks not supported in env — skip
}
const result = listDirectoryEntries(tmpDir);
expect(result.find((e) => e.name === 'good.md')).toBeTruthy();
expect(result.find((e) => e.name === 'broken-link')).toBeUndefined();
});
});