diff --git a/src/main.js b/src/main.js index 12d6ab3..f9305c0 100644 --- a/src/main.js +++ b/src/main.js @@ -6,6 +6,7 @@ const WordTemplateExporter = require('./wordTemplateExporter'); const PDFOperations = require('./main/PDFOperations'); const GitOperations = require('./main/GitOperations'); const { getAllowedDirectories, validatePath, resolveWritablePath, isPathAccessible } = require('./main/utils/paths'); +const fileOps = require('./main/files'); // Add MiKTeX to PATH for LaTeX support if (process.platform === 'win32') { @@ -2936,55 +2937,6 @@ ipcMain.on('select-folder', (event, type) => { } }); -// List directory contents as FileEntry[] -ipcMain.handle('list-directory', async (event, dirPath) => { - try { - const entries = fs.readdirSync(dirPath, { withFileTypes: true }); - return entries.map((dirent) => { - const fullPath = path.join(dirPath, dirent.name); - let size; - let modifiedAt; - if (!dirent.isDirectory()) { - try { - const stat = fs.statSync(fullPath); - size = stat.size; - modifiedAt = stat.mtime.toISOString(); - } catch { - // ignore stat errors - } - } - return { - name: dirent.name, - path: fullPath, - isDirectory: dirent.isDirectory(), - size, - modifiedAt, - }; - }); - } catch (err) { - return { error: err.message }; - } -}); - -// Show folder picker dialog and return selected path or null -ipcMain.handle('pick-folder', async () => { - const result = await dialog.showOpenDialog(mainWindow, { - properties: ['openDirectory'], - }); - if (result.canceled || result.filePaths.length === 0) return null; - return result.filePaths[0]; -}); - -// Show file picker dialog for markdown files and return selected path or null -ipcMain.handle('pick-file', async () => { - const result = await dialog.showOpenDialog(mainWindow, { - properties: ['openFile'], - filters: [{ name: 'Markdown', extensions: ['md', 'markdown', 'mdown', 'mkd'] }], - }); - if (result.canceled || result.filePaths.length === 0) return null; - return result.filePaths[0]; -}); - ipcMain.on('export-spreadsheet', (event, { content, format }) => { const outputFile = dialog.showSaveDialogSync(mainWindow, { defaultPath: currentFile.replace(/\.[^/.]+$/, `.${format}`), @@ -3662,6 +3614,15 @@ app.whenReady().then(() => { } createWindow(); + + // Register file ops IPC handlers + fileOps.register({ + validatePath, + resolveWritablePath, + isPathAccessible, + currentFileRef: { get current() { return currentFile; } }, + mainWindow, + }); // Handle file association on app startup // In packaged apps, process.argv structure is different: @@ -3994,152 +3955,6 @@ ipcMain.handle('select-custom-css', async (event) => { return null; }); - - -ipcMain.handle('read-file', async (event, filePath) => { - const validation = validatePath(filePath); - if (!validation.valid || !isPathAccessible(validation.resolved)) { - throw new Error(validation.error || 'Invalid file path'); - } - - return fs.readFileSync(validation.resolved, 'utf-8'); -}); - -ipcMain.handle('write-file', async (event, payload) => { - const validation = resolveWritablePath(payload?.path); - if (!validation.valid) { - throw new Error(validation.error || 'Invalid file path'); - } - - fs.mkdirSync(path.dirname(validation.resolved), { recursive: true }); - fs.writeFileSync(validation.resolved, payload?.content ?? '', 'utf-8'); - return { path: validation.resolved }; -}); - -ipcMain.handle('delete-file', async (event, filePath) => { - const validation = validatePath(filePath); - if (!validation.valid || !isPathAccessible(validation.resolved)) { - throw new Error(validation.error || 'Invalid file path'); - } - - fs.rmSync(validation.resolved, { recursive: true, force: false }); - return true; -}); - -ipcMain.handle('ensure-directory', async (event, dirPath) => { - const validation = resolveWritablePath(dirPath); - if (!validation.valid) { - throw new Error(validation.error || 'Invalid directory path'); - } - - fs.mkdirSync(validation.resolved, { recursive: true }); - return validation.resolved; -}); - -ipcMain.handle('path-exists', async (event, filePath) => { - const validation = resolveWritablePath(filePath); - return validation.valid ? fs.existsSync(validation.resolved) : false; -}); - -ipcMain.handle('is-directory', async (event, filePath) => { - const validation = validatePath(filePath); - if (!validation.valid || !isPathAccessible(validation.resolved)) { - return false; - } - - return fs.statSync(validation.resolved).isDirectory(); -}); - -ipcMain.handle('copy-path', async (event, payload) => { - const sourceValidation = validatePath(payload?.source); - const destinationValidation = resolveWritablePath(payload?.destination); - - if (!sourceValidation.valid || !isPathAccessible(sourceValidation.resolved)) { - throw new Error(sourceValidation.error || 'Invalid source path'); - } - if (!destinationValidation.valid) { - throw new Error(destinationValidation.error || 'Invalid destination path'); - } - - fs.mkdirSync(path.dirname(destinationValidation.resolved), { recursive: true }); - fs.cpSync(sourceValidation.resolved, destinationValidation.resolved, { recursive: true }); - return { source: sourceValidation.resolved, destination: destinationValidation.resolved }; -}); - -ipcMain.handle('move-path', async (event, payload) => { - const sourceValidation = validatePath(payload?.source); - const destinationValidation = resolveWritablePath(payload?.destination); - - if (!sourceValidation.valid || !isPathAccessible(sourceValidation.resolved)) { - throw new Error(sourceValidation.error || 'Invalid source path'); - } - if (!destinationValidation.valid) { - throw new Error(destinationValidation.error || 'Invalid destination path'); - } - - fs.mkdirSync(path.dirname(destinationValidation.resolved), { recursive: true }); - - try { - fs.renameSync(sourceValidation.resolved, destinationValidation.resolved); - } catch (error) { - if (error.code !== 'EXDEV') { - throw error; - } - - fs.cpSync(sourceValidation.resolved, destinationValidation.resolved, { recursive: true }); - fs.rmSync(sourceValidation.resolved, { recursive: true, force: false }); - } - - return { source: sourceValidation.resolved, destination: destinationValidation.resolved }; -}); - -// Open a file by path (from explorer panel) -ipcMain.on('open-file-path', (event, filePath) => { - try { - // Validate path to prevent traversal attacks - const validation = validatePath(filePath); - if (!validation.valid) { - console.error('[SECURITY] Invalid file path:', validation.error); - return; - } - - if (!isPathAccessible(validation.resolved)) { - return; - } - - const stat = fs.statSync(validation.resolved); - if (stat.size > MAX_FILE_SIZE) return; - currentFile = validation.resolved; - const content = fs.readFileSync(validation.resolved, 'utf-8'); - mainWindow.webContents.send('file-opened', { path: validation.resolved, content }); - } catch (err) { - console.error('open-file-path error:', err); - } -}); - -// ============================================ -// Git IPC Handlers -// ============================================ -ipcMain.handle('git-status', async () => { - const dir = currentFile ? path.dirname(currentFile) : process.cwd(); - return GitOperations.getStatus(dir); -}); - -ipcMain.handle('git-stage', async (event, { files }) => { - const dir = currentFile ? path.dirname(currentFile) : process.cwd(); - return GitOperations.stage(dir, files); -}); - -ipcMain.handle('git-commit', async (event, { message }) => { - const dir = currentFile ? path.dirname(currentFile) : process.cwd(); - return GitOperations.commit(dir, message); -}); - -ipcMain.handle('git-log', async () => { - const dir = currentFile ? path.dirname(currentFile) : process.cwd(); - return GitOperations.log(dir); -}); - // ============================================ // Snippets IPC Handlers // ============================================ diff --git a/src/main/files/binary.js b/src/main/files/binary.js new file mode 100644 index 0000000..e653e74 --- /dev/null +++ b/src/main/files/binary.js @@ -0,0 +1,13 @@ +// src/main/files/binary.js +// Binary file write handler (used by Word .docx export) +const { ipcMain } = require('electron'); +const fs = require('fs').promises; + +function register() { + ipcMain.handle('write-buffer', async (_event, { path: filePath, buffer }) => { + await fs.writeFile(filePath, Buffer.from(buffer)); + return { ok: true }; + }); +} + +module.exports = { register }; \ No newline at end of file diff --git a/src/main/files/git.js b/src/main/files/git.js new file mode 100644 index 0000000..b17bdd4 --- /dev/null +++ b/src/main/files/git.js @@ -0,0 +1,28 @@ +// src/main/files/git.js +// Git IPC handlers — thin wrapper over GitOperations +const { ipcMain } = require('electron'); +const GitOperations = require('../GitOperations'); + +function register(currentFileRef) { + ipcMain.handle('git-status', async () => { + const dir = currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd(); + return GitOperations.getStatus(dir); + }); + + ipcMain.handle('git-stage', async (_event, { files }) => { + const dir = currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd(); + return GitOperations.stage(dir, files); + }); + + ipcMain.handle('git-commit', async (_event, { message }) => { + const dir = currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd(); + return GitOperations.commit(dir, message); + }); + + ipcMain.handle('git-log', async () => { + const dir = currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd(); + return GitOperations.log(dir); + }); +} + +module.exports = { register }; \ No newline at end of file diff --git a/src/main/files/index.js b/src/main/files/index.js new file mode 100644 index 0000000..dfa4b6f --- /dev/null +++ b/src/main/files/index.js @@ -0,0 +1,161 @@ +// src/main/files/index.js +// File ops facade — registers all file-related IPC handlers +const { ipcMain, dialog, shell } = require('electron'); +const fs = require('fs'); +const path = require('path'); +const { listDirectory } = require('./search'); +const { register: registerGit } = require('./git'); +const { register: registerBinary } = require('./binary'); + +function register({ validatePath, resolveWritablePath, isPathAccessible, currentFileRef, mainWindow }) { + // list-directory (simple version) + ipcMain.handle('list-directory', async (event, dirPath) => listDirectory(dirPath)); + + // pick-folder + ipcMain.handle('pick-folder', async () => { + const result = await dialog.showOpenDialog(mainWindow, { + properties: ['openDirectory'], + }); + if (result.canceled || result.filePaths.length === 0) return null; + return result.filePaths[0]; + }); + + // pick-file + ipcMain.handle('pick-file', async () => { + const result = await dialog.showOpenDialog(mainWindow, { + properties: ['openFile'], + filters: [{ name: 'Markdown', extensions: ['md', 'markdown', 'mdown', 'mkd'] }], + }); + if (result.canceled || result.filePaths.length === 0) return null; + return result.filePaths[0]; + }); + + // read-file + ipcMain.handle('read-file', async (event, filePath) => { + const validation = validatePath(filePath); + if (!validation.valid || !isPathAccessible(validation.resolved)) { + throw new Error(validation.error || 'Invalid file path'); + } + return fs.readFileSync(validation.resolved, 'utf-8'); + }); + + // write-file + ipcMain.handle('write-file', async (event, payload) => { + const validation = resolveWritablePath(payload?.path); + if (!validation.valid) { + throw new Error(validation.error || 'Invalid file path'); + } + fs.mkdirSync(path.dirname(validation.resolved), { recursive: true }); + fs.writeFileSync(validation.resolved, payload?.content ?? '', 'utf-8'); + return { path: validation.resolved }; + }); + + // delete-file + ipcMain.handle('delete-file', async (event, filePath) => { + const validation = validatePath(filePath); + if (!validation.valid || !isPathAccessible(validation.resolved)) { + throw new Error(validation.error || 'Invalid file path'); + } + fs.rmSync(validation.resolved, { recursive: true, force: false }); + return true; + }); + + // ensure-directory + ipcMain.handle('ensure-directory', async (event, dirPath) => { + const validation = resolveWritablePath(dirPath); + if (!validation.valid) { + throw new Error(validation.error || 'Invalid directory path'); + } + fs.mkdirSync(validation.resolved, { recursive: true }); + return validation.resolved; + }); + + // path-exists + ipcMain.handle('path-exists', async (event, filePath) => { + const validation = resolveWritablePath(filePath); + return validation.valid ? fs.existsSync(validation.resolved) : false; + }); + + // is-directory + ipcMain.handle('is-directory', async (event, filePath) => { + const validation = validatePath(filePath); + if (!validation.valid || !isPathAccessible(validation.resolved)) { + return false; + } + return fs.statSync(validation.resolved).isDirectory(); + }); + + // copy-path + ipcMain.handle('copy-path', async (event, payload) => { + const sourceValidation = validatePath(payload?.source); + const destinationValidation = resolveWritablePath(payload?.destination); + + if (!sourceValidation.valid || !isPathAccessible(sourceValidation.resolved)) { + throw new Error(sourceValidation.error || 'Invalid source path'); + } + if (!destinationValidation.valid) { + throw new Error(destinationValidation.error || 'Invalid destination path'); + } + + fs.mkdirSync(path.dirname(destinationValidation.resolved), { recursive: true }); + fs.cpSync(sourceValidation.resolved, destinationValidation.resolved, { recursive: true }); + return { source: sourceValidation.resolved, destination: destinationValidation.resolved }; + }); + + // move-path + ipcMain.handle('move-path', async (event, payload) => { + const sourceValidation = validatePath(payload?.source); + const destinationValidation = resolveWritablePath(payload?.destination); + + if (!sourceValidation.valid || !isPathAccessible(sourceValidation.resolved)) { + throw new Error(sourceValidation.error || 'Invalid source path'); + } + if (!destinationValidation.valid) { + throw new Error(destinationValidation.error || 'Invalid destination path'); + } + + fs.mkdirSync(path.dirname(destinationValidation.resolved), { recursive: true }); + + try { + fs.renameSync(sourceValidation.resolved, destinationValidation.resolved); + } catch (error) { + if (error.code !== 'EXDEV') { + throw error; + } + fs.cpSync(sourceValidation.resolved, destinationValidation.resolved, { recursive: true }); + fs.rmSync(sourceValidation.resolved, { recursive: true, force: false }); + } + + return { source: sourceValidation.resolved, destination: destinationValidation.resolved }; + }); + + // open-file-path + ipcMain.on('open-file-path', (event, filePath) => { + try { + const validation = validatePath(filePath); + if (!validation.valid) { + console.error('[SECURITY] Invalid file path:', validation.error); + return; + } + + if (!isPathAccessible(validation.resolved)) { + return; + } + + const stat = fs.statSync(validation.resolved); + if (stat.size > 50 * 1024 * 1024) return; + const content = fs.readFileSync(validation.resolved, 'utf-8'); + // Emit set-current-file back to main process so it updates its currentFile variable + mainWindow.webContents.send('set-current-file', validation.resolved); + mainWindow.webContents.send('file-opened', { path: validation.resolved, content }); + } catch (err) { + console.error('open-file-path error:', err); + } + }); + + // Register sub-modules + registerGit(currentFileRef); + registerBinary(); +} + +module.exports = { register }; \ No newline at end of file diff --git a/src/main/files/search.js b/src/main/files/search.js new file mode 100644 index 0000000..d2f757b --- /dev/null +++ b/src/main/files/search.js @@ -0,0 +1,35 @@ +// src/main/files/search.js +// File search + basic list ops +const fs = require('fs'); +const path = require('path'); + +async function listDirectory(dirPath) { + try { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + return entries.map((dirent) => { + const fullPath = path.join(dirPath, dirent.name); + let size; + let modifiedAt; + if (!dirent.isDirectory()) { + try { + const stat = fs.statSync(fullPath); + size = stat.size; + modifiedAt = stat.mtime.toISOString(); + } catch { + // ignore stat errors + } + } + return { + name: dirent.name, + path: fullPath, + isDirectory: dirent.isDirectory(), + size, + modifiedAt, + }; + }); + } catch (err) { + return { error: err.message }; + } +} + +module.exports = { listDirectory }; \ No newline at end of file