| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | "net/url" |
| 7 | "reasonix/internal/session" |
| 8 | "strings" |
| 9 | ) |
| 10 | |
| 11 | // Topic-only navigation remains a compatibility entrypoint. It may create an |
| 12 | // empty surface, but must never choose one of several durable sessions. |
| 13 | func (a *App) resolveTopicOpenPath(scope, root, topicID string) (string, error) { |
| 14 | if strings.TrimSpace(topicID) != "" { |
| 15 | target, err := a.resolveSessionTarget(SessionSelector{TopicID: topicID}) |
| 16 | if err == nil && target.SessionPath != "" { |
| 17 | return target.SessionPath, nil |
| 18 | } |
| 19 | var operationError *SessionOperationError |
| 20 | if err != nil && (!errors.As(err, &operationError) || operationError.Code != sessionOperationTargetNotFound) { |
| 21 | return "", err |
| 22 | } |
| 23 | } |
| 24 | path, _ := a.findTopicSessionForTarget(scope, root, topicID) |
| 25 | return path, nil |
| 26 | } |
| 27 | |
| 28 | // Head-specific writes/opening take ownership through the existing migration |
| 29 | // journal. Resolving or listing a source itself remains read-only. |
| 30 | func (a *App) resolveSessionMutationTarget(selector SessionSelector) (SessionTarget, error) { |
| 31 | target, err := a.resolveSessionTarget(selector) |
| 32 | if err != nil || target.Source == nil { |
| 33 | return target, err |
| 34 | } |
| 35 | view, err := a.PrepareSession(SessionSelector{Source: target.Source, TopicID: target.TopicID}) |
| 36 | if err != nil { |
| 37 | return SessionTarget{}, err |
| 38 | } |
| 39 | c := &a.historicalImports |
| 40 | c.mu.Lock() |
| 41 | call := c.operations[view.OperationID] |
| 42 | c.mu.Unlock() |
| 43 | if call == nil { |
| 44 | return SessionTarget{}, newSessionOperationError("target_changed", "The source preparation task is unavailable.") |
| 45 | } |
| 46 | result, err := waitHistoricalImport(call) |
| 47 | if err != nil { |
| 48 | return SessionTarget{}, err |
| 49 | } |
| 50 | return a.resolveCanonicalSessionTarget(result.Session, target.TopicID) |
| 51 | } |
| 52 | |
| 53 | func parseSessionSourceRoute(route string) (*SessionSourceRef, error) { |
| 54 | const prefix = "session-source:" |
| 55 | if !strings.HasPrefix(route, prefix) { |
| 56 | return nil, nil |
| 57 | } |
| 58 | text, err := url.PathUnescape(strings.TrimPrefix(route, prefix)) |
| 59 | if err != nil { |
| 60 | return nil, err |
| 61 | } |
| 62 | var source SessionSourceRef |
| 63 | if err := json.Unmarshal([]byte(text), &source); err != nil { |
| 64 | return nil, err |
| 65 | } |
| 66 | return &source, nil |
| 67 | } |
| 68 | |
| 69 | // Session title projection is addressed by the same durable ID as its write. |
| 70 | // A topic may gain another branch while an asynchronous title is generated. |
| 71 | func (a *App) updateCanonicalSessionTitle(ref session.SessionRef, title, source string) { |
| 72 | a.mu.Lock() |
| 73 | defer a.mu.Unlock() |
| 74 | for _, tab := range a.runtimeTabsLocked() { |
| 75 | if tab != nil && tab.SessionID == ref.SessionID { |
| 76 | tab.TopicTitle, tab.topicTitleSource = title, source |
| 77 | } |
| 78 | } |
| 79 | a.saveTabsLocked() |
| 80 | } |
| 81 |