449 lines
12 KiB
JavaScript
449 lines
12 KiB
JavaScript
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();
|
||
});
|