返回 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 WorkspaceGeneration *uint64 `json:"workspaceGeneration,omitempty"`
70 Projects []ProjectNode `json:"projects"`
71 Catalog SessionCatalogStatus `json:"catalog"`
72 Indexed int64 `json:"indexed"`
73 Total int64 `json:"total"`
74 IndexingDone bool `json:"indexingDone"`
75 }
76
77 type ProjectTopicPageRequest struct {
78 Scope string `json:"scope"`
79 WorkspaceRoot string `json:"workspaceRoot,omitempty"`
80 Cursor string `json:"cursor,omitempty"`
81 Limit int `json:"limit,omitempty"`
82 Query string `json:"query,omitempty"`
83 TimeFilter string `json:"timeFilter,omitempty"`
84 SortMode string `json:"sortMode,omitempty"`
85 GroupFilter string `json:"groupFilter,omitempty"`
86 GroupID string `json:"groupId,omitempty"`
87 ExcludePinned bool `json:"excludePinned,omitempty"`
88
89 groupIncludeJSON string
90 groupExcludeJSON string
91 groupCursorBind string
92 groupInclude map[string]struct{}
93 groupExclude map[string]struct{}
94 groupSelected *desktopGroup
95 groupAll []desktopGroup
96 pinnedOnly bool
97 timeCutoff int64
98 readContext context.Context
99 metadataSnapshot *[]ProjectNode
100 readAllSources bool
101 readAvailability *catalogWorkspaceAvailability
102 }
103
104 type ProjectTopicKey struct {
105 Scope string `json:"scope"`
106 WorkspaceRoot string `json:"workspaceRoot,omitempty"`
107 TopicID string `json:"topicId"`
108 // Path optionally binds topic-wide recovery actions to one physical lineage.
109 // Older frontends omit it and remain compatible when the topic has one group.
110 Path string `json:"path,omitempty"`
111 // RecordClassification is set only by the recovery-event coordinator after
112 // a catalog revision. Ordinary History reads remain diagnostic-free.
113 RecordClassification bool `json:"recordClassification,omitempty"`
114 }
115
116 type ProjectTopicPage struct {
117 SnapshotID string `json:"snapshotId,omitempty"`
118 SnapshotExpiresAt int64 `json:"snapshotExpiresAt,omitempty"`
119 Items []ProjectNode `json:"items"`
120 NextCursor string `json:"nextCursor,omitempty"`
121 Revision uint64 `json:"revision"`
122 Complete bool `json:"complete"`
123 ReadyDirectories int `json:"readyDirectories"`
124 PendingDirectories int `json:"pendingDirectories"`
125 FailedDirectories int `json:"failedDirectories"`
126 }
127
128 type ProjectTreeChangedV2 struct {
129 Revision uint64 `json:"revision"`
130 Roots []string `json:"roots"`
131 Reason string `json:"reason"`
132 }
133
134 // ProjectRuntimeTopic is one process-local runtime projected onto its stable
135 // logical topic identity. The catalog remains the authority for persisted
136 // history; this projection is the authority for what this process is running.
137 type ProjectRuntimeTopic struct {
138 Scope string `json:"scope"`
139 WorkspaceRoot string `json:"workspaceRoot,omitempty"`
140 Node ProjectNode `json:"node"`
141 }
142
143 // ProjectTreeRuntimeSnapshot is a replace-all, idempotent runtime projection.
144 // Its revision is independent from the session catalog revision so clients can
145 // order ownership/status changes without reloading any catalog page.
146 type ProjectTreeRuntimeSnapshot struct {
147 Revision uint64 `json:"revision"`
148 Topics []ProjectRuntimeTopic `json:"topics"`
149 }
150
151 func flushDesktopDerivedCatalogs(ctx context.Context) error {
152 var first error
153 if err := history.FlushSharedCatalog(ctx); err != nil && first == nil {
154 first = err
155 }
156 if err := history.CloseSharedCatalog(ctx); err != nil && first == nil {
157 first = err
158 }
159 if err := stats.CloseUsageCatalogs(ctx); err != nil && first == nil {
160 first = err
161 }
162 if err := taskcatalog.ShutdownShared(ctx); err != nil && first == nil {
163 first = err
164 }
165 return first
166 }
167
168 func sessionCatalogStatus(status sessioncatalog.Status) SessionCatalogStatus {
169 return SessionCatalogStatus{
170 State: string(status.State),
171 Mode: string(status.Mode),
172 Revision: status.Revision,
173 Indexed: status.Indexed,
174 Total: status.Total,
175 RepairPending: status.RepairPending,
176 RepairActive: status.RepairActive,
177 RepairDeferred: status.RepairDeferred,
178 RepairBlocked: status.RepairBlocked,
179 NextRepairAt: status.NextRepairAt,
180 CanRebuild: status.RepairActive == 0 && (status.State == sessioncatalog.StateDegraded ||
181 (status.State == sessioncatalog.StateReady && strings.TrimSpace(status.LastError) != "")),
182 LastError: status.LastError,
183 QuarantinedPath: status.QuarantinedPath,
184 }
185 }
186
187 func (a *App) currentSessionCatalogStatus() SessionCatalogStatus {
188 if a == nil {
189 return SessionCatalogStatus{State: string(sessioncatalog.StateDegraded), Mode: string(sessioncatalog.ModeMemory)}
190 }
191 if catalog := a.sessionCatalog.Load(); catalog != nil {
192 status := sessionCatalogStatus(catalog.Status())
193 if a.catalogRebuilding.Load() {
194 status.State = string(sessioncatalog.StateRebuilding)
195 status.CanRebuild = false
196 }
197 return status
198 }
199 if a.catalogRebuilding.Load() {
200 return SessionCatalogStatus{State: string(sessioncatalog.StateRebuilding)}
201 }
202 return SessionCatalogStatus{State: string(sessioncatalog.StateOpening)}
203 }
204
205 func (a *App) startSessionCatalog() {
206 if a == nil || a.shuttingDown.Load() {
207 return
208 }
209 a.catalogLifecycleMu.Lock()
210 if a.catalogCancel != nil {
211 a.catalogLifecycleMu.Unlock()
212 return
213 }
214 ctx, cancel := context.WithCancel(a.bootContext())
215 done := make(chan struct{})
216 initialReconcileDone := make(chan struct{})
217 metadataRequests := make(chan struct{}, 1)
218 a.catalogCancel = cancel
219 a.catalogDone = done
220 a.catalogInitialReconcileDone = initialReconcileDone
221 a.catalogMetadataRequests = metadataRequests
222 a.catalogLifecycleMu.Unlock()
223 history.RegisterSessionPersistObserver(desktopSessionCatalogPersistObserverKey, desktopSessionCatalogPersistObserver{app: a})
224
225 go func() {
226 defer close(done)
227 a.runSessionCatalog(ctx, initialReconcileDone, metadataRequests)
228 }()
229 }
230
231 // sessionCatalogClose is one stop's release of the catalog handles it took.
232 type sessionCatalogClose struct {
233 done chan struct{}
234 err error
235 }
236
237 // stopSessionCatalog reports whether every catalog handle this App opened is
238 // released. A close that outlives its deadline keeps running and stays owed:
239 // the next stop waits for it rather than reporting a handle it has forgotten.
240 func (a *App) stopSessionCatalog(timeout time.Duration) bool {
241 if a == nil {
242 return true
243 }
244 deadline := time.Now().Add(timeout)
245 closing := &sessionCatalogClose{done: make(chan struct{})}
246 a.catalogLifecycleMu.Lock()
247 cancel := a.catalogCancel
248 done := a.catalogDone
249 earlier := a.catalogClosing
250 a.catalogCancel = nil
251 a.catalogDone = nil
252 a.catalogInitialReconcileDone = nil
253 a.catalogMetadataRequests = nil
254 a.catalogClosing = closing
255 a.catalogLifecycleMu.Unlock()
256 if cancel != nil {
257 cancel()
258 }
259 catalog := a.sessionCatalog.Swap(nil)
260 // Pair the nil publication with the request-side locked recheck. Once this
261 // barrier passes, the snapshot contains every reconcile that can use catalog
262 // and no new one can be added.
263 a.catalogReconcileMu.Lock()
264 reconcileDone := make([]<-chan struct{}, 0, len(a.catalogReconcileJobs))
265 for _, job := range a.catalogReconcileJobs {
266 reconcileDone = append(reconcileDone, job.done)
267 }
268 a.catalogReconcileMu.Unlock()
269 go func() {
270 defer close(closing.done)
271 // A reconcile may wait on discovery that stopping has paused, so it
272 // only defers the close until the deadline, never past it.
273 for _, reconciled := range reconcileDone {
274 if !waitChannelBefore(reconciled, deadline) {
275 break
276 }
277 }
278 if catalog != nil {
279 closing.err = catalog.Close(context.Background())
280 }
281 if done != nil {
282 <-done
283 }
284 if earlier != nil {
285 <-earlier.done
286 }
287 }()
288 return waitChannelBefore(closing.done, deadline) && closing.err == nil
289 }
290
291 func waitChannelBefore(done <-chan struct{}, deadline time.Time) bool {
292 remaining := time.Until(deadline)
293 if remaining <= 0 {
294 return false
295 }
296 timer := time.NewTimer(remaining)
297 defer timer.Stop()
298 select {
299 case <-done:
300 return true
301 case <-timer.C:
302 return false
303 }
304 }
305
306 func (a *App) cancelAllTabBuilds() {
307 if a == nil {
308 return
309 }
310 a.mu.Lock()
311 for _, tab := range a.tabs {
312 a.supersedeTabBuildLocked(tab)
313 }
314 for _, tab := range a.detachedSessions {
315 a.supersedeTabBuildLocked(tab)
316 }
317 a.mu.Unlock()
318 }
319
320 func listCatalogSessionsForDirectory(ctx context.Context, catalog *sessioncatalog.Catalog,
321 target sessioncatalog.DirectoryTarget, directory string) ([]sessioncatalog.SessionRecord, error) {
322 for range 2 {
323 records := []sessioncatalog.SessionRecord{}
324 cursor := ""
325 for {
326 page, err := catalog.ListSessions(ctx, sessioncatalog.SessionPageRequest{Scope: target.Scope,
327 WorkspaceRoot: target.WorkspaceRoot, Directory: directory, Cursor: cursor, Limit: sessioncatalog.MaxLimit})
328 if err != nil {
329 return nil, err
330 }
331 if page.StaleCursor {
332 break
333 }
334 records = append(records, page.Items...)
335 if page.NextCursor == "" {
336 return records, nil
337 }
338 cursor = page.NextCursor
339 }
340 }
341 return []sessioncatalog.SessionRecord{}, nil
342 }
343
344 // Metadata-only projection bounds each writer slice inside the catalog. A
345 // whole-observation timeout would repeatedly restart large registries at their
346 // first batch. Older catalog modes retain their whole-transaction deadline.
347 func (a *App) syncSessionCatalogMetadataBounded(ctx context.Context, catalog *sessioncatalog.Catalog) error {
348 if catalog.MetadataOnly() {
349 return a.syncSessionCatalogMetadata(ctx, catalog)
350 }
351 ctx, cancel := context.WithTimeout(ctx, sessionCatalogMetadataSyncTimeout)
352 defer cancel()
353 return a.syncSessionCatalogMetadata(ctx, catalog)
354 }
355
356 func (a *App) syncSessionCatalogMetadata(ctx context.Context, catalog *sessioncatalog.Catalog) error {
357 f := loadProjectsFile()
358 deleted := map[string]bool{}
359 for _, topicID := range f.DeletedTopics {
360 deleted[topicID] = true
361 }
362 projects := []sessioncatalog.ProjectRecord{{
363 Scope: "global", Title: strings.TrimSpace(f.GlobalTitle), Color: normalizeProjectColor(f.GlobalColor),
364 }}
365 if projects[0].Title == "" {
366 projects[0].Title = "Global"
367 }
368 topics := []sessioncatalog.TopicMetadata{}
369 appendTopics := func(scope, root string, ids, pinnedIDs []string, manualOrder bool) {
370 titles := loadTopicTitles(root)
371 sources := loadTopicTitleSources(root)
372 created := loadTopicCreatedAts(root)
373 ordered := pinnedTopicIDs(orderedTopicIDs(ids, titles), pinnedIDs)
374 for index, topicID := range ordered {
375 if deleted[topicID] {
376 continue
377 }
378 title := strings.TrimSpace(titles[topicID])
379 if title == "" {
380 title = defaultTopicTitle
381 }
382 sortOrder := -1
383 if manualOrder {
384 sortOrder = index
385 }
386 topics = append(topics, sessioncatalog.TopicMetadata{
387 Scope: scope, WorkspaceRoot: root, TopicID: topicID, Title: title,
388 TitleSource: sources[topicID], Pinned: containsDesktopString(pinnedIDs, topicID),
389 SortOrder: sortOrder, CreatedAt: topicCreatedAtForTree(created, topicID),
390 })
391 }
392 }
393 appendTopics("global", "", f.GlobalTopics, f.GlobalPinnedTopics, f.GlobalManualTopicOrder)
394 for index, project := range f.Projects {
395 title := strings.TrimSpace(project.Title)
396 if title == "" {
397 title = workspaceName(project.Root)
398 }
399 projects = append(projects, sessioncatalog.ProjectRecord{
400 Scope: "project", WorkspaceRoot: project.Root, Title: title, Color: project.Color,
401 Pinned: containsDesktopString(f.PinnedProjects, project.Root), SortOrder: index,
402 })
403 appendTopics("project", project.Root, project.Topics, project.PinnedTopics, project.ManualTopicOrder)
404 }
405 return catalog.SyncMetadata(ctx, projects, topics)
406 }
407
408 func (a *App) emitProjectTreeChangedV2(revision uint64, roots []string, reason string) {
409 if roots == nil {
410 roots = []string{}
411 }
412 a.emitRuntimeEvent("project-tree:changed-v2", ProjectTreeChangedV2{Revision: a.unifiedProjectRevision(revision), Roots: roots, Reason: reason})
413 // One-release compatibility event. Its wrapper is catalog-only, so legacy
414 // frontends refresh without making current frontends rebuild the whole tree
415 // after they already consumed the targeted v2 revision.
416 a.emitRuntimeEvent("project-tree:changed", map[string]string{"reason": "catalog-v2"})
417 }
418
419 type desktopCatalogReconcileJob struct {
420 target sessioncatalog.DirectoryTarget
421 dirty bool
422 done chan struct{}
423 }
424
425 func (a *App) requestSessionCatalogReconcile(dir string) bool {
426 catalog := a.sessionCatalog.Load()
427 if catalog == nil || a.shuttingDown.Load() || strings.TrimSpace(dir) == "" {
428 return false
429 }
430 clean := filepath.Clean(dir)
431 key := projectRootKey(clean)
432 target := sessioncatalog.DirectoryTarget{Path: clean, Scope: "global"}
433 for _, candidate := range a.sessionCatalogTargets() {
434 if sameDesktopPath(candidate.Path, clean) {
435 target = candidate
436 break
437 }
438 }
439 a.catalogReconcileMu.Lock()
440 if a.sessionCatalog.Load() != catalog || a.shuttingDown.Load() {
441 a.catalogReconcileMu.Unlock()
442 return false
443 }
444 if a.catalogReconcileJobs == nil {
445 a.catalogReconcileJobs = map[string]*desktopCatalogReconcileJob{}
446 }
447 if job := a.catalogReconcileJobs[key]; job != nil {
448 job.target = target
449 job.dirty = true
450 a.catalogReconcileMu.Unlock()
451 return true
452 }
453 done := make(chan struct{})
454 a.catalogReconcileJobs[key] = &desktopCatalogReconcileJob{target: target, done: done}
455 a.catalogReconcileMu.Unlock()
456 go a.runSessionCatalogReconcile(key, done)
457 return true
458 }
459
460 func (a *App) runSessionCatalogReconcile(key string, done chan struct{}) {
461 defer close(done)
462 for {
463 a.catalogReconcileMu.Lock()
464 job := a.catalogReconcileJobs[key]
465 if job == nil {
466 a.catalogReconcileMu.Unlock()
467 return
468 }
469 target := job.target
470 job.dirty = false
471 a.catalogReconcileMu.Unlock()
472 catalog := a.sessionCatalog.Load()
473 if catalog == nil || a.shuttingDown.Load() {
474 a.catalogReconcileMu.Lock()
475 delete(a.catalogReconcileJobs, key)
476 a.catalogReconcileMu.Unlock()
477 return
478 }
479
480 if a.catalogReconcileHook != nil {
481 a.catalogReconcileHook(target)
482 }
483 // Discovery projects metadata without rewriting organization sidecars or
484 // proving recovery ancestry through transcript reads.
485 if settled, accepted := catalog.ScheduleReconcile(target); accepted {
486 select {
487 case <-settled:
488 case <-a.bootContext().Done():
489 }
490 }
491
492 a.catalogReconcileMu.Lock()
493 job = a.catalogReconcileJobs[key]
494 if job == nil {
495 a.catalogReconcileMu.Unlock()
496 return
497 }
498 if job.dirty && !a.shuttingDown.Load() {
499 a.catalogReconcileMu.Unlock()
500 continue
501 }
502 delete(a.catalogReconcileJobs, key)
503 a.catalogReconcileMu.Unlock()
504 if a.catalogReconcileDoneHook != nil {
505 a.catalogReconcileDoneHook(target)
506 }
507 return
508 }
509 }
510
511 func sessionDirectoryForPath(path string) string {
512 path = strings.TrimSpace(path)
513 if path == "" {
514 return ""
515 }
516 clean := filepath.Clean(path)
517 if clean == "." || filepath.Base(clean) == clean {
518 return ""
519 }
520 return filepath.Dir(clean)
521 }
522
523 func (a *App) saveTabSessionMetaSnapshotAndIndex(snap tabSessionMetaSnapshot) error {
524 if err := saveTabSessionMetaSnapshot(snap); err != nil {
525 return err
526 }
527 // Transcript saves index through the observer; enqueue again after the
528 // sidecar commit so scope and title changes are visible without a full scan.
529 a.requestSessionCatalogIndexPath(snap.scope, snap.workspaceRoot, string(snap.path))
530 return nil
531 }
532
533 func discardTransientBlankSessionArtifacts(path string) bool {
534 if strings.TrimSpace(path) == "" {
535 return false
536 }
537 if err := removeDesktopSessionArtifacts(path); err != nil {
538 slog.Warn("desktop: discard transient blank session artifacts failed", "path", path, "err", err)
539 return false
540 }
541 return true
542 }
543
544 func (a *App) requestSessionCatalogPath(scope, workspaceRoot, path string) {
545 if strings.TrimSpace(path) != "" {
546 _ = history.PersistObserver().EnqueueSessionPersist(agent.SessionPersistEvent{Path: path, Rewrite: true})
547 }
548 a.requestSessionCatalogIndexPath(scope, workspaceRoot, path)
549 }
550
551 // requestSessionCatalogIndexPath publishes one committed session/sidecar
552 // change without walking its directory. A saturated exact-path queue falls
553 // back to a scoped reconcile so the disposable projection still converges.
554 func (a *App) requestSessionCatalogIndexPath(scope, workspaceRoot, path string) {
555 catalog := a.sessionCatalog.Load()
556 if catalog == nil || a.shuttingDown.Load() || strings.TrimSpace(path) == "" {
557 return
558 }
559 target := sessioncatalog.DirectoryTarget{
560 Path: sessionDirectoryForPath(path), Scope: scope, WorkspaceRoot: workspaceRoot,
561 }
562 if !catalog.RequestIndexSession(target, path) {
563 a.requestSessionCatalogReconcile(target.Path)
564 }
565 }
566
567 func (a *App) removeSessionCatalogPath(path, reason string) {
568 if strings.TrimSpace(path) == "" {
569 return
570 }
571 _ = history.PersistObserver().EnqueueSessionPersist(agent.SessionPersistEvent{Path: path, Removed: true})
572 catalog := a.sessionCatalog.Load()
573 if catalog == nil {
574 return
575 }
576 ctx, cancel := context.WithTimeout(a.bootContext(), 150*time.Millisecond)
577 defer cancel()
578 if err := catalog.RemoveSession(ctx, path, reason); err != nil && !errors.Is(err, context.Canceled) {
579 slog.Debug("desktop: remove session catalog row", "err", err)
580 }
581 }
582
583 func (a *App) requestSessionCatalogMetadataSync() {
584 if a.shuttingDown.Load() {
585 return
586 }
587 a.catalogLifecycleMu.Lock()
588 requests := a.catalogMetadataRequests
589 a.catalogLifecycleMu.Unlock()
590 // Every source shares the watcher's worker, including user edits. A nil
591 // channel before startup/after shutdown simply has no receiver to wake.
592 select {
593 case requests <- struct{}{}:
594 default:
595 }
596 }
597
598 func (a *App) GetProjectTreeSnapshot() (ProjectTreeSnapshot, error) {
599 // Membership/visibility and its generation come from one verified registry
600 // snapshot. Never silently replace it with legacy membership on read failure.
601 state, versions, err := a.workspaceRegistry().LoadProjectionWithVersions(a.bootContext())
602 if err != nil {
603 return ProjectTreeSnapshot{Projects: []ProjectNode{}}, err
604 }
605 f := loadProjectsFile()
606 deleted := make(map[string]bool, len(f.DeletedTopics))
607 for _, topicID := range f.DeletedTopics {
608 deleted[topicID] = true
609 }
610 projects := []ProjectNode{}
611 if strings.TrimSpace(f.GlobalTitle) != "" || len(f.GlobalTopics) > 0 || len(f.Projects) == 0 {
612 label := strings.TrimSpace(f.GlobalTitle)
613 if label == "" {
614 label = "Global"
615 }
616 projects = append(projects, ProjectNode{
617 Key: "global_folder", Kind: "global_folder", Label: label,
618 Root: globalWorkspaceRoot(), ProjectColor: normalizeProjectColor(f.GlobalColor),
619 Children: a.pinnedTopicShells("global", "", f.GlobalTopics, f.GlobalPinnedTopics, f.GlobalColor, deleted),
620 })
621 }
622 for _, project := range f.Projects {
623 label := strings.TrimSpace(project.Title)
624 if label == "" {
625 label = workspaceName(project.Root)
626 }
627 projects = append(projects, ProjectNode{
628 Key: "project_" + project.Root, Kind: "project", Label: label,
629 Root: project.Root, ProjectColor: project.Color,
630 Pinned: containsDesktopString(f.PinnedProjects, project.Root),
631 Children: a.pinnedTopicShells("project", project.Root, project.Topics, project.PinnedTopics, project.Color, deleted),
632 })
633 }
634 // Remote projects (pinned via the connection wizard) render as project
635 // groups too; the Remote ref swaps the folder icon for a cloud icon.
636 if remoteNodes, err := a.remoteProjectNodes(); err == nil {
637 projects = append(projects, remoteNodes...)
638 }
639 projects = a.mergeCanonicalWorkspaceShellsFromProjection(projects, state, versions)
640 projects = applyPinnedProjectOrder(applyProjectTreeOrder(projects, f.SidebarOrder), f.PinnedProjects)
641 status := a.currentSessionCatalogStatus()
642 return ProjectTreeSnapshot{
643 Revision: status.Revision + state.Generation, WorkspaceGeneration: &state.Generation, Projects: projects, Catalog: status,
644 Indexed: status.Indexed, Total: status.Total,
645 IndexingDone: a.catalogIndexingDone(status),
646 }, nil
647 }
648
649 // pinnedTopicShells keeps pinned conversations available in the metadata-only
650 // project snapshot. Ordinary topic pages remain lazy, but a collapsed folder
651 // must not hide its pinned conversations until the user expands it.
652 func (a *App) pinnedTopicShells(scope, workspaceRoot string, topicIDs, pinnedIDs []string, projectColor string, deleted map[string]bool) []ProjectNode {
653 if len(pinnedIDs) == 0 {
654 return []ProjectNode{}
655 }
656 titles := loadTopicTitles(workspaceRoot)
657 sources := loadTopicTitleSources(workspaceRoot)
658 created := loadTopicCreatedAts(workspaceRoot)
659 available := make(map[string]bool, len(topicIDs)+len(titles))
660 for _, topicID := range orderedTopicIDs(topicIDs, titles) {
661 available[topicID] = true
662 }
663 kind := "topic"
664 if scope != "project" {
665 kind = "global_topic"
666 }
667 out := make([]ProjectNode, 0, len(pinnedIDs))
668 for _, topicID := range uniqueStrings(pinnedIDs) {
669 if !available[topicID] || deleted[topicID] {
670 continue
671 }
672 title := strings.TrimSpace(titles[topicID])
673 if title == "" {
674 title = defaultTopicTitle
675 }
676 out = append(out, ProjectNode{
677 Key: kind + "_" + topicID, Kind: kind,
678 Label: a.localizedTopicTitle(title, sources[topicID]), Root: workspaceRoot,
679 TopicID: topicID, ProjectColor: normalizeProjectColor(projectColor),
680 CreatedAt: topicCreatedAtForTree(created, topicID), Pinned: true,
681 TurnsState: string(sessioncatalog.TurnsUnknown), Health: string(sessioncatalog.HealthOK),
682 Children: []ProjectNode{},
683 })
684 }
685 return out
686 }
687
688 func (a *App) catalogIndexingDone(status SessionCatalogStatus) bool {
689 if status.State != string(sessioncatalog.StateReady) || status.RepairActive > 0 {
690 return false
691 }
692 catalog := a.sessionCatalog.Load()
693 if catalog == nil {
694 return false
695 }
696 ctx, cancel := a.catalogReadContext()
697 defer cancel()
698 targets := a.sessionCatalogTargets()
699 if len(targets) == 0 {
700 return false
701 }
702 sawExisting := false
703 for _, target := range targets {
704 if _, err := os.Stat(target.Path); os.IsNotExist(err) {
705 continue
706 }
707 sawExisting = true
708 if !catalog.DirectoryScanReady(ctx, target.Path) {
709 return false
710 }
711 }
712 return sawExisting
713 }
714
714 lines GO