Files
MarkLite/main.js
T

448 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 BOMWindows 某些编辑器会添加)
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
async function saveToPath(filePath, content) {
stopWatching();
try {
isSelfWriting = true;
await fs.promises.writeFile(filePath, content, 'utf-8');
isSelfWriting = false;
} catch (err) {
isSelfWriting = false;
startWatching(activeFilePath);
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 await saveToPath(filePath, content);
} else {
const result = await dialog.showSaveDialog(mainWindow, {
filters: [
{ name: 'Markdown 文件', extensions: ['md'] }
]
});
if (!result.canceled) {
return await 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 await 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();
});