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 */}
-
-
- {currentFile ? ( - - - {currentFile.replace(projectPath, "")} +
+
+ {openTabs.length === 0 ? ( + + Select a file to edit ) : ( - Select a file to edit + openTabs.map((tab) => { + const isActive = tab.id === activeTabId; + const isDirty = unsavedChanges[tab.path] !== undefined; + return ( + + ); + }) )}
@@ -719,6 +973,64 @@ export default function EditorView() { )}
+ + !open && setPendingCloseTabId(null)} + > + e.preventDefault()} + > + + Unsaved changes + + {pendingCloseTabId != null && + (() => { + const tab = openTabs.find((t) => t.id === pendingCloseTabId); + return tab + ? `Do you want to save the changes you made to "${tab.fileName}"?` + : null; + })()} + + + + + + + + +
); } diff --git a/src/components/FileTree.jsx b/src/components/FileTree.jsx index c598a79..290d114 100644 --- a/src/components/FileTree.jsx +++ b/src/components/FileTree.jsx @@ -124,17 +124,21 @@ const FileTreeNode = ({ onSelect, selectedPath, level = 0, - defaultOpen = false, projectRoot, onRefresh, onRequestCreate, gitStatusByPath, folderChangesByPath, + expandedPaths, + onToggleFolder, }) => { - const [isOpen, setIsOpen] = useState(defaultOpen); // All folders closed by default + const isDirectory = node.type === "directory"; + const isOpen = + isDirectory && expandedPaths + ? !!expandedPaths[normalizePath(node.path)] + : false; const [iconError, setIconError] = useState(false); const [isDragOver, setIsDragOver] = useState(false); - const isDirectory = node.type === "directory"; const isSelected = selectedPath === node.path; const hasChildren = isDirectory && node.children && node.children.length > 0; const parentPath = isDirectory ? node.path : dirname(node.path); @@ -145,16 +149,16 @@ const FileTreeNode = ({ const handleToggle = (e) => { e.stopPropagation(); - if (isDirectory) { - setIsOpen(!isOpen); + if (isDirectory && onToggleFolder) { + onToggleFolder(node.path); } }; const handleSelect = (e) => { e.stopPropagation(); if (isDirectory) { - if (hasChildren) { - setIsOpen(!isOpen); + if (hasChildren && onToggleFolder) { + onToggleFolder(node.path); } // Don't call onSelect for directories return; @@ -469,18 +473,20 @@ const FileTreeNode = ({ className="overflow-hidden" > {node.children.map((child) => ( - + ))} )} @@ -496,6 +502,8 @@ export default function FileTree({ projectRoot, onRefresh, gitStatusByPath, + expandedPaths = {}, + onToggleFolder, }) { // Sort files and compute which folders contain any changes const { sortedFiles, folderChangesByPath } = useMemo(() => { @@ -658,6 +666,8 @@ export default function FileTree({ onRequestCreate={openCreateDialog} gitStatusByPath={gitStatusByPath} folderChangesByPath={folderChangesByPath} + expandedPaths={expandedPaths} + onToggleFolder={onToggleFolder} /> )) )} diff --git a/src/components/LogViewer.jsx b/src/components/LogViewer.jsx index 58cceba..41ca41c 100644 --- a/src/components/LogViewer.jsx +++ b/src/components/LogViewer.jsx @@ -1,6 +1,13 @@ import React, { useEffect, useRef, useState } from "react"; import { useOutletContext } from "react-router-dom"; -import { Terminal as TerminalIcon, Send, BrushCleaning } from "lucide-react"; +import { + Terminal as TerminalIcon, + Send, + BrushCleaning, + Copy, + Download, + MoreVertical, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { Terminal } from "@xterm/xterm"; @@ -12,6 +19,13 @@ import { toast } from "react-toastify"; import { useAtomValue, useSetAtom } from "jotai"; import { logsAtom } from "@/store/atoms"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, + ContextMenuSeparator, +} from "@/components/ui/context-menu"; export default function LogViewer(props) { const context = useOutletContext(); @@ -27,6 +41,8 @@ export default function LogViewer(props) { const xtermRef = useRef(null); const fitAddonRef = useRef(null); const lastLogIndexRef = useRef(0); + const [isCopying, setIsCopying] = useState(false); + const [isExporting, setIsExporting] = useState(false); useEffect(() => { lastLogIndexRef.current = 0; @@ -159,6 +175,91 @@ export default function LogViewer(props) { }; const setAllLogs = useSetAtom(logsAtom); + const copyTextToClipboard = async (text) => { + await navigator.clipboard.writeText(text); + }; + + const handleCopyCurrentLogs = async () => { + if (!logs || logs.length === 0) { + toast.info("No console logs to copy"); + return; + } + setIsCopying(true); + try { + const text = logs.map((l) => l?.data ?? "").join(""); + await copyTextToClipboard(text); + toast.success("Console logs copied"); + } catch (err) { + console.error("Failed to copy console logs:", err); + toast.error("Failed to copy console logs"); + } finally { + setIsCopying(false); + } + }; + + const handleExportCurrentLogs = async () => { + setIsExporting(true); + try { + const res = await window.api.exportConsoleLogsProject(projectId); + if (res?.canceled) { + toast.info("Export canceled"); + return; + } + toast.success("Console logs exported"); + } catch (err) { + console.error("Failed to export console logs:", err); + toast.error("Failed to export console logs"); + } finally { + setIsExporting(false); + } + }; + + const handleCopyAllLogs = async () => { + setIsCopying(true); + try { + const [projects, allLogs] = await Promise.all([ + window.api.getProjects(), + window.api.getAllLogs(), + ]); + const parts = projects.map((p) => { + const id = p?.id; + const key = String(id); + const projectLogs = allLogs?.[key] || []; + const projectName = p?.name || `Project ${id}`; + const header = `===== Console Logs: ${projectName} (ID: ${id}) =====\n`; + const body = Array.isArray(projectLogs) + ? projectLogs.map((l) => l?.data ?? "").join("") + : ""; + return `${header}${body}`; + }); + const text = parts.join("\n"); + await copyTextToClipboard(text); + toast.success("All console logs copied"); + } catch (err) { + console.error("Failed to copy all console logs:", err); + toast.error("Failed to copy all console logs"); + } finally { + setIsCopying(false); + } + }; + + const handleExportAllLogs = async () => { + setIsExporting(true); + try { + const res = await window.api.exportConsoleLogsAll(); + if (res?.canceled) { + toast.info("Export canceled"); + return; + } + toast.success("Console logs exported (.zip)"); + } catch (err) { + console.error("Failed to export all console logs:", err); + toast.error("Failed to export all console logs"); + } finally { + setIsExporting(false); + } + }; + const handleClear = () => { lastLogIndexRef.current = 0; xtermRef.current.clear(); @@ -213,11 +314,61 @@ export default function LogViewer(props) {
-
- -
+ + + + + + + + Copy this project's console logs + + + + Export this project's console logs (.log) + + + + + Copy all projects' console logs + + + + Export all projects' console logs (.zip) + + + + + Clear this project's console logs + + +
); } diff --git a/src/components/ProjectLayout.jsx b/src/components/ProjectLayout.jsx index 99fbe27..48698de 100644 --- a/src/components/ProjectLayout.jsx +++ b/src/components/ProjectLayout.jsx @@ -240,7 +240,28 @@ export default function ProjectLayout() { }; const handleEditorFileChange = (projectId, filePath) => { - setProjectEditorStates((prev) => ({ ...prev, [projectId]: filePath })); + setProjectEditorStates((prev) => { + const existing = prev?.[projectId]; + // Backwards-compatible: if previous value was just a string, lift it into the new shape. + if (!existing || typeof existing !== "object") { + return { + ...prev, + [projectId]: { + openTabs: [], + activeTabId: null, + explorerExpanded: {}, + lastActiveFile: filePath, + }, + }; + } + return { + ...prev, + [projectId]: { + ...existing, + lastActiveFile: filePath, + }, + }; + }); }; if (project == null) return null; diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index 06679d1..2f00edb 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -1390,11 +1390,13 @@ const Sidebar = React.memo(({ onProjectsChange }) => {
- {discordInfo?.approximate_presence_count?.toLocaleString() || "-"} + {discordInfo?.approximate_presence_count?.toLocaleString("en-US") || + "-"} - {discordInfo?.approximate_member_count?.toLocaleString() || "-"} Members + {discordInfo?.approximate_member_count?.toLocaleString("en-US") || "-"}{" "} + Members
diff --git a/src/components/TunnelLogViewer.jsx b/src/components/TunnelLogViewer.jsx index b30980d..72a4a03 100644 --- a/src/components/TunnelLogViewer.jsx +++ b/src/components/TunnelLogViewer.jsx @@ -102,7 +102,7 @@ export default function TunnelLogViewer({ logs = [] }) { for (let i = lastLogIndexRef.current; i < logs.length; i++) { const log = logs[i]; - const timestamp = new Date(log.timestamp).toLocaleTimeString([], { hour12: false }); + const timestamp = new Date(log.timestamp).toLocaleTimeString("en-US", { hour12: false }); const color = colors[log.type] || colors.info; term.write(`\x1b[90m[${timestamp}]\x1b[0m ${color}${log.message}${colors.reset}\n`); diff --git a/src/components/TunnelView.jsx b/src/components/TunnelView.jsx index e140859..c7f2888 100644 --- a/src/components/TunnelView.jsx +++ b/src/components/TunnelView.jsx @@ -14,6 +14,9 @@ import { Globe, ShieldCheck, Zap, + Download, + BrushCleaning, + MoreVertical, } from "lucide-react"; import TunnelLogViewer from "./TunnelLogViewer"; import { Button } from "@/components/ui/button"; @@ -33,6 +36,14 @@ import { useAtom } from "jotai"; import { tunnelStateAtom } from "@/store/atoms"; import { useOutletContext } from "react-router-dom"; import { cn } from "@/lib/utils"; +import { normalizeDigitsToEnglish } from "@/lib/numberUtils"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, + ContextMenuSeparator, +} from "@/components/ui/context-menu"; export default function TunnelView(props) { const context = useOutletContext(); @@ -44,12 +55,16 @@ export default function TunnelView(props) { : { status: "stopped", url: null, logs: [] }; const [mode, setMode] = useState(selectedProject?.tunnelMode || "quick"); - const [port, setPort] = useState(selectedProject?.tunnelPort ?? 3000); + const [port, setPort] = useState( + selectedProject?.tunnelPort != null ? String(selectedProject.tunnelPort) : "3000" + ); const [token, setToken] = useState(selectedProject?.encryptedTunnelToken || ""); const [showAdvanced, setShowAdvanced] = useState(false); const [showHelp, setShowHelp] = useState(false); const [autoStart, setAutoStart] = useState(selectedProject?.autoStartTunnel || false); const [isProcessing, setIsProcessing] = useState(false); + const [isCopyingLogs, setIsCopyingLogs] = useState(false); + const [isExportingLogs, setIsExportingLogs] = useState(false); const [config, setConfig] = useState({ protocol: selectedProject?.tunnelConfig?.protocol || "http2", @@ -65,7 +80,7 @@ export default function TunnelView(props) { useEffect(() => { if (!selectedProject) return; setMode(selectedProject.tunnelMode || "quick"); - setPort(selectedProject.tunnelPort || 3000); + setPort(selectedProject?.tunnelPort != null ? String(selectedProject.tunnelPort) : "3000"); setToken(selectedProject.encryptedTunnelToken || ""); setShowAdvanced(false); setShowHelp(false); @@ -94,7 +109,7 @@ export default function TunnelView(props) { const updatedProject = { ...selectedProject, tunnelMode: mode, - tunnelPort: port === "" ? 3000 : port, + tunnelPort: port === "" ? 3000 : parseInt(port, 10), encryptedTunnelToken: token, tunnelConfig: config, autoStartTunnel: autoStart, @@ -104,8 +119,8 @@ export default function TunnelView(props) { const handleStart = async () => { // Port validation - const portToCheck = port === "" ? 3000 : port; - const portNum = parseInt(portToCheck); + const portToCheck = port === "" ? "3000" : port; + const portNum = parseInt(portToCheck, 10); if (isNaN(portNum) || portNum < 1 || portNum > 65535) { toast.error("Please enter a valid port number (1-65535)"); @@ -163,6 +178,112 @@ export default function TunnelView(props) { window.api.openExternal(url); }; + const formatTunnelLogEntry = (log) => { + const timestamp = log?.timestamp ? new Date(log.timestamp) : null; + const time = timestamp ? timestamp.toLocaleTimeString("en-US", { hour12: false }) : "unknown"; + return `[${time}] ${log?.message ?? ""}`; + }; + + const handleCopyCurrentTunnelLogs = async () => { + const currentLogs = projectTunnelState?.logs || []; + if (currentLogs.length === 0) { + toast.info("No tunnel logs to copy"); + return; + } + setIsCopyingLogs(true); + try { + const text = currentLogs.map(formatTunnelLogEntry).join("\n"); + await navigator.clipboard.writeText(text); + toast.success("Tunnel logs copied"); + } catch (err) { + console.error("Failed to copy tunnel logs:", err); + toast.error("Failed to copy tunnel logs"); + } finally { + setIsCopyingLogs(false); + } + }; + + const handleExportCurrentTunnelLogs = async () => { + setIsExportingLogs(true); + try { + const res = await window.api.exportTunnelLogsProject(selectedProject.id); + if (res?.canceled) { + toast.info("Export canceled"); + return; + } + toast.success("Tunnel logs exported"); + } catch (err) { + console.error("Failed to export tunnel logs:", err); + toast.error("Failed to export tunnel logs"); + } finally { + setIsExportingLogs(false); + } + }; + + const handleCopyAllTunnelLogs = async () => { + setIsCopyingLogs(true); + try { + const [projects, allTunnelLogs] = await Promise.all([ + window.api.getProjects(), + window.api.getAllTunnelLogs(), + ]); + + const parts = projects.map((p) => { + const id = p?.id; + const key = String(id); + const projectLogs = allTunnelLogs?.[key] || []; + const projectName = p?.name || `Project ${id}`; + const header = `===== Tunnel Logs: ${projectName} (ID: ${id}) =====`; + const body = projectLogs.length ? projectLogs.map(formatTunnelLogEntry).join("\n") : ""; + return body ? `${header}\n${body}` : header; + }); + + const text = parts.join("\n\n"); + await navigator.clipboard.writeText(text); + toast.success("All tunnel logs copied"); + } catch (err) { + console.error("Failed to copy all tunnel logs:", err); + toast.error("Failed to copy all tunnel logs"); + } finally { + setIsCopyingLogs(false); + } + }; + + const handleExportAllTunnelLogs = async () => { + setIsExportingLogs(true); + try { + const res = await window.api.exportTunnelLogsAll(); + if (res?.canceled) { + toast.info("Export canceled"); + return; + } + toast.success("Tunnel logs exported (.zip)"); + } catch (err) { + console.error("Failed to export all tunnel logs:", err); + toast.error("Failed to export all tunnel logs"); + } finally { + setIsExportingLogs(false); + } + }; + + const handleClearTunnelLogs = async () => { + try { + await window.api.clearTunnelLogs(selectedProject.id); + setTunnelState((prev) => ({ + ...prev, + [selectedProject.id]: { + ...(prev[selectedProject.id] || + projectTunnelState || { status: "stopped", url: null, logs: [] }), + logs: [], + }, + })); + toast.success("Tunnel logs cleared"); + } catch (err) { + console.error("Failed to clear logs:", err); + toast.error("Failed to clear tunnel logs"); + } + }; + return (
@@ -252,18 +373,14 @@ export default function TunnelView(props) {
{ - const val = e.target.value; - if (val === "") { - setPort(""); - } else { - const parsed = parseInt(val); - if (!isNaN(parsed)) { - setPort(parsed); - } - } + // Normalize any Unicode digits to ASCII so the input stays English-only. + const ascii = normalizeDigitsToEnglish(e.target.value); + const digitsOnly = ascii.replace(/[^\d]/g, ""); + setPort(digitsOnly); }} className="bg-black/20 border-white/5 h-11" placeholder="3000" @@ -614,28 +731,73 @@ export default function TunnelView(props) { Tunnel Logs - + + + + + + + + Copy this project's tunnel logs + + + + Export this project's tunnel logs (.log) + + + + + Copy all projects' tunnel logs + + + + Export all projects' tunnel logs (.zip) + + + + + Clear tunnel logs + + + diff --git a/src/editors/MonacoEditor.jsx b/src/editors/MonacoEditor.jsx index cf3d222..31b2a5f 100644 --- a/src/editors/MonacoEditor.jsx +++ b/src/editors/MonacoEditor.jsx @@ -15,6 +15,18 @@ const MonacoEditor = ({ onChange(value); }; + // When `value` changes due to file load/reload, update the editor model. + // We intentionally do not fully control Monaco on every keystroke. + useEffect(() => { + const editor = editorRef.current; + if (!editor) return; + const nextValue = value ?? ""; + const currentValue = editor.getValue(); + if (currentValue !== nextValue) { + editor.setValue(nextValue); + } + }, [value]); + useEffect(() => { if (scrollToLine != null && editorRef.current) { editorRef.current.revealLineInCenter(Math.max(1, scrollToLine)); @@ -28,7 +40,7 @@ const MonacoEditor = ({ width="100%" defaultLanguage={language} language={language} - value={value} + defaultValue={value} theme={theme} onChange={handleEditorChange} onMount={(editor, monaco) => { diff --git a/src/lib/numberUtils.js b/src/lib/numberUtils.js new file mode 100644 index 0000000..ba6f89a --- /dev/null +++ b/src/lib/numberUtils.js @@ -0,0 +1,11 @@ +// Utilities for forcing "English digits" (ASCII 0-9) regardless of device locale. +// This mainly matters for numeric inputs and any code that uses locale-based formatting. + +export function normalizeDigitsToEnglish(input) { + if (input == null) return ""; + const str = String(input); + + // Arabic-Indic: ٠١٢٣٤٥٦٧٨٩ (U+0660..U+0669) + // Eastern Arabic-Indic: ۰۱۲۳۴۵۶۷۸۹ (U+06F0..U+06F9) + return str.replace(/[٠-٩۰-۹]/g, (d) => String(Number(d))); +} diff --git a/src/store/atoms.js b/src/store/atoms.js index 62b0e08..035e99e 100644 --- a/src/store/atoms.js +++ b/src/store/atoms.js @@ -22,14 +22,28 @@ export const resourceHistoryAtom = atom({}); export const fileTreeAtom = atom([]); export const isFileTreeLoadingAtom = atom(false); -// Editor file states per project +// Editor UI state per project. +// Shape: +// { +// [projectId: string | number]: { +// openTabs: Array<{ +// id: string; +// path: string; +// fileName: string; +// language?: string; +// }>; +// activeTabId: string | null; +// explorerExpanded: Record; +// lastActiveFile?: string | null; +// }; +// } export const projectEditorStatesAtom = atom({}); // Modal states export const isAddProjectModalOpenAtom = atom(false); export const isProjectSettingsOpenAtom = atom(false); -// Unsaved changes in projects (filePath -> content) +// Unsaved changes in projects (filePath -> content). Used to mark tabs as dirty. export const unsavedChangesAtom = atom({}); // Cloudflare Tunnel State (projectId -> { status, url, logs }) diff --git a/src/store/editorState.js b/src/store/editorState.js new file mode 100644 index 0000000..bb925d5 --- /dev/null +++ b/src/store/editorState.js @@ -0,0 +1,30 @@ +import { atom } from "jotai"; +import { projectEditorStatesAtom } from "./atoms"; + +// Helpers for working with per-project editor state. + +export const makeProjectEditorState = () => ({ + openTabs: [], + activeTabId: null, + explorerExpanded: {}, + lastActiveFile: null, +}); + +// Derived atom that exposes convenient read/write helpers. Components should +// generally prefer using these helpers via useAtom/useSetAtom rather than +// manipulating projectEditorStatesAtom directly. +export const editorStateFamilyAtom = atom( + (get) => { + const all = get(projectEditorStatesAtom) || {}; + return all; + }, + (get, set, updater) => { + const prev = get(projectEditorStatesAtom) || {}; + const next = + typeof updater === "function" + ? updater(prev) + : updater; + set(projectEditorStatesAtom, next || {}); + } +); +