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