Initial commit

This commit is contained in:
xggz
2026-03-06 22:56:13 +08:00
commit 54d1097b41
273 changed files with 92457 additions and 0 deletions

View File

@@ -0,0 +1,337 @@
"use client"
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react"
import { terminalSpawn, terminalKill } from "@/lib/tauri"
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
}
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
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 spawningRef = useRef(false)
const suppressAutoCreateRef = useRef(false)
const lastMouseActivityInTerminalRef = useRef(false)
// Keep a ref of tabs for cleanup on unmount (effect [] captures stale state)
const tabsRef = useRef(tabs)
tabsRef.current = tabs
const folderPath = folder?.path ?? ""
const killTerminalTabs = useCallback((targetTabs: TerminalTab[]) => {
targetTabs.forEach((tab) => {
terminalKill(tab.id).catch(() => {})
})
}, [])
const toggle = useCallback(() => {
setIsOpen((prev) => !prev)
}, [])
const createTerminalWithCommand = useCallback(
async (title: string, command: string) => {
if (!folderPath) return null
suppressAutoCreateRef.current = true
setIsOpen(true)
try {
const id = await terminalSpawn(folderPath, command)
tabCounterRef.current += 1
setTabs((prev) => [...prev, { id, title }])
setActiveTabId(id)
return id
} catch (err) {
console.error("Failed to spawn terminal for command:", err)
return null
} finally {
suppressAutoCreateRef.current = false
}
},
[folderPath]
)
const createTerminalInDirectory = useCallback(
async (workingDir: string, title?: string) => {
if (!workingDir || spawningRef.current) return null
suppressAutoCreateRef.current = true
setIsOpen(true)
spawningRef.current = true
try {
const id = await terminalSpawn(workingDir)
tabCounterRef.current += 1
const defaultTitle = `Terminal ${tabCounterRef.current}`
setTabs((prev) => [...prev, { id, title: title ?? defaultTitle }])
setActiveTabId(id)
return id
} catch (err) {
console.error("Failed to spawn terminal in directory:", err)
return null
} finally {
spawningRef.current = false
suppressAutoCreateRef.current = false
}
},
[]
)
const createTerminal = useCallback(async () => {
if (!folderPath) return
await createTerminalInDirectory(folderPath)
}, [folderPath, createTerminalInDirectory])
// Auto-create first terminal when panel opens with no tabs
useEffect(() => {
if (isOpen && tabs.length === 0 && !suppressAutoCreateRef.current) {
createTerminal()
}
}, [isOpen, tabs.length, createTerminal])
const setHeight = useCallback((h: number) => {
setHeightState(Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, h)))
}, [])
// No stale closure — reads current activeTabId via updater
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)
}
return next
})
setActiveTabId((prev) => (prev === id ? null : prev))
}, [])
// Auto-select last tab when active tab is removed
useEffect(() => {
if (activeTabId === null && tabs.length > 0) {
setActiveTabId(tabs[tabs.length - 1].id)
}
}, [activeTabId, tabs])
const closeOtherTerminals = useCallback(
(id: string) => {
setTabs((prev) => {
killTerminalTabs(prev.filter((t) => t.id !== id))
return prev.filter((t) => t.id === id)
})
setActiveTabId(id)
},
[killTerminalTabs]
)
const closeAllTerminals = useCallback(() => {
setTabs((prev) => {
killTerminalTabs(prev)
return []
})
tabCounterRef.current = 0
setActiveTabId(null)
setIsOpen(false)
}, [killTerminalTabs])
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,
createTerminal,
createTerminalInDirectory,
createTerminalWithCommand,
closeTerminal,
closeOtherTerminals,
closeAllTerminals,
renameTerminal,
switchTerminal,
}),
[
isOpen,
height,
toggle,
setHeight,
tabs,
activeTabId,
createTerminal,
createTerminalInDirectory,
createTerminalWithCommand,
closeTerminal,
closeOtherTerminals,
closeAllTerminals,
renameTerminal,
switchTerminal,
]
)
return (
<TerminalContext.Provider value={value}>
{children}
</TerminalContext.Provider>
)
}