const { app, BrowserWindow, dialog, ipcMain } = require('electron'); const path = require('path'); const fs = require('fs'); const { readFile, stat } = require('fs/promises'); const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB let mainWindow = null; let activeFilePath = null; // Currently active tab's file path let pendingFilePath = null; let fileWatcher = null; let closeTimeout = null; let isClosing = false; // Prevent re-entrant close let isSelfWriting = false; // Skip fs.watch events triggered by our own writes // ===== Single Instance Lock ===== const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { app.quit(); } else { app.on('second-instance', (event, commandLine, workingDirectory) => { // A second instance was launched (e.g. double-clicking a .md file) // Focus the existing window and open the file in it if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); // Extract file path from the second instance's command line args const filePath = getFilePathFromArgs(commandLine); if (filePath) { openFileInTab(filePath); } } }); } // Extract a valid file path from command line arguments (skip Electron flags) function getFilePathFromArgs(args) { for (let i = 1; i < args.length; i++) { const arg = args[i]; if (!arg.startsWith('--') && !arg.startsWith('-')) { try { if (fs.existsSync(arg)) return arg; } catch (e) { /* ignore */ } } } return null; } function createWindow() { mainWindow = new BrowserWindow({ width: 1200, height: 800, minWidth: 800, minHeight: 600, icon: path.join(__dirname, 'assets', 'icon.ico'), webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false }, titleBarStyle: 'default', show: false }); mainWindow.webContents.on('did-finish-load', () => { if (pendingFilePath) { openFileInTab(pendingFilePath); pendingFilePath = null; } }); mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html')); mainWindow.once('ready-to-show', () => { mainWindow.show(); }); mainWindow.setMenu(null); // Unsaved changes warning on close mainWindow.on('close', (e) => { if (isClosing) return; // Prevent re-entrant close isClosing = true; e.preventDefault(); try { // 检查 webContents 是否仍可用 if (!mainWindow.webContents.isDestroyed()) { mainWindow.webContents.send('window:confirmClose'); } else { // 渲染进程已销毁,直接关闭 mainWindow.removeAllListeners('close'); mainWindow.close(); return; } } catch (err) { mainWindow.removeAllListeners('close'); mainWindow.close(); return; } // 5s safety timeout in case renderer is unresponsive closeTimeout = setTimeout(() => { closeTimeout = null; if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.removeAllListeners('close'); mainWindow.close(); } }, 5000); }); mainWindow.on('closed', () => { stopWatching(); stopSidebarWatching(); mainWindow = null; }); } // File watcher function startWatching(filePath) { stopWatching(); if (!filePath) return; try { fileWatcher = fs.watch(filePath, (eventType) => { if (eventType === 'change' && mainWindow && !mainWindow.isDestroyed()) { // 跳过自身写入触发的 change 事件 if (isSelfWriting) return; mainWindow.webContents.send('file:externallyModified', filePath); } }); } catch (err) { // Silently ignore } } function stopWatching() { if (fileWatcher) { fileWatcher.close(); fileWatcher = null; } } async function showOpenDialog() { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openFile'], filters: [ { name: 'Markdown 文件', extensions: ['md', 'markdown', 'txt'] } ] }); if (!result.canceled && result.filePaths.length > 0) { return result.filePaths; } return null; } async function readFileContent(filePath) { // 文件大小检查 const stats = await stat(filePath); if (stats.size > MAX_FILE_SIZE) { throw new Error(`文件过大(${(stats.size / 1024 / 1024).toFixed(1)} MB),暂不支持超过 20MB 的文件`); } let content = await readFile(filePath, 'utf-8'); // 剥离 UTF-8 BOM(Windows 某些编辑器会添加) if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1); return content; } // Open file in a new tab (renderer manages tabs) async function openFileInTab(filePath) { if (!mainWindow || mainWindow.isDestroyed()) return; try { const content = await readFileContent(filePath); activeFilePath = filePath; startWatching(filePath); mainWindow.setTitle(`MarkLite - ${path.basename(filePath)}`); mainWindow.webContents.send('file:openInTab', { filePath, content }); } catch (err) { dialog.showErrorBox('错误', `无法读取文件: ${err.message}`); } } // Switch active file (for tab switching) function switchActiveFile(filePath) { activeFilePath = filePath || null; startWatching(filePath); if (mainWindow && !mainWindow.isDestroyed()) { if (filePath) { mainWindow.setTitle(`MarkLite - ${path.basename(filePath)}`); } else { mainWindow.setTitle('MarkLite'); } } } // IPC Handlers // Save file to a specific path with watcher management function saveToPath(filePath, content) { stopWatching(); try { isSelfWriting = true; fs.writeFileSync(filePath, content, 'utf-8'); // 写入后短暂延迟再恢复监听,让 fs.watch 错过自身写入的事件 isSelfWriting = false; } catch (err) { isSelfWriting = false; startWatching(activeFilePath); // Restore watcher on failure return { success: false, error: err.message }; } activeFilePath = filePath; // 延迟 300ms 重新监听,确保文件系统的 change 事件已过期 setTimeout(() => { if (activeFilePath === filePath) { startWatching(filePath); } }, 300); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.setTitle(`MarkLite - ${path.basename(filePath)}`); } return { success: true, filePath }; } // Open single file via dialog ipcMain.handle('dialog:openFile', async () => { try { const files = await showOpenDialog(); if (files && files.length > 0) { const content = await readFileContent(files[0]); activeFilePath = files[0]; startWatching(files[0]); mainWindow.setTitle(`MarkLite - ${path.basename(files[0])}`); return { filePath: files[0], content }; } return null; } catch (err) { return { error: err.message }; } }); ipcMain.handle('file:read', async (event, filePath) => { try { const content = await readFileContent(filePath); return { success: true, content }; } catch (err) { return { success: false, error: err.message }; } }); ipcMain.handle('file:save', async (event, { filePath, content }) => { try { if (filePath) { return saveToPath(filePath, content); } else { const result = await dialog.showSaveDialog(mainWindow, { filters: [ { name: 'Markdown 文件', extensions: ['md'] } ] }); if (!result.canceled) { return saveToPath(result.filePath, content); } return { success: false, canceled: true }; } } catch (err) { return { success: false, error: err.message }; } }); ipcMain.handle('file:saveAs', async (event, { content }) => { try { const result = await dialog.showSaveDialog(mainWindow, { filters: [ { name: 'Markdown 文件', extensions: ['md'] } ] }); if (!result.canceled) { return saveToPath(result.filePath, content); } return { success: false, canceled: true }; } catch (err) { return { success: false, error: err.message }; } }); ipcMain.handle('file:getCurrentPath', () => { return activeFilePath; }); ipcMain.handle('file:stats', async (event, filePath) => { try { const fileStat = await stat(filePath); return { success: true, size: fileStat.size, mtime: fileStat.mtime.toISOString() }; } catch (err) { return { success: false, error: err.message }; } }); ipcMain.handle('file:reload', async () => { if (!activeFilePath) return { success: false, error: '没有打开的文件' }; try { const content = await readFileContent(activeFilePath); return { success: true, content, filePath: activeFilePath }; } catch (err) { return { success: false, error: err.message }; } }); // ===== File Tree (Sidebar) ===== const SKIP_DIRS = new Set(['node_modules', '.git', '.svn', '.hg', 'dist', 'out', '.next', '.nuxt', '__pycache__', '.DS_Store']); const MARKDOWN_EXTS = new Set(['.md', '.markdown', '.txt']); async function buildDirTree(dirPath) { const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); // Sort: directories first, then files, alphabetically entries.sort((a, b) => { if (a.isDirectory() && !b.isDirectory()) return -1; if (!a.isDirectory() && b.isDirectory()) return 1; return a.name.localeCompare(b.name); }); const children = []; for (const entry of entries) { if (SKIP_DIRS.has(entry.name)) continue; if (entry.name.startsWith('.')) continue; const childPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { const subChildren = await buildDirTree(childPath); if (subChildren.length > 0) { children.push({ name: entry.name, path: childPath, type: 'dir', children: subChildren }); } } else { const ext = path.extname(entry.name).toLowerCase(); if (MARKDOWN_EXTS.has(ext)) { children.push({ name: entry.name, path: childPath, type: 'file' }); } } } return children; } ipcMain.handle('dir:readTree', async (event, dirPath) => { try { const tree = await buildDirTree(dirPath); return { success: true, tree, rootPath: dirPath }; } catch (err) { return { success: false, error: err.message }; } }); let sidebarWatcher = null; let sidebarWatchPath = null; function startSidebarWatching(dirPath) { stopSidebarWatching(); if (!dirPath) return; try { sidebarWatchPath = dirPath; sidebarWatcher = fs.watch(dirPath, { recursive: true }, (eventType, filename) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('sidebar:dirChanged'); } }); } catch (err) { /* ignore */ } } function stopSidebarWatching() { if (sidebarWatcher) { sidebarWatcher.close(); sidebarWatcher = null; } sidebarWatchPath = null; } ipcMain.handle('dir:watch', (event, dirPath) => { startSidebarWatching(dirPath); }); ipcMain.handle('dir:unwatch', () => { stopSidebarWatching(); }); ipcMain.handle('dir:openDialog', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory'] }); if (!result.canceled && result.filePaths.length > 0) { return result.filePaths[0]; } return null; }); // Tab switched - update active file tracking ipcMain.handle('tab:switched', (event, filePath) => { switchActiveFile(filePath); }); ipcMain.handle('window:forceClose', () => { if (closeTimeout) { clearTimeout(closeTimeout); closeTimeout = null; } // 窗口已销毁则无需操作(超时兜底可能已经关闭了窗口) if (!mainWindow || mainWindow.isDestroyed()) return; stopWatching(); mainWindow.removeAllListeners('close'); mainWindow.close(); }); ipcMain.handle('window:cancelClose', () => { if (closeTimeout) { clearTimeout(closeTimeout); closeTimeout = null; } isClosing = false; // Reset so close can be triggered again }); // Command line file (first instance) function getCommandLineFile() { return getFilePathFromArgs(process.argv); } // App lifecycle app.whenReady().then(() => { app.on('open-file', (event, filePath) => { event.preventDefault(); if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents.isLoading()) { pendingFilePath = filePath; } else if (mainWindow && !mainWindow.isDestroyed()) { openFileInTab(filePath); } else { pendingFilePath = filePath; } }); createWindow(); const fileToOpen = getCommandLineFile(); if (fileToOpen) { pendingFilePath = fileToOpen; } }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });