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:
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -35,6 +35,8 @@ interface TerminalContextValue {
|
||||
setHeight: (h: number) => void
|
||||
tabs: TerminalTab[]
|
||||
activeTabId: string | null
|
||||
exitedTerminals: Set<string>
|
||||
markTerminalExited: (id: string) => void
|
||||
createTerminal: () => Promise<void>
|
||||
createTerminalInDirectory: (
|
||||
workingDir: string,
|
||||
@@ -69,6 +71,7 @@ export function TerminalProvider({ children }: { children: ReactNode }) {
|
||||
const [tabs, setTabs] = useState<TerminalTab[]>([])
|
||||
const [activeTabId, setActiveTabId] = useState<string | null>(null)
|
||||
const tabCounterRef = useRef(0)
|
||||
const [exitedTerminals, setExitedTerminals] = useState<Set<string>>(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,
|
||||
|
||||
Reference in New Issue
Block a user