From eabb52c375bfd0e1d1a7440709a9b8f0c6ab1723 Mon Sep 17 00:00:00 2001 From: AboMeezO Date: Wed, 18 Mar 2026 08:21:54 +0300 Subject: [PATCH 1/6] Update version to 0.36.0 and enhance EditorView component with improved project state management, including tab handling and folder expansion features. Refactor FileTree and ProjectLayout components for better integration with the new editor state structure. --- package.json | 2 +- src/components/EditorView.jsx | 320 +++++++++++++++++++++++++++++-- src/components/FileTree.jsx | 48 +++-- src/components/ProjectLayout.jsx | 23 ++- src/store/atoms.js | 18 +- src/store/editorState.js | 30 +++ 6 files changed, 402 insertions(+), 39 deletions(-) create mode 100644 src/store/editorState.js diff --git a/package.json b/package.json index 6aa597a..60eaf73 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "selfhost-helper", - "version": "0.35.1", + "version": "0.36.0", "description": "Node.js Project Manager", "main": "electron/main.js", "type": "module", diff --git a/src/components/EditorView.jsx b/src/components/EditorView.jsx index 7c452ef..0f85a1c 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,6 +133,15 @@ 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); @@ -124,6 +159,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 +313,18 @@ export default function EditorView() { searchPanelHeight, ]); + // On project change, ensure initial file (if any) is loaded and tabs are restored. useEffect(() => { - if (initialFile) { - if (initialFile !== currentFile) { - loadFile(initialFile); + if (!projectId) return; + if (initialProjectState?.lastActiveFile) { + if (initialProjectState.lastActiveFile !== currentFileRef.current) { + loadFile(initialProjectState.lastActiveFile); } } else { setCurrentFile(null); setEditorContent(""); } - }, [projectId, initialFile]); + }, [projectId]); useEffect(() => { loadGitStatus(); @@ -309,6 +360,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 +426,109 @@ 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; + }); + 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)) { @@ -412,12 +588,38 @@ export default function EditorView() { if (isSave) { e.preventDefault(); handleSaveFile(); + return; + } + + const isCloseTab = (e.ctrlKey || e.metaKey) && (e.key === "w" || e.key === "W"); + if (isCloseTab) { + e.preventDefault(); + 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]); + }, [handleSaveFile, activeTabId, openTabs, handleCloseTab]); if (!project) return null; @@ -512,21 +714,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 +953,60 @@ 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/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/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 || {}); + } +); + From ab78682b6a50ce523d8d4eafd114f77f3dc9072e Mon Sep 17 00:00:00 2001 From: AboMeezO Date: Thu, 19 Mar 2026 00:18:13 +0300 Subject: [PATCH 2/6] Refactor EditorView and MonacoEditor components to improve file loading and state management. Ensure editor content updates correctly on project changes and optimize performance by reducing unnecessary state updates. Enhance dialog handling for unsaved changes and synchronize editor model with loaded file content. --- src/components/EditorView.jsx | 46 +++++++++++++++++++++++++---------- src/editors/MonacoEditor.jsx | 14 ++++++++++- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/components/EditorView.jsx b/src/components/EditorView.jsx index 0f85a1c..d5b60b8 100644 --- a/src/components/EditorView.jsx +++ b/src/components/EditorView.jsx @@ -313,18 +313,22 @@ export default function EditorView() { searchPanelHeight, ]); - // On project change, ensure initial file (if any) is loaded and tabs are restored. + // 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 (!projectId) return; - if (initialProjectState?.lastActiveFile) { - if (initialProjectState.lastActiveFile !== currentFileRef.current) { - loadFile(initialProjectState.lastActiveFile); + const targetFile = initialProjectState?.lastActiveFile; + if (targetFile) { + const shouldLoad = targetFile !== currentFileRef.current || editorContentRef.current === ""; + if (shouldLoad) { + loadFile(targetFile); } } else { setCurrentFile(null); setEditorContent(""); } - }, [projectId]); + }, [projectId, initialProjectState?.lastActiveFile]); useEffect(() => { loadGitStatus(); @@ -457,6 +461,9 @@ export default function EditorView() { 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; @@ -550,6 +557,8 @@ export default function EditorView() { 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 }; @@ -572,8 +581,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, @@ -954,7 +970,10 @@ export default function EditorView() {
- !open && setPendingCloseTabId(null)}> + !open && setPendingCloseTabId(null)} + > 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; - })()} + {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/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) => { From 12b070eeb7fc00ce30a6c3c12ba67722c504f688 Mon Sep 17 00:00:00 2001 From: AboMeezO Date: Thu, 19 Mar 2026 00:21:50 +0300 Subject: [PATCH 3/6] Enhance EditorView component to suppress file reloads immediately after saving, preventing cursor disruption. Update file change handling to include event detection and improve state management for unsaved changes. --- src/components/EditorView.jsx | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/components/EditorView.jsx b/src/components/EditorView.jsx index d5b60b8..239b086 100644 --- a/src/components/EditorView.jsx +++ b/src/components/EditorView.jsx @@ -146,6 +146,7 @@ export default function EditorView() { const editorContentRef = useRef(editorContent); const currentFileRef = useRef(currentFile); const unsavedChangesRef = useRef(unsavedChanges); + const suppressReloadForPathRef = useRef({}); useEffect(() => { editorContentRef.current = editorContent; @@ -345,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; @@ -554,6 +561,10 @@ 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"); @@ -600,13 +611,6 @@ 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) { - e.preventDefault(); - handleSaveFile(); - return; - } - const isCloseTab = (e.ctrlKey || e.metaKey) && (e.key === "w" || e.key === "W"); if (isCloseTab) { e.preventDefault(); @@ -635,7 +639,7 @@ export default function EditorView() { window.addEventListener("keydown", onKeyDown, { capture: true }); return () => window.removeEventListener("keydown", onKeyDown, { capture: true }); - }, [handleSaveFile, activeTabId, openTabs, handleCloseTab]); + }, [activeTabId, openTabs, handleCloseTab]); if (!project) return null; From 95357be4871390d78a53c320c4532d3bb3385d3e Mon Sep 17 00:00:00 2001 From: AboMeezO Date: Thu, 19 Mar 2026 00:28:27 +0300 Subject: [PATCH 4/6] Update version to 0.37.0 and enhance number formatting in Sidebar and TunnelLogViewer components to ensure consistent locale usage. Refactor TunnelView component to handle port input as a string and normalize digit input for better user experience. --- package.json | 2 +- src/components/Sidebar.jsx | 6 ++++-- src/components/TunnelLogViewer.jsx | 2 +- src/components/TunnelView.jsx | 29 ++++++++++++++--------------- src/lib/numberUtils.js | 11 +++++++++++ 5 files changed, 31 insertions(+), 19 deletions(-) create mode 100644 src/lib/numberUtils.js diff --git a/package.json b/package.json index 60eaf73..2b3a49f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "selfhost-helper", - "version": "0.36.0", + "version": "0.37.0", "description": "Node.js Project Manager", "main": "electron/main.js", "type": "module", 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..0193d26 100644 --- a/src/components/TunnelView.jsx +++ b/src/components/TunnelView.jsx @@ -33,6 +33,7 @@ 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"; export default function TunnelView(props) { const context = useOutletContext(); @@ -44,7 +45,9 @@ 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); @@ -65,7 +68,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 +97,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 +107,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)"); @@ -252,18 +255,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" 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))); +} From 8c5bccc1befbde084dbdd70ea7fd251d3f22efed Mon Sep 17 00:00:00 2001 From: AboMeezO Date: Thu, 19 Mar 2026 01:04:13 +0300 Subject: [PATCH 5/6] Update version to 0.38.0, add log export functionality for both project and tunnel logs, and enhance log management with new API methods for retrieving all logs. Introduce context menus in LogViewer and TunnelView components for improved user interaction. --- electron/ipc/handlers.js | 187 ++++++++++++++++++++++++ electron/preload.js | 6 + electron/services/projectsManager.js | 5 + electron/services/tunnelManager.js | 6 + package.json | 7 +- src/components/LogViewer.jsx | 163 ++++++++++++++++++++- src/components/TunnelView.jsx | 207 ++++++++++++++++++++++++--- 7 files changed, 550 insertions(+), 31 deletions(-) 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 2b3a49f..691ca53 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "selfhost-helper", - "version": "0.37.0", + "version": "0.38.0", "description": "Node.js Project Manager", "main": "electron/main.js", "type": "module", @@ -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/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/TunnelView.jsx b/src/components/TunnelView.jsx index 0193d26..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"; @@ -34,6 +37,13 @@ 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(); @@ -53,6 +63,8 @@ export default function TunnelView(props) { 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", @@ -166,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 (
@@ -613,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 + + + From 28085f589a88abe0c12a54e92d398be6a5c7a18b Mon Sep 17 00:00:00 2001 From: AboMeezO Date: Thu, 19 Mar 2026 05:12:50 +0300 Subject: [PATCH 6/6] Update publish:prod script in package.json to include vite build step for production publishing. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 691ca53..21f4d11 100644 --- a/package.json +++ b/package.json @@ -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 .",