Make AI, MCP, Metrics, and Wake-on-LAN free while retaining iCloud sync as a Pro feature. Limit free accounts to three saved hosts and preserve excess hosts with remote-operation locks. Gate host creation, import, and runtime access; preserve stable host admission across persistence and sync. Add upgrade states on macOS and iOS, open the paywall from locked Wake actions, and use wrench icons for MCP. Include regression coverage, opt-in UI review fixtures, localized copy, and the updated pricing website revision.
1809 lines
74 KiB
Swift
1809 lines
74 KiB
Swift
import Foundation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPresentation
|
|
import TermindRemoteInfrastructure
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
enum SessionOpenPlacement: Equatable {
|
|
case newWorkspace
|
|
case currentWorkspace
|
|
}
|
|
|
|
/// The state of one window: its workspaces, selection, and shared left sidebar.
|
|
///
|
|
/// One of these per window rather than one per app, so two windows can hold
|
|
/// different sets of workspaces. Menu commands reach the right instance through
|
|
/// `FocusedValues.window`.
|
|
@Observable
|
|
final class WindowModel {
|
|
let id = UUID()
|
|
private(set) var isClosed = false
|
|
let workspaceDeck: WorkspaceDeckModel
|
|
var workspaces: [Workspace] {
|
|
get { workspaceDeck.workspaces }
|
|
set { workspaceDeck.workspaces = newValue }
|
|
}
|
|
|
|
var unreadCompletionCount: Int {
|
|
workspaces.reduce(0) { $0 + $1.unreadCompletionCount }
|
|
}
|
|
/// The top-level shell has Vault, Assistant, Settings, and dynamic SSH/SFTP
|
|
/// Workspaces. This is the single source of truth for workspace selection.
|
|
var selectedDestination: WindowDestination = .vault {
|
|
didSet {
|
|
if oldValue == .settings, selectedDestination != oldValue {
|
|
saveTerminalThemeChanges()
|
|
}
|
|
if oldValue == .settings, selectedDestination != oldValue, hasUnsavedTerminalThemeChanges {
|
|
let next = selectedDestination
|
|
selectedDestination = oldValue
|
|
pendingTerminalThemeNavigation = { [weak self] in
|
|
self?.settingsRoute = .terminalCustomThemes
|
|
self?.selectedDestination = next
|
|
}
|
|
showsDiscardTerminalTheme = true
|
|
return
|
|
}
|
|
if oldValue == .settings, selectedDestination != oldValue, case .terminalCustomTheme = settingsRoute {
|
|
settingsRoute = .terminalCustomThemes
|
|
}
|
|
if selectedDestination != .assistant {
|
|
assistantReturnDestination = selectedDestination
|
|
}
|
|
reconcileSidebarMode()
|
|
commandFocusChanged?()
|
|
}
|
|
}
|
|
/// Where Navigator returns after the Assistant temporarily replaces the
|
|
/// detail surface. Assistant has no row inside the Navigator that could
|
|
/// retain the previous selection for us.
|
|
private var assistantReturnDestination: WindowDestination = .vault
|
|
/// Settings is a reversible trip from the content in front. Its own route is
|
|
/// window-scoped so two windows can configure shared preferences independently.
|
|
private var settingsReturnDestination: WindowDestination = .vault
|
|
weak var metricsModel: ServerMetricsModel?
|
|
var wakeNotice: String?
|
|
var hostAccessError: String?
|
|
var pendingMetricsHostIDs: [UUID]?
|
|
var pendingMetricsEditableHostIDs: Set<UUID> = []
|
|
var metricsHostID: UUID? {
|
|
didSet {
|
|
if metricsHostID != oldValue { metricsDetailPage = .status }
|
|
}
|
|
}
|
|
var metricsDetailPage = MetricsDetailPage.status
|
|
var metricsDetailWidth: CGFloat = 0 {
|
|
didSet {
|
|
if isToolbarFindFocused { revealMetricsProcessesForFind() }
|
|
}
|
|
}
|
|
var showsCompactMetricsDetail: Bool {
|
|
guard selectedDestination == .metrics, metricsDetailWidth > 0,
|
|
!MetricsDetailPage.showsBothColumns(width: metricsDetailWidth),
|
|
let server = metricsModel?.servers.first(where: { $0.id == metricsHostID })
|
|
else { return false }
|
|
return !server.showsConnectionJourney
|
|
}
|
|
var metricsQuery = ""
|
|
var metricsProcessQuery = "" {
|
|
didSet {
|
|
if isToolbarFindFocused { revealMetricsProcessesForFind() }
|
|
}
|
|
}
|
|
var metricsSelection: Set<UUID> = []
|
|
var settingsRoute: SettingsRoute = .index {
|
|
didSet {
|
|
guard oldValue != settingsRoute, case .terminalCustomTheme = oldValue else { return }
|
|
saveTerminalThemeChanges()
|
|
if hasUnsavedTerminalThemeChanges {
|
|
let next = settingsRoute
|
|
settingsRoute = oldValue
|
|
pendingTerminalThemeNavigation = { [weak self] in self?.settingsRoute = next }
|
|
showsDiscardTerminalTheme = true
|
|
} else {
|
|
clearTerminalThemeEditor()
|
|
}
|
|
}
|
|
}
|
|
var terminalThemeDraft: TerminalCustomThemeDraft?
|
|
var hasUnsavedTerminalThemeChanges: Bool {
|
|
guard let draft = terminalThemeDraft else { return false }
|
|
return draft.isDirty || (draft.isNew && draft.value.isValid)
|
|
}
|
|
@ObservationIgnored weak var terminalThemeSettings: TerminalSettingsModel?
|
|
var isImportingTerminalTheme = false
|
|
var isDeletingTerminalTheme = false
|
|
var customThemeQuery = ""
|
|
var showsDiscardTerminalTheme = false
|
|
@ObservationIgnored private var pendingTerminalThemeNavigation: (() -> Void)?
|
|
var terminalThemeQueries: [TerminalCustomConfigurationScheme: String] = [:]
|
|
var vaultDestination: VaultDestination = .welcome
|
|
var vaultWorkRoute: VaultWorkRoute?
|
|
let assistantLibrary: AssistantLibraryModel
|
|
/// Native transcript views survive destination and conversation switches,
|
|
/// while remaining window-local and bounded in memory.
|
|
let transcriptCache = TranscriptListCache()
|
|
let sidebar = WindowSidebarState()
|
|
let presentations = WindowPresentationState()
|
|
let dragState = WorkspaceDragState()
|
|
/// Query and confirmation state for the three manager pages, kept here so a
|
|
/// half-typed search survives a trip to another page — see `VaultPageStates`.
|
|
let vaultPages = VaultPageStates()
|
|
/// What the titlebar Find field holds while the Assistant is in front.
|
|
/// Deliberately window-local and never persisted: a search is a way of
|
|
/// looking at the list, not a property of it.
|
|
var assistantQuery = ""
|
|
/// Archived chats are a destination, not a third section in live history.
|
|
/// This remains window-local so two windows may independently manage the
|
|
/// archive and continue a conversation.
|
|
var isAssistantArchivePresented = false
|
|
var connectionCoordinator: RemoteConnectionCoordinator? {
|
|
didSet {
|
|
guard let connectionCoordinator else { return }
|
|
for workspace in workspaces {
|
|
workspace.configureConnections(using: connectionCoordinator)
|
|
}
|
|
}
|
|
}
|
|
var assistantCommands: AssistantConversationCommands?
|
|
var assistantOperationError: String?
|
|
var workspaceTemplateNotice: String?
|
|
var pendingExternalConnection: RemoteConnectionURL?
|
|
var externalConnectionError: String?
|
|
var hostStore: HostLibraryModel? {
|
|
didSet {
|
|
refreshSessionContext()
|
|
}
|
|
}
|
|
@ObservationIgnored var workspacePreferences: () -> WorkspacePreferences = { WorkspacePreferences() }
|
|
|
|
/// What is being dragged right now, from the workspace bar or from a pane.
|
|
///
|
|
/// Held on the model because every drop target has to decide things *during*
|
|
/// the drag: whether to draw itself without the thing in flight, where a
|
|
/// ghost slot should open, and whether the drop will move a pane or fold in
|
|
/// a whole workspace. The pasteboard payload resolves asynchronously, one drop too
|
|
/// late to answer any of them.
|
|
///
|
|
/// Pane sources clear this when their native session ends. Workspace sources
|
|
/// use the List's session callback on macOS 26 and later; older systems clear
|
|
/// any leftover workspace preview on the next click or drag.
|
|
var dragging: DragPayload? {
|
|
get { dragState.payload }
|
|
set { dragState.payload = newValue }
|
|
}
|
|
|
|
/// Ordered selection saved for a Vault manager page.
|
|
func vaultSelection(for page: VaultPage) -> [VaultSelectionID] {
|
|
vaultPages.selection.selection(for: page)
|
|
}
|
|
|
|
/// Replaces a page selection, removing duplicates and ids belonging to a
|
|
/// different page while keeping the first occurrence of each item.
|
|
func setVaultSelection(_ details: [VaultSelectionID], for page: VaultPage) {
|
|
vaultPages.selection.set(details, for: page)
|
|
}
|
|
|
|
func selectVaultItem(_ detail: VaultSelectionID, on page: VaultPage) {
|
|
vaultPages.selection.select(detail, on: page)
|
|
}
|
|
|
|
/// Shift-click semantics: selected items retain their order; a newly added
|
|
/// item is appended at the point the user selected it.
|
|
func toggleVaultItem(_ detail: VaultSelectionID, on page: VaultPage) {
|
|
vaultPages.selection.toggle(detail, on: page)
|
|
}
|
|
|
|
/// Removes records deleted out from under a remembered selection.
|
|
func pruneVaultSelections(in store: HostLibraryModel) {
|
|
vaultPages.selection.prune(available: Self.availableVaultSelections(in: store))
|
|
}
|
|
|
|
func pruneVaultSelections(in store: CredentialLibraryModel) {
|
|
let available = Set(store.keys.map { VaultSelectionID.key($0.id) })
|
|
.union(store.identities.map { .identity($0.id) })
|
|
vaultPages.selection.prune(available: available, pages: [.keychain])
|
|
}
|
|
|
|
func pruneVaultSelections(in store: SnippetLibraryModel) {
|
|
vaultPages.selection.prune(
|
|
available: Set(store.snippets.map { VaultSelectionID.snippet($0.id) }), pages: [.snippets]
|
|
)
|
|
}
|
|
|
|
private static func availableVaultSelections(in store: HostLibraryModel) -> Set<VaultSelectionID> {
|
|
Set(store.hosts.map { .host($0.id) })
|
|
.union(store.keys.map { .key($0.id) })
|
|
.union(store.identities.map { .identity($0.id) })
|
|
.union(store.portForwardings.map { .forwarding($0.id) })
|
|
.union(store.snippets.map { .snippet($0.id) })
|
|
.union(store.knownHosts.map { .knownHost($0.id) })
|
|
}
|
|
|
|
typealias SidebarMode = WindowSidebarState.Mode
|
|
|
|
var showsSidebar: Bool {
|
|
get { sidebar.isPresented }
|
|
set { sidebar.isPresented = newValue }
|
|
}
|
|
|
|
var availableSidebarModes: [SidebarMode] {
|
|
SidebarMode.allCases.filter(isSidebarModeEnabled)
|
|
}
|
|
|
|
var sidebarMode: SidebarMode {
|
|
get { sidebar.mode }
|
|
set {
|
|
guard isSidebarModeEnabled(newValue) else { return }
|
|
sidebar.mode = newValue
|
|
}
|
|
}
|
|
|
|
func isSidebarModeEnabled(_ mode: SidebarMode) -> Bool {
|
|
mode != .history || (focusedSerialSession == nil && focusedTelnetSession == nil)
|
|
}
|
|
|
|
func reconcileSidebarMode() {
|
|
if !isSidebarModeEnabled(sidebarMode) {
|
|
sidebar.mode = .snippets
|
|
}
|
|
}
|
|
|
|
/// Applies a Sidebar mode chosen by the user, including the two mode-rail
|
|
/// buttons that also act as top-level navigation affordances.
|
|
///
|
|
/// Direct `sidebarMode` assignment deliberately remains presentation-only
|
|
/// for restoration, screenshots, and other programmatic configuration.
|
|
func activateSidebarMode(_ mode: SidebarMode) {
|
|
guard isSidebarModeEnabled(mode) else { return }
|
|
sidebarMode = mode
|
|
|
|
switch mode {
|
|
case .assistant:
|
|
// Inside a Workspace this is its compact, context-aware Assistant.
|
|
// Everywhere else the same control opens the full Assistant route.
|
|
if selectedWorkspace == nil {
|
|
selectAssistant()
|
|
}
|
|
case .navigation:
|
|
if selectedDestination == .assistant {
|
|
restoreAssistantReturnDestination()
|
|
}
|
|
case .snippets, .history:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func restoreAssistantReturnDestination() {
|
|
switch assistantReturnDestination {
|
|
case .workspace(let id):
|
|
if workspaces.contains(where: { $0.id == id }) {
|
|
select(id)
|
|
} else {
|
|
selectVault()
|
|
}
|
|
case .vault:
|
|
selectVault()
|
|
case .assistant:
|
|
// Defensive only: the remembered destination is updated exclusively
|
|
// from non-Assistant routes.
|
|
selectVault()
|
|
case .metrics:
|
|
selectedDestination = .metrics
|
|
case .settings:
|
|
selectedDestination = .settings
|
|
}
|
|
}
|
|
|
|
/// Opens the Workspace's Assistant sidebar, or the full Assistant on other pages.
|
|
/// Repeating the command is intentionally idempotent; hiding the column is
|
|
/// the native Toggle Sidebar command's job.
|
|
func showAssistant() {
|
|
refreshSessionContext()
|
|
sidebar.isPresented = true
|
|
activateSidebarMode(.assistant)
|
|
}
|
|
|
|
var hostChooser: HostChooserPurpose? {
|
|
get { presentations.hostChooser }
|
|
set { presentations.hostChooser = newValue }
|
|
}
|
|
|
|
var isQuickOpenPresented: Bool {
|
|
get { presentations.isQuickOpenPresented }
|
|
set {
|
|
presentations.isQuickOpenPresented = newValue
|
|
if newValue { isToolbarFindFocused = false }
|
|
}
|
|
}
|
|
|
|
var commandFocusChanged: (() -> Void)?
|
|
var configureCommandNotifications: ((Pane) -> Void)?
|
|
|
|
var recordConnectionHistory: ((Host.ID, ConnectionHistoryEntry.Kind) -> ConnectionHistoryEntry.ID?)?
|
|
var endConnectionHistory: ((ConnectionHistoryEntry.ID) -> Void)?
|
|
|
|
init(
|
|
workspaces: [Workspace] = [],
|
|
vaultDestination: VaultDestination = .welcome,
|
|
assistantLibrary: AssistantLibraryModel = AssistantLibraryModel()
|
|
) {
|
|
self.workspaceDeck = WorkspaceDeckModel(workspaces: workspaces)
|
|
self.vaultDestination = vaultDestination
|
|
self.assistantLibrary = assistantLibrary
|
|
selectedDestination = workspaces.first.map { .workspace($0.id) } ?? .vault
|
|
// Property observers do not run while an instance is being initialized.
|
|
assistantReturnDestination = selectedDestination
|
|
settingsReturnDestination = selectedDestination
|
|
for workspace in workspaces {
|
|
workspace.lifecycleRouter = self
|
|
}
|
|
}
|
|
|
|
// MARK: - Selection
|
|
|
|
var selectedWorkspace: Workspace? {
|
|
guard case .workspace(let selectedWorkspaceID) = selectedDestination else { return nil }
|
|
return workspaces.first { $0.id == selectedWorkspaceID }
|
|
}
|
|
|
|
var selectedWorkspaceID: Workspace.ID? {
|
|
get {
|
|
guard case .workspace(let id) = selectedDestination else { return nil }
|
|
return id
|
|
}
|
|
set {
|
|
selectedDestination = newValue.map(WindowDestination.workspace) ?? .vault
|
|
}
|
|
}
|
|
|
|
/// The only pane that application-wide find commands may touch. A Workspace
|
|
/// can contain several terminals, so routing to "any session in the workspace"
|
|
/// would make ⌘F search a buffer other than the one under the caret.
|
|
var focusedSSHSession: SSHSessionModel? {
|
|
return selectedWorkspace?.focusedPane.sshSession
|
|
}
|
|
|
|
var focusedSFTPChannel: SFTPChannelModel? {
|
|
selectedWorkspace?.focusedPane.sftpChannel
|
|
}
|
|
|
|
var focusedSerialSession: SerialSessionModel? {
|
|
selectedWorkspace?.focusedPane.serialSession
|
|
}
|
|
|
|
var focusedTelnetSession: TelnetSessionModel? {
|
|
selectedWorkspace?.focusedPane.telnetSession
|
|
}
|
|
|
|
var canFindInTerminal: Bool {
|
|
focusedSSHSession != nil || focusedTelnetSession != nil || focusedSerialSession != nil
|
|
}
|
|
|
|
var canGenerateTerminalCommand: Bool { focusedSSHSession != nil }
|
|
|
|
func generateTerminalCommand(using controller: CommandGeneratorController) {
|
|
selectedWorkspace?.generateFocusedCommand(using: controller)
|
|
}
|
|
|
|
// MARK: - Find
|
|
|
|
/// Bumped by ⌘F. The titlebar field watches it and takes first responder,
|
|
/// so pressing ⌘F while the field is already showing refocuses rather than
|
|
/// doing nothing.
|
|
private(set) var findFocusRequest: UInt64 = 0
|
|
|
|
/// Bumped by ⌘F while Welcome is in front. Welcome owns a large search
|
|
/// field in its content instead of borrowing the titlebar Find field.
|
|
private(set) var welcomeSearchFocusRequest: UInt64 = 0
|
|
|
|
/// True while the titlebar Find field owns the keyboard.
|
|
///
|
|
/// The field is outside every pane, but what it filters is inside one — so
|
|
/// typing in it changes state the pane observes, and a pane that claims
|
|
/// first responder whenever it is the focused pane takes the caret back
|
|
/// after each keystroke. Panes read this and stand down while it is set.
|
|
///
|
|
/// Level, not edge. A pane has to consult it every time it is about to hold
|
|
/// focus, not only when this flips: the caret is lost on *each* keystroke,
|
|
/// and a stand-down that fires once cannot answer the second one.
|
|
var isToolbarFindFocused = false {
|
|
didSet {
|
|
if isToolbarFindFocused { revealMetricsProcessesForFind() }
|
|
}
|
|
}
|
|
|
|
private func revealMetricsProcessesForFind() {
|
|
if showsCompactMetricsDetail { metricsDetailPage = .processes }
|
|
}
|
|
|
|
/// Focuses the titlebar Find field. Installed by the field itself while it
|
|
/// is mounted, and left pointing at a field that has gone away otherwise —
|
|
/// harmlessly, since the closure holds it weakly and `focusToolbarFind`
|
|
/// checks there is something to search first.
|
|
///
|
|
/// ⌘F does not go through `findFocusRequest` alone. That counter only
|
|
/// reaches the field if SwiftUI delivers an update to the toolbar item
|
|
/// holding it, which is not a refresh this code owns.
|
|
@ObservationIgnored var focusToolbarFindField: (() -> Void)?
|
|
|
|
/// What the window's one Find field is pointed at, decided by the content in
|
|
/// front. A split Workspace resolves to its *focused* pane — "any session in
|
|
/// the workspace" would make ⌘F search a buffer other than the one under the
|
|
/// caret.
|
|
var findKind: WindowToolbarFindKind? {
|
|
switch selectedDestination {
|
|
case .assistant:
|
|
return .assistant
|
|
case .metrics:
|
|
return metricsHostID == nil ? .metrics : .processes
|
|
case .settings:
|
|
if case .terminalThemes = settingsRoute { return .terminalThemes }
|
|
if case .terminalCustomThemes = settingsRoute { return .terminalThemes }
|
|
return nil
|
|
case .workspace:
|
|
if focusedSSHSession != nil || focusedTelnetSession != nil || focusedSerialSession != nil {
|
|
return .terminal
|
|
}
|
|
if focusedSFTPChannel != nil { return .sftp }
|
|
return nil
|
|
case .vault:
|
|
switch vaultDestination {
|
|
// Welcome has its own field, and it is the page's whole point.
|
|
case .welcome: return nil
|
|
case .hosts: return .hosts
|
|
case .keychain: return .keychain
|
|
case .portForwarding: return .portForwarding
|
|
case .snippets: return .snippets
|
|
case .knownHosts: return .knownHosts
|
|
}
|
|
}
|
|
}
|
|
|
|
private var hasWelcomeSearch: Bool {
|
|
guard case .vault = selectedDestination else { return false }
|
|
return vaultDestination == .welcome
|
|
}
|
|
|
|
var canFindInMainContent: Bool { hasWelcomeSearch || findKind != nil }
|
|
|
|
/// ⌘F. Focuses the titlebar field, and for a terminal also starts Ghostty's
|
|
/// search again on a query retained from last time.
|
|
///
|
|
/// The panes are stood down *first*. Everything after this line changes
|
|
/// state a pane observes, so by the time the resulting update reaches one it
|
|
/// must already know not to claim the keyboard — otherwise ⌘F focuses the
|
|
/// field and the rebuild it caused takes the caret straight back, which is
|
|
/// how the Find menu item came to be clickable and yet do nothing.
|
|
func focusToolbarFind() {
|
|
if hasWelcomeSearch {
|
|
welcomeSearchFocusRequest &+= 1
|
|
return
|
|
}
|
|
guard findKind != nil else { return }
|
|
isToolbarFindFocused = true
|
|
if findKind == .terminal {
|
|
focusedSSHSession?.find.present()
|
|
focusedTelnetSession?.find.present()
|
|
focusedSerialSession?.find.present()
|
|
}
|
|
findFocusRequest &+= 1
|
|
// On the next turn, so the update the two lines above set off has
|
|
// already landed and cannot take the caret straight back out.
|
|
Task { @MainActor [weak self] in self?.focusToolbarFindField?() }
|
|
}
|
|
|
|
/// ⌘G and F3.
|
|
///
|
|
/// A terminal search starts at the bottom of the buffer, so Ghostty's
|
|
/// `.previous` is the direction that reaches the first match — which is what
|
|
/// a reader means by "next". Return in the Find field is bound the same way;
|
|
/// the two used to disagree.
|
|
func navigateTerminalFind(_ direction: TerminalFindDirection) {
|
|
focusedSSHSession?.find.presentAndNavigate(direction)
|
|
focusedTelnetSession?.find.presentAndNavigate(direction)
|
|
focusedSerialSession?.find.presentAndNavigate(direction)
|
|
}
|
|
|
|
/// The workspace whose content the detail column shows.
|
|
///
|
|
/// The selected one, except while the selected one is the thing being
|
|
/// dragged. Merging is done by dropping a workspace into a Workspace's pane area,
|
|
/// and a workspace cannot be dropped into itself — so leaving it on screen would
|
|
/// point the app's one merge gesture at the only target that refuses it.
|
|
/// It steps aside to the next workspace, or the previous one when there is no
|
|
/// next: strictly adjacent, and only when that neighbour is somewhere a workspace
|
|
/// could actually land.
|
|
///
|
|
/// Derived rather than a real selection change, the same way
|
|
/// `WorkspaceContentView.displayedLayout` draws a Workspace without the pane in
|
|
/// flight. Nothing is committed, so every way a drag can end — a reorder, a
|
|
/// merge, a cancel, a release outside the window that no delegate ever hears
|
|
/// about — restores the selected view once the source clears its drag state.
|
|
var displayedWorkspace: Workspace? {
|
|
guard case .workspace(let id)? = dragging,
|
|
id == selectedWorkspaceID,
|
|
let index = workspaces.firstIndex(where: { $0.id == id }),
|
|
workspaces[index].isCombinable
|
|
else { return selectedWorkspace }
|
|
for neighbour in [index + 1, index - 1] where workspaces.indices.contains(neighbour) {
|
|
if workspaces[neighbour].acceptsMerge { return workspaces[neighbour] }
|
|
}
|
|
return selectedWorkspace
|
|
}
|
|
|
|
/// Every pane of a given kind, with the Workspace holding it.
|
|
///
|
|
/// The sidebar lists *panes*, not workspaces. A Workspace can be mixed — a shell
|
|
/// beside a file browser — and it belongs under both Sessions and File
|
|
/// Transfers rather than under whichever half had focus last. Filing the
|
|
/// whole workspace by its focused pane meant a row that changed sections as you
|
|
/// clicked around inside it, which is the one way a list of what is open can
|
|
/// be worse than no list.
|
|
///
|
|
/// It also means a Workspace with three shells gets three rows, which is the
|
|
/// point of naming panes — see `Pane.customTitle`.
|
|
func panes(ofKind kind: PaneKind) -> [PaneRef] {
|
|
workspaces.flatMap { workspace in
|
|
workspace.panes
|
|
.filter { $0.kind == kind }
|
|
.map(PaneRef.init(pane:))
|
|
}
|
|
}
|
|
|
|
/// Shells on a host — what the sidebar lists under "Sessions", and what
|
|
/// "Close All Sessions" acts on.
|
|
///
|
|
/// Deliberately narrower than "not a page": a host editor is a form, a chat
|
|
/// is a conversation, and a host's tunnels are a setting on that host. None
|
|
/// of them is an SSH session no matter how it got opened.
|
|
var sshSessionPanes: [PaneRef] { panes(ofKind: .sshSession) }
|
|
|
|
/// One host's file browser, per open browser. The queue page is not among
|
|
/// them: it is a page, so its `kind` is `.page`, and it lives in Manage.
|
|
var sftpChannelPanes: [PaneRef] { panes(ofKind: .sftpChannel) }
|
|
|
|
var serialSessionPanes: [PaneRef] { panes(ofKind: .serialSession) }
|
|
|
|
var sessionPanes: [PaneRef] {
|
|
workspaces.flatMap { workspace in
|
|
workspace.panes.compactMap { pane in
|
|
guard
|
|
pane.kind == .sshSession || pane.kind == .sftpChannel
|
|
|| pane.kind == .serialSession
|
|
else { return nil }
|
|
return PaneRef(pane: pane)
|
|
}
|
|
}
|
|
}
|
|
|
|
var rememberClosedWorkspace: ((Workspace) -> Void)?
|
|
var rememberClosedWindow: (([Workspace], UUID?) -> Void)?
|
|
var confirmsClearConnectionHistory = false
|
|
var historyOperationError: String?
|
|
|
|
func showConnectionHistory() {
|
|
selectVault()
|
|
showsSidebar = true
|
|
activateSidebarMode(.history)
|
|
}
|
|
|
|
func select(_ id: Workspace.ID) {
|
|
guard workspaces.contains(where: { $0.id == id }) else { return }
|
|
selectedDestination = .workspace(id)
|
|
workspaces.first { $0.id == id }?.markRead()
|
|
refreshSessionContext()
|
|
}
|
|
|
|
func selectVault() {
|
|
selectedDestination = .vault
|
|
}
|
|
|
|
/// Enters the Assistant without changing its in-memory navigation state:
|
|
/// the current conversation, New Chat, and Archive are all left untouched.
|
|
func selectAssistant() {
|
|
selectedDestination = .assistant
|
|
}
|
|
|
|
func selectAssistant(_ id: ConversationModel.ID) {
|
|
guard openAssistantConversation(id) else { return }
|
|
selectedDestination = .assistant
|
|
}
|
|
|
|
func openSettings(route: SettingsRoute = .index) {
|
|
if selectedDestination != .settings {
|
|
settingsReturnDestination = selectedDestination
|
|
}
|
|
settingsRoute = route
|
|
selectedDestination = .settings
|
|
}
|
|
|
|
func closeSettings() {
|
|
let destination = settingsReturnDestination
|
|
settingsRoute = .index
|
|
switch destination {
|
|
case .workspace(let id):
|
|
if workspaces.contains(where: { $0.id == id }) {
|
|
select(id)
|
|
} else {
|
|
selectVault()
|
|
}
|
|
case .vault:
|
|
selectVault()
|
|
case .assistant:
|
|
selectAssistant()
|
|
case .metrics:
|
|
selectedDestination = .metrics
|
|
case .settings:
|
|
selectVault()
|
|
}
|
|
}
|
|
|
|
func navigateBackInSettings() {
|
|
guard selectedDestination == .settings else { return }
|
|
if let parent = settingsRoute.parent {
|
|
settingsRoute = parent
|
|
} else {
|
|
closeSettings()
|
|
}
|
|
}
|
|
|
|
func discardTerminalThemeAndNavigate() {
|
|
let navigate = pendingTerminalThemeNavigation
|
|
clearTerminalThemeEditor()
|
|
cancelTerminalThemeNavigation()
|
|
navigate?()
|
|
}
|
|
|
|
func beginEditingTerminalTheme(_ theme: TerminalCustomTheme, isNew: Bool, in model: TerminalSettingsModel) {
|
|
terminalThemeDraft = TerminalCustomThemeDraft(theme, isNew: isNew)
|
|
terminalThemeSettings = model
|
|
settingsRoute = .terminalCustomTheme(theme.id)
|
|
}
|
|
|
|
func saveTerminalThemeChanges(allowNameTrimming: Bool = true) {
|
|
guard let draft = terminalThemeDraft, let model = terminalThemeSettings,
|
|
draft.isNew || draft.isDirty, draft.value.isValid, !draft.isDeleted(in: model)
|
|
else { return }
|
|
// Do not remove a space between words while the user is still typing a name.
|
|
guard allowNameTrimming || draft.value.name == draft.value.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
else { return }
|
|
_ = draft.save(in: model)
|
|
}
|
|
|
|
func clearTerminalThemeEditor() {
|
|
terminalThemeDraft = nil
|
|
terminalThemeSettings = nil
|
|
isImportingTerminalTheme = false
|
|
isDeletingTerminalTheme = false
|
|
}
|
|
|
|
func cancelTerminalThemeNavigation() {
|
|
pendingTerminalThemeNavigation = nil
|
|
showsDiscardTerminalTheme = false
|
|
}
|
|
|
|
/// Promotes a compact Sidebar preview into the main Assistant surface while
|
|
/// keeping the conversation navigator visible in the shared Sidebar.
|
|
func openAssistantFullScreen(_ id: ConversationModel.ID? = nil) {
|
|
sidebarMode = .assistant
|
|
if let id {
|
|
selectAssistant(id)
|
|
} else {
|
|
_ = openNewAssistantChat()
|
|
}
|
|
}
|
|
|
|
func showAssistantArchive() {
|
|
presentAssistantArchive()
|
|
selectedDestination = .assistant
|
|
}
|
|
|
|
func isShowingAssistant(_ id: UUID) -> Bool {
|
|
guard !isClosed, !isAssistantArchivePresented, assistantLibrary.selectedConversationID == id else {
|
|
return false
|
|
}
|
|
if selectedDestination == .assistant { return true }
|
|
return selectedWorkspace != nil && showsSidebar && sidebarMode == .assistant
|
|
}
|
|
|
|
var visibleAssistantConversationID: UUID? {
|
|
guard let id = assistantLibrary.selectedConversationID, isShowingAssistant(id) else { return nil }
|
|
return id
|
|
}
|
|
|
|
var sidebarAssistantConversation: ConversationModel? {
|
|
guard !isAssistantArchivePresented else { return nil }
|
|
return assistantLibrary.selectedConversation
|
|
}
|
|
|
|
func openAssistantInSidebar(_ id: ConversationModel.ID) {
|
|
guard openAssistantConversation(id) else { return }
|
|
refreshSessionContext()
|
|
}
|
|
|
|
func closeAssistantInSidebar() {
|
|
presentAssistantHome()
|
|
refreshSessionContext()
|
|
}
|
|
|
|
func showAssistantArchiveInSidebar() {
|
|
presentAssistantArchive()
|
|
refreshSessionContext()
|
|
}
|
|
|
|
@discardableResult
|
|
private func openAssistantConversation(_ id: ConversationModel.ID) -> Bool {
|
|
guard assistantLibrary.conversation(id: id) != nil else { return false }
|
|
isAssistantArchivePresented = false
|
|
assistantLibrary.select(id)
|
|
return true
|
|
}
|
|
|
|
private func presentAssistantHome() {
|
|
isAssistantArchivePresented = false
|
|
assistantLibrary.selectNewConversation()
|
|
}
|
|
|
|
func presentAssistantArchive() {
|
|
assistantLibrary.selectNewConversation()
|
|
isAssistantArchivePresented = true
|
|
}
|
|
|
|
func deleteAssistantConversation(_ id: ConversationModel.ID) {
|
|
if let assistantCommands {
|
|
assistantCommands.delete(id)
|
|
return
|
|
}
|
|
transcriptCache.removeConversation(id)
|
|
let wasArchived = assistantLibrary.conversation(id: id)?.isArchived == true
|
|
let wasSelected = assistantLibrary.selectedConversationID == id
|
|
assistantLibrary.delete(id)
|
|
if wasArchived, wasSelected { presentAssistantArchive() }
|
|
}
|
|
|
|
func toggleAssistantPin(_ id: ConversationModel.ID) {
|
|
if let assistantCommands {
|
|
assistantCommands.togglePin(id)
|
|
} else if let conversation = assistantLibrary.conversation(id: id),
|
|
!conversation.isArchived
|
|
{
|
|
let toPinned = !conversation.isPinned
|
|
let nextID =
|
|
(toPinned
|
|
? assistantLibrary.pinnedConversations
|
|
: assistantLibrary.activeConversations).first?.id
|
|
moveAssistantConversation(id, toPinned: toPinned, before: nextID)
|
|
}
|
|
}
|
|
func moveAssistantConversation(
|
|
_ id: ConversationModel.ID,
|
|
toPinned: Bool,
|
|
before nextID: ConversationModel.ID?
|
|
) {
|
|
if let assistantCommands {
|
|
assistantCommands.move(id, toPinned, nextID)
|
|
} else if let updates = assistantLibrary.orderUpdates(
|
|
moving: id, toPinned: toPinned, before: nextID
|
|
) {
|
|
assistantLibrary.applyOrder(updates)
|
|
}
|
|
}
|
|
func renameAssistantConversation(_ id: ConversationModel.ID, to title: String) {
|
|
if let assistantCommands {
|
|
assistantCommands.rename(id, title)
|
|
} else {
|
|
let value = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !value.isEmpty {
|
|
assistantLibrary.conversation(id: id)?.customTitle = String(value.prefix(80))
|
|
}
|
|
}
|
|
}
|
|
func setAssistantArchived(_ id: ConversationModel.ID, archived: Bool) {
|
|
if let assistantCommands {
|
|
assistantCommands.archive(id, archived)
|
|
} else if let conversation = assistantLibrary.conversation(id: id),
|
|
!conversation.isStreaming, !conversation.isCompacting
|
|
{
|
|
conversation.archivedAt = archived ? .now : nil
|
|
if archived { conversation.isPinned = false }
|
|
if archived { assistantConversationWasArchived(id) }
|
|
}
|
|
}
|
|
|
|
/// An archived conversation leaves every live-chat surface that was showing
|
|
/// it. Keeping its id selected would strand the window on a read-only chat
|
|
/// that has just disappeared from the ordinary navigator.
|
|
func assistantConversationWasArchived(_ id: ConversationModel.ID) {
|
|
if assistantLibrary.selectedConversationID == id {
|
|
assistantLibrary.selectNewConversation()
|
|
isAssistantArchivePresented = false
|
|
}
|
|
}
|
|
func forkAssistantConversation(
|
|
_ id: ConversationModel.ID, at messageID: UUID, inSidebar: Bool = false
|
|
) {
|
|
assistantCommands?.fork(id, messageID, inSidebar)
|
|
}
|
|
func editAssistantUserMessage(
|
|
_ id: ConversationModel.ID, messageID: UUID, text: String,
|
|
context: SessionContext = .none, terminalSuggestionsAvailable: Bool = false
|
|
) {
|
|
assistantCommands?.edit(id, messageID, text, context, terminalSuggestionsAvailable)
|
|
}
|
|
|
|
func assistantSidebarConversationDidStart(_ conversation: ConversationModel) {
|
|
isAssistantArchivePresented = false
|
|
assistantLibrary.select(conversation.id)
|
|
sidebar.assistantDraft = ConversationModel()
|
|
}
|
|
|
|
/// Republishes the focused Workspace's host, directory, and OS facts for the
|
|
/// sidebar composer.
|
|
///
|
|
/// It writes to the column, not to a conversation. Stamping this onto
|
|
/// whichever chat the sidebar happened to be showing meant the chat kept it:
|
|
/// send from the sidebar, open the same conversation in the Assistant workspace,
|
|
/// and a host and directory that workspace knows nothing about were still on
|
|
/// display — and would still have been uploaded.
|
|
///
|
|
/// No Workspace selected clears it rather than returning early. Leaving the
|
|
/// last value in place froze a host and directory the user had navigated
|
|
/// away from.
|
|
func refreshSessionContext() {
|
|
guard let workspace = selectedWorkspace, let hostStore else {
|
|
sidebar.sessionContext = .none
|
|
return
|
|
}
|
|
let pane = workspace.focusedPane
|
|
let paneID: UUID? =
|
|
switch pane.kind {
|
|
case .sshSession, .telnetSession, .serialSession: pane.id
|
|
case .sftpChannel, .empty: nil
|
|
}
|
|
sidebar.sessionContext = SessionContext(
|
|
paneID: paneID,
|
|
hostID: workspace.focusedPane.hostID,
|
|
hostName: workspace.focusedPane.hostID.flatMap { hostStore.host($0)?.name },
|
|
workingDirectory: workspace.focusedPane.workingDirectory,
|
|
workingDirectoryDisplayLabel: workspace.focusedPane.displayedWorkingDirectory,
|
|
showsWorkingDirectoryChip: workspace.focusedPane.displayedWorkingDirectory != nil,
|
|
systemInfo: workspace.focusedPane.remoteSystemInfo
|
|
)
|
|
}
|
|
|
|
/// Selects a dynamic Workspace by its order in the Navigator.
|
|
func select(index: Int) {
|
|
guard workspaces.indices.contains(index) else { return }
|
|
select(workspaces[index].id)
|
|
}
|
|
|
|
func selectNext() {
|
|
guard !workspaces.isEmpty else { return }
|
|
guard let selectedWorkspaceID,
|
|
let index = workspaces.firstIndex(where: { $0.id == selectedWorkspaceID })
|
|
else {
|
|
select(workspaces[0].id)
|
|
return
|
|
}
|
|
select(workspaces[(index + 1) % workspaces.count].id)
|
|
}
|
|
|
|
func selectPrevious() {
|
|
guard !workspaces.isEmpty else { return }
|
|
guard let selectedWorkspaceID,
|
|
let index = workspaces.firstIndex(where: { $0.id == selectedWorkspaceID })
|
|
else {
|
|
select(workspaces[workspaces.count - 1].id)
|
|
return
|
|
}
|
|
select(workspaces[(index - 1 + workspaces.count) % workspaces.count].id)
|
|
}
|
|
|
|
// MARK: - Opening
|
|
|
|
@discardableResult
|
|
func open(
|
|
_ workspace: Workspace,
|
|
select shouldSelect: Bool = true
|
|
) -> Workspace {
|
|
workspace.panes.forEach(configureConnectionHistory)
|
|
workspace.lifecycleRouter = self
|
|
if let connectionCoordinator {
|
|
workspace.configureConnections(using: connectionCoordinator)
|
|
}
|
|
workspaces.append(workspace)
|
|
if shouldSelect { select(workspace.id) }
|
|
return workspace
|
|
}
|
|
|
|
/// Opens a route inside the pinned Vault workspace.
|
|
func openPage(_ page: VaultPage) {
|
|
vaultDestination = VaultDestination(page: page)
|
|
vaultWorkRoute = nil
|
|
selectedDestination = .vault
|
|
}
|
|
|
|
func openVaultWork(_ route: VaultWorkRoute, on page: VaultPage) {
|
|
vaultDestination = VaultDestination(page: page)
|
|
vaultWorkRoute = route
|
|
selectedDestination = .vault
|
|
}
|
|
|
|
func closeVaultWork() {
|
|
vaultWorkRoute = nil
|
|
}
|
|
|
|
func canOpenCompanionSession(from sessionID: UUID) -> Bool {
|
|
guard let source = companionSessionSource(sessionID) else { return false }
|
|
return source.pane.sshSession != nil || source.host.sshEnabled
|
|
}
|
|
|
|
/// Resolve the owning pane again at dispatch, not the pane that happened to
|
|
/// have focus when a native context menu was constructed.
|
|
@discardableResult
|
|
func openCompanionSession(from sessionID: UUID, placement: SessionOpenPlacement) -> Workspace? {
|
|
guard let source = companionSessionSource(sessionID) else { return nil }
|
|
if let session = source.pane.sshSession, let ticket = session.directoryContextTicket {
|
|
Task { [weak self, weak session] in
|
|
guard let self, let session else { return }
|
|
do {
|
|
try await session.confirmDirectoryContext(ticket)
|
|
guard let current = companionSessionSource(sessionID), current.path == source.path,
|
|
current.workspace.id == source.workspace.id, session.isDirectoryContextCurrent(ticket)
|
|
else { return }
|
|
if placement == .currentWorkspace {
|
|
select(current.workspace.id)
|
|
current.workspace.focus(current.pane.id)
|
|
}
|
|
openSFTPChannel(host: current.host, initialPath: current.path, placement: placement)
|
|
} catch {}
|
|
}
|
|
return nil
|
|
}
|
|
if placement == .currentWorkspace {
|
|
select(source.workspace.id)
|
|
source.workspace.focus(source.pane.id)
|
|
}
|
|
if source.pane.sshSession != nil {
|
|
return openSFTPChannel(host: source.host, initialPath: source.path, placement: placement)
|
|
}
|
|
guard source.host.sshEnabled, let transport = source.host.preferredTerminalProtocol else { return nil }
|
|
return openSSHSession(host: source.host, transport: transport, initialPath: source.path, placement: placement)
|
|
}
|
|
|
|
private func companionSessionSource(_ sessionID: UUID) -> (
|
|
workspace: Workspace, pane: Pane, host: Host, path: String
|
|
)? {
|
|
for workspace in workspaces {
|
|
for pane in workspace.panes {
|
|
let path: String
|
|
if let session = pane.sshSession, session.id == sessionID {
|
|
guard session.status.isLive, let directory = session.workingDirectory, !directory.isEmpty else {
|
|
return nil
|
|
}
|
|
path = directory
|
|
} else if let channel = pane.sftpChannel, channel.id == sessionID {
|
|
guard channel.status == .connected, !channel.path.isEmpty else { return nil }
|
|
path = channel.path
|
|
} else {
|
|
continue
|
|
}
|
|
guard let hostID = pane.hostID, let host = hostStore?.host(hostID) else { return nil }
|
|
return (workspace, pane, host, path)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
@discardableResult
|
|
func openSSHSession(
|
|
host: Host, transport: HostTerminalProtocol = .ssh,
|
|
usernameHint: String? = nil,
|
|
connectionRoute: SSHConnectionRoute? = nil,
|
|
initialPath: String? = nil,
|
|
placement: SessionOpenPlacement = .newWorkspace
|
|
) -> Workspace {
|
|
let pane = Pane.sshSession(
|
|
host: host,
|
|
usernameHint: usernameHint,
|
|
initialCommand: initialPath.flatMap(RemoteShellCommand.changeDirectory),
|
|
recordsInitialCommandInHistory: initialPath == nil, transport: transport
|
|
)
|
|
pane.sshSession?.connectionRoute = connectionRoute
|
|
configureConnectionHistory(pane)
|
|
|
|
if placement == .currentWorkspace,
|
|
let workspace = selectedWorkspace,
|
|
workspace.focusedPane.sftpChannel != nil
|
|
{
|
|
workspace.splitFocusedPane(axis: .horizontal, with: pane)
|
|
return workspace
|
|
}
|
|
|
|
return open(automaticallyNamedWorkspace(pane: pane, prefix: host.name))
|
|
}
|
|
|
|
@discardableResult
|
|
func openTelnetSession(host: Host) -> Workspace {
|
|
let pane = Pane.telnetSession(host: host)
|
|
configureConnectionHistory(pane)
|
|
return open(automaticallyNamedWorkspace(pane: pane, prefix: host.name))
|
|
}
|
|
|
|
@discardableResult
|
|
func openExternalTelnetSession(
|
|
host: Host, username: String?, password: SSHSecret?
|
|
) -> Workspace {
|
|
let pane = Pane.telnetSession(
|
|
host: host, usernameHint: username,
|
|
advisoryPassword: password,
|
|
isExternalTemporaryConnection: true)
|
|
return open(automaticallyNamedWorkspace(pane: pane, prefix: host.name))
|
|
}
|
|
|
|
func disconnectSessions(
|
|
for hostID: Host.ID, protocol connectionProtocol: HostTerminalProtocol, message: String
|
|
) {
|
|
for pane in workspaces.flatMap(\.panes) where pane.hostID == hostID {
|
|
switch (connectionProtocol, pane.content) {
|
|
case (.ssh, .sshSession(let session)):
|
|
session.disconnect()
|
|
session.status = .failed(message)
|
|
case (.ssh, .sftpChannel(let channel)):
|
|
channel.disconnect()
|
|
case (.telnet, .telnetSession(let session)):
|
|
session.disconnect()
|
|
session.status = .failed(message)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Batch Startup opens every host as a pane of one new Workspace, tiled into a grid.
|
|
///
|
|
/// Returns nil for a target that cannot be a Workspace pane — only SSH and
|
|
/// SFTP channels can, which is the same rule `PaneKind.isCombinable`
|
|
/// enforces everywhere else.
|
|
@discardableResult
|
|
func openWorkspace(_ hosts: [Host], as target: HostOpenTarget) -> Workspace? {
|
|
let panes: [Pane] = hosts.compactMap { host in
|
|
switch target {
|
|
case .preferredTerminal:
|
|
switch host.preferredTerminalProtocol {
|
|
case .ssh: .sshSession(host: host)
|
|
case .mosh: .sshSession(host: host, transport: .mosh)
|
|
case .eternalTerminal: .sshSession(host: host, transport: .eternalTerminal)
|
|
case .telnet: .telnetSession(host: host)
|
|
case nil: nil
|
|
}
|
|
case .sshSession: .sshSession(host: host)
|
|
case .moshSession: .sshSession(host: host, transport: .mosh)
|
|
case .eternalTerminalSession: .sshSession(host: host, transport: .eternalTerminal)
|
|
case .telnetSession: .telnetSession(host: host)
|
|
case .sftpChannel: .sftpChannel(host: host)
|
|
}
|
|
}
|
|
guard let first = panes.first, let layout = SplitNode.tiled(panes) else { return nil }
|
|
|
|
let workspace = Workspace(pane: first)
|
|
workspace.layout = layout
|
|
workspace.focusedPaneID = first.id
|
|
open(workspace)
|
|
return workspace
|
|
}
|
|
|
|
/// Opens every target using its default SSH-authenticated terminal transport.
|
|
/// Workspace titles keep their ordinary host-derived defaults; each pane
|
|
/// submits the same command independently after login.
|
|
@discardableResult
|
|
func openSnippetWorkspace(hosts: [Host], command: String) -> Workspace? {
|
|
let panes = hosts.filter(\.sshEnabled).map { host in
|
|
Pane.sshSession(
|
|
host: host, initialCommand: command,
|
|
transport: host.terminalTransports.preferred(isTailscaleManaged: host.isTailscaleManaged))
|
|
}
|
|
guard let first = panes.first, let layout = SplitNode.tiled(panes) else { return nil }
|
|
|
|
let workspace = Workspace(pane: first)
|
|
workspace.layout = layout
|
|
workspace.focusedPaneID = first.id
|
|
open(workspace)
|
|
return workspace
|
|
}
|
|
|
|
@discardableResult
|
|
func openNewAssistantChat() -> ConversationModel {
|
|
presentAssistantHome()
|
|
selectedDestination = .assistant
|
|
return assistantLibrary.draftConversation
|
|
}
|
|
|
|
/// One machine's SFTP browser, listed in the sidebar under "Sessions".
|
|
///
|
|
/// A caller with a terminal working directory can override the host's saved
|
|
/// initial path for this browser only. Opening in the current Workspace is
|
|
/// an explicit layout choice and always places the browser to the right of
|
|
/// the focused shell.
|
|
@discardableResult
|
|
func openSFTPChannel(
|
|
host: Host,
|
|
initialPath: String? = nil,
|
|
placement: SessionOpenPlacement = .newWorkspace
|
|
) -> Workspace {
|
|
let pane = Pane.sftpChannel(host: host, initialPath: initialPath)
|
|
configureConnectionHistory(pane)
|
|
if let connectionCoordinator { pane.configureConnections(using: connectionCoordinator) }
|
|
|
|
if placement == .currentWorkspace,
|
|
let workspace = selectedWorkspace,
|
|
workspace.focusedPane.sshSession != nil
|
|
{
|
|
workspace.splitFocusedPane(axis: .horizontal, with: pane)
|
|
return workspace
|
|
}
|
|
|
|
return open(automaticallyNamedWorkspace(pane: pane, prefix: host.name))
|
|
}
|
|
|
|
@discardableResult
|
|
func openSerialSession(
|
|
configuration: SerialConnectionConfiguration,
|
|
service: any SerialPortServing
|
|
) -> Workspace {
|
|
open(Workspace(pane: .serialSession(configuration: configuration, service: service)))
|
|
}
|
|
|
|
private func configureConnectionHistory(_ pane: Pane) {
|
|
configureCommandNotifications?(pane)
|
|
guard !pane.hasActiveConnectionHistory, let hostID = pane.hostID else { return }
|
|
let kind: ConnectionHistoryEntry.Kind
|
|
switch pane.kind {
|
|
case .sshSession: kind = .terminal(pane.sshSession?.transport ?? .ssh)
|
|
case .telnetSession: kind = .telnet
|
|
case .sftpChannel: kind = .sftp
|
|
case .serialSession, .empty: return
|
|
}
|
|
guard let entryID = recordConnectionHistory?(hostID, kind), let endConnectionHistory else { return }
|
|
pane.configureConnectionHistory(entryID: entryID, end: endConnectionHistory)
|
|
}
|
|
|
|
/// Opens the host editor, reusing the workspace that already holds one for the
|
|
/// same host — and the same initial group counts as the same new Host form.
|
|
/// The same reasoning as `openPage`: a half-typed form is easier to find
|
|
/// again than to tell apart from another empty copy.
|
|
func openHostEditor(host: Host?, groupID: HostGroup.ID? = nil) {
|
|
openVaultWork(
|
|
.hostEditor(host?.id, groupID: host == nil ? groupID : nil),
|
|
on: .hosts
|
|
)
|
|
}
|
|
|
|
/// An open Tailscale form follows a single eligible host selection.
|
|
func updateTailscaleDetails(for host: Host?, hasPeer: Bool) {
|
|
guard case .tailscaleDetails = vaultWorkRoute else { return }
|
|
guard let host, host.isTailscaleManaged || hasPeer else {
|
|
closeVaultWork()
|
|
return
|
|
}
|
|
vaultWorkRoute = .tailscaleDetails(host.id)
|
|
}
|
|
|
|
// MARK: - Combining
|
|
|
|
/// Folds every pane of `source` into `target` beside a named pane, on a named
|
|
/// side, and closes the source workspace.
|
|
///
|
|
/// The one merge gesture in the app: drag a workspace out of the strip and drop it
|
|
/// on the edge of a pane. Which edge you let go over is which side the panes
|
|
/// land on, so the preview rectangle and the result are the same shape. The
|
|
/// workspace strip itself merges nothing — a workspace dropped there reorders, and a pane
|
|
/// dropped there becomes its own workspace.
|
|
///
|
|
/// A Workspace is built only from things already open. There used to be a
|
|
/// "Connect in Split" that welded a host you had never opened onto whatever
|
|
/// workspace happened to be front; combining is a rearrangement of what is on
|
|
/// screen, and nothing connects as a side effect of a layout change.
|
|
///
|
|
/// The source's own thread goes with it. A Workspace has one thread of its
|
|
/// own, and silently keeping the wrong one is worse than continuing the
|
|
/// survivor's — which is the one still in front of you. A *chat pane's*
|
|
/// thread is not the workspace's and travels with the pane, so folding a chat into
|
|
/// a Workspace brings its conversation along intact.
|
|
func merge(
|
|
_ source: Workspace.ID,
|
|
into target: Workspace.ID,
|
|
beside paneID: Pane.ID,
|
|
axis: SplitAxis,
|
|
side: SplitSide
|
|
) {
|
|
guard source != target,
|
|
let sourceWorkspace = workspaces.first(where: { $0.id == source }),
|
|
let targetWorkspace = workspaces.first(where: { $0.id == target }),
|
|
sourceWorkspace.isCombinable,
|
|
let targetPane = targetWorkspace.layout.pane(paneID),
|
|
targetPane.isCombinable
|
|
else { return }
|
|
targetWorkspace.layout = targetWorkspace.layout.splitting(
|
|
paneID,
|
|
axis: axis,
|
|
side: side,
|
|
with: sourceWorkspace.layout
|
|
)
|
|
targetWorkspace.focusedPaneID = sourceWorkspace.layout.firstPane.id
|
|
workspaces.removeAll { $0.id == source }
|
|
select(target)
|
|
}
|
|
|
|
/// The same fold, into a specific empty slot rather than beside the focused
|
|
/// pane — what dropping onto an `EmptyPaneView` does.
|
|
func fill(_ slot: Pane.ID, in target: Workspace.ID, withPanesOf source: Workspace.ID) {
|
|
guard source != target,
|
|
let sourceWorkspace = workspaces.first(where: { $0.id == source }),
|
|
let targetWorkspace = workspaces.first(where: { $0.id == target }),
|
|
sourceWorkspace.isCombinable,
|
|
targetWorkspace.layout.pane(slot)?.isEmptySlot == true
|
|
else { return }
|
|
targetWorkspace.replacePane(slot, with: sourceWorkspace.layout)
|
|
workspaces.removeAll { $0.id == source }
|
|
select(target)
|
|
}
|
|
|
|
// MARK: - Rearranging panes
|
|
|
|
/// Moves one pane beside another, inside the Workspace they share.
|
|
///
|
|
/// Panes do not cross Workspaces. Getting a pane into a different one is two
|
|
/// deliberate moves — drop it on the workspace strip so it becomes a workspace, then drag
|
|
/// that workspace into the target's pane area — rather than one gesture a few
|
|
/// pixels from a gesture that means something else. `PaneLayoutDropController`
|
|
/// enforces the same-Workspace rule at the other end.
|
|
///
|
|
/// Lifted out before it is placed. That is safe because the target is never
|
|
/// the pane being moved (the caller's own guard), so it always survives the
|
|
/// removal — and doing it in this order means the pane is never in the tree
|
|
/// twice, which matters because both copies would share one id and `removing`
|
|
/// would take out the wrong one.
|
|
///
|
|
/// Closes the source workspace when the move emptied it.
|
|
func movePane(_ paneID: Pane.ID, beside target: Pane.ID, axis: SplitAxis, side: SplitSide) {
|
|
guard paneID != target, let pane = pane(paneID) else { return }
|
|
detach(paneID)
|
|
guard let targetWorkspace = workspace(holding: target) else { return }
|
|
targetWorkspace.layout = targetWorkspace.layout.splitting(
|
|
target, axis: axis, side: side, with: pane
|
|
)
|
|
targetWorkspace.focusedPaneID = paneID
|
|
select(targetWorkspace.id)
|
|
}
|
|
|
|
/// The same move, into an unfilled slot, which the pane replaces.
|
|
func movePane(_ paneID: Pane.ID, into slot: Pane.ID) {
|
|
guard paneID != slot, let pane = pane(paneID) else { return }
|
|
detach(paneID)
|
|
guard let targetWorkspace = workspace(holding: slot) else { return }
|
|
targetWorkspace.layout = targetWorkspace.layout.replacing(slot, with: .leaf(pane))
|
|
targetWorkspace.focusedPaneID = paneID
|
|
select(targetWorkspace.id)
|
|
}
|
|
|
|
/// Lifts a pane out into a workspace of its own at `index`, or at the end of the
|
|
/// strip when there is none.
|
|
///
|
|
/// What dropping a pane on the workspace strip does — anywhere on it. The strip
|
|
/// takes a pane to mean one thing only, so there is no zone to aim for and no
|
|
/// second outcome a few pixels away.
|
|
///
|
|
/// The inverse of `merge`: a Workspace can be taken apart the way it was put
|
|
/// together. The new workspace gets its own conversation — the thread stays with
|
|
/// the Workspace that held it, the same choice `duplicateSelectedInNewWorkspace`
|
|
/// makes, because carrying it along would hand one thread's context to two
|
|
/// workspaces that no longer share a screen.
|
|
///
|
|
/// A no-op for a pane that is already alone in its workspace: there is nothing to
|
|
/// detach it from, and doing it anyway would be a reorder wearing the wrong
|
|
/// name. That also means the lift never removes a workspace, so an index worked out
|
|
/// before the call is still the right one after it.
|
|
@discardableResult
|
|
func detachPane(_ paneID: Pane.ID, at index: Int? = nil) -> Workspace? {
|
|
guard let pane = pane(paneID),
|
|
let source = workspace(holding: paneID),
|
|
source.panes.count > 1
|
|
else { return nil }
|
|
detach(paneID)
|
|
|
|
let workspace = automaticallyNamedWorkspace(pane: pane)
|
|
workspace.lifecycleRouter = self
|
|
workspaces.insert(workspace, at: min(index ?? workspaces.count, workspaces.count))
|
|
select(workspace.id)
|
|
return workspace
|
|
}
|
|
|
|
/// The inverse of `merge`: every pane of a split Workspace becomes a
|
|
/// Workspace of its own.
|
|
///
|
|
/// A Workspace is built by folding things together, so it has to be possible
|
|
/// to take one back apart in a single move — otherwise undoing a merge means
|
|
/// dragging each pane out to the Navigator one at a time, and the last one
|
|
/// cannot be dragged out at all.
|
|
///
|
|
/// The panes keep their layout order, and the first stays in the original
|
|
/// Workspace so the row you right-clicked is still there afterwards. The
|
|
/// sessions are untouched: this is a rearrangement, and nothing disconnects.
|
|
func separatePanes(in id: Workspace.ID) {
|
|
guard let source = workspaces.first(where: { $0.id == id }), source.isSplit,
|
|
let index = workspaces.firstIndex(where: { $0.id == id })
|
|
else { return }
|
|
|
|
// Front to back, each inserted just after the one before it. Detaching
|
|
// never removes the source — `detachPane` refuses a pane that is already
|
|
// alone — so this index stays correct for the whole run.
|
|
for (offset, pane) in source.panes.dropFirst().enumerated() {
|
|
detachPane(pane.id, at: index + 1 + offset)
|
|
}
|
|
select(id)
|
|
}
|
|
|
|
/// Takes a pane out of whatever holds it, closing that workspace if it was the
|
|
/// last one there.
|
|
private func detach(_ paneID: Pane.ID) {
|
|
guard let workspace = workspace(holding: paneID) else { return }
|
|
if !workspace.closePane(paneID) {
|
|
removeWorkspaceForMove(workspace.id)
|
|
}
|
|
}
|
|
|
|
/// Removes an emptied source workspace while its pane is being moved. This is not
|
|
/// a close: the same live session is about to be inserted elsewhere.
|
|
private func removeWorkspaceForMove(_ id: Workspace.ID) {
|
|
guard let index = workspaces.firstIndex(where: { $0.id == id }) else { return }
|
|
workspaces.remove(at: index)
|
|
if selectedWorkspaceID == id {
|
|
selectedDestination =
|
|
workspaces.indices.contains(index)
|
|
? .workspace(workspaces[index].id)
|
|
: workspaces.last.map { .workspace($0.id) } ?? .vault
|
|
}
|
|
}
|
|
|
|
func pane(_ id: Pane.ID) -> Pane? {
|
|
workspaces.lazy.compactMap { $0.layout.pane(id) }.first
|
|
}
|
|
|
|
func workspace(holding paneID: Pane.ID) -> Workspace? {
|
|
workspaces.first { $0.layout.pane(paneID) != nil }
|
|
}
|
|
|
|
// MARK: - Splitting and duplicating
|
|
|
|
/// Whether Split and Duplicate have anything to act on.
|
|
///
|
|
/// Both come down to the same question — is the focused pane something that
|
|
/// belongs in a Workspace — so both read the same predicate. Carving a slot
|
|
/// beside a manager page, or a second copy of a hole, are results nobody
|
|
/// asked for; better a disabled menu item than a command that quietly does
|
|
/// nothing. See `PaneKind.isCombinable`.
|
|
var canSplitSelected: Bool {
|
|
selectedWorkspace?.focusedPane.isCombinable ?? false
|
|
}
|
|
|
|
var canDuplicateSelected: Bool {
|
|
guard canSplitSelected, let workspace = selectedWorkspace else { return false }
|
|
return !workspace.panes.contains { $0.serialSession != nil }
|
|
}
|
|
|
|
/// Carves an empty slot beside the focused pane, for something already open
|
|
/// to be dragged into. Deliberately not a connection: choosing a layout and
|
|
/// choosing a machine are two decisions, and only one of them was asked for.
|
|
func splitSelected(axis: SplitAxis) {
|
|
guard let workspace = selectedWorkspace, canSplitSelected else { return }
|
|
workspace.splitFocusedPane(axis: axis, with: Pane(.empty))
|
|
}
|
|
|
|
/// Copies the focused pane beside itself, keeping its kind — a second shell
|
|
/// on the same host, a second browser on the same machine.
|
|
func duplicateSelected(axis: SplitAxis) {
|
|
guard let workspace = selectedWorkspace, let copy = copyOfFocusedPane() else { return }
|
|
workspace.splitFocusedPane(axis: axis, with: copy)
|
|
}
|
|
|
|
/// The same copy, in a workspace of its own — for when the second shell is a
|
|
/// separate job rather than something to watch side by side.
|
|
///
|
|
/// Pane-level, and named so, because the sidebar's rows are panes: "Duplicate
|
|
/// in New Workspace" on a row that names one shell has to mean that shell, even
|
|
/// when it lives in a Workspace with two others.
|
|
func duplicatePaneInNewWorkspace() {
|
|
guard let copy = copyOfFocusedPane() else { return }
|
|
open(automaticallyNamedWorkspace(pane: copy))
|
|
}
|
|
|
|
/// Workspace-level: a Workspace is copied whole — same layout, same divider
|
|
/// positions, fresh panes — and anything else copies its one pane.
|
|
///
|
|
/// The scope follows the surface. This is what the *workspace* menu and the menu
|
|
/// bar call, and both of those point at a workspace; a right-click on a Workspace
|
|
/// points at no particular pane, so duplicating whichever one the caret was
|
|
/// last in would be a coin toss dressed as a command. `duplicatePaneInNewWorkspace`
|
|
/// is the pane-level twin, for the sidebar.
|
|
///
|
|
/// The copy is the arrangement and nothing else. Names do not come along —
|
|
/// two workspaces called "prod cluster" is a collision, not a copy, the same
|
|
/// reasoning `Pane.copy` gives — and neither does `broadcastsInput`: a
|
|
/// duplicate that arrived already broadcasting would send the first thing
|
|
/// typed into it to shells nobody switched it on for.
|
|
func duplicateSelectedInNewWorkspace() {
|
|
guard let sourceWorkspace = selectedWorkspace else { return }
|
|
guard sourceWorkspace.isSplit else { return duplicatePaneInNewWorkspace() }
|
|
guard let layout = sourceWorkspace.layout.copied() else { return }
|
|
|
|
let duplicate = Workspace(pane: layout.firstPane)
|
|
duplicate.layout = layout
|
|
duplicate.focusedPaneID = layout.firstPane.id
|
|
open(duplicate)
|
|
}
|
|
|
|
func toggleBroadcastInput(in workspace: Workspace) {
|
|
workspace.broadcastsInput.toggle()
|
|
}
|
|
|
|
/// An unfilled slot is not something to make a second of, so this is
|
|
/// narrower than `Pane.copy` — which does copy one, because a Workspace
|
|
/// duplicated whole should arrive with the same hole in it.
|
|
private func copyOfFocusedPane() -> Pane? {
|
|
guard let source = selectedWorkspace?.focusedPane, source.isCombinable,
|
|
let copy = source.copy()
|
|
else { return nil }
|
|
return copy
|
|
}
|
|
|
|
private func automaticallyNamedWorkspace(pane: Pane, prefix: String? = nil) -> Workspace {
|
|
let workspace = Workspace(pane: pane)
|
|
guard let prefix = prefix ?? automaticNamePrefix(for: pane) else { return workspace }
|
|
workspace.automaticTitle = WorkspaceNameAllocator.nextName(
|
|
prefix: prefix,
|
|
occupiedNames: occupiedWorkspaceNames(),
|
|
automaticallyRenames: workspacePreferences().automaticallyRenamesWorkspaces)
|
|
return workspace
|
|
}
|
|
|
|
private func automaticNamePrefix(for pane: Pane) -> String? {
|
|
if let hostID = pane.hostID, let name = hostStore?.host(hostID)?.name { return name }
|
|
switch pane.content {
|
|
case .sshSession(let session): return session.title
|
|
case .telnetSession(let session): return session.title
|
|
case .sftpChannel: return nil
|
|
case .serialSession, .empty: return nil
|
|
}
|
|
}
|
|
|
|
private func occupiedWorkspaceNames(locale: Locale = .current, excluding excludedID: Workspace.ID? = nil)
|
|
-> [String]
|
|
{
|
|
let store = hostStore ?? HostLibraryModel()
|
|
return workspaces.filter { $0.id != excludedID }.map { $0.title(in: store, locale: locale) }
|
|
}
|
|
|
|
/// Moving preserves live transports and does not populate close history.
|
|
func takeWorkspaceForMove(_ id: Workspace.ID) -> Workspace? {
|
|
guard !isClosed, let workspace = workspaces.first(where: { $0.id == id }) else { return nil }
|
|
persistentClose.cancel()
|
|
removeWorkspaceForMove(id)
|
|
workspace.lifecycleRouter = nil
|
|
dragging = nil
|
|
if workspaces.isEmpty {
|
|
selectedDestination = .vault
|
|
vaultDestination = .welcome
|
|
vaultWorkRoute = nil
|
|
}
|
|
refreshSessionContext()
|
|
reconcileSidebarMode()
|
|
return workspace
|
|
}
|
|
|
|
/// Extracts live pane state only after another window is ready to own it.
|
|
/// A lone pane carries its existing workspace, including its assigned name.
|
|
func takePaneForMove(_ id: Pane.ID) -> Workspace? {
|
|
guard !isClosed, let pane = pane(id), pane.isCombinable,
|
|
let source = workspace(holding: id)
|
|
else { return nil }
|
|
persistentClose.cancel()
|
|
if source.panes.count == 1 { return takeWorkspaceForMove(source.id) }
|
|
guard source.closePane(id) else { return nil }
|
|
let workspace = automaticallyNamedWorkspace(pane: pane)
|
|
dragging = nil
|
|
refreshSessionContext()
|
|
reconcileSidebarMode()
|
|
return workspace
|
|
}
|
|
|
|
let persistentClose = RemoteSessionCloseCoordinator()
|
|
|
|
func authorizePersistentClose(_ sessions: [SSHSessionModel]) async -> Bool {
|
|
let workspaceIDs = Set(workspaces.map(\.id))
|
|
let paneIDs = Set(workspaces.flatMap(\.panes).map(\.id))
|
|
let references = sessions.compactMap { $0.sessionPersistence.attachment }
|
|
let allowed = await persistentClose.close(attachments: references) { [weak self] reference in
|
|
guard let self, let session = sessions.first(where: { $0.sessionPersistence.attachment == reference }),
|
|
self.workspaces.flatMap(\.panes).contains(where: { $0.sshSession === session })
|
|
else { throw CancellationError() }
|
|
try await session.terminatePersistentSession(reference) {
|
|
self.workspaces.flatMap(\.panes).contains(where: { $0.sshSession === session })
|
|
}
|
|
for session in sessions
|
|
where session.sessionPersistence.attachment?.hasSameIdentity(as: reference) == true {
|
|
session.sessionPersistence.markEnded()
|
|
}
|
|
}
|
|
// Confirmation covers this snapshot, never workspaces added or moved while it was open.
|
|
return allowed && workspaceIDs == Set(workspaces.map(\.id))
|
|
&& paneIDs == Set(workspaces.flatMap(\.panes).map(\.id))
|
|
}
|
|
|
|
private func deferPersistentClose(_ sessions: [SSHSessionModel], action: @escaping @MainActor () -> Void) -> Bool {
|
|
guard sessions.contains(where: { $0.sessionPersistence.attachment?.isManaged == true }) else { return false }
|
|
guard !persistentClose.isClosing else { return true }
|
|
Task { if await authorizePersistentClose(sessions) { action() } }
|
|
return true
|
|
}
|
|
|
|
func closeForWindow() {
|
|
guard !isClosed else { return }
|
|
// Window dismissal disconnects clients but preserves remote sessions and their recovery references.
|
|
persistentClose.cancel()
|
|
rememberClosedWindow?(workspaces, selectedWorkspaceID)
|
|
isClosed = true
|
|
for workspace in workspaces {
|
|
workspace.disconnectConnections()
|
|
workspace.lifecycleRouter = nil
|
|
}
|
|
workspaces.removeAll()
|
|
dragging = nil
|
|
presentations.route = nil
|
|
transcriptCache.removeAll()
|
|
focusToolbarFindField = nil
|
|
selectedDestination = .vault
|
|
refreshSessionContext()
|
|
}
|
|
|
|
// MARK: - Closing
|
|
|
|
func close(_ id: Workspace.ID, authorized: Bool = false) {
|
|
if !authorized, let target = workspaces.first(where: { $0.id == id }),
|
|
deferPersistentClose(
|
|
target.panes.compactMap(\.sshSession), action: { [weak self] in self?.close(id, authorized: true) })
|
|
{
|
|
return
|
|
}
|
|
guard let index = workspaces.firstIndex(where: { $0.id == id }) else { return }
|
|
let removed = workspaces.remove(at: index)
|
|
removed.disconnectConnections()
|
|
rememberClosedWorkspace?(removed)
|
|
|
|
if selectedWorkspaceID == id {
|
|
// Select the neighbour that visually takes its place.
|
|
if workspaces.indices.contains(index) {
|
|
selectedDestination = .workspace(workspaces[index].id)
|
|
} else if let neighbour = workspaces.last {
|
|
selectedDestination = .workspace(neighbour.id)
|
|
} else {
|
|
promoteVisibleSidebarConversationOrSelectVault()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// ⌘W: closes the focused *pane* when the workspace is split, otherwise the workspace.
|
|
/// Both cases use the same recoverable close path as the pane's Close button.
|
|
func closeSelected() {
|
|
guard let workspace = selectedWorkspace else { return }
|
|
closePane(workspace.focusedPaneID)
|
|
}
|
|
|
|
func closeOthers(than id: Workspace.ID, authorized: Bool = false) {
|
|
if !authorized,
|
|
deferPersistentClose(
|
|
workspaces.filter { $0.id != id }.flatMap(\.panes).compactMap(\.sshSession),
|
|
action: { [weak self] in self?.closeOthers(than: id, authorized: true) })
|
|
{
|
|
return
|
|
}
|
|
for workspace in workspaces where workspace.id != id {
|
|
workspace.disconnectConnections()
|
|
rememberClosedWorkspace?(workspace)
|
|
}
|
|
workspaces.removeAll { $0.id != id }
|
|
selectedWorkspaceID = id
|
|
}
|
|
|
|
func closeAll(intent: TerminalTeardownIntent = .explicitClose, authorized: Bool = false) {
|
|
if !intent.endsManagedSession { persistentClose.cancel() }
|
|
if !authorized, intent.endsManagedSession,
|
|
deferPersistentClose(
|
|
workspaces.flatMap(\.panes).compactMap(\.sshSession),
|
|
action: { [weak self] in self?.closeAll(authorized: true) })
|
|
{
|
|
return
|
|
}
|
|
for workspace in workspaces {
|
|
workspace.disconnectConnections()
|
|
rememberClosedWorkspace?(workspace)
|
|
}
|
|
workspaces.removeAll()
|
|
promoteVisibleSidebarConversationOrSelectVault()
|
|
}
|
|
|
|
private func promoteVisibleSidebarConversationOrSelectVault() {
|
|
guard sidebarMode == .assistant else {
|
|
selectedDestination = .vault
|
|
return
|
|
}
|
|
if isAssistantArchivePresented {
|
|
selectedDestination = .assistant
|
|
} else if let conversationID = assistantLibrary.selectedConversationID,
|
|
assistantLibrary.conversation(id: conversationID) != nil
|
|
{
|
|
openAssistantFullScreen(conversationID)
|
|
} else {
|
|
selectedDestination = .vault
|
|
}
|
|
}
|
|
|
|
/// Closes one pane, and the Workspace with it when that was the last one.
|
|
///
|
|
/// The sidebar's rows are panes, so its Close acts on a pane — closing the
|
|
/// whole Workspace because you dismissed one of its three shells would take
|
|
/// two things you did not point at.
|
|
func closePane(_ paneID: Pane.ID, authorized: Bool = false) {
|
|
guard let workspace = workspace(holding: paneID), let pane = workspace.layout.pane(paneID) else { return }
|
|
guard workspace.isSplit else {
|
|
close(workspace.id, authorized: authorized)
|
|
return
|
|
}
|
|
if !authorized,
|
|
deferPersistentClose(
|
|
[pane.sshSession].compactMap { $0 },
|
|
action: { [weak self] in self?.closePane(paneID, authorized: true) })
|
|
{
|
|
return
|
|
}
|
|
pane.disconnectConnection()
|
|
_ = workspace.closePane(paneID)
|
|
// Keep only the removed pane: reopening must not reconnect surviving
|
|
// sessions or replace edits made to the original split after the close.
|
|
let closedWorkspace = automaticallyNamedWorkspace(pane: pane)
|
|
// The saved wrapper must not route late events from the disconnected
|
|
// session. prepareForReopen restores routing when it becomes live again.
|
|
pane.sshSession?.inputRouter = nil
|
|
pane.sftpChannel?.navigationRouter = nil
|
|
rememberClosedWorkspace?(closedWorkspace)
|
|
}
|
|
|
|
func closeWorkspaces(for hostID: Host.ID) {
|
|
let paneIDs = workspaces.flatMap(\.panes).filter { $0.hostID == hostID }.map(\.id)
|
|
for paneID in paneIDs { closePane(paneID) }
|
|
switch vaultWorkRoute {
|
|
case .hostEditor(let editingID, _, _) where editingID == hostID:
|
|
closeVaultWork()
|
|
case .tailscaleDetails(let detailID) where detailID == hostID:
|
|
closeVaultWork()
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
// MARK: - Reordering
|
|
|
|
/// Drag-reorder in the Navigator: puts `id` at `index` in the Workspace
|
|
/// list after excluding the source workspace from insertion-index geometry.
|
|
///
|
|
/// Remove-then-insert rather than `move(fromOffsets:toOffset:)`. The index
|
|
/// comes from the other visible rows; the offset form would read it as an index into
|
|
/// the list that still contains it, and land one place short of the preview
|
|
/// every time the workspace moved rightwards.
|
|
func move(_ id: Workspace.ID, to index: Int) {
|
|
guard let from = workspaces.firstIndex(where: { $0.id == id }) else { return }
|
|
let workspace = workspaces.remove(at: from)
|
|
workspaces.insert(workspace, at: min(max(index, 0), workspaces.count))
|
|
}
|
|
|
|
/// A manual name for a single-pane Workspace lives on its pane, while its
|
|
/// generated name remains on the Workspace. A split Workspace names the group.
|
|
///
|
|
/// Manager pages, forms and a host's tunnels refuse outright — see
|
|
/// `Workspace.isRenameable`. The guard is here as well as in the menu so the
|
|
/// rule is a property of the model rather than of one view remembering to
|
|
/// ask.
|
|
func rename(_ id: Workspace.ID, to title: String) {
|
|
guard let workspace = workspaces.first(where: { $0.id == id }), workspace.isRenameable else { return }
|
|
let cleaned = Self.cleaned(title)
|
|
if workspace.isSplit {
|
|
if let cleaned {
|
|
workspace.customTitle = cleaned
|
|
} else {
|
|
let prefix = AppLocalization.resolve(AppStrings.Workspace.workspace, locale: .current)
|
|
let automatic = WorkspaceNameAllocator.nextName(
|
|
prefix: prefix,
|
|
occupiedNames: occupiedWorkspaceNames(excluding: workspace.id),
|
|
automaticallyRenames: workspacePreferences().automaticallyRenamesWorkspaces)
|
|
workspace.customTitle = automatic == prefix ? nil : automatic
|
|
}
|
|
} else {
|
|
workspace.focusedPane.customTitle = cleaned
|
|
if cleaned == nil, let prefix = automaticNamePrefix(for: workspace.focusedPane) {
|
|
workspace.automaticTitle = WorkspaceNameAllocator.nextName(
|
|
prefix: prefix,
|
|
occupiedNames: occupiedWorkspaceNames(excluding: workspace.id),
|
|
automaticallyRenames: workspacePreferences().automaticallyRenamesWorkspaces)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Panes are renamed from their sidebar row. Empty clears the name rather
|
|
/// than setting a blank one, so there is one way back to the derived title
|
|
/// and it is the obvious one.
|
|
func rename(pane id: Pane.ID, to title: String) {
|
|
guard let workspace = workspaces.first(where: { $0.layout.pane(id) != nil }),
|
|
let pane = workspace.layout.pane(id)
|
|
else { return }
|
|
let cleaned = Self.cleaned(title)
|
|
pane.customTitle = cleaned
|
|
if cleaned == nil, !workspace.isSplit, let prefix = automaticNamePrefix(for: pane) {
|
|
workspace.automaticTitle = WorkspaceNameAllocator.nextName(
|
|
prefix: prefix,
|
|
occupiedNames: occupiedWorkspaceNames(excluding: workspace.id),
|
|
automaticallyRenames: workspacePreferences().automaticallyRenamesWorkspaces)
|
|
}
|
|
}
|
|
|
|
private static func cleaned(_ title: String) -> String? {
|
|
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
}
|
|
|
|
extension WindowModel: WorkspaceLifecycleRouting {
|
|
func workspace(_ workspace: Workspace, closePaneAfterSSHSessionExit paneID: Pane.ID) {
|
|
guard workspaces.contains(where: { $0 === workspace }),
|
|
workspace.layout.pane(paneID) != nil
|
|
else { return }
|
|
closePane(paneID)
|
|
}
|
|
|
|
func workspaceContextDidChange(_ workspace: Workspace) {
|
|
commandFocusChanged?()
|
|
guard selectedWorkspace === workspace else { return }
|
|
refreshSessionContext()
|
|
reconcileSidebarMode()
|
|
}
|
|
}
|
|
|
|
// MARK: - Dragging
|
|
|
|
/// What a drag in the workspace bar or the pane area is carrying.
|
|
///
|
|
/// Two cases because the same drop target answers both: dropping a *workspace* on a
|
|
/// pane folds that whole Workspace in, while dropping a *pane* moves just that
|
|
/// one out of wherever it was.
|
|
nonisolated enum DragPayload: Hashable, Sendable {
|
|
case workspace(UUID)
|
|
case pane(UUID)
|
|
case assistantConversation(UUID)
|
|
}
|
|
|
|
// MARK: - Sidebar rows
|
|
|
|
/// One pane, with the Workspace holding it — what a sidebar row is.
|
|
///
|
|
/// A pair rather than a back-pointer on `Pane`: a pane does not otherwise need
|
|
/// to know which workspace owns it, and a reference cycle for the sake of the sidebar
|
|
/// would be paying in the model for something the view can compute.
|
|
struct PaneRef: Identifiable {
|
|
let pane: Pane
|
|
|
|
var id: Pane.ID { pane.id }
|
|
|
|
}
|