diff --git a/electron/ipc/handlers.js b/electron/ipc/handlers.js index 5906ee5..6487669 100644 --- a/electron/ipc/handlers.js +++ b/electron/ipc/handlers.js @@ -1,6 +1,7 @@ import { ipcMain, dialog, BrowserWindow, shell, app } from "electron"; import fs from "fs/promises"; import path from "path"; +import AdmZip from "adm-zip"; import AutoLaunch from "auto-launch"; import { getProjects, @@ -24,6 +25,7 @@ import { restartProject, getRunningProjects, getProjectLogs, + getAllProjectLogs, writeToProcess, getProjectStats, getProjectStartTime, @@ -34,6 +36,7 @@ import { startTunnel, stopTunnel, getTunnelLogs, + getAllTunnelLogs, clearTunnelLogs, getTunnelStatus, } from "../services/tunnelManager.js"; @@ -65,6 +68,19 @@ const appLauncher = new AutoLaunch({ path: process.execPath, }); +/** + * Create a filesystem-safe filename segment for Windows/macOS/Linux. + * Removes characters that are invalid on Windows and trims length. + */ +const sanitizeFileName = (value) => { + const name = typeof value === "string" ? value : ""; + const cleaned = name + .replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_") + .replace(/\s+/g, " ") + .trim(); + return cleaned.length > 0 ? cleaned.slice(0, 120) : "project"; +}; + export const registerHandlers = () => { // Helper to log all IPC calls const originalHandle = ipcMain.handle.bind(ipcMain); @@ -289,11 +305,89 @@ export const registerHandlers = () => { ipcMain.handle("logs:get", async (_, id) => { return getProjectLogs(id); }); + ipcMain.handle("logs:getAll", async () => { + return getAllProjectLogs(); + }); ipcMain.handle("logs:clear", async (_, id) => { clearProjectLogs(id); return true; }); + ipcMain.handle("logs:exportProject", async (_, projectId) => { + const numericId = Number(projectId); + if (!Number.isFinite(numericId)) { + throw new Error("Invalid projectId for logs export"); + } + + const window = BrowserWindow.getFocusedWindow() || global.mainWindow || null; + const projects = await getProjects(); + const project = projects.find((p) => Number(p.id) === numericId) || null; + const projectName = project?.name || `Project ${numericId}`; + + const fileName = `${sanitizeFileName(projectName)}-${numericId}.log`; + const { canceled, filePath } = await dialog.showSaveDialog(window, { + title: `Export console logs - ${projectName}`, + defaultPath: fileName, + filters: [ + { name: "Log Files", extensions: ["log"] }, + { name: "All Files", extensions: ["*"] }, + ], + }); + + if (canceled || !filePath) { + return { canceled: true }; + } + + const logs = getProjectLogs(numericId); + const header = `===== Console Logs: ${projectName} (ID: ${numericId}) =====\n`; + const body = Array.isArray(logs) ? logs.map((l) => l?.data ?? "").join("") : ""; + const text = `${header}${body}`; + + await fs.writeFile(filePath, text, "utf8"); + return { success: true, path: filePath }; + }); + + ipcMain.handle("logs:exportAll", async () => { + const window = BrowserWindow.getFocusedWindow() || global.mainWindow || null; + + const projects = await getProjects(); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const defaultName = `console-logs-all-${timestamp}.zip`; + + const { canceled, filePath } = await dialog.showSaveDialog(window, { + title: "Export console logs (.zip)", + defaultPath: defaultName, + filters: [ + { name: "Zip Files", extensions: ["zip"] }, + { name: "All Files", extensions: ["*"] }, + ], + }); + + if (canceled || !filePath) { + return { canceled: true }; + } + + const zip = new AdmZip(); + + for (const project of projects) { + const numericId = Number(project?.id); + if (!Number.isFinite(numericId)) continue; + + const projectName = project?.name || `Project ${numericId}`; + const logs = getProjectLogs(numericId); + const header = `===== Console Logs: ${projectName} (ID: ${numericId}) =====\n`; + const body = Array.isArray(logs) ? logs.map((l) => l?.data ?? "").join("") : ""; + const text = `${header}${body}`; + + const fileName = `${sanitizeFileName(projectName)}-${numericId}.log`; + zip.addFile(fileName, Buffer.from(text, "utf8")); + } + + const out = zip.toBuffer(); + await fs.writeFile(filePath, out); + return { success: true, path: filePath }; + }); + ipcMain.handle("project:getStats", async (_, id) => { return getProjectStats(id); }); @@ -314,6 +408,9 @@ export const registerHandlers = () => { ipcMain.handle("tunnel:getLogs", async (_, id) => { return getTunnelLogs(id); }); + ipcMain.handle("tunnel:getAllLogs", async () => { + return getAllTunnelLogs(); + }); ipcMain.handle("tunnel:clearLogs", async (_, id) => { return clearTunnelLogs(id); @@ -323,6 +420,96 @@ export const registerHandlers = () => { return getTunnelStatus(id); }); + ipcMain.handle("tunnel:exportProject", async (_, projectId) => { + const numericId = Number(projectId); + if (!Number.isFinite(numericId)) { + throw new Error("Invalid projectId for tunnel logs export"); + } + + const window = BrowserWindow.getFocusedWindow() || global.mainWindow || null; + const projects = await getProjects(); + const project = projects.find((p) => Number(p.id) === numericId) || null; + const projectName = project?.name || `Project ${numericId}`; + + const fileName = `${sanitizeFileName(projectName)}-${numericId}.log`; + const { canceled, filePath } = await dialog.showSaveDialog(window, { + title: `Export tunnel logs - ${projectName}`, + defaultPath: fileName, + filters: [ + { name: "Log Files", extensions: ["log"] }, + { name: "All Files", extensions: ["*"] }, + ], + }); + + if (canceled || !filePath) { + return { canceled: true }; + } + + const logs = getTunnelLogs(numericId); + const header = `===== Tunnel Logs: ${projectName} (ID: ${numericId}) =====\n`; + + const formatLine = (entry) => { + const timestamp = entry?.timestamp ? new Date(entry.timestamp) : null; + const time = timestamp ? timestamp.toLocaleTimeString("en-US", { hour12: false }) : "unknown"; + const message = entry?.message ?? ""; + return `[${time}] ${message}\n`; + }; + + const body = Array.isArray(logs) ? logs.map(formatLine).join("") : ""; + const text = `${header}${body}`; + + await fs.writeFile(filePath, text, "utf8"); + return { success: true, path: filePath }; + }); + + ipcMain.handle("tunnel:exportAll", async () => { + const window = BrowserWindow.getFocusedWindow() || global.mainWindow || null; + + const projects = await getProjects(); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const defaultName = `tunnel-logs-all-${timestamp}.zip`; + + const { canceled, filePath } = await dialog.showSaveDialog(window, { + title: "Export tunnel logs (.zip)", + defaultPath: defaultName, + filters: [ + { name: "Zip Files", extensions: ["zip"] }, + { name: "All Files", extensions: ["*"] }, + ], + }); + + if (canceled || !filePath) { + return { canceled: true }; + } + + const formatLine = (entry) => { + const timestamp = entry?.timestamp ? new Date(entry.timestamp) : null; + const time = timestamp ? timestamp.toLocaleTimeString("en-US", { hour12: false }) : "unknown"; + const message = entry?.message ?? ""; + return `[${time}] ${message}\n`; + }; + + const zip = new AdmZip(); + + for (const project of projects) { + const numericId = Number(project?.id); + if (!Number.isFinite(numericId)) continue; + + const projectName = project?.name || `Project ${numericId}`; + const logs = getTunnelLogs(numericId); + const header = `===== Tunnel Logs: ${projectName} (ID: ${numericId}) =====\n`; + const body = Array.isArray(logs) ? logs.map(formatLine).join("") : ""; + const text = `${header}${body}`; + + const fileName = `${sanitizeFileName(projectName)}-${numericId}.log`; + zip.addFile(fileName, Buffer.from(text, "utf8")); + } + + const out = zip.toBuffer(); + await fs.writeFile(filePath, out); + return { success: true, path: filePath }; + }); + // Dialogs ipcMain.handle("dialog:openDirectory", async () => { const { canceled, filePaths } = await dialog.showOpenDialog({ diff --git a/electron/preload.js b/electron/preload.js index 6f4e8c2..72c6d78 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -50,7 +50,10 @@ contextBridge.exposeInMainWorld("api", { stopWatchingFolder: (folderPath) => ipcRenderer.invoke("watcher:stop", folderPath), getLogs: (id) => ipcRenderer.invoke("logs:get", id), + getAllLogs: () => ipcRenderer.invoke("logs:getAll"), clearLogs: (id) => ipcRenderer.invoke("logs:clear", id), + exportConsoleLogsProject: (id) => ipcRenderer.invoke("logs:exportProject", id), + exportConsoleLogsAll: () => ipcRenderer.invoke("logs:exportAll"), isAutoLaunchEnabled: () => ipcRenderer.invoke("app:isAutoLaunchEnabled"), enableAutoLaunch: () => ipcRenderer.invoke("app:enableAutoLaunch"), @@ -181,6 +184,9 @@ contextBridge.exposeInMainWorld("api", { clearTunnelLogs: (id) => ipcRenderer.invoke("tunnel:clearLogs", id), getTunnelStatus: (id) => ipcRenderer.invoke("tunnel:getStatus", id), getTunnelLogs: (id) => ipcRenderer.invoke("tunnel:getLogs", id), + getAllTunnelLogs: () => ipcRenderer.invoke("tunnel:getAllLogs"), + exportTunnelLogsProject: (id) => ipcRenderer.invoke("tunnel:exportProject", id), + exportTunnelLogsAll: () => ipcRenderer.invoke("tunnel:exportAll"), getSettings: () => ipcRenderer.invoke("settings:get"), updateSettings: (settings) => ipcRenderer.invoke("settings:update", settings), getUserDataPath: () => ipcRenderer.invoke("database:getUserDataPath"), diff --git a/electron/services/projectsManager.js b/electron/services/projectsManager.js index dc16854..31f9863 100644 --- a/electron/services/projectsManager.js +++ b/electron/services/projectsManager.js @@ -202,6 +202,11 @@ async function isProcessGroupAlive(rootPid, platform) { export const getRunningProjects = () => Object.keys(runningRuntimes); export const getProjectLogs = (id) => logHistory[id] || []; +/** + * Get all in-memory project console logs. + * Returned object shape: { [projectId: string]: Array<{data,type,timestamp,projectId}> } + */ +export const getAllProjectLogs = () => ({ ...logHistory }); export const getProjectStartTime = (id) => runningRuntimes[id] ? runningRuntimes[id].startTime : null; diff --git a/electron/services/tunnelManager.js b/electron/services/tunnelManager.js index c4936c1..5799e15 100644 --- a/electron/services/tunnelManager.js +++ b/electron/services/tunnelManager.js @@ -336,6 +336,12 @@ export const getTunnelLogs = (projectId) => { return tunnelLogs[projectId] || []; }; +/** + * Get all in-memory tunnel logs per project. + * Returned object shape: { [projectId: string]: Array<{message,type,timestamp}> } + */ +export const getAllTunnelLogs = () => ({ ...tunnelLogs }); + /** * Clear tunnel logs for a project * @param {number} projectId - Project ID diff --git a/package.json b/package.json index 6aa597a..21f4d11 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "selfhost-helper", - "version": "0.35.1", + "version": "0.38.0", "description": "Node.js Project Manager", "main": "electron/main.js", "type": "module", @@ -11,7 +11,7 @@ "build": "vite build && electron-builder", "build:dev": "vite build && cross-env NODE_ENV=development electron-builder --config.extraMetadata.appId=com.selfhosthelper.dev --config.directories.output=release-dev --config.productName=\"SelfHost Helper Dev\"", "build:prod": "vite build && cross-env NODE_ENV=production electron-builder", - "publish:prod": "dotenv -e .env -- cross-env NODE_ENV=production electron-builder --publish always", + "publish:prod": "vite build && dotenv -e .env -- cross-env NODE_ENV=production electron-builder --publish always", "build:web": "vite build", "postinstall": "electron-builder install-app-deps", "lint": "eslint .", @@ -117,7 +117,6 @@ "dependencies": { "@hello-pangea/dnd": "^18.0.1", "@monaco-editor/react": "latest", - "monaco-editor": "latest", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", @@ -130,16 +129,18 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", + "adm-zip": "^0.5.16", "auto-launch": "latest", - "electron-updater": "^4.0.0", "chalk": "^5.6.2", "chokidar": "latest", "class-variance-authority": "latest", "cloudflared": "latest", "clsx": "latest", + "electron-updater": "^4.0.0", "framer-motion": "latest", "jotai": "^2.16.1", "lucide-react": "latest", + "monaco-editor": "latest", "node-addon-api": "^8.5.0", "pg-hstore": "^2.3.4", "pidusage": "^4.0.1", diff --git a/src/components/EditorView.jsx b/src/components/EditorView.jsx index 7c452ef..239b086 100644 --- a/src/components/EditorView.jsx +++ b/src/components/EditorView.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useRef } from "react"; +import React, { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useOutletContext } from "react-router-dom"; import { FileCode, @@ -14,12 +14,20 @@ import { motion } from "framer-motion"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { toast } from "react-toastify"; -import { useAtom } from "jotai"; +import { useAtom, useSetAtom } from "jotai"; import * as atoms from "@/store/atoms"; import FileTree from "@/components/FileTree"; import SearchPanel from "@/components/SearchPanel"; import GitPanel from "@/components/GitPanel"; import MonacoEditor from "@/editors/MonacoEditor"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from "@/components/ui/dialog"; const API = window.api; @@ -91,7 +99,25 @@ export default function EditorView() { const fileTree = context?.fileTree ?? []; const isFileTreeLoading = context?.isFileTreeLoading ?? false; const projectEditorStates = context?.projectEditorStates ?? {}; - const initialFile = project ? projectEditorStates[project.id] : null; + const rawProjectState = project ? projectEditorStates[project.id] : null; + const initialProjectState = useMemo(() => { + if (!project) return null; + if (!rawProjectState || typeof rawProjectState !== "object") { + return { + openTabs: [], + activeTabId: null, + explorerExpanded: {}, + lastActiveFile: rawProjectState || null, + }; + } + return { + openTabs: Array.isArray(rawProjectState.openTabs) ? rawProjectState.openTabs : [], + activeTabId: + typeof rawProjectState.activeTabId === "string" ? rawProjectState.activeTabId : null, + explorerExpanded: rawProjectState.explorerExpanded || {}, + lastActiveFile: rawProjectState.lastActiveFile || null, + }; + }, [project, rawProjectState]); const onFileSelect = context?.handleEditorFileChange ? (path) => context.handleEditorFileChange(project?.id, path) : () => {}; @@ -99,7 +125,7 @@ export default function EditorView() { ? () => project?.path && context.loadFileTree(project.path) : () => {}; const [editorContent, setEditorContent] = useState(""); - const [currentFile, setCurrentFile] = useState(null); + const [currentFile, setCurrentFile] = useState(initialProjectState?.lastActiveFile || null); const [isFileLoading, setIsFileLoading] = useState(false); const [fileLoadError, setFileLoadError] = useState(null); const [unsavedChanges, setUnsavedChanges] = useAtom(atoms.unsavedChangesAtom); @@ -107,10 +133,20 @@ export default function EditorView() { const [gitOpen, setGitOpen] = useState(false); const [scrollToLine, setScrollToLine] = useState(null); const [gitStatusByPath, setGitStatusByPath] = useState({}); + const [openTabs, setOpenTabs] = useState(initialProjectState?.openTabs || []); + const [activeTabId, setActiveTabId] = useState(initialProjectState?.activeTabId || null); + const [explorerExpanded, setExplorerExpanded] = useState( + initialProjectState?.explorerExpanded || {} + ); + const [pendingCloseTabId, setPendingCloseTabId] = useState(null); + const [isSavingCloseTab, setIsSavingCloseTab] = useState(false); + + const setProjectEditorStates = useSetAtom(atoms.projectEditorStatesAtom); const editorContentRef = useRef(editorContent); const currentFileRef = useRef(currentFile); const unsavedChangesRef = useRef(unsavedChanges); + const suppressReloadForPathRef = useRef({}); useEffect(() => { editorContentRef.current = editorContent; @@ -124,6 +160,20 @@ export default function EditorView() { unsavedChangesRef.current = unsavedChanges; }, [unsavedChanges]); + // Persist per-project editor UI state whenever relevant pieces change. + useEffect(() => { + if (!projectId) return; + setProjectEditorStates((prev) => ({ + ...prev, + [projectId]: { + openTabs, + activeTabId, + explorerExpanded, + lastActiveFile: currentFile || null, + }, + })); + }, [projectId, openTabs, activeTabId, explorerExpanded, currentFile, setProjectEditorStates]); + const normalizePath = (p) => (p || "").replace(/\\/g, "/"); const applyGitStatusToMap = useCallback( @@ -264,16 +314,22 @@ export default function EditorView() { searchPanelHeight, ]); + // On project change, ensure initial file content is loaded. + // Without this, we may restore `currentFile` from state but keep `editorContent` + // at the initial empty string, resulting in an empty editor on re-open. useEffect(() => { - if (initialFile) { - if (initialFile !== currentFile) { - loadFile(initialFile); + if (!projectId) return; + const targetFile = initialProjectState?.lastActiveFile; + if (targetFile) { + const shouldLoad = targetFile !== currentFileRef.current || editorContentRef.current === ""; + if (shouldLoad) { + loadFile(targetFile); } } else { setCurrentFile(null); setEditorContent(""); } - }, [projectId, initialFile]); + }, [projectId, initialProjectState?.lastActiveFile]); useEffect(() => { loadGitStatus(); @@ -290,11 +346,17 @@ export default function EditorView() { // Sync with external file changes: reload current file if it changed on disk useEffect(() => { - const unsub = API.onFileChange?.(({ filePath }) => { + const unsub = API.onFileChange?.(({ event, filePath }) => { const current = currentFileRef.current; if (!current || !filePath) return; const norm = (p) => (p || "").replace(/\\/g, "/"); if (norm(filePath) !== norm(current)) return; + + const suppressedAt = suppressReloadForPathRef.current[current]; + if (suppressedAt && Date.now() - suppressedAt < 2000) { + return; + } + if (unsavedChangesRef.current[current] !== undefined) { toast.info("File changed on disk. Save or reload to see changes."); return; @@ -309,6 +371,28 @@ export default function EditorView() { setFileLoadError(null); setCurrentFile(filePath); + // Ensure tab exists for this file + setOpenTabs((prev) => { + const existing = prev.find((t) => t.path === filePath); + if (existing) { + setActiveTabId(existing.id); + return prev; + } + const id = filePath; + const fileName = filePath.split(/[/\\]/).pop() || filePath; + const next = [ + ...prev, + { + id, + path: filePath, + fileName, + language: getLanguageFromPath(filePath), + }, + ]; + setActiveTabId(id); + return next; + }); + // Use ref so callers (e.g. onFileChange) always see latest unsaved state if (unsavedChangesRef.current[filePath] !== undefined) { setEditorContent(unsavedChangesRef.current[filePath]); @@ -353,6 +437,112 @@ export default function EditorView() { } }; + const handleToggleFolder = useCallback( + (folderPath) => { + const norm = normalizePath(folderPath); + setExplorerExpanded((prev) => ({ + ...prev, + [norm]: !prev[norm], + })); + }, + [setExplorerExpanded] + ); + + const handleSelectTab = (tabId) => { + const tab = openTabs.find((t) => t.id === tabId); + if (!tab) return; + setActiveTabId(tabId); + if (tab.path !== currentFileRef.current) { + loadFile(tab.path); + } + }; + + const performCloseTab = useCallback( + (tabId) => { + const tab = openTabs.find((t) => t.id === tabId); + if (!tab) return; + const nextTabs = openTabs.filter((t) => t.id !== tabId); + setOpenTabs(nextTabs); + setUnsavedChanges((prev) => { + const updated = { ...prev }; + delete updated[tab.path]; + return updated; + }); + // Keep watcher dirty-check in sync immediately. + // This avoids a race where chokidar fires before the Jotai atom propagates. + delete unsavedChangesRef.current?.[tab.path]; + if (activeTabId === tabId) { + const idx = openTabs.findIndex((t) => t.id === tabId); + const replacement = nextTabs[idx] ?? nextTabs[idx - 1] ?? null; + setActiveTabId(replacement ? replacement.id : null); + if (replacement) { + if (replacement.path !== currentFileRef.current) { + loadFile(replacement.path); + } + } else { + setCurrentFile(null); + setEditorContent(""); + } + } + setPendingCloseTabId(null); + }, + [openTabs, activeTabId, setUnsavedChanges] + ); + + const handleCloseTab = (tabId) => { + const tab = openTabs.find((t) => t.id === tabId); + if (tab && unsavedChangesRef.current[tab.path] !== undefined) { + setPendingCloseTabId(tabId); + return; + } + performCloseTab(tabId); + }; + + const handleConfirmCloseSave = useCallback(async () => { + if (pendingCloseTabId == null) return; + const tab = openTabs.find((t) => t.id === pendingCloseTabId); + if (!tab) { + setPendingCloseTabId(null); + return; + } + setIsSavingCloseTab(true); + const content = + currentFileRef.current === tab.path + ? editorContentRef.current + : unsavedChangesRef.current[tab.path]; + const contentToWrite = content ?? ""; + try { + const success = await API.writeFile(tab.path, contentToWrite); + if (success) { + toast.success("File saved"); + performCloseTab(pendingCloseTabId); + } else { + toast.error("Failed to save file"); + setPendingCloseTabId(null); + } + } catch (err) { + const isDeleted = + (err?.message && err.message.includes("no longer exists")) || err?.code === "ENOENT"; + if (isDeleted) { + toast.info("File was deleted or moved; cannot save."); + } else { + toast.error(`Error saving file: ${err?.message || "Unknown error"}`); + } + setPendingCloseTabId(null); + } finally { + setIsSavingCloseTab(false); + } + }, [pendingCloseTabId, openTabs, performCloseTab]); + + const handleConfirmCloseDiscard = useCallback(() => { + if (pendingCloseTabId == null) return; + performCloseTab(pendingCloseTabId); + }, [pendingCloseTabId, performCloseTab]); + + const handleConfirmCloseCancel = useCallback(() => { + setPendingCloseTabId(null); + }, []); + const handleOpenSearchResult = (filePath, lineNumber) => { const current = currentFileRef.current; if (normalizePath(filePath) !== normalizePath(current)) { @@ -371,9 +561,15 @@ export default function EditorView() { if (fileToSave && contentToSave !== undefined) { try { + // Prevent the file watcher from re-loading the editor immediately after + // our own save (which can look like the file "re-opens" and disrupt cursor). + suppressReloadForPathRef.current[fileToSave] = Date.now(); + const success = await API.writeFile(fileToSave, contentToSave); if (success) { toast.success("File saved"); + // Ensure watcher sees this as clean right away. + delete unsavedChangesRef.current?.[fileToSave]; // Remove from unsaved changes setUnsavedChanges((prev) => { const next = { ...prev }; @@ -396,8 +592,15 @@ export default function EditorView() { }, [setUnsavedChanges]); const handleEditorChange = (newContent) => { - setEditorContent(newContent); + // IMPORTANT: + // Do not update `editorContent` state on every keystroke. + // Updating the controlled Monaco `value` frequently can cause Monaco + // to reset cursor/selection and sometimes drop keystrokes. + // We keep the latest text in refs + dirty atom instead. + editorContentRef.current = newContent; if (currentFile) { + // Keep watcher dirty-check in sync immediately. + unsavedChangesRef.current[currentFile] = newContent; setUnsavedChanges((prev) => ({ ...prev, [currentFile]: newContent, @@ -408,16 +611,35 @@ export default function EditorView() { // Bind Ctrl/Cmd+S to save the current file (capture phase to prevent browser default) useEffect(() => { const onKeyDown = (e) => { - const isSave = (e.ctrlKey || e.metaKey) && (e.key === "s" || e.key === "S"); - if (isSave) { + const isCloseTab = (e.ctrlKey || e.metaKey) && (e.key === "w" || e.key === "W"); + if (isCloseTab) { e.preventDefault(); - handleSaveFile(); + if (activeTabId) { + handleCloseTab(activeTabId); + } + return; + } + + const isNextTab = + (e.ctrlKey || e.metaKey) && !e.shiftKey && (e.key === "Tab" || e.key === "tab"); + const isPrevTab = + (e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === "Tab" || e.key === "tab"); + if ((isNextTab || isPrevTab) && openTabs.length > 0) { + e.preventDefault(); + const idx = openTabs.findIndex((t) => t.id === activeTabId); + if (idx === -1) return; + const delta = isNextTab ? 1 : -1; + const nextIdx = (idx + delta + openTabs.length) % openTabs.length; + const nextTab = openTabs[nextIdx]; + if (nextTab) { + handleSelectTab(nextTab.id); + } } }; window.addEventListener("keydown", onKeyDown, { capture: true }); return () => window.removeEventListener("keydown", onKeyDown, { capture: true }); - }, [handleSaveFile]); + }, [activeTabId, openTabs, handleCloseTab]); if (!project) return null; @@ -512,21 +734,53 @@ export default function EditorView() { projectRoot={projectPath} onRefresh={onRefreshFileTree} gitStatusByPath={gitStatusByPath} + expandedPaths={explorerExpanded} + onToggleFolder={handleToggleFolder} /> )} {/* Editor Main */}