Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions packages/electron/src/main/directory-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';

vi.mock('chokidar', () => ({
watch: vi.fn().mockReturnValue({
on: vi.fn().mockReturnThis(),
close: vi.fn().mockResolvedValue(undefined),
}),
}));

import { DirectoryService } from './directory-service';
import { watch } from 'chokidar';

describe('DirectoryService', () => {
let service: DirectoryService;
let tmpDir: string;

beforeEach(() => {
vi.clearAllMocks();
service = new DirectoryService();
tmpDir = mkdtempSync(join(tmpdir(), 'mdview-test-'));
});

afterEach(() => {
service.dispose();
rmSync(tmpDir, { recursive: true, force: true });
});

it('should list markdown files in a directory', () => {
writeFileSync(join(tmpDir, 'readme.md'), '# Hello');
writeFileSync(join(tmpDir, 'notes.markdown'), '# Notes');

const entries = service.listDirectory(tmpDir);
const names = entries.map((e) => e.name);

expect(names).toContain('readme.md');
expect(names).toContain('notes.markdown');
expect(entries.every((e) => e.type === 'file')).toBe(true);
});

it('should sort directories first, then alphabetically', () => {
mkdirSync(join(tmpDir, 'zebra'));
writeFileSync(join(tmpDir, 'zebra', 'file.md'), '# Z');
mkdirSync(join(tmpDir, 'alpha'));
writeFileSync(join(tmpDir, 'alpha', 'file.md'), '# A');
writeFileSync(join(tmpDir, 'beta.md'), '# B');
writeFileSync(join(tmpDir, 'aaa.md'), '# A');

const entries = service.listDirectory(tmpDir);
const names = entries.map((e) => e.name);

// Directories first (alphabetically), then files (alphabetically)
expect(names).toEqual(['alpha', 'zebra', 'aaa.md', 'beta.md']);
});

it('should filter out non-markdown files', () => {
writeFileSync(join(tmpDir, 'readme.md'), '# Hello');
writeFileSync(join(tmpDir, 'image.png'), 'binary');
writeFileSync(join(tmpDir, 'style.css'), 'body {}');
writeFileSync(join(tmpDir, 'data.json'), '{}');

const entries = service.listDirectory(tmpDir);

expect(entries).toHaveLength(1);
expect(entries[0].name).toBe('readme.md');
});

it('should build recursive tree structure', () => {
mkdirSync(join(tmpDir, 'docs'));
writeFileSync(join(tmpDir, 'docs', 'guide.md'), '# Guide');
mkdirSync(join(tmpDir, 'docs', 'api'));
writeFileSync(join(tmpDir, 'docs', 'api', 'reference.md'), '# Ref');
writeFileSync(join(tmpDir, 'readme.md'), '# Root');

const entries = service.listDirectory(tmpDir);

// Should have docs/ directory and readme.md
expect(entries).toHaveLength(2);

const docsDir = entries.find((e) => e.name === 'docs');
expect(docsDir).toBeDefined();
expect(docsDir!.type).toBe('directory');
expect(docsDir!.children).toBeDefined();
expect(docsDir!.children).toHaveLength(2); // api/ and guide.md

const apiDir = docsDir!.children!.find((e) => e.name === 'api');
expect(apiDir).toBeDefined();
expect(apiDir!.type).toBe('directory');
expect(apiDir!.children).toHaveLength(1);
expect(apiDir!.children![0].name).toBe('reference.md');
});

it('should handle empty directory', () => {
const entries = service.listDirectory(tmpDir);
expect(entries).toEqual([]);
});

it('should handle nonexistent directory gracefully', () => {
const entries = service.listDirectory(join(tmpDir, 'nonexistent'));
expect(entries).toEqual([]);
});

it('should exclude directories that contain no markdown files', () => {
mkdirSync(join(tmpDir, 'images'));
writeFileSync(join(tmpDir, 'images', 'photo.png'), 'binary');
mkdirSync(join(tmpDir, 'docs'));
writeFileSync(join(tmpDir, 'docs', 'guide.md'), '# Guide');

const entries = service.listDirectory(tmpDir);

const names = entries.map((e) => e.name);
expect(names).not.toContain('images');
expect(names).toContain('docs');
});

it('should watch and call back on changes', () => {
const callback = vi.fn();
service.watchDirectory(tmpDir, callback);

expect(watch).toHaveBeenCalledWith(
tmpDir,
expect.objectContaining({
ignoreInitial: true,
})
);
});

it('should dispose all watchers', () => {
const mockWatcher = {
on: vi.fn().mockReturnThis(),
close: vi.fn().mockResolvedValue(undefined),
};
vi.mocked(watch).mockReturnValue(mockWatcher as never);

service.watchDirectory(tmpDir, vi.fn());
service.watchDirectory(join(tmpDir, 'other'), vi.fn());
service.dispose();

expect(mockWatcher.close).toHaveBeenCalledTimes(2);
});

it('should unwatch a specific directory', () => {
const mockWatcher = {
on: vi.fn().mockReturnThis(),
close: vi.fn().mockResolvedValue(undefined),
};
vi.mocked(watch).mockReturnValue(mockWatcher as never);

service.watchDirectory(tmpDir, vi.fn());
service.unwatchDirectory(tmpDir);

expect(mockWatcher.close).toHaveBeenCalledTimes(1);
});

it('should recognize all markdown extensions', () => {
const extensions = ['.md', '.markdown', '.mdown', '.mkd', '.mkdn', '.mdx'];
for (const ext of extensions) {
writeFileSync(join(tmpDir, `file${ext}`), '# Test');
}

const entries = service.listDirectory(tmpDir);
expect(entries).toHaveLength(extensions.length);
});
});
103 changes: 103 additions & 0 deletions packages/electron/src/main/directory-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { readdirSync, statSync } from 'fs';
import { join, extname } from 'path';
import { watch } from 'chokidar';
import type { DirectoryEntry } from '../shared/workspace-types';

const MD_EXTENSIONS = new Set(['.md', '.markdown', '.mdown', '.mkd', '.mkdn', '.mdx']);

interface WatcherHandle {
close(): Promise<void>;
}

export class DirectoryService {
private watchers = new Map<string, WatcherHandle>();

listDirectory(dirPath: string): DirectoryEntry[] {
try {
return this.readDirectoryRecursive(dirPath);
} catch {
return [];
}
}

watchDirectory(dirPath: string, callback: () => void): void {
this.unwatchDirectory(dirPath);

let timeout: ReturnType<typeof setTimeout> | null = null;
const debounced = () => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(callback, 300);
};

const watcher = watch(dirPath, {
ignoreInitial: true,
persistent: true,
});

watcher.on('add', debounced);
watcher.on('unlink', debounced);
watcher.on('addDir', debounced);
watcher.on('unlinkDir', debounced);

this.watchers.set(dirPath, watcher);
}

unwatchDirectory(dirPath: string): void {
const watcher = this.watchers.get(dirPath);
if (watcher) {
void watcher.close();
this.watchers.delete(dirPath);
}
}

dispose(): void {
for (const watcher of this.watchers.values()) {
void watcher.close();
}
this.watchers.clear();
}

private readDirectoryRecursive(dirPath: string): DirectoryEntry[] {
const rawEntries = readdirSync(dirPath);
const directories: DirectoryEntry[] = [];
const files: DirectoryEntry[] = [];

for (const name of rawEntries) {
// Skip hidden files/directories
if (name.startsWith('.')) continue;

const fullPath = join(dirPath, name);
let stat;
try {
stat = statSync(fullPath);
} catch {
continue;
}

if (stat.isDirectory()) {
const children = this.readDirectoryRecursive(fullPath);
// Only include directories that contain markdown files (directly or nested)
if (children.length > 0) {
directories.push({
name,
path: fullPath,
type: 'directory',
children,
});
}
} else if (stat.isFile() && MD_EXTENSIONS.has(extname(name).toLowerCase())) {
files.push({
name,
path: fullPath,
type: 'file',
});
}
}

// Sort directories alphabetically, then files alphabetically
directories.sort((a, b) => a.name.localeCompare(b.name));
files.sort((a, b) => a.name.localeCompare(b.name));

return [...directories, ...files];
}
}
Loading
Loading