优化事件处理
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { acpListAgents } from "@/lib/tauri"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
import type { AgentType, AcpAgentInfo } from "@/lib/types"
|
||||
import { AGENT_LABELS } from "@/lib/types"
|
||||
import { AgentIcon } from "@/components/agent-icon"
|
||||
@@ -100,7 +101,7 @@ export function AgentSelector({
|
||||
)
|
||||
.then((dispose) => {
|
||||
if (cancelled) {
|
||||
dispose()
|
||||
disposeTauriListener(dispose, "AgentSelector.agentsUpdated")
|
||||
return
|
||||
}
|
||||
unlisten = dispose
|
||||
@@ -112,9 +113,7 @@ export function AgentSelector({
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.removeEventListener("focus", onWindowFocus)
|
||||
if (unlisten) {
|
||||
unlisten()
|
||||
}
|
||||
disposeTauriListener(unlisten, "AgentSelector.agentsUpdated")
|
||||
}
|
||||
}, [defaultAgentType])
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { TauriEvent } from "@tauri-apps/api/event"
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview"
|
||||
import { open } from "@tauri-apps/plugin-dialog"
|
||||
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 { cn } from "@/lib/utils"
|
||||
import { readFileBase64 } from "@/lib/tauri"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
import type {
|
||||
AvailableCommandInfo,
|
||||
PromptCapabilitiesInfo,
|
||||
@@ -686,46 +688,109 @@ export function MessageInput({
|
||||
}, [appendResourceAttachments, attachmentTabId])
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | null = null
|
||||
let cancelled = false
|
||||
const unlisteners: Array<() => void | Promise<void>> = []
|
||||
|
||||
getCurrentWebview()
|
||||
.onDragDropEvent((event) => {
|
||||
const host = containerRef.current
|
||||
if (!host) return
|
||||
const payload = event.payload
|
||||
if (payload.type === "leave") {
|
||||
setIsDragActive(false)
|
||||
return
|
||||
const cleanupListeners = () => {
|
||||
for (const fn of unlisteners.splice(0)) {
|
||||
disposeTauriListener(fn, "MessageInput.dragDrop")
|
||||
}
|
||||
}
|
||||
|
||||
type DragDropPayload =
|
||||
| {
|
||||
type: "enter" | "drop"
|
||||
paths: string[]
|
||||
position: { x: number; y: number }
|
||||
}
|
||||
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)
|
||||
| {
|
||||
type: "over"
|
||||
position: { x: number; y: number }
|
||||
}
|
||||
| { type: "leave" }
|
||||
|
||||
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
|
||||
}
|
||||
setIsDragActive(inside && !disabled && !isPrompting)
|
||||
})
|
||||
.then((fn) => {
|
||||
if (cancelled) {
|
||||
fn()
|
||||
} else {
|
||||
unlisten = fn
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
})
|
||||
unlisteners.push(unlistenEnter)
|
||||
|
||||
const unlistenOver = await webview.listen<{
|
||||
position: { x: number; y: number }
|
||||
}>(TauriEvent.DRAG_OVER, (event) => {
|
||||
if (cancelled) return
|
||||
handlePayload({
|
||||
type: "over",
|
||||
position: event.payload.position,
|
||||
})
|
||||
})
|
||||
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.
|
||||
})
|
||||
} finally {
|
||||
if (cancelled) {
|
||||
cleanupListeners()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void setup()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (unlisten) {
|
||||
unlisten()
|
||||
}
|
||||
cleanupListeners()
|
||||
}
|
||||
}, [appendPathsFromDrop, disabled, isPrompting])
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
updateConversationStatus,
|
||||
updateConversationExternalId,
|
||||
} from "@/lib/tauri"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
import { AgentSelector } from "@/components/chat/agent-selector"
|
||||
import { LiveMessageBlock } from "@/components/chat/live-message-block"
|
||||
import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay"
|
||||
@@ -448,7 +449,7 @@ export function WelcomeInputPanel({
|
||||
)
|
||||
.then((dispose) => {
|
||||
if (cancelled) {
|
||||
dispose()
|
||||
disposeTauriListener(dispose, "WelcomeInputPanel.agentsUpdated")
|
||||
return
|
||||
}
|
||||
unlisten = dispose
|
||||
@@ -463,9 +464,7 @@ export function WelcomeInputPanel({
|
||||
clearTimeout(agentStatusRefreshTimerRef.current)
|
||||
agentStatusRefreshTimerRef.current = null
|
||||
}
|
||||
if (unlisten) {
|
||||
unlisten()
|
||||
}
|
||||
disposeTauriListener(unlisten, "WelcomeInputPanel.agentsUpdated")
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type IntlLocale,
|
||||
} from "@/lib/i18n"
|
||||
import { getSystemLanguageSettings } from "@/lib/tauri"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
import { AppBootLoading } from "@/components/layout/app-boot-loading"
|
||||
import type { AppLocale, SystemLanguageSettings } from "@/lib/types"
|
||||
|
||||
@@ -162,7 +163,7 @@ export function AppI18nProvider({
|
||||
)
|
||||
.then((dispose) => {
|
||||
if (cancelled) {
|
||||
dispose()
|
||||
disposeTauriListener(dispose, "I18nProvider.languageSettings")
|
||||
return
|
||||
}
|
||||
unlisten = dispose
|
||||
@@ -174,9 +175,7 @@ export function AppI18nProvider({
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.removeEventListener("storage", onStorage)
|
||||
if (unlisten) {
|
||||
unlisten()
|
||||
}
|
||||
disposeTauriListener(unlisten, "I18nProvider.languageSettings")
|
||||
}
|
||||
}, [setLanguageSettings])
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
startFileTreeWatch,
|
||||
stopFileTreeWatch,
|
||||
} from "@/lib/tauri"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
import { emitAttachFileToSession } from "@/lib/session-attachment-events"
|
||||
import type {
|
||||
FileTreeChangedEvent,
|
||||
@@ -1961,9 +1962,7 @@ export function FileTreeTab() {
|
||||
pendingTreeRefreshRef.current = false
|
||||
pendingTreeRefreshNeedsStatusRef.current = false
|
||||
pendingStatusRefreshRef.current = false
|
||||
if (unlisten) {
|
||||
unlisten()
|
||||
}
|
||||
disposeTauriListener(unlisten, "AuxPanelFileTree.fileTreeChanged")
|
||||
void stopFileTreeWatch(rootPath)
|
||||
}
|
||||
}, [fetchTree, folder?.path, openFilePreview, t])
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
startFileTreeWatch,
|
||||
stopFileTreeWatch,
|
||||
} from "@/lib/tauri"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
import type { FileTreeChangedEvent, GitStatusEntry } from "@/lib/types"
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -630,9 +631,7 @@ export function GitChangesTab() {
|
||||
clearTimeout(refreshTimerRef.current)
|
||||
refreshTimerRef.current = null
|
||||
}
|
||||
if (unlisten) {
|
||||
unlisten()
|
||||
}
|
||||
disposeTauriListener(unlisten, "AuxPanelGitChanges.fileTreeChanged")
|
||||
void stopFileTreeWatch(rootPath)
|
||||
}
|
||||
}, [fetchChanges, folder?.path, isChangesTabActive])
|
||||
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
openCommitWindow,
|
||||
setFolderParentBranch,
|
||||
} from "@/lib/tauri"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
import type { GitBranchList } from "@/lib/types"
|
||||
import { toast } from "sonner"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
@@ -161,7 +162,7 @@ export function BranchDropdown({
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (unlisten) unlisten()
|
||||
disposeTauriListener(unlisten, "BranchDropdown.gitCommitSucceeded")
|
||||
}
|
||||
}, [folder, onBranchChange, t])
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
terminalKill,
|
||||
terminalList,
|
||||
} from "@/lib/tauri"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
import type { FolderCommand, TerminalEvent } from "@/lib/types"
|
||||
import { CommandManageDialog } from "./command-manage-dialog"
|
||||
|
||||
@@ -66,7 +67,7 @@ export function CommandDropdown() {
|
||||
const clearRunningByTerminalId = useCallback((terminalId: string) => {
|
||||
const unlisten = exitUnlistenersRef.current.get(terminalId)
|
||||
if (unlisten) {
|
||||
unlisten()
|
||||
disposeTauriListener(unlisten, "CommandDropdown.terminalExit")
|
||||
exitUnlistenersRef.current.delete(terminalId)
|
||||
}
|
||||
|
||||
@@ -85,7 +86,7 @@ export function CommandDropdown() {
|
||||
|
||||
const clearAllRunningStates = useCallback(() => {
|
||||
for (const unlisten of exitUnlistenersRef.current.values()) {
|
||||
unlisten()
|
||||
disposeTauriListener(unlisten, "CommandDropdown.terminalExit")
|
||||
}
|
||||
exitUnlistenersRef.current.clear()
|
||||
setRunningCommandTerminals({})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react"
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { usePlatform } from "@/hooks/use-platform"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function WindowControls() {
|
||||
@@ -59,7 +60,7 @@ export function WindowControls() {
|
||||
if (resizeFrame !== null) {
|
||||
window.cancelAnimationFrame(resizeFrame)
|
||||
}
|
||||
unlistenResize?.()
|
||||
disposeTauriListener(unlistenResize, "WindowControls.resize")
|
||||
}
|
||||
}, [isWindows])
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
import { listen } from "@tauri-apps/api/event"
|
||||
import { terminalWrite, terminalResize } from "@/lib/tauri"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
import type { TerminalEvent } from "@/lib/types"
|
||||
import type { ITheme } from "@xterm/xterm"
|
||||
|
||||
@@ -186,8 +187,8 @@ export function TerminalView({
|
||||
themeObserver.disconnect()
|
||||
onDataDisposable.dispose()
|
||||
onResizeDisposable.dispose()
|
||||
unlisten()
|
||||
unlistenExit()
|
||||
disposeTauriListener(unlisten, "TerminalView.output")
|
||||
disposeTauriListener(unlistenExit, "TerminalView.exit")
|
||||
term.dispose()
|
||||
return
|
||||
}
|
||||
@@ -221,8 +222,8 @@ export function TerminalView({
|
||||
themeObserver.disconnect()
|
||||
onDataDisposable.dispose()
|
||||
onResizeDisposable.dispose()
|
||||
unlisten()
|
||||
unlistenExit()
|
||||
disposeTauriListener(unlisten, "TerminalView.output")
|
||||
disposeTauriListener(unlistenExit, "TerminalView.exit")
|
||||
resizeObserver.disconnect()
|
||||
term.dispose()
|
||||
fitAddonRef.current = null
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
import {
|
||||
acpConnect,
|
||||
acpListAgents,
|
||||
@@ -1420,11 +1421,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
||||
})
|
||||
.then((fn) => {
|
||||
if (cancelled) {
|
||||
try {
|
||||
fn()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
disposeTauriListener(fn, "AcpConnectionsProvider.globalEvent")
|
||||
} else {
|
||||
unlisten = fn
|
||||
listenerReadyRef.current = true
|
||||
@@ -1444,7 +1441,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
||||
clearTimeout(flushTimerRef.current)
|
||||
flushTimerRef.current = null
|
||||
}
|
||||
unlisten?.()
|
||||
disposeTauriListener(unlisten, "AcpConnectionsProvider.globalEvent")
|
||||
}
|
||||
}, [bufferUnmappedEvent, handleMappedEvent, resolveListenerReadyWaiters])
|
||||
|
||||
|
||||
53
src/lib/tauri-listener.ts
Normal file
53
src/lib/tauri-listener.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user