Initial commit
This commit is contained in:
1780
src/contexts/acp-connections-context.tsx
Normal file
1780
src/contexts/acp-connections-context.tsx
Normal file
File diff suppressed because it is too large
Load Diff
110
src/contexts/alert-context.tsx
Normal file
110
src/contexts/alert-context.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useReducer,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import type { FixActionKind } from "@/lib/types"
|
||||
|
||||
export type AlertLevel = "error" | "warning"
|
||||
|
||||
export interface AlertAction {
|
||||
label: string
|
||||
kind: FixActionKind
|
||||
payload: string
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
id: string
|
||||
level: AlertLevel
|
||||
message: string
|
||||
detail?: string
|
||||
actions?: AlertAction[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
interface AlertContextValue {
|
||||
alerts: Alert[]
|
||||
hasAlerts: boolean
|
||||
pushAlert: (
|
||||
level: AlertLevel,
|
||||
message: string,
|
||||
detail?: string,
|
||||
actions?: AlertAction[]
|
||||
) => string
|
||||
dismissAlert: (id: string) => void
|
||||
clearAll: () => void
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: "push"; alert: Alert }
|
||||
| { type: "dismiss"; id: string }
|
||||
| { type: "clear_all" }
|
||||
|
||||
let seq = 0
|
||||
const MAX_ALERTS = 50
|
||||
|
||||
function reducer(state: Alert[], action: Action): Alert[] {
|
||||
switch (action.type) {
|
||||
case "push": {
|
||||
const next = [...state, action.alert]
|
||||
return next.length > MAX_ALERTS ? next.slice(-MAX_ALERTS) : next
|
||||
}
|
||||
case "dismiss":
|
||||
return state.filter((a) => a.id !== action.id)
|
||||
case "clear_all":
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const AlertContext = createContext<AlertContextValue | null>(null)
|
||||
|
||||
export function useAlertContext() {
|
||||
const ctx = useContext(AlertContext)
|
||||
if (!ctx) {
|
||||
throw new Error("useAlertContext must be used within AlertProvider")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function AlertProvider({ children }: { children: ReactNode }) {
|
||||
const [alerts, dispatch] = useReducer(reducer, [])
|
||||
|
||||
const pushAlert = useCallback(
|
||||
(
|
||||
level: AlertLevel,
|
||||
message: string,
|
||||
detail?: string,
|
||||
actions?: AlertAction[]
|
||||
) => {
|
||||
const id = `alert-${++seq}-${Date.now()}`
|
||||
dispatch({
|
||||
type: "push",
|
||||
alert: { id, level, message, detail, actions, timestamp: Date.now() },
|
||||
})
|
||||
return id
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const dismissAlert = useCallback((id: string) => {
|
||||
dispatch({ type: "dismiss", id })
|
||||
}, [])
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
dispatch({ type: "clear_all" })
|
||||
}, [])
|
||||
|
||||
const hasAlerts = alerts.length > 0
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ alerts, hasAlerts, pushAlert, dismissAlert, clearAll }),
|
||||
[alerts, hasAlerts, pushAlert, dismissAlert, clearAll]
|
||||
)
|
||||
|
||||
return <AlertContext.Provider value={value}>{children}</AlertContext.Provider>
|
||||
}
|
||||
113
src/contexts/aux-panel-context.tsx
Normal file
113
src/contexts/aux-panel-context.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import {
|
||||
loadPersistedPanelState,
|
||||
savePersistedPanelState,
|
||||
} from "@/lib/panel-state-storage"
|
||||
|
||||
export type AuxPanelTab = "file_tree" | "changes" | "git_log" | "session_files"
|
||||
|
||||
const DEFAULT_WIDTH = 320
|
||||
const MIN_WIDTH = 200
|
||||
const MAX_WIDTH = 500
|
||||
const DEFAULT_IS_OPEN = false
|
||||
|
||||
interface AuxPanelContextValue {
|
||||
isOpen: boolean
|
||||
width: number
|
||||
minWidth: number
|
||||
maxWidth: number
|
||||
activeTab: AuxPanelTab
|
||||
toggle: () => void
|
||||
setWidth: (w: number) => void
|
||||
setActiveTab: (tab: AuxPanelTab) => void
|
||||
openTab: (tab: AuxPanelTab) => void
|
||||
}
|
||||
|
||||
const AuxPanelContext = createContext<AuxPanelContextValue | null>(null)
|
||||
|
||||
export function useAuxPanelContext() {
|
||||
const ctx = useContext(AuxPanelContext)
|
||||
if (!ctx) {
|
||||
throw new Error("useAuxPanelContext must be used within AuxPanelProvider")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
function clampWidth(width: number) {
|
||||
return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width))
|
||||
}
|
||||
|
||||
interface AuxPanelProviderProps {
|
||||
children: ReactNode
|
||||
folderId: number
|
||||
}
|
||||
|
||||
export function AuxPanelProvider({
|
||||
children,
|
||||
folderId,
|
||||
}: AuxPanelProviderProps) {
|
||||
const storageKey = useMemo(
|
||||
() => `folder:${folderId}:right-sidebar`,
|
||||
[folderId]
|
||||
)
|
||||
const [isOpen, setIsOpen] = useState(DEFAULT_IS_OPEN)
|
||||
const [width, setWidthState] = useState(DEFAULT_WIDTH)
|
||||
const [restored, setRestored] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<AuxPanelTab>("session_files")
|
||||
|
||||
const toggle = useCallback(() => setIsOpen((prev) => !prev), [])
|
||||
|
||||
const setWidth = useCallback((w: number) => {
|
||||
setWidthState(clampWidth(w))
|
||||
}, [])
|
||||
|
||||
const openTab = useCallback((tab: AuxPanelTab) => {
|
||||
setActiveTab(tab)
|
||||
setIsOpen(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const stored = loadPersistedPanelState(storageKey)
|
||||
// Hydrate from localStorage after mount to keep SSR/CSR markup consistent.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsOpen(stored?.isOpen ?? DEFAULT_IS_OPEN)
|
||||
setWidthState(clampWidth(stored?.width ?? DEFAULT_WIDTH))
|
||||
setRestored(true)
|
||||
}, [storageKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!restored) return
|
||||
savePersistedPanelState(storageKey, { isOpen, width })
|
||||
}, [isOpen, restored, storageKey, width])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
isOpen,
|
||||
width,
|
||||
minWidth: MIN_WIDTH,
|
||||
maxWidth: MAX_WIDTH,
|
||||
activeTab,
|
||||
toggle,
|
||||
setWidth,
|
||||
setActiveTab,
|
||||
openTab,
|
||||
}),
|
||||
[isOpen, width, activeTab, toggle, setWidth, openTab]
|
||||
)
|
||||
|
||||
return (
|
||||
<AuxPanelContext.Provider value={value}>
|
||||
{children}
|
||||
</AuxPanelContext.Provider>
|
||||
)
|
||||
}
|
||||
269
src/contexts/folder-context.tsx
Normal file
269
src/contexts/folder-context.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { getFolder, listFolderConversations } from "@/lib/tauri"
|
||||
import type {
|
||||
AgentType,
|
||||
AgentStats,
|
||||
DbConversationSummary,
|
||||
FolderDetail,
|
||||
} from "@/lib/types"
|
||||
|
||||
interface SelectedConversation {
|
||||
id: number
|
||||
agentType: AgentType
|
||||
}
|
||||
|
||||
interface NewConversationState {
|
||||
agentType: AgentType
|
||||
workingDir: string
|
||||
}
|
||||
|
||||
interface FolderContextValue {
|
||||
folder: FolderDetail | null
|
||||
folderId: number
|
||||
folderLoading: boolean
|
||||
|
||||
conversations: DbConversationSummary[]
|
||||
loading: boolean
|
||||
refreshing: boolean
|
||||
error: string | null
|
||||
|
||||
selectedConversation: SelectedConversation | null
|
||||
selectConversation: (id: number, agentType: AgentType) => void
|
||||
clearSelection: () => void
|
||||
|
||||
newConversation: NewConversationState | null
|
||||
startNewConversation: (agentType: AgentType, workingDir: string) => void
|
||||
cancelNewConversation: () => void
|
||||
|
||||
stats: AgentStats | null
|
||||
|
||||
refreshConversations: () => void
|
||||
}
|
||||
|
||||
const FolderContext = createContext<FolderContextValue | null>(null)
|
||||
|
||||
export function useFolderContext() {
|
||||
const ctx = useContext(FolderContext)
|
||||
if (!ctx) {
|
||||
throw new Error("useFolderContext must be used within FolderProvider")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
function computeStats(conversations: DbConversationSummary[]): AgentStats {
|
||||
const byAgent = new Map<AgentType, number>()
|
||||
let totalMessages = 0
|
||||
|
||||
for (const s of conversations) {
|
||||
byAgent.set(s.agent_type, (byAgent.get(s.agent_type) ?? 0) + 1)
|
||||
totalMessages += s.message_count
|
||||
}
|
||||
|
||||
return {
|
||||
total_conversations: conversations.length,
|
||||
total_messages: totalMessages,
|
||||
by_agent: Array.from(byAgent.entries()).map(([agent_type, count]) => ({
|
||||
agent_type,
|
||||
conversation_count: count,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/** Module-level cache — survives component unmounts / page navigations. */
|
||||
const cache = new Map<string, DbConversationSummary[]>()
|
||||
|
||||
interface FolderProviderProps {
|
||||
children: ReactNode
|
||||
folderId: number
|
||||
initialConversationId?: number | null
|
||||
initialAgentType?: AgentType | null
|
||||
}
|
||||
|
||||
export function FolderProvider({
|
||||
children,
|
||||
folderId,
|
||||
initialConversationId,
|
||||
initialAgentType,
|
||||
}: FolderProviderProps) {
|
||||
// Folder info
|
||||
const [folder, setFolder] = useState<FolderDetail | null>(null)
|
||||
const [folderLoading, setFolderLoading] = useState(true)
|
||||
|
||||
// Conversations
|
||||
const cacheKey = String(folderId)
|
||||
const [conversations, setConversations] = useState<DbConversationSummary[]>(
|
||||
() => cache.get(cacheKey) ?? []
|
||||
)
|
||||
const [loading, setLoading] = useState(() => !cache.has(cacheKey))
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [selectedConversation, setSelectedConversation] =
|
||||
useState<SelectedConversation | null>(() => {
|
||||
if (initialConversationId != null && initialAgentType) {
|
||||
return { id: initialConversationId, agentType: initialAgentType }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// Sync selection when URL params change (e.g. navigation)
|
||||
useEffect(() => {
|
||||
if (initialConversationId != null && initialAgentType) {
|
||||
setSelectedConversation({
|
||||
id: initialConversationId,
|
||||
agentType: initialAgentType,
|
||||
})
|
||||
}
|
||||
}, [initialConversationId, initialAgentType])
|
||||
const [newConversation, setNewConversation] =
|
||||
useState<NewConversationState | null>(null)
|
||||
|
||||
const mountedRef = useRef(true)
|
||||
|
||||
// Fetch folder info
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setFolderLoading(true)
|
||||
getFolder(folderId)
|
||||
.then((f) => {
|
||||
if (!cancelled) {
|
||||
setFolder(f)
|
||||
setFolderLoading(false)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[FolderProvider] getFolder failed:", err)
|
||||
if (!cancelled) setFolderLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [folderId])
|
||||
|
||||
const fetchConversations = useCallback(async () => {
|
||||
const cached = cache.get(cacheKey)
|
||||
|
||||
if (cached) {
|
||||
setConversations(cached)
|
||||
setLoading(false)
|
||||
setRefreshing(true)
|
||||
} else {
|
||||
setLoading(true)
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null)
|
||||
const data = await listFolderConversations({
|
||||
folder_id: folderId,
|
||||
status: null,
|
||||
})
|
||||
if (!mountedRef.current) return
|
||||
cache.set(cacheKey, data)
|
||||
setConversations(data)
|
||||
} catch (e) {
|
||||
if (!mountedRef.current) return
|
||||
if (!cached) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
}, [folderId, cacheKey])
|
||||
|
||||
useEffect(() => {
|
||||
fetchConversations()
|
||||
}, [fetchConversations])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selectConversation = useCallback((id: number, agentType: AgentType) => {
|
||||
setSelectedConversation({ id, agentType })
|
||||
setNewConversation(null)
|
||||
}, [])
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedConversation(null)
|
||||
}, [])
|
||||
|
||||
const startNewConversation = useCallback(
|
||||
(agentType: AgentType, workingDir: string) => {
|
||||
setNewConversation({ agentType, workingDir })
|
||||
setSelectedConversation(null)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const cancelNewConversation = useCallback(() => {
|
||||
setNewConversation(null)
|
||||
}, [])
|
||||
|
||||
const refreshConversations = useCallback(() => {
|
||||
cache.delete(cacheKey)
|
||||
fetchConversations()
|
||||
}, [cacheKey, fetchConversations])
|
||||
|
||||
const stats = useMemo(
|
||||
() => (conversations.length > 0 ? computeStats(conversations) : null),
|
||||
[conversations]
|
||||
)
|
||||
|
||||
const value = useMemo<FolderContextValue>(
|
||||
() => ({
|
||||
folder,
|
||||
folderId,
|
||||
folderLoading,
|
||||
conversations,
|
||||
loading,
|
||||
refreshing,
|
||||
error,
|
||||
selectedConversation,
|
||||
selectConversation,
|
||||
clearSelection,
|
||||
newConversation,
|
||||
startNewConversation,
|
||||
cancelNewConversation,
|
||||
stats,
|
||||
refreshConversations,
|
||||
}),
|
||||
[
|
||||
folder,
|
||||
folderId,
|
||||
folderLoading,
|
||||
conversations,
|
||||
loading,
|
||||
refreshing,
|
||||
error,
|
||||
selectedConversation,
|
||||
selectConversation,
|
||||
clearSelection,
|
||||
newConversation,
|
||||
startNewConversation,
|
||||
cancelNewConversation,
|
||||
stats,
|
||||
refreshConversations,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<FolderContext.Provider value={value}>{children}</FolderContext.Provider>
|
||||
)
|
||||
}
|
||||
35
src/contexts/session-stats-context.tsx
Normal file
35
src/contexts/session-stats-context.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext, useState, useCallback } from "react"
|
||||
import type { SessionStats } from "@/lib/types"
|
||||
|
||||
interface SessionStatsContextValue {
|
||||
sessionStats: SessionStats | null
|
||||
setSessionStats: (stats: SessionStats | null) => void
|
||||
}
|
||||
|
||||
const SessionStatsContext = createContext<SessionStatsContextValue>({
|
||||
sessionStats: null,
|
||||
setSessionStats: () => {},
|
||||
})
|
||||
|
||||
export function SessionStatsProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [sessionStats, setSessionStatsRaw] = useState<SessionStats | null>(null)
|
||||
const setSessionStats = useCallback(
|
||||
(stats: SessionStats | null) => setSessionStatsRaw(stats),
|
||||
[]
|
||||
)
|
||||
return (
|
||||
<SessionStatsContext.Provider value={{ sessionStats, setSessionStats }}>
|
||||
{children}
|
||||
</SessionStatsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSessionStats() {
|
||||
return useContext(SessionStatsContext)
|
||||
}
|
||||
94
src/contexts/sidebar-context.tsx
Normal file
94
src/contexts/sidebar-context.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import {
|
||||
loadPersistedPanelState,
|
||||
savePersistedPanelState,
|
||||
} from "@/lib/panel-state-storage"
|
||||
|
||||
const DEFAULT_WIDTH = 320
|
||||
const MIN_WIDTH = 200
|
||||
const MAX_WIDTH = 600
|
||||
const DEFAULT_IS_OPEN = true
|
||||
|
||||
interface SidebarContextValue {
|
||||
isOpen: boolean
|
||||
width: number
|
||||
minWidth: number
|
||||
maxWidth: number
|
||||
toggle: () => void
|
||||
setWidth: (w: number) => void
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContextValue | null>(null)
|
||||
|
||||
export function useSidebarContext() {
|
||||
const ctx = useContext(SidebarContext)
|
||||
if (!ctx) {
|
||||
throw new Error("useSidebarContext must be used within SidebarProvider")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
function clampWidth(width: number) {
|
||||
return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width))
|
||||
}
|
||||
|
||||
interface SidebarProviderProps {
|
||||
children: ReactNode
|
||||
folderId: number
|
||||
}
|
||||
|
||||
export function SidebarProvider({ children, folderId }: SidebarProviderProps) {
|
||||
const storageKey = useMemo(
|
||||
() => `folder:${folderId}:left-sidebar`,
|
||||
[folderId]
|
||||
)
|
||||
const [isOpen, setIsOpen] = useState(DEFAULT_IS_OPEN)
|
||||
const [width, setWidthState] = useState(DEFAULT_WIDTH)
|
||||
const [restored, setRestored] = useState(false)
|
||||
|
||||
const toggle = useCallback(() => setIsOpen((prev) => !prev), [])
|
||||
|
||||
const setWidth = useCallback((w: number) => {
|
||||
setWidthState(clampWidth(w))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const stored = loadPersistedPanelState(storageKey)
|
||||
// Hydrate from localStorage after mount to keep SSR/CSR markup consistent.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsOpen(stored?.isOpen ?? DEFAULT_IS_OPEN)
|
||||
setWidthState(clampWidth(stored?.width ?? DEFAULT_WIDTH))
|
||||
setRestored(true)
|
||||
}, [storageKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!restored) return
|
||||
savePersistedPanelState(storageKey, { isOpen, width })
|
||||
}, [isOpen, restored, storageKey, width])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
isOpen,
|
||||
width,
|
||||
minWidth: MIN_WIDTH,
|
||||
maxWidth: MAX_WIDTH,
|
||||
toggle,
|
||||
setWidth,
|
||||
}),
|
||||
[isOpen, width, toggle, setWidth]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={value}>{children}</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
632
src/contexts/tab-context.tsx
Normal file
632
src/contexts/tab-context.tsx
Normal file
@@ -0,0 +1,632 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||
import { saveFolderOpenedConversations } from "@/lib/tauri"
|
||||
import type {
|
||||
AgentType,
|
||||
ConversationStatus,
|
||||
OpenedConversation,
|
||||
} from "@/lib/types"
|
||||
|
||||
interface TabItemInternal {
|
||||
id: string
|
||||
kind: "conversation" | "new_conversation"
|
||||
conversationId?: number
|
||||
agentType: AgentType
|
||||
title: string
|
||||
isPinned: boolean
|
||||
workingDir?: string
|
||||
status?: ConversationStatus
|
||||
}
|
||||
|
||||
export type TabItem = TabItemInternal
|
||||
|
||||
interface TabContextValue {
|
||||
tabs: TabItem[]
|
||||
activeTabId: string | null
|
||||
openTab: (
|
||||
conversationId: number,
|
||||
agentType: AgentType,
|
||||
pin?: boolean,
|
||||
title?: string
|
||||
) => void
|
||||
closeTab: (tabId: string) => void
|
||||
closeOtherTabs: (tabId: string) => void
|
||||
closeAllTabs: () => void
|
||||
switchTab: (tabId: string) => void
|
||||
pinTab: (tabId: string) => void
|
||||
openNewConversationTab: (agentType: AgentType, workingDir: string) => void
|
||||
promoteNewConversationTab: (
|
||||
tabId: string,
|
||||
conversationId: number,
|
||||
agentType: AgentType,
|
||||
title: string
|
||||
) => void
|
||||
linkTabConversation: (
|
||||
tabId: string,
|
||||
conversationId: number,
|
||||
agentType: AgentType,
|
||||
title: string
|
||||
) => void
|
||||
reorderTabs: (reorderedTabs: TabItem[]) => void
|
||||
}
|
||||
|
||||
const TabContext = createContext<TabContextValue | null>(null)
|
||||
|
||||
export function useTabContext() {
|
||||
const ctx = useContext(TabContext)
|
||||
if (!ctx) {
|
||||
throw new Error("useTabContext must be used within TabProvider")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
function makeConversationTabId(
|
||||
agentType: AgentType,
|
||||
conversationId: number
|
||||
): string {
|
||||
return `conv-${agentType}-${conversationId}`
|
||||
}
|
||||
|
||||
function makeNewConversationTabId(): string {
|
||||
return `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a tab that represents the given conversation, regardless of whether
|
||||
* it has been promoted to a canonical id yet. Checks canonical id first,
|
||||
* then falls back to matching by conversationId + agentType (covers the
|
||||
* linked-but-not-yet-promoted new_conversation tabs).
|
||||
*/
|
||||
function findTabIndexForConversation(
|
||||
tabs: TabItemInternal[],
|
||||
agentType: AgentType,
|
||||
conversationId: number
|
||||
): number {
|
||||
const canonicalId = makeConversationTabId(agentType, conversationId)
|
||||
const idx = tabs.findIndex((t) => t.id === canonicalId)
|
||||
if (idx >= 0) return idx
|
||||
return tabs.findIndex(
|
||||
(t) => t.conversationId === conversationId && t.agentType === agentType
|
||||
)
|
||||
}
|
||||
|
||||
interface TabProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function TabProvider({ children }: TabProviderProps) {
|
||||
const { activateConversationPane } = useWorkspaceContext()
|
||||
const {
|
||||
folder,
|
||||
folderId,
|
||||
selectedConversation,
|
||||
selectConversation,
|
||||
clearSelection,
|
||||
startNewConversation,
|
||||
cancelNewConversation,
|
||||
conversations,
|
||||
} = useFolderContext()
|
||||
|
||||
const [rawTabs, setTabs] = useState<TabItemInternal[]>(() => {
|
||||
if (selectedConversation) {
|
||||
const tabId = makeConversationTabId(
|
||||
selectedConversation.agentType,
|
||||
selectedConversation.id
|
||||
)
|
||||
return [
|
||||
{
|
||||
id: tabId,
|
||||
kind: "conversation" as const,
|
||||
conversationId: selectedConversation.id,
|
||||
agentType: selectedConversation.agentType,
|
||||
title: "Loading...",
|
||||
isPinned: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const [activeTabId, setActiveTabId] = useState<string | null>(() => {
|
||||
if (selectedConversation) {
|
||||
return makeConversationTabId(
|
||||
selectedConversation.agentType,
|
||||
selectedConversation.id
|
||||
)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// Refs for volatile state — used in callbacks to avoid re-creation
|
||||
const activeTabIdRef = useRef(activeTabId)
|
||||
useEffect(() => {
|
||||
activeTabIdRef.current = activeTabId
|
||||
}, [activeTabId])
|
||||
|
||||
const rawTabsRef = useRef(rawTabs)
|
||||
useEffect(() => {
|
||||
rawTabsRef.current = rawTabs
|
||||
}, [rawTabs])
|
||||
|
||||
const conversationsRef = useRef(conversations)
|
||||
useEffect(() => {
|
||||
conversationsRef.current = conversations
|
||||
}, [conversations])
|
||||
|
||||
// Restore tabs from folder.opened_conversations when folder first loads
|
||||
const [restoredFolderId, setRestoredFolderId] = useState<number | null>(() =>
|
||||
selectedConversation ? folderId : null
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!folder) return
|
||||
if (restoredFolderId === folder.id) return
|
||||
|
||||
let cancelled = false
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
|
||||
setRestoredFolderId(folder.id)
|
||||
|
||||
const opened = folder.opened_conversations
|
||||
if (opened.length === 0) return
|
||||
|
||||
const restoredTabs: TabItemInternal[] = opened.map((oc) => ({
|
||||
id: makeConversationTabId(oc.agent_type, oc.conversation_id),
|
||||
kind: "conversation" as const,
|
||||
conversationId: oc.conversation_id,
|
||||
agentType: oc.agent_type,
|
||||
title: "Loading...",
|
||||
isPinned: oc.is_pinned,
|
||||
}))
|
||||
|
||||
setTabs(restoredTabs)
|
||||
|
||||
const activeItem = opened.find((oc) => oc.is_active)
|
||||
const target = activeItem ?? opened[0]
|
||||
setActiveTabId(
|
||||
makeConversationTabId(target.agent_type, target.conversation_id)
|
||||
)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [folder, restoredFolderId])
|
||||
|
||||
// Sync restored active tab to FolderProvider (deferred to avoid
|
||||
// updating parent during child render)
|
||||
const prevRestoredIdRef = useRef(restoredFolderId)
|
||||
useEffect(() => {
|
||||
if (restoredFolderId === prevRestoredIdRef.current) return
|
||||
prevRestoredIdRef.current = restoredFolderId
|
||||
|
||||
if (!folder || folder.opened_conversations.length === 0) return
|
||||
const opened = folder.opened_conversations
|
||||
const target = opened.find((oc) => oc.is_active) ?? opened[0]
|
||||
selectConversation(target.conversation_id, target.agent_type)
|
||||
}, [restoredFolderId, folder, selectConversation])
|
||||
|
||||
// Debounced save to DB
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const skipSaveRef = useRef(true) // skip saving until first restore completes
|
||||
|
||||
useEffect(() => {
|
||||
// Skip the initial render and restoration phase
|
||||
if (skipSaveRef.current) {
|
||||
if (restoredFolderId != null) {
|
||||
skipSaveRef.current = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
}
|
||||
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
const items: OpenedConversation[] = rawTabs
|
||||
.filter(
|
||||
(t): t is TabItemInternal & { conversationId: number } =>
|
||||
t.conversationId != null
|
||||
)
|
||||
.map((t, i) => ({
|
||||
conversation_id: t.conversationId,
|
||||
agent_type: t.agentType,
|
||||
position: i,
|
||||
is_active: t.id === activeTabId,
|
||||
is_pinned: t.isPinned,
|
||||
}))
|
||||
|
||||
saveFolderOpenedConversations(folderId, items).catch(() => {
|
||||
// Silently ignore save errors
|
||||
})
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [rawTabs, activeTabId, folderId, restoredFolderId])
|
||||
|
||||
// Pre-index conversations for O(1) lookup in tabs derivation
|
||||
const conversationMap = useMemo(() => {
|
||||
const m = new Map<string, (typeof conversations)[number]>()
|
||||
for (const c of conversations) {
|
||||
m.set(`${c.agent_type}-${c.id}`, c)
|
||||
}
|
||||
return m
|
||||
}, [conversations])
|
||||
|
||||
// Derive tabs with up-to-date titles and status from conversations
|
||||
const tabs = useMemo(() => {
|
||||
if (conversationMap.size === 0) return rawTabs
|
||||
return rawTabs.map((tab) => {
|
||||
if (tab.conversationId != null) {
|
||||
const conv = conversationMap.get(
|
||||
`${tab.agentType}-${tab.conversationId}`
|
||||
)
|
||||
if (conv) {
|
||||
const newTitle = conv.title || "Untitled conversation"
|
||||
const newStatus = conv.status as ConversationStatus | undefined
|
||||
if (tab.title !== newTitle || tab.status !== newStatus) {
|
||||
return { ...tab, title: newTitle, status: newStatus }
|
||||
}
|
||||
}
|
||||
}
|
||||
return tab
|
||||
})
|
||||
}, [rawTabs, conversationMap])
|
||||
|
||||
const syncFolderContext = useCallback(
|
||||
(tab: TabItem | null) => {
|
||||
if (!tab) {
|
||||
clearSelection()
|
||||
cancelNewConversation()
|
||||
return
|
||||
}
|
||||
if (tab.kind === "conversation" && tab.conversationId != null) {
|
||||
selectConversation(tab.conversationId, tab.agentType)
|
||||
} else if (tab.kind === "new_conversation" && tab.workingDir) {
|
||||
startNewConversation(tab.agentType, tab.workingDir)
|
||||
}
|
||||
},
|
||||
[
|
||||
selectConversation,
|
||||
clearSelection,
|
||||
startNewConversation,
|
||||
cancelNewConversation,
|
||||
]
|
||||
)
|
||||
|
||||
const openTab = useCallback(
|
||||
(
|
||||
conversationId: number,
|
||||
agentType: AgentType,
|
||||
pin = false,
|
||||
title?: string
|
||||
) => {
|
||||
let activateTabId: string | undefined
|
||||
|
||||
setTabs((prev) => {
|
||||
const existingIndex = findTabIndexForConversation(
|
||||
prev,
|
||||
agentType,
|
||||
conversationId
|
||||
)
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
activateTabId = prev[existingIndex].id
|
||||
if (pin && !prev[existingIndex].isPinned) {
|
||||
const updated = [...prev]
|
||||
updated[existingIndex] = {
|
||||
...updated[existingIndex],
|
||||
isPinned: true,
|
||||
}
|
||||
return updated
|
||||
}
|
||||
return prev
|
||||
}
|
||||
|
||||
// Resolve title from conversations list (via ref)
|
||||
const resolvedTitle =
|
||||
title ??
|
||||
conversationsRef.current.find(
|
||||
(c) => c.id === conversationId && c.agent_type === agentType
|
||||
)?.title ??
|
||||
"Untitled conversation"
|
||||
|
||||
const tabId = makeConversationTabId(agentType, conversationId)
|
||||
activateTabId = tabId
|
||||
const newTab: TabItemInternal = {
|
||||
id: tabId,
|
||||
kind: "conversation",
|
||||
conversationId,
|
||||
agentType,
|
||||
title: resolvedTitle,
|
||||
isPinned: pin,
|
||||
}
|
||||
|
||||
if (pin) {
|
||||
return [...prev, newTab]
|
||||
}
|
||||
|
||||
// Preview (not pinned): replace existing preview tab
|
||||
const previewIndex = prev.findIndex((t) => !t.isPinned)
|
||||
if (previewIndex >= 0) {
|
||||
const updated = [...prev]
|
||||
updated[previewIndex] = newTab
|
||||
return updated
|
||||
}
|
||||
|
||||
return [...prev, newTab]
|
||||
})
|
||||
|
||||
if (activateTabId) {
|
||||
setActiveTabId(activateTabId)
|
||||
}
|
||||
selectConversation(conversationId, agentType)
|
||||
activateConversationPane()
|
||||
},
|
||||
[activateConversationPane, selectConversation]
|
||||
)
|
||||
|
||||
const makeReplacementNewConversationTab = useCallback(
|
||||
(preferred?: TabItemInternal): TabItemInternal => ({
|
||||
id: makeNewConversationTabId(),
|
||||
kind: "new_conversation",
|
||||
agentType: preferred?.agentType ?? "codex",
|
||||
title: "New Conversation",
|
||||
isPinned: true,
|
||||
workingDir: preferred?.workingDir ?? folder?.path,
|
||||
}),
|
||||
[folder?.path]
|
||||
)
|
||||
|
||||
const closeTab = useCallback(
|
||||
(tabId: string) => {
|
||||
let neighborToSync: TabItemInternal | undefined
|
||||
|
||||
setTabs((prev) => {
|
||||
const index = prev.findIndex((t) => t.id === tabId)
|
||||
if (index < 0) return prev
|
||||
|
||||
const closingTab = prev[index]
|
||||
const next = prev.filter((t) => t.id !== tabId)
|
||||
|
||||
if (next.length === 0) {
|
||||
const replacementTab = makeReplacementNewConversationTab(closingTab)
|
||||
neighborToSync = replacementTab
|
||||
return [replacementTab]
|
||||
}
|
||||
|
||||
// If closing the active tab, pick a neighbor to activate
|
||||
if (tabId === activeTabIdRef.current) {
|
||||
// Prefer right neighbor, then left
|
||||
const newIndex = Math.min(index, next.length - 1)
|
||||
neighborToSync = next[newIndex]
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
|
||||
// Sync folder context outside the updater to avoid
|
||||
// updating FolderProvider state during TabProvider render
|
||||
if (neighborToSync) {
|
||||
setActiveTabId(neighborToSync.id)
|
||||
syncFolderContext(neighborToSync)
|
||||
activateConversationPane()
|
||||
}
|
||||
},
|
||||
[
|
||||
activateConversationPane,
|
||||
makeReplacementNewConversationTab,
|
||||
syncFolderContext,
|
||||
]
|
||||
)
|
||||
|
||||
const closeOtherTabs = useCallback(
|
||||
(tabId: string) => {
|
||||
setTabs((prev) => {
|
||||
const kept = prev.filter((t) => t.id === tabId)
|
||||
return kept.length === prev.length ? prev : kept
|
||||
})
|
||||
|
||||
const tab = rawTabsRef.current.find((t) => t.id === tabId)
|
||||
if (tab) {
|
||||
setActiveTabId(tabId)
|
||||
syncFolderContext(tab)
|
||||
}
|
||||
},
|
||||
[syncFolderContext]
|
||||
)
|
||||
|
||||
const closeAllTabs = useCallback(() => {
|
||||
const seedTab =
|
||||
rawTabsRef.current.find(
|
||||
(t) => t.kind === "new_conversation" && t.workingDir
|
||||
) ??
|
||||
rawTabsRef.current.find((t) => t.id === activeTabIdRef.current) ??
|
||||
rawTabsRef.current[0]
|
||||
|
||||
const replacementTab = makeReplacementNewConversationTab(seedTab)
|
||||
setTabs([replacementTab])
|
||||
setActiveTabId(replacementTab.id)
|
||||
syncFolderContext(replacementTab)
|
||||
activateConversationPane()
|
||||
}, [
|
||||
activateConversationPane,
|
||||
makeReplacementNewConversationTab,
|
||||
syncFolderContext,
|
||||
])
|
||||
|
||||
const switchTab = useCallback(
|
||||
(tabId: string) => {
|
||||
const tab = rawTabsRef.current.find((t) => t.id === tabId)
|
||||
if (!tab) return
|
||||
|
||||
setActiveTabId(tabId)
|
||||
syncFolderContext(tab)
|
||||
activateConversationPane()
|
||||
},
|
||||
[activateConversationPane, syncFolderContext]
|
||||
)
|
||||
|
||||
const pinTab = useCallback((tabId: string) => {
|
||||
setTabs((prev) =>
|
||||
prev.map((t) => (t.id === tabId ? { ...t, isPinned: true } : t))
|
||||
)
|
||||
}, [])
|
||||
|
||||
const reorderTabs = useCallback(
|
||||
(reorderedTabs: TabItem[]) => setTabs(reorderedTabs),
|
||||
[]
|
||||
)
|
||||
|
||||
const openNewConversationTab = useCallback(
|
||||
(agentType: AgentType, workingDir: string) => {
|
||||
const existingTab = rawTabsRef.current.find(
|
||||
(t) =>
|
||||
t.kind === "new_conversation" &&
|
||||
t.agentType === agentType &&
|
||||
!t.conversationId
|
||||
)
|
||||
|
||||
if (existingTab) {
|
||||
setActiveTabId(existingTab.id)
|
||||
syncFolderContext(existingTab)
|
||||
activateConversationPane()
|
||||
return
|
||||
}
|
||||
|
||||
const tabId = makeNewConversationTabId()
|
||||
const newTab: TabItemInternal = {
|
||||
id: tabId,
|
||||
kind: "new_conversation",
|
||||
agentType,
|
||||
title: "New Conversation",
|
||||
isPinned: true,
|
||||
workingDir,
|
||||
}
|
||||
|
||||
setTabs((prev) => [...prev, newTab])
|
||||
setActiveTabId(tabId)
|
||||
startNewConversation(agentType, workingDir)
|
||||
activateConversationPane()
|
||||
},
|
||||
[activateConversationPane, startNewConversation, syncFolderContext]
|
||||
)
|
||||
|
||||
const linkTabConversation = useCallback(
|
||||
(
|
||||
tabId: string,
|
||||
conversationId: number,
|
||||
agentType: AgentType,
|
||||
title: string
|
||||
) => {
|
||||
setTabs((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === tabId ? { ...t, conversationId, agentType, title } : t
|
||||
)
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const promoteNewConversationTab = useCallback(
|
||||
(
|
||||
tabId: string,
|
||||
conversationId: number,
|
||||
agentType: AgentType,
|
||||
title: string
|
||||
) => {
|
||||
let activateId: string | undefined
|
||||
|
||||
setTabs((prev) => {
|
||||
const index = prev.findIndex((t) => t.id === tabId)
|
||||
if (index < 0) return prev
|
||||
|
||||
const newId = makeConversationTabId(agentType, conversationId)
|
||||
|
||||
// Check if a *different* tab already represents this conversation
|
||||
const dupeIndex = findTabIndexForConversation(
|
||||
prev,
|
||||
agentType,
|
||||
conversationId
|
||||
)
|
||||
if (dupeIndex >= 0 && dupeIndex !== index) {
|
||||
activateId = prev[dupeIndex].id
|
||||
return prev.filter((t) => t.id !== tabId)
|
||||
}
|
||||
|
||||
const promoted: TabItemInternal = {
|
||||
...prev[index],
|
||||
id: newId,
|
||||
kind: "conversation",
|
||||
conversationId,
|
||||
agentType,
|
||||
title,
|
||||
isPinned: true,
|
||||
}
|
||||
activateId = newId
|
||||
|
||||
const updated = [...prev]
|
||||
updated[index] = promoted
|
||||
return updated
|
||||
})
|
||||
|
||||
if (activateId) {
|
||||
setActiveTabId(activateId)
|
||||
selectConversation(conversationId, agentType)
|
||||
activateConversationPane()
|
||||
}
|
||||
},
|
||||
[activateConversationPane, selectConversation]
|
||||
)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
tabs,
|
||||
activeTabId,
|
||||
openTab,
|
||||
closeTab,
|
||||
closeOtherTabs,
|
||||
closeAllTabs,
|
||||
switchTab,
|
||||
pinTab,
|
||||
openNewConversationTab,
|
||||
promoteNewConversationTab,
|
||||
linkTabConversation,
|
||||
reorderTabs,
|
||||
}),
|
||||
[
|
||||
tabs,
|
||||
activeTabId,
|
||||
openTab,
|
||||
closeTab,
|
||||
closeOtherTabs,
|
||||
closeAllTabs,
|
||||
switchTab,
|
||||
pinTab,
|
||||
openNewConversationTab,
|
||||
promoteNewConversationTab,
|
||||
linkTabConversation,
|
||||
reorderTabs,
|
||||
]
|
||||
)
|
||||
|
||||
return <TabContext.Provider value={value}>{children}</TabContext.Provider>
|
||||
}
|
||||
127
src/contexts/task-context.tsx
Normal file
127
src/contexts/task-context.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useReducer,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
|
||||
export type TaskStatus = "pending" | "running" | "completed" | "failed"
|
||||
|
||||
export interface Task {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
status: TaskStatus
|
||||
progress?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface TaskContextValue {
|
||||
tasks: Task[]
|
||||
hasRunningTasks: boolean
|
||||
addTask: (id: string, label: string, description?: string) => void
|
||||
updateTask: (
|
||||
id: string,
|
||||
update: Partial<Pick<Task, "status" | "progress" | "error">>
|
||||
) => void
|
||||
removeTask: (id: string) => void
|
||||
clearCompleted: () => void
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: "add"; id: string; label: string; description?: string }
|
||||
| {
|
||||
type: "update"
|
||||
id: string
|
||||
update: Partial<Pick<Task, "status" | "progress" | "error">>
|
||||
}
|
||||
| { type: "remove"; id: string }
|
||||
| { type: "clear_completed" }
|
||||
|
||||
function reducer(state: Task[], action: Action): Task[] {
|
||||
switch (action.type) {
|
||||
case "add":
|
||||
return [
|
||||
...state,
|
||||
{
|
||||
id: action.id,
|
||||
label: action.label,
|
||||
description: action.description,
|
||||
status: "pending",
|
||||
},
|
||||
]
|
||||
case "update":
|
||||
return state.map((t) =>
|
||||
t.id === action.id ? { ...t, ...action.update } : t
|
||||
)
|
||||
case "remove":
|
||||
return state.filter((t) => t.id !== action.id)
|
||||
case "clear_completed":
|
||||
return state.filter(
|
||||
(t) => t.status !== "completed" && t.status !== "failed"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const TaskContext = createContext<TaskContextValue | null>(null)
|
||||
|
||||
export function useTaskContext() {
|
||||
const ctx = useContext(TaskContext)
|
||||
if (!ctx) {
|
||||
throw new Error("useTaskContext must be used within TaskProvider")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function TaskProvider({ children }: { children: ReactNode }) {
|
||||
const [tasks, dispatch] = useReducer(reducer, [])
|
||||
|
||||
const addTask = useCallback(
|
||||
(id: string, label: string, description?: string) => {
|
||||
dispatch({ type: "add", id, label, description })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const removeTask = useCallback((id: string) => {
|
||||
dispatch({ type: "remove", id })
|
||||
}, [])
|
||||
|
||||
const updateTask = useCallback(
|
||||
(
|
||||
id: string,
|
||||
update: Partial<Pick<Task, "status" | "progress" | "error">>
|
||||
) => {
|
||||
if (update.status === "completed" || update.status === "failed") {
|
||||
dispatch({ type: "remove", id })
|
||||
} else {
|
||||
dispatch({ type: "update", id, update })
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const clearCompleted = useCallback(() => {
|
||||
dispatch({ type: "clear_completed" })
|
||||
}, [])
|
||||
|
||||
const hasRunningTasks = tasks.some((t) => t.status === "running")
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
tasks,
|
||||
hasRunningTasks,
|
||||
addTask,
|
||||
updateTask,
|
||||
removeTask,
|
||||
clearCompleted,
|
||||
}),
|
||||
[tasks, hasRunningTasks, addTask, updateTask, removeTask, clearCompleted]
|
||||
)
|
||||
|
||||
return <TaskContext.Provider value={value}>{children}</TaskContext.Provider>
|
||||
}
|
||||
337
src/contexts/terminal-context.tsx
Normal file
337
src/contexts/terminal-context.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1008
src/contexts/workspace-context.tsx
Normal file
1008
src/contexts/workspace-context.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user