Files
codeg/src/contexts/terminal-context.tsx
xintaofei 4f394ea521 fix: use randomUUID fallback for non-secure contexts in terminal-context
Replace crypto.randomUUID() with the existing randomUUID() utility
that falls back to crypto.getRandomValues() over plain HTTP.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 19:21:56 +08:00

377 lines
9.9 KiB
TypeScript

"use client"
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react"
import { terminalKill } from "@/lib/api"
import { randomUUID } from "@/lib/utils"
import { useFolderContext } from "@/contexts/folder-context"
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
export interface TerminalTab {
id: string
title: string
workingDir: string
initialCommand?: string
}
const DEFAULT_HEIGHT = 300
const MIN_HEIGHT = 150
const MAX_HEIGHT = 600
interface TerminalContextValue {
isOpen: boolean
height: number
minHeight: number
maxHeight: number
toggle: () => void
setHeight: (h: number) => void
tabs: TerminalTab[]
activeTabId: string | null
exitedTerminals: Set<string>
markTerminalExited: (id: string) => void
createTerminal: () => Promise<void>
createTerminalInDirectory: (
workingDir: string,
title?: string
) => Promise<string | null>
createTerminalWithCommand: (
title: string,
command: string
) => Promise<string | null>
closeTerminal: (id: string) => void
closeOtherTerminals: (id: string) => void
closeAllTerminals: () => void
renameTerminal: (id: string, title: string) => void
switchTerminal: (id: string) => void
}
const TerminalContext = createContext<TerminalContextValue | null>(null)
export function useTerminalContext() {
const ctx = useContext(TerminalContext)
if (!ctx) {
throw new Error("useTerminalContext must be used within TerminalProvider")
}
return ctx
}
export function TerminalProvider({ children }: { children: ReactNode }) {
const { folder } = useFolderContext()
const { shortcuts } = useShortcutSettings()
const [isOpen, setIsOpen] = useState(false)
const [height, setHeightState] = useState(DEFAULT_HEIGHT)
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)
useEffect(() => {
tabsRef.current = tabs
}, [tabs])
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(() => {})
})
}, [])
const toggle = useCallback(() => {
const autoId = randomUUID()
const nextCounter = tabCounterRef.current + 1
setIsOpen((wasOpen) => !wasOpen)
// Auto-create first terminal when opening with no tabs
setTabs((currentTabs) => {
if (currentTabs.length > 0 || !folderPath) return currentTabs
tabCounterRef.current = nextCounter
return [
{
id: autoId,
title: `Terminal ${nextCounter}`,
workingDir: folderPath,
},
]
})
setActiveTabId((prev) => {
if (prev !== null) return prev
if (!folderPath) return null
return autoId
})
}, [folderPath])
const createTerminalWithCommand = useCallback(
async (title: string, command: string) => {
if (!folderPath) return null
setIsOpen(true)
const id = randomUUID()
tabCounterRef.current += 1
setTabs((prev) => [
...prev,
{ id, title, workingDir: folderPath, initialCommand: command },
])
setActiveTabId(id)
return id
},
[folderPath]
)
const createTerminalInDirectory = useCallback(
async (workingDir: string, title?: string) => {
if (!workingDir) return null
setIsOpen(true)
const id = randomUUID()
tabCounterRef.current += 1
const defaultTitle = `Terminal ${tabCounterRef.current}`
setTabs((prev) => [
...prev,
{ id, title: title ?? defaultTitle, workingDir },
])
setActiveTabId(id)
return id
},
[]
)
const createTerminal = useCallback(async () => {
if (!folderPath) return
await createTerminalInDirectory(folderPath)
}, [folderPath, createTerminalInDirectory])
const setHeight = useCallback((h: number) => {
setHeightState(Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, h)))
}, [])
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) => {
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, removeExitedTerminals]
)
const closeAllTerminals = useCallback(() => {
setTabs((prev) => {
killTerminalTabs(prev)
removeExitedTerminals(prev.map((t) => t.id))
return []
})
tabCounterRef.current = 0
setActiveTabId(null)
setIsOpen(false)
}, [killTerminalTabs, removeExitedTerminals])
const renameTerminal = useCallback((id: string, title: string) => {
setTabs((prev) => prev.map((t) => (t.id === id ? { ...t, title } : t)))
}, [])
const switchTerminal = useCallback((id: string) => {
setActiveTabId(id)
}, [])
const isInTerminalRegion = useCallback((target: EventTarget | null) => {
if (!(target instanceof Element)) return false
return Boolean(target.closest('[data-terminal-panel-region="true"]'))
}, [])
const updateLastMouseActivity = useCallback(
(target: EventTarget | null) => {
const next = isInTerminalRegion(target)
if (lastMouseActivityInTerminalRef.current === next) return
lastMouseActivityInTerminalRef.current = next
},
[isInTerminalRegion]
)
useEffect(() => {
const handlePointerActivity = (event: PointerEvent) => {
updateLastMouseActivity(event.target)
}
const handleFocusActivity = (event: FocusEvent) => {
updateLastMouseActivity(event.target)
}
window.addEventListener("pointerover", handlePointerActivity, true)
window.addEventListener("pointerdown", handlePointerActivity, true)
window.addEventListener("focusin", handleFocusActivity, true)
return () => {
window.removeEventListener("pointerover", handlePointerActivity, true)
window.removeEventListener("pointerdown", handlePointerActivity, true)
window.removeEventListener("focusin", handleFocusActivity, true)
}
}, [updateLastMouseActivity])
useEffect(() => {
if (!isOpen) {
lastMouseActivityInTerminalRef.current = false
}
}, [isOpen])
useEffect(() => {
const handleTerminalHotkeys = (event: KeyboardEvent) => {
if (!isOpen) return
const targetInTerminal = isInTerminalRegion(event.target)
const activeElementInTerminal = isInTerminalRegion(document.activeElement)
const shouldHandle =
lastMouseActivityInTerminalRef.current ||
targetInTerminal ||
activeElementInTerminal
if (!shouldHandle) return
if (matchShortcutEvent(event, shortcuts.new_terminal_tab)) {
event.preventDefault()
event.stopPropagation()
void createTerminal()
return
}
if (
activeTabId &&
matchShortcutEvent(event, shortcuts.close_current_terminal_tab)
) {
event.preventDefault()
event.stopPropagation()
closeTerminal(activeTabId)
}
}
window.addEventListener("keydown", handleTerminalHotkeys, true)
return () => {
window.removeEventListener("keydown", handleTerminalHotkeys, true)
}
}, [
activeTabId,
closeTerminal,
createTerminal,
isInTerminalRegion,
isOpen,
shortcuts.close_current_terminal_tab,
shortcuts.new_terminal_tab,
])
// Cleanup all terminals on unmount — uses ref to get current tabs
useEffect(() => {
return () => {
tabsRef.current.forEach((t) => {
terminalKill(t.id).catch(() => {})
})
}
}, [])
const value = useMemo(
() => ({
isOpen,
height,
minHeight: MIN_HEIGHT,
maxHeight: MAX_HEIGHT,
toggle,
setHeight,
tabs,
activeTabId,
exitedTerminals,
markTerminalExited,
createTerminal,
createTerminalInDirectory,
createTerminalWithCommand,
closeTerminal,
closeOtherTerminals,
closeAllTerminals,
renameTerminal,
switchTerminal,
}),
[
isOpen,
height,
toggle,
setHeight,
tabs,
activeTabId,
exitedTerminals,
markTerminalExited,
createTerminal,
createTerminalInDirectory,
createTerminalWithCommand,
closeTerminal,
closeOtherTerminals,
closeAllTerminals,
renameTerminal,
switchTerminal,
]
)
return (
<TerminalContext.Provider value={value}>
{children}
</TerminalContext.Provider>
)
}