Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/electron/src/main/lib/workspace-watcher-utils.ts
Original file line number Diff line number Diff line change
@@ -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([
Expand Down
24 changes: 24 additions & 0 deletions apps/electron/src/main/lib/workspace-watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 9 additions & 17 deletions apps/electron/src/renderer/hooks/useGlobalAgentListeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 }
}))

Expand Down
75 changes: 75 additions & 0 deletions apps/electron/src/renderer/lib/session-file-changes.test.ts
Original file line number Diff line number Diff line change
@@ -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',
])
})
})
43 changes: 43 additions & 0 deletions apps/electron/src/renderer/lib/session-file-changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down