-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Expand file tree
/
Copy pathDockSplitStore+SurfaceTransfer.swift
More file actions
320 lines (305 loc) · 15.9 KB
/
Copy pathDockSplitStore+SurfaceTransfer.swift
File metadata and controls
320 lines (305 loc) · 15.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
import AppKit
import Bonsplit
import CMUXAgentLaunch
import CmuxTerminal
import CmuxTerminalCore
import Darwin
/// Cross-container surface transfer for the Dock.
///
/// Mirrors `Workspace.detachSurface`/`attachDetachedSurface` so a *live* panel
/// (a running terminal or browser, not a copy) can move between the main split
/// area and a Dock, or between Docks, reusing the same `DetachedSurfaceTransfer`
/// currency the workspace-to-workspace move already uses. The Dock keeps its
/// own panel registry (`panels`/`surfaceIdToPanelId`), so these methods manage
/// that registry directly rather than going through the workspace pane tree.
extension DockSplitStore {
static func dockAgentPIDProbeIndicatesExited(result: Int32, errnoCode: Int32) -> Bool {
result != 0 && errnoCode == ESRCH
}
/// Computes the resume-cwd rescue value to carry out of the Dock. A nil
/// preserved value means cwd tracking was intentionally suppressed.
static func dockRestoredResumeSessionWorkingDirectory(
preservedSessionDirectory: String?,
detachedDirectory: String?,
detachedDirectoryWasReadFromLiveForegroundProcess: Bool,
agentProvenExited: Bool
) -> String? {
guard !agentProvenExited else { return nil }
guard preservedSessionDirectory != nil else { return nil }
return detachedDirectoryWasReadFromLiveForegroundProcess
? detachedDirectory
: preservedSessionDirectory
}
static func dockResumeBinding(
preservedBinding: SurfaceResumeBindingSnapshot?,
preservedSessionDirectory: String?,
restoredResumeSessionWorkingDirectory: String?,
detachedDirectoryWasReadFromLiveForegroundProcess: Bool,
agentProvenExited: Bool
) -> SurfaceResumeBindingSnapshot? {
guard !agentProvenExited, let preservedBinding else { return nil }
guard detachedDirectoryWasReadFromLiveForegroundProcess,
let preservedSessionDirectory,
let restoredResumeSessionWorkingDirectory else {
return preservedBinding
}
let resolvedWorkingDirectory = AgentResumeWorkingDirectory().resolve(
kind: preservedBinding.kind ?? "",
runtimeCwd: restoredResumeSessionWorkingDirectory,
launchWorkingDirectory: preservedSessionDirectory
)
guard resolvedWorkingDirectory != preservedBinding.cwd else { return preservedBinding }
return preservedBinding.retargetingWorkingDirectory(resolvedWorkingDirectory)
}
private static func dockAgentPIDHasExited(_ pid: pid_t) -> Bool {
errno = 0
let result = Darwin.kill(pid, 0)
return dockAgentPIDProbeIndicatesExited(result: result, errnoCode: errno)
}
/// Detaches a live panel from this Dock *without closing it*, packaging it
/// into a `Workspace.DetachedSurfaceTransfer` for re-attachment elsewhere.
///
/// Ownership is dropped from `panels`/`surfaceIdToPanelId` and the title
/// subscription cancelled *before* the Bonsplit tab is closed, so the
/// `didCloseTab` → `reconcilePanels()` path cannot tear the live panel down.
func detachSurface(panelId: UUID) -> Workspace.DetachedSurfaceTransfer? {
guard let tabId = surfaceId(forPanelId: panelId), let panel = panels[panelId] else { return nil }
let preservedTransfer = detachedSurfaceTransfersByPanelId.removeValue(forKey: panelId)
let kind = (panel.panelType == .browser) ? "browser" : "terminal"
let icon = panel.displayIcon
let browser = panel as? BrowserPanel
let iconImageData = browser?.faviconPNGData
let isLoading = browser?.isLoading ?? false
// The Dock has no cwd-report routing, so a preserved transfer's
// directory is frozen at Dock-entry time and goes stale if the
// terminal cds while docked. Prefer the live foreground process's
// actual cwd at detach time. Local panes only: a remote pane's
// foreground process is the local relay, not the remote shell.
let liveTerminalDirectory: String?
if preservedTransfer?.isRemoteTerminal != true,
let terminal = panel as? TerminalPanel,
let pid = terminal.surface.foregroundProcessID() {
liveTerminalDirectory = Workspace.processCurrentWorkingDirectory(pid: Int32(clamping: pid))
} else {
liveTerminalDirectory = nil
}
let detachedDirectory: String?
var liveTerminalDirectoryIsDirectory: ObjCBool = false
if let liveTerminalDirectory,
FileManager.default.fileExists(atPath: liveTerminalDirectory, isDirectory: &liveTerminalDirectoryIsDirectory),
liveTerminalDirectoryIsDirectory.boolValue {
detachedDirectory = liveTerminalDirectory
} else {
detachedDirectory = preservedTransfer?.directory
}
let detachedDirectoryWasReadFromLiveForegroundProcess =
liveTerminalDirectory != nil && detachedDirectory == liveTerminalDirectory
// Agent resume metadata can likewise go stale while docked (the Dock
// receives no shell-activity or agent lifecycle updates), so re-emit
// it only while the agent is not proven dead: recorded agent pids
// exist and none is still running. Where the transfer recorded a
// process start-time identity, compare it so a reused pid does not
// masquerade as the exited agent (same contract as
// `isRecordedAgentPIDLive`); without one, fall back to the ESRCH
// probe. The workspace lifecycle clears the same metadata when an
// agent exits at a prompt, so this mirrors it. An empty pid set stays
// preserved — a restored-but-unscanned agent has no pids yet, and
// dropping it would reintroduce the Dock round-trip metadata loss
// #7155 fixes.
let cachedRuntime = preservedTransfer?.agentRuntime
let cachedAgentPIDs = (cachedRuntime?.agentPIDs ?? [:]).filter { $0.value > 0 }
let agentProvenExited = !cachedAgentPIDs.isEmpty && cachedAgentPIDs.allSatisfy { key, pid in
if let recordedIdentity = cachedRuntime?.agentPIDProcessIdentities[key] {
return Workspace.agentPIDProcessIdentity(pid: pid) != recordedIdentity
}
return Self.dockAgentPIDHasExited(pid)
}
let restoredResumeSessionWorkingDirectory = Self.dockRestoredResumeSessionWorkingDirectory(
preservedSessionDirectory: preservedTransfer?.restoredResumeSessionWorkingDirectory,
detachedDirectory: detachedDirectory,
detachedDirectoryWasReadFromLiveForegroundProcess: detachedDirectoryWasReadFromLiveForegroundProcess,
agentProvenExited: agentProvenExited
)
let resumeBinding = Self.dockResumeBinding(
preservedBinding: preservedTransfer?.resumeBinding,
preservedSessionDirectory: preservedTransfer?.restoredResumeSessionWorkingDirectory,
restoredResumeSessionWorkingDirectory: restoredResumeSessionWorkingDirectory,
detachedDirectoryWasReadFromLiveForegroundProcess: detachedDirectoryWasReadFromLiveForegroundProcess,
agentProvenExited: agentProvenExited
)
let trimmedCustomTitle = preservedTransfer?.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines)
let transferTitle = trimmedCustomTitle?.isEmpty == false
? preservedTransfer?.customTitle
: panel.displayTitle
// Drop our ownership first: once the tab close fires `reconcilePanels`,
// a still-tracked panel would be `panel.close()`d (killing the process).
panelCancellables[panelId]?.cancel()
panelCancellables.removeValue(forKey: panelId)
surfaceIdToPanelId.removeValue(forKey: tabId)
panels.removeValue(forKey: panelId)
forceCloseDockTabIds.insert(tabId)
defer { forceCloseDockTabIds.remove(tabId) }
guard bonsplitController.closeTab(tabId) else {
// Close rejected: re-take ownership so the Dock stays consistent.
panels[panelId] = panel
surfaceIdToPanelId[tabId] = panelId
if let preservedTransfer {
detachedSurfaceTransfersByPanelId[panelId] = preservedTransfer
}
installSubscription(for: panel, tracksTerminalTitle: true)
return nil
}
return Workspace.DetachedSurfaceTransfer(
sourceWorkspaceId: workspaceId,
panelId: panelId,
panel: panel,
title: transferTitle ?? panel.displayTitle,
icon: icon,
iconImageData: iconImageData,
kind: kind,
isLoading: isLoading,
isPinned: false,
directory: detachedDirectory,
directoryIsTrustedRemoteReport: detachedDirectory != nil &&
detachedDirectory == preservedTransfer?.directory &&
preservedTransfer?.directoryIsTrustedRemoteReport == true,
directoryDisplayLabel: detachedDirectory == preservedTransfer?.directory
? preservedTransfer?.directoryDisplayLabel
: nil,
ttyName: preservedTransfer?.ttyName,
cachedTitle: panel.displayTitle,
customTitle: preservedTransfer?.customTitle,
customTitleSource: preservedTransfer?.customTitleSource,
manuallyUnread: preservedTransfer?.manuallyUnread ?? false,
restoredUnreadIndicator: preservedTransfer?.restoredUnreadIndicator,
restorableAgent: agentProvenExited ? nil : preservedTransfer?.restorableAgent,
restorableAgentResumeState: agentProvenExited ? nil : preservedTransfer?.restorableAgentResumeState,
restoredResumeSessionWorkingDirectory: restoredResumeSessionWorkingDirectory,
resumeBinding: resumeBinding,
agentRuntime: agentProvenExited ? nil : preservedTransfer?.agentRuntime,
isRemoteTerminal: preservedTransfer?.isRemoteTerminal ?? false,
remoteRelayPort: preservedTransfer?.remoteRelayPort,
remotePTYSessionID: preservedTransfer?.remotePTYSessionID,
remoteCleanupConfiguration: preservedTransfer?.remoteCleanupConfiguration
)
}
/// Attaches a detached live panel into this Dock at `paneId`. Re-targets the
/// panel to this Dock's workspace id and, for terminals, flips the surface
/// focus placement to `.rightSidebarDock` so portal layering and focus
/// routing treat it as a Dock surface (without recreating the surface).
@discardableResult
func attachDetachedSurface(
_ detached: Workspace.DetachedSurfaceTransfer,
inPane paneId: PaneID,
atIndex index: Int? = nil,
focus: Bool = true
) -> UUID? {
guard bonsplitController.allPaneIds.contains(paneId), panels[detached.panelId] == nil else { return nil }
let panel = detached.panel
if let terminal = panel as? TerminalPanel {
terminal.surface.setFocusPlacement(.rightSidebarDock)
terminal.updateWorkspaceId(workspaceId)
} else if let browser = panel as? BrowserPanel {
browser.updateWorkspaceId(workspaceId)
}
panels[detached.panelId] = panel
// Cache the transfer as-is, transient resume state included: while the
// agent is alive that state (and the #7155 rescue directory) is still
// current, and `detachSurface` drops all agent metadata once the
// recorded processes are proven dead. Stripping here instead would
// lose the rescue for live agents whenever the detach-time live cwd
// read is unavailable.
detachedSurfaceTransfersByPanelId[detached.panelId] = detached
let kind = detached.kind ?? ((panel.panelType == .browser) ? "browser" : "terminal")
let restoredIconImageData = detached.panel is TerminalPanel ? nil : detached.iconImageData
guard let newTabId = bonsplitController.createTab(
title: detached.title,
icon: detached.icon,
iconImageData: restoredIconImageData,
kind: kind,
isDirty: panel.isDirty,
isLoading: detached.isLoading,
isAudioMuted: (panel as? BrowserPanel)?.isMuted ?? false,
isPinned: false,
inPane: paneId
) else {
panels.removeValue(forKey: detached.panelId)
detachedSurfaceTransfersByPanelId.removeValue(forKey: detached.panelId)
return nil
}
surfaceIdToPanelId[newTabId] = detached.panelId
if let index {
_ = bonsplitController.reorderTab(newTabId, toIndex: index)
}
installSubscription(for: panel, tracksTerminalTitle: true)
withCoalescedTerminalViewReattach {
applyVisibility(to: panel)
if let terminal = panel as? TerminalPanel {
requestTerminalViewReattach(terminal)
}
recordExplicitPanelCreation()
if focus {
bonsplitController.focusPane(paneId)
bonsplitController.selectTab(newTabId)
applyDockSelection(tabId: newTabId, inPane: paneId)
panel.focus()
}
}
scheduleDockPortalReconcile(reason: "dock.attachDetachedSurface")
return detached.panelId
}
}
// MARK: - Tab "Move to…" destinations
extension DockSplitStore {
static let dockMoveNewWorkspaceDestinationId = "new-workspace"
static let dockMoveExistingWorkspacePrefix = "workspace:"
/// Backs `tabContextMoveDestinationsProvider`: offers the same "Move to…"
/// destinations a main-area tab has — New Workspace plus every other
/// workspace — so a Dock tab can leave the Dock for a workspace via the tab
/// context menu, matching `Workspace.bonsplitTabMoveDestinations`.
func dockTabMoveDestinations(for tabId: TabID) -> [TabContextMoveDestination] {
guard panel(for: tabId) != nil, let app = AppDelegate.shared else { return [] }
var destinations: [TabContextMoveDestination] = [
TabContextMoveDestination(
id: Self.dockMoveNewWorkspaceDestinationId,
title: String(localized: "command.newWorkspace.title", defaultValue: "New Workspace")
)
]
// A window Dock resolves its owning window; a Workspace Dock resolves
// that workspace's window (see `dockReferenceTabManager`).
let referenceWindowId = app.dockReferenceTabManager(for: self).flatMap { app.windowId(for: $0) }
let targets = app.workspaceMoveTargets(excludingWorkspaceId: workspaceId, referenceWindowId: referenceWindowId)
destinations.append(contentsOf: targets.map { target in
TabContextMoveDestination(
id: Self.dockMoveExistingWorkspacePrefix + target.workspaceId.uuidString,
title: target.label
)
})
return destinations
}
func splitTabBar(
_ controller: BonsplitController,
didRequestTabMoveToDestination destinationId: String,
for tab: Bonsplit.Tab,
inPane pane: PaneID
) {
guard let panel = panel(for: tab.id), let app = AppDelegate.shared else { return }
let panelId = panel.id
if destinationId == Self.dockMoveNewWorkspaceDestinationId {
_ = app.moveDockSurfaceToNewWorkspace(sourceDock: self, panelId: panelId, focus: true, focusWindow: false)
} else if destinationId.hasPrefix(Self.dockMoveExistingWorkspacePrefix) {
let rawWorkspaceId = destinationId.dropFirst(Self.dockMoveExistingWorkspacePrefix.count)
guard let workspaceId = UUID(uuidString: String(rawWorkspaceId)) else { return }
_ = app.moveDockSurfaceToWorkspace(
sourceDock: self,
panelId: panelId,
toWorkspace: workspaceId,
targetPane: nil,
targetIndex: nil,
splitTarget: nil,
focus: true,
focusWindow: true
)
}
}
}