diff --git a/src/components/EditorView.jsx b/src/components/EditorView.jsx index 239b086..0b5b006 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 loadFileRef = useRef(null); const suppressReloadForPathRef = useRef({}); useEffect(() => { @@ -323,7 +324,7 @@ export default function EditorView() { if (targetFile) { const shouldLoad = targetFile !== currentFileRef.current || editorContentRef.current === ""; if (shouldLoad) { - loadFile(targetFile); + loadFileRef.current?.(targetFile); } } else { setCurrentFile(null); @@ -361,7 +362,7 @@ export default function EditorView() { toast.info("File changed on disk. Save or reload to see changes."); return; } - loadFile(current); + loadFileRef.current?.(current); }); return () => unsub?.(); }, [projectPath]); @@ -429,6 +430,7 @@ export default function EditorView() { setIsFileLoading(false); } }; + loadFileRef.current = loadFile; const handleFileSelect = async (node) => { if (node.type === "file") { @@ -448,14 +450,14 @@ export default function EditorView() { [setExplorerExpanded] ); - const handleSelectTab = (tabId) => { + const handleSelectTab = useCallback((tabId) => { const tab = openTabs.find((t) => t.id === tabId); if (!tab) return; setActiveTabId(tabId); if (tab.path !== currentFileRef.current) { - loadFile(tab.path); + loadFileRef.current?.(tab.path); } - }; + }, [openTabs]); const performCloseTab = useCallback( (tabId) => { @@ -477,7 +479,7 @@ export default function EditorView() { setActiveTabId(replacement ? replacement.id : null); if (replacement) { if (replacement.path !== currentFileRef.current) { - loadFile(replacement.path); + loadFileRef.current?.(replacement.path); } } else { setCurrentFile(null); @@ -489,14 +491,14 @@ export default function EditorView() { [openTabs, activeTabId, setUnsavedChanges] ); - const handleCloseTab = (tabId) => { + const handleCloseTab = useCallback((tabId) => { const tab = openTabs.find((t) => t.id === tabId); if (tab && unsavedChangesRef.current[tab.path] !== undefined) { setPendingCloseTabId(tabId); return; } performCloseTab(tabId); - }; + }, [openTabs, performCloseTab]); const handleConfirmCloseSave = useCallback(async () => { if (pendingCloseTabId == null) return; @@ -639,7 +641,7 @@ export default function EditorView() { window.addEventListener("keydown", onKeyDown, { capture: true }); return () => window.removeEventListener("keydown", onKeyDown, { capture: true }); - }, [activeTabId, openTabs, handleCloseTab]); + }, [activeTabId, openTabs, handleCloseTab, handleSelectTab]); if (!project) return null; diff --git a/src/components/GitPanel.jsx b/src/components/GitPanel.jsx index 2a64031..297f1a8 100644 --- a/src/components/GitPanel.jsx +++ b/src/components/GitPanel.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from "react"; +import React, { useCallback, useState, useEffect, useRef } from "react"; import { GitBranch, Upload, @@ -75,7 +75,7 @@ export default function GitPanel({ const workingFiles = status?.files?.filter((f) => !f.index || f.index === " " || f.workingDir === "U") || []; - const loadGitStatus = async () => { + const loadGitStatus = useCallback(async () => { if (!projectPath) return; setError(null); setIsRefreshing(true); @@ -105,13 +105,13 @@ export default function GitPanel({ } finally { setIsRefreshing(false); } - }; + }, [onStatusChange, projectPath]); useEffect(() => { if (isOpen && projectPath) { loadGitStatus(); } - }, [isOpen, projectPath]); + }, [isOpen, loadGitStatus, projectPath]); const handleAddAll = async () => { if (!projectPath) return; diff --git a/src/components/LogViewer.jsx b/src/components/LogViewer.jsx index 41ca41c..5064f85 100644 --- a/src/components/LogViewer.jsx +++ b/src/components/LogViewer.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { useOutletContext } from "react-router-dom"; import { Terminal as TerminalIcon, @@ -33,7 +33,7 @@ export default function LogViewer(props) { const status = context?.project?.status; const onSendInput = context?.handleSendInput; const allLogs = useAtomValue(logsAtom); - const logs = allLogs[projectId] || []; + const logs = useMemo(() => allLogs[projectId] || [], [allLogs, projectId]); const [input, setInput] = useState(""); const [history, setHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(-1); diff --git a/src/components/OverviewView.jsx b/src/components/OverviewView.jsx index 940d92f..086683a 100644 --- a/src/components/OverviewView.jsx +++ b/src/components/OverviewView.jsx @@ -107,7 +107,7 @@ function useProjectLayout({ projectId, defaultLayout }) { function ConsoleMiniTile({ projectId, status, onSendInput }) { const allLogs = useAtomValue(logsAtom); - const logs = allLogs?.[projectId] || []; + const logs = useMemo(() => allLogs?.[projectId] || [], [allLogs, projectId]); const terminalContainerRef = useRef(null); const xtermRef = useRef(null); diff --git a/src/components/ProjectLayout.jsx b/src/components/ProjectLayout.jsx index 400b54a..a94622d 100644 --- a/src/components/ProjectLayout.jsx +++ b/src/components/ProjectLayout.jsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { useNavigate, Outlet } from "react-router-dom"; import { useAtom, useSetAtom } from "jotai"; import { toast } from "react-toastify"; @@ -27,6 +27,8 @@ export default function ProjectLayout() { const setTunnelState = useSetAtom(atoms.tunnelStateAtom); const projectPathRef = useRef(null); projectPathRef.current = project?.path ?? null; + const projectId = project?.id; + const projectPath = project?.path; // Redirect if project ID in URL doesn't exist (e.g. deleted) useEffect(() => { @@ -35,22 +37,22 @@ export default function ProjectLayout() { } }, [projects.length, project, navigate]); - const loadData = async () => { + const loadData = useCallback(async () => { const [projectList, categoryList] = await Promise.all([API.getProjects(), API.getCategories()]); setProjects(normalizeProjectList(projectList)); setCategories(normalizeCategoryList(categoryList)); - }; + }, [setCategories, setProjects]); - const loadFileTree = async (path) => { + const loadFileTree = useCallback(async (path) => { setIsFileTreeLoading(true); const tree = await API.readDirectory(path); setFileTree(tree); setIsFileTreeLoading(false); - }; + }, [setFileTree, setIsFileTreeLoading]); // Per-project: file tree, watcher, log history, tunnel state useEffect(() => { - if (!project) return; + if (!projectId) return; let cancelled = false; setStats(null); @@ -58,37 +60,37 @@ export default function ProjectLayout() { const list = normalizeProjectList(await API.getProjects()); if (cancelled) return; setProjects(list); - const currentProject = list.find((p) => p.id === project.id); + const currentProject = list.find((p) => p.id === projectId); if (currentProject) { loadFileTree(currentProject.path); API.watchFolder(currentProject.path); } })(); - API.getLogHistory(project.id).then((history) => { + API.getLogHistory(projectId).then((history) => { if (history?.length > 0) { setLogs((prev) => ({ ...prev, - [project.id]: history, + [projectId]: history, })); } }); - API.getTunnelStatus(project.id).then((status) => { + API.getTunnelStatus(projectId).then((status) => { if (status) { setTunnelState((prev) => ({ ...prev, - [project.id]: { ...(prev[project.id] || { logs: [] }), ...status }, + [projectId]: { ...(prev[projectId] || { logs: [] }), ...status }, })); } }); - API.getTunnelLogs(project.id).then((logs) => { + API.getTunnelLogs(projectId).then((logs) => { if (logs?.length > 0) { setTunnelState((prev) => ({ ...prev, - [project.id]: { - ...(prev[project.id] || { status: "stopped", url: null }), + [projectId]: { + ...(prev[projectId] || { status: "stopped", url: null }), logs, }, })); @@ -97,15 +99,15 @@ export default function ProjectLayout() { return () => { cancelled = true; - if (project?.path) { - API.stopWatchingFolder(project.path).catch(() => {}); + if (projectPath) { + API.stopWatchingFolder(projectPath).catch(() => {}); } }; - }, [project?.id]); + }, [loadFileTree, projectId, projectPath, setLogs, setProjects, setStats, setTunnelState]); // File change listener (reload tree when structure changes under this project) useEffect(() => { - if (!project?.path) return; + if (!projectPath) return; let fileChangeDebounceTimer = null; const cleanupFileChange = API.onFileChange(({ event, filePath }) => { const projectPath = projectPathRef.current; @@ -127,7 +129,7 @@ export default function ProjectLayout() { if (fileChangeDebounceTimer) clearTimeout(fileChangeDebounceTimer); cleanupFileChange(); }; - }, [project?.path]); + }, [loadFileTree, projectPath]); // Subscribe to native push-based stats events (no polling) useEffect(() => { diff --git a/src/components/TunnelView.jsx b/src/components/TunnelView.jsx index c7f2888..58a7eb3 100644 --- a/src/components/TunnelView.jsx +++ b/src/components/TunnelView.jsx @@ -53,6 +53,14 @@ export default function TunnelView(props) { const projectTunnelState = selectedProject ? tunnelState[selectedProject.id] || { status: "stopped", url: null, logs: [] } : { status: "stopped", url: null, logs: [] }; + const selectedProjectId = selectedProject?.id; + const selectedTunnelMode = selectedProject?.tunnelMode; + const selectedTunnelPort = selectedProject?.tunnelPort; + const selectedTunnelToken = selectedProject?.encryptedTunnelToken; + const selectedAutoStartTunnel = selectedProject?.autoStartTunnel; + const selectedTunnelConfig = selectedProject?.tunnelConfig; + const currentTunnelStatusRef = useRef(projectTunnelState.status); + currentTunnelStatusRef.current = projectTunnelState.status; const [mode, setMode] = useState(selectedProject?.tunnelMode || "quick"); const [port, setPort] = useState( @@ -78,22 +86,29 @@ export default function TunnelView(props) { // Reset state on project change useEffect(() => { - if (!selectedProject) return; - setMode(selectedProject.tunnelMode || "quick"); - setPort(selectedProject?.tunnelPort != null ? String(selectedProject.tunnelPort) : "3000"); - setToken(selectedProject.encryptedTunnelToken || ""); + if (!selectedProjectId) return; + setMode(selectedTunnelMode || "quick"); + setPort(selectedTunnelPort != null ? String(selectedTunnelPort) : "3000"); + setToken(selectedTunnelToken || ""); setShowAdvanced(false); setShowHelp(false); - setAutoStart(selectedProject.autoStartTunnel || false); + setAutoStart(selectedAutoStartTunnel || false); setConfig({ - protocol: selectedProject.tunnelConfig?.protocol || "http2", - loglevel: selectedProject.tunnelConfig?.loglevel || "info", - noTLSVerify: selectedProject.tunnelConfig?.noTLSVerify || false, - connectTimeout: selectedProject.tunnelConfig?.connectTimeout || "30s", - httpHostHeader: selectedProject.tunnelConfig?.httpHostHeader || "", + protocol: selectedTunnelConfig?.protocol || "http2", + loglevel: selectedTunnelConfig?.loglevel || "info", + noTLSVerify: selectedTunnelConfig?.noTLSVerify || false, + connectTimeout: selectedTunnelConfig?.connectTimeout || "30s", + httpHostHeader: selectedTunnelConfig?.httpHostHeader || "", }); - prevStatusRef.current = projectTunnelState.status; - }, [selectedProject?.id]); + prevStatusRef.current = currentTunnelStatusRef.current; + }, [ + selectedProjectId, + selectedTunnelMode, + selectedTunnelPort, + selectedTunnelToken, + selectedAutoStartTunnel, + selectedTunnelConfig, + ]); useEffect(() => { if (prevStatusRef.current !== "running" && projectTunnelState.status === "running") { toast.success("Tunnel established successfully!"); diff --git a/src/components/overview/tiles/ConsoleMiniTile.jsx b/src/components/overview/tiles/ConsoleMiniTile.jsx index 0672435..fdfa4c1 100644 --- a/src/components/overview/tiles/ConsoleMiniTile.jsx +++ b/src/components/overview/tiles/ConsoleMiniTile.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { useAtomValue } from "jotai"; import { Terminal as TerminalIcon, Send } from "lucide-react"; import { toast } from "react-toastify"; @@ -14,7 +14,7 @@ const API = window.api; export default function ConsoleMiniTile({ projectId, status, onSendInput }) { const allLogs = useAtomValue(logsAtom); - const logs = allLogs?.[projectId] || []; + const logs = useMemo(() => allLogs?.[projectId] || [], [allLogs, projectId]); const terminalContainerRef = useRef(null); const xtermRef = useRef(null); diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 291db07..94b3438 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { Outlet } from "react-router-dom"; import { useAtom, useSetAtom } from "jotai"; import * as atoms from "@/store/atoms"; @@ -16,11 +16,11 @@ export default function Dashboard() { const setTunnelState = useSetAtom(atoms.tunnelStateAtom); const setResourceHistory = useSetAtom(atoms.resourceHistoryAtom); - const loadData = async () => { + const loadData = useCallback(async () => { const [projectList, categoryList] = await Promise.all([API.getProjects(), API.getCategories()]); setProjects(normalizeProjectList(projectList)); setCategories(normalizeCategoryList(categoryList)); - }; + }, [setCategories, setProjects]); useEffect(() => { loadData(); @@ -124,7 +124,7 @@ export default function Dashboard() { cleanupTunnelStatus(); cleanupTunnelLog(); }; - }, []); + }, [loadData, setLogs, setProjects, setResourceHistory, setTunnelState]); return (