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"
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<FolderCommand[]>([])
const [manageOpen, setManageOpen] = useState(false)
const [bootstrapping, setBootstrapping] = useState(false)
@@ -53,7 +51,6 @@ export function CommandDropdown() {
const [runningCommandTerminals, setRunningCommandTerminals] = useState<
Record<number, string>
>({})
const exitUnlistenersRef = useRef<Map<string, () => void>>(new Map())
const runningCommandTerminalsRef = useRef<Record<number, string>>({})
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<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(
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(
() =>

View File

@@ -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 (
<section
@@ -22,6 +22,7 @@ export function TerminalPanel() {
initialCommand={tab.initialCommand}
isActive={tab.id === activeTabId}
isVisible={isOpen}
onProcessExited={markTerminalExited}
/>
))}
</div>

View File

@@ -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<HTMLDivElement>(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<TerminalEvent>(
`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)