| 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 |