"use client" import { listen, type UnlistenFn } from "@tauri-apps/api/event" import { useState, useEffect, useCallback, useMemo, useRef } from "react" import { ChevronDown, Play, Plus, Square } from "lucide-react" import { useTranslations } from "next-intl" import { Button } from "@/components/ui/button" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { useFolderContext } from "@/contexts/folder-context" import { useTerminalContext } from "@/contexts/terminal-context" import { bootstrapFolderCommandsFromPackageJson, listFolderCommands, terminalKill, terminalList, } from "@/lib/tauri" import { disposeTauriListener } from "@/lib/tauri-listener" import type { FolderCommand, TerminalEvent } from "@/lib/types" import { CommandManageDialog } from "./command-manage-dialog" function getSelectedCommandId(folderId: number): number | null { try { const v = localStorage.getItem(`lastCmd:${folderId}`) return v ? Number(v) : null } catch { return null } } function setSelectedCommandId(folderId: number, cmdId: number) { try { localStorage.setItem(`lastCmd:${folderId}`, String(cmdId)) } catch { /* ignore */ } } export function CommandDropdown() { const t = useTranslations("Folder.commandDropdown") const { folder } = useFolderContext() const { createTerminalWithCommand } = useTerminalContext() const [commands, setCommands] = useState([]) const [manageOpen, setManageOpen] = useState(false) const [bootstrapping, setBootstrapping] = useState(false) const [selectedCommandId, setSelectedCommandIdState] = useState< number | null >(null) const [runningCommandTerminals, setRunningCommandTerminals] = useState< Record >({}) const exitUnlistenersRef = useRef>(new Map()) const runningCommandTerminalsRef = useRef>({}) const folderId = folder?.id ?? 0 const folderPath = folder?.path ?? "" useEffect(() => { runningCommandTerminalsRef.current = runningCommandTerminals }, [runningCommandTerminals]) const clearRunningByTerminalId = useCallback((terminalId: string) => { const unlisten = exitUnlistenersRef.current.get(terminalId) if (unlisten) { disposeTauriListener(unlisten, "CommandDropdown.terminalExit") exitUnlistenersRef.current.delete(terminalId) } setRunningCommandTerminals((prev) => { let changed = false const next = { ...prev } for (const [commandId, mappedTerminalId] of Object.entries(prev)) { if (mappedTerminalId === terminalId) { delete next[Number(commandId)] changed = true } } return changed ? next : prev }) }, []) const clearAllRunningStates = useCallback(() => { for (const unlisten of exitUnlistenersRef.current.values()) { disposeTauriListener(unlisten, "CommandDropdown.terminalExit") } exitUnlistenersRef.current.clear() setRunningCommandTerminals({}) }, []) const selectCommand = useCallback( (commandId: number) => { if (!folderId) return setSelectedCommandId(folderId, commandId) setSelectedCommandIdState(commandId) }, [folderId] ) useEffect(() => { if (!folderId) { setSelectedCommandIdState(null) clearAllRunningStates() return } setSelectedCommandIdState(getSelectedCommandId(folderId)) clearAllRunningStates() }, [clearAllRunningStates, folderId]) useEffect( () => () => { clearAllRunningStates() }, [clearAllRunningStates] ) const refreshCommands = useCallback(async () => { if (!folderId) return try { setCommands(await listFolderCommands(folderId)) } catch (err) { console.error("Failed to load commands:", err) } }, [folderId]) useEffect(() => { if (!folderId) return let ignore = false const loadCommands = async () => { try { setBootstrapping(false) const data = await listFolderCommands(folderId) if (ignore) return if (data.length > 0 || !folderPath) { setCommands(data) return } setBootstrapping(true) const bootstrapped = await bootstrapFolderCommandsFromPackageJson( folderId, folderPath ) if (!ignore) setCommands(bootstrapped) } catch (err) { console.error("Failed to load commands:", err) } finally { if (!ignore) setBootstrapping(false) } } loadCommands() return () => { ignore = true } }, [folderId, folderPath]) const registerExitListener = useCallback( async (terminalId: string) => { if (exitUnlistenersRef.current.has(terminalId)) return try { const unlisten = await listen( `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 if (runningCommandTerminalsRef.current[cmd.id]) return selectCommand(cmd.id) const terminalId = await createTerminalWithCommand(cmd.name, cmd.command) if (!terminalId) return setRunningCommandTerminals((prev) => ({ ...prev, [cmd.id]: terminalId })) await registerExitListener(terminalId) }, [createTerminalWithCommand, folderPath, registerExitListener, selectCommand] ) 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) } } syncRunningCommandState() const timer = setInterval(syncRunningCommandState, 1500) return () => { cancelled = true clearInterval(timer) } }, [clearRunningByTerminalId, runningCommandTerminals]) const activeCmd = useMemo( () => commands.find((c) => c.id === selectedCommandId) ?? commands[0] ?? null, [commands, selectedCommandId] ) const activeTerminalId = activeCmd ? runningCommandTerminals[activeCmd.id] : undefined const isActiveCommandRunning = Boolean(activeTerminalId) useEffect(() => { if (!activeCmd && selectedCommandId !== null) { setSelectedCommandIdState(null) return } if (!activeCmd || selectedCommandId === activeCmd.id) return selectCommand(activeCmd.id) }, [activeCmd, selectedCommandId, selectCommand]) const handleRunOrStop = useCallback(() => { if (!activeCmd) return if (isActiveCommandRunning) { void stopCommand(activeCmd) return } void runCommand(activeCmd) }, [activeCmd, isActiveCommandRunning, runCommand, stopCommand]) const handleSelectCommand = useCallback( (cmd: FolderCommand) => { selectCommand(cmd.id) }, [selectCommand] ) if (!folder) return null // No commands → show add command button if (commands.length === 0) { return ( <> ) } // Has commands → split button: [name ▼] [run/stop] return ( <>
{commands.map((cmd) => ( handleSelectCommand(cmd)} className={`flex items-center justify-between gap-4 ${ cmd.id === activeCmd?.id ? "bg-accent/60" : "" }`} > {cmd.name} {cmd.command} ))} setManageOpen(true)}> {t("manageCommands")}
) }