Initial commit
This commit is contained in:
365
src/components/layout/command-dropdown.tsx
Normal file
365
src/components/layout/command-dropdown.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
"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 { 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 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 { folder } = useFolderContext()
|
||||
const { createTerminalWithCommand } = useTerminalContext()
|
||||
const [commands, setCommands] = useState<FolderCommand[]>([])
|
||||
const [manageOpen, setManageOpen] = useState(false)
|
||||
const [bootstrapping, setBootstrapping] = useState(false)
|
||||
const [selectedCommandId, setSelectedCommandIdState] = useState<
|
||||
number | null
|
||||
>(null)
|
||||
const [runningCommandTerminals, setRunningCommandTerminals] = useState<
|
||||
Record<number, string>
|
||||
>({})
|
||||
const exitUnlistenersRef = useRef<Map<string, UnlistenFn>>(new Map())
|
||||
const runningCommandTerminalsRef = useRef<Record<number, string>>({})
|
||||
|
||||
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) {
|
||||
unlisten()
|
||||
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()) {
|
||||
unlisten()
|
||||
}
|
||||
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<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
|
||||
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"
|
||||
if (commands.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs gap-1 hover:text-foreground/80"
|
||||
onClick={() => setManageOpen(true)}
|
||||
disabled={bootstrapping}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{bootstrapping ? "Loading..." : "Add Command"}
|
||||
</Button>
|
||||
<CommandManageDialog
|
||||
open={manageOpen}
|
||||
onOpenChange={setManageOpen}
|
||||
folderId={folderId}
|
||||
commands={commands}
|
||||
onSaved={refreshCommands}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Has commands → split button: [name ▼] [run/stop]
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-6 hover:text-foreground/80">
|
||||
<span className="max-w-24 truncate">{activeCmd?.name}</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-56">
|
||||
{commands.map((cmd) => (
|
||||
<DropdownMenuItem
|
||||
key={cmd.id}
|
||||
onClick={() => handleSelectCommand(cmd)}
|
||||
className={`flex items-center justify-between gap-4 ${
|
||||
cmd.id === activeCmd?.id ? "bg-accent/60" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{cmd.name}</span>
|
||||
<span className="text-xs text-muted-foreground font-mono truncate max-w-32">
|
||||
{cmd.command}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setManageOpen(true)}>
|
||||
Manage Commands...
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-xs gap-1 ${
|
||||
isActiveCommandRunning
|
||||
? "text-destructive hover:text-destructive"
|
||||
: "hover:text-foreground/80"
|
||||
}`}
|
||||
onClick={handleRunOrStop}
|
||||
title={
|
||||
isActiveCommandRunning
|
||||
? `Stop: ${activeCmd?.command}`
|
||||
: `Run: ${activeCmd?.command}`
|
||||
}
|
||||
>
|
||||
{isActiveCommandRunning ? (
|
||||
<Square className="h-3 w-3" />
|
||||
) : (
|
||||
<Play className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CommandManageDialog
|
||||
open={manageOpen}
|
||||
onOpenChange={setManageOpen}
|
||||
folderId={folderId}
|
||||
commands={commands}
|
||||
onSaved={refreshCommands}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user