返回 DeepSeek-Reasonix
tab_selection.go
根目录 / desktop / tab_selection.go
1 package main
2
3 import (
4 "fmt"
5
6 "reasonix/internal/control"
7 )
8
9 // activeOrSingleLocalTab resolves the workspace tab a local command should
10 // target: the active tab when there is one, otherwise the dormant tab
11 // a remote-only layout restores. Both resolve through tabAndCtrlByID so a tab
12 // whose startup was blocked by a session lease gets the same recovery attempt
13 // every other tab-scoped command performs before reporting a missing runtime.
14 func (a *App) activeOrSingleLocalTab() (*WorkspaceTab, control.SessionAPI) {
15 if tab, ctrl := a.tabAndCtrlByID(""); tab != nil {
16 return tab, ctrl
17 }
18 return a.firstResolvableLocalTab()
19 }
20
21 // firstResolvableLocalTab returns the first visible tab that resolves through
22 // tabAndCtrlByID, so a startup blocked by a session lease is retried here too.
23 // A remote-only layout restores exactly one such tab for local work.
24 func (a *App) firstResolvableLocalTab() (*WorkspaceTab, control.SessionAPI) {
25 a.mu.RLock()
26 ordered, _ := a.orderedTabIDsSnapshotLocked()
27 a.mu.RUnlock()
28 for _, id := range ordered {
29 if tab, ctrl := a.tabAndCtrlByID(id); tab != nil {
30 return tab, ctrl
31 }
32 }
33 return nil, nil
34 }
35
36 // singleLocalTab resolves the workspace tab local commands should target when no
37 // tab is active. A remote-only single-surface layout restores one dormant tab
38 // for exactly this purpose, so callers resolve instead of reporting that the
39 // workspace is not ready.
40 func (a *App) singleLocalTab() *WorkspaceTab {
41 tab, _ := a.activeOrSingleLocalTab()
42 return tab
43 }
44
45 // SetActiveTab switches the frontend's active tab. Restored remote shells
46 // reconnect only when activated.
47 func (a *App) SetActiveTab(tabID string) error {
48 // Even selecting the already-visible tab cancels a slower source adoption.
49 a.desktopSessions.navigationSeq.Add(1)
50 a.tabSelectionMu.Lock()
51 defer a.tabSelectionMu.Unlock()
52
53 a.remoteTabMu.Lock()
54 if _, isRemote := a.remoteTabs[tabID]; isRemote {
55 switchingFromLocal := a.remoteTabLayout.activeID == ""
56 a.remoteTabMu.Unlock()
57 if switchingFromLocal {
58 if err := a.snapshotActiveLocalBeforeRemote(); err != nil {
59 return err
60 }
61 }
62
63 a.remoteTabMu.Lock()
64 tab, isRemote := a.remoteTabs[tabID]
65 if !isRemote {
66 a.remoteTabMu.Unlock()
67 return fmt.Errorf("tab %q not found", tabID)
68 }
69 a.remoteTabLayout.activeID = tabID
70 revive := tab.state == "disconnected"
71 terminalState, terminalErr := "", ""
72 if revive {
73 tab.state = "connecting"
74 } else if tab.state == "error" || tab.state == "serve_down" {
75 terminalState, terminalErr = tab.state, tab.err
76 }
77 hostID, workspace := tab.ref.HostID, tab.ref.Workspace
78 a.remoteTabMu.Unlock()
79 if revive {
80 a.emitRemoteTabState(tabID, "connecting", "")
81 a.goRemoteTabSafe("remoteTabServe", func() { a.bootstrapRemoteTab(tabID, hostID, workspace) })
82 } else if terminalState != "" {
83 // A restored shell can fail before its React surface subscribes. Re-publish
84 // the authoritative terminal state on activation so the recovery UI does
85 // not remain on an inferred connecting placeholder.
86 a.emitRemoteTabState(tabID, terminalState, terminalErr)
87 }
88 a.saveTabsFromRemote()
89 return nil
90 }
91 a.remoteTabMu.Unlock()
92 a.mu.RLock()
93 _, ok := a.tabs[tabID]
94 alreadyActive := a.activeTabID == tabID
95 a.mu.RUnlock()
96 if !ok {
97 return fmt.Errorf("tab %q not found", tabID)
98 }
99 if alreadyActive {
100 a.remoteTabMu.Lock()
101 a.remoteTabLayout.activeID = ""
102 a.remoteTabMu.Unlock()
103 a.saveTabsFromRemote()
104 return nil
105 }
106 a.mu.RLock()
107 active := a.tabs[a.activeTabID]
108 a.mu.RUnlock()
109 if err := a.snapshotTabForAction(active, "switching tabs"); err != nil {
110 return err
111 }
112
113 a.mu.Lock()
114 if _, ok := a.tabs[tabID]; !ok {
115 a.mu.Unlock()
116 return fmt.Errorf("tab %q not found", tabID)
117 }
118 if a.activeTabID == tabID {
119 a.mu.Unlock()
120 a.remoteTabMu.Lock()
121 a.remoteTabLayout.activeID = ""
122 a.remoteTabMu.Unlock()
123 a.saveTabsFromRemote()
124 return nil
125 }
126 a.activeTabID = tabID
127 next := a.tabs[tabID]
128 // A tab restored dormant (remote-only layout) has no runtime yet: activating
129 // it is the first demand for one.
130 dormant := next != nil && next.Ctrl == nil
131 // A direct click supersedes pending publication without cancelling its
132 // build: the tab stays open, and selecting that same tab keeps it alive.
133 supersededReq, supersededTab := a.supersedePendingTopicActivationLocked(tabID, false)
134 dir, entries, activeID, version := a.saveTabsCollectLocked()
135 a.mu.Unlock()
136 a.remoteTabMu.Lock()
137 a.remoteTabLayout.activeID = ""
138 a.remoteTabMu.Unlock()
139
140 // I/O outside the lock — disk writes can block for hundreds of ms on
141 // Windows when antivirus or the search indexer briefly locks the file.
142 a.saveTabsWrite(dir, entries, activeID, version)
143 if dormant {
144 a.startTabControllerBuild(next)
145 }
146 if active != nil {
147 active.clearRuntimeDisplayCurrency()
148 }
149 if next != nil {
150 next.clearRuntimeDisplayCurrency()
151 }
152 if supersededReq != "" {
153 a.emitTopicActivation(TopicActivationEvent{RequestID: supersededReq, TabID: supersededTab, Phase: topicActivationPhaseCancelled})
154 }
155 a.kickDeferredRebuildRetry()
156 return nil
157 }
158
158 lines GO