Skip to content

Commit b47e5e0

Browse files
authored
feat(workspace): unify lifecycle refresh (#1352)
1 parent 765e2a7 commit b47e5e0

15 files changed

Lines changed: 1398 additions & 217 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Workspace Lifecycle Plan
2+
3+
## Architecture
4+
5+
- `WorkspacePresenter` owns watcher runtimes keyed by `workspacePath`.
6+
- Each runtime contains:
7+
- a recursive filesystem watcher for workspace content
8+
- a targeted Git metadata watcher for `HEAD`, `index`, `packed-refs`, and `refs`
9+
- a debounce buffer that emits one invalidation payload
10+
- `registerWorkspace` remains the read-security boundary.
11+
- `watchWorkspace` / `unwatchWorkspace` control watcher lifecycle separately from path registration.
12+
13+
## Event Contract
14+
15+
- Channel: `workspace:files-changed`
16+
- Canonical constant: `WORKSPACE_EVENTS.INVALIDATED`
17+
- Legacy alias: `WORKSPACE_EVENTS.FILES_CHANGED`
18+
- Payload:
19+
20+
```ts
21+
type WorkspaceInvalidationEvent = {
22+
workspacePath: string
23+
kind: 'fs' | 'git' | 'full'
24+
source: 'watcher' | 'fallback' | 'lifecycle'
25+
}
26+
```
27+
28+
## Renderer Refresh Flow
29+
30+
- Initial load and all invalidation refreshes use the same sync helper.
31+
- `fs/full` refresh:
32+
- reload root tree
33+
- restore expanded directories
34+
- reload Git state
35+
- reload current preview and diff
36+
- clear stale selected file/diff state
37+
- `git` refresh:
38+
- reload Git state
39+
- reload current diff
40+
- leave file tree and plain file preview untouched
41+
42+
## Test Strategy
43+
44+
- Main-process tests cover watcher ref counting, debounce behavior, Git invalidation emission, and destroy cleanup.
45+
- Renderer tests cover watcher lifecycle, full-vs-git refresh routing, expanded directory restoration, and stale selection cleanup.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Workspace Lifecycle
2+
3+
## Summary
4+
5+
Workspace sidepanel state should refresh through a single invalidation pipeline instead of ad-hoc reloads. The main process owns invalidation production, and the renderer owns refresh execution.
6+
7+
## User Stories
8+
9+
- As a user, when files are created, edited, moved, or removed in the current workspace, the file tree should refresh without manual action.
10+
- As a user, when Git metadata changes without a file content change, the Git section should still refresh.
11+
- As a maintainer, I need one clear contract for workspace invalidation so future features do not add more bespoke refresh paths.
12+
13+
## Acceptance Criteria
14+
15+
- Workspace content changes emit a typed invalidation event with `kind: 'fs'`.
16+
- Git metadata changes emit a typed invalidation event with `kind: 'git'`.
17+
- Renderer refreshes file tree, Git state, preview, and diff through one shared sync path.
18+
- Expanded directories stay expanded after a full refresh when the directory still exists.
19+
- Stale selected file and diff state are cleared when the backing file or Git change disappears.
20+
- Read-only filesystem operations do not emit workspace invalidation events.
21+
22+
## Non-Goals
23+
24+
- Artifact refresh is not part of workspace invalidation.
25+
- Hidden file visibility rules are unchanged.
26+
- Watchers are not promoted to a global always-on workspace service.

src/main/events.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,8 @@ export const LIFECYCLE_EVENTS = {
256256

257257
// Workspace events
258258
export const WORKSPACE_EVENTS = {
259-
FILES_CHANGED: 'workspace:files-changed' // File tree changed
259+
INVALIDATED: 'workspace:files-changed', // Workspace invalidation event
260+
FILES_CHANGED: 'workspace:files-changed' // Legacy alias
260261
}
261262

262263
// ACP-specific workspace events

src/main/presenter/agentPresenter/acp/acpProcessManager.ts

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { buildClientCapabilities } from './acpCapabilities'
1919
import { AcpFsHandler } from './acpFsHandler'
2020
import { AcpTerminalManager } from './acpTerminalManager'
2121
import { eventBus, SendTarget } from '@/eventbus'
22-
import { ACP_WORKSPACE_EVENTS, WORKSPACE_EVENTS } from '@/events'
22+
import { ACP_WORKSPACE_EVENTS } from '@/events'
2323

2424
export interface AcpProcessHandle extends AgentProcessHandle {
2525
child: ChildProcessWithoutNullStreams
@@ -139,14 +139,6 @@ export class AcpProcessManager implements AgentProcessManager<AcpProcessHandle,
139139
return handler
140140
}
141141

142-
private notifyWorkspaceFilesChanged(sessionId: string): void {
143-
const conversationId = this.sessionConversations.get(sessionId)
144-
if (!conversationId) return
145-
eventBus.sendToRenderer(WORKSPACE_EVENTS.FILES_CHANGED, SendTarget.ALL_WINDOWS, {
146-
conversationId
147-
})
148-
}
149-
150142
/**
151143
* Provide a fallback workspace for sessions that haven't registered a workdir.
152144
* Keeps file access constrained to a temp directory rather than the entire filesystem.
@@ -897,19 +889,11 @@ export class AcpProcessManager implements AgentProcessManager<AcpProcessHandle,
897889
// File system operations
898890
readTextFile: async (params) => {
899891
const handler = this.getFsHandler(params.sessionId)
900-
try {
901-
return await handler.readTextFile(params)
902-
} finally {
903-
this.notifyWorkspaceFilesChanged(params.sessionId)
904-
}
892+
return await handler.readTextFile(params)
905893
},
906894
writeTextFile: async (params) => {
907895
const handler = this.getFsHandler(params.sessionId)
908-
try {
909-
return await handler.writeTextFile(params)
910-
} finally {
911-
this.notifyWorkspaceFilesChanged(params.sessionId)
912-
}
896+
return await handler.writeTextFile(params)
913897
},
914898
// Terminal operations
915899
createTerminal: async (params) => {

src/main/presenter/agentPresenter/loop/agentLoopHandler.ts

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ import {
55
LLMAgentEvent,
66
MCPToolCall
77
} from '@shared/presenter'
8-
import { eventBus, SendTarget } from '@/eventbus'
9-
import { WORKSPACE_EVENTS } from '@/events'
108
import { BaseLLMProvider } from '@/presenter/llmProviderPresenter/baseProvider'
119
import { StreamState } from './loopState'
1210
import { RateLimitManager } from '@/presenter/llmProviderPresenter/managers/rateLimitManager'
@@ -66,10 +64,6 @@ export class AgentLoopHandler {
6664
return null
6765
}
6866
return await toolPresenter.preCheckToolPermission(request)
69-
},
70-
onToolCallFinished: ({ toolServerName, conversationId }) => {
71-
if (toolServerName !== 'agent-filesystem') return
72-
this.notifyWorkspaceFilesChanged(conversationId)
7367
}
7468
})
7569
}
@@ -95,13 +89,6 @@ export class AgentLoopHandler {
9589
return this.options.sessionRuntime.resolveWorkspaceContext(conversationId, modelId)
9690
}
9791

98-
private notifyWorkspaceFilesChanged(conversationId?: string): void {
99-
if (!conversationId) return
100-
eventBus.sendToRenderer(WORKSPACE_EVENTS.FILES_CHANGED, SendTarget.ALL_WINDOWS, {
101-
conversationId
102-
})
103-
}
104-
10592
private requiresReasoningField(modelId: string): boolean {
10693
const lower = modelId.toLowerCase()
10794
return (
@@ -677,11 +664,6 @@ export class AgentLoopHandler {
677664

678665
this.options.activeStreams.delete(eventId)
679666
console.log('Agent loop finished for event:', eventId, 'User stopped:', userStop)
680-
681-
// Trigger ACP workspace file refresh (only for ACP provider)
682-
if (providerId === 'acp') {
683-
this.notifyWorkspaceFilesChanged(conversationId)
684-
}
685667
}
686668
}
687669
}

src/main/presenter/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,7 @@ export class Presenter implements IPresenter {
566566
this.syncPresenter.destroy() // 销毁同步相关资源
567567
this.notificationPresenter.clearAllNotifications() // 清除所有通知
568568
this.knowledgePresenter.destroy() // 释放所有数据库连接
569+
;(this.workspacePresenter as WorkspacePresenter).destroy() // 销毁 Workspace watchers
569570
;(this.skillPresenter as SkillPresenter).destroy() // 销毁 Skills 相关资源
570571
;(this.skillSyncPresenter as SkillSyncPresenter).destroy() // 销毁 Skill Sync 相关资源
571572
// 注意: trayPresenter.destroy() 在 main/index.ts 的 will-quit 事件中处理

0 commit comments

Comments
 (0)