From e7bd12e16ff58fd7fef280906962ed976b8c2383 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 2 Apr 2026 19:02:01 +0800 Subject: [PATCH] fix: sync run button state with terminal process via centralized exit tracking Centralize terminal process lifecycle in terminal-context as single source of truth. TerminalView reports exit/failure via callback, context maintains exitedTerminals set, command-dropdown reacts to it. Removes redundant polling and per-component exit event subscriptions that raced with deferred spawn introduced in b2d10fa. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/layout/command-dropdown.tsx | 124 +++++---------------- src/components/terminal/terminal-panel.tsx | 3 +- src/components/terminal/terminal-view.tsx | 9 ++ src/contexts/terminal-context.tsx | 74 ++++++++---- 4 files changed, 94 insertions(+), 116 deletions(-) diff --git a/src/components/layout/command-dropdown.tsx b/src/components/layout/command-dropdown.tsx index f610772..5c0032e 100644 --- a/src/components/layout/command-dropdown.tsx +++ b/src/components/layout/command-dropdown.tsx @@ -1,6 +1,5 @@ "use client" -import { subscribe } from "@/lib/platform" import { useState, useEffect, useCallback, useMemo, useRef } from "react" import { ChevronDown, Play, Plus, Square } from "lucide-react" import { useTranslations } from "next-intl" @@ -18,9 +17,8 @@ import { bootstrapFolderCommandsFromPackageJson, listFolderCommands, terminalKill, - terminalList, } from "@/lib/api" -import type { FolderCommand, TerminalEvent } from "@/lib/types" +import type { FolderCommand } from "@/lib/types" import { CommandManageDialog } from "./command-manage-dialog" function getSelectedCommandId(folderId: number): number | null { @@ -43,7 +41,7 @@ function setSelectedCommandId(folderId: number, cmdId: number) { export function CommandDropdown() { const t = useTranslations("Folder.commandDropdown") const { folder } = useFolderContext() - const { createTerminalWithCommand } = useTerminalContext() + const { createTerminalWithCommand, exitedTerminals } = useTerminalContext() const [commands, setCommands] = useState([]) const [manageOpen, setManageOpen] = useState(false) const [bootstrapping, setBootstrapping] = useState(false) @@ -53,7 +51,6 @@ export function CommandDropdown() { const [runningCommandTerminals, setRunningCommandTerminals] = useState< Record >({}) - const exitUnlistenersRef = useRef void>>(new Map()) const runningCommandTerminalsRef = useRef>({}) const folderId = folder?.id ?? 0 @@ -63,33 +60,22 @@ export function CommandDropdown() { runningCommandTerminalsRef.current = runningCommandTerminals }, [runningCommandTerminals]) - const clearRunningByTerminalId = useCallback((terminalId: string) => { - const unlisten = exitUnlistenersRef.current.get(terminalId) - if (unlisten) { - unlisten() - exitUnlistenersRef.current.delete(terminalId) - } - + // React to process exits reported by the terminal context + useEffect(() => { + if (exitedTerminals.size === 0) return setRunningCommandTerminals((prev) => { + if (Object.keys(prev).length === 0) return prev let changed = false const next = { ...prev } - for (const [commandId, mappedTerminalId] of Object.entries(prev)) { - if (mappedTerminalId === terminalId) { - delete next[Number(commandId)] + for (const [cmdId, termId] of Object.entries(prev)) { + if (exitedTerminals.has(termId)) { + delete next[Number(cmdId)] changed = true } } return changed ? next : prev }) - }, []) - - const clearAllRunningStates = useCallback(() => { - for (const unlisten of exitUnlistenersRef.current.values()) { - unlisten() - } - exitUnlistenersRef.current.clear() - setRunningCommandTerminals({}) - }, []) + }, [exitedTerminals]) const selectCommand = useCallback( (commandId: number) => { @@ -103,19 +89,12 @@ export function CommandDropdown() { useEffect(() => { if (!folderId) { setSelectedCommandIdState(null) - clearAllRunningStates() + setRunningCommandTerminals({}) return } setSelectedCommandIdState(getSelectedCommandId(folderId)) - clearAllRunningStates() - }, [clearAllRunningStates, folderId]) - - useEffect( - () => () => { - clearAllRunningStates() - }, - [clearAllRunningStates] - ) + setRunningCommandTerminals({}) + }, [folderId]) const refreshCommands = useCallback(async () => { if (!folderId) return @@ -160,24 +139,6 @@ export function CommandDropdown() { } }, [folderId, folderPath]) - const registerExitListener = useCallback( - async (terminalId: string) => { - if (exitUnlistenersRef.current.has(terminalId)) return - try { - const unlisten = await subscribe( - `terminal://exit/${terminalId}`, - () => { - clearRunningByTerminalId(terminalId) - } - ) - exitUnlistenersRef.current.set(terminalId, unlisten) - } catch (err) { - console.error("Failed to subscribe terminal exit event:", err) - } - }, - [clearRunningByTerminalId] - ) - const runCommand = useCallback( async (cmd: FolderCommand) => { if (!folderPath) return @@ -188,55 +149,26 @@ export function CommandDropdown() { if (!terminalId) return setRunningCommandTerminals((prev) => ({ ...prev, [cmd.id]: terminalId })) - await registerExitListener(terminalId) }, - [createTerminalWithCommand, folderPath, registerExitListener, selectCommand] + [createTerminalWithCommand, folderPath, selectCommand] ) - const stopCommand = useCallback( - async (cmd: FolderCommand) => { - const terminalId = runningCommandTerminalsRef.current[cmd.id] - if (!terminalId) return + const stopCommand = useCallback(async (cmd: FolderCommand) => { + const terminalId = runningCommandTerminalsRef.current[cmd.id] + if (!terminalId) return - clearRunningByTerminalId(terminalId) - try { - await terminalKill(terminalId) - } catch (err) { - console.error("Failed to stop command terminal:", err) - } - }, - [clearRunningByTerminalId] - ) - - useEffect(() => { - if (Object.keys(runningCommandTerminals).length === 0) return - let cancelled = false - - const syncRunningCommandState = async () => { - try { - const terminals = await terminalList() - if (cancelled) return - - const aliveTerminalIds = new Set(terminals.map((item) => item.id)) - for (const terminalId of Object.values( - runningCommandTerminalsRef.current - )) { - if (!aliveTerminalIds.has(terminalId)) { - clearRunningByTerminalId(terminalId) - } - } - } catch (err) { - console.error("Failed to sync command terminal state:", err) - } + setRunningCommandTerminals((prev) => { + if (!(cmd.id in prev)) return prev + const next = { ...prev } + delete next[cmd.id] + return next + }) + try { + await terminalKill(terminalId) + } catch (err) { + console.error("Failed to stop command terminal:", err) } - - syncRunningCommandState() - const timer = setInterval(syncRunningCommandState, 1500) - return () => { - cancelled = true - clearInterval(timer) - } - }, [clearRunningByTerminalId, runningCommandTerminals]) + }, []) const activeCmd = useMemo( () => diff --git a/src/components/terminal/terminal-panel.tsx b/src/components/terminal/terminal-panel.tsx index 227ae16..7107d35 100644 --- a/src/components/terminal/terminal-panel.tsx +++ b/src/components/terminal/terminal-panel.tsx @@ -5,7 +5,7 @@ import { TerminalTabBar } from "./terminal-tab-bar" import { TerminalView } from "./terminal-view" export function TerminalPanel() { - const { isOpen, tabs, activeTabId } = useTerminalContext() + const { isOpen, tabs, activeTabId, markTerminalExited } = useTerminalContext() return (
))} diff --git a/src/components/terminal/terminal-view.tsx b/src/components/terminal/terminal-view.tsx index 50f47ae..d5f9bfd 100644 --- a/src/components/terminal/terminal-view.tsx +++ b/src/components/terminal/terminal-view.tsx @@ -95,6 +95,7 @@ interface TerminalViewProps { initialCommand?: string isActive: boolean isVisible: boolean + onProcessExited?: (terminalId: string) => void } export function TerminalView({ @@ -103,6 +104,7 @@ export function TerminalView({ initialCommand, isActive, isVisible, + onProcessExited, }: TerminalViewProps) { const containerRef = useRef(null) const fitAddonRef = useRef<{ fit: () => void } | null>(null) @@ -110,6 +112,7 @@ export function TerminalView({ const lastResizeRef = useRef<{ cols: number; rows: number } | null>(null) const isActiveRef = useRef(isActive) const isVisibleRef = useRef(isVisible) + const onProcessExitedRef = useRef(onProcessExited) const [loading, setLoading] = useState(true) useEffect(() => { @@ -117,6 +120,10 @@ export function TerminalView({ isVisibleRef.current = isVisible }, [isActive, isVisible]) + useEffect(() => { + onProcessExitedRef.current = onProcessExited + }, [onProcessExited]) + useEffect(() => { let cancelled = false let cleanup: (() => void) | undefined @@ -188,6 +195,7 @@ export function TerminalView({ const unlistenExit = await subscribe( `terminal://exit/${terminalId}`, () => { + onProcessExitedRef.current?.(terminalId) term.write("\r\n\x1b[90m[Process exited]\x1b[0m\r\n") } ) @@ -206,6 +214,7 @@ export function TerminalView({ try { await terminalSpawn(workingDir, initialCommand, terminalId) } catch (err) { + onProcessExitedRef.current?.(terminalId) term.write(`\r\n\x1b[31m[Failed to start terminal: ${err}]\x1b[0m\r\n`) } finally { if (!cancelled) setLoading(false) diff --git a/src/contexts/terminal-context.tsx b/src/contexts/terminal-context.tsx index 9609060..4c0610e 100644 --- a/src/contexts/terminal-context.tsx +++ b/src/contexts/terminal-context.tsx @@ -35,6 +35,8 @@ interface TerminalContextValue { setHeight: (h: number) => void tabs: TerminalTab[] activeTabId: string | null + exitedTerminals: Set + markTerminalExited: (id: string) => void createTerminal: () => Promise createTerminalInDirectory: ( workingDir: string, @@ -69,6 +71,7 @@ export function TerminalProvider({ children }: { children: ReactNode }) { const [tabs, setTabs] = useState([]) const [activeTabId, setActiveTabId] = useState(null) const tabCounterRef = useRef(0) + const [exitedTerminals, setExitedTerminals] = useState>(new Set()) const lastMouseActivityInTerminalRef = useRef(false) // Keep a ref of tabs for cleanup on unmount (effect [] captures stale state) const tabsRef = useRef(tabs) @@ -78,6 +81,27 @@ export function TerminalProvider({ children }: { children: ReactNode }) { const folderPath = folder?.path ?? "" + const markTerminalExited = useCallback((id: string) => { + setExitedTerminals((prev) => { + if (prev.has(id)) return prev + const next = new Set(prev) + next.add(id) + return next + }) + }, []) + + const removeExitedTerminals = useCallback((ids: string[]) => { + setExitedTerminals((prev) => { + if (prev.size === 0) return prev + let changed = false + const next = new Set(prev) + for (const id of ids) { + if (next.delete(id)) changed = true + } + return changed ? next : prev + }) + }, []) + const killTerminalTabs = useCallback((targetTabs: TerminalTab[]) => { targetTabs.forEach((tab) => { terminalKill(tab.id).catch(() => {}) @@ -158,43 +182,51 @@ export function TerminalProvider({ children }: { children: ReactNode }) { setHeightState(Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, h))) }, []) - const closeTerminal = useCallback((id: string) => { - terminalKill(id).catch(() => {}) - setTabs((prev) => { - const next = prev.filter((t) => t.id !== id) - if (next.length === 0) { - tabCounterRef.current = 0 - setIsOpen(false) - setActiveTabId(null) - } else { - setActiveTabId((prevActive) => - prevActive === id ? next[next.length - 1].id : prevActive - ) - } - return next - }) - }, []) + const closeTerminal = useCallback( + (id: string) => { + markTerminalExited(id) + removeExitedTerminals([id]) + terminalKill(id).catch(() => {}) + setTabs((prev) => { + const next = prev.filter((t) => t.id !== id) + if (next.length === 0) { + tabCounterRef.current = 0 + setIsOpen(false) + setActiveTabId(null) + } else { + setActiveTabId((prevActive) => + prevActive === id ? next[next.length - 1].id : prevActive + ) + } + return next + }) + }, + [markTerminalExited, removeExitedTerminals] + ) const closeOtherTerminals = useCallback( (id: string) => { setTabs((prev) => { - killTerminalTabs(prev.filter((t) => t.id !== id)) + const closed = prev.filter((t) => t.id !== id) + killTerminalTabs(closed) + removeExitedTerminals(closed.map((t) => t.id)) return prev.filter((t) => t.id === id) }) setActiveTabId(id) }, - [killTerminalTabs] + [killTerminalTabs, removeExitedTerminals] ) const closeAllTerminals = useCallback(() => { setTabs((prev) => { killTerminalTabs(prev) + removeExitedTerminals(prev.map((t) => t.id)) return [] }) tabCounterRef.current = 0 setActiveTabId(null) setIsOpen(false) - }, [killTerminalTabs]) + }, [killTerminalTabs, removeExitedTerminals]) const renameTerminal = useCallback((id: string, title: string) => { setTabs((prev) => prev.map((t) => (t.id === id ? { ...t, title } : t))) @@ -304,6 +336,8 @@ export function TerminalProvider({ children }: { children: ReactNode }) { setHeight, tabs, activeTabId, + exitedTerminals, + markTerminalExited, createTerminal, createTerminalInDirectory, createTerminalWithCommand, @@ -320,6 +354,8 @@ export function TerminalProvider({ children }: { children: ReactNode }) { setHeight, tabs, activeTabId, + exitedTerminals, + markTerminalExited, createTerminal, createTerminalInDirectory, createTerminalWithCommand,