| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "reasonix/internal/agent" |
| 5 | "reasonix/internal/sessioncatalog" |
| 6 | "sort" |
| 7 | "strings" |
| 8 | ) |
| 9 | |
| 10 | // projectNodesFromCatalogTopic expands every ordinary, independently |
| 11 | // addressable legacy session into its own sidebar row. TopicID remains shared |
| 12 | // history metadata; SessionPath is the operation and navigation target. |
| 13 | // Recovery-only topics intentionally keep their single summary row because |
| 14 | // their physical replicas are managed by the saved-versions surface. |
| 15 | func (a *App) projectNodesFromCatalogTopic(topic sessioncatalog.TopicRecord, topicOverlays, sessionOverlays map[string]catalogRuntimeOverlay, preferred map[string]struct{}) []ProjectNode { |
| 16 | base, visibleTopic := a.projectNodeFromCatalogTopic(topic, topicOverlays, sessionOverlays, preferred) |
| 17 | if !visibleTopic { |
| 18 | return []ProjectNode{} |
| 19 | } |
| 20 | if topic.RecoveryState == "recovery_only" { |
| 21 | return []ProjectNode{base} |
| 22 | } |
| 23 | localPreferred := preferred |
| 24 | if localPreferred == nil { |
| 25 | localPreferred = sessioncatalog.PreferredOrdinarySessionPaths(topic.Sessions) |
| 26 | } |
| 27 | kind := "topic" |
| 28 | if topic.Scope == "global" { |
| 29 | kind = "global_topic" |
| 30 | } |
| 31 | nodes := make([]ProjectNode, 0, len(topic.Sessions)) |
| 32 | projects := loadProjectsFile() |
| 33 | coveredRuntime := coveredLegacyRuntime(topic.Sessions, sessionOverlays, localPreferred) |
| 34 | for _, record := range topic.Sessions { |
| 35 | overlay := sessionOverlays[sessionRuntimeKey(record.Path)] |
| 36 | // Covered recovery copies remain represented by their logical parent in |
| 37 | // the ordinary tree, even while an older restored tab still has the copy |
| 38 | // open. The dedicated recovery surface owns the physical copy. |
| 39 | if hiddenLegacyRecovery(record, localPreferred) { |
| 40 | continue |
| 41 | } |
| 42 | if !sessioncatalog.OrdinaryTreeSession(record, overlay.open, overlay.running, localPreferred) { |
| 43 | continue |
| 44 | } |
| 45 | if inherited := coveredRuntime[agent.BranchID(record.Path)]; inherited.open || inherited.running { |
| 46 | overlay.open = overlay.open || inherited.open |
| 47 | overlay.running = overlay.running || inherited.running |
| 48 | if overlay.status == "" { |
| 49 | overlay.status = inherited.status |
| 50 | } |
| 51 | } |
| 52 | label := strings.TrimSpace(record.CustomTitle) |
| 53 | if label == "" { |
| 54 | label = a.localizedTopicTitle(topic.Title, topic.TitleSource) |
| 55 | } |
| 56 | turnsState := string(record.TurnsState) |
| 57 | if turnsState == "" { |
| 58 | turnsState = base.TurnsState |
| 59 | } |
| 60 | health := string(record.Health) |
| 61 | if health == "" { |
| 62 | health = base.Health |
| 63 | } |
| 64 | node := ProjectNode{ |
| 65 | Key: projectSessionNodeKey(topic.Scope, record.Path), Kind: kind, Label: label, |
| 66 | Root: topic.WorkspaceRoot, TopicID: topic.TopicID, SessionPath: record.Path, |
| 67 | Preview: strings.TrimSpace(record.Preview), Turns: record.Turns, |
| 68 | TurnsState: turnsState, Health: health, |
| 69 | CreatedAt: record.CreatedAt, LastActivityAt: record.LastActivityAt, |
| 70 | Pinned: topic.Pinned, SortOrder: topic.SortOrder, |
| 71 | Recovered: record.Recovered, RecoveryReason: record.RecoveryReason, |
| 72 | RecoveryDigest: record.RecoveryDigest, RecoveryParentID: record.ParentID, |
| 73 | Open: overlay.open, Running: overlay.running, Status: overlay.status, |
| 74 | Children: []ProjectNode{}, |
| 75 | } |
| 76 | if rank := sessionOrderRank(projects, topic.Scope, topic.WorkspaceRoot, projectNodeSessionKey(node)); rank >= 0 { |
| 77 | node.SortOrder = rank |
| 78 | } else if (topic.Scope == "global" && projects.GlobalManualSessionOrder) || (topic.Scope == "project" && projectManualSessionOrder(projects, topic.WorkspaceRoot)) { |
| 79 | node.SortOrder = -1 |
| 80 | } |
| 81 | if node.Preview == "" && len(topic.Sessions) == 1 { |
| 82 | node.Preview = base.Preview |
| 83 | } |
| 84 | nodes = append(nodes, node) |
| 85 | } |
| 86 | if len(nodes) == 0 { |
| 87 | // Pinned and runtime-only topic shells remain reachable while their |
| 88 | // catalog session is incomplete. |
| 89 | return []ProjectNode{base} |
| 90 | } |
| 91 | manual := manualSessionOrderFor(topic.Scope, topic.WorkspaceRoot) |
| 92 | sort.SliceStable(nodes, func(i, j int) bool { |
| 93 | if manual && nodes[i].SortOrder != nodes[j].SortOrder { |
| 94 | left, right := nodes[i].SortOrder, nodes[j].SortOrder |
| 95 | if left < 0 { |
| 96 | left = int(^uint(0) >> 1) |
| 97 | } |
| 98 | if right < 0 { |
| 99 | right = int(^uint(0) >> 1) |
| 100 | } |
| 101 | return left < right |
| 102 | } |
| 103 | if nodes[i].LastActivityAt != nodes[j].LastActivityAt { |
| 104 | return nodes[i].LastActivityAt > nodes[j].LastActivityAt |
| 105 | } |
| 106 | if nodes[i].CreatedAt != nodes[j].CreatedAt { |
| 107 | return nodes[i].CreatedAt > nodes[j].CreatedAt |
| 108 | } |
| 109 | return nodes[i].Key < nodes[j].Key |
| 110 | }) |
| 111 | return nodes |
| 112 | } |
| 113 | |
| 114 | func projectManualSessionOrder(projects desktopProjectFile, workspaceRoot string) bool { |
| 115 | index := projectIndexByRoot(projects.Projects, workspaceRoot) |
| 116 | return index >= 0 && projects.Projects[index].ManualSessionOrder |
| 117 | } |
| 118 | |
| 119 | func hiddenLegacyRecovery(record sessioncatalog.SessionRecord, localPreferred map[string]struct{}) bool { |
| 120 | if record.RecoveryCopy || record.RecoveryRole == sessioncatalog.RecoveryRoleCoveredCopy { |
| 121 | return true |
| 122 | } |
| 123 | if !record.Recovered || localPreferred == nil { |
| 124 | return false |
| 125 | } |
| 126 | _, visible := localPreferred[strings.TrimSpace(record.Path)] |
| 127 | return !visible |
| 128 | } |
| 129 | |
| 130 | func coveredLegacyRuntime(records []sessioncatalog.SessionRecord, sessionOverlays map[string]catalogRuntimeOverlay, localPreferred map[string]struct{}) map[string]catalogRuntimeOverlay { |
| 131 | coveredRuntime := map[string]catalogRuntimeOverlay{} |
| 132 | for _, record := range records { |
| 133 | if !hiddenLegacyRecovery(record, localPreferred) { |
| 134 | continue |
| 135 | } |
| 136 | overlay := sessionOverlays[sessionRuntimeKey(record.Path)] |
| 137 | if !overlay.open && !overlay.running { |
| 138 | continue |
| 139 | } |
| 140 | parentID := strings.TrimSpace(record.ParentID) |
| 141 | if parentID == "" { |
| 142 | parentID = strings.TrimSpace(record.RecoveryGroupID) |
| 143 | } |
| 144 | if parentID == "" { |
| 145 | continue |
| 146 | } |
| 147 | current := coveredRuntime[parentID] |
| 148 | current.open = current.open || overlay.open |
| 149 | current.running = current.running || overlay.running |
| 150 | if current.status == "" { |
| 151 | current.status = overlay.status |
| 152 | } |
| 153 | coveredRuntime[parentID] = current |
| 154 | } |
| 155 | return coveredRuntime |
| 156 | } |
| 157 |