diff --git a/packages/electron/src/main/directory-service.test.ts b/packages/electron/src/main/directory-service.test.ts new file mode 100644 index 0000000..270c9ac --- /dev/null +++ b/packages/electron/src/main/directory-service.test.ts @@ -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); + }); +}); diff --git a/packages/electron/src/main/directory-service.ts b/packages/electron/src/main/directory-service.ts new file mode 100644 index 0000000..6b647a3 --- /dev/null +++ b/packages/electron/src/main/directory-service.ts @@ -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; +} + +export class DirectoryService { + private watchers = new Map(); + + listDirectory(dirPath: string): DirectoryEntry[] { + try { + return this.readDirectoryRecursive(dirPath); + } catch { + return []; + } + } + + watchDirectory(dirPath: string, callback: () => void): void { + this.unwatchDirectory(dirPath); + + let timeout: ReturnType | 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]; + } +} diff --git a/packages/electron/src/main/index.ts b/packages/electron/src/main/index.ts index 989deca..46e6db1 100644 --- a/packages/electron/src/main/index.ts +++ b/packages/electron/src/main/index.ts @@ -1,5 +1,5 @@ import { app, BrowserWindow, dialog } from 'electron'; -import { join, extname } from 'path'; +import { join, extname, basename } from 'path'; import ElectronStore from 'electron-store'; import { CacheManager } from '@mdview/core'; import { ElectronStorageAdapter } from './adapters/storage-adapter'; @@ -8,11 +8,19 @@ import { ElectronIdentityAdapter } from './adapters/identity-adapter'; import { ElectronExportAdapter } from './adapters/export-adapter'; import { StateManager } from './state-manager'; import { registerIpcHandlers } from './ipc-handlers'; +import { RecentFilesManager } from './recent-files'; +import { SessionManager } from './session-restore'; +import { DirectoryService } from './directory-service'; +import { buildApplicationMenu } from './menu'; +import { IPC_CHANNELS } from '../shared/ipc-channels'; const MD_EXTENSIONS = new Set(['.md', '.markdown', '.mdown', '.mkd', '.mkdn', '.mdx']); let mainWindow: BrowserWindow | null = null; let openFilePath: string | null = null; +let stateManagerRef: StateManager | null = null; +let sessionManagerRef: SessionManager | null = null; +let recentFilesRef: RecentFilesManager | null = null; function parseOpenFilePath(): string | null { const args = process.argv.slice(app.isPackaged ? 1 : 2); @@ -28,6 +36,7 @@ function createWindow(): BrowserWindow { const win = new BrowserWindow({ width: 1024, height: 768, + titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', webPreferences: { preload: join(__dirname, '../preload/index.js'), contextIsolation: true, @@ -44,6 +53,90 @@ function createWindow(): BrowserWindow { return win; } +function updateWindowTitle(filePath: string | null): void { + if (!mainWindow) return; + if (filePath) { + mainWindow.setTitle(`${basename(filePath)} — mdview`); + } else { + mainWindow.setTitle('mdview'); + } +} + +function sendOpenFileToRenderer(path: string): void { + if (mainWindow) { + mainWindow.webContents.send(IPC_CHANNELS.OPEN_FILE, path); + updateWindowTitle(path); + } +} + +function openFileDialog(): void { + if (!mainWindow) return; + void dialog + .showOpenDialog(mainWindow, { + properties: ['openFile', 'multiSelections'], + filters: [ + { name: 'Markdown', extensions: ['md', 'markdown', 'mdown', 'mkd', 'mkdn', 'mdx'] }, + ], + }) + .then((result) => { + if (!result.canceled) { + for (const filePath of result.filePaths) { + sendOpenFileToRenderer(filePath); + } + } + }); +} + +function openFolderDialog(): void { + if (!mainWindow) return; + void dialog + .showOpenDialog(mainWindow, { + properties: ['openDirectory'], + }) + .then((result) => { + if (!result.canceled && result.filePaths[0]) { + mainWindow?.webContents.send(IPC_CHANNELS.OPEN_FOLDER, result.filePaths[0]); + } + }); +} + +function rebuildMenu(): void { + buildApplicationMenu({ + onOpenFile: openFileDialog, + onOpenFolder: openFolderDialog, + getRecentFiles: () => recentFilesRef?.getFiles() ?? [], + onClearRecent: () => { + recentFilesRef?.clear(); + rebuildMenu(); + }, + onCloseTab: () => { + mainWindow?.webContents.send(IPC_CHANNELS.MENU_COMMAND, 'close-tab'); + }, + onToggleSidebar: () => { + mainWindow?.webContents.send(IPC_CHANNELS.MENU_COMMAND, 'toggle-sidebar'); + }, + onToggleToc: () => { + mainWindow?.webContents.send(IPC_CHANNELS.MENU_COMMAND, 'toggle-toc'); + }, + onMenuCommand: (command: string) => { + if (command.startsWith('open:')) { + sendOpenFileToRenderer(command.slice(5)); + } else { + mainWindow?.webContents.send(IPC_CHANNELS.MENU_COMMAND, command); + } + }, + }); +} + +function saveSession(): void { + if (!stateManagerRef || !sessionManagerRef) return; + const ws = stateManagerRef.getWorkspaceState(); + sessionManagerRef.saveSession({ + tabs: ws.tabs.map((t) => t.filePath), + activeIndex: ws.activeTabId ? ws.tabs.findIndex((t) => t.id === ws.activeTabId) : 0, + }); +} + void app.whenReady().then(async () => { openFilePath = parseOpenFilePath(); @@ -55,6 +148,13 @@ void app.whenReady().then(async () => { const identityAdapter = new ElectronIdentityAdapter(); const cacheManager = new CacheManager({ maxSize: 50, maxAge: 3600000 }); const stateManager = new StateManager(storageAdapter); + const recentFiles = new RecentFilesManager(localStore); + const sessionManager = new SessionManager(localStore); + const directoryService = new DirectoryService(); + + stateManagerRef = stateManager; + sessionManagerRef = sessionManager; + recentFilesRef = recentFiles; await stateManager.initialize(); @@ -72,10 +172,20 @@ void app.whenReady().then(async () => { fileAdapter, identityAdapter, exportAdapter, + recentFiles, + directoryService, getWindow: () => mainWindow, getOpenFilePath: () => openFilePath, }); + // Build application menu + rebuildMenu(); + + // Update window title when active tab changes + mainWindow.webContents.on('did-finish-load', () => { + updateWindowTitle(openFilePath); + }); + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { mainWindow = createWindow(); @@ -83,9 +193,15 @@ void app.whenReady().then(async () => { }); mainWindow.on('closed', () => { + saveSession(); fileAdapter.dispose(); + directoryService.dispose(); mainWindow = null; }); + + app.on('before-quit', () => { + saveSession(); + }); }); app.on('window-all-closed', () => { @@ -94,10 +210,12 @@ app.on('window-all-closed', () => { } }); -// Handle file open from OS (macOS) +// Handle file open from OS (macOS) — send to renderer instead of reloading page app.on('open-file', (_event, path) => { - openFilePath = path; if (mainWindow) { - void mainWindow.loadFile(join(__dirname, '../renderer/index.html')); + sendOpenFileToRenderer(path); + } else { + // App not ready yet — store for initial load + openFilePath = path; } }); diff --git a/packages/electron/src/main/ipc-handlers.test.ts b/packages/electron/src/main/ipc-handlers.test.ts index d1b4a75..4b4b2d5 100644 --- a/packages/electron/src/main/ipc-handlers.test.ts +++ b/packages/electron/src/main/ipc-handlers.test.ts @@ -11,6 +11,9 @@ vi.mock('electron', () => ({ }), removeHandler: vi.fn(), }, + dialog: { + showOpenDialog: vi.fn().mockResolvedValue({ canceled: false, filePaths: ['/tmp/test.md'] }), + }, })); // Import after mock @@ -25,6 +28,21 @@ function createMockDeps() { ui: { theme: null }, }), updatePreferences: vi.fn(), + getWorkspaceState: vi.fn().mockReturnValue({ + tabs: [], + activeTabId: null, + sidebarVisible: true, + sidebarWidth: 250, + openFolderPath: null, + statusBarVisible: true, + }), + openTab: vi.fn().mockReturnValue({ id: 'tab-1', filePath: '/tmp/test.md', title: 'test.md' }), + closeTab: vi.fn(), + setActiveTab: vi.fn(), + updateTabMetadata: vi.fn(), + updateTabScrollPosition: vi.fn(), + setSidebarVisible: vi.fn(), + setOpenFolder: vi.fn(), }, cacheManager: { generateKey: vi.fn().mockResolvedValue('cache-key-123'), @@ -81,6 +99,22 @@ describe('IPC Handlers', () => { IPC_CHANNELS.SAVE_FILE, IPC_CHANNELS.PRINT_TO_PDF, IPC_CHANNELS.GET_OPEN_FILE_PATH, + IPC_CHANNELS.SHOW_OPEN_FILE_DIALOG, + IPC_CHANNELS.SHOW_OPEN_FOLDER_DIALOG, + IPC_CHANNELS.GET_RECENT_FILES, + IPC_CHANNELS.ADD_RECENT_FILE, + IPC_CHANNELS.CLEAR_RECENT_FILES, + IPC_CHANNELS.GET_WORKSPACE_STATE, + IPC_CHANNELS.OPEN_TAB, + IPC_CHANNELS.CLOSE_TAB, + IPC_CHANNELS.SET_ACTIVE_TAB, + IPC_CHANNELS.UPDATE_TAB_METADATA, + IPC_CHANNELS.UPDATE_TAB_SCROLL, + IPC_CHANNELS.SET_SIDEBAR_VISIBLE, + IPC_CHANNELS.SET_OPEN_FOLDER, + IPC_CHANNELS.LIST_DIRECTORY, + IPC_CHANNELS.WATCH_DIRECTORY, + IPC_CHANNELS.UNWATCH_DIRECTORY, ]; for (const channel of expectedChannels) { expect(handlers.has(channel)).toBe(true); diff --git a/packages/electron/src/main/ipc-handlers.ts b/packages/electron/src/main/ipc-handlers.ts index 593e0a3..8c18381 100644 --- a/packages/electron/src/main/ipc-handlers.ts +++ b/packages/electron/src/main/ipc-handlers.ts @@ -1,10 +1,13 @@ -import { ipcMain, type BrowserWindow } from 'electron'; +import { ipcMain, dialog, type BrowserWindow } from 'electron'; import { IPC_CHANNELS } from '../shared/ipc-channels'; import type { StateManager } from './state-manager'; import type { ElectronFileAdapter } from './adapters/file-adapter'; import type { ElectronIdentityAdapter } from './adapters/identity-adapter'; import type { ElectronExportAdapter } from './adapters/export-adapter'; +import type { RecentFilesManager } from './recent-files'; +import type { DirectoryService } from './directory-service'; import type { CacheManager, CachedResult, Preferences } from '@mdview/core'; +import type { TabState } from '../shared/workspace-types'; export interface IPCHandlerDeps { stateManager: StateManager; @@ -12,6 +15,8 @@ export interface IPCHandlerDeps { fileAdapter: ElectronFileAdapter; identityAdapter: ElectronIdentityAdapter; exportAdapter: ElectronExportAdapter; + recentFiles?: RecentFilesManager; + directoryService?: DirectoryService; getWindow: () => BrowserWindow | null; getOpenFilePath: () => string | null; } @@ -23,6 +28,8 @@ export function registerIpcHandlers(deps: IPCHandlerDeps): void { fileAdapter, identityAdapter, exportAdapter, + recentFiles, + directoryService, getWindow, getOpenFilePath, } = deps; @@ -119,4 +126,111 @@ export function registerIpcHandlers(deps: IPCHandlerDeps): void { ipcMain.handle(IPC_CHANNELS.GET_OPEN_FILE_PATH, () => { return getOpenFilePath(); }); + + // File dialogs + ipcMain.handle(IPC_CHANNELS.SHOW_OPEN_FILE_DIALOG, async () => { + const win = getWindow(); + if (!win) return null; + const result = await dialog.showOpenDialog(win, { + properties: ['openFile', 'multiSelections'], + filters: [ + { name: 'Markdown', extensions: ['md', 'markdown', 'mdown', 'mkd', 'mkdn', 'mdx'] }, + ], + }); + return result.canceled ? null : result.filePaths; + }); + + ipcMain.handle(IPC_CHANNELS.SHOW_OPEN_FOLDER_DIALOG, async () => { + const win = getWindow(); + if (!win) return null; + const result = await dialog.showOpenDialog(win, { + properties: ['openDirectory'], + }); + return result.canceled ? null : (result.filePaths[0] ?? null); + }); + + // Recent files + ipcMain.handle(IPC_CHANNELS.GET_RECENT_FILES, () => { + return recentFiles?.getFiles() ?? []; + }); + + ipcMain.handle(IPC_CHANNELS.ADD_RECENT_FILE, (_event, path: string) => { + recentFiles?.addFile(path); + }); + + ipcMain.handle(IPC_CHANNELS.CLEAR_RECENT_FILES, () => { + recentFiles?.clear(); + }); + + // Workspace state + ipcMain.handle(IPC_CHANNELS.GET_WORKSPACE_STATE, () => { + return stateManager.getWorkspaceState(); + }); + + ipcMain.handle(IPC_CHANNELS.OPEN_TAB, (_event, filePath: string) => { + const tab = stateManager.openTab(filePath); + const win = getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TAB_OPENED, tab); + win.webContents.send(IPC_CHANNELS.ACTIVE_TAB_CHANGED, tab.id); + } + return tab; + }); + + ipcMain.handle(IPC_CHANNELS.CLOSE_TAB, (_event, tabId: string) => { + stateManager.closeTab(tabId); + const win = getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TAB_CLOSED, tabId); + const ws = stateManager.getWorkspaceState(); + if (ws.activeTabId) { + win.webContents.send(IPC_CHANNELS.ACTIVE_TAB_CHANGED, ws.activeTabId); + } + } + }); + + ipcMain.handle(IPC_CHANNELS.SET_ACTIVE_TAB, (_event, tabId: string) => { + stateManager.setActiveTab(tabId); + const win = getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.ACTIVE_TAB_CHANGED, tabId); + } + }); + + ipcMain.handle( + IPC_CHANNELS.UPDATE_TAB_METADATA, + (_event, tabId: string, metadata: Partial) => { + stateManager.updateTabMetadata(tabId, metadata); + } + ); + + ipcMain.handle(IPC_CHANNELS.UPDATE_TAB_SCROLL, (_event, tabId: string, position: number) => { + stateManager.updateTabScrollPosition(tabId, position); + }); + + ipcMain.handle(IPC_CHANNELS.SET_SIDEBAR_VISIBLE, (_event, visible: boolean) => { + stateManager.setSidebarVisible(visible); + }); + + ipcMain.handle(IPC_CHANNELS.SET_OPEN_FOLDER, (_event, path: string | null) => { + stateManager.setOpenFolder(path); + }); + + // Directory service + ipcMain.handle(IPC_CHANNELS.LIST_DIRECTORY, (_event, dirPath: string) => { + return directoryService?.listDirectory(dirPath) ?? []; + }); + + ipcMain.handle(IPC_CHANNELS.WATCH_DIRECTORY, (_event, dirPath: string) => { + directoryService?.watchDirectory(dirPath, () => { + const win = getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.DIRECTORY_CHANGED, dirPath); + } + }); + }); + + ipcMain.handle(IPC_CHANNELS.UNWATCH_DIRECTORY, (_event, dirPath: string) => { + directoryService?.unwatchDirectory(dirPath); + }); } diff --git a/packages/electron/src/main/menu.test.ts b/packages/electron/src/main/menu.test.ts new file mode 100644 index 0000000..f208cb8 --- /dev/null +++ b/packages/electron/src/main/menu.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockBuildFromTemplate = vi.fn((template: unknown[]) => ({ items: template })); +const mockSetApplicationMenu = vi.fn(); + +vi.mock('electron', () => ({ + Menu: { + buildFromTemplate: mockBuildFromTemplate, + setApplicationMenu: mockSetApplicationMenu, + }, + app: { + name: 'mdview', + }, +})); + +const { buildApplicationMenu } = await import('./menu'); + +function createMockDeps() { + return { + onOpenFile: vi.fn(), + onOpenFolder: vi.fn(), + getRecentFiles: vi.fn().mockReturnValue([]), + onClearRecent: vi.fn(), + onCloseTab: vi.fn(), + onToggleSidebar: vi.fn(), + onToggleToc: vi.fn(), + onMenuCommand: vi.fn(), + }; +} + +describe('buildApplicationMenu', () => { + let deps: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + deps = createMockDeps(); + }); + + it('should build and set the application menu', () => { + buildApplicationMenu(deps); + expect(mockBuildFromTemplate).toHaveBeenCalled(); + expect(mockSetApplicationMenu).toHaveBeenCalled(); + }); + + it('should include File, Edit, View, Export, and Help menus', () => { + buildApplicationMenu(deps); + const template = mockBuildFromTemplate.mock.calls[0][0] as Array<{ + label?: string; + role?: string; + }>; + const labels = template.map((item) => item.label ?? item.role); + expect(labels).toContain('File'); + expect(labels).toContain('Edit'); + expect(labels).toContain('View'); + expect(labels).toContain('Export'); + expect(labels).toContain('Help'); + }); + + it('should call onOpenFile when Open File is clicked', () => { + buildApplicationMenu(deps); + const template = mockBuildFromTemplate.mock.calls[0][0] as Array<{ + label?: string; + submenu?: Array<{ label?: string; click?: () => void }>; + }>; + const fileMenu = template.find((item) => item.label === 'File'); + const openItem = fileMenu?.submenu?.find((item) => item.label === 'Open File...'); + openItem?.click?.(); + expect(deps.onOpenFile).toHaveBeenCalled(); + }); + + it('should call onOpenFolder when Open Folder is clicked', () => { + buildApplicationMenu(deps); + const template = mockBuildFromTemplate.mock.calls[0][0] as Array<{ + label?: string; + submenu?: Array<{ label?: string; click?: () => void }>; + }>; + const fileMenu = template.find((item) => item.label === 'File'); + const openFolder = fileMenu?.submenu?.find((item) => item.label === 'Open Folder...'); + openFolder?.click?.(); + expect(deps.onOpenFolder).toHaveBeenCalled(); + }); + + it('should populate Recent Files submenu', () => { + deps.getRecentFiles.mockReturnValue(['/tmp/a.md', '/tmp/b.md']); + buildApplicationMenu(deps); + const template = mockBuildFromTemplate.mock.calls[0][0] as Array<{ + label?: string; + submenu?: Array<{ + label?: string; + submenu?: Array<{ label?: string; click?: () => void }>; + }>; + }>; + const fileMenu = template.find((item) => item.label === 'File'); + const recentItem = fileMenu?.submenu?.find((item) => item.label === 'Recent Files'); + expect(recentItem?.submenu).toBeDefined(); + // File entries + separator + clear + expect(recentItem?.submenu?.length).toBe(4); + }); + + it('should call onCloseTab from File > Close Tab', () => { + buildApplicationMenu(deps); + const template = mockBuildFromTemplate.mock.calls[0][0] as Array<{ + label?: string; + submenu?: Array<{ label?: string; click?: () => void }>; + }>; + const fileMenu = template.find((item) => item.label === 'File'); + const closeItem = fileMenu?.submenu?.find((item) => item.label === 'Close Tab'); + closeItem?.click?.(); + expect(deps.onCloseTab).toHaveBeenCalled(); + }); + + it('should call onToggleSidebar from View menu', () => { + buildApplicationMenu(deps); + const template = mockBuildFromTemplate.mock.calls[0][0] as Array<{ + label?: string; + submenu?: Array<{ label?: string; click?: () => void }>; + }>; + const viewMenu = template.find((item) => item.label === 'View'); + const toggleSidebar = viewMenu?.submenu?.find((item) => item.label === 'Toggle Sidebar'); + toggleSidebar?.click?.(); + expect(deps.onToggleSidebar).toHaveBeenCalled(); + }); +}); diff --git a/packages/electron/src/main/menu.ts b/packages/electron/src/main/menu.ts new file mode 100644 index 0000000..cffe5e0 --- /dev/null +++ b/packages/electron/src/main/menu.ts @@ -0,0 +1,149 @@ +import { Menu, app } from 'electron'; +import type { MenuItemConstructorOptions } from 'electron'; + +export interface MenuDeps { + onOpenFile: () => void; + onOpenFolder: () => void; + getRecentFiles: () => string[]; + onClearRecent: () => void; + onCloseTab: () => void; + onToggleSidebar: () => void; + onToggleToc: () => void; + onMenuCommand: (command: string) => void; +} + +export function buildApplicationMenu(deps: MenuDeps): void { + const { + onOpenFile, + onOpenFolder, + getRecentFiles, + onClearRecent, + onCloseTab, + onToggleSidebar, + onToggleToc, + onMenuCommand, + } = deps; + + const recentFiles = getRecentFiles(); + const recentSubmenu: MenuItemConstructorOptions[] = [ + ...recentFiles.map( + (filePath): MenuItemConstructorOptions => ({ + label: filePath, + click: () => onMenuCommand(`open:${filePath}`), + }) + ), + { type: 'separator' }, + { label: 'Clear Recent', click: onClearRecent }, + ]; + + const isMac = process.platform === 'darwin'; + + const template: MenuItemConstructorOptions[] = [ + ...(isMac + ? [ + { + label: app.name, + submenu: [ + { role: 'about' as const }, + { type: 'separator' as const }, + { role: 'services' as const }, + { type: 'separator' as const }, + { role: 'hide' as const }, + { role: 'hideOthers' as const }, + { role: 'unhide' as const }, + { type: 'separator' as const }, + { role: 'quit' as const }, + ], + }, + ] + : []), + { + label: 'File', + submenu: [ + { + label: 'Open File...', + accelerator: 'CmdOrCtrl+O', + click: onOpenFile, + }, + { + label: 'Open Folder...', + accelerator: 'CmdOrCtrl+Shift+O', + click: onOpenFolder, + }, + { + label: 'Recent Files', + submenu: recentSubmenu, + }, + { type: 'separator' }, + { + label: 'Close Tab', + accelerator: 'CmdOrCtrl+W', + click: onCloseTab, + }, + ...(isMac ? [] : [{ type: 'separator' as const }, { role: 'quit' as const }]), + ], + }, + { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'selectAll' }, + ], + }, + { + label: 'View', + submenu: [ + { + label: 'Toggle Sidebar', + accelerator: 'CmdOrCtrl+B', + click: onToggleSidebar, + }, + { + label: 'Toggle Table of Contents', + click: onToggleToc, + }, + { type: 'separator' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { role: 'resetZoom' }, + { type: 'separator' }, + { role: 'toggleDevTools' }, + { role: 'togglefullscreen' }, + ], + }, + { + label: 'Export', + submenu: [ + { + label: 'Export as PDF...', + click: () => onMenuCommand('export:pdf'), + }, + { + label: 'Export as DOCX...', + click: () => onMenuCommand('export:docx'), + }, + ], + }, + { + label: 'Help', + submenu: [ + { + label: 'About mdview', + click: () => onMenuCommand('help:about'), + }, + { + label: 'View on GitHub', + click: () => onMenuCommand('help:github'), + }, + ], + }, + ]; + + const menu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(menu); +} diff --git a/packages/electron/src/main/recent-files.test.ts b/packages/electron/src/main/recent-files.test.ts new file mode 100644 index 0000000..b3503a9 --- /dev/null +++ b/packages/electron/src/main/recent-files.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { RecentFilesManager } from './recent-files'; + +function createMockStore() { + const data = new Map(); + return { + get: vi.fn((key: string, defaultValue?: unknown) => data.get(key) ?? defaultValue), + set: vi.fn((key: string, value: unknown) => data.set(key, value)), + delete: vi.fn((key: string) => data.delete(key)), + _data: data, + }; +} + +describe('RecentFilesManager', () => { + let store: ReturnType; + let manager: RecentFilesManager; + let fileExists: ReturnType; + + beforeEach(() => { + store = createMockStore(); + fileExists = vi.fn().mockReturnValue(true); + manager = new RecentFilesManager(store as never, fileExists); + }); + + it('should return empty list initially', () => { + expect(manager.getFiles()).toEqual([]); + }); + + it('should add a file and persist it', () => { + manager.addFile('/tmp/test.md'); + expect(manager.getFiles()).toEqual(['/tmp/test.md']); + expect(store.set).toHaveBeenCalledWith('recentFiles', ['/tmp/test.md']); + }); + + it('should deduplicate files (most recent first)', () => { + manager.addFile('/tmp/a.md'); + manager.addFile('/tmp/b.md'); + manager.addFile('/tmp/a.md'); + expect(manager.getFiles()).toEqual(['/tmp/a.md', '/tmp/b.md']); + }); + + it('should cap at 10 files', () => { + for (let i = 0; i < 12; i++) { + manager.addFile(`/tmp/file-${i}.md`); + } + const files = manager.getFiles(); + expect(files).toHaveLength(10); + expect(files[0]).toBe('/tmp/file-11.md'); + }); + + it('should filter out nonexistent files on get', () => { + manager.addFile('/tmp/exists.md'); + manager.addFile('/tmp/gone.md'); + fileExists.mockImplementation((p: string) => p !== '/tmp/gone.md'); + expect(manager.getFiles()).toEqual(['/tmp/exists.md']); + }); + + it('should clear all files', () => { + manager.addFile('/tmp/a.md'); + manager.addFile('/tmp/b.md'); + manager.clear(); + expect(manager.getFiles()).toEqual([]); + expect(store.set).toHaveBeenCalledWith('recentFiles', []); + }); + + it('should restore files from store on construction', () => { + store._data.set('recentFiles', ['/tmp/restored.md']); + const manager2 = new RecentFilesManager(store as never, fileExists); + expect(manager2.getFiles()).toEqual(['/tmp/restored.md']); + }); +}); diff --git a/packages/electron/src/main/recent-files.ts b/packages/electron/src/main/recent-files.ts new file mode 100644 index 0000000..ab46e26 --- /dev/null +++ b/packages/electron/src/main/recent-files.ts @@ -0,0 +1,38 @@ +import type ElectronStore from 'electron-store'; +import { existsSync } from 'fs'; + +const MAX_RECENT = 10; +const STORE_KEY = 'recentFiles'; + +export class RecentFilesManager { + private files: string[]; + + constructor( + private store: ElectronStore, + private fileExists: (path: string) => boolean = existsSync + ) { + this.files = (this.store.get(STORE_KEY, []) as string[]).slice(0, MAX_RECENT); + } + + addFile(path: string): void { + this.files = this.files.filter((f) => f !== path); + this.files.unshift(path); + if (this.files.length > MAX_RECENT) { + this.files = this.files.slice(0, MAX_RECENT); + } + this.persist(); + } + + getFiles(): string[] { + return this.files.filter((f) => this.fileExists(f)); + } + + clear(): void { + this.files = []; + this.persist(); + } + + private persist(): void { + this.store.set(STORE_KEY, this.files); + } +} diff --git a/packages/electron/src/main/session-restore.test.ts b/packages/electron/src/main/session-restore.test.ts new file mode 100644 index 0000000..3380d0a --- /dev/null +++ b/packages/electron/src/main/session-restore.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SessionManager } from './session-restore'; + +function createMockStore() { + const data = new Map(); + return { + get: vi.fn((key: string, defaultValue?: unknown) => data.get(key) ?? defaultValue), + set: vi.fn((key: string, value: unknown) => data.set(key, value)), + delete: vi.fn((key: string) => data.delete(key)), + _data: data, + }; +} + +describe('SessionManager', () => { + let store: ReturnType; + let fileExists: ReturnType; + let manager: SessionManager; + + beforeEach(() => { + store = createMockStore(); + fileExists = vi.fn().mockReturnValue(true); + manager = new SessionManager(store as never, fileExists); + }); + + it('should save session with tab file paths and active tab', () => { + manager.saveSession({ + tabs: ['/tmp/a.md', '/tmp/b.md'], + activeIndex: 1, + }); + expect(store.set).toHaveBeenCalledWith('session', { + tabs: ['/tmp/a.md', '/tmp/b.md'], + activeIndex: 1, + }); + }); + + it('should restore session and filter nonexistent files', () => { + store._data.set('session', { + tabs: ['/tmp/exists.md', '/tmp/gone.md'], + activeIndex: 0, + }); + fileExists.mockImplementation((p: string) => p !== '/tmp/gone.md'); + + const session = manager.getLastSession(); + expect(session).not.toBeNull(); + expect(session?.tabs).toEqual(['/tmp/exists.md']); + expect(session?.activeIndex).toBe(0); + }); + + it('should return null when no session saved', () => { + expect(manager.getLastSession()).toBeNull(); + }); + + it('should clear session', () => { + store._data.set('session', { tabs: ['/tmp/a.md'], activeIndex: 0 }); + manager.clearSession(); + expect(store.delete).toHaveBeenCalledWith('session'); + }); + + it('should handle corrupt session data gracefully', () => { + store._data.set('session', 'not an object'); + expect(manager.getLastSession()).toBeNull(); + }); + + it('should clamp activeIndex when files are filtered out', () => { + store._data.set('session', { + tabs: ['/tmp/gone1.md', '/tmp/gone2.md', '/tmp/exists.md'], + activeIndex: 2, + }); + fileExists.mockImplementation((p: string) => p === '/tmp/exists.md'); + + const session = manager.getLastSession(); + expect(session?.tabs).toEqual(['/tmp/exists.md']); + expect(session?.activeIndex).toBe(0); + }); +}); diff --git a/packages/electron/src/main/session-restore.ts b/packages/electron/src/main/session-restore.ts new file mode 100644 index 0000000..eb60f48 --- /dev/null +++ b/packages/electron/src/main/session-restore.ts @@ -0,0 +1,41 @@ +import type ElectronStore from 'electron-store'; +import { existsSync } from 'fs'; + +const STORE_KEY = 'session'; + +export interface SessionData { + tabs: string[]; + activeIndex: number; +} + +export class SessionManager { + constructor( + private store: ElectronStore, + private fileExists: (path: string) => boolean = existsSync + ) {} + + saveSession(data: SessionData): void { + this.store.set(STORE_KEY, data); + } + + getLastSession(): SessionData | null { + const raw: unknown = this.store.get(STORE_KEY); + if (!raw || typeof raw !== 'object') return null; + + const data = raw as Record; + if (!Array.isArray(data.tabs) || typeof data.activeIndex !== 'number') { + return null; + } + + const tabs = (data.tabs as string[]).filter((p) => this.fileExists(p)); + if (tabs.length === 0) return null; + + const activeIndex = Math.min(data.activeIndex, tabs.length - 1); + + return { tabs, activeIndex }; + } + + clearSession(): void { + this.store.delete(STORE_KEY); + } +} diff --git a/packages/electron/src/main/state-manager.test.ts b/packages/electron/src/main/state-manager.test.ts index 7a1f2e9..6ac436c 100644 --- a/packages/electron/src/main/state-manager.test.ts +++ b/packages/electron/src/main/state-manager.test.ts @@ -93,4 +93,176 @@ describe('StateManager', () => { expect(p1).not.toBe(p2); }); }); + + describe('workspace state', () => { + it('should return default workspace state', () => { + const ws = manager.getWorkspaceState(); + expect(ws.tabs).toEqual([]); + expect(ws.activeTabId).toBeNull(); + expect(ws.sidebarVisible).toBe(true); + expect(ws.statusBarVisible).toBe(true); + }); + + it('should return a deep copy of workspace state', () => { + const ws1 = manager.getWorkspaceState(); + const ws2 = manager.getWorkspaceState(); + expect(ws1).not.toBe(ws2); + expect(ws1).toEqual(ws2); + }); + + describe('openTab', () => { + it('should create a new tab and set it active', () => { + const tab = manager.openTab('/tmp/test.md'); + expect(tab.filePath).toBe('/tmp/test.md'); + expect(tab.title).toBe('test.md'); + expect(tab.scrollPosition).toBe(0); + expect(tab.renderState).toBe('pending'); + + const ws = manager.getWorkspaceState(); + expect(ws.tabs).toHaveLength(1); + expect(ws.activeTabId).toBe(tab.id); + }); + + it('should deduplicate by file path', () => { + const tab1 = manager.openTab('/tmp/test.md'); + const tab2 = manager.openTab('/tmp/test.md'); + expect(tab2.id).toBe(tab1.id); + + const ws = manager.getWorkspaceState(); + expect(ws.tabs).toHaveLength(1); + }); + + it('should open multiple different files', () => { + manager.openTab('/tmp/a.md'); + manager.openTab('/tmp/b.md'); + const tab3 = manager.openTab('/tmp/c.md'); + + const ws = manager.getWorkspaceState(); + expect(ws.tabs).toHaveLength(3); + expect(ws.activeTabId).toBe(tab3.id); + }); + }); + + describe('closeTab', () => { + it('should remove the tab', () => { + const tab = manager.openTab('/tmp/test.md'); + manager.closeTab(tab.id); + + const ws = manager.getWorkspaceState(); + expect(ws.tabs).toHaveLength(0); + expect(ws.activeTabId).toBeNull(); + }); + + it('should activate adjacent tab when closing active tab', () => { + const tab1 = manager.openTab('/tmp/a.md'); + manager.openTab('/tmp/b.md'); + manager.openTab('/tmp/c.md'); + + // Activate middle tab then close it + manager.setActiveTab(manager.getWorkspaceState().tabs[1].id); + manager.closeTab(manager.getWorkspaceState().tabs[1].id); + + const ws = manager.getWorkspaceState(); + expect(ws.tabs).toHaveLength(2); + // Should activate the tab at the same index (which is now c.md) + expect(ws.activeTabId).not.toBe(tab1.id); + }); + + it('should activate previous tab when closing last tab in list', () => { + manager.openTab('/tmp/a.md'); + const tab2 = manager.openTab('/tmp/b.md'); + + // tab2 is active (last opened), close it + manager.closeTab(tab2.id); + + const ws = manager.getWorkspaceState(); + expect(ws.tabs).toHaveLength(1); + expect(ws.activeTabId).toBe(ws.tabs[0].id); + }); + + it('should ignore closing nonexistent tab', () => { + manager.openTab('/tmp/a.md'); + manager.closeTab('nonexistent'); + expect(manager.getWorkspaceState().tabs).toHaveLength(1); + }); + }); + + describe('setActiveTab', () => { + it('should change the active tab', () => { + const tab1 = manager.openTab('/tmp/a.md'); + manager.openTab('/tmp/b.md'); + + manager.setActiveTab(tab1.id); + expect(manager.getWorkspaceState().activeTabId).toBe(tab1.id); + }); + + it('should ignore nonexistent tab id', () => { + const tab1 = manager.openTab('/tmp/a.md'); + manager.setActiveTab('nonexistent'); + expect(manager.getWorkspaceState().activeTabId).toBe(tab1.id); + }); + }); + + describe('updateTabMetadata', () => { + it('should update tab metadata', () => { + const tab = manager.openTab('/tmp/test.md'); + manager.updateTabMetadata(tab.id, { + wordCount: 100, + headingCount: 5, + renderState: 'complete', + }); + + const ws = manager.getWorkspaceState(); + expect(ws.tabs[0].wordCount).toBe(100); + expect(ws.tabs[0].headingCount).toBe(5); + expect(ws.tabs[0].renderState).toBe('complete'); + }); + + it('should not overwrite tab id', () => { + const tab = manager.openTab('/tmp/test.md'); + manager.updateTabMetadata(tab.id, { id: 'hacked' } as never); + expect(manager.getWorkspaceState().tabs[0].id).toBe(tab.id); + }); + }); + + describe('updateTabScrollPosition', () => { + it('should update scroll position', () => { + const tab = manager.openTab('/tmp/test.md'); + manager.updateTabScrollPosition(tab.id, 500); + expect(manager.getWorkspaceState().tabs[0].scrollPosition).toBe(500); + }); + }); + + describe('setSidebarVisible', () => { + it('should toggle sidebar visibility', () => { + manager.setSidebarVisible(false); + expect(manager.getWorkspaceState().sidebarVisible).toBe(false); + manager.setSidebarVisible(true); + expect(manager.getWorkspaceState().sidebarVisible).toBe(true); + }); + }); + + describe('setOpenFolder', () => { + it('should set open folder path', () => { + manager.setOpenFolder('/tmp/docs'); + expect(manager.getWorkspaceState().openFolderPath).toBe('/tmp/docs'); + }); + + it('should allow clearing folder path', () => { + manager.setOpenFolder('/tmp/docs'); + manager.setOpenFolder(null); + expect(manager.getWorkspaceState().openFolderPath).toBeNull(); + }); + }); + + describe('workspace persistence', () => { + it('should persist workspace settings on change', async () => { + manager.setSidebarVisible(false); + // Allow async persistence to complete + await new Promise((r) => setTimeout(r, 10)); + const stored = await storage.getLocal(['workspace']); + expect(stored.workspace).toBeDefined(); + }); + }); + }); }); diff --git a/packages/electron/src/main/state-manager.ts b/packages/electron/src/main/state-manager.ts index 06660c2..1dec898 100644 --- a/packages/electron/src/main/state-manager.ts +++ b/packages/electron/src/main/state-manager.ts @@ -1,9 +1,22 @@ import type { AppState, Preferences } from '@mdview/core'; import { DEFAULT_STATE } from '@mdview/core'; import type { StorageAdapter } from '@mdview/core'; +import { basename } from 'path'; +import { + type TabState, + type WorkspaceState, + DEFAULT_WORKSPACE_STATE, +} from '../shared/workspace-types'; + +let tabCounter = 0; + +function generateTabId(): string { + return `tab-${Date.now()}-${++tabCounter}`; +} export class StateManager { private state: AppState = structuredClone(DEFAULT_STATE); + private workspace: WorkspaceState = structuredClone(DEFAULT_WORKSPACE_STATE); constructor(private storage: StorageAdapter) {} @@ -14,7 +27,7 @@ export class StateManager { Object.assign(this.state.preferences, storedPrefs); } - const localData = await this.storage.getLocal(['ui', 'document']); + const localData = await this.storage.getLocal(['ui', 'document', 'workspace']); const storedUI: unknown = localData['ui']; if (storedUI && typeof storedUI === 'object') { Object.assign(this.state.ui, storedUI); @@ -23,6 +36,22 @@ export class StateManager { if (storedDoc && typeof storedDoc === 'object') { Object.assign(this.state.document, storedDoc); } + const storedWorkspace: unknown = localData['workspace']; + if (storedWorkspace && typeof storedWorkspace === 'object') { + const ws = storedWorkspace as Partial; + if (typeof ws.sidebarVisible === 'boolean') { + this.workspace.sidebarVisible = ws.sidebarVisible; + } + if (typeof ws.sidebarWidth === 'number') { + this.workspace.sidebarWidth = ws.sidebarWidth; + } + if (typeof ws.statusBarVisible === 'boolean') { + this.workspace.statusBarVisible = ws.statusBarVisible; + } + if (typeof ws.openFolderPath === 'string') { + this.workspace.openFolderPath = ws.openFolderPath; + } + } } getState(): AppState { @@ -37,4 +66,98 @@ export class StateManager { getPreferences(): AppState['preferences'] { return { ...this.state.preferences }; } + + // Workspace methods + + getWorkspaceState(): WorkspaceState { + return structuredClone(this.workspace); + } + + openTab(filePath: string): TabState { + // Deduplicate: if file already open, just activate it + const existing = this.workspace.tabs.find((t) => t.filePath === filePath); + if (existing) { + this.workspace.activeTabId = existing.id; + this.persistWorkspace(); + return { ...existing }; + } + + const tab: TabState = { + id: generateTabId(), + filePath, + title: basename(filePath), + scrollPosition: 0, + renderState: 'pending', + }; + + this.workspace.tabs.push(tab); + this.workspace.activeTabId = tab.id; + this.persistWorkspace(); + return { ...tab }; + } + + closeTab(tabId: string): void { + const index = this.workspace.tabs.findIndex((t) => t.id === tabId); + if (index === -1) return; + + this.workspace.tabs.splice(index, 1); + + if (this.workspace.activeTabId === tabId) { + if (this.workspace.tabs.length === 0) { + this.workspace.activeTabId = null; + } else { + // Activate the adjacent tab (prefer the one at the same index, else previous) + const newIndex = Math.min(index, this.workspace.tabs.length - 1); + this.workspace.activeTabId = this.workspace.tabs[newIndex].id; + } + } + + this.persistWorkspace(); + } + + setActiveTab(tabId: string): void { + const tab = this.workspace.tabs.find((t) => t.id === tabId); + if (!tab) return; + this.workspace.activeTabId = tabId; + this.persistWorkspace(); + } + + updateTabMetadata(tabId: string, metadata: Partial): void { + const tab = this.workspace.tabs.find((t) => t.id === tabId); + if (!tab) return; + Object.assign(tab, metadata); + // Don't overwrite id or filePath + tab.id = tabId; + this.persistWorkspace(); + } + + updateTabScrollPosition(tabId: string, position: number): void { + const tab = this.workspace.tabs.find((t) => t.id === tabId); + if (!tab) return; + tab.scrollPosition = position; + this.persistWorkspace(); + } + + setSidebarVisible(visible: boolean): void { + this.workspace.sidebarVisible = visible; + this.persistWorkspace(); + } + + setOpenFolder(path: string | null): void { + this.workspace.openFolderPath = path; + this.persistWorkspace(); + } + + private persistWorkspace(): void { + void this.storage.setLocal({ + workspace: { + sidebarVisible: this.workspace.sidebarVisible, + sidebarWidth: this.workspace.sidebarWidth, + statusBarVisible: this.workspace.statusBarVisible, + openFolderPath: this.workspace.openFolderPath, + tabs: this.workspace.tabs.map((t) => ({ filePath: t.filePath })), + activeTabId: this.workspace.activeTabId, + }, + }); + } } diff --git a/packages/electron/src/preload/index.ts b/packages/electron/src/preload/index.ts index a06a4bc..958f3d0 100644 --- a/packages/electron/src/preload/index.ts +++ b/packages/electron/src/preload/index.ts @@ -31,6 +31,30 @@ const api: MdviewPreloadAPI = { // File path getOpenFilePath: () => ipcRenderer.invoke(IPC_CHANNELS.GET_OPEN_FILE_PATH), + // File dialogs + showOpenFileDialog: () => ipcRenderer.invoke(IPC_CHANNELS.SHOW_OPEN_FILE_DIALOG), + showOpenFolderDialog: () => ipcRenderer.invoke(IPC_CHANNELS.SHOW_OPEN_FOLDER_DIALOG), + getRecentFiles: () => ipcRenderer.invoke(IPC_CHANNELS.GET_RECENT_FILES), + addRecentFile: (path) => ipcRenderer.invoke(IPC_CHANNELS.ADD_RECENT_FILE, path), + clearRecentFiles: () => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_RECENT_FILES), + + // Workspace state + getWorkspaceState: () => ipcRenderer.invoke(IPC_CHANNELS.GET_WORKSPACE_STATE), + openTab: (filePath) => ipcRenderer.invoke(IPC_CHANNELS.OPEN_TAB, filePath), + closeTab: (tabId) => ipcRenderer.invoke(IPC_CHANNELS.CLOSE_TAB, tabId), + setActiveTab: (tabId) => ipcRenderer.invoke(IPC_CHANNELS.SET_ACTIVE_TAB, tabId), + updateTabMetadata: (tabId, metadata) => + ipcRenderer.invoke(IPC_CHANNELS.UPDATE_TAB_METADATA, tabId, metadata), + updateTabScroll: (tabId, position) => + ipcRenderer.invoke(IPC_CHANNELS.UPDATE_TAB_SCROLL, tabId, position), + setSidebarVisible: (visible) => ipcRenderer.invoke(IPC_CHANNELS.SET_SIDEBAR_VISIBLE, visible), + setOpenFolder: (path) => ipcRenderer.invoke(IPC_CHANNELS.SET_OPEN_FOLDER, path), + + // Directory + listDirectory: (dirPath) => ipcRenderer.invoke(IPC_CHANNELS.LIST_DIRECTORY, dirPath), + watchDirectory: (dirPath) => ipcRenderer.invoke(IPC_CHANNELS.WATCH_DIRECTORY, dirPath), + unwatchDirectory: (dirPath) => ipcRenderer.invoke(IPC_CHANNELS.UNWATCH_DIRECTORY, dirPath), + // Event listeners onFileChanged: (callback) => { const listener = (_event: Electron.IpcRendererEvent, path: string) => callback(path); @@ -48,6 +72,47 @@ const api: MdviewPreloadAPI = { ipcRenderer.on(IPC_CHANNELS.THEME_CHANGED, listener); return () => ipcRenderer.removeListener(IPC_CHANNELS.THEME_CHANGED, listener); }, + onOpenFile: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, path: string) => callback(path); + ipcRenderer.on(IPC_CHANNELS.OPEN_FILE, listener); + return () => ipcRenderer.removeListener(IPC_CHANNELS.OPEN_FILE, listener); + }, + onOpenFolder: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, path: string) => callback(path); + ipcRenderer.on(IPC_CHANNELS.OPEN_FOLDER, listener); + return () => ipcRenderer.removeListener(IPC_CHANNELS.OPEN_FOLDER, listener); + }, + onMenuCommand: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, command: string) => callback(command); + ipcRenderer.on(IPC_CHANNELS.MENU_COMMAND, listener); + return () => ipcRenderer.removeListener(IPC_CHANNELS.MENU_COMMAND, listener); + }, + onWorkspaceUpdated: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, state: unknown) => + callback(state as never); + ipcRenderer.on(IPC_CHANNELS.WORKSPACE_UPDATED, listener); + return () => ipcRenderer.removeListener(IPC_CHANNELS.WORKSPACE_UPDATED, listener); + }, + onTabOpened: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, tab: unknown) => callback(tab as never); + ipcRenderer.on(IPC_CHANNELS.TAB_OPENED, listener); + return () => ipcRenderer.removeListener(IPC_CHANNELS.TAB_OPENED, listener); + }, + onTabClosed: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, tabId: string) => callback(tabId); + ipcRenderer.on(IPC_CHANNELS.TAB_CLOSED, listener); + return () => ipcRenderer.removeListener(IPC_CHANNELS.TAB_CLOSED, listener); + }, + onActiveTabChanged: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, tabId: string) => callback(tabId); + ipcRenderer.on(IPC_CHANNELS.ACTIVE_TAB_CHANGED, listener); + return () => ipcRenderer.removeListener(IPC_CHANNELS.ACTIVE_TAB_CHANGED, listener); + }, + onDirectoryChanged: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, dirPath: string) => callback(dirPath); + ipcRenderer.on(IPC_CHANNELS.DIRECTORY_CHANGED, listener); + return () => ipcRenderer.removeListener(IPC_CHANNELS.DIRECTORY_CHANGED, listener); + }, }; contextBridge.exposeInMainWorld('mdview', api); diff --git a/packages/electron/src/renderer/document-context.test.ts b/packages/electron/src/renderer/document-context.test.ts new file mode 100644 index 0000000..a65fe6f --- /dev/null +++ b/packages/electron/src/renderer/document-context.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@mdview/core', async () => { + const actual = await vi.importActual>('@mdview/core'); + return { + ...actual, + RenderPipeline: vi.fn().mockImplementation(function (this: Record) { + this.render = vi.fn().mockResolvedValue(undefined); + this.onProgress = vi.fn().mockReturnValue(vi.fn()); + }), + ThemeEngine: vi.fn().mockImplementation(function (this: Record) { + this.applyTheme = vi.fn().mockResolvedValue(undefined); + }), + TocRenderer: vi.fn().mockImplementation(function (this: Record) { + this.render = vi.fn(); + }), + ExportUI: vi.fn().mockImplementation(function (this: Record) { + this.render = vi.fn(); + }), + CommentManager: vi.fn().mockImplementation(function (this: Record) { + this.initialize = vi.fn().mockResolvedValue(undefined); + }), + FileScanner: { + getFileSize: vi.fn().mockReturnValue(100), + }, + }; +}); + +function createMockMdviewAPI() { + return { + getState: vi.fn().mockResolvedValue({ + preferences: { + theme: 'github-light', + autoReload: false, + showToc: false, + commentsEnabled: false, + }, + document: { path: '', content: '', scrollPosition: 0, renderState: 'pending' }, + ui: { theme: null, maximizedDiagram: null, visibleDiagrams: new Set() }, + }), + readFile: vi.fn().mockResolvedValue('# Hello World\n\nSome content here with words.'), + watchFile: vi.fn().mockResolvedValue(undefined), + unwatchFile: vi.fn().mockResolvedValue(undefined), + onFileChanged: vi.fn().mockReturnValue(vi.fn()), + onPreferencesUpdated: vi.fn().mockReturnValue(vi.fn()), + onThemeChanged: vi.fn().mockReturnValue(vi.fn()), + updatePreferences: vi.fn(), + cacheGenerateKey: vi.fn(), + cacheGet: vi.fn().mockResolvedValue(null), + cacheSet: vi.fn(), + writeFile: vi.fn(), + checkFileChanged: vi.fn(), + getUsername: vi.fn().mockResolvedValue('testuser'), + saveFile: vi.fn(), + printToPDF: vi.fn(), + getOpenFilePath: vi.fn(), + showOpenFileDialog: vi.fn(), + showOpenFolderDialog: vi.fn(), + getRecentFiles: vi.fn(), + addRecentFile: vi.fn(), + clearRecentFiles: vi.fn(), + getWorkspaceState: vi.fn(), + openTab: vi.fn(), + closeTab: vi.fn(), + setActiveTab: vi.fn(), + updateTabMetadata: vi.fn(), + updateTabScroll: vi.fn(), + setSidebarVisible: vi.fn(), + setOpenFolder: vi.fn(), + listDirectory: vi.fn(), + watchDirectory: vi.fn(), + unwatchDirectory: vi.fn(), + onOpenFile: vi.fn().mockReturnValue(vi.fn()), + onOpenFolder: vi.fn().mockReturnValue(vi.fn()), + onMenuCommand: vi.fn().mockReturnValue(vi.fn()), + onWorkspaceUpdated: vi.fn().mockReturnValue(vi.fn()), + onTabOpened: vi.fn().mockReturnValue(vi.fn()), + onTabClosed: vi.fn().mockReturnValue(vi.fn()), + onActiveTabChanged: vi.fn().mockReturnValue(vi.fn()), + onDirectoryChanged: vi.fn().mockReturnValue(vi.fn()), + }; +} + +describe('DocumentContext', () => { + let mockMdview: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockMdview = createMockMdviewAPI(); + (globalThis.window as Record).mdview = mockMdview; + document.body.innerHTML = '
'; + }); + + it('should load and render a file', async () => { + const { DocumentContext } = await import('./document-context'); + const container = document.getElementById('test-container')!; + const ctx = new DocumentContext('tab-1'); + const metadata = await ctx.load('/tmp/test.md', container); + + expect(mockMdview.readFile).toHaveBeenCalledWith('/tmp/test.md'); + expect(metadata).toBeDefined(); + expect(metadata.filePath).toBe('/tmp/test.md'); + }); + + it('should return metadata after load', async () => { + const { DocumentContext } = await import('./document-context'); + const container = document.getElementById('test-container')!; + const ctx = new DocumentContext('tab-1'); + const metadata = await ctx.load('/tmp/test.md', container); + + expect(metadata.renderState).toBe('complete'); + expect(metadata.title).toBe('test.md'); + // wordCount is 0 since RenderPipeline is mocked and doesn't populate DOM + expect(typeof metadata.wordCount).toBe('number'); + }); + + it('should store and return scroll position', async () => { + const { DocumentContext } = await import('./document-context'); + const container = document.getElementById('test-container')!; + const ctx = new DocumentContext('tab-1'); + await ctx.load('/tmp/test.md', container); + + ctx.setScrollPosition(200); + expect(ctx.getScrollPosition()).toBe(200); + }); + + it('should reload content', async () => { + const { DocumentContext } = await import('./document-context'); + const container = document.getElementById('test-container')!; + const ctx = new DocumentContext('tab-1'); + await ctx.load('/tmp/test.md', container); + + mockMdview.readFile.mockResolvedValue('# Updated'); + await ctx.reload(); + expect(mockMdview.readFile).toHaveBeenCalledTimes(2); + }); + + it('should dispose cleanly', async () => { + const { DocumentContext } = await import('./document-context'); + const container = document.getElementById('test-container')!; + const ctx = new DocumentContext('tab-1'); + await ctx.load('/tmp/test.md', container); + + ctx.dispose(); + expect(mockMdview.unwatchFile).toHaveBeenCalledWith('/tmp/test.md'); + }); + + it('should get file path', async () => { + const { DocumentContext } = await import('./document-context'); + const container = document.getElementById('test-container')!; + const ctx = new DocumentContext('tab-1'); + await ctx.load('/tmp/test.md', container); + + expect(ctx.getFilePath()).toBe('/tmp/test.md'); + }); + + it('should return tab id', async () => { + const { DocumentContext } = await import('./document-context'); + const ctx = new DocumentContext('tab-1'); + expect(ctx.getTabId()).toBe('tab-1'); + }); + + it('should handle load errors gracefully', async () => { + mockMdview.readFile.mockRejectedValue(new Error('File not found')); + const { DocumentContext } = await import('./document-context'); + const container = document.getElementById('test-container')!; + const ctx = new DocumentContext('tab-1'); + + await expect(ctx.load('/tmp/missing.md', container)).rejects.toThrow('File not found'); + }); +}); diff --git a/packages/electron/src/renderer/document-context.ts b/packages/electron/src/renderer/document-context.ts new file mode 100644 index 0000000..b798a30 --- /dev/null +++ b/packages/electron/src/renderer/document-context.ts @@ -0,0 +1,244 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */ + +import { + RenderPipeline, + ThemeEngine, + TocRenderer, + ExportUI, + CommentManager, + FileScanner, + type Preferences, + type RenderProgress, +} from '@mdview/core'; +import { + ElectronMessagingAdapter, + ElectronRendererStorageAdapter, + ElectronRendererFileAdapter, + ElectronRendererIdentityAdapter, +} from './adapters'; + +export interface DocumentMetadata { + filePath: string; + title: string; + wordCount: number; + headingCount: number; + diagramCount: number; + codeBlockCount: number; + renderState: 'pending' | 'rendering' | 'complete' | 'error'; +} + +export class DocumentContext { + private filePath: string | null = null; + private container: HTMLElement | null = null; + private scrollPosition = 0; + private renderPipeline: RenderPipeline; + private themeEngine: ThemeEngine; + private fileAdapter: ElectronRendererFileAdapter; + private identityAdapter: ElectronRendererIdentityAdapter; + private tocRenderer: TocRenderer | null = null; + private exportUI: ExportUI | null = null; + private commentManager: CommentManager | null = null; + private autoReloadCleanup: (() => void) | null = null; + private metadata: DocumentMetadata = { + filePath: '', + title: '', + wordCount: 0, + headingCount: 0, + diagramCount: 0, + codeBlockCount: 0, + renderState: 'pending', + }; + + constructor(private tabId: string) { + const messaging = new ElectronMessagingAdapter(); + const storage = new ElectronRendererStorageAdapter(); + this.fileAdapter = new ElectronRendererFileAdapter(); + this.identityAdapter = new ElectronRendererIdentityAdapter(); + this.renderPipeline = new RenderPipeline({ messaging }); + this.themeEngine = new ThemeEngine(storage); + } + + async load(filePath: string, container: HTMLElement): Promise { + this.filePath = filePath; + this.container = container; + this.metadata.filePath = filePath; + this.metadata.title = filePath.split('/').pop() ?? filePath; + this.metadata.renderState = 'rendering'; + + const state = await window.mdview.getState(); + const content = await window.mdview.readFile(filePath); + const fileSize = FileScanner.getFileSize(content); + const theme = state.preferences.theme || 'github-light'; + + document.body.classList.add('mdview-active'); + await this.themeEngine.applyTheme(theme); + + const cleanupProgress = this.renderPipeline.onProgress((_progress: RenderProgress) => { + // Progress tracking could be wired to status bar later + }); + + await this.renderPipeline.render({ + container, + markdown: content, + progressive: fileSize > 500000, + filePath, + theme, + preferences: state.preferences, + useCache: true, + useWorkers: false, + }); + + cleanupProgress(); + + // Post-render: TOC + if (state.preferences.showToc) { + const headings = this.extractHeadings(container); + if (headings.length > 0) { + this.tocRenderer = new TocRenderer(headings, { + maxDepth: state.preferences.tocMaxDepth ?? 6, + autoCollapse: state.preferences.tocAutoCollapse ?? false, + position: state.preferences.tocPosition ?? 'left', + style: state.preferences.tocStyle ?? 'floating', + }); + this.tocRenderer.render(container); + } + } + + // Post-render: Export UI + this.exportUI = new ExportUI({ messaging: new ElectronMessagingAdapter() }); + this.exportUI.render(container); + + // Post-render: Comments + if (state.preferences.commentsEnabled !== false) { + this.commentManager = new CommentManager({ + file: this.fileAdapter, + identity: this.identityAdapter, + }); + await this.commentManager.initialize(content, filePath, container); + } + + // Auto-reload + if (state.preferences.autoReload) { + this.setupAutoReload(filePath, container, content, state.preferences); + } + + // Compute metadata + this.updateMetadata(container, content); + this.metadata.renderState = 'complete'; + + return { ...this.metadata }; + } + + async reload(): Promise { + if (!this.filePath || !this.container) return null; + + const state = await window.mdview.getState(); + const content = await window.mdview.readFile(this.filePath); + + await this.renderPipeline.render({ + container: this.container, + markdown: content, + progressive: false, + filePath: this.filePath, + theme: state.preferences.theme || 'github-light', + preferences: state.preferences, + useCache: true, + useWorkers: false, + }); + + this.updateMetadata(this.container, content); + return { ...this.metadata }; + } + + getMetadata(): DocumentMetadata { + return { ...this.metadata }; + } + + getFilePath(): string | null { + return this.filePath; + } + + getTabId(): string { + return this.tabId; + } + + getScrollPosition(): number { + return this.scrollPosition; + } + + setScrollPosition(pos: number): void { + this.scrollPosition = pos; + } + + dispose(): void { + if (this.autoReloadCleanup) { + this.autoReloadCleanup(); + this.autoReloadCleanup = null; + } + if (this.filePath) { + void window.mdview.unwatchFile(this.filePath); + } + } + + private updateMetadata(container: HTMLElement, content: string): void { + const text = container.textContent ?? ''; + this.metadata.wordCount = text.split(/\s+/).filter((w) => w.length > 0).length; + this.metadata.headingCount = container.querySelectorAll('h1, h2, h3, h4, h5, h6').length; + this.metadata.diagramCount = container.querySelectorAll('.mermaid').length; + this.metadata.codeBlockCount = container.querySelectorAll('pre code').length; + // Preserve filePath and title from content + void content; + } + + private extractHeadings(container: HTMLElement): { level: number; text: string; id: string }[] { + const headings: { level: number; text: string; id: string }[] = []; + container.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((el) => { + const level = parseInt(el.tagName.charAt(1), 10); + headings.push({ + level, + text: el.textContent || '', + id: el.id || '', + }); + }); + return headings; + } + + private setupAutoReload( + filePath: string, + container: HTMLElement, + initialContent: string, + preferences: Preferences + ): void { + let lastContent = initialContent; + let reloadTimeout: ReturnType | null = null; + + const unwatch = this.fileAdapter.watch(filePath, () => { + if (reloadTimeout) clearTimeout(reloadTimeout); + reloadTimeout = setTimeout(() => { + void (async () => { + try { + const newContent = await this.fileAdapter.readFile(filePath); + if (newContent !== lastContent) { + lastContent = newContent; + await this.renderPipeline.render({ + container, + markdown: newContent, + progressive: false, + filePath, + theme: preferences.theme || 'github-light', + preferences, + useCache: true, + useWorkers: false, + }); + this.updateMetadata(container, newContent); + } + } catch (err) { + console.error('[mdview] Auto-reload error:', err); + } + })(); + }, 500); + }); + + this.autoReloadCleanup = unwatch; + } +} diff --git a/packages/electron/src/renderer/drag-drop.test.ts b/packages/electron/src/renderer/drag-drop.test.ts new file mode 100644 index 0000000..05f254e --- /dev/null +++ b/packages/electron/src/renderer/drag-drop.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { setupDragAndDrop } from './drag-drop'; + +describe('setupDragAndDrop', () => { + let target: HTMLElement; + let onFilesDropped: ReturnType; + + beforeEach(() => { + target = document.createElement('div'); + document.body.appendChild(target); + onFilesDropped = vi.fn(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('should prevent default on dragover', () => { + setupDragAndDrop({ target, onFilesDropped }); + + const event = new Event('dragover', { bubbles: true, cancelable: true }); + const spy = vi.spyOn(event, 'preventDefault'); + target.dispatchEvent(event); + + expect(spy).toHaveBeenCalled(); + }); + + it('should add drag indicator class on dragover', () => { + setupDragAndDrop({ target, onFilesDropped }); + + const event = new Event('dragover', { bubbles: true, cancelable: true }); + target.dispatchEvent(event); + + expect(target.classList.contains('mdview-drag-over')).toBe(true); + }); + + it('should remove indicator class on dragleave', () => { + setupDragAndDrop({ target, onFilesDropped }); + + // First add the class via dragover + const dragoverEvent = new Event('dragover', { bubbles: true, cancelable: true }); + target.dispatchEvent(dragoverEvent); + expect(target.classList.contains('mdview-drag-over')).toBe(true); + + // Then remove via dragleave + const dragleaveEvent = new Event('dragleave', { bubbles: true, cancelable: true }); + target.dispatchEvent(dragleaveEvent); + + expect(target.classList.contains('mdview-drag-over')).toBe(false); + }); + + it('should extract .md file paths from drop event and call callback', () => { + setupDragAndDrop({ target, onFilesDropped }); + + const event = new Event('drop', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'dataTransfer', { + value: { + files: [{ name: 'readme.md', path: '/tmp/readme.md' }], + }, + }); + target.dispatchEvent(event); + + expect(onFilesDropped).toHaveBeenCalledWith(['/tmp/readme.md']); + }); + + it('should filter out non-.md files', () => { + setupDragAndDrop({ target, onFilesDropped }); + + const event = new Event('drop', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'dataTransfer', { + value: { + files: [ + { name: 'readme.md', path: '/tmp/readme.md' }, + { name: 'image.png', path: '/tmp/image.png' }, + { name: 'style.css', path: '/tmp/style.css' }, + { name: 'notes.markdown', path: '/tmp/notes.markdown' }, + { name: 'doc.mdown', path: '/tmp/doc.mdown' }, + { name: 'file.mkd', path: '/tmp/file.mkd' }, + { name: 'file.mkdn', path: '/tmp/file.mkdn' }, + { name: 'component.mdx', path: '/tmp/component.mdx' }, + { name: 'data.json', path: '/tmp/data.json' }, + ], + }, + }); + target.dispatchEvent(event); + + expect(onFilesDropped).toHaveBeenCalledWith([ + '/tmp/readme.md', + '/tmp/notes.markdown', + '/tmp/doc.mdown', + '/tmp/file.mkd', + '/tmp/file.mkdn', + '/tmp/component.mdx', + ]); + }); + + it('should handle multiple files in a single drop', () => { + setupDragAndDrop({ target, onFilesDropped }); + + const event = new Event('drop', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'dataTransfer', { + value: { + files: [ + { name: 'a.md', path: '/tmp/a.md' }, + { name: 'b.md', path: '/tmp/b.md' }, + { name: 'c.md', path: '/tmp/c.md' }, + ], + }, + }); + target.dispatchEvent(event); + + expect(onFilesDropped).toHaveBeenCalledWith(['/tmp/a.md', '/tmp/b.md', '/tmp/c.md']); + }); + + it('should remove indicator on drop', () => { + setupDragAndDrop({ target, onFilesDropped }); + + // First add the class + const dragoverEvent = new Event('dragover', { bubbles: true, cancelable: true }); + target.dispatchEvent(dragoverEvent); + expect(target.classList.contains('mdview-drag-over')).toBe(true); + + // Drop should remove it + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }); + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { + files: [{ name: 'test.md', path: '/tmp/test.md' }], + }, + }); + target.dispatchEvent(dropEvent); + + expect(target.classList.contains('mdview-drag-over')).toBe(false); + }); + + it('should return a cleanup function', () => { + const cleanup = setupDragAndDrop({ target, onFilesDropped }); + + cleanup(); + + // After cleanup, events should no longer be handled + const event = new Event('dragover', { bubbles: true, cancelable: true }); + const spy = vi.spyOn(event, 'preventDefault'); + target.dispatchEvent(event); + + expect(spy).not.toHaveBeenCalled(); + expect(target.classList.contains('mdview-drag-over')).toBe(false); + + // Drop should also not fire callback + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }); + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { + files: [{ name: 'test.md', path: '/tmp/test.md' }], + }, + }); + target.dispatchEvent(dropEvent); + + expect(onFilesDropped).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/electron/src/renderer/drag-drop.ts b/packages/electron/src/renderer/drag-drop.ts new file mode 100644 index 0000000..6080a1b --- /dev/null +++ b/packages/electron/src/renderer/drag-drop.ts @@ -0,0 +1,52 @@ +export interface DragDropOptions { + target: HTMLElement; + onFilesDropped: (paths: string[]) => void; +} + +const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown', '.mdown', '.mkd', '.mkdn', '.mdx']); + +function getExtension(filename: string): string { + const lastDot = filename.lastIndexOf('.'); + if (lastDot === -1) return ''; + return filename.slice(lastDot).toLowerCase(); +} + +export function setupDragAndDrop(options: DragDropOptions): () => void { + const { target, onFilesDropped } = options; + + const handleDragOver = (e: Event): void => { + e.preventDefault(); + target.classList.add('mdview-drag-over'); + }; + + const handleDragLeave = (): void => { + target.classList.remove('mdview-drag-over'); + }; + + const handleDrop = (e: Event): void => { + e.preventDefault(); + target.classList.remove('mdview-drag-over'); + + const dataTransfer = (e as DragEvent).dataTransfer; + if (!dataTransfer) return; + + const files = Array.from(dataTransfer.files) as Array; + const mdPaths = files + .filter((file) => MARKDOWN_EXTENSIONS.has(getExtension(file.name))) + .map((file) => file.path); + + if (mdPaths.length > 0) { + onFilesDropped(mdPaths); + } + }; + + target.addEventListener('dragover', handleDragOver); + target.addEventListener('dragleave', handleDragLeave); + target.addEventListener('drop', handleDrop); + + return () => { + target.removeEventListener('dragover', handleDragOver); + target.removeEventListener('dragleave', handleDragLeave); + target.removeEventListener('drop', handleDrop); + }; +} diff --git a/packages/electron/src/renderer/file-tree.test.ts b/packages/electron/src/renderer/file-tree.test.ts new file mode 100644 index 0000000..d12b18c --- /dev/null +++ b/packages/electron/src/renderer/file-tree.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { FileTree } from './file-tree'; +import type { DirectoryEntry } from '../shared/workspace-types'; + +function createEntries(): DirectoryEntry[] { + return [ + { + name: 'docs', + path: '/project/docs', + type: 'directory', + children: [ + { name: 'api.md', path: '/project/docs/api.md', type: 'file' }, + { name: 'guide.md', path: '/project/docs/guide.md', type: 'file' }, + ], + }, + { name: 'readme.md', path: '/project/readme.md', type: 'file' }, + ]; +} + +describe('FileTree', () => { + let fileTree: FileTree; + let parent: HTMLElement; + + beforeEach(() => { + document.body.innerHTML = ''; + parent = document.getElementById('sidebar')!; + fileTree = new FileTree(); + }); + + it('should render into parent element', () => { + fileTree.render(parent); + + const container = parent.querySelector('.file-tree'); + expect(container).not.toBeNull(); + }); + + it('should display directory entries as tree', () => { + fileTree.render(parent); + fileTree.loadDirectory(createEntries()); + + const items = parent.querySelectorAll('.file-tree-item'); + // docs dir + 2 children + readme.md = 4 items + expect(items.length).toBe(4); + }); + + it('should expand and collapse directories on click', () => { + fileTree.render(parent); + fileTree.loadDirectory(createEntries()); + + const dirItem = parent.querySelector('.file-tree-directory') as HTMLElement; + expect(dirItem).not.toBeNull(); + + // Initially expanded + const childContainer = dirItem + .closest('.file-tree-item')! + .querySelector('.file-tree-children') as HTMLElement; + expect(childContainer.style.display).not.toBe('none'); + + // Click to collapse + dirItem.click(); + expect(childContainer.style.display).toBe('none'); + + // Click to expand + dirItem.click(); + expect(childContainer.style.display).not.toBe('none'); + }); + + it('should call onFileSelected when clicking a file', () => { + const callback = vi.fn(); + fileTree.render(parent); + fileTree.onFileSelected(callback); + fileTree.loadDirectory(createEntries()); + + const fileItems = parent.querySelectorAll('.file-tree-file'); + expect(fileItems.length).toBeGreaterThan(0); + + (fileItems[0] as HTMLElement).click(); + expect(callback).toHaveBeenCalledWith('/project/docs/api.md'); + }); + + it('should toggle visibility', () => { + fileTree.render(parent); + + const container = parent.querySelector('.file-tree') as HTMLElement; + expect(container.style.display).not.toBe('none'); + + fileTree.setVisible(false); + expect(container.style.display).toBe('none'); + + fileTree.setVisible(true); + expect(container.style.display).not.toBe('none'); + }); + + it('should refresh with new entries', () => { + fileTree.render(parent); + fileTree.loadDirectory(createEntries()); + + const newEntries: DirectoryEntry[] = [ + { name: 'changelog.md', path: '/project/changelog.md', type: 'file' }, + ]; + fileTree.refresh(newEntries); + + const items = parent.querySelectorAll('.file-tree-item'); + expect(items.length).toBe(1); + expect(items[0].textContent).toContain('changelog.md'); + }); + + it('should handle empty entries', () => { + fileTree.render(parent); + fileTree.loadDirectory([]); + + const items = parent.querySelectorAll('.file-tree-item'); + expect(items.length).toBe(0); + }); + + it('should show directories before files', () => { + fileTree.render(parent); + fileTree.loadDirectory(createEntries()); + + // Get top-level items only (depth 0) + const topLevel = parent.querySelector('.file-tree-list')!.children; + const topLevelItems = Array.from(topLevel); + + // First should be docs directory, second should be readme.md file + expect(topLevelItems[0].querySelector('.file-tree-directory')).not.toBeNull(); + expect(topLevelItems[1].querySelector('.file-tree-file')).not.toBeNull(); + }); + + it('should mark active file', () => { + fileTree.render(parent); + fileTree.loadDirectory(createEntries()); + + fileTree.setActiveFile('/project/docs/api.md'); + + const activeItems = parent.querySelectorAll('.file-tree-file.active'); + expect(activeItems.length).toBe(1); + expect((activeItems[0] as HTMLElement).dataset.path).toBe('/project/docs/api.md'); + }); + + it('should dispose cleanly', () => { + fileTree.render(parent); + fileTree.loadDirectory(createEntries()); + + fileTree.dispose(); + + const container = parent.querySelector('.file-tree'); + expect(container).toBeNull(); + }); +}); diff --git a/packages/electron/src/renderer/file-tree.ts b/packages/electron/src/renderer/file-tree.ts new file mode 100644 index 0000000..8cfe52b --- /dev/null +++ b/packages/electron/src/renderer/file-tree.ts @@ -0,0 +1,127 @@ +import type { DirectoryEntry } from '../shared/workspace-types'; + +export class FileTree { + private container: HTMLElement | null = null; + private visible = true; + private fileSelectCallback: ((path: string) => void) | null = null; + private activeFilePath: string | null = null; + + render(parent: HTMLElement): void { + this.container = document.createElement('div'); + this.container.className = 'file-tree'; + parent.appendChild(this.container); + } + + loadDirectory(entries: DirectoryEntry[]): void { + if (!this.container) return; + + // Clear existing content + this.container.innerHTML = ''; + + const list = document.createElement('div'); + list.className = 'file-tree-list'; + this.buildTree(list, entries, 0); + this.container.appendChild(list); + } + + setVisible(visible: boolean): void { + this.visible = visible; + if (this.container) { + this.container.style.display = visible ? '' : 'none'; + } + } + + onFileSelected(callback: (path: string) => void): void { + this.fileSelectCallback = callback; + } + + setActiveFile(filePath: string): void { + this.activeFilePath = filePath; + + if (!this.container) return; + + // Remove existing active class + const previousActive = this.container.querySelectorAll('.file-tree-file.active'); + for (const el of previousActive) { + el.classList.remove('active'); + } + + // Add active class to matching file + const fileElements = this.container.querySelectorAll('.file-tree-file'); + for (const el of fileElements) { + if ((el as HTMLElement).dataset.path === filePath) { + el.classList.add('active'); + } + } + } + + refresh(entries: DirectoryEntry[]): void { + this.loadDirectory(entries); + if (this.activeFilePath) { + this.setActiveFile(this.activeFilePath); + } + } + + dispose(): void { + if (this.container) { + this.container.remove(); + this.container = null; + } + this.fileSelectCallback = null; + this.activeFilePath = null; + } + + private buildTree(parent: HTMLElement, entries: DirectoryEntry[], depth: number): void { + for (const entry of entries) { + const item = document.createElement('div'); + item.className = 'file-tree-item'; + item.style.paddingLeft = `${depth * 16}px`; + + if (entry.type === 'directory') { + this.buildDirectoryItem(item, entry, depth); + } else { + this.buildFileItem(item, entry); + } + + parent.appendChild(item); + } + } + + private buildDirectoryItem(item: HTMLElement, entry: DirectoryEntry, depth: number): void { + const label = document.createElement('div'); + label.className = 'file-tree-directory'; + label.dataset.path = entry.path; + label.textContent = entry.name; + + const childContainer = document.createElement('div'); + childContainer.className = 'file-tree-children'; + + let expanded = true; + + label.addEventListener('click', () => { + expanded = !expanded; + childContainer.style.display = expanded ? '' : 'none'; + }); + + item.appendChild(label); + + if (entry.children && entry.children.length > 0) { + this.buildTree(childContainer, entry.children, depth + 1); + } + + item.appendChild(childContainer); + } + + private buildFileItem(item: HTMLElement, entry: DirectoryEntry): void { + const label = document.createElement('div'); + label.className = 'file-tree-file'; + label.dataset.path = entry.path; + label.textContent = entry.name; + + label.addEventListener('click', () => { + this.fileSelectCallback?.(entry.path); + }); + + item.appendChild(label); + } +} diff --git a/packages/electron/src/renderer/index.html b/packages/electron/src/renderer/index.html index 7374566..23ad214 100644 --- a/packages/electron/src/renderer/index.html +++ b/packages/electron/src/renderer/index.html @@ -6,7 +6,16 @@ mdview -
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/electron/src/renderer/keyboard-shortcuts.test.ts b/packages/electron/src/renderer/keyboard-shortcuts.test.ts new file mode 100644 index 0000000..32bfe60 --- /dev/null +++ b/packages/electron/src/renderer/keyboard-shortcuts.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { registerKeyboardShortcuts, KeyboardShortcutHandlers } from './keyboard-shortcuts'; + +describe('registerKeyboardShortcuts', () => { + let handlers: KeyboardShortcutHandlers; + let cleanup: () => void; + + beforeEach(() => { + handlers = { + nextTab: vi.fn(), + prevTab: vi.fn(), + switchToTab: vi.fn(), + }; + }); + + afterEach(() => { + cleanup?.(); + }); + + function dispatchKey(options: KeyboardEventInit) { + document.dispatchEvent(new KeyboardEvent('keydown', options)); + } + + it('should call nextTab on Ctrl/Cmd+Tab', () => { + cleanup = registerKeyboardShortcuts(handlers); + + dispatchKey({ key: 'Tab', ctrlKey: true }); + expect(handlers.nextTab).toHaveBeenCalledTimes(1); + + dispatchKey({ key: 'Tab', metaKey: true }); + expect(handlers.nextTab).toHaveBeenCalledTimes(2); + }); + + it('should call prevTab on Ctrl/Cmd+Shift+Tab', () => { + cleanup = registerKeyboardShortcuts(handlers); + + dispatchKey({ key: 'Tab', ctrlKey: true, shiftKey: true }); + expect(handlers.prevTab).toHaveBeenCalledTimes(1); + + dispatchKey({ key: 'Tab', metaKey: true, shiftKey: true }); + expect(handlers.prevTab).toHaveBeenCalledTimes(2); + }); + + it('should call switchToTab with index for Ctrl/Cmd+1 through Ctrl/Cmd+9', () => { + cleanup = registerKeyboardShortcuts(handlers); + + for (let digit = 1; digit <= 9; digit++) { + dispatchKey({ key: String(digit), ctrlKey: true }); + } + + expect(handlers.switchToTab).toHaveBeenCalledTimes(9); + for (let digit = 1; digit <= 9; digit++) { + expect(handlers.switchToTab).toHaveBeenCalledWith(digit - 1); + } + + // Also test with metaKey + (handlers.switchToTab as ReturnType).mockClear(); + dispatchKey({ key: '3', metaKey: true }); + expect(handlers.switchToTab).toHaveBeenCalledWith(2); + }); + + it('should return a cleanup function that removes event listeners', () => { + cleanup = registerKeyboardShortcuts(handlers); + + dispatchKey({ key: 'Tab', ctrlKey: true }); + expect(handlers.nextTab).toHaveBeenCalledTimes(1); + + cleanup(); + + dispatchKey({ key: 'Tab', ctrlKey: true }); + expect(handlers.nextTab).toHaveBeenCalledTimes(1); // no additional call + }); + + it('should not trigger on unrelated key combos', () => { + cleanup = registerKeyboardShortcuts(handlers); + + // Plain Tab without modifier + dispatchKey({ key: 'Tab' }); + // Ctrl+A + dispatchKey({ key: 'a', ctrlKey: true }); + // Ctrl+0 (not in 1-9 range) + dispatchKey({ key: '0', ctrlKey: true }); + // Shift+1 without Ctrl/Cmd + dispatchKey({ key: '1', shiftKey: true }); + + expect(handlers.nextTab).not.toHaveBeenCalled(); + expect(handlers.prevTab).not.toHaveBeenCalled(); + expect(handlers.switchToTab).not.toHaveBeenCalled(); + }); + + it('should handle case when handler is undefined', () => { + const partial = { + nextTab: undefined as unknown as () => void, + prevTab: vi.fn(), + switchToTab: undefined as unknown as (index: number) => void, + }; + cleanup = registerKeyboardShortcuts(partial); + + // These should not throw even though handlers are undefined + expect(() => dispatchKey({ key: 'Tab', ctrlKey: true })).not.toThrow(); + expect(() => dispatchKey({ key: '1', ctrlKey: true })).not.toThrow(); + + // The defined handler should still work + dispatchKey({ key: 'Tab', ctrlKey: true, shiftKey: true }); + expect(partial.prevTab).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/electron/src/renderer/keyboard-shortcuts.ts b/packages/electron/src/renderer/keyboard-shortcuts.ts new file mode 100644 index 0000000..6975cf7 --- /dev/null +++ b/packages/electron/src/renderer/keyboard-shortcuts.ts @@ -0,0 +1,34 @@ +export interface KeyboardShortcutHandlers { + nextTab: () => void; + prevTab: () => void; + switchToTab: (index: number) => void; +} + +export function registerKeyboardShortcuts(handlers: KeyboardShortcutHandlers): () => void { + const onKeyDown = (e: KeyboardEvent) => { + const modifier = e.metaKey || e.ctrlKey; + if (!modifier) return; + + if (e.key === 'Tab') { + if (e.shiftKey) { + handlers.prevTab?.(); + } else { + handlers.nextTab?.(); + } + e.preventDefault(); + return; + } + + const digit = parseInt(e.key, 10); + if (digit >= 1 && digit <= 9 && !e.shiftKey && !e.altKey) { + handlers.switchToTab?.(digit - 1); + e.preventDefault(); + } + }; + + document.addEventListener('keydown', onKeyDown); + + return () => { + document.removeEventListener('keydown', onKeyDown); + }; +} diff --git a/packages/electron/src/renderer/status-bar.test.ts b/packages/electron/src/renderer/status-bar.test.ts new file mode 100644 index 0000000..43bfed1 --- /dev/null +++ b/packages/electron/src/renderer/status-bar.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { StatusBar } from './status-bar'; + +describe('StatusBar', () => { + let statusBar: StatusBar; + let container: HTMLElement; + + beforeEach(() => { + document.body.innerHTML = '
'; + container = document.getElementById('mdview-status-bar') as HTMLElement; + statusBar = new StatusBar(); + statusBar.render(container); + }); + + it('should render into the container', () => { + expect(container.querySelector('.status-bar-content')).not.toBeNull(); + }); + + it('should update with file path', () => { + statusBar.update({ filePath: '/tmp/docs/readme.md' }); + expect(container.textContent).toContain('readme.md'); + }); + + it('should display word count', () => { + statusBar.update({ filePath: '/tmp/test.md', wordCount: 1234 }); + expect(container.textContent).toContain('1,234'); + expect(container.textContent).toContain('words'); + }); + + it('should display heading count', () => { + statusBar.update({ filePath: '/tmp/test.md', headingCount: 5 }); + expect(container.textContent).toContain('5'); + expect(container.textContent).toContain('heading'); + }); + + it('should display code block count', () => { + statusBar.update({ filePath: '/tmp/test.md', codeBlockCount: 3 }); + expect(container.textContent).toContain('3'); + expect(container.textContent).toContain('code block'); + }); + + it('should display diagram count', () => { + statusBar.update({ filePath: '/tmp/test.md', diagramCount: 2 }); + expect(container.textContent).toContain('2'); + expect(container.textContent).toContain('diagram'); + }); + + it('should display theme name', () => { + statusBar.update({ filePath: '/tmp/test.md', themeName: 'github-dark' }); + expect(container.textContent).toContain('github-dark'); + }); + + it('should display render state', () => { + statusBar.update({ filePath: '/tmp/test.md', renderState: 'rendering' }); + expect(container.textContent).toContain('rendering'); + }); + + it('should clear all data', () => { + statusBar.update({ filePath: '/tmp/test.md', wordCount: 100 }); + statusBar.clear(); + expect(container.querySelector('.status-bar-content')?.textContent).toBe(''); + }); + + it('should toggle visibility', () => { + statusBar.setVisible(false); + expect(container.style.display).toBe('none'); + statusBar.setVisible(true); + expect(container.style.display).toBe(''); + }); + + it('should handle partial updates', () => { + statusBar.update({ filePath: '/tmp/test.md', wordCount: 100, headingCount: 3 }); + expect(container.textContent).toContain('100'); + expect(container.textContent).toContain('3'); + }); +}); diff --git a/packages/electron/src/renderer/status-bar.ts b/packages/electron/src/renderer/status-bar.ts new file mode 100644 index 0000000..f4fb91f --- /dev/null +++ b/packages/electron/src/renderer/status-bar.ts @@ -0,0 +1,77 @@ +export interface StatusBarData { + filePath?: string; + wordCount?: number; + headingCount?: number; + diagramCount?: number; + codeBlockCount?: number; + themeName?: string; + renderState?: string; +} + +function formatNumber(n: number): string { + return n.toLocaleString(); +} + +function pluralize(count: number, singular: string): string { + return `${formatNumber(count)} ${singular}${count === 1 ? '' : 's'}`; +} + +export class StatusBar { + private container: HTMLElement | null = null; + private contentEl: HTMLElement | null = null; + + render(parent: HTMLElement): void { + this.container = parent; + this.contentEl = document.createElement('div'); + this.contentEl.className = 'status-bar-content'; + parent.appendChild(this.contentEl); + } + + update(data: StatusBarData): void { + if (!this.contentEl) return; + + const segments: string[] = []; + + if (data.filePath) { + segments.push(data.filePath.split('/').pop() ?? data.filePath); + } + + if (data.wordCount !== undefined) { + segments.push(pluralize(data.wordCount, 'word')); + } + + if (data.headingCount !== undefined) { + segments.push(pluralize(data.headingCount, 'heading')); + } + + if (data.codeBlockCount !== undefined) { + segments.push(pluralize(data.codeBlockCount, 'code block')); + } + + if (data.diagramCount !== undefined) { + segments.push(pluralize(data.diagramCount, 'diagram')); + } + + if (data.themeName) { + segments.push(data.themeName); + } + + if (data.renderState) { + segments.push(data.renderState); + } + + this.contentEl.textContent = segments.join(' \u00b7 '); + } + + clear(): void { + if (this.contentEl) { + this.contentEl.textContent = ''; + } + } + + setVisible(visible: boolean): void { + if (this.container) { + this.container.style.display = visible ? '' : 'none'; + } + } +} diff --git a/packages/electron/src/renderer/tab-manager.test.ts b/packages/electron/src/renderer/tab-manager.test.ts new file mode 100644 index 0000000..c250ba0 --- /dev/null +++ b/packages/electron/src/renderer/tab-manager.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TabManager } from './tab-manager'; + +describe('TabManager', () => { + let tabManager: TabManager; + let container: HTMLElement; + + beforeEach(() => { + document.body.innerHTML = '
'; + container = document.getElementById('mdview-tab-bar')!; + tabManager = new TabManager(); + tabManager.render(container); + }); + + it('should create a tab', () => { + tabManager.createTab('tab-1', '/tmp/test.md', 'test.md'); + expect(tabManager.getTabCount()).toBe(1); + }); + + it('should activate a tab on creation', () => { + tabManager.createTab('tab-1', '/tmp/test.md', 'test.md'); + expect(tabManager.getActiveTab()).toBe('tab-1'); + }); + + it('should render tab button in the tab bar', () => { + tabManager.createTab('tab-1', '/tmp/test.md', 'test.md'); + const tabBtn = container.querySelector('[data-tab-id="tab-1"]'); + expect(tabBtn).not.toBeNull(); + expect(tabBtn?.textContent).toContain('test.md'); + }); + + it('should switch active tab', () => { + tabManager.createTab('tab-1', '/tmp/a.md', 'a.md'); + tabManager.createTab('tab-2', '/tmp/b.md', 'b.md'); + tabManager.activateTab('tab-1'); + expect(tabManager.getActiveTab()).toBe('tab-1'); + }); + + it('should apply active class to active tab button', () => { + tabManager.createTab('tab-1', '/tmp/a.md', 'a.md'); + tabManager.createTab('tab-2', '/tmp/b.md', 'b.md'); + tabManager.activateTab('tab-1'); + + const tab1Btn = container.querySelector('[data-tab-id="tab-1"]'); + const tab2Btn = container.querySelector('[data-tab-id="tab-2"]'); + expect(tab1Btn?.classList.contains('active')).toBe(true); + expect(tab2Btn?.classList.contains('active')).toBe(false); + }); + + it('should close a tab', () => { + tabManager.createTab('tab-1', '/tmp/a.md', 'a.md'); + tabManager.createTab('tab-2', '/tmp/b.md', 'b.md'); + tabManager.closeTab('tab-1'); + expect(tabManager.getTabCount()).toBe(1); + + const tab1Btn = container.querySelector('[data-tab-id="tab-1"]'); + expect(tab1Btn).toBeNull(); + }); + + it('should call onTabClick callback when tab button is clicked', () => { + const onTabClick = vi.fn(); + tabManager.onTabClick(onTabClick); + + tabManager.createTab('tab-1', '/tmp/test.md', 'test.md'); + const tabBtn = container.querySelector('[data-tab-id="tab-1"]') as HTMLElement; + tabBtn.click(); + + expect(onTabClick).toHaveBeenCalledWith('tab-1'); + }); + + it('should call onTabClose callback when close button is clicked', () => { + const onTabClose = vi.fn(); + tabManager.onTabClose(onTabClose); + + tabManager.createTab('tab-1', '/tmp/test.md', 'test.md'); + const closeBtn = container.querySelector('[data-tab-id="tab-1"] .tab-close') as HTMLElement; + closeBtn.click(); + + expect(onTabClose).toHaveBeenCalledWith('tab-1'); + }); + + it('should call onTabClose on middle-click', () => { + const onTabClose = vi.fn(); + tabManager.onTabClose(onTabClose); + + tabManager.createTab('tab-1', '/tmp/test.md', 'test.md'); + const tabBtn = container.querySelector('[data-tab-id="tab-1"]') as HTMLElement; + tabBtn.dispatchEvent(new MouseEvent('auxclick', { button: 1, bubbles: true })); + + expect(onTabClose).toHaveBeenCalledWith('tab-1'); + }); + + it('should return null for active tab when empty', () => { + expect(tabManager.getActiveTab()).toBeNull(); + }); + + it('should get content container for a tab', () => { + const contentArea = document.getElementById('mdview-content-area')!; + tabManager.setContentArea(contentArea); + tabManager.createTab('tab-1', '/tmp/test.md', 'test.md'); + const tabContainer = tabManager.getTabContainer('tab-1'); + expect(tabContainer).not.toBeNull(); + expect(tabContainer?.parentElement).toBe(contentArea); + }); + + it('should hide inactive tab containers and show active', () => { + const contentArea = document.getElementById('mdview-content-area')!; + tabManager.setContentArea(contentArea); + + tabManager.createTab('tab-1', '/tmp/a.md', 'a.md'); + tabManager.createTab('tab-2', '/tmp/b.md', 'b.md'); + + const container1 = tabManager.getTabContainer('tab-1'); + const container2 = tabManager.getTabContainer('tab-2'); + + expect(container1?.style.display).toBe('none'); + expect(container2?.style.display).toBe(''); + }); +}); diff --git a/packages/electron/src/renderer/tab-manager.ts b/packages/electron/src/renderer/tab-manager.ts new file mode 100644 index 0000000..3f2aab4 --- /dev/null +++ b/packages/electron/src/renderer/tab-manager.ts @@ -0,0 +1,141 @@ +interface TabEntry { + id: string; + filePath: string; + title: string; + buttonEl: HTMLElement; + containerEl: HTMLElement; +} + +export class TabManager { + private tabs = new Map(); + private activeTabId: string | null = null; + private tabBar: HTMLElement | null = null; + private contentArea: HTMLElement | null = null; + private tabClickCallback: ((tabId: string) => void) | null = null; + private tabCloseCallback: ((tabId: string) => void) | null = null; + + render(tabBar: HTMLElement): void { + this.tabBar = tabBar; + } + + setContentArea(contentArea: HTMLElement): void { + this.contentArea = contentArea; + } + + createTab(id: string, filePath: string, title: string): void { + if (this.tabs.has(id)) return; + + const buttonEl = this.createTabButton(id, title); + const containerEl = document.createElement('div'); + containerEl.className = 'mdview-tab-content'; + containerEl.dataset.tabContentId = id; + + if (this.contentArea) { + this.contentArea.appendChild(containerEl); + } + + this.tabs.set(id, { id, filePath, title, buttonEl, containerEl }); + + if (this.tabBar) { + this.tabBar.appendChild(buttonEl); + } + + this.activateTab(id); + } + + activateTab(id: string): void { + const tab = this.tabs.get(id); + if (!tab) return; + + this.activeTabId = id; + + for (const [tabId, entry] of this.tabs) { + if (tabId === id) { + entry.buttonEl.classList.add('active'); + entry.containerEl.style.display = ''; + } else { + entry.buttonEl.classList.remove('active'); + entry.containerEl.style.display = 'none'; + } + } + } + + closeTab(id: string): void { + const tab = this.tabs.get(id); + if (!tab) return; + + tab.buttonEl.remove(); + tab.containerEl.remove(); + this.tabs.delete(id); + + if (this.activeTabId === id) { + this.activeTabId = null; + } + } + + getActiveTab(): string | null { + return this.activeTabId; + } + + getTabCount(): number { + return this.tabs.size; + } + + getTabContainer(id: string): HTMLElement | null { + return this.tabs.get(id)?.containerEl ?? null; + } + + onTabClick(callback: (tabId: string) => void): void { + this.tabClickCallback = callback; + } + + onTabClose(callback: (tabId: string) => void): void { + this.tabCloseCallback = callback; + } + + getTabIds(): string[] { + return Array.from(this.tabs.keys()); + } + + dispose(): void { + for (const tab of this.tabs.values()) { + tab.buttonEl.remove(); + tab.containerEl.remove(); + } + this.tabs.clear(); + this.activeTabId = null; + } + + private createTabButton(id: string, title: string): HTMLElement { + const btn = document.createElement('div'); + btn.className = 'tab-button'; + btn.dataset.tabId = id; + + const label = document.createElement('span'); + label.className = 'tab-label'; + label.textContent = title; + + const closeBtn = document.createElement('span'); + closeBtn.className = 'tab-close'; + closeBtn.textContent = '\u00d7'; + closeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.tabCloseCallback?.(id); + }); + + btn.appendChild(label); + btn.appendChild(closeBtn); + + btn.addEventListener('click', () => { + this.tabClickCallback?.(id); + }); + + btn.addEventListener('auxclick', (e) => { + if (e.button === 1) { + this.tabCloseCallback?.(id); + } + }); + + return btn; + } +} diff --git a/packages/electron/src/renderer/viewer.test.ts b/packages/electron/src/renderer/viewer.test.ts index 71f084f..af7a93a 100644 --- a/packages/electron/src/renderer/viewer.test.ts +++ b/packages/electron/src/renderer/viewer.test.ts @@ -55,6 +55,47 @@ function createMockMdviewAPI() { onFileChanged: vi.fn().mockReturnValue(vi.fn()), onPreferencesUpdated: vi.fn().mockReturnValue(vi.fn()), onThemeChanged: vi.fn().mockReturnValue(vi.fn()), + + // Workspace methods + showOpenFileDialog: vi.fn().mockResolvedValue(null), + showOpenFolderDialog: vi.fn().mockResolvedValue(null), + getRecentFiles: vi.fn().mockResolvedValue([]), + addRecentFile: vi.fn().mockResolvedValue(undefined), + clearRecentFiles: vi.fn().mockResolvedValue(undefined), + getWorkspaceState: vi.fn().mockResolvedValue({ + tabs: [], + activeTabId: null, + sidebarVisible: true, + sidebarWidth: 250, + openFolderPath: null, + statusBarVisible: true, + }), + openTab: vi.fn().mockImplementation((filePath: string) => + Promise.resolve({ + id: `tab-${filePath.replace(/[^a-z0-9]/g, '')}`, + filePath, + title: filePath.split('/').pop(), + scrollPosition: 0, + renderState: 'pending', + }) + ), + closeTab: vi.fn().mockResolvedValue(undefined), + setActiveTab: vi.fn().mockResolvedValue(undefined), + updateTabMetadata: vi.fn().mockResolvedValue(undefined), + updateTabScroll: vi.fn().mockResolvedValue(undefined), + setSidebarVisible: vi.fn().mockResolvedValue(undefined), + setOpenFolder: vi.fn().mockResolvedValue(undefined), + listDirectory: vi.fn().mockResolvedValue([]), + watchDirectory: vi.fn().mockResolvedValue(undefined), + unwatchDirectory: vi.fn().mockResolvedValue(undefined), + onOpenFile: vi.fn().mockReturnValue(vi.fn()), + onOpenFolder: vi.fn().mockReturnValue(vi.fn()), + onMenuCommand: vi.fn().mockReturnValue(vi.fn()), + onWorkspaceUpdated: vi.fn().mockReturnValue(vi.fn()), + onTabOpened: vi.fn().mockReturnValue(vi.fn()), + onTabClosed: vi.fn().mockReturnValue(vi.fn()), + onActiveTabChanged: vi.fn().mockReturnValue(vi.fn()), + onDirectoryChanged: vi.fn().mockReturnValue(vi.fn()), }; } @@ -65,53 +106,107 @@ describe('MDViewElectronViewer', () => { mockMdview = createMockMdviewAPI(); (globalThis.window as Record).mdview = mockMdview; - document.body.innerHTML = '
'; + document.body.innerHTML = ` +
+
+
+
+
+
+
+
+
+
+ `; }); - it('should initialize and render markdown', async () => { + it('should initialize and open file from CLI args', async () => { const { MDViewElectronViewer } = await import('./viewer'); const viewer = new MDViewElectronViewer(); await viewer.initialize(); expect(mockMdview.getOpenFilePath).toHaveBeenCalled(); - expect(mockMdview.readFile).toHaveBeenCalledWith('/tmp/test.md'); - expect(mockMdview.getState).toHaveBeenCalled(); + expect(mockMdview.openTab).toHaveBeenCalledWith('/tmp/test.md'); + expect(viewer.getTabCount()).toBe(1); }); - it('should show message when no file path is provided', async () => { + it('should show empty state when no file path is provided', async () => { mockMdview.getOpenFilePath.mockResolvedValue(null); const { MDViewElectronViewer } = await import('./viewer'); const viewer = new MDViewElectronViewer(); await viewer.initialize(); - const container = document.getElementById('mdview-container'); - expect(container?.innerHTML).toContain('No file specified'); + const emptyState = document.getElementById('mdview-empty-state'); + expect(emptyState).not.toBeNull(); + expect(viewer.getTabCount()).toBe(0); }); - it('should add mdview-active class to body', async () => { + it('should open multiple files as tabs', async () => { + mockMdview.getOpenFilePath.mockResolvedValue(null); + const { MDViewElectronViewer } = await import('./viewer'); const viewer = new MDViewElectronViewer(); await viewer.initialize(); - expect(document.body.classList.contains('mdview-active')).toBe(true); + await viewer.openFile('/tmp/a.md'); + await viewer.openFile('/tmp/b.md'); + + expect(viewer.getTabCount()).toBe(2); + }); + + it('should deduplicate when opening same file', async () => { + mockMdview.getOpenFilePath.mockResolvedValue(null); + + const { MDViewElectronViewer } = await import('./viewer'); + const viewer = new MDViewElectronViewer(); + await viewer.initialize(); + + await viewer.openFile('/tmp/a.md'); + await viewer.openFile('/tmp/a.md'); + + expect(viewer.getTabCount()).toBe(1); }); - it('should listen for preference and theme updates', async () => { + it('should close a tab and show empty state if last', async () => { const { MDViewElectronViewer } = await import('./viewer'); const viewer = new MDViewElectronViewer(); await viewer.initialize(); + const activeTabId = viewer.getActiveTabId(); + expect(activeTabId).not.toBeNull(); + + await viewer.closeFile(activeTabId!); + + expect(viewer.getTabCount()).toBe(0); + const emptyState = document.getElementById('mdview-empty-state'); + expect(emptyState?.style.display).not.toBe('none'); + }); + + it('should listen for IPC events', async () => { + const { MDViewElectronViewer } = await import('./viewer'); + const viewer = new MDViewElectronViewer(); + await viewer.initialize(); + + expect(mockMdview.onOpenFile).toHaveBeenCalled(); + expect(mockMdview.onMenuCommand).toHaveBeenCalled(); expect(mockMdview.onPreferencesUpdated).toHaveBeenCalled(); expect(mockMdview.onThemeChanged).toHaveBeenCalled(); }); - it('should handle missing container gracefully', async () => { + it('should add mdview-active class on file load', async () => { + const { MDViewElectronViewer } = await import('./viewer'); + const viewer = new MDViewElectronViewer(); + await viewer.initialize(); + + expect(document.body.classList.contains('mdview-active')).toBe(true); + }); + + it('should handle missing workspace elements gracefully', async () => { document.body.innerHTML = ''; const { MDViewElectronViewer } = await import('./viewer'); const viewer = new MDViewElectronViewer(); - // Should not throw await viewer.initialize(); }); diff --git a/packages/electron/src/renderer/viewer.ts b/packages/electron/src/renderer/viewer.ts index e022582..6f92a53 100644 --- a/packages/electron/src/renderer/viewer.ts +++ b/packages/electron/src/renderer/viewer.ts @@ -1,265 +1,348 @@ /* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */ -import { - RenderPipeline, - ThemeEngine, - TocRenderer, - ExportUI, - CommentManager, - FileScanner, - type AppState, - type RenderProgress, - type Preferences, -} from '@mdview/core'; -import { - ElectronMessagingAdapter, - ElectronRendererStorageAdapter, - ElectronRendererFileAdapter, - ElectronRendererIdentityAdapter, - ElectronRendererExportAdapter, -} from './adapters'; +import { TabManager } from './tab-manager'; +import { DocumentContext } from './document-context'; +import { StatusBar } from './status-bar'; +import { registerKeyboardShortcuts } from './keyboard-shortcuts'; +import { setupDragAndDrop } from './drag-drop'; +import { FileTree } from './file-tree'; export class MDViewElectronViewer { - private state: AppState | null = null; - private renderPipeline: RenderPipeline; - private themeEngine: ThemeEngine; - private fileAdapter: ElectronRendererFileAdapter; - private identityAdapter: ElectronRendererIdentityAdapter; - private exportAdapter: ElectronRendererExportAdapter; - private tocRenderer: TocRenderer | null = null; - private exportUI: ExportUI | null = null; - private commentManager: CommentManager | null = null; - private autoReloadCleanup: (() => void) | null = null; + private tabManager: TabManager; + private statusBar: StatusBar; + private fileTree: FileTree; + private documents = new Map(); private cleanupListeners: (() => void)[] = []; constructor() { - const messaging = new ElectronMessagingAdapter(); - const storage = new ElectronRendererStorageAdapter(); - this.fileAdapter = new ElectronRendererFileAdapter(); - this.identityAdapter = new ElectronRendererIdentityAdapter(); - this.exportAdapter = new ElectronRendererExportAdapter(); - - this.renderPipeline = new RenderPipeline({ messaging }); - this.themeEngine = new ThemeEngine(storage); + this.tabManager = new TabManager(); + this.statusBar = new StatusBar(); + this.fileTree = new FileTree(); } async initialize(): Promise { + const tabBar = document.getElementById('mdview-tab-bar'); + const contentArea = document.getElementById('mdview-content-area'); const container = document.getElementById('mdview-container'); - if (!container) { - console.error('[mdview] No #mdview-container found'); - return; - } - try { - // 1. Get file path from main process - const filePath = await window.mdview.getOpenFilePath(); - if (!filePath) { - container.innerHTML = - '

No file specified. Drag a .md file onto the window or open one from the command line.

'; + if (!tabBar || !contentArea) { + // Fallback: legacy single-container mode + if (!container) { + console.error('[mdview] No workspace elements found'); return; } + } - // 2. Load state/preferences - this.state = await window.mdview.getState(); + if (tabBar) { + this.tabManager.render(tabBar); + } + if (contentArea) { + this.tabManager.setContentArea(contentArea); + } - // 3. Read file - const content = await window.mdview.readFile(filePath); - const fileSize = FileScanner.getFileSize(content); + const statusBarEl = document.getElementById('mdview-status-bar'); + if (statusBarEl) { + this.statusBar.render(statusBarEl); + } - // 4. Create loading UI - const loadingDiv = this.createLoadingOverlay(); - const progressIndicator = this.createProgressIndicator(); - document.body.appendChild(loadingDiv); - document.body.appendChild(progressIndicator); + // File tree sidebar + const sidebarEl = document.getElementById('mdview-sidebar'); + if (sidebarEl) { + this.fileTree.render(sidebarEl); + this.fileTree.onFileSelected((path) => { + void this.openFile(path); + }); + } - // Activate body class for CSS scoping - document.body.classList.add('mdview-active'); + // Wire tab callbacks + this.tabManager.onTabClick((tabId) => { + this.switchTab(tabId); + }); - // 5. Apply theme - const theme = this.state.preferences.theme || 'github-light'; - await this.themeEngine.applyTheme(theme); + this.tabManager.onTabClose((tabId) => { + void this.closeFile(tabId); + }); - // 6. Set up progress callback - const cleanupProgress = this.renderPipeline.onProgress((progress: RenderProgress) => { - this.updateProgress(progressIndicator, loadingDiv, progress); - }); + // Keyboard shortcuts + const cleanupShortcuts = registerKeyboardShortcuts({ + nextTab: () => this.cycleTab(1), + prevTab: () => this.cycleTab(-1), + switchToTab: (index) => this.switchToTabByIndex(index), + }); + this.cleanupListeners.push(cleanupShortcuts); + + // Drag and drop + const workspace = document.getElementById('mdview-workspace') ?? document.body; + const cleanupDragDrop = setupDragAndDrop({ + target: workspace, + onFilesDropped: (paths) => { + for (const p of paths) { + void this.openFile(p); + } + }, + }); + this.cleanupListeners.push(cleanupDragDrop); - // 7. Render - await this.renderPipeline.render({ - container, - markdown: content, - progressive: fileSize > 500000, - filePath, - theme, - preferences: this.state.preferences, - useCache: true, - useWorkers: false, // Workers not available in Electron renderer with contextIsolation - }); + // Listen for IPC events from main process + this.setupIPCListeners(); - cleanupProgress(); - loadingDiv.remove(); - - // 8. Post-render: TOC - if (this.state.preferences.showToc) { - const headings = this.extractHeadings(container); - if (headings.length > 0) { - this.tocRenderer = new TocRenderer(headings, { - maxDepth: this.state.preferences.tocMaxDepth ?? 6, - autoCollapse: this.state.preferences.tocAutoCollapse ?? false, - position: this.state.preferences.tocPosition ?? 'left', - style: this.state.preferences.tocStyle ?? 'floating', - }); - this.tocRenderer.render(container); - } + // Check for initial file from CLI args + try { + const filePath = await window.mdview.getOpenFilePath(); + if (filePath) { + await this.openFile(filePath); + } else { + this.showEmptyState(); } + } catch (error) { + console.error('[mdview] Initialization error:', error); + } + } - // 9. Post-render: Export UI - this.exportUI = new ExportUI({ - messaging: new ElectronMessagingAdapter(), - }); - this.exportUI.render(container); - - // 10. Post-render: Comments - if (this.state.preferences.commentsEnabled !== false) { - this.commentManager = new CommentManager({ - file: this.fileAdapter, - identity: this.identityAdapter, - }); - await this.commentManager.initialize(content, filePath, container); + async openFile(filePath: string): Promise { + // Check if already open + for (const [tabId, ctx] of this.documents) { + if (ctx.getFilePath() === filePath) { + this.switchTab(tabId); + return; } + } - // 11. Auto-reload - if (this.state.preferences.autoReload) { - this.setupAutoReload(filePath, container, content); - } + // Open tab in main process state + const tabState = await window.mdview.openTab(filePath); + + // Create tab UI + this.tabManager.createTab(tabState.id, filePath, tabState.title); + const tabContainer = this.tabManager.getTabContainer(tabState.id); + + if (!tabContainer) { + console.error('[mdview] Failed to create tab container'); + return; + } + + // Hide empty state + this.hideEmptyState(); - // 12. Listen for preference/theme updates from main - this.setupEventListeners(container, filePath); + // Create document context and load + const ctx = new DocumentContext(tabState.id); + this.documents.set(tabState.id, ctx); + + try { + const metadata = await ctx.load(filePath, tabContainer); + await window.mdview.updateTabMetadata(tabState.id, { + renderState: metadata.renderState, + wordCount: metadata.wordCount, + headingCount: metadata.headingCount, + diagramCount: metadata.diagramCount, + codeBlockCount: metadata.codeBlockCount, + }); + await window.mdview.addRecentFile(filePath); + this.updateStatusBar(ctx); } catch (error) { - console.error('[mdview] Initialization error:', error); - container.innerHTML = `

Error loading file: ${String(error)}

`; + console.error('[mdview] Error loading file:', error); + tabContainer.innerHTML = `

Error loading file: ${String(error)}

`; } } - private createLoadingOverlay(): HTMLElement { - const div = document.createElement('div'); - div.id = 'mdview-loading-overlay'; - div.className = 'mdview-loading'; - div.textContent = 'Rendering markdown...'; - return div; - } + async closeFile(tabId: string): Promise { + const ctx = this.documents.get(tabId); + if (ctx) { + ctx.dispose(); + this.documents.delete(tabId); + } + + this.tabManager.closeTab(tabId); + await window.mdview.closeTab(tabId); - private createProgressIndicator(): HTMLElement { - const div = document.createElement('div'); - div.id = 'mdview-progress-indicator'; - div.innerHTML = ` -
Rendering... 0%
-
-
-
- `; - return div; + if (this.documents.size === 0) { + this.showEmptyState(); + this.statusBar.clear(); + } } - private updateProgress( - indicator: HTMLElement, - loadingDiv: HTMLElement, - progress: RenderProgress - ): void { - const pct = Math.round(progress.progress); - const text = indicator.querySelector('.progress-text'); - const bar = indicator.querySelector('.progress-bar-fill'); + switchTab(tabId: string): void { + // Save scroll position of current tab + const currentTabId = this.tabManager.getActiveTab(); + if (currentTabId) { + const currentCtx = this.documents.get(currentTabId); + const currentContainer = this.tabManager.getTabContainer(currentTabId); + if (currentCtx && currentContainer) { + currentCtx.setScrollPosition(currentContainer.scrollTop); + } + } - if (text) text.textContent = `${pct}%`; - if (bar) bar.style.width = `${pct}%`; + this.tabManager.activateTab(tabId); + void window.mdview.setActiveTab(tabId); - if (progress.progress > 5 && loadingDiv.style.display !== 'none') { - loadingDiv.style.display = 'none'; + // Restore scroll position + const ctx = this.documents.get(tabId); + const container = this.tabManager.getTabContainer(tabId); + if (ctx && container) { + container.scrollTop = ctx.getScrollPosition(); } - if (progress.progress >= 100) { - indicator.classList.add('complete'); - setTimeout(() => indicator.remove(), 1000); + // Update status bar for new active tab + if (ctx) { + this.updateStatusBar(ctx); } } - private extractHeadings(container: HTMLElement): { level: number; text: string; id: string }[] { - const headings: { level: number; text: string; id: string }[] = []; - container.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((el) => { - const level = parseInt(el.tagName.charAt(1), 10); - headings.push({ - level, - text: el.textContent || '', - id: el.id || '', - }); - }); - return headings; + getTabCount(): number { + return this.tabManager.getTabCount(); } - private setupAutoReload(filePath: string, container: HTMLElement, initialContent: string): void { - let lastContent = initialContent; - let reloadTimeout: ReturnType | null = null; - - const unwatch = this.fileAdapter.watch(filePath, () => { - if (reloadTimeout) clearTimeout(reloadTimeout); - reloadTimeout = setTimeout(() => { - void (async () => { - try { - const newContent = await this.fileAdapter.readFile(filePath); - if (newContent !== lastContent) { - lastContent = newContent; - await this.renderPipeline.render({ - container, - markdown: newContent, - progressive: false, - filePath, - theme: this.state?.preferences.theme || 'github-light', - preferences: this.state?.preferences || ({} as Preferences), - useCache: true, - useWorkers: false, - }); - } - } catch (err) { - console.error('[mdview] Auto-reload error:', err); - } - })(); - }, 500); - }); + getActiveTabId(): string | null { + return this.tabManager.getActiveTab(); + } + + getDocument(tabId: string): DocumentContext | undefined { + return this.documents.get(tabId); + } + + private cycleTab(direction: number): void { + const tabIds = this.tabManager.getTabIds(); + if (tabIds.length <= 1) return; + const currentIndex = tabIds.indexOf(this.tabManager.getActiveTab() ?? ''); + if (currentIndex === -1) return; + const newIndex = (currentIndex + direction + tabIds.length) % tabIds.length; + this.switchTab(tabIds[newIndex]); + } - this.autoReloadCleanup = unwatch; + private switchToTabByIndex(index: number): void { + const tabIds = this.tabManager.getTabIds(); + if (index < tabIds.length) { + this.switchTab(tabIds[index]); + } } - private setupEventListeners(_container: HTMLElement, filePath: string): void { - const unsubPrefs = window.mdview.onPreferencesUpdated((prefs) => { - if (!this.state) return; - Object.assign(this.state.preferences, prefs); + private updateStatusBar(ctx: DocumentContext): void { + const metadata = ctx.getMetadata(); + this.statusBar.update({ + filePath: metadata.filePath, + wordCount: metadata.wordCount, + headingCount: metadata.headingCount, + diagramCount: metadata.diagramCount, + codeBlockCount: metadata.codeBlockCount, + renderState: metadata.renderState, + }); + } - if ('theme' in prefs) { - void this.themeEngine.applyTheme(this.state.preferences.theme); + private setupIPCListeners(): void { + const unsubOpenFile = window.mdview.onOpenFile((path: string) => { + void this.openFile(path); + }); + this.cleanupListeners.push(unsubOpenFile); + + const unsubMenuCommand = window.mdview.onMenuCommand((command: string) => { + if (command.startsWith('open:')) { + void this.openFile(command.slice(5)); + } else if (command === 'close-tab') { + const activeTab = this.tabManager.getActiveTab(); + if (activeTab) { + void this.closeFile(activeTab); + } + } else if (command === 'toggle-sidebar') { + const sidebarEl = document.getElementById('mdview-sidebar'); + if (sidebarEl) { + const isVisible = sidebarEl.style.display !== 'none'; + this.fileTree.setVisible(!isVisible); + void window.mdview.setSidebarVisible(!isVisible); + } } }); + this.cleanupListeners.push(unsubMenuCommand); + + const unsubOpenFolder = window.mdview.onOpenFolder((folderPath: string) => { + void this.loadFolder(folderPath); + }); + this.cleanupListeners.push(unsubOpenFolder); + + const unsubPrefs = window.mdview.onPreferencesUpdated(() => { + // Preferences changes could trigger re-renders + }); this.cleanupListeners.push(unsubPrefs); - const unsubTheme = window.mdview.onThemeChanged((theme) => { - void this.themeEngine.applyTheme(theme as AppState['preferences']['theme']); + const unsubTheme = window.mdview.onThemeChanged(() => { + // Theme changes could trigger re-applies }); this.cleanupListeners.push(unsubTheme); - // Clean up on page unload window.addEventListener('beforeunload', () => { - this.dispose(filePath); + this.dispose(); }); } - private dispose(filePath: string): void { - if (this.autoReloadCleanup) { - this.autoReloadCleanup(); - this.autoReloadCleanup = null; + private showEmptyState(): void { + const contentArea = document.getElementById('mdview-content-area'); + if (!contentArea) return; + + let emptyState = document.getElementById('mdview-empty-state'); + if (!emptyState) { + emptyState = document.createElement('div'); + emptyState.id = 'mdview-empty-state'; + emptyState.innerHTML = ` +
+

mdview

+

Open a file to get started

+
+ + +
+
+ `; + contentArea.appendChild(emptyState); + + document.getElementById('empty-open-file')?.addEventListener('click', () => { + void this.handleOpenFileDialog(); + }); + document.getElementById('empty-open-folder')?.addEventListener('click', () => { + void this.handleOpenFolderDialog(); + }); + } + + emptyState.style.display = ''; + } + + private hideEmptyState(): void { + const emptyState = document.getElementById('mdview-empty-state'); + if (emptyState) { + emptyState.style.display = 'none'; + } + } + + private async handleOpenFileDialog(): Promise { + const paths = await window.mdview.showOpenFileDialog(); + if (paths) { + for (const p of paths) { + await this.openFile(p); + } + } + } + + private async handleOpenFolderDialog(): Promise { + const folderPath = await window.mdview.showOpenFolderDialog(); + if (folderPath) { + await this.loadFolder(folderPath); + } + } + + private async loadFolder(folderPath: string): Promise { + await window.mdview.setOpenFolder(folderPath); + const entries = await window.mdview.listDirectory(folderPath); + this.fileTree.loadDirectory(entries); + this.fileTree.setVisible(true); + } + + private dispose(): void { + for (const ctx of this.documents.values()) { + ctx.dispose(); } + this.documents.clear(); for (const cleanup of this.cleanupListeners) { cleanup(); } this.cleanupListeners = []; - void window.mdview.unwatchFile(filePath); + this.tabManager.dispose(); + this.fileTree.dispose(); } } diff --git a/packages/electron/src/shared/ipc-channels.ts b/packages/electron/src/shared/ipc-channels.ts index 8f86b80..0040454 100644 --- a/packages/electron/src/shared/ipc-channels.ts +++ b/packages/electron/src/shared/ipc-channels.ts @@ -14,10 +14,40 @@ export const IPC_CHANNELS = { PRINT_TO_PDF: 'mdview:print-to-pdf', GET_OPEN_FILE_PATH: 'mdview:get-open-file-path', + // File dialogs + SHOW_OPEN_FILE_DIALOG: 'mdview:show-open-file-dialog', + SHOW_OPEN_FOLDER_DIALOG: 'mdview:show-open-folder-dialog', + GET_RECENT_FILES: 'mdview:get-recent-files', + ADD_RECENT_FILE: 'mdview:add-recent-file', + CLEAR_RECENT_FILES: 'mdview:clear-recent-files', + + // Workspace state + GET_WORKSPACE_STATE: 'mdview:get-workspace-state', + OPEN_TAB: 'mdview:open-tab', + CLOSE_TAB: 'mdview:close-tab', + SET_ACTIVE_TAB: 'mdview:set-active-tab', + UPDATE_TAB_METADATA: 'mdview:update-tab-metadata', + UPDATE_TAB_SCROLL: 'mdview:update-tab-scroll', + SET_SIDEBAR_VISIBLE: 'mdview:set-sidebar-visible', + SET_OPEN_FOLDER: 'mdview:set-open-folder', + + // Directory + LIST_DIRECTORY: 'mdview:list-directory', + WATCH_DIRECTORY: 'mdview:watch-directory', + UNWATCH_DIRECTORY: 'mdview:unwatch-directory', + // Events from main → renderer FILE_CHANGED: 'mdview:file-changed', PREFERENCES_UPDATED: 'mdview:preferences-updated', THEME_CHANGED: 'mdview:theme-changed', + OPEN_FILE: 'mdview:open-file', + OPEN_FOLDER: 'mdview:open-folder', + MENU_COMMAND: 'mdview:menu-command', + WORKSPACE_UPDATED: 'mdview:workspace-updated', + TAB_OPENED: 'mdview:tab-opened', + TAB_CLOSED: 'mdview:tab-closed', + ACTIVE_TAB_CHANGED: 'mdview:active-tab-changed', + DIRECTORY_CHANGED: 'mdview:directory-changed', } as const; export type IPCChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]; diff --git a/packages/electron/src/shared/preload-api.ts b/packages/electron/src/shared/preload-api.ts index 651fe3b..3864ace 100644 --- a/packages/electron/src/shared/preload-api.ts +++ b/packages/electron/src/shared/preload-api.ts @@ -1,5 +1,6 @@ import type { AppState, Preferences, CachedResult } from '@mdview/core'; import type { FileChangeInfo, FileWriteResult } from '@mdview/core'; +import type { WorkspaceState, TabState, DirectoryEntry } from './workspace-types'; export interface MdviewPreloadAPI { // State @@ -37,10 +38,40 @@ export interface MdviewPreloadAPI { // File open path (from CLI args or file association) getOpenFilePath(): Promise; + // File dialogs + showOpenFileDialog(): Promise; + showOpenFolderDialog(): Promise; + getRecentFiles(): Promise; + addRecentFile(path: string): Promise; + clearRecentFiles(): Promise; + + // Workspace state + getWorkspaceState(): Promise; + openTab(filePath: string): Promise; + closeTab(tabId: string): Promise; + setActiveTab(tabId: string): Promise; + updateTabMetadata(tabId: string, metadata: Partial): Promise; + updateTabScroll(tabId: string, position: number): Promise; + setSidebarVisible(visible: boolean): Promise; + setOpenFolder(path: string | null): Promise; + + // Directory + listDirectory(dirPath: string): Promise; + watchDirectory(dirPath: string): Promise; + unwatchDirectory(dirPath: string): Promise; + // Event listeners (main → renderer) onFileChanged(callback: (path: string) => void): () => void; onPreferencesUpdated(callback: (prefs: Partial) => void): () => void; onThemeChanged(callback: (theme: string) => void): () => void; + onOpenFile(callback: (path: string) => void): () => void; + onOpenFolder(callback: (path: string) => void): () => void; + onMenuCommand(callback: (command: string) => void): () => void; + onWorkspaceUpdated(callback: (state: WorkspaceState) => void): () => void; + onTabOpened(callback: (tab: TabState) => void): () => void; + onTabClosed(callback: (tabId: string) => void): () => void; + onActiveTabChanged(callback: (tabId: string) => void): () => void; + onDirectoryChanged(callback: (dirPath: string) => void): () => void; } declare global { diff --git a/packages/electron/src/shared/workspace-types.ts b/packages/electron/src/shared/workspace-types.ts new file mode 100644 index 0000000..f3086e9 --- /dev/null +++ b/packages/electron/src/shared/workspace-types.ts @@ -0,0 +1,36 @@ +export interface TabState { + id: string; + filePath: string; + title: string; + scrollPosition: number; + renderState: 'pending' | 'rendering' | 'complete' | 'error'; + wordCount?: number; + headingCount?: number; + diagramCount?: number; + codeBlockCount?: number; +} + +export interface DirectoryEntry { + name: string; + path: string; + type: 'file' | 'directory'; + children?: DirectoryEntry[]; +} + +export interface WorkspaceState { + tabs: TabState[]; + activeTabId: string | null; + sidebarVisible: boolean; + sidebarWidth: number; + openFolderPath: string | null; + statusBarVisible: boolean; +} + +export const DEFAULT_WORKSPACE_STATE: WorkspaceState = { + tabs: [], + activeTabId: null, + sidebarVisible: true, + sidebarWidth: 250, + openFolderPath: null, + statusBarVisible: true, +};