| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strings" |
| 6 | |
| 7 | "reasonix/internal/sessioncatalog" |
| 8 | ) |
| 9 | |
| 10 | // preferLiveSessionPath chooses which session a topic row should open. |
| 11 | // A live runtime wins over ordinary catalog rows (RepresentativePath, |
| 12 | // canonical leaf, and the covering parent that continue would follow). |
| 13 | // An explicit non-ordinary path (History inspect) is left alone. |
| 14 | func preferLiveSessionPath(requested, live string, ordinary ...string) string { |
| 15 | liveKey := sessionRuntimeKey(live) |
| 16 | if liveKey == "" { |
| 17 | return requested |
| 18 | } |
| 19 | reqKey := sessionRuntimeKey(requested) |
| 20 | if reqKey == "" || reqKey == liveKey { |
| 21 | if reqKey == "" { |
| 22 | return live |
| 23 | } |
| 24 | return requested |
| 25 | } |
| 26 | if len(ordinary) == 0 { |
| 27 | return live |
| 28 | } |
| 29 | for _, path := range ordinary { |
| 30 | if sessionRuntimeKey(path) == reqKey { |
| 31 | return live |
| 32 | } |
| 33 | } |
| 34 | return requested |
| 35 | } |
| 36 | |
| 37 | func (a *App) liveSessionPathForTopic(scope, workspaceRoot, topicID string) string { |
| 38 | if a == nil { |
| 39 | return "" |
| 40 | } |
| 41 | a.mu.RLock() |
| 42 | defer a.mu.RUnlock() |
| 43 | if tab := a.liveRuntimeForTopicLocked(scope, workspaceRoot, topicID); tab != nil { |
| 44 | return tab.currentSessionPath() |
| 45 | } |
| 46 | return "" |
| 47 | } |
| 48 | |
| 49 | func (a *App) liveRuntimeForTopicLocked(scope, workspaceRoot, topicID string) *WorkspaceTab { |
| 50 | var best *WorkspaceTab |
| 51 | visit := func(tab *WorkspaceTab) { |
| 52 | if tab == nil || !tabMatchesTopicTarget(tab, scope, workspaceRoot, topicID) { |
| 53 | return |
| 54 | } |
| 55 | if tab.currentSessionPath() == "" { |
| 56 | return |
| 57 | } |
| 58 | if best == nil { |
| 59 | best = tab |
| 60 | return |
| 61 | } |
| 62 | if best.Ctrl == nil && tab.Ctrl != nil { |
| 63 | best = tab |
| 64 | return |
| 65 | } |
| 66 | if normalizeTopicStatus(best.ActivityStatus) == "" && normalizeTopicStatus(tab.ActivityStatus) != "" { |
| 67 | best = tab |
| 68 | } |
| 69 | } |
| 70 | for _, tab := range a.tabs { |
| 71 | visit(tab) |
| 72 | } |
| 73 | for _, tab := range a.detachedSessions { |
| 74 | visit(tab) |
| 75 | } |
| 76 | return best |
| 77 | } |
| 78 | |
| 79 | func (a *App) resolveOpenSessionPath(scope, workspaceRoot, topicID, requested string) string { |
| 80 | if strings.TrimSpace(requested) != "" { |
| 81 | return requested |
| 82 | } |
| 83 | live := a.liveSessionPathForTopic(scope, workspaceRoot, topicID) |
| 84 | return preferLiveSessionPath(requested, live, a.ordinaryOpenSessionPaths(scope, workspaceRoot, topicID, requested)...) |
| 85 | } |
| 86 | |
| 87 | func (a *App) catalogTopicRecord(scope, workspaceRoot, topicID string) (sessioncatalog.TopicRecord, bool) { |
| 88 | if a == nil || strings.TrimSpace(topicID) == "" { |
| 89 | return sessioncatalog.TopicRecord{}, false |
| 90 | } |
| 91 | catalog := a.sessionCatalog.Load() |
| 92 | if catalog == nil { |
| 93 | return sessioncatalog.TopicRecord{}, false |
| 94 | } |
| 95 | topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{ |
| 96 | Scope: scope, WorkspaceRoot: workspaceRoot, TopicID: topicID, |
| 97 | }) |
| 98 | if err != nil || !ok { |
| 99 | return sessioncatalog.TopicRecord{}, false |
| 100 | } |
| 101 | return topic, true |
| 102 | } |
| 103 | |
| 104 | // ordinaryOpenSessionPaths are the catalog identities of the sidebar row: |
| 105 | // RepresentativePath, the canonical covering leaf, and the parent that |
| 106 | // continue would follow. History inspect paths are not in this set. |
| 107 | func (a *App) ordinaryOpenSessionPaths(scope, workspaceRoot, topicID, requested string) []string { |
| 108 | var ordinary []string |
| 109 | if topic, ok := a.catalogTopicRecord(scope, workspaceRoot, topicID); ok { |
| 110 | if rep := strings.TrimSpace(topic.RepresentativePath); rep != "" { |
| 111 | ordinary = append(ordinary, rep) |
| 112 | } |
| 113 | if canonical := sessioncatalog.CanonicalSessionPathForTopic(topic.Sessions, ""); canonical != "" { |
| 114 | ordinary = append(ordinary, canonical) |
| 115 | } |
| 116 | } else if path := a.catalogSessionPathForTopic(scope, workspaceRoot, topicID); path != "" { |
| 117 | ordinary = append(ordinary, path) |
| 118 | } |
| 119 | if continued := a.continuePathForOpen(requested); continued != "" { |
| 120 | ordinary = append(ordinary, requested, continued) |
| 121 | } |
| 122 | return ordinary |
| 123 | } |
| 124 | |
| 125 | func (a *App) openTopicTabPreferLiveActivation(scope, workspaceRoot, topicID, sessionPath string, activate bool) (TabMeta, error) { |
| 126 | sessionPath = a.resolveOpenSessionPath(scope, workspaceRoot, topicID, sessionPath) |
| 127 | a.mu.Lock() |
| 128 | if promoted := a.promoteDetachedRuntimeLocked(sessionPath, activate); promoted != nil { |
| 129 | meta := a.tabMeta(promoted, promoted.ID == a.activeTabID) |
| 130 | a.saveTabsLocked() |
| 131 | a.mu.Unlock() |
| 132 | // A TurnDone missed while the runtime was detached leaves a permanent |
| 133 | // spinner; reconcile against the controller's real turn state on open. |
| 134 | a.reconcileTabActivityStatus(promoted) |
| 135 | a.emitProjectTreeRuntimeChangedWithLegacy() |
| 136 | return enrichTabMeta(meta), nil |
| 137 | } |
| 138 | a.mu.Unlock() |
| 139 | return a.openTopicTabWithActivation(scope, workspaceRoot, topicID, sessionPath, activate) |
| 140 | } |
| 141 | |
| 142 | func (a *App) promoteDetachedRuntimeLocked(sessionPath string, activate bool) *WorkspaceTab { |
| 143 | key := sessionRuntimeKey(sessionPath) |
| 144 | if key == "" { |
| 145 | return nil |
| 146 | } |
| 147 | tab := a.detachedSessions[key] |
| 148 | if tab == nil || tab.Ctrl == nil { |
| 149 | return nil |
| 150 | } |
| 151 | delete(a.detachedSessions, key) |
| 152 | if a.tabs[tab.ID] == nil { |
| 153 | a.tabs[tab.ID] = tab |
| 154 | a.tabOrder = append(a.tabOrder, tab.ID) |
| 155 | } |
| 156 | if activate { |
| 157 | a.activeTabID = tab.ID |
| 158 | } |
| 159 | return tab |
| 160 | } |
| 161 | |
| 162 | // renameCatalogOnlyTopic persists a rename for a topic known only to the |
| 163 | // session catalog (metadata shell, no title map entry, no open tab) through |
| 164 | // the same title metadata path as an indexed topic, instead of failing the |
| 165 | // rename (#9090). It returns the usual "not found" error when the catalog |
| 166 | // does not list the topic either. |
| 167 | func (a *App) renameCatalogOnlyTopic(topicID, title string) error { |
| 168 | scope, workspaceRoot, ok := a.findCatalogTopicLocation(topicID) |
| 169 | if !ok { |
| 170 | return fmt.Errorf("topic %q not found", topicID) |
| 171 | } |
| 172 | if scope == "global" { |
| 173 | workspaceRoot = "" |
| 174 | } |
| 175 | if err := setTopicTitle(workspaceRoot, topicID, title); err != nil { |
| 176 | return err |
| 177 | } |
| 178 | changedDirs := a.updateTopicSessionTitles(topicID, title) |
| 179 | if len(changedDirs) > 0 { |
| 180 | a.emitProjectTreeChangedForSessionDirs(changedDirs...) |
| 181 | } else { |
| 182 | a.emitProjectTreeMetadataChanged() |
| 183 | } |
| 184 | return nil |
| 185 | } |
| 186 | |
| 187 | // findCatalogTopicLocation resolves a topic that exists only in the session |
| 188 | // catalog to its scope and workspace root. |
| 189 | func (a *App) findCatalogTopicLocation(topicID string) (string, string, bool) { |
| 190 | topicID = strings.TrimSpace(topicID) |
| 191 | if topicID == "" { |
| 192 | return "", "", false |
| 193 | } |
| 194 | catalog := a.sessionCatalog.Load() |
| 195 | if catalog == nil { |
| 196 | return "", "", false |
| 197 | } |
| 198 | ctx, cancel := a.catalogReadContext() |
| 199 | defer cancel() |
| 200 | if _, ok, err := catalog.GetTopic(ctx, sessioncatalog.TopicKey{Scope: "global", TopicID: topicID}); err == nil && ok { |
| 201 | return "global", "", true |
| 202 | } |
| 203 | for _, p := range loadProjectsFile().Projects { |
| 204 | root := normalizeProjectRoot(p.Root) |
| 205 | if root == "" { |
| 206 | continue |
| 207 | } |
| 208 | if _, ok, err := catalog.GetTopic(ctx, sessioncatalog.TopicKey{Scope: "project", WorkspaceRoot: root, TopicID: topicID}); err == nil && ok { |
| 209 | return "project", root, true |
| 210 | } |
| 211 | } |
| 212 | return "", "", false |
| 213 | } |
| 214 |