返回 DeepSeek-Reasonix
workspace_watch.go
根目录 / desktop / workspace_watch.go
1 package main
2
3 // Workspace change invalidation lives at the desktop boundary. Agent events
4 // cover Reasonix writes; fsnotify covers IDE and external terminal edits.
5 // The hub emits bounded metadata; panels decide which resources to reload.
6
7 import (
8 "context"
9 "os"
10 "path/filepath"
11 "strings"
12 "sync"
13 "time"
14
15 "github.com/fsnotify/fsnotify"
16 "reasonix/internal/event"
17 "reasonix/internal/fileref"
18 "reasonix/internal/gitcmd"
19 )
20
21 const (
22 workspaceWatchQuiet = 250 * time.Millisecond
23 workspaceWatchMaxDirs = 4096
24 workspaceWatchMaxPaths = 512
25 workspaceGitProbeLimit = 2 * time.Second
26 )
27
28 type WorkspaceRevisionView struct {
29 Revisions event.WorkspaceRevision
30 WatchState event.WorkspaceWatchState
31 }
32
33 type workspaceWatchRoot struct {
34 key string
35 root string
36 gitDirs []string
37 watcher workspaceWatcher
38 watched map[string]struct{}
39 dirs int
40 state event.WorkspaceWatchState
41 revisions event.WorkspaceRevision
42 pending map[string]event.WorkspacePathChange
43 allPaths bool
44 source string
45 timer *time.Timer
46 publishGen uint64
47 closed bool
48 }
49
50 type workspaceChangeHub struct {
51 app *App
52 mu sync.Mutex
53 roots map[string]*workspaceWatchRoot
54 session map[string]uint64
55 closed bool
56 }
57
58 func newWorkspaceChangeHub(app *App) *workspaceChangeHub {
59 return &workspaceChangeHub{app: app, roots: make(map[string]*workspaceWatchRoot), session: make(map[string]uint64)}
60 }
61
62 func canonicalWorkspaceRoot(root string) string {
63 if strings.TrimSpace(root) == "" {
64 return ""
65 }
66 abs, err := filepath.Abs(root)
67 if err != nil {
68 return filepath.Clean(root)
69 }
70 abs = filepath.Clean(abs)
71 if resolved, err := filepath.EvalSymlinks(abs); err == nil {
72 return filepath.Clean(resolved)
73 }
74 return abs
75 }
76
77 func (h *workspaceChangeHub) ensureRoot(root string) string {
78 key := canonicalWorkspaceRoot(root)
79 if key == "" {
80 return ""
81 }
82 h.mu.Lock()
83 defer h.mu.Unlock()
84 if h.closed {
85 return key
86 }
87 if _, ok := h.roots[key]; ok {
88 return key
89 }
90 r := &workspaceWatchRoot{
91 key: key, root: key, state: event.WorkspaceWatchActive,
92 pending: make(map[string]event.WorkspacePathChange), watched: make(map[string]struct{}),
93 }
94 h.roots[key] = r
95 h.startRootLocked(r)
96 return key
97 }
98
99 func (h *workspaceChangeHub) startRootLocked(r *workspaceWatchRoot) {
100 watcher, err := newWorkspaceWatcher()
101 if err != nil {
102 r.state = event.WorkspaceWatchUnavailable
103 return
104 }
105 r.watcher = watcher
106 info, err := os.Stat(r.root)
107 if err != nil || !info.IsDir() {
108 r.state = event.WorkspaceWatchUnavailable
109 _ = watcher.Close()
110 r.watcher = nil
111 return
112 }
113 h.addTreeLocked(r, r.root)
114 h.addGitMetadataLocked(r)
115 if r.dirs == 0 {
116 r.state = event.WorkspaceWatchUnavailable
117 _ = watcher.Close()
118 r.watcher = nil
119 return
120 }
121 go h.watchLoop(r)
122 }
123
124 func (h *workspaceChangeHub) addTreeLocked(r *workspaceWatchRoot, root string) {
125 if r.watcher != nil && r.watcher.SupportsRecursive() {
126 h.addWatchDirModeLocked(r, root, true)
127 return
128 }
129 _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
130 if err != nil {
131 r.state = event.WorkspaceWatchDegraded
132 return nil
133 }
134 if d.Type()&os.ModeSymlink != 0 {
135 if d.IsDir() {
136 return filepath.SkipDir
137 }
138 return nil
139 }
140 if path != r.root && fileref.SkipBrowseEntry(d.Name(), d.IsDir()) {
141 if d.IsDir() {
142 return filepath.SkipDir
143 }
144 return nil
145 }
146 if !d.IsDir() {
147 return nil
148 }
149 if !h.addWatchDirLocked(r, path) && r.dirs >= workspaceWatchMaxDirs {
150 return filepath.SkipDir
151 }
152 return nil
153 })
154 }
155
156 func (h *workspaceChangeHub) addWatchDirLocked(r *workspaceWatchRoot, path string) bool {
157 return h.addWatchDirModeLocked(r, path, false)
158 }
159
160 func (h *workspaceChangeHub) addWatchDirModeLocked(r *workspaceWatchRoot, path string, recursive bool) bool {
161 path = filepath.Clean(path)
162 if _, ok := r.watched[path]; ok {
163 return true
164 }
165 if r.watcher == nil || r.dirs >= workspaceWatchMaxDirs {
166 r.state = event.WorkspaceWatchDegraded
167 return false
168 }
169 if err := r.watcher.Add(path, recursive); err != nil {
170 r.state = event.WorkspaceWatchDegraded
171 return false
172 }
173 r.watched[path] = struct{}{}
174 r.dirs++
175 return true
176 }
177
178 func (h *workspaceChangeHub) removeWatchTreeLocked(r *workspaceWatchRoot, root string) {
179 root = filepath.Clean(root)
180 for path := range r.watched {
181 if path != root && !strings.HasPrefix(path, root+string(filepath.Separator)) {
182 continue
183 }
184 if r.watcher != nil {
185 _ = r.watcher.Remove(path)
186 }
187 delete(r.watched, path)
188 if r.dirs > 0 {
189 r.dirs--
190 }
191 }
192 }
193
194 func (h *workspaceChangeHub) addGitMetadataLocked(r *workspaceWatchRoot) {
195 if len(r.gitDirs) == 0 {
196 r.gitDirs = gitMetadataDirsForWorkspace(r.root)
197 }
198 if len(r.gitDirs) == 0 || r.watcher == nil || r.dirs >= workspaceWatchMaxDirs {
199 return
200 }
201 if r.watcher.SupportsRecursive() {
202 for _, gitDir := range r.gitDirs {
203 if pathWithinRoot(r.root, gitDir) {
204 continue
205 }
206 h.addWatchDirModeLocked(r, gitDir, false)
207 for _, rel := range []string{"refs", "logs", "worktrees"} {
208 path := filepath.Join(gitDir, rel)
209 if info, err := os.Stat(path); err == nil && info.IsDir() {
210 h.addWatchDirModeLocked(r, path, true)
211 }
212 }
213 }
214 return
215 }
216 // Watch selected metadata trees recursively. fsnotify is non-recursive, so
217 // watching refs alone would miss refs/heads/* and logs/refs/* updates.
218 for _, gitDir := range r.gitDirs {
219 h.addWatchDirLocked(r, gitDir)
220 for _, rel := range []string{"", "refs", "logs", "worktrees"} {
221 if rel == "" {
222 continue
223 }
224 h.addGitMetadataTreeLocked(r, filepath.Join(gitDir, rel))
225 }
226 }
227 }
228
229 func (h *workspaceChangeHub) addGitMetadataTreeLocked(r *workspaceWatchRoot, root string) {
230 _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
231 if err != nil {
232 if !os.IsNotExist(err) {
233 r.state = event.WorkspaceWatchDegraded
234 }
235 return nil
236 }
237 if d.Type()&os.ModeSymlink != 0 {
238 if d.IsDir() {
239 return filepath.SkipDir
240 }
241 return nil
242 }
243 if !d.IsDir() {
244 return nil
245 }
246 if !h.addWatchDirLocked(r, path) && r.dirs >= workspaceWatchMaxDirs {
247 return filepath.SkipDir
248 }
249 return nil
250 })
251 }
252
253 func gitMetadataPathAllowed(gitDirs []string, path string) bool {
254 path = filepath.Clean(path)
255 for _, gitDir := range gitDirs {
256 rel, err := filepath.Rel(gitDir, path)
257 if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
258 continue
259 }
260 if rel == "." {
261 return true
262 }
263 first := strings.SplitN(filepath.ToSlash(rel), "/", 2)[0]
264 if first == "refs" || first == "logs" || first == "worktrees" {
265 return true
266 }
267 }
268 return false
269 }
270
271 func gitMetadataEventAllowed(gitDir, path string) bool {
272 rel, err := filepath.Rel(gitDir, path)
273 if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
274 return false
275 }
276 if rel == "." || !strings.Contains(filepath.ToSlash(rel), "/") {
277 return true
278 }
279 first := strings.SplitN(filepath.ToSlash(rel), "/", 2)[0]
280 return first == "refs" || first == "logs" || first == "worktrees"
281 }
282
283 func pathWithinRoot(root, path string) bool {
284 rel, err := filepath.Rel(root, path)
285 return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
286 }
287
288 func workspaceWatchPathSkipped(root, path string) bool {
289 rel, err := filepath.Rel(root, path)
290 if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
291 return false
292 }
293 parts := strings.Split(filepath.ToSlash(rel), "/")
294 for i, name := range parts {
295 if fileref.SkipBrowseEntry(name, i < len(parts)-1) {
296 return true
297 }
298 }
299 return false
300 }
301
302 func workspaceWatchPathClass(r *workspaceWatchRoot, path string) (isGit, ignored bool) {
303 underGit := false
304 for _, gitDir := range r.gitDirs {
305 if path != gitDir && !strings.HasPrefix(path, gitDir+string(filepath.Separator)) {
306 continue
307 }
308 underGit = true
309 if gitMetadataEventAllowed(gitDir, path) {
310 return true, false
311 }
312 }
313 if underGit {
314 return false, true
315 }
316 return false, workspaceWatchPathSkipped(r.root, path)
317 }
318
319 func (h *workspaceChangeHub) addCreatedGitMetadataLocked(r *workspaceWatchRoot, path string) {
320 info, err := os.Stat(path)
321 if err != nil || !info.IsDir() || !gitMetadataPathAllowed(r.gitDirs, path) {
322 return
323 }
324 if !r.watcher.SupportsRecursive() {
325 h.addGitMetadataTreeLocked(r, path)
326 return
327 }
328 if pathWithinRoot(r.root, path) {
329 return
330 }
331 for _, gitDir := range r.gitDirs {
332 rel, relErr := filepath.Rel(gitDir, path)
333 if relErr == nil && (rel == "refs" || rel == "logs" || rel == "worktrees") {
334 h.addWatchDirModeLocked(r, path, true)
335 return
336 }
337 }
338 }
339
340 func gitMetadataDirsForWorkspace(root string) []string {
341 seen := make(map[string]struct{}, 2)
342 var dirs []string
343 for _, flag := range []string{"--git-dir", "--git-common-dir"} {
344 ctx, cancel := context.WithTimeout(context.Background(), workspaceGitProbeLimit)
345 // gitcmd.Command applies CREATE_NO_WINDOW / HideWindow on Windows so
346 // these startup probes do not flash console windows, and keeps the
347 // credential-filtered env plus maintenance/fsmonitor hardening.
348 cmd := gitcmd.Command(ctx, root, "rev-parse", flag)
349 out, err := cmd.Output()
350 cancel()
351 if err != nil {
352 continue
353 }
354 gitDir := strings.TrimSpace(string(out))
355 if gitDir == "" {
356 continue
357 }
358 if !filepath.IsAbs(gitDir) {
359 gitDir = filepath.Join(root, gitDir)
360 }
361 gitDir = canonicalWorkspaceRoot(gitDir)
362 if gitDir == "" {
363 continue
364 }
365 if _, ok := seen[gitDir]; ok {
366 continue
367 }
368 seen[gitDir] = struct{}{}
369 dirs = append(dirs, gitDir)
370 }
371 return dirs
372 }
373
374 func (h *workspaceChangeHub) watchLoop(r *workspaceWatchRoot) {
375 for {
376 select {
377 case ev, ok := <-r.watcher.Events():
378 if !ok {
379 return
380 }
381 h.observeFilesystem(r.key, ev)
382 case _, ok := <-r.watcher.Errors():
383 if !ok {
384 return
385 }
386 h.mu.Lock()
387 if !r.closed {
388 r.state = event.WorkspaceWatchDegraded
389 r.allPaths = true
390 r.source = mergeWorkspaceSource(r.source, "filesystem")
391 h.schedulePublishLocked(r)
392 }
393 h.mu.Unlock()
394 }
395 }
396 }
397
398 func (h *workspaceChangeHub) observeFilesystem(key string, ev fsnotify.Event) {
399 path := filepath.Clean(ev.Name)
400 h.mu.Lock()
401 r := h.roots[key]
402 if r == nil || r.closed {
403 h.mu.Unlock()
404 return
405 }
406 isGit, ignored := workspaceWatchPathClass(r, path)
407 if ignored {
408 h.mu.Unlock()
409 return
410 }
411 if isGit {
412 if ev.Op&fsnotify.Create != 0 {
413 h.addCreatedGitMetadataLocked(r, path)
414 }
415 if ev.Op&(fsnotify.Remove|fsnotify.Rename) != 0 {
416 h.removeWatchTreeLocked(r, path)
417 }
418 r.revisions.GitMeta++
419 r.revisions.WorkingTree++
420 r.source = mergeWorkspaceSource(r.source, "git")
421 r.allPaths = true
422 } else {
423 op := workspaceOp(ev.Op)
424 r.revisions.Content++
425 r.revisions.WorkingTree++
426 if op == "create" || op == "remove" || op == "rename" || op == "unknown" {
427 r.revisions.Tree++
428 }
429 rel, err := filepath.Rel(r.root, path)
430 if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
431 r.allPaths = true
432 } else if len(r.pending) < workspaceWatchMaxPaths {
433 rel = filepath.ToSlash(rel)
434 r.pending[rel] = mergePathChange(r.pending[rel], event.WorkspacePathChange{Path: rel, Op: op})
435 } else {
436 r.allPaths = true
437 }
438 r.source = mergeWorkspaceSource(r.source, "filesystem")
439 if ev.Op&fsnotify.Create != 0 && !r.watcher.SupportsRecursive() {
440 if info, statErr := os.Stat(path); statErr == nil && info.IsDir() {
441 h.addTreeLocked(r, path)
442 }
443 }
444 if ev.Op&(fsnotify.Remove|fsnotify.Rename) != 0 {
445 h.removeWatchTreeLocked(r, path)
446 }
447 }
448 h.schedulePublishLocked(r)
449 h.mu.Unlock()
450 }
451
452 func workspaceOp(op fsnotify.Op) string {
453 switch {
454 case op&fsnotify.Remove != 0:
455 return "remove"
456 case op&fsnotify.Rename != 0:
457 return "rename"
458 case op&fsnotify.Create != 0:
459 return "create"
460 case op&fsnotify.Write != 0:
461 return "write"
462 default:
463 return "unknown"
464 }
465 }
466
467 func mergePathChange(old, next event.WorkspacePathChange) event.WorkspacePathChange {
468 if old.Path == "" {
469 return next
470 }
471 // A create/write/remove burst is represented by the final operation while
472 // retaining rename semantics when the backend reports it.
473 if next.Op == "remove" || next.Op == "rename" {
474 old.Op = next.Op
475 } else if old.Op != "rename" {
476 old.Op = next.Op
477 }
478 return old
479 }
480
481 func mergeWorkspaceSource(old, next string) string {
482 if old == "" || old == next {
483 return next
484 }
485 return "mixed"
486 }
487
488 func (h *workspaceChangeHub) schedulePublishLocked(r *workspaceWatchRoot) {
489 r.publishGen++
490 generation := r.publishGen
491 if r.timer != nil {
492 r.timer.Stop()
493 }
494 r.timer = time.AfterFunc(workspaceWatchQuiet, func() { h.publish(r.key, generation) })
495 }
496
497 func (h *workspaceChangeHub) observeAgentMutation(tabID string, mutation event.WorkspaceMutation) {
498 root := h.app.workspaceRootForTab(tabID)
499 key := h.ensureRoot(root)
500 if key == "" {
501 return
502 }
503 h.mu.Lock()
504 r := h.roots[key]
505 if r == nil || h.closed {
506 h.mu.Unlock()
507 return
508 }
509 if mutation.Content {
510 r.revisions.Content++
511 }
512 // A writer path does not carry an atomic create-vs-overwrite result. Treat
513 // the tree as possibly changed so newly-created files appear immediately;
514 // the frontend still reloads only affected open parents when paths are known.
515 if mutation.Tree {
516 r.revisions.Tree++
517 }
518 if mutation.WorkingTree {
519 r.revisions.WorkingTree++
520 }
521 if mutation.GitMeta {
522 r.revisions.GitMeta++
523 }
524 r.source = mergeWorkspaceSource(r.source, "agent")
525 if mutation.AllPaths || (mutation.Content && len(mutation.Paths) == 0) {
526 r.allPaths = true
527 } else {
528 for _, raw := range mutation.Paths {
529 path, ok := workspaceMutationRelPath(r.root, raw)
530 if !ok {
531 r.allPaths = true
532 continue
533 }
534 if len(r.pending) >= workspaceWatchMaxPaths {
535 r.allPaths = true
536 break
537 }
538 r.pending[path] = mergePathChange(r.pending[path], event.WorkspacePathChange{Path: path, Op: "write"})
539 }
540 }
541 h.session[tabID]++
542 h.schedulePublishLocked(r)
543 h.mu.Unlock()
544 }
545
546 func workspaceMutationRelPath(root, raw string) (string, bool) {
547 if strings.TrimSpace(raw) == "" {
548 return "", false
549 }
550 path := filepath.Clean(raw)
551 if path == "." {
552 return "", false
553 }
554 if !filepath.IsAbs(path) {
555 path = filepath.Join(root, path)
556 }
557 rel, err := filepath.Rel(root, path)
558 if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
559 return "", false
560 }
561 return filepath.ToSlash(rel), true
562 }
563
564 func (h *workspaceChangeHub) publish(key string, generation uint64) {
565 h.mu.Lock()
566 r := h.roots[key]
567 if r == nil || r.closed || h.closed || r.publishGen != generation {
568 h.mu.Unlock()
569 return
570 }
571 changes := make([]event.WorkspacePathChange, 0, len(r.pending))
572 for _, c := range r.pending {
573 changes = append(changes, c)
574 }
575 allPaths, source, revisions, state := r.allPaths, r.source, r.revisions, r.state
576 r.pending = make(map[string]event.WorkspacePathChange)
577 r.allPaths = false
578 r.source = ""
579 r.timer = nil
580 h.mu.Unlock()
581
582 for _, target := range h.tabsForRoot(key) {
583 targetID, sink := target.id, target.sink
584 if sink == nil {
585 continue
586 }
587 h.mu.Lock()
588 revisions.Session = h.session[targetID]
589 h.mu.Unlock()
590 sink.Emit(event.Event{Kind: event.WorkspaceChanged, Workspace: &event.WorkspaceChangedPayload{
591 Revisions: revisions, Changes: append([]event.WorkspacePathChange(nil), changes...),
592 AllPaths: allPaths, Source: source, WatchState: state,
593 }})
594 }
595 }
596
597 type workspaceSinkTarget struct {
598 id string
599 sink *tabEventSink
600 }
601
602 func (h *workspaceChangeHub) tabsForRoot(key string) []workspaceSinkTarget {
603 if h.app == nil {
604 return nil
605 }
606 globalKey := canonicalWorkspaceRoot(globalWorkspaceRoot())
607 h.app.mu.RLock()
608 tabs := make([]workspaceSinkTarget, 0, len(h.app.tabs))
609 for id, tab := range h.app.tabs {
610 if tab == nil || tab.sink == nil {
611 continue
612 }
613 tabRoot := tab.WorkspaceRoot
614 if tabRoot == "" {
615 if globalKey != key {
616 continue
617 }
618 } else if canonicalWorkspaceRoot(tabRoot) != key {
619 continue
620 }
621 tabs = append(tabs, workspaceSinkTarget{id: id, sink: tab.sink})
622 }
623 h.app.mu.RUnlock()
624 return tabs
625 }
626
627 func (h *workspaceChangeHub) revisionForTab(tabID, root string) WorkspaceRevisionView {
628 key := h.ensureRoot(root)
629 h.mu.Lock()
630 defer h.mu.Unlock()
631 r := h.roots[key]
632 if r == nil {
633 return WorkspaceRevisionView{WatchState: event.WorkspaceWatchUnavailable}
634 }
635 revisions := r.revisions
636 revisions.Session = h.session[tabID]
637 return WorkspaceRevisionView{Revisions: revisions, WatchState: r.state}
638 }
639
640 func (h *workspaceChangeHub) reconcile(tabID string) {
641 root := h.app.workspaceRootForTab(tabID)
642 key := h.ensureRoot(root)
643 if key == "" {
644 return
645 }
646 h.mu.Lock()
647 r := h.roots[key]
648 if r != nil && r.state != event.WorkspaceWatchActive {
649 r.revisions.Content++
650 r.revisions.Tree++
651 r.revisions.WorkingTree++
652 r.revisions.GitMeta++
653 r.source = "reconcile"
654 r.allPaths = true
655 h.schedulePublishLocked(r)
656 }
657 h.mu.Unlock()
658 }
659
660 func (h *workspaceChangeHub) reconcileRoots() {
661 if h == nil || h.app == nil {
662 return
663 }
664 h.app.mu.RLock()
665 used := make(map[string]struct{}, len(h.app.tabs))
666 for _, tab := range h.app.tabs {
667 if tab == nil {
668 continue
669 }
670 root := tab.WorkspaceRoot
671 if root == "" {
672 root = globalWorkspaceRoot()
673 }
674 if key := canonicalWorkspaceRoot(root); key != "" {
675 used[key] = struct{}{}
676 }
677 }
678 h.mu.Lock()
679 watchers := make([]workspaceWatcher, 0)
680 for key, r := range h.roots {
681 if _, ok := used[key]; ok {
682 continue
683 }
684 r.closed = true
685 if r.timer != nil {
686 r.timer.Stop()
687 }
688 if r.watcher != nil {
689 watchers = append(watchers, r.watcher)
690 }
691 delete(h.roots, key)
692 }
693 h.mu.Unlock()
694 h.app.mu.RUnlock()
695 for _, watcher := range watchers {
696 _ = watcher.Close()
697 }
698 }
699
700 func (h *workspaceChangeHub) close() {
701 if h == nil {
702 return
703 }
704 h.mu.Lock()
705 if h.closed {
706 h.mu.Unlock()
707 return
708 }
709 h.closed = true
710 watchers := make([]workspaceWatcher, 0, len(h.roots))
711 for key, r := range h.roots {
712 r.closed = true
713 if r.timer != nil {
714 r.timer.Stop()
715 }
716 if r.watcher != nil {
717 watchers = append(watchers, r.watcher)
718 }
719 delete(h.roots, key)
720 }
721 h.mu.Unlock()
722 for _, watcher := range watchers {
723 _ = watcher.Close()
724 }
725 }
726
727 func (a *App) workspaceRootForTab(tabID string) string {
728 if a == nil {
729 return ""
730 }
731 a.mu.RLock()
732 tab := a.tabs[tabID]
733 if tab == nil {
734 for _, detached := range a.detachedSessions {
735 if detached != nil && detached.ID == tabID {
736 tab = detached
737 break
738 }
739 }
740 }
741 if tab == nil && tabID == "" {
742 tab = a.tabs[a.activeTabID]
743 }
744 root := ""
745 if tab != nil {
746 root = tab.WorkspaceRoot
747 }
748 a.mu.RUnlock()
749 if root == "" {
750 root = globalWorkspaceRoot()
751 }
752 return root
753 }
754
755 // WorkspaceRevisionForTab is a read-only reconciliation seam for panels that
756 // were mounted after an event, restored from a runtime, or resumed from focus.
757 func (a *App) WorkspaceRevisionForTab(tabID string) WorkspaceRevisionView {
758 if a == nil || a.workspaceHub == nil {
759 return WorkspaceRevisionView{WatchState: event.WorkspaceWatchUnavailable}
760 }
761 return a.workspaceHub.revisionForTab(tabID, a.workspaceRootForTab(tabID))
762 }
763
764 // RecordWorkspaceMutation bypasses ToolResult's provider-ordered presentation
765 // stream so a later long-running tool cannot delay the host refresh signal.
766 func (s *tabEventSink) RecordWorkspaceMutation(mutation event.WorkspaceMutation) {
767 tabID, app := s.binding()
768 if app != nil && app.workspaceHub != nil {
769 app.workspaceHub.observeAgentMutation(tabID, mutation)
770 }
771 }
772
773 func (a *App) reconcileWorkspaceForTab(tabID string) {
774 if a != nil && a.workspaceHub != nil {
775 a.workspaceHub.reconcile(tabID)
776 }
777 }
778
778 lines GO