优化事件处理

This commit is contained in:
xintaofei
2026-03-08 17:08:31 +08:00
parent 7a4cbcb73e
commit c1220e1a8f
12 changed files with 178 additions and 64 deletions

View File

@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { acpListAgents } from "@/lib/tauri" import { acpListAgents } from "@/lib/tauri"
import { disposeTauriListener } from "@/lib/tauri-listener"
import type { AgentType, AcpAgentInfo } from "@/lib/types" import type { AgentType, AcpAgentInfo } from "@/lib/types"
import { AGENT_LABELS } from "@/lib/types" import { AGENT_LABELS } from "@/lib/types"
import { AgentIcon } from "@/components/agent-icon" import { AgentIcon } from "@/components/agent-icon"
@@ -100,7 +101,7 @@ export function AgentSelector({
) )
.then((dispose) => { .then((dispose) => {
if (cancelled) { if (cancelled) {
dispose() disposeTauriListener(dispose, "AgentSelector.agentsUpdated")
return return
} }
unlisten = dispose unlisten = dispose
@@ -112,9 +113,7 @@ export function AgentSelector({
return () => { return () => {
cancelled = true cancelled = true
window.removeEventListener("focus", onWindowFocus) window.removeEventListener("focus", onWindowFocus)
if (unlisten) { disposeTauriListener(unlisten, "AgentSelector.agentsUpdated")
unlisten()
}
} }
}, [defaultAgentType]) }, [defaultAgentType])

View File

@@ -1,6 +1,7 @@
"use client" "use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { TauriEvent } from "@tauri-apps/api/event"
import { getCurrentWebview } from "@tauri-apps/api/webview" import { getCurrentWebview } from "@tauri-apps/api/webview"
import { open } from "@tauri-apps/plugin-dialog" import { open } from "@tauri-apps/plugin-dialog"
import Image from "next/image" import Image from "next/image"
@@ -10,6 +11,7 @@ import { Textarea } from "@/components/ui/textarea"
import { FileSearch, Plus, Send, Square, X } from "lucide-react" import { FileSearch, Plus, Send, Square, X } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { readFileBase64 } from "@/lib/tauri" import { readFileBase64 } from "@/lib/tauri"
import { disposeTauriListener } from "@/lib/tauri-listener"
import type { import type {
AvailableCommandInfo, AvailableCommandInfo,
PromptCapabilitiesInfo, PromptCapabilitiesInfo,
@@ -686,46 +688,109 @@ export function MessageInput({
}, [appendResourceAttachments, attachmentTabId]) }, [appendResourceAttachments, attachmentTabId])
useEffect(() => { useEffect(() => {
let unlisten: (() => void) | null = null
let cancelled = false let cancelled = false
const unlisteners: Array<() => void | Promise<void>> = []
getCurrentWebview() const cleanupListeners = () => {
.onDragDropEvent((event) => { for (const fn of unlisteners.splice(0)) {
const host = containerRef.current disposeTauriListener(fn, "MessageInput.dragDrop")
if (!host) return }
const payload = event.payload }
if (payload.type === "leave") {
setIsDragActive(false) type DragDropPayload =
return | {
type: "enter" | "drop"
paths: string[]
position: { x: number; y: number }
} }
const inside = pointWithinElement(payload.position, host) | {
if (payload.type === "drop") { type: "over"
setIsDragActive(false) position: { x: number; y: number }
if (Date.now() - lastDomDropAtRef.current < 250) return }
if (!inside || disabled || isPrompting) return | { type: "leave" }
void appendPathsFromDrop(payload.paths).catch((error) => {
console.error("[MessageInput] drag drop paths failed:", error) const handlePayload = (payload: DragDropPayload) => {
const host = containerRef.current
if (!host) return
if (payload.type === "leave") {
setIsDragActive(false)
return
}
const inside = pointWithinElement(payload.position, host)
if (payload.type === "drop") {
setIsDragActive(false)
if (Date.now() - lastDomDropAtRef.current < 250) return
if (!inside || disabled || isPrompting) return
void appendPathsFromDrop(payload.paths).catch((error) => {
console.error("[MessageInput] drag drop paths failed:", error)
})
return
}
setIsDragActive(inside && !disabled && !isPrompting)
}
const setup = async () => {
const webview = getCurrentWebview()
try {
const unlistenEnter = await webview.listen<{
paths: string[]
position: { x: number; y: number }
}>(TauriEvent.DRAG_ENTER, (event) => {
if (cancelled) return
handlePayload({
type: "enter",
paths: event.payload.paths,
position: event.payload.position,
}) })
return })
} unlisteners.push(unlistenEnter)
setIsDragActive(inside && !disabled && !isPrompting)
}) const unlistenOver = await webview.listen<{
.then((fn) => { position: { x: number; y: number }
if (cancelled) { }>(TauriEvent.DRAG_OVER, (event) => {
fn() if (cancelled) return
} else { handlePayload({
unlisten = fn type: "over",
} position: event.payload.position,
}) })
.catch(() => { })
unlisteners.push(unlistenOver)
const unlistenDrop = await webview.listen<{
paths: string[]
position: { x: number; y: number }
}>(TauriEvent.DRAG_DROP, (event) => {
if (cancelled) return
handlePayload({
type: "drop",
paths: event.payload.paths,
position: event.payload.position,
})
})
unlisteners.push(unlistenDrop)
const unlistenLeave = await webview.listen(
TauriEvent.DRAG_LEAVE,
() => {
if (cancelled) return
handlePayload({ type: "leave" })
}
)
unlisteners.push(unlistenLeave)
} catch {
// Ignore non-Tauri environments. // Ignore non-Tauri environments.
}) } finally {
if (cancelled) {
cleanupListeners()
}
}
}
void setup()
return () => { return () => {
cancelled = true cancelled = true
if (unlisten) { cleanupListeners()
unlisten()
}
} }
}, [appendPathsFromDrop, disabled, isPrompting]) }, [appendPathsFromDrop, disabled, isPrompting])

View File

@@ -36,6 +36,7 @@ import {
updateConversationStatus, updateConversationStatus,
updateConversationExternalId, updateConversationExternalId,
} from "@/lib/tauri" } from "@/lib/tauri"
import { disposeTauriListener } from "@/lib/tauri-listener"
import { AgentSelector } from "@/components/chat/agent-selector" import { AgentSelector } from "@/components/chat/agent-selector"
import { LiveMessageBlock } from "@/components/chat/live-message-block" import { LiveMessageBlock } from "@/components/chat/live-message-block"
import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay" import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay"
@@ -448,7 +449,7 @@ export function WelcomeInputPanel({
) )
.then((dispose) => { .then((dispose) => {
if (cancelled) { if (cancelled) {
dispose() disposeTauriListener(dispose, "WelcomeInputPanel.agentsUpdated")
return return
} }
unlisten = dispose unlisten = dispose
@@ -463,9 +464,7 @@ export function WelcomeInputPanel({
clearTimeout(agentStatusRefreshTimerRef.current) clearTimeout(agentStatusRefreshTimerRef.current)
agentStatusRefreshTimerRef.current = null agentStatusRefreshTimerRef.current = null
} }
if (unlisten) { disposeTauriListener(unlisten, "WelcomeInputPanel.agentsUpdated")
unlisten()
}
} }
}, []) }, [])

View File

@@ -23,6 +23,7 @@ import {
type IntlLocale, type IntlLocale,
} from "@/lib/i18n" } from "@/lib/i18n"
import { getSystemLanguageSettings } from "@/lib/tauri" import { getSystemLanguageSettings } from "@/lib/tauri"
import { disposeTauriListener } from "@/lib/tauri-listener"
import { AppBootLoading } from "@/components/layout/app-boot-loading" import { AppBootLoading } from "@/components/layout/app-boot-loading"
import type { AppLocale, SystemLanguageSettings } from "@/lib/types" import type { AppLocale, SystemLanguageSettings } from "@/lib/types"
@@ -162,7 +163,7 @@ export function AppI18nProvider({
) )
.then((dispose) => { .then((dispose) => {
if (cancelled) { if (cancelled) {
dispose() disposeTauriListener(dispose, "I18nProvider.languageSettings")
return return
} }
unlisten = dispose unlisten = dispose
@@ -174,9 +175,7 @@ export function AppI18nProvider({
return () => { return () => {
cancelled = true cancelled = true
window.removeEventListener("storage", onStorage) window.removeEventListener("storage", onStorage)
if (unlisten) { disposeTauriListener(unlisten, "I18nProvider.languageSettings")
unlisten()
}
} }
}, [setLanguageSettings]) }, [setLanguageSettings])

View File

@@ -35,6 +35,7 @@ import {
startFileTreeWatch, startFileTreeWatch,
stopFileTreeWatch, stopFileTreeWatch,
} from "@/lib/tauri" } from "@/lib/tauri"
import { disposeTauriListener } from "@/lib/tauri-listener"
import { emitAttachFileToSession } from "@/lib/session-attachment-events" import { emitAttachFileToSession } from "@/lib/session-attachment-events"
import type { import type {
FileTreeChangedEvent, FileTreeChangedEvent,
@@ -1961,9 +1962,7 @@ export function FileTreeTab() {
pendingTreeRefreshRef.current = false pendingTreeRefreshRef.current = false
pendingTreeRefreshNeedsStatusRef.current = false pendingTreeRefreshNeedsStatusRef.current = false
pendingStatusRefreshRef.current = false pendingStatusRefreshRef.current = false
if (unlisten) { disposeTauriListener(unlisten, "AuxPanelFileTree.fileTreeChanged")
unlisten()
}
void stopFileTreeWatch(rootPath) void stopFileTreeWatch(rootPath)
} }
}, [fetchTree, folder?.path, openFilePreview, t]) }, [fetchTree, folder?.path, openFilePreview, t])

View File

@@ -46,6 +46,7 @@ import {
startFileTreeWatch, startFileTreeWatch,
stopFileTreeWatch, stopFileTreeWatch,
} from "@/lib/tauri" } from "@/lib/tauri"
import { disposeTauriListener } from "@/lib/tauri-listener"
import type { FileTreeChangedEvent, GitStatusEntry } from "@/lib/types" import type { FileTreeChangedEvent, GitStatusEntry } from "@/lib/types"
import { import {
AlertDialog, AlertDialog,
@@ -630,9 +631,7 @@ export function GitChangesTab() {
clearTimeout(refreshTimerRef.current) clearTimeout(refreshTimerRef.current)
refreshTimerRef.current = null refreshTimerRef.current = null
} }
if (unlisten) { disposeTauriListener(unlisten, "AuxPanelGitChanges.fileTreeChanged")
unlisten()
}
void stopFileTreeWatch(rootPath) void stopFileTreeWatch(rootPath)
} }
}, [fetchChanges, folder?.path, isChangesTabActive]) }, [fetchChanges, folder?.path, isChangesTabActive])

View File

@@ -80,6 +80,7 @@ import {
openCommitWindow, openCommitWindow,
setFolderParentBranch, setFolderParentBranch,
} from "@/lib/tauri" } from "@/lib/tauri"
import { disposeTauriListener } from "@/lib/tauri-listener"
import type { GitBranchList } from "@/lib/types" import type { GitBranchList } from "@/lib/types"
import { toast } from "sonner" import { toast } from "sonner"
import { useFolderContext } from "@/contexts/folder-context" import { useFolderContext } from "@/contexts/folder-context"
@@ -161,7 +162,7 @@ export function BranchDropdown({
}) })
return () => { return () => {
if (unlisten) unlisten() disposeTauriListener(unlisten, "BranchDropdown.gitCommitSucceeded")
} }
}, [folder, onBranchChange, t]) }, [folder, onBranchChange, t])

View File

@@ -20,6 +20,7 @@ import {
terminalKill, terminalKill,
terminalList, terminalList,
} from "@/lib/tauri" } from "@/lib/tauri"
import { disposeTauriListener } from "@/lib/tauri-listener"
import type { FolderCommand, TerminalEvent } from "@/lib/types" import type { FolderCommand, TerminalEvent } from "@/lib/types"
import { CommandManageDialog } from "./command-manage-dialog" import { CommandManageDialog } from "./command-manage-dialog"
@@ -66,7 +67,7 @@ export function CommandDropdown() {
const clearRunningByTerminalId = useCallback((terminalId: string) => { const clearRunningByTerminalId = useCallback((terminalId: string) => {
const unlisten = exitUnlistenersRef.current.get(terminalId) const unlisten = exitUnlistenersRef.current.get(terminalId)
if (unlisten) { if (unlisten) {
unlisten() disposeTauriListener(unlisten, "CommandDropdown.terminalExit")
exitUnlistenersRef.current.delete(terminalId) exitUnlistenersRef.current.delete(terminalId)
} }
@@ -85,7 +86,7 @@ export function CommandDropdown() {
const clearAllRunningStates = useCallback(() => { const clearAllRunningStates = useCallback(() => {
for (const unlisten of exitUnlistenersRef.current.values()) { for (const unlisten of exitUnlistenersRef.current.values()) {
unlisten() disposeTauriListener(unlisten, "CommandDropdown.terminalExit")
} }
exitUnlistenersRef.current.clear() exitUnlistenersRef.current.clear()
setRunningCommandTerminals({}) setRunningCommandTerminals({})

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from "react"
import { getCurrentWindow } from "@tauri-apps/api/window" import { getCurrentWindow } from "@tauri-apps/api/window"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { usePlatform } from "@/hooks/use-platform" import { usePlatform } from "@/hooks/use-platform"
import { disposeTauriListener } from "@/lib/tauri-listener"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export function WindowControls() { export function WindowControls() {
@@ -59,7 +60,7 @@ export function WindowControls() {
if (resizeFrame !== null) { if (resizeFrame !== null) {
window.cancelAnimationFrame(resizeFrame) window.cancelAnimationFrame(resizeFrame)
} }
unlistenResize?.() disposeTauriListener(unlistenResize, "WindowControls.resize")
} }
}, [isWindows]) }, [isWindows])

View File

@@ -3,6 +3,7 @@
import { useEffect, useRef } from "react" import { useEffect, useRef } from "react"
import { listen } from "@tauri-apps/api/event" import { listen } from "@tauri-apps/api/event"
import { terminalWrite, terminalResize } from "@/lib/tauri" import { terminalWrite, terminalResize } from "@/lib/tauri"
import { disposeTauriListener } from "@/lib/tauri-listener"
import type { TerminalEvent } from "@/lib/types" import type { TerminalEvent } from "@/lib/types"
import type { ITheme } from "@xterm/xterm" import type { ITheme } from "@xterm/xterm"
@@ -186,8 +187,8 @@ export function TerminalView({
themeObserver.disconnect() themeObserver.disconnect()
onDataDisposable.dispose() onDataDisposable.dispose()
onResizeDisposable.dispose() onResizeDisposable.dispose()
unlisten() disposeTauriListener(unlisten, "TerminalView.output")
unlistenExit() disposeTauriListener(unlistenExit, "TerminalView.exit")
term.dispose() term.dispose()
return return
} }
@@ -221,8 +222,8 @@ export function TerminalView({
themeObserver.disconnect() themeObserver.disconnect()
onDataDisposable.dispose() onDataDisposable.dispose()
onResizeDisposable.dispose() onResizeDisposable.dispose()
unlisten() disposeTauriListener(unlisten, "TerminalView.output")
unlistenExit() disposeTauriListener(unlistenExit, "TerminalView.exit")
resizeObserver.disconnect() resizeObserver.disconnect()
term.dispose() term.dispose()
fitAddonRef.current = null fitAddonRef.current = null

View File

@@ -11,6 +11,7 @@ import {
} from "react" } from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { listen, type UnlistenFn } from "@tauri-apps/api/event" import { listen, type UnlistenFn } from "@tauri-apps/api/event"
import { disposeTauriListener } from "@/lib/tauri-listener"
import { import {
acpConnect, acpConnect,
acpListAgents, acpListAgents,
@@ -1420,11 +1421,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
}) })
.then((fn) => { .then((fn) => {
if (cancelled) { if (cancelled) {
try { disposeTauriListener(fn, "AcpConnectionsProvider.globalEvent")
fn()
} catch {
// ignore
}
} else { } else {
unlisten = fn unlisten = fn
listenerReadyRef.current = true listenerReadyRef.current = true
@@ -1444,7 +1441,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
clearTimeout(flushTimerRef.current) clearTimeout(flushTimerRef.current)
flushTimerRef.current = null flushTimerRef.current = null
} }
unlisten?.() disposeTauriListener(unlisten, "AcpConnectionsProvider.globalEvent")
} }
}, [bufferUnmappedEvent, handleMappedEvent, resolveListenerReadyWaiters]) }, [bufferUnmappedEvent, handleMappedEvent, resolveListenerReadyWaiters])

53
src/lib/tauri-listener.ts Normal file
View File

@@ -0,0 +1,53 @@
type TauriUnlisten = () => void | Promise<void>
const disposedListeners = new WeakSet<TauriUnlisten>()
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message
return String(error)
}
function isKnownDisposeRaceError(message: string): boolean {
const normalized = message.toLowerCase()
return (
normalized.includes("listeners[eventid].handlerid") ||
normalized.includes("cannot read properties of undefined") ||
normalized.includes("undefined is not an object")
)
}
function handleDisposeError(error: unknown, scope?: string): void {
const message = getErrorMessage(error)
if (isKnownDisposeRaceError(message)) return
if (scope) {
console.warn(`[${scope}] failed to dispose listener:`, error)
} else {
console.warn("[tauri-listener] failed to dispose listener:", error)
}
}
/**
* Dispose Tauri listener functions defensively.
*
* React StrictMode, rapid tab/window switches, and async listener setup can
* trigger duplicate dispose paths. Tauri can throw when the internal listener
* is already gone; this helper makes cleanup idempotent.
*/
export function disposeTauriListener(
unlisten: TauriUnlisten | null | undefined,
scope?: string
): void {
if (!unlisten) return
if (disposedListeners.has(unlisten)) return
disposedListeners.add(unlisten)
try {
const disposeResult = unlisten()
void Promise.resolve(disposeResult).catch((error) => {
handleDisposeError(error, scope)
})
} catch (error) {
handleDisposeError(error, scope)
}
}