diff --git a/jest.config.js b/jest.config.js index 5435ffa..fa73ac1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -10,10 +10,23 @@ module.exports = { // Root directory 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: [ - '**/tests/**/*.test.js', - '**/tests/**/*.spec.js' + '/tests/**/*.test.js', + '/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 diff --git a/src/main/files/list-directory.js b/src/main/files/list-directory.js new file mode 100644 index 0000000..cc4e41c --- /dev/null +++ b/src/main/files/list-directory.js @@ -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 }; diff --git a/src/main/index.js b/src/main/index.js index 64a4cc1..5cc50c4 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -13,6 +13,7 @@ const WordTemplateExporter = require('./word-template'); const PDFOperations = require('./PDFOperations'); const { validatePath, resolveWritablePath, isPathAccessible } = require('./utils/paths'); const fileOps = require('./files'); +const { listDirectoryEntries } = require('./files/list-directory'); const menu = require('./menu'); const { createMainWindow } = require('./window'); const MonospaceFontConfig = require('./MonospaceFontConfig'); @@ -3522,22 +3523,7 @@ ipcMain.handle('list-directory', async (event, dirPath) => { return null; } - const entries = fs - .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 }; + return listDirectoryEntries(validation.resolved); } catch (err) { console.error('list-directory error:', err); return null; diff --git a/src/renderer/stores/file-store.ts b/src/renderer/stores/file-store.ts index e555f8d..6a0f529 100644 --- a/src/renderer/stores/file-store.ts +++ b/src/renderer/stores/file-store.ts @@ -86,8 +86,7 @@ export const useFileStore = create()( return; } - const raw: any = result.data!; - const entries: any[] = Array.isArray(raw) ? raw : raw.entries; + const entries = result.data ?? []; const children: FileNode[] = entries.map(entryToNode); set((s) => { @@ -119,8 +118,7 @@ export const useFileStore = create()( set((s) => { updateNode(s.tree!, dirPath, (node) => { - const raw: any = result.data!; - const items: any[] = Array.isArray(raw) ? raw : raw.entries; + const items = result.data ?? []; node.children = items.map(entryToNode); node.loaded = true; }); diff --git a/tests/unit/main/files/list-directory.test.js b/tests/unit/main/files/list-directory.test.js new file mode 100644 index 0000000..a68a82c --- /dev/null +++ b/tests/unit/main/files/list-directory.test.js @@ -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(); + }); +});