diff --git a/apps/electron/src/main/lib/workspace-watcher-utils.ts b/apps/electron/src/main/lib/workspace-watcher-utils.ts index 981d31f04..e7dbf90f8 100644 --- a/apps/electron/src/main/lib/workspace-watcher-utils.ts +++ b/apps/electron/src/main/lib/workspace-watcher-utils.ts @@ -1,7 +1,10 @@ -// 高频变动目录:跳过其中的变更事件,防止 node_modules / .next 等产生 IPC 事件风暴。 +// 高频变动目录:跳过依赖、缓存和构建中间物,防止产生 IPC 事件风暴。 const HIGH_NOISE_SEGMENTS = new Set([ 'node_modules', '.next', '.nuxt', '.git', 'dist', 'build', '.cache', '__pycache__', '.turbo', '.parcel-cache', '.svelte-kit', + '.venv', 'venv', '.tox', '.nox', '__pypackages__', + '.pytest_cache', '.mypy_cache', '.ruff_cache', '.hypothesis', + '.gradle', ]) const GIT_DIFF_STATE_FILES = new Set([ diff --git a/apps/electron/src/main/lib/workspace-watcher.test.ts b/apps/electron/src/main/lib/workspace-watcher.test.ts index 298e1b57c..a294da0c5 100644 --- a/apps/electron/src/main/lib/workspace-watcher.test.ts +++ b/apps/electron/src/main/lib/workspace-watcher.test.ts @@ -11,6 +11,30 @@ describe('shouldNotifyForWatchFilename', () => { expect(shouldNotifyForWatchFilename('src\\components\\Button.tsx')).toBe(true) }) + it('ignores Python, test and build cache directories', () => { + const noisyPaths = [ + '.venv/lib/python3.12/site-packages/pkg.py', + 'venv/Lib/site-packages/pkg.py', + '.tox/py312/lib/pkg.py', + '.nox/tests/lib/pkg.py', + '__pypackages__/3.12/lib/pkg.py', + '.pytest_cache/v/cache/lastfailed', + '.mypy_cache/3.12/pkg.meta.json', + '.ruff_cache/0.8.0/cache', + '.hypothesis/examples/example.db', + '.gradle/caches/modules-2/metadata.bin', + ] + + for (const path of noisyPaths) { + expect(shouldNotifyForWatchFilename(path)).toBe(false) + } + }) + + it('keeps generic coverage and target directories observable', () => { + expect(shouldNotifyForWatchFilename('coverage/lcov.info')).toBe(true) + expect(shouldNotifyForWatchFilename('target/debug/generated.rs')).toBe(true) + }) + it('normalizes Buffer filenames before filtering', () => { expect(shouldNotifyForWatchFilename(Buffer.from('.git/index'))).toBe(true) expect(shouldNotifyForWatchFilename(Buffer.from('src/file.ts'))).toBe(true) diff --git a/apps/electron/src/renderer/hooks/useGlobalAgentListeners.ts b/apps/electron/src/renderer/hooks/useGlobalAgentListeners.ts index 82527383e..c9c72245b 100644 --- a/apps/electron/src/renderer/hooks/useGlobalAgentListeners.ts +++ b/apps/electron/src/renderer/hooks/useGlobalAgentListeners.ts @@ -80,7 +80,7 @@ import { import { getPlanModeChangeFromToolName, updatePlanModeSessionSet } from '@/lib/agent-plan-mode' import { buildTodoAgentPrompt } from '@/lib/todo-agent-prompt' import { detectIsWindows } from '@/lib/platform' -import { getSessionFileChangeKind, arePathsEqual, isPathWithinRoot, upsertSessionFileChange } from '@/lib/session-file-changes' +import { getSessionFileChangeKind, getOwnedSessionWatcherPaths, upsertSessionFileChange } from '@/lib/session-file-changes' import { removeQueuedMessage, createQueuedAgentStreamState } from '@/lib/agent-message-queue' import { createAgentStreamEventBatcher } from '@/lib/agent-stream-event-batcher' @@ -846,24 +846,16 @@ export function useGlobalAgentListeners(): void { const sessionPath = sessionPaths.get(sessionId) const workspaceFilesPath = await getWorkspaceFilesPathForSession(sessionId) const workspaceAttachments = await getWorkspaceAttachmentsForSession(sessionId) - if (!session || !workspaceAttachments.complete) { - // 缺少运行会话的权威附件配置时,任何路径都不能安全归属给其他会话。 - return { sessionId, matchingPaths: [...filePaths] } - } - const directoryRoots = uniqueTruthyPaths([ + const matchingPaths = getOwnedSessionWatcherPaths(filePaths, { + sessionExists: Boolean(session), sessionPath, + sessionAttachedDirectories: session?.attachedDirectories ?? [], + sessionAttachedFiles: session?.attachedFiles ?? [], + workspaceAttachmentsComplete: workspaceAttachments.complete, workspaceFilesPath, - ...(session.attachedDirectories ?? []), - ...workspaceAttachments.directories, - ]) - const attachedFiles = uniqueTruthyPaths([ - ...(session.attachedFiles ?? []), - ...workspaceAttachments.files, - ]) - const matchingPaths = filePaths.filter((changedPath) => ( - directoryRoots.some((rootPath) => isPathWithinRoot(rootPath, changedPath, isWindows)) - || attachedFiles.some((filePath) => arePathsEqual(filePath, changedPath, isWindows)) - )) + workspaceAttachedDirectories: workspaceAttachments.directories, + workspaceAttachedFiles: workspaceAttachments.files, + }, isWindows) return { sessionId, matchingPaths } })) diff --git a/apps/electron/src/renderer/lib/session-file-changes.test.ts b/apps/electron/src/renderer/lib/session-file-changes.test.ts new file mode 100644 index 000000000..da099d8a3 --- /dev/null +++ b/apps/electron/src/renderer/lib/session-file-changes.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from 'bun:test' +import { getOwnedSessionWatcherPaths } from './session-file-changes' + +describe('getOwnedSessionWatcherPaths', () => { + test('does not attribute paths for a missing session', () => { + expect(getOwnedSessionWatcherPaths( + ['/workspaces/current-session/generated/file.txt'], + { + sessionExists: false, + sessionPath: '/workspaces/current-session', + sessionAttachedDirectories: [], + sessionAttachedFiles: [], + workspaceAttachmentsComplete: true, + workspaceFilesPath: '/workspaces/workspace-files', + workspaceAttachedDirectories: [], + workspaceAttachedFiles: [], + }, + )).toEqual([]) + }) + + test('retains session-local paths when workspace attachments are unavailable', () => { + expect(getOwnedSessionWatcherPaths( + [ + '/workspaces/current-session/generated/file.txt', + '/external/session-directory/file.txt', + '/external/session-file.md', + '/workspaces/workspace-files/shared.md', + '/external/workspace-directory/file.txt', + '/external/workspace-file.md', + ], + { + sessionExists: true, + sessionPath: '/workspaces/current-session', + sessionAttachedDirectories: ['/external/session-directory'], + sessionAttachedFiles: ['/external/session-file.md'], + workspaceAttachmentsComplete: false, + workspaceFilesPath: '/workspaces/workspace-files', + workspaceAttachedDirectories: ['/external/workspace-directory'], + workspaceAttachedFiles: ['/external/workspace-file.md'], + }, + )).toEqual([ + '/workspaces/current-session/generated/file.txt', + '/external/session-directory/file.txt', + '/external/session-file.md', + ]) + }) + + test('includes complete workspace scope without crossing root boundaries', () => { + expect(getOwnedSessionWatcherPaths( + [ + '/workspaces/current-session/generated/file.txt', + '/workspaces/current-session-copy/file.txt', + '/workspaces/workspace-files/shared.md', + '/external/workspace-directory/file.txt', + '/external/workspace-file.md', + '/external/unattached.md', + ], + { + sessionExists: true, + sessionPath: '/workspaces/current-session', + sessionAttachedDirectories: [], + sessionAttachedFiles: [], + workspaceAttachmentsComplete: true, + workspaceFilesPath: '/workspaces/workspace-files', + workspaceAttachedDirectories: ['/external/workspace-directory'], + workspaceAttachedFiles: ['/external/workspace-file.md'], + }, + )).toEqual([ + '/workspaces/current-session/generated/file.txt', + '/workspaces/workspace-files/shared.md', + '/external/workspace-directory/file.txt', + '/external/workspace-file.md', + ]) + }) +}) diff --git a/apps/electron/src/renderer/lib/session-file-changes.ts b/apps/electron/src/renderer/lib/session-file-changes.ts index 60a497a7d..cd3846cce 100644 --- a/apps/electron/src/renderer/lib/session-file-changes.ts +++ b/apps/electron/src/renderer/lib/session-file-changes.ts @@ -13,6 +13,49 @@ export function isPathWithinRoot(rootPath: string, targetPath: string, caseInsen return target === root || target.startsWith(`${root}/`) } +export interface SessionWatcherOwnershipScope { + sessionExists: boolean + sessionPath?: string + sessionAttachedDirectories: readonly string[] + sessionAttachedFiles: readonly string[] + workspaceAttachmentsComplete: boolean + workspaceFilesPath?: string | null + workspaceAttachedDirectories: readonly string[] + workspaceAttachedFiles: readonly string[] +} + +/** Returns watcher paths that can be attributed from the available session scope. */ +export function getOwnedSessionWatcherPaths( + changedPaths: readonly string[], + scope: SessionWatcherOwnershipScope, + caseInsensitive = false, +): string[] { + if (!scope.sessionExists) return [] + + const directoryRoots = [ + scope.sessionPath, + ...scope.sessionAttachedDirectories, + ] + const attachedFiles = [...scope.sessionAttachedFiles] + + if (scope.workspaceAttachmentsComplete) { + directoryRoots.push( + scope.workspaceFilesPath ?? undefined, + ...scope.workspaceAttachedDirectories, + ) + attachedFiles.push(...scope.workspaceAttachedFiles) + } + + return changedPaths.filter((changedPath) => ( + directoryRoots.some((rootPath) => ( + typeof rootPath === 'string' + && rootPath.length > 0 + && isPathWithinRoot(rootPath, changedPath, caseInsensitive) + )) + || attachedFiles.some((filePath) => arePathsEqual(filePath, changedPath, caseInsensitive)) + )) +} + export type SessionFileChangeKind = "created" | "edited"; export interface SessionFileChange {