返回 DeepSeek-Reasonix
session_catalog.go
根目录 / desktop / session_catalog.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "log/slog"
7 "os"
8 "path/filepath"
9 "strings"
10 "time"
11
12 "reasonix/internal/agent"
13 "reasonix/internal/history"
14 "reasonix/internal/sessioncatalog"
15 "reasonix/internal/stats"
16 "reasonix/internal/taskcatalog"
17 )
18
19 const sessionCatalogMetadataSyncTimeout = 30 * time.Second
20
21 const desktopSessionCatalogPersistObserverKey = "desktop-session-catalog"
22
23 type desktopSessionCatalogPersistObserver struct{ app *App }
24
25 func (observer desktopSessionCatalogPersistObserver) EnqueueSessionPersist(event agent.SessionPersistEvent) bool {
26 a := observer.app
27 if a == nil || a.shuttingDown.Load() || strings.TrimSpace(event.Path) == "" {
28 return false
29 }
30 catalog := a.sessionCatalog.Load()
31 if catalog == nil {
32 return false
33 }
34 path := filepath.Clean(event.Path)
35 if event.Removed {
36 go func() {
37 ctx, cancel := context.WithTimeout(a.bootContext(), 5*time.Second)
38 defer cancel()
39 _ = catalog.RemoveSession(ctx, path, "authoritative_persist_removed")
40 }()
41 return true
42 }
43 // IndexSessionPath loads authoritative branch metadata, correcting this
44 // global fallback to the real project scope. Exact-path requests also make
45 // bot/controller saves visible without waiting for the directory sweep.
46 return catalog.RequestIndexSession(sessioncatalog.DirectoryTarget{
47 Path: filepath.Dir(path), Scope: "global",
48 }, path)
49 }
50
51 type SessionCatalogStatus struct {
52 State string `json:"state"`
53 Mode string `json:"mode"`
54 Revision uint64 `json:"revision"`
55 Indexed int64 `json:"indexed"`
56 Total int64 `json:"total"`
57 RepairPending int64 `json:"repairPending"`
58 RepairActive int64 `json:"repairActive"`
59 RepairDeferred int64 `json:"repairDeferred"`
60 RepairBlocked int64 `json:"repairBlocked"`
61 NextRepairAt int64 `json:"nextRepairAt,omitempty"`
62 CanRebuild bool `json:"canRebuild"`
63 LastError string `json:"lastError,omitempty"`
64 QuarantinedPath string `json:"quarantinedPath,omitempty"`
65 }
66
67 type ProjectTreeSnapshot struct {
68 Revision uint64 `json:"revision"`
69 Projects []ProjectNode `json:"projects"`
70 Catalog SessionCatalogStatus `json:"catalog"`
71 Indexed int64 `json:"indexed"`
72 Total int64 `json:"total"`
73 IndexingDone bool `json:"indexingDone"`
74 }
75
76 type ProjectTopicPageRequest struct {
77 Scope string `json:"scope"`
78 WorkspaceRoot string `json:"workspaceRoot,omitempty"`
79 Cursor string `json:"cursor,omitempty"`
80 Limit int `json:"limit,omitempty"`
81 Query string `json:"query,omitempty"`
82 TimeFilter string `json:"timeFilter,omitempty"`
83 SortMode string `json:"sortMode,omitempty"`
84 GroupFilter string `json:"groupFilter,omitempty"`
85 GroupID string `json:"groupId,omitempty"`
86 ExcludePinned bool `json:"excludePinned,omitempty"`
87
88 groupIncludeJSON string
89 groupExcludeJSON string
90 groupCursorBind string
91 groupInclude map[string]struct{}
92 groupExclude map[string]struct{}
93 groupSelected *desktopGroup
94 groupAll []desktopGroup
95 }
96
97 type ProjectTopicKey struct {
98 Scope string `json:"scope"`
99 WorkspaceRoot string `json:"workspaceRoot,omitempty"`
100 TopicID string `json:"topicId"`
101 // Path optionally binds topic-wide recovery actions to one physical lineage.
102 // Older frontends omit it and remain compatible when the topic has one group.
103 Path string `json:"path,omitempty"`
104 // RecordClassification is set only by the recovery-event coordinator after
105 // a catalog revision. Ordinary History reads remain diagnostic-free.
106 RecordClassification bool `json:"recordClassification,omitempty"`
107 }
108
109 type ProjectTopicPage struct {
110 Items []ProjectNode `json:"items"`
111 NextCursor string `json:"nextCursor,omitempty"`
112 Revision uint64 `json:"revision"`
113 Complete bool `json:"complete"`
114 ReadyDirectories int `json:"readyDirectories"`
115 PendingDirectories int `json:"pendingDirectories"`
116 FailedDirectories int `json:"failedDirectories"`
117 }
118
119 type ProjectTreeChangedV2 struct {
120 Revision uint64 `json:"revision"`
121 Roots []string `json:"roots"`
122 Reason string `json:"reason"`
123 }
124
125 // ProjectRuntimeTopic is one process-local runtime projected onto its stable
126 // logical topic identity. The catalog remains the authority for persisted
127 // history; this projection is the authority for what this process is running.
128 type ProjectRuntimeTopic struct {
129 Scope string `json:"scope"`
130 WorkspaceRoot string `json:"workspaceRoot,omitempty"`
131 Node ProjectNode `json:"node"`
132 }
133
134 // ProjectTreeRuntimeSnapshot is a replace-all, idempotent runtime projection.
135 // Its revision is independent from the session catalog revision so clients can
136 // order ownership/status changes without reloading any catalog page.
137 type ProjectTreeRuntimeSnapshot struct {
138 Revision uint64 `json:"revision"`
139 Topics []ProjectRuntimeTopic `json:"topics"`
140 }
141
142 func flushDesktopDerivedCatalogs(ctx context.Context) error {
143 var first error
144 if err := history.FlushSharedCatalog(ctx); err != nil && first == nil {
145 first = err
146 }
147 if err := history.CloseSharedCatalog(ctx); err != nil && first == nil {
148 first = err
149 }
150 if err := stats.CloseUsageCatalogs(ctx); err != nil && first == nil {
151 first = err
152 }
153 if err := taskcatalog.ShutdownShared(ctx); err != nil && first == nil {
154 first = err
155 }
156 return first
157 }
158
159 func sessionCatalogStatus(status sessioncatalog.Status) SessionCatalogStatus {
160 return SessionCatalogStatus{
161 State: string(status.State),
162 Mode: string(status.Mode),
163 Revision: status.Revision,
164 Indexed: status.Indexed,
165 Total: status.Total,
166 RepairPending: status.RepairPending,
167 RepairActive: status.RepairActive,
168 RepairDeferred: status.RepairDeferred,
169 RepairBlocked: status.RepairBlocked,
170 NextRepairAt: status.NextRepairAt,
171 CanRebuild: status.RepairActive == 0 && (status.State == sessioncatalog.StateDegraded ||
172 (status.State == sessioncatalog.StateReady && strings.TrimSpace(status.LastError) != "")),
173 LastError: status.LastError,
174 QuarantinedPath: status.QuarantinedPath,
175 }
176 }
177
178 func (a *App) currentSessionCatalogStatus() SessionCatalogStatus {
179 if a == nil {
180 return SessionCatalogStatus{State: string(sessioncatalog.StateDegraded), Mode: string(sessioncatalog.ModeMemory)}
181 }
182 if catalog := a.sessionCatalog.Load(); catalog != nil {
183 status := sessionCatalogStatus(catalog.Status())
184 if a.catalogRebuilding.Load() {
185 status.State = string(sessioncatalog.StateRebuilding)
186 status.CanRebuild = false
187 }
188 return status
189 }
190 if a.catalogRebuilding.Load() {
191 return SessionCatalogStatus{State: string(sessioncatalog.StateRebuilding)}
192 }
193 return SessionCatalogStatus{State: string(sessioncatalog.StateOpening)}
194 }
195
196 func (a *App) startSessionCatalog() {
197 if a == nil || a.shuttingDown.Load() {
198 return
199 }
200 a.catalogLifecycleMu.Lock()
201 if a.catalogCancel != nil {
202 a.catalogLifecycleMu.Unlock()
203 return
204 }
205 ctx, cancel := context.WithCancel(a.bootContext())
206 done := make(chan struct{})
207 initialReconcileDone := make(chan struct{})
208 a.catalogCancel = cancel
209 a.catalogDone = done
210 a.catalogInitialReconcileDone = initialReconcileDone
211 a.catalogLifecycleMu.Unlock()
212 history.RegisterSessionPersistObserver(desktopSessionCatalogPersistObserverKey, desktopSessionCatalogPersistObserver{app: a})
213
214 go func() {
215 defer close(done)
216 a.runSessionCatalog(ctx, initialReconcileDone)
217 }()
218 }
219
220 func (a *App) stopSessionCatalog(timeout time.Duration) bool {
221 if a == nil {
222 return true
223 }
224 a.catalogLifecycleMu.Lock()
225 cancel := a.catalogCancel
226 done := a.catalogDone
227 a.catalogCancel = nil
228 a.catalogDone = nil
229 a.catalogInitialReconcileDone = nil
230 a.catalogLifecycleMu.Unlock()
231 if cancel != nil {
232 cancel()
233 }
234 catalog := a.sessionCatalog.Swap(nil)
235 deadline := time.Now().Add(timeout)
236 // Pair the nil publication with the request-side locked recheck. Once this
237 // barrier passes, the snapshot contains every reconcile that can use catalog
238 // and no new one can be added.
239 a.catalogReconcileMu.Lock()
240 reconcileDone := make([]<-chan struct{}, 0, len(a.catalogReconcileJobs))
241 for _, job := range a.catalogReconcileJobs {
242 reconcileDone = append(reconcileDone, job.done)
243 }
244 a.catalogReconcileMu.Unlock()
245 stopped := true
246 for _, done := range reconcileDone {
247 if !waitChannelBefore(done, deadline) {
248 stopped = false
249 break
250 }
251 }
252 if catalog != nil {
253 remaining := max(time.Until(deadline), 0)
254 ctx, closeCancel := context.WithTimeout(context.Background(), remaining)
255 err := catalog.Close(ctx)
256 closeCancel()
257 if err != nil {
258 stopped = false
259 }
260 }
261 if done != nil && !waitChannelBefore(done, deadline) {
262 stopped = false
263 }
264 return stopped
265 }
266
267 func waitChannelBefore(done <-chan struct{}, deadline time.Time) bool {
268 remaining := time.Until(deadline)
269 if remaining <= 0 {
270 return false
271 }
272 timer := time.NewTimer(remaining)
273 defer timer.Stop()
274 select {
275 case <-done:
276 return true
277 case <-timer.C:
278 return false
279 }
280 }
281
282 func (a *App) cancelAllTabBuilds() {
283 if a == nil {
284 return
285 }
286 a.mu.Lock()
287 for _, tab := range a.tabs {
288 a.supersedeTabBuildLocked(tab)
289 }
290 for _, tab := range a.detachedSessions {
291 a.supersedeTabBuildLocked(tab)
292 }
293 a.mu.Unlock()
294 }
295
296 func listCatalogSessionsForDirectory(ctx context.Context, catalog *sessioncatalog.Catalog,
297 target sessioncatalog.DirectoryTarget, directory string) ([]sessioncatalog.SessionRecord, error) {
298 for range 2 {
299 records := []sessioncatalog.SessionRecord{}
300 cursor := ""
301 for {
302 page, err := catalog.ListSessions(ctx, sessioncatalog.SessionPageRequest{Scope: target.Scope,
303 WorkspaceRoot: target.WorkspaceRoot, Directory: directory, Cursor: cursor, Limit: sessioncatalog.MaxLimit})
304 if err != nil {
305 return nil, err
306 }
307 if page.StaleCursor {
308 break
309 }
310 records = append(records, page.Items...)
311 if page.NextCursor == "" {
312 return records, nil
313 }
314 cursor = page.NextCursor
315 }
316 }
317 return []sessioncatalog.SessionRecord{}, nil
318 }
319
320 // syncSessionCatalogMetadataBounded is the only form the long-lived catalog
321 // goroutine may use. SyncMetadata runs under the catalog's single-writer mutex,
322 // so one transaction that never returns silently wedges every later index,
323 // reconcile, and revision bump — and the sidebar then stops updating for the
324 // rest of the process lifetime instead of failing loudly.
325 func (a *App) syncSessionCatalogMetadataBounded(ctx context.Context, catalog *sessioncatalog.Catalog) error {
326 ctx, cancel := context.WithTimeout(ctx, sessionCatalogMetadataSyncTimeout)
327 defer cancel()
328 return a.syncSessionCatalogMetadata(ctx, catalog)
329 }
330
331 func (a *App) syncSessionCatalogMetadata(ctx context.Context, catalog *sessioncatalog.Catalog) error {
332 f := loadProjectsFile()
333 deleted := map[string]bool{}
334 for _, topicID := range f.DeletedTopics {
335 deleted[topicID] = true
336 }
337 projects := []sessioncatalog.ProjectRecord{{
338 Scope: "global", Title: strings.TrimSpace(f.GlobalTitle), Color: normalizeProjectColor(f.GlobalColor),
339 }}
340 if projects[0].Title == "" {
341 projects[0].Title = "Global"
342 }
343 topics := []sessioncatalog.TopicMetadata{}
344 appendTopics := func(scope, root string, ids, pinnedIDs []string, manualOrder bool) {
345 titles := loadTopicTitles(root)
346 sources := loadTopicTitleSources(root)
347 created := loadTopicCreatedAts(root)
348 ordered := pinnedTopicIDs(orderedTopicIDs(ids, titles), pinnedIDs)
349 for index, topicID := range ordered {
350 if deleted[topicID] {
351 continue
352 }
353 title := strings.TrimSpace(titles[topicID])
354 if title == "" {
355 title = defaultTopicTitle
356 }
357 sortOrder := -1
358 if manualOrder {
359 sortOrder = index
360 }
361 topics = append(topics, sessioncatalog.TopicMetadata{
362 Scope: scope, WorkspaceRoot: root, TopicID: topicID, Title: title,
363 TitleSource: sources[topicID], Pinned: containsDesktopString(pinnedIDs, topicID),
364 SortOrder: sortOrder, CreatedAt: topicCreatedAtForTree(created, topicID),
365 })
366 }
367 }
368 appendTopics("global", "", f.GlobalTopics, f.GlobalPinnedTopics, f.GlobalManualTopicOrder)
369 for index, project := range f.Projects {
370 title := strings.TrimSpace(project.Title)
371 if title == "" {
372 title = workspaceName(project.Root)
373 }
374 projects = append(projects, sessioncatalog.ProjectRecord{
375 Scope: "project", WorkspaceRoot: project.Root, Title: title, Color: project.Color,
376 Pinned: containsDesktopString(f.PinnedProjects, project.Root), SortOrder: index,
377 })
378 appendTopics("project", project.Root, project.Topics, project.PinnedTopics, project.ManualTopicOrder)
379 }
380 return catalog.SyncMetadata(ctx, projects, topics)
381 }
382
383 func (a *App) emitProjectTreeChangedV2(revision uint64, roots []string, reason string) {
384 if roots == nil {
385 roots = []string{}
386 }
387 a.emitRuntimeEvent("project-tree:changed-v2", ProjectTreeChangedV2{Revision: a.unifiedProjectRevision(revision), Roots: roots, Reason: reason})
388 // One-release compatibility event. Its wrapper is catalog-only, so legacy
389 // frontends refresh without making current frontends rebuild the whole tree
390 // after they already consumed the targeted v2 revision.
391 a.emitRuntimeEvent("project-tree:changed", map[string]string{"reason": "catalog-v2"})
392 }
393
394 type desktopCatalogReconcileJob struct {
395 target sessioncatalog.DirectoryTarget
396 dirty bool
397 done chan struct{}
398 }
399
400 func (a *App) requestSessionCatalogReconcile(dir string) bool {
401 catalog := a.sessionCatalog.Load()
402 if catalog == nil || a.shuttingDown.Load() || strings.TrimSpace(dir) == "" {
403 return false
404 }
405 clean := filepath.Clean(dir)
406 key := projectRootKey(clean)
407 target := sessioncatalog.DirectoryTarget{Path: clean, Scope: "global"}
408 for _, candidate := range a.sessionCatalogTargets() {
409 if sameDesktopPath(candidate.Path, clean) {
410 target = candidate
411 break
412 }
413 }
414 a.catalogReconcileMu.Lock()
415 if a.sessionCatalog.Load() != catalog || a.shuttingDown.Load() {
416 a.catalogReconcileMu.Unlock()
417 return false
418 }
419 if a.catalogReconcileJobs == nil {
420 a.catalogReconcileJobs = map[string]*desktopCatalogReconcileJob{}
421 }
422 if job := a.catalogReconcileJobs[key]; job != nil {
423 job.target = target
424 job.dirty = true
425 a.catalogReconcileMu.Unlock()
426 return true
427 }
428 done := make(chan struct{})
429 a.catalogReconcileJobs[key] = &desktopCatalogReconcileJob{target: target, done: done}
430 a.catalogReconcileMu.Unlock()
431 go a.runSessionCatalogReconcile(key, done)
432 return true
433 }
434
435 func (a *App) runSessionCatalogReconcile(key string, done chan struct{}) {
436 defer close(done)
437 for {
438 a.catalogReconcileMu.Lock()
439 job := a.catalogReconcileJobs[key]
440 if job == nil {
441 a.catalogReconcileMu.Unlock()
442 return
443 }
444 target := job.target
445 job.dirty = false
446 a.catalogReconcileMu.Unlock()
447 catalog := a.sessionCatalog.Load()
448 if catalog == nil || a.shuttingDown.Load() {
449 a.catalogReconcileMu.Lock()
450 delete(a.catalogReconcileJobs, key)
451 a.catalogReconcileMu.Unlock()
452 return
453 }
454
455 if a.catalogReconcileHook != nil {
456 a.catalogReconcileHook(target)
457 }
458 // Explicit reconcile bypasses disposable migration markers. Signatures
459 // keep periodic passes cheap, but an old CLI or restored backup must
460 // never be permanently hidden by a timestamp/content collision.
461 migrated, migratedPaths := forceMigrateLegacySessionsIntoGlobalTopicsWithPaths(target.Path)
462 if len(migrated) > 0 {
463 ctx, cancel := context.WithTimeout(a.bootContext(), 30*time.Second)
464 // Publish the exact migrated sessions before the broader metadata
465 // projection. On large stores (and especially Windows), the metadata
466 // pass can take long enough to defeat this interactive fast path.
467 for _, path := range migratedPaths {
468 if err := catalog.IndexSessionPath(ctx, target, path); err != nil && !errors.Is(err, context.Canceled) {
469 slog.Debug("desktop: index migrated session", "path", path, "err", err)
470 }
471 }
472 _ = a.syncSessionCatalogMetadata(ctx, catalog)
473 cancel()
474 }
475 // Keep the per-directory single-flight slot until the catalog scan ends.
476 // Enqueuing would reopen the pre-scan stampede window while the catalog
477 // worker was still reconciling the same directory.
478 if err := catalog.ReconcileDirectory(a.bootContext(), target); err != nil && !errors.Is(err, context.Canceled) {
479 slog.Debug("desktop: reconcile session catalog", "path", target.Path, "err", err)
480 }
481 // The count sweep rides the reconcile worker; every move re-proves
482 // coverage from disk, so a stale projection after a failed scan is safe.
483 a.sweepExcessRecoveryCopies(catalog, target)
484
485 a.catalogReconcileMu.Lock()
486 job = a.catalogReconcileJobs[key]
487 if job == nil {
488 a.catalogReconcileMu.Unlock()
489 return
490 }
491 if job.dirty && !a.shuttingDown.Load() {
492 a.catalogReconcileMu.Unlock()
493 continue
494 }
495 delete(a.catalogReconcileJobs, key)
496 a.catalogReconcileMu.Unlock()
497 if a.catalogReconcileDoneHook != nil {
498 a.catalogReconcileDoneHook(target)
499 }
500 return
501 }
502 }
503
504 func sessionDirectoryForPath(path string) string {
505 path = strings.TrimSpace(path)
506 if path == "" {
507 return ""
508 }
509 clean := filepath.Clean(path)
510 if clean == "." || filepath.Base(clean) == clean {
511 return ""
512 }
513 return filepath.Dir(clean)
514 }
515
516 func (a *App) saveTabSessionMetaSnapshotAndIndex(snap tabSessionMetaSnapshot) error {
517 if err := saveTabSessionMetaSnapshot(snap); err != nil {
518 return err
519 }
520 // Transcript saves index through the observer; enqueue again after the
521 // sidecar commit so scope and title changes are visible without a full scan.
522 a.requestSessionCatalogIndexPath(snap.scope, snap.workspaceRoot, string(snap.path))
523 return nil
524 }
525
526 func discardTransientBlankSessionArtifacts(path string) bool {
527 if strings.TrimSpace(path) == "" {
528 return false
529 }
530 if err := removeDesktopSessionArtifacts(path); err != nil {
531 slog.Warn("desktop: discard transient blank session artifacts failed", "path", path, "err", err)
532 return false
533 }
534 return true
535 }
536
537 func (a *App) requestSessionCatalogPath(scope, workspaceRoot, path string) {
538 if strings.TrimSpace(path) != "" {
539 _ = history.PersistObserver().EnqueueSessionPersist(agent.SessionPersistEvent{Path: path, Rewrite: true})
540 }
541 a.requestSessionCatalogIndexPath(scope, workspaceRoot, path)
542 }
543
544 // requestSessionCatalogIndexPath publishes one committed session/sidecar
545 // change without walking its directory. A saturated exact-path queue falls
546 // back to a scoped reconcile so the disposable projection still converges.
547 func (a *App) requestSessionCatalogIndexPath(scope, workspaceRoot, path string) {
548 catalog := a.sessionCatalog.Load()
549 if catalog == nil || a.shuttingDown.Load() || strings.TrimSpace(path) == "" {
550 return
551 }
552 target := sessioncatalog.DirectoryTarget{
553 Path: sessionDirectoryForPath(path), Scope: scope, WorkspaceRoot: workspaceRoot,
554 }
555 if !catalog.RequestIndexSession(target, path) {
556 a.requestSessionCatalogReconcile(target.Path)
557 }
558 }
559
560 func (a *App) removeSessionCatalogPath(path, reason string) {
561 if strings.TrimSpace(path) == "" {
562 return
563 }
564 _ = history.PersistObserver().EnqueueSessionPersist(agent.SessionPersistEvent{Path: path, Removed: true})
565 catalog := a.sessionCatalog.Load()
566 if catalog == nil {
567 return
568 }
569 ctx, cancel := context.WithTimeout(a.bootContext(), 150*time.Millisecond)
570 defer cancel()
571 if err := catalog.RemoveSession(ctx, path, reason); err != nil && !errors.Is(err, context.Canceled) {
572 slog.Debug("desktop: remove session catalog row", "err", err)
573 }
574 }
575
576 func (a *App) requestSessionCatalogMetadataSync() {
577 catalog := a.sessionCatalog.Load()
578 if catalog == nil || a.shuttingDown.Load() {
579 return
580 }
581 go func() {
582 ctx, cancel := context.WithTimeout(a.bootContext(), 5*time.Second)
583 defer cancel()
584 _ = a.syncSessionCatalogMetadata(ctx, catalog)
585 }()
586 }
587
588 func (a *App) GetProjectTreeSnapshot() ProjectTreeSnapshot {
589 f := loadProjectsFile()
590 deleted := make(map[string]bool, len(f.DeletedTopics))
591 for _, topicID := range f.DeletedTopics {
592 deleted[topicID] = true
593 }
594 projects := []ProjectNode{}
595 if strings.TrimSpace(f.GlobalTitle) != "" || len(f.GlobalTopics) > 0 || len(f.Projects) == 0 {
596 label := strings.TrimSpace(f.GlobalTitle)
597 if label == "" {
598 label = "Global"
599 }
600 projects = append(projects, ProjectNode{
601 Key: "global_folder", Kind: "global_folder", Label: label,
602 Root: globalWorkspaceRoot(), ProjectColor: normalizeProjectColor(f.GlobalColor),
603 Children: a.pinnedTopicShells("global", "", f.GlobalTopics, f.GlobalPinnedTopics, f.GlobalColor, deleted),
604 })
605 }
606 for _, project := range f.Projects {
607 label := strings.TrimSpace(project.Title)
608 if label == "" {
609 label = workspaceName(project.Root)
610 }
611 projects = append(projects, ProjectNode{
612 Key: "project_" + project.Root, Kind: "project", Label: label,
613 Root: project.Root, ProjectColor: project.Color,
614 Pinned: containsDesktopString(f.PinnedProjects, project.Root),
615 Children: a.pinnedTopicShells("project", project.Root, project.Topics, project.PinnedTopics, project.Color, deleted),
616 })
617 }
618 // Remote projects (pinned via the connection wizard) render as project
619 // groups too; the Remote ref swaps the folder icon for a cloud icon.
620 if remoteNodes, err := a.remoteProjectNodes(); err == nil {
621 projects = append(projects, remoteNodes...)
622 }
623 projects = a.mergeCanonicalWorkspaceShells(projects)
624 projects = applyPinnedProjectOrder(applyProjectTreeOrder(projects, f.SidebarOrder), f.PinnedProjects)
625 status := a.currentSessionCatalogStatus()
626 return ProjectTreeSnapshot{
627 Revision: a.unifiedProjectRevision(status.Revision), Projects: projects, Catalog: status,
628 Indexed: status.Indexed, Total: status.Total,
629 IndexingDone: a.catalogIndexingDone(status),
630 }
631 }
632
633 // pinnedTopicShells keeps pinned conversations available in the metadata-only
634 // project snapshot. Ordinary topic pages remain lazy, but a collapsed folder
635 // must not hide its pinned conversations until the user expands it.
636 func (a *App) pinnedTopicShells(scope, workspaceRoot string, topicIDs, pinnedIDs []string, projectColor string, deleted map[string]bool) []ProjectNode {
637 if len(pinnedIDs) == 0 {
638 return []ProjectNode{}
639 }
640 titles := loadTopicTitles(workspaceRoot)
641 sources := loadTopicTitleSources(workspaceRoot)
642 created := loadTopicCreatedAts(workspaceRoot)
643 available := make(map[string]bool, len(topicIDs)+len(titles))
644 for _, topicID := range orderedTopicIDs(topicIDs, titles) {
645 available[topicID] = true
646 }
647 kind := "topic"
648 if scope != "project" {
649 kind = "global_topic"
650 }
651 out := make([]ProjectNode, 0, len(pinnedIDs))
652 for _, topicID := range uniqueStrings(pinnedIDs) {
653 if !available[topicID] || deleted[topicID] {
654 continue
655 }
656 title := strings.TrimSpace(titles[topicID])
657 if title == "" {
658 title = defaultTopicTitle
659 }
660 out = append(out, ProjectNode{
661 Key: kind + "_" + topicID, Kind: kind,
662 Label: a.localizedTopicTitle(title, sources[topicID]), Root: workspaceRoot,
663 TopicID: topicID, ProjectColor: normalizeProjectColor(projectColor),
664 CreatedAt: topicCreatedAtForTree(created, topicID), Pinned: true,
665 TurnsState: string(sessioncatalog.TurnsUnknown), Health: string(sessioncatalog.HealthOK),
666 Children: []ProjectNode{},
667 })
668 }
669 return out
670 }
671
672 func (a *App) catalogIndexingDone(status SessionCatalogStatus) bool {
673 if status.State != string(sessioncatalog.StateReady) || status.RepairActive > 0 {
674 return false
675 }
676 catalog := a.sessionCatalog.Load()
677 if catalog == nil {
678 return false
679 }
680 ctx, cancel := a.catalogReadContext()
681 defer cancel()
682 targets := a.sessionCatalogTargets()
683 if len(targets) == 0 {
684 return false
685 }
686 sawExisting := false
687 for _, target := range targets {
688 if _, err := os.Stat(target.Path); os.IsNotExist(err) {
689 continue
690 }
691 sawExisting = true
692 if !catalog.DirectoryScanReady(ctx, target.Path) {
693 return false
694 }
695 }
696 return sawExisting
697 }
698
698 lines GO