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) <noreply@anthropic.com>
This commit is contained in:
xintaofei
2026-04-02 19:02:01 +08:00
parent ef94687c87
commit e7bd12e16f
4 changed files with 94 additions and 116 deletions

View File

@@ -1,6 +1,5 @@
"use client" "use client"
import { subscribe } from "@/lib/platform"
import { useState, useEffect, useCallback, useMemo, useRef } from "react" import { useState, useEffect, useCallback, useMemo, useRef } from "react"
import { ChevronDown, Play, Plus, Square } from "lucide-react" import { ChevronDown, Play, Plus, Square } from "lucide-react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
@@ -18,9 +17,8 @@ import {
bootstrapFolderCommandsFromPackageJson, bootstrapFolderCommandsFromPackageJson,
listFolderCommands, listFolderCommands,
terminalKill, terminalKill,
terminalList,
} from "@/lib/api" } from "@/lib/api"
import type { FolderCommand, TerminalEvent } from "@/lib/types" import type { FolderCommand } from "@/lib/types"
import { CommandManageDialog } from "./command-manage-dialog" import { CommandManageDialog } from "./command-manage-dialog"
function getSelectedCommandId(folderId: number): number | null { function getSelectedCommandId(folderId: number): number | null {
@@ -43,7 +41,7 @@ function setSelectedCommandId(folderId: number, cmdId: number) {
export function CommandDropdown() { export function CommandDropdown() {
const t = useTranslations("Folder.commandDropdown") const t = useTranslations("Folder.commandDropdown")
const { folder } = useFolderContext() const { folder } = useFolderContext()
const { createTerminalWithCommand } = useTerminalContext() const { createTerminalWithCommand, exitedTerminals } = useTerminalContext()
const [commands, setCommands] = useState<FolderCommand[]>([]) const [commands, setCommands] = useState<FolderCommand[]>([])
const [manageOpen, setManageOpen] = useState(false) const [manageOpen, setManageOpen] = useState(false)
const [bootstrapping, setBootstrapping] = useState(false) const [bootstrapping, setBootstrapping] = useState(false)
@@ -53,7 +51,6 @@ export function CommandDropdown() {
const [runningCommandTerminals, setRunningCommandTerminals] = useState< const [runningCommandTerminals, setRunningCommandTerminals] = useState<
Record<number, string> Record<number, string>
>({}) >({})
const exitUnlistenersRef = useRef<Map<string, () => void>>(new Map())
const runningCommandTerminalsRef = useRef<Record<number, string>>({}) const runningCommandTerminalsRef = useRef<Record<number, string>>({})
const folderId = folder?.id ?? 0 const folderId = folder?.id ?? 0
@@ -63,33 +60,22 @@ export function CommandDropdown() {
runningCommandTerminalsRef.current = runningCommandTerminals runningCommandTerminalsRef.current = runningCommandTerminals
}, [runningCommandTerminals]) }, [runningCommandTerminals])
const clearRunningByTerminalId = useCallback((terminalId: string) => { // React to process exits reported by the terminal context
const unlisten = exitUnlistenersRef.current.get(terminalId) useEffect(() => {
if (unlisten) { if (exitedTerminals.size === 0) return
unlisten()
exitUnlistenersRef.current.delete(terminalId)
}
setRunningCommandTerminals((prev) => { setRunningCommandTerminals((prev) => {
if (Object.keys(prev).length === 0) return prev
let changed = false let changed = false
const next = { ...prev } const next = { ...prev }
for (const [commandId, mappedTerminalId] of Object.entries(prev)) { for (const [cmdId, termId] of Object.entries(prev)) {
if (mappedTerminalId === terminalId) { if (exitedTerminals.has(termId)) {
delete next[Number(commandId)] delete next[Number(cmdId)]
changed = true changed = true
} }
} }
return changed ? next : prev return changed ? next : prev
}) })
}, []) }, [exitedTerminals])
const clearAllRunningStates = useCallback(() => {
for (const unlisten of exitUnlistenersRef.current.values()) {
unlisten()
}
exitUnlistenersRef.current.clear()
setRunningCommandTerminals({})
}, [])
const selectCommand = useCallback( const selectCommand = useCallback(
(commandId: number) => { (commandId: number) => {
@@ -103,19 +89,12 @@ export function CommandDropdown() {
useEffect(() => { useEffect(() => {
if (!folderId) { if (!folderId) {
setSelectedCommandIdState(null) setSelectedCommandIdState(null)
clearAllRunningStates() setRunningCommandTerminals({})
return return
} }
setSelectedCommandIdState(getSelectedCommandId(folderId)) setSelectedCommandIdState(getSelectedCommandId(folderId))
clearAllRunningStates() setRunningCommandTerminals({})
}, [clearAllRunningStates, folderId]) }, [folderId])
useEffect(
() => () => {
clearAllRunningStates()
},
[clearAllRunningStates]
)
const refreshCommands = useCallback(async () => { const refreshCommands = useCallback(async () => {
if (!folderId) return if (!folderId) return
@@ -160,24 +139,6 @@ export function CommandDropdown() {
} }
}, [folderId, folderPath]) }, [folderId, folderPath])
const registerExitListener = useCallback(
async (terminalId: string) => {
if (exitUnlistenersRef.current.has(terminalId)) return
try {
const unlisten = await subscribe<TerminalEvent>(
`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( const runCommand = useCallback(
async (cmd: FolderCommand) => { async (cmd: FolderCommand) => {
if (!folderPath) return if (!folderPath) return
@@ -188,55 +149,26 @@ export function CommandDropdown() {
if (!terminalId) return if (!terminalId) return
setRunningCommandTerminals((prev) => ({ ...prev, [cmd.id]: terminalId })) setRunningCommandTerminals((prev) => ({ ...prev, [cmd.id]: terminalId }))
await registerExitListener(terminalId)
}, },
[createTerminalWithCommand, folderPath, registerExitListener, selectCommand] [createTerminalWithCommand, folderPath, selectCommand]
) )
const stopCommand = useCallback( const stopCommand = useCallback(async (cmd: FolderCommand) => {
async (cmd: FolderCommand) => {
const terminalId = runningCommandTerminalsRef.current[cmd.id] const terminalId = runningCommandTerminalsRef.current[cmd.id]
if (!terminalId) return if (!terminalId) return
clearRunningByTerminalId(terminalId) setRunningCommandTerminals((prev) => {
if (!(cmd.id in prev)) return prev
const next = { ...prev }
delete next[cmd.id]
return next
})
try { try {
await terminalKill(terminalId) await terminalKill(terminalId)
} catch (err) { } catch (err) {
console.error("Failed to stop command terminal:", 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)
}
}
syncRunningCommandState()
const timer = setInterval(syncRunningCommandState, 1500)
return () => {
cancelled = true
clearInterval(timer)
}
}, [clearRunningByTerminalId, runningCommandTerminals])
const activeCmd = useMemo( const activeCmd = useMemo(
() => () =>

View File

@@ -5,7 +5,7 @@ import { TerminalTabBar } from "./terminal-tab-bar"
import { TerminalView } from "./terminal-view" import { TerminalView } from "./terminal-view"
export function TerminalPanel() { export function TerminalPanel() {
const { isOpen, tabs, activeTabId } = useTerminalContext() const { isOpen, tabs, activeTabId, markTerminalExited } = useTerminalContext()
return ( return (
<section <section
@@ -22,6 +22,7 @@ export function TerminalPanel() {
initialCommand={tab.initialCommand} initialCommand={tab.initialCommand}
isActive={tab.id === activeTabId} isActive={tab.id === activeTabId}
isVisible={isOpen} isVisible={isOpen}
onProcessExited={markTerminalExited}
/> />
))} ))}
</div> </div>

View File

@@ -95,6 +95,7 @@ interface TerminalViewProps {
initialCommand?: string initialCommand?: string
isActive: boolean isActive: boolean
isVisible: boolean isVisible: boolean
onProcessExited?: (terminalId: string) => void
} }
export function TerminalView({ export function TerminalView({
@@ -103,6 +104,7 @@ export function TerminalView({
initialCommand, initialCommand,
isActive, isActive,
isVisible, isVisible,
onProcessExited,
}: TerminalViewProps) { }: TerminalViewProps) {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const fitAddonRef = useRef<{ fit: () => void } | null>(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 lastResizeRef = useRef<{ cols: number; rows: number } | null>(null)
const isActiveRef = useRef(isActive) const isActiveRef = useRef(isActive)
const isVisibleRef = useRef(isVisible) const isVisibleRef = useRef(isVisible)
const onProcessExitedRef = useRef(onProcessExited)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
@@ -117,6 +120,10 @@ export function TerminalView({
isVisibleRef.current = isVisible isVisibleRef.current = isVisible
}, [isActive, isVisible]) }, [isActive, isVisible])
useEffect(() => {
onProcessExitedRef.current = onProcessExited
}, [onProcessExited])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
let cleanup: (() => void) | undefined let cleanup: (() => void) | undefined
@@ -188,6 +195,7 @@ export function TerminalView({
const unlistenExit = await subscribe<TerminalEvent>( const unlistenExit = await subscribe<TerminalEvent>(
`terminal://exit/${terminalId}`, `terminal://exit/${terminalId}`,
() => { () => {
onProcessExitedRef.current?.(terminalId)
term.write("\r\n\x1b[90m[Process exited]\x1b[0m\r\n") term.write("\r\n\x1b[90m[Process exited]\x1b[0m\r\n")
} }
) )
@@ -206,6 +214,7 @@ export function TerminalView({
try { try {
await terminalSpawn(workingDir, initialCommand, terminalId) await terminalSpawn(workingDir, initialCommand, terminalId)
} catch (err) { } catch (err) {
onProcessExitedRef.current?.(terminalId)
term.write(`\r\n\x1b[31m[Failed to start terminal: ${err}]\x1b[0m\r\n`) term.write(`\r\n\x1b[31m[Failed to start terminal: ${err}]\x1b[0m\r\n`)
} finally { } finally {
if (!cancelled) setLoading(false) if (!cancelled) setLoading(false)

View File

@@ -35,6 +35,8 @@ interface TerminalContextValue {
setHeight: (h: number) => void setHeight: (h: number) => void
tabs: TerminalTab[] tabs: TerminalTab[]
activeTabId: string | null activeTabId: string | null
exitedTerminals: Set<string>
markTerminalExited: (id: string) => void
createTerminal: () => Promise<void> createTerminal: () => Promise<void>
createTerminalInDirectory: ( createTerminalInDirectory: (
workingDir: string, workingDir: string,
@@ -69,6 +71,7 @@ export function TerminalProvider({ children }: { children: ReactNode }) {
const [tabs, setTabs] = useState<TerminalTab[]>([]) const [tabs, setTabs] = useState<TerminalTab[]>([])
const [activeTabId, setActiveTabId] = useState<string | null>(null) const [activeTabId, setActiveTabId] = useState<string | null>(null)
const tabCounterRef = useRef(0) const tabCounterRef = useRef(0)
const [exitedTerminals, setExitedTerminals] = useState<Set<string>>(new Set())
const lastMouseActivityInTerminalRef = useRef(false) const lastMouseActivityInTerminalRef = useRef(false)
// Keep a ref of tabs for cleanup on unmount (effect [] captures stale state) // Keep a ref of tabs for cleanup on unmount (effect [] captures stale state)
const tabsRef = useRef(tabs) const tabsRef = useRef(tabs)
@@ -78,6 +81,27 @@ export function TerminalProvider({ children }: { children: ReactNode }) {
const folderPath = folder?.path ?? "" 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[]) => { const killTerminalTabs = useCallback((targetTabs: TerminalTab[]) => {
targetTabs.forEach((tab) => { targetTabs.forEach((tab) => {
terminalKill(tab.id).catch(() => {}) terminalKill(tab.id).catch(() => {})
@@ -158,7 +182,10 @@ export function TerminalProvider({ children }: { children: ReactNode }) {
setHeightState(Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, h))) setHeightState(Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, h)))
}, []) }, [])
const closeTerminal = useCallback((id: string) => { const closeTerminal = useCallback(
(id: string) => {
markTerminalExited(id)
removeExitedTerminals([id])
terminalKill(id).catch(() => {}) terminalKill(id).catch(() => {})
setTabs((prev) => { setTabs((prev) => {
const next = prev.filter((t) => t.id !== id) const next = prev.filter((t) => t.id !== id)
@@ -173,28 +200,33 @@ export function TerminalProvider({ children }: { children: ReactNode }) {
} }
return next return next
}) })
}, []) },
[markTerminalExited, removeExitedTerminals]
)
const closeOtherTerminals = useCallback( const closeOtherTerminals = useCallback(
(id: string) => { (id: string) => {
setTabs((prev) => { 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) return prev.filter((t) => t.id === id)
}) })
setActiveTabId(id) setActiveTabId(id)
}, },
[killTerminalTabs] [killTerminalTabs, removeExitedTerminals]
) )
const closeAllTerminals = useCallback(() => { const closeAllTerminals = useCallback(() => {
setTabs((prev) => { setTabs((prev) => {
killTerminalTabs(prev) killTerminalTabs(prev)
removeExitedTerminals(prev.map((t) => t.id))
return [] return []
}) })
tabCounterRef.current = 0 tabCounterRef.current = 0
setActiveTabId(null) setActiveTabId(null)
setIsOpen(false) setIsOpen(false)
}, [killTerminalTabs]) }, [killTerminalTabs, removeExitedTerminals])
const renameTerminal = useCallback((id: string, title: string) => { const renameTerminal = useCallback((id: string, title: string) => {
setTabs((prev) => prev.map((t) => (t.id === id ? { ...t, title } : t))) setTabs((prev) => prev.map((t) => (t.id === id ? { ...t, title } : t)))
@@ -304,6 +336,8 @@ export function TerminalProvider({ children }: { children: ReactNode }) {
setHeight, setHeight,
tabs, tabs,
activeTabId, activeTabId,
exitedTerminals,
markTerminalExited,
createTerminal, createTerminal,
createTerminalInDirectory, createTerminalInDirectory,
createTerminalWithCommand, createTerminalWithCommand,
@@ -320,6 +354,8 @@ export function TerminalProvider({ children }: { children: ReactNode }) {
setHeight, setHeight,
tabs, tabs,
activeTabId, activeTabId,
exitedTerminals,
markTerminalExited,
createTerminal, createTerminal,
createTerminalInDirectory, createTerminalInDirectory,
createTerminalWithCommand, createTerminalWithCommand,