From b5c8f6a5f5c4a9c8f04e32663f1579170577971b Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:21:54 -0400 Subject: [PATCH 1/8] app: extract shared connection presentation --- Remux.xcodeproj/project.pbxproj | 7 +++ .../Sources/App/ConnectionPresentation.swift | 7 +++ RemuxApp/Sources/App/RootView.swift | 54 ++----------------- .../App/TerminalRuntimeStateIndicator.swift | 46 ++++++++++++++++ 4 files changed, 64 insertions(+), 50 deletions(-) create mode 100644 RemuxApp/Sources/App/ConnectionPresentation.swift create mode 100644 RemuxApp/Sources/App/TerminalRuntimeStateIndicator.swift diff --git a/Remux.xcodeproj/project.pbxproj b/Remux.xcodeproj/project.pbxproj index c0002c20..5e2b4507 100644 --- a/Remux.xcodeproj/project.pbxproj +++ b/Remux.xcodeproj/project.pbxproj @@ -45,6 +45,7 @@ 38C6D9FC205E5E2189D61E4C /* GhosttyAttachmentTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8151E185EE3CC5784181D01E /* GhosttyAttachmentTransfer.swift */; }; 3959173AC4F6B2A7849A6A1E /* GhosttyAttachmentStagingStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A9A68C878571836AC7EDA41 /* GhosttyAttachmentStagingStoreTests.swift */; }; 39A58132CBB849E2CDF37BC2 /* DebugConnectionProfileSeeder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 700C6FB2B5DB6D0645B1F5FA /* DebugConnectionProfileSeeder.swift */; }; + 3C0580E8F8A18447EE09DB44 /* ConnectionPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5087B7872EECF17538D23AF9 /* ConnectionPresentation.swift */; }; 3C845A0E1D2E5BE8627CA4DF /* GhosttySurfaceStatusOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E94EB8EAE8524275106704 /* GhosttySurfaceStatusOverlay.swift */; }; 3E192E1F4E9DEC7691077660 /* GhosttyTerminalDisconnectReasonClassifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6195E1BD742AC775F143F912 /* GhosttyTerminalDisconnectReasonClassifierTests.swift */; }; 3F60EA4DB041B4296572C2AB /* RemuxPreparedTransportCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A245FF40A1998FA618E6370 /* RemuxPreparedTransportCacheTests.swift */; }; @@ -55,6 +56,7 @@ 44CA2BB4387DB7CC530AC84C /* RemuxSmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87358B27991E5D2ECE199ECC /* RemuxSmokeTests.swift */; }; 44D949999FE921E140F6E4D5 /* SSHPrivateKeyInspectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C925D814F660B71813EB84 /* SSHPrivateKeyInspectorTests.swift */; }; 452A533BD9FA3A594F9C3F28 /* TmuxSessionControllerClientSizeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63BEFB87916C69E34AD0D481 /* TmuxSessionControllerClientSizeTests.swift */; }; + 45490159CE92B33F95A59CBC /* TerminalRuntimeStateIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8E224CBCEC9520C101DFC0 /* TerminalRuntimeStateIndicator.swift */; }; 4664BA5BF9C8D90E67CC459F /* SSHTmuxControlChannelRequestTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C278E39F3479557BA84AEF81 /* SSHTmuxControlChannelRequestTracker.swift */; }; 4809C175892171E7B13557EF /* TmuxControlTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00BF7405360DC035455D7ACE /* TmuxControlTransport.swift */; }; 48124543E4A60E49F2B1051B /* GhosttyTerminalResponderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB7372853BCE26F35945D24A /* GhosttyTerminalResponderView.swift */; }; @@ -260,6 +262,7 @@ 4B3782AB19C138622144EE40 /* RemuxPreparedTransportCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemuxPreparedTransportCoordinatorTests.swift; sourceTree = ""; }; 4D407500AB027C206B65EE7C /* GhosttyTerminalPresentationProjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalPresentationProjector.swift; sourceTree = ""; }; 4E84F774C17B5C4DB6B715A9 /* GhosttySurfaceKeyEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySurfaceKeyEventTests.swift; sourceTree = ""; }; + 5087B7872EECF17538D23AF9 /* ConnectionPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionPresentation.swift; sourceTree = ""; }; 532E9E62CF3E74FEE31F5C05 /* GhosttyTopLevelSurfaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTopLevelSurfaceTests.swift; sourceTree = ""; }; 5370A2DE2689DAF5E27E57FD /* TmuxPanePreviewImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxPanePreviewImageCache.swift; sourceTree = ""; }; 575EDAA61B2B7A0B241A5E4D /* TmuxSessionLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxSessionLink.swift; sourceTree = ""; }; @@ -357,6 +360,7 @@ DAE4DA1C9532D675CB9E87C4 /* GhosttyPaneScrollContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyPaneScrollContainerView.swift; sourceTree = ""; }; DB62707ACD4905DCE40ECBFA /* RemuxPreparedTransportCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemuxPreparedTransportCoordinator.swift; sourceTree = ""; }; DB8184BF6C8C3A418E9BD893 /* TmuxTerminalSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxTerminalSession.swift; sourceTree = ""; }; + DB8E224CBCEC9520C101DFC0 /* TerminalRuntimeStateIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRuntimeStateIndicator.swift; sourceTree = ""; }; DC2BCC7F60325417F2E882FF /* SSHTmuxControlChannelDataRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHTmuxControlChannelDataRouter.swift; sourceTree = ""; }; DC3FA910C9C3B9A094B8497A /* GhosttyRuntimeSurfaceTopologySnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyRuntimeSurfaceTopologySnapshot.swift; sourceTree = ""; }; DCC5D29DF530DB8A90FA8A60 /* GhosttyAttachmentPreviewStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyAttachmentPreviewStyle.swift; sourceTree = ""; }; @@ -449,6 +453,7 @@ D20F57C8C6D3F75E3E07DDC9 /* RemuxRootModel.swift */, 0CBF1D7B0CCEAA8D032C1DDC /* RootView.swift */, 1970DDCD2CEF7C163B953BAC /* SSHAuthResolver.swift */, + DB8E224CBCEC9520C101DFC0 /* TerminalRuntimeStateIndicator.swift */, 267386EB1A94C2A6029D90F6 /* TerminalRuntimeStatusPresentation.swift */, ); path = App; @@ -840,6 +845,7 @@ buildActionMask = 2147483647; files = ( 3F71D9F3D7782DCD769CF47E /* ApplicationStorage.swift in Sources */, + 3C0580E8F8A18447EE09DB44 /* ConnectionPresentation.swift in Sources */, E35A4C6758E1D06E01EE5826 /* ConnectionProfileRepository.swift in Sources */, 39A58132CBB849E2CDF37BC2 /* DebugConnectionProfileSeeder.swift in Sources */, 3266F0AE9A1F836618FF115D /* DeterministicTmuxControlTransport.swift in Sources */, @@ -931,6 +937,7 @@ 56BB24F517CE55F41BB4E0D4 /* TerminalPreviewSession.swift in Sources */, 9DFCAA9DF3EF505BC8112CEF /* TerminalPreviewStaticHTML.swift in Sources */, 31FA4F4D3F831DE0DEAB770F /* TerminalPreviewView.swift in Sources */, + 45490159CE92B33F95A59CBC /* TerminalRuntimeStateIndicator.swift in Sources */, D139ABC351802768520D8A4F /* TerminalRuntimeStatusPresentation.swift in Sources */, 0494A11A7D6207165CFCDAD7 /* TerminalSettings.swift in Sources */, A0D6CF5A40A4953636A1928F /* TerminalSettingsRepository.swift in Sources */, diff --git a/RemuxApp/Sources/App/ConnectionPresentation.swift b/RemuxApp/Sources/App/ConnectionPresentation.swift new file mode 100644 index 00000000..503ab393 --- /dev/null +++ b/RemuxApp/Sources/App/ConnectionPresentation.swift @@ -0,0 +1,7 @@ +import Foundation + +extension SavedServer { + var displayAddress: String { + "\(username)@\(host)\(port == 22 ? "" : ":\(port)")" + } +} diff --git a/RemuxApp/Sources/App/RootView.swift b/RemuxApp/Sources/App/RootView.swift index d585c9e1..a81bb644 100644 --- a/RemuxApp/Sources/App/RootView.swift +++ b/RemuxApp/Sources/App/RootView.swift @@ -665,7 +665,6 @@ private enum LibraryHomePalette { static let controlAccent = Color(uiColor: .libraryHomeControlAccent) static let rowIconForeground = Color(uiColor: .libraryHomeRowIconForeground) static let rowIconSurface = Color(uiColor: .libraryHomeRowIconSurface) - static let connectedStatus = Color(uiColor: .libraryHomeConnectedStatus) } private extension TerminalTheme { @@ -786,14 +785,6 @@ private extension UIColor { } } - static let libraryHomeConnectedStatus = UIColor { traits in - switch traits.userInterfaceStyle { - case .dark: - UIColor(red: 0.43, green: 0.89, blue: 0.66, alpha: 1.0) - default: - .systemGreen - } - } } private struct ServerDetailView: View { @@ -813,7 +804,7 @@ private struct ServerDetailView: View { VStack(alignment: .leading, spacing: 5) { Text("Address") - Text(serverAddress(server)) + Text(server.displayAddress) .font(.footnote.monospaced()) .foregroundStyle(.secondary) .lineLimit(2) @@ -1002,7 +993,7 @@ private struct ActiveSessionLibraryRow: View { Spacer() - RuntimeStateIndicator(state: session.runtimeState) + TerminalRuntimeStateIndicator(state: session.runtimeState) Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) @@ -1047,7 +1038,7 @@ private struct SessionLibraryRow: View { Spacer() if let runtimeState { - RuntimeStateIndicator(state: runtimeState) + TerminalRuntimeStateIndicator(state: runtimeState) } Image(systemName: "chevron.right") @@ -1100,7 +1091,7 @@ private struct ServerLibraryRow: View { .font(.headline) .lineLimit(1) - Text(serverAddress(server)) + Text(server.displayAddress) .font(.footnote.monospaced()) .foregroundStyle(.secondary) .lineLimit(1) @@ -1125,43 +1116,6 @@ private struct ServerLibraryRow: View { } } -private struct RuntimeStateIndicator: View { - let state: TerminalRuntimeState - - private var presentation: TerminalRuntimeStatusPresentation { - TerminalRuntimeStatusPresentation.projection(for: state) - } - - var body: some View { - HStack(spacing: 4) { - Circle() - .fill(color) - .frame(width: 6, height: 6) - Text(presentation.label) - .font(.caption.weight(.medium)) - } - .foregroundStyle(color) - .accessibilityElement(children: .combine) - } - - private var color: Color { - switch presentation.tone { - case .connecting: - .blue - case .reconnecting: - .orange - case .connected: - LibraryHomePalette.connectedStatus - case .disconnected: - .red - } - } -} - -private func serverAddress(_ server: SavedServer) -> String { - "\(server.username)@\(server.host)\(server.port == 22 ? "" : ":\(server.port)")" -} - private func serverSummary( sessionCount: Int, connectedSessionCount: Int, diff --git a/RemuxApp/Sources/App/TerminalRuntimeStateIndicator.swift b/RemuxApp/Sources/App/TerminalRuntimeStateIndicator.swift new file mode 100644 index 00000000..0584df57 --- /dev/null +++ b/RemuxApp/Sources/App/TerminalRuntimeStateIndicator.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct TerminalRuntimeStateIndicator: View { + let state: TerminalRuntimeState + + private var presentation: TerminalRuntimeStatusPresentation { + TerminalRuntimeStatusPresentation.projection(for: state) + } + + var body: some View { + HStack(spacing: 4) { + Circle() + .fill(color) + .frame(width: 6, height: 6) + + Text(presentation.label) + .font(.caption.weight(.medium)) + } + .foregroundStyle(color) + .accessibilityElement(children: .combine) + } + + private var color: Color { + switch presentation.tone { + case .connecting: + .blue + case .reconnecting: + .orange + case .connected: + Color(uiColor: .remuxConnectedStatus) + case .disconnected: + .red + } + } +} + +private extension UIColor { + static let remuxConnectedStatus = UIColor { traits in + switch traits.userInterfaceStyle { + case .dark: + UIColor(red: 0.43, green: 0.89, blue: 0.66, alpha: 1.0) + default: + .systemGreen + } + } +} From c44adce27b15b97381bfd0253251f5359cb7aab7 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:24:07 -0400 Subject: [PATCH 2/8] app: return cancelled session setup to its origin --- RemuxApp/Sources/App/RemuxRootModel.swift | 170 +++++++++++++++------- RemuxApp/Sources/App/RootView.swift | 20 ++- RemuxAppTests/RemuxRootModelTests.swift | 151 +++++++++++++------ 3 files changed, 242 insertions(+), 99 deletions(-) diff --git a/RemuxApp/Sources/App/RemuxRootModel.swift b/RemuxApp/Sources/App/RemuxRootModel.swift index 69c4225e..c07c839b 100644 --- a/RemuxApp/Sources/App/RemuxRootModel.swift +++ b/RemuxApp/Sources/App/RemuxRootModel.swift @@ -120,10 +120,34 @@ final class RemuxRootModel: ObservableObject { } } + enum SetupCancelDestination: Equatable { + case library + case terminal(SavedWorkspace.ID) + } + + struct ConnectionSetupState: Equatable { + var draft: TmuxConnectionDraft + var validation: TmuxConnectionDraftValidation + let mode: SetupMode + let cancelDestination: SetupCancelDestination + + init( + draft: TmuxConnectionDraft, + validation: TmuxConnectionDraftValidation = .empty, + mode: SetupMode, + cancelDestination: SetupCancelDestination = .library + ) { + self.draft = draft + self.validation = validation + self.mode = mode + self.cancelDestination = cancelDestination + } + } + enum State: Equatable { case loading case library - case setup(TmuxConnectionDraft, TmuxConnectionDraftValidation, SetupMode) + case setup(ConnectionSetupState) case terminal(SavedWorkspace.ID) case failed(String) } @@ -211,10 +235,18 @@ final class RemuxRootModel: ObservableObject { } func beginNewServer() { - state = .setup(TmuxConnectionDraft(), .empty, .newServer) + state = .setup( + ConnectionSetupState( + draft: TmuxConnectionDraft(), + mode: .newServer + ) + ) } - func beginNewWorkspace(for serverID: SavedServer.ID) async { + func beginNewWorkspace( + for serverID: SavedServer.ID, + cancelDestination: SetupCancelDestination = .library + ) async { guard let server = library.server(id: serverID) else { return } let workspace = SavedWorkspace( @@ -222,12 +254,26 @@ final class RemuxRootModel: ObservableObject { sessionName: "" ) state = .setup( - TmuxConnectionDraft(server: server, workspace: workspace), - .empty, - .newWorkspace(serverID) + ConnectionSetupState( + draft: TmuxConnectionDraft(server: server, workspace: workspace), + mode: .newWorkspace(serverID), + cancelDestination: cancelDestination + ) ) } + func cancelSetup() async { + guard case .setup(let setup) = state else { return } + + switch setup.cancelDestination { + case .library: + await showLibrary() + + case .terminal(let workspaceID): + showActiveSession(workspaceID) + } + } + func beginEditServer(serverID: SavedServer.ID) async { await beginEditServer(serverID: serverID, reconnectWorkspaceID: nil) } @@ -266,14 +312,15 @@ final class RemuxRootModel: ObservableObject { ?? library.workspaces(for: serverID).first ?? SavedWorkspace(serverID: serverID, sessionName: "") state = .setup( - TmuxConnectionDraft( - server: server, - workspace: workspace, - identity: identity, - credential: credential - ), - .empty, - .editServer(serverID, reconnectWorkspaceID: reconnectWorkspaceID) + ConnectionSetupState( + draft: TmuxConnectionDraft( + server: server, + workspace: workspace, + identity: identity, + credential: credential + ), + mode: .editServer(serverID, reconnectWorkspaceID: reconnectWorkspaceID) + ) ) } @@ -286,47 +333,49 @@ final class RemuxRootModel: ObservableObject { } state = .setup( - TmuxConnectionDraft(server: server, workspace: workspace), - .empty, - .editWorkspace(serverID, workspaceID) + ConnectionSetupState( + draft: TmuxConnectionDraft(server: server, workspace: workspace), + mode: .editWorkspace(serverID, workspaceID) + ) ) } func updateDraft(_ mutation: (inout TmuxConnectionDraft) -> Void) { - guard case .setup(var draft, let validation, let mode) = state else { return } - mutation(&draft) - state = .setup(draft, validation, mode) + guard case .setup(var setup) = state else { return } + mutation(&setup.draft) + state = .setup(setup) } func saveAndConnect() async { - guard case .setup(let draft, _, let mode) = state else { return } + guard case .setup(let setup) = state else { return } - switch mode { + switch setup.mode { case .editServer(let serverID, let reconnectWorkspaceID): - await saveServer(draft, serverID: serverID, reconnectWorkspaceID: reconnectWorkspaceID, mode: mode) + await saveServer( + setup, + serverID: serverID, + reconnectWorkspaceID: reconnectWorkspaceID + ) case .editWorkspace(let serverID, let workspaceID): - await saveWorkspace(draft, serverID: serverID, workspaceID: workspaceID, mode: mode) + await saveWorkspace(setup, serverID: serverID, workspaceID: workspaceID) case .newServer: - await saveProfileAndConnect(draft, mode: mode) + await saveProfileAndConnect(setup) case .newWorkspace(let serverID): - await saveNewWorkspaceAndConnect(draft, serverID: serverID, mode: mode) + await saveNewWorkspaceAndConnect(setup, serverID: serverID) } } - private func saveProfileAndConnect( - _ draft: TmuxConnectionDraft, - mode: SetupMode - ) async { + private func saveProfileAndConnect(_ setup: ConnectionSetupState) async { switch TmuxConnectionDraftValidator.validate( - draft, - existingServerID: mode.existingServerID, - existingWorkspaceID: mode.existingWorkspaceID + setup.draft, + existingServerID: setup.mode.existingServerID, + existingWorkspaceID: setup.mode.existingWorkspaceID ) { case .invalid(let validation): - state = .setup(draft, validation, mode) + state = .setup(setupWithValidation(validation, from: setup)) case .valid(let submission): let identityCredential: SSHIdentityCredentialPair @@ -334,9 +383,10 @@ final class RemuxRootModel: ObservableObject { identityCredential = try makeIdentityCredentialPair(from: submission.server) } catch { state = .setup( - draft, - privateKeyValidation(from: error), - mode + setupWithValidation( + privateKeyValidation(from: error), + from: setup + ) ) return } @@ -369,14 +419,16 @@ final class RemuxRootModel: ObservableObject { } private func saveServer( - _ draft: TmuxConnectionDraft, + _ setup: ConnectionSetupState, serverID: SavedServer.ID, - reconnectWorkspaceID: SavedWorkspace.ID?, - mode: SetupMode + reconnectWorkspaceID: SavedWorkspace.ID? ) async { - switch TmuxConnectionDraftValidator.validateServer(draft, existingServerID: serverID) { + switch TmuxConnectionDraftValidator.validateServer( + setup.draft, + existingServerID: serverID + ) { case .invalid(let validation): - state = .setup(draft, validation, mode) + state = .setup(setupWithValidation(validation, from: setup)) case .valid(let submission): let updatedIdentityCredential: SSHIdentityCredentialPair @@ -394,7 +446,12 @@ final class RemuxRootModel: ObservableObject { existingIdentity: identity ) } catch let error as SSHPrivateKeyInspectionError { - state = .setup(draft, privateKeyValidation(from: error), mode) + state = .setup( + setupWithValidation( + privateKeyValidation(from: error), + from: setup + ) + ) return } catch { transitionToFailed(error) @@ -464,17 +521,16 @@ final class RemuxRootModel: ObservableObject { } private func saveNewWorkspaceAndConnect( - _ draft: TmuxConnectionDraft, - serverID: SavedServer.ID, - mode: SetupMode + _ setup: ConnectionSetupState, + serverID: SavedServer.ID ) async { switch TmuxConnectionDraftValidator.validateWorkspace( - draft, + setup.draft, serverID: serverID, existingWorkspaceID: nil ) { case .invalid(let validation): - state = .setup(draft, validation, mode) + state = .setup(setupWithValidation(validation, from: setup)) case .valid(let submission): do { @@ -501,18 +557,17 @@ final class RemuxRootModel: ObservableObject { } private func saveWorkspace( - _ draft: TmuxConnectionDraft, + _ setup: ConnectionSetupState, serverID: SavedServer.ID, - workspaceID: SavedWorkspace.ID, - mode: SetupMode + workspaceID: SavedWorkspace.ID ) async { switch TmuxConnectionDraftValidator.validateWorkspace( - draft, + setup.draft, serverID: serverID, existingWorkspaceID: workspaceID ) { case .invalid(let validation): - state = .setup(draft, validation, mode) + state = .setup(setupWithValidation(validation, from: setup)) case .valid(let submission): do { @@ -537,6 +592,15 @@ final class RemuxRootModel: ObservableObject { } } + private func setupWithValidation( + _ validation: TmuxConnectionDraftValidation, + from setup: ConnectionSetupState + ) -> ConnectionSetupState { + var updated = setup + updated.validation = validation + return updated + } + func connect(to workspaceID: SavedWorkspace.ID) async { let flow = sessionOpenFlowID(workspaceID) GhosttyRuntimeTrace.flowEvent( diff --git a/RemuxApp/Sources/App/RootView.swift b/RemuxApp/Sources/App/RootView.swift index a81bb644..e0716080 100644 --- a/RemuxApp/Sources/App/RootView.swift +++ b/RemuxApp/Sources/App/RootView.swift @@ -151,19 +151,19 @@ private struct RemuxWorkspaceShell: View { case .library: libraryStack - case .setup(let draft, let validation, let mode): + case .setup(let setup): NavigationStack { ConnectionSetupView( - draft: draft, - validation: validation, - mode: mode, + draft: setup.draft, + validation: setup.validation, + mode: setup.mode, terminalTheme: model.terminalSettings.theme, onChange: model.updateDraft, onConnect: { Task { await model.saveAndConnect() } }, onCancel: { - Task { await model.showLibrary() } + Task { await model.cancelSetup() } } ) } @@ -251,6 +251,16 @@ private struct RemuxWorkspaceShell: View { "session.show.\(workspaceID.uuidString)" } + private func beginNewWorkspaceFromTerminal(on serverID: SavedServer.ID) { + guard let selectedTerminalID else { return } + Task { + await model.beginNewWorkspace( + for: serverID, + cancelDestination: .terminal(selectedTerminalID) + ) + } + } + } struct RemuxAppLifecycleProjection: Equatable { diff --git a/RemuxAppTests/RemuxRootModelTests.swift b/RemuxAppTests/RemuxRootModelTests.swift index a553d988..a5c8c8c9 100644 --- a/RemuxAppTests/RemuxRootModelTests.swift +++ b/RemuxAppTests/RemuxRootModelTests.swift @@ -475,17 +475,17 @@ final class RemuxRootModelTests: XCTestCase { await harness.model.beginNewWorkspace(for: server.id) - guard case .setup(let draft, let validation, let mode) = harness.model.state else { + guard case .setup(let setup) = harness.model.state else { XCTFail("expected setup state") return } - XCTAssertEqual(draft.displayName, server.displayName) - XCTAssertEqual(draft.host, server.host) - XCTAssertEqual(draft.username, server.username) - XCTAssertEqual(draft.sessionName, "") - XCTAssertEqual(validation, .empty) - XCTAssertEqual(mode, .newWorkspace(server.id)) + XCTAssertEqual(setup.draft.displayName, server.displayName) + XCTAssertEqual(setup.draft.host, server.host) + XCTAssertEqual(setup.draft.username, server.username) + XCTAssertEqual(setup.draft.sessionName, "") + XCTAssertEqual(setup.validation, .empty) + XCTAssertEqual(setup.mode, .newWorkspace(server.id)) } func testBeginNewWorkspaceDoesNotGenerateSessionNameFromExistingWorkspaces() async throws { @@ -506,14 +506,83 @@ final class RemuxRootModelTests: XCTestCase { await harness.model.beginNewWorkspace(for: server.id) - guard case .setup(let draft, let validation, let mode) = harness.model.state else { + guard case .setup(let setup) = harness.model.state else { XCTFail("expected setup state") return } - XCTAssertEqual(draft.sessionName, "") - XCTAssertEqual(validation, .empty) - XCTAssertEqual(mode, .newWorkspace(server.id)) + XCTAssertEqual(setup.draft.sessionName, "") + XCTAssertEqual(setup.validation, .empty) + XCTAssertEqual(setup.mode, .newWorkspace(server.id)) + } + + func testCancelNewWorkspaceFromTerminalReturnsToOriginatingSession() async throws { + let passwordBackedServer = makePasswordBackedServer() + let server = passwordBackedServer.server + let workspace = SavedWorkspace(serverID: server.id, sessionName: "base") + let harness = makeHarness( + servers: [server], + workspaces: [workspace], + identities: [passwordBackedServer.identity] + ) + try await harness.credentialStore.saveCredential( + .password("demo-password"), + identityID: passwordBackedServer.identity.id + ) + await harness.model.load() + await harness.model.connect(to: workspace.id) + + await harness.model.beginNewWorkspace( + for: server.id, + cancelDestination: .terminal(workspace.id) + ) + await harness.model.cancelSetup() + + XCTAssertEqual(harness.model.state, .terminal(workspace.id)) + XCTAssertEqual(harness.model.activeSessions.map(\.id), [workspace.id]) + } + + func testCancelNewWorkspaceFallsBackToLibraryWhenOriginatingSessionIsGone() async throws { + let passwordBackedServer = makePasswordBackedServer() + let server = passwordBackedServer.server + let workspace = SavedWorkspace(serverID: server.id, sessionName: "base") + let harness = makeHarness( + servers: [server], + workspaces: [workspace], + identities: [passwordBackedServer.identity] + ) + try await harness.credentialStore.saveCredential( + .password("demo-password"), + identityID: passwordBackedServer.identity.id + ) + await harness.model.load() + await harness.model.connect(to: workspace.id) + + await harness.model.beginNewWorkspace( + for: server.id, + cancelDestination: .terminal(workspace.id) + ) + harness.model.disconnectActiveSession(workspace.id) + await harness.model.cancelSetup() + + XCTAssertEqual(harness.model.state, .library) + XCTAssertTrue(harness.model.activeSessions.isEmpty) + } + + func testCancelNewWorkspaceFromLibraryReturnsToLibrary() async throws { + let passwordBackedServer = makePasswordBackedServer() + let server = passwordBackedServer.server + let harness = makeHarness( + servers: [server], + workspaces: [], + identities: [passwordBackedServer.identity] + ) + await harness.model.load() + + await harness.model.beginNewWorkspace(for: server.id) + await harness.model.cancelSetup() + + XCTAssertEqual(harness.model.state, .library) } func testNewWorkspaceSavesTypedSessionNameAndConnectsExistingServer() async throws { @@ -617,16 +686,16 @@ final class RemuxRootModelTests: XCTestCase { await harness.model.beginEditServer(serverID: server.id) - guard case .setup(let draft, let validation, let mode) = harness.model.state else { + guard case .setup(let setup) = harness.model.state else { XCTFail("expected setup state") return } - XCTAssertEqual(draft.displayName, server.displayName) - XCTAssertEqual(draft.sessionName, "logs") - XCTAssertEqual(draft.password, "demo-password") - XCTAssertEqual(validation, .empty) - XCTAssertEqual(mode, .editServer(server.id, reconnectWorkspaceID: nil)) + XCTAssertEqual(setup.draft.displayName, server.displayName) + XCTAssertEqual(setup.draft.sessionName, "logs") + XCTAssertEqual(setup.draft.password, "demo-password") + XCTAssertEqual(setup.validation, .empty) + XCTAssertEqual(setup.mode, .editServer(server.id, reconnectWorkspaceID: nil)) } func testBeginEditServerWorksWithoutExistingWorkspaces() async throws { @@ -645,16 +714,16 @@ final class RemuxRootModelTests: XCTestCase { await harness.model.beginEditServer(serverID: server.id) - guard case .setup(let draft, let validation, let mode) = harness.model.state else { + guard case .setup(let setup) = harness.model.state else { XCTFail("expected setup state") return } - XCTAssertEqual(draft.displayName, server.displayName) - XCTAssertEqual(draft.sessionName, "") - XCTAssertEqual(draft.password, "demo-password") - XCTAssertEqual(validation, .empty) - XCTAssertEqual(mode, .editServer(server.id, reconnectWorkspaceID: nil)) + XCTAssertEqual(setup.draft.displayName, server.displayName) + XCTAssertEqual(setup.draft.sessionName, "") + XCTAssertEqual(setup.draft.password, "demo-password") + XCTAssertEqual(setup.validation, .empty) + XCTAssertEqual(setup.mode, .editServer(server.id, reconnectWorkspaceID: nil)) } func testBeginCredentialRepairCapturesReconnectWorkspace() async throws { @@ -674,16 +743,16 @@ final class RemuxRootModelTests: XCTestCase { await harness.model.beginCredentialRepair(for: workspace.id) - guard case .setup(let draft, let validation, let mode) = harness.model.state else { + guard case .setup(let setup) = harness.model.state else { XCTFail("expected setup state") return } - XCTAssertEqual(draft.displayName, server.displayName) - XCTAssertEqual(draft.sessionName, workspace.sessionName) - XCTAssertEqual(draft.password, "demo-password") - XCTAssertEqual(validation, .empty) - XCTAssertEqual(mode, .editServer(server.id, reconnectWorkspaceID: workspace.id)) + XCTAssertEqual(setup.draft.displayName, server.displayName) + XCTAssertEqual(setup.draft.sessionName, workspace.sessionName) + XCTAssertEqual(setup.draft.password, "demo-password") + XCTAssertEqual(setup.validation, .empty) + XCTAssertEqual(setup.mode, .editServer(server.id, reconnectWorkspaceID: workspace.id)) } func testCredentialRepairReconnectsOriginalWorkspaceWithoutDuplication() async throws { @@ -746,16 +815,16 @@ final class RemuxRootModelTests: XCTestCase { await harness.model.beginServerRepair(for: workspace.id) - guard case .setup(let draft, let validation, let mode) = harness.model.state else { + guard case .setup(let setup) = harness.model.state else { XCTFail("expected setup state") return } - XCTAssertEqual(draft.displayName, server.displayName) - XCTAssertEqual(draft.sessionName, workspace.sessionName) - XCTAssertEqual(draft.password, "demo-password") - XCTAssertEqual(validation, .empty) - XCTAssertEqual(mode, .editServer(server.id, reconnectWorkspaceID: workspace.id)) + XCTAssertEqual(setup.draft.displayName, server.displayName) + XCTAssertEqual(setup.draft.sessionName, workspace.sessionName) + XCTAssertEqual(setup.draft.password, "demo-password") + XCTAssertEqual(setup.validation, .empty) + XCTAssertEqual(setup.mode, .editServer(server.id, reconnectWorkspaceID: workspace.id)) } func testServerRepairReconnectsOriginalWorkspaceWithoutDuplication() async throws { @@ -816,16 +885,16 @@ final class RemuxRootModelTests: XCTestCase { await harness.model.beginEditWorkspace(serverID: server.id, workspaceID: logs.id) - guard case .setup(let draft, let validation, let mode) = harness.model.state else { + guard case .setup(let setup) = harness.model.state else { XCTFail("expected setup state") return } - XCTAssertEqual(draft.displayName, server.displayName) - XCTAssertEqual(draft.sessionName, "logs") - XCTAssertEqual(draft.password, "") - XCTAssertEqual(validation, .empty) - XCTAssertEqual(mode, .editWorkspace(server.id, logs.id)) + XCTAssertEqual(setup.draft.displayName, server.displayName) + XCTAssertEqual(setup.draft.sessionName, "logs") + XCTAssertEqual(setup.draft.password, "") + XCTAssertEqual(setup.validation, .empty) + XCTAssertEqual(setup.mode, .editWorkspace(server.id, logs.id)) } func testEditServerSavesServerWithoutCreatingWorkspaceOrOpeningTerminal() async throws { From caa7ee69e4acea33a6e598de3cbcadfe467116d4 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:26:16 -0400 Subject: [PATCH 3/8] app: make session disconnect follow display order --- .../App/RemuxActiveSessionCollection.swift | 21 +++++++++++ RemuxApp/Sources/App/RemuxRootModel.swift | 12 ++++++- RemuxApp/Sources/App/RootView.swift | 16 ++++----- RemuxAppTests/RemuxRootModelTests.swift | 36 ++++++++++++++++--- RemuxAppUITests/RemuxAppUITests.swift | 6 ++-- 5 files changed, 73 insertions(+), 18 deletions(-) diff --git a/RemuxApp/Sources/App/RemuxActiveSessionCollection.swift b/RemuxApp/Sources/App/RemuxActiveSessionCollection.swift index b8657783..24c485b9 100644 --- a/RemuxApp/Sources/App/RemuxActiveSessionCollection.swift +++ b/RemuxApp/Sources/App/RemuxActiveSessionCollection.swift @@ -1,6 +1,27 @@ import Foundation enum RemuxActiveSessionCollection { + static func sortedForDisplay( + _ activeSessions: [ActiveTerminalSession] + ) -> [ActiveTerminalSession] { + activeSessions.sorted { lhs, rhs in + if lhs.target.workspace.lastOpenedAt != rhs.target.workspace.lastOpenedAt { + return lhs.target.workspace.lastOpenedAt > rhs.target.workspace.lastOpenedAt + } + + let sessionComparison = lhs.target.workspace.sessionName.localizedStandardCompare( + rhs.target.workspace.sessionName + ) + if sessionComparison != .orderedSame { + return sessionComparison == .orderedAscending + } + + return lhs.target.server.displayName.localizedStandardCompare( + rhs.target.server.displayName + ) == .orderedAscending + } + } + static func containsWorkspace( _ workspaceID: SavedWorkspace.ID, in activeSessions: [ActiveTerminalSession] diff --git a/RemuxApp/Sources/App/RemuxRootModel.swift b/RemuxApp/Sources/App/RemuxRootModel.swift index c07c839b..0150206b 100644 --- a/RemuxApp/Sources/App/RemuxRootModel.swift +++ b/RemuxApp/Sources/App/RemuxRootModel.swift @@ -790,7 +790,11 @@ final class RemuxRootModel: ObservableObject { } } - func closeActiveSession(_ id: SavedWorkspace.ID) { + func disconnectActiveSession(_ id: SavedWorkspace.ID) { + // Fallback selection follows the order users see in the switcher + // and library, not internal activation order. + let displayedIndex = RemuxActiveSessionCollection.sortedForDisplay(activeSessions) + .firstIndex { $0.id == id } closePreparedTransport(for: id) stopTerminalScreenModels(workspaceID: id) RemuxActiveSessionCollection.removeWorkspace(id, from: &activeSessions) @@ -799,6 +803,12 @@ final class RemuxRootModel: ObservableObject { return } + let remaining = RemuxActiveSessionCollection.sortedForDisplay(activeSessions) + if let displayedIndex, !remaining.isEmpty { + state = .terminal(remaining[min(displayedIndex, remaining.count - 1)].id) + return + } + state = .library scheduleLibrarySSHPrewarm(snapshot: library) } diff --git a/RemuxApp/Sources/App/RootView.swift b/RemuxApp/Sources/App/RootView.swift index e0716080..74086ba1 100644 --- a/RemuxApp/Sources/App/RootView.swift +++ b/RemuxApp/Sources/App/RootView.swift @@ -207,7 +207,7 @@ private struct RemuxWorkspaceShell: View { ) model.showActiveSession(workspaceID) }, - onCloseActiveSession: model.closeActiveSession, + onDisconnectActiveSession: model.disconnectActiveSession, onDeleteServer: { serverID in Task { await model.deleteServer(serverID) } }, @@ -381,7 +381,7 @@ private struct ConnectionLibraryView: View { let onEditWorkspace: (SavedServer.ID, SavedWorkspace.ID) -> Void let onConnect: (SavedWorkspace.ID) -> Void let onShowActiveSession: (SavedWorkspace.ID) -> Void - let onCloseActiveSession: (SavedWorkspace.ID) -> Void + let onDisconnectActiveSession: (SavedWorkspace.ID) -> Void let onDeleteServer: (SavedServer.ID) -> Void let onDeleteWorkspace: (SavedWorkspace.ID) -> Void let onSettingsChange: (TerminalSettings) -> Void @@ -455,8 +455,8 @@ private struct ConnectionLibraryView: View { } .buttonStyle(.plain) .swipeActions(edge: .trailing) { - Button("Close") { - closeActiveSession(session.id) + Button("Disconnect") { + disconnectActiveSession(session.id) } .tint(.red) } @@ -618,9 +618,7 @@ private struct ConnectionLibraryView: View { } private var sortedActiveSessions: [ActiveTerminalSession] { - activeSessions.sorted { - $0.target.workspace.lastOpenedAt > $1.target.workspace.lastOpenedAt - } + RemuxActiveSessionCollection.sortedForDisplay(activeSessions) } private var visibleConnectedSessions: [ActiveTerminalSession] { @@ -656,12 +654,12 @@ private struct ConnectionLibraryView: View { }.count } - private func closeActiveSession(_ sessionID: SavedWorkspace.ID) { + private func disconnectActiveSession(_ sessionID: SavedWorkspace.ID) { var transaction = Transaction(animation: nil) transaction.disablesAnimations = true withTransaction(transaction) { - onCloseActiveSession(sessionID) + onDisconnectActiveSession(sessionID) } } } diff --git a/RemuxAppTests/RemuxRootModelTests.swift b/RemuxAppTests/RemuxRootModelTests.swift index a5c8c8c9..11af2e39 100644 --- a/RemuxAppTests/RemuxRootModelTests.swift +++ b/RemuxAppTests/RemuxRootModelTests.swift @@ -1313,7 +1313,7 @@ final class RemuxRootModelTests: XCTestCase { XCTAssertTrue(harness.model.terminalScreenModel(for: session) === terminalModel) } - func testCloseActiveSessionStopsOwnedTerminalModel() async throws { + func testDisconnectActiveSessionStopsOwnedTerminalModel() async throws { let server = SavedServer( displayName: "Build Host", host: "build.example.test", @@ -1334,9 +1334,10 @@ final class RemuxRootModelTests: XCTestCase { let terminalModel = harness.model.terminalScreenModel(for: session) await waitForConnecting(terminalModel) - harness.model.closeActiveSession(workspace.id) + harness.model.disconnectActiveSession(workspace.id) XCTAssertFalse(harness.model.hasTerminalScreenModel(for: session)) + XCTAssertEqual(harness.model.state, .library) // Teardown ordering (surface -> link -> controller) is owned by // the model's async stop; completion nils the session. await waitForStopped(terminalModel) @@ -2054,7 +2055,7 @@ final class RemuxRootModelTests: XCTestCase { XCTAssertNotEqual(freshTransport.id, createdID) } - func testCloseActiveSessionRemovesOnlyThatRuntimeSession() async throws { + func testDisconnectSelectedSessionSelectsRemainingRuntimeSession() async throws { let server = SavedServer( displayName: "Build Host", host: "build.example.test", @@ -2069,12 +2070,37 @@ final class RemuxRootModelTests: XCTestCase { await harness.model.connect(to: base.id) await harness.model.connect(to: logs.id) - harness.model.closeActiveSession(logs.id) + harness.model.disconnectActiveSession(logs.id) - XCTAssertEqual(harness.model.state, .library) + XCTAssertEqual(harness.model.state, .terminal(base.id)) XCTAssertEqual(harness.model.activeSessions.map(\.id), [base.id]) } + func testDisconnectSelectedSessionSelectsNextInDisplayedOrder() async throws { + let server = SavedServer( + displayName: "Build Host", + host: "build.example.test", + username: "builder" + ) + let zeta = SavedWorkspace(serverID: server.id, sessionName: "zeta") + let mid = SavedWorkspace(serverID: server.id, sessionName: "mid") + let alpha = SavedWorkspace(serverID: server.id, sessionName: "alpha") + let harness = makeHarness(servers: [server], workspaces: [zeta, mid, alpha]) + try await harness.credentialHelper.savePassword("demo-password", for: server.id) + + await harness.model.load() + await harness.model.connect(to: zeta.id) + await harness.model.connect(to: mid.id) + await harness.model.connect(to: alpha.id) + harness.model.showActiveSession(mid.id) + + harness.model.disconnectActiveSession(mid.id) + + // Displayed order is most-recent-first: [alpha, mid, zeta]. + // The session taking mid's display position is zeta. + XCTAssertEqual(harness.model.state, .terminal(zeta.id)) + } + func testUpdateTerminalSettingsPersistsSettings() async throws { let harness = makeHarness() await harness.model.load() diff --git a/RemuxAppUITests/RemuxAppUITests.swift b/RemuxAppUITests/RemuxAppUITests.swift index c4447165..07636c47 100644 --- a/RemuxAppUITests/RemuxAppUITests.swift +++ b/RemuxAppUITests/RemuxAppUITests.swift @@ -1713,10 +1713,10 @@ final class RemuxAppUITests: XCTestCase { guard activeSession.waitForExistence(timeout: 5) else { return } activeSession.swipeLeft() - let close = app.buttons["Close"].firstMatch - guard close.waitForExistence(timeout: 3) else { return } + let disconnect = app.buttons["Disconnect"].firstMatch + guard disconnect.waitForExistence(timeout: 3) else { return } - close.tap() + disconnect.tap() RunLoop.current.run(until: Date().addingTimeInterval(2)) } From 5f24d9bfc78181c33efe9b873278830759d4e066 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:28:50 -0400 Subject: [PATCH 4/8] terminal: fit selector sheets to complete grid rows --- Remux.xcodeproj/project.pbxproj | 4 + .../GhosttyKeyboardVisibilityProjection.swift | 2 +- .../Ghostty/GhosttySurfaceScreen.swift | 120 ++------ .../GhosttySurfaceSelectionSheet.swift | 285 ++++-------------- ...GhosttyTerminalPresentationProjector.swift | 18 +- .../GhosttyTerminalScreenModeling.swift | 3 +- .../Sources/Ghostty/PanePreviewLayout.swift | 62 ++-- .../Ghostty/TerminalSelectionSheetStyle.swift | 220 ++++++++++++++ .../Tmux/TmuxTerminalScreenAdapter.swift | 10 +- .../GhosttyPhoneChromeLayoutTests.swift | 31 -- ...tyTerminalPresentationProjectorTests.swift | 103 ------- RemuxAppTests/PanePreviewLayoutTests.swift | 26 +- .../TmuxTerminalScreenAdapterTests.swift | 1 - 13 files changed, 362 insertions(+), 523 deletions(-) create mode 100644 RemuxApp/Sources/Ghostty/TerminalSelectionSheetStyle.swift diff --git a/Remux.xcodeproj/project.pbxproj b/Remux.xcodeproj/project.pbxproj index 5e2b4507..323f5496 100644 --- a/Remux.xcodeproj/project.pbxproj +++ b/Remux.xcodeproj/project.pbxproj @@ -129,6 +129,7 @@ A0D6CF5A40A4953636A1928F /* TerminalSettingsRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AA39774D73AE5B6A77D824C /* TerminalSettingsRepository.swift */; }; A7AFC27CD8E0B30AFF1A32BF /* GhosttyAttachmentImageMarkupEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC550DCE031B69C4EDD3C6F5 /* GhosttyAttachmentImageMarkupEditor.swift */; }; A8ABF8A3A082320C5C11BA70 /* RemuxActiveSessionRuntimeReducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03F3C1F4357FDC042F5B6E5C /* RemuxActiveSessionRuntimeReducerTests.swift */; }; + A8D2AAE9BEC8E1557E05FD71 /* TerminalSelectionSheetStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFB3839D169808DACE1125F /* TerminalSelectionSheetStyle.swift */; }; AA0449DB0EEBE5F640BF6B79 /* GhosttyTerminalInputCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29658D4FE019C12073CC58BE /* GhosttyTerminalInputCoordinator.swift */; }; AB2DBE12494A327FBC54FF9D /* GhosttyScrollDeltaBudgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C09E9400D2638AE72F1CEBFB /* GhosttyScrollDeltaBudgetTests.swift */; }; B056E6334A9EFBDB7FA305B3 /* TerminalPreviewCandidateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66FB23E143EA43A7AB28A323 /* TerminalPreviewCandidateTests.swift */; }; @@ -336,6 +337,7 @@ B94CC628E68FDC18E59E9D79 /* GhosttyAttachmentTray.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyAttachmentTray.swift; sourceTree = ""; }; B9F00A0064AC2897BDF34495 /* GhosttyKeyboardVisibilityProjectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyKeyboardVisibilityProjectionTests.swift; sourceTree = ""; }; BA66578016B8B2B62FF2332F /* TmuxConnectionDraftValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxConnectionDraftValidatorTests.swift; sourceTree = ""; }; + BBFB3839D169808DACE1125F /* TerminalSelectionSheetStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSelectionSheetStyle.swift; sourceTree = ""; }; BC76333B637D49B9A27CB798 /* ShortcutPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutPalette.swift; sourceTree = ""; }; BF6CD3A6EEDAD871DB47B5CF /* GhosttyFlowTraceStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyFlowTraceStoreTests.swift; sourceTree = ""; }; C09E9400D2638AE72F1CEBFB /* GhosttyScrollDeltaBudgetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyScrollDeltaBudgetTests.swift; sourceTree = ""; }; @@ -690,6 +692,7 @@ 60FE6CB3913B2DD433F57472 /* ShortcutExecutor.swift */, BC76333B637D49B9A27CB798 /* ShortcutPalette.swift */, CFE401B861C30DA18E8BE1C9 /* ShortcutsSettingsSheet.swift */, + BBFB3839D169808DACE1125F /* TerminalSelectionSheetStyle.swift */, 7C826602D9C963622FB26A40 /* TerminalThemePreviewRenderer.swift */, ); path = Ghostty; @@ -939,6 +942,7 @@ 31FA4F4D3F831DE0DEAB770F /* TerminalPreviewView.swift in Sources */, 45490159CE92B33F95A59CBC /* TerminalRuntimeStateIndicator.swift in Sources */, D139ABC351802768520D8A4F /* TerminalRuntimeStatusPresentation.swift in Sources */, + A8D2AAE9BEC8E1557E05FD71 /* TerminalSelectionSheetStyle.swift in Sources */, 0494A11A7D6207165CFCDAD7 /* TerminalSettings.swift in Sources */, A0D6CF5A40A4953636A1928F /* TerminalSettingsRepository.swift in Sources */, 4D354C37600CA12E917B566F /* TerminalThemePreviewRenderer.swift in Sources */, diff --git a/RemuxApp/Sources/Ghostty/GhosttyKeyboardVisibilityProjection.swift b/RemuxApp/Sources/Ghostty/GhosttyKeyboardVisibilityProjection.swift index 78bda98e..386b6275 100644 --- a/RemuxApp/Sources/Ghostty/GhosttyKeyboardVisibilityProjection.swift +++ b/RemuxApp/Sources/Ghostty/GhosttyKeyboardVisibilityProjection.swift @@ -72,7 +72,7 @@ struct GhosttyKeyboardVisibilityProjection: Equatable { self.frameEnd = frameEnd self.screenBounds = screenBounds - let visibleOverlapHeight = GhosttySelectionSheetSizing.normalizedHeight( + let visibleOverlapHeight = GhosttyViewportSizing.normalizedHeight( GhosttySoftwareKeyboardVisibility.visibleOverlapHeight( frameEnd: frameEnd, screenBounds: screenBounds diff --git a/RemuxApp/Sources/Ghostty/GhosttySurfaceScreen.swift b/RemuxApp/Sources/Ghostty/GhosttySurfaceScreen.swift index 4250a653..9a225d56 100644 --- a/RemuxApp/Sources/Ghostty/GhosttySurfaceScreen.swift +++ b/RemuxApp/Sources/Ghostty/GhosttySurfaceScreen.swift @@ -60,7 +60,6 @@ struct GhosttySurfaceScreen: View { @State private var inputCoordinator = GhosttyTerminalInputCoordinator() @State private var terminalInputController = GhosttyTerminalInputController() @State private var selectionSheet: GhosttySurfaceSelectionSheet? - @State private var selectionSheetPresentationState = GhosttySelectionSheetPresentationState() @State private var bottomChromeHeight: CGFloat = 0 @State private var softwareKeyboardOverlapHeight: CGFloat = 0 @State private var lastSoftwareKeyboardOverlapHeight: CGFloat = 0 @@ -372,7 +371,7 @@ struct GhosttySurfaceScreen: View { } } .onPreferenceChange(GhosttyBottomChromeHeightPreferenceKey.self) { newHeight in - let normalizedHeight = GhosttySelectionSheetSizing.normalizedHeight(newHeight) + let normalizedHeight = GhosttyViewportSizing.normalizedHeight(newHeight) guard bottomChromeHeight != normalizedHeight else { return } GhosttyRuntimeTrace.tmuxViewport( "viewport.bottomChrome old=\(bottomChromeHeight.traceLabel) new=\(normalizedHeight.traceLabel) keyboardMode=\(inputCoordinator.keyboardMode.traceLabel) renderedMode=\(renderedKeyboardMode.traceLabel) softwareKeyboardVisible=\(inputCoordinator.isSoftwareKeyboardVisible) overlap=\(softwareKeyboardOverlapHeight.traceLabel)" @@ -400,11 +399,15 @@ struct GhosttySurfaceScreen: View { completeKeyboardDidHide() } .sheet(item: selectionSheetBinding) { sheet in + // Every input to this height is independent of the sheet's + // current height: spec tokens and grid math from screen width. selectionSheetContent(sheet) - .presentationDetents(selectionSheetDetents(for: sheet)) + .presentationDetents( + [.height(selectionSheetHeight(for: sheet))] + ) .presentationContentInteraction(.scrolls) - .presentationDragIndicator(.visible) - .ghosttySelectionSheetPresentationBackground() + .presentationDragIndicator(.hidden) + .terminalSelectionSheetPresentationBackground() .ghosttyTerminalChromePresentation( presentation.terminalTheme.terminalChromeColorScheme, chromeStyle: presentation.terminalTheme.terminalChromeStyle @@ -445,7 +448,7 @@ struct GhosttySurfaceScreen: View { ) .presentationDetents([.medium, .large], selection: $attachmentPreviewDetent) .presentationDragIndicator(.visible) - .ghosttySelectionSheetPresentationBackground() + .terminalSelectionSheetPresentationBackground() .ghosttyTerminalChromePresentation( presentation.terminalTheme.terminalChromeColorScheme, chromeStyle: presentation.terminalTheme.terminalChromeStyle @@ -1423,7 +1426,6 @@ struct GhosttySurfaceScreen: View { private func showWindows() { guard let projection = model.windowSheetPresentationProjection() else { return } GhosttyRuntimeTrace.flowEventIfActive("tmux.newWindow", event: "ui.showWindows") - captureSelectionSheetBottomReplacementHeight() applySelectionSheetPresentation( .windows( makeWindowPreviewSession(leafIDs: projection.previewLeafIDs) @@ -1443,8 +1445,7 @@ struct GhosttySurfaceScreen: View { } private func applySelectionSheetPresentation(_ newValue: GhosttySurfaceSelectionSheet?) { - let change = selectionSheetPresentationState.apply(nextKind: newValue?.presentationKind) - if change.shouldCancelCurrentPreviewSession { + if selectionSheet != nil, newValue == nil { cancelSelectionSheetPreviewSession(selectionSheet) } @@ -1460,44 +1461,6 @@ struct GhosttySurfaceScreen: View { } } - private func selectionSheetDetents( - for sheet: GhosttySurfaceSelectionSheet - ) -> Set { - switch sheet { - case .windows(_): - let cellCount = model.windowSheetDetentCellCount() - switch PanePreviewLayout.windowMetricsForCurrentScreen(cellCount: cellCount).sheetDetent { - case .fixed(let height): - return [ - .height( - GhosttySelectionSheetSizing.fixedDetentHeight( - preferredHeight: height, - bottomReplacementHeight: selectionSheetPresentationState.bottomReplacementHeight - ) - ), - ] - case .large: - return [.large] - } - - case .panes(let topLevelID, _): - let paneCount = model.paneSheetDetentPaneCount(topLevelID: topLevelID) - switch PanePreviewLayout.metricsForCurrentScreen(for: paneCount).sheetDetent { - case .fixed(let height): - return [ - .height( - GhosttySelectionSheetSizing.fixedDetentHeight( - preferredHeight: height, - bottomReplacementHeight: selectionSheetPresentationState.bottomReplacementHeight - ) - ), - ] - case .large: - return [.large] - } - } - } - private func showPanes() { guard let projection = model.selectedPaneSheetPresentationProjection() else { return } GhosttyRuntimeTrace.flowEventIfActive("tmux.splitPane", event: "ui.showPanes") @@ -1505,7 +1468,6 @@ struct GhosttySurfaceScreen: View { // Carry the preview session in the sheet payload itself so the pane // sheet never renders against a separate optional state that may lag // the presentation transaction. - captureSelectionSheetBottomReplacementHeight() applySelectionSheetPresentation( .panes( topLevelID: projection.topLevelID, @@ -1963,17 +1925,6 @@ struct GhosttySurfaceScreen: View { withTransaction(transaction, changes) } - private func captureSelectionSheetBottomReplacementHeight() { - let replacementHeight = GhosttySelectionSheetSizing.bottomReplacementHeight( - bottomChromeHeight: bottomChromeHeight, - softwareKeyboardOverlapHeight: softwareKeyboardOverlapHeight - ) - selectionSheetPresentationState.captureBottomReplacementHeight(replacementHeight) - GhosttyRuntimeTrace.tmuxViewport( - "selectionSheet.captureBottomReplacement bottomChrome=\(bottomChromeHeight.traceLabel) keyboardOverlap=\(softwareKeyboardOverlapHeight.traceLabel) replacement=\(selectionSheetPresentationState.bottomReplacementHeight.traceLabel) keyboardMode=\(inputCoordinator.keyboardMode.traceLabel)" - ) - } - private var sessionOpenFlowID: String { "session.open.\(presentation.workspaceID.uuidString)" } @@ -2031,7 +1982,7 @@ struct GhosttySurfaceScreen: View { "tmux.splitPane", event: event, fields: [ - "panesBefore": "\(model.paneSheetDetentPaneCount(topLevelID: topLevelID))", + "panesBefore": "\(model.paneCount(topLevelID: topLevelID))", "workspaceID": presentation.workspaceID.uuidString, ] ) @@ -2075,6 +2026,26 @@ struct GhosttySurfaceScreen: View { } } + private func selectionSheetHeight( + for sheet: GhosttySurfaceSelectionSheet + ) -> CGFloat { + let gridHeight: CGFloat + switch sheet { + case .windows: + gridHeight = PanePreviewLayout.gridIdealHeight( + itemCount: model.windowSelectionSheetRenderProjection().windows.count, + metrics: PanePreviewLayout.windowMetricsForCurrentScreen() + ) + case .panes(let topLevelID, _): + let paneCount = model.paneSelectionSheetRenderProjection(topLevelID: topLevelID).panes.count + gridHeight = PanePreviewLayout.gridIdealHeight( + itemCount: paneCount, + metrics: PanePreviewLayout.metricsForCurrentScreen(for: paneCount) + ) + } + return TerminalSelectionSheetLayout.sheetHeight(gridHeight: gridHeight) + } + @ViewBuilder private func selectionSheetContent(_ sheet: GhosttySurfaceSelectionSheet) -> some View { switch sheet { @@ -2159,23 +2130,7 @@ private struct GhosttyBottomChromeHeightPreferenceKey: PreferenceKey { } } -struct GhosttySelectionSheetSizing { - static let windowPreferredHeight: CGFloat = 310 - - static func fixedDetentHeight( - preferredHeight: CGFloat, - bottomReplacementHeight: CGFloat - ) -> CGFloat { - max(normalizedHeight(preferredHeight), normalizedHeight(bottomReplacementHeight)) - } - - static func bottomReplacementHeight( - bottomChromeHeight: CGFloat, - softwareKeyboardOverlapHeight: CGFloat - ) -> CGFloat { - normalizedHeight(bottomChromeHeight) + normalizedHeight(softwareKeyboardOverlapHeight) - } - +enum GhosttyViewportSizing { static func normalizedHeight(_ height: CGFloat) -> CGFloat { guard height.isFinite, height > 0 else { return 0 } return ceil(height) @@ -2349,17 +2304,6 @@ extension TerminalTheme { } } -private extension View { - @ViewBuilder - func ghosttySelectionSheetPresentationBackground() -> some View { - if #available(iOS 26.0, *) { - self - } else { - self.presentationBackground(.regularMaterial) - } - } -} - struct GhosttySurfaceScreenLifecycleProjection: Equatable { let scenePhase: ScenePhase let isSelected: Bool diff --git a/RemuxApp/Sources/Ghostty/GhosttySurfaceSelectionSheet.swift b/RemuxApp/Sources/Ghostty/GhosttySurfaceSelectionSheet.swift index 71113611..2ce131a6 100644 --- a/RemuxApp/Sources/Ghostty/GhosttySurfaceSelectionSheet.swift +++ b/RemuxApp/Sources/Ghostty/GhosttySurfaceSelectionSheet.swift @@ -13,15 +13,6 @@ enum GhosttySurfaceSelectionSheet: Identifiable { } } - var presentationKind: GhosttySelectionSheetPresentationKind { - switch self { - case .windows(_): - return .windows - case .panes(_, _): - return .panes - } - } - var paneTopLevelIDForTopologyValidation: UUID? { switch self { case .windows(_): @@ -32,69 +23,6 @@ enum GhosttySurfaceSelectionSheet: Identifiable { } } -enum GhosttySelectionSheetPresentationKind: Equatable, Sendable { - case windows - case panes -} - -struct GhosttySelectionSheetPresentationChange: Equatable, Sendable { - let currentKind: GhosttySelectionSheetPresentationKind? - let nextKind: GhosttySelectionSheetPresentationKind? - let shouldCancelCurrentPreviewSession: Bool - let shouldResetBottomReplacementHeight: Bool - - init( - currentKind: GhosttySelectionSheetPresentationKind?, - nextKind: GhosttySelectionSheetPresentationKind? - ) { - self.currentKind = currentKind - self.nextKind = nextKind - self.shouldCancelCurrentPreviewSession = currentKind != nil && nextKind == nil - self.shouldResetBottomReplacementHeight = nextKind == nil - } -} - -struct GhosttySelectionSheetPresentationState: Equatable { - private(set) var presentedKind: GhosttySelectionSheetPresentationKind? - private(set) var bottomReplacementHeight: CGFloat = 0 - - mutating func captureBottomReplacementHeight(_ height: CGFloat) { - bottomReplacementHeight = height - } - - mutating func apply( - nextKind: GhosttySelectionSheetPresentationKind? - ) -> GhosttySelectionSheetPresentationChange { - let change = GhosttySelectionSheetPresentationChange( - currentKind: presentedKind, - nextKind: nextKind - ) - presentedKind = nextKind - if change.shouldResetBottomReplacementHeight { - bottomReplacementHeight = 0 - } - return change - } -} - -enum GhosttySheetPalette { - static let row = Color(uiColor: .secondarySystemFill) - static let stroke = Color.primary.opacity(0.12) - static let controlFill = Color(uiColor: .secondarySystemFill) - static let destructiveControlFill = Color(uiColor: .systemRed).opacity(0.14) - static let primary = Color.primary.opacity(0.92) - static let secondary = Color.secondary.opacity(0.78) - static let tertiary = Color.secondary.opacity(0.56) - - static func rowSelected(_ chromeStyle: GhosttyTerminalChromeStyle) -> Color { - chromeStyle.selectedFill - } - - static func selectedStroke(_ chromeStyle: GhosttyTerminalChromeStyle) -> Color { - chromeStyle.selectedStroke - } -} - struct GhosttyWindowSelectionSheet: View { @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle @ObservedObject var session: GhosttyPanePreviewSession @@ -108,11 +36,13 @@ struct GhosttyWindowSelectionSheet: View { let onRemoveWindow: (UUID) -> Void var body: some View { - let layout = PanePreviewLayout.windowMetricsForCurrentScreen(cellCount: projection.cellCount) - - VStack(alignment: .leading, spacing: 14) { - sheetHeader(caption: "SESSION", title: sessionName) + let layout = PanePreviewLayout.windowMetricsForCurrentScreen() + TerminalSelectionSheetScaffold( + title: "Windows", + context: "\(sessionName) · \(projection.windows.count) \(projection.windows.count == 1 ? "window" : "windows")", + closeAccessibilityIdentifier: "terminal.windows.close" + ) { ScrollView(showsIndicators: false) { windowGrid( windows: projection.windows, @@ -120,21 +50,15 @@ struct GhosttyWindowSelectionSheet: View { ) } .accessibilityIdentifier("terminal.windows.scroll") - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - - GhosttySheetBottomActionBar { - GhosttySheetActionButton( - title: "New Window", - systemName: "plus", - accessibilityIdentifier: "terminal.window.new", - action: onCreateWindow - ) - } + .contentMargins(.horizontal, 16, for: .scrollContent) + } actions: { + TerminalSelectionSheetActionButton( + title: "New Window", + systemName: "plus", + accessibilityIdentifier: "terminal.window.new", + action: onCreateWindow + ) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 16) .task(id: session.id) { session.reconcile(leafIDs: projection.previewLeafIDs) await Task.yield() @@ -173,6 +97,7 @@ struct GhosttyWindowSelectionSheet: View { } message: { request in Text(windowRemovalMessage(for: request)) } + .accessibilityElement(children: .contain) .accessibilityIdentifier("terminal.windows.sheet") } @@ -276,12 +201,11 @@ struct GhosttyPaneSelectionSheet: View { var body: some View { let layout = PanePreviewLayout.metricsForCurrentScreen(for: projection.paneCount) - VStack(alignment: .leading, spacing: 14) { - sheetHeader( - caption: "PANES", - title: "\(projection.paneCount) \(projection.paneCount == 1 ? "pane" : "panes")" - ) - + TerminalSelectionSheetScaffold( + title: "Panes", + context: "\(projection.paneCount) \(projection.paneCount == 1 ? "pane" : "panes")", + closeAccessibilityIdentifier: "terminal.panes.close" + ) { ScrollView(showsIndicators: false) { paneLayout( panes: projection.panes, @@ -296,30 +220,24 @@ struct GhosttyPaneSelectionSheet: View { ) } .accessibilityIdentifier("terminal.panes.scroll") - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - - GhosttySheetBottomActionBar { - HStack(spacing: 10) { - GhosttySheetActionButton( - title: "Split", - systemName: "square.split.2x1", - accessibilityIdentifier: "terminal.pane.split", - action: onSplitPane - ) + .contentMargins(.horizontal, 16, for: .scrollContent) + } actions: { + HStack(spacing: 10) { + TerminalSelectionSheetActionButton( + title: "Split", + systemName: "square.split.2x1", + accessibilityIdentifier: "terminal.pane.split", + action: onSplitPane + ) - GhosttySheetActionButton( - title: "Stack", - systemName: "square.split.1x2", - accessibilityIdentifier: "terminal.pane.stack", - action: onStackPane - ) - } + TerminalSelectionSheetActionButton( + title: "Stack", + systemName: "square.split.1x2", + accessibilityIdentifier: "terminal.pane.stack", + action: onStackPane + ) } } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 16) .task(id: session.id) { // First-render reconcile closes the gap between tap-time session // creation and the sheet's initial body render. If pane @@ -362,6 +280,7 @@ struct GhosttyPaneSelectionSheet: View { } message: { request in Text(paneRemovalMessage(for: request)) } + .accessibilityElement(children: .contain) .accessibilityIdentifier("terminal.panes.sheet") } @@ -562,78 +481,6 @@ private enum GhosttySelectionContextActionPalette { static let shadow = Color.black.opacity(0.20) } -@ViewBuilder -private func sheetHeader(caption: String, title: String) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(caption) - .font(.system(size: 10, weight: .semibold)) - .tracking(1.0) - .foregroundStyle(GhosttySheetPalette.tertiary) - - Text(title) - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(GhosttySheetPalette.primary) - .lineLimit(1) - .truncationMode(.middle) - } -} - -private struct GhosttySheetBottomActionBar: View { - @ViewBuilder let content: Content - - var body: some View { - content - .padding(.top, 6) - } -} - -private struct GhosttySheetActionButton: View { - let title: String - let systemName: String - let accessibilityIdentifier: String - let action: (() -> Void)? - var isDestructive = false - - var body: some View { - Button { - Haptic.tap() - action?() - } label: { - HStack(spacing: 8) { - Image(systemName: systemName) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(foreground) - - Text(title) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(foreground) - } - .frame(maxWidth: .infinity) - .frame(height: 44) - .padding(.horizontal, 14) - .ghosttySheetActionSurface(isDestructive: isDestructive) - } - .buttonStyle(GhosttySheetActionButtonStyle(isEnabled: action != nil)) - .accessibilityIdentifier(accessibilityIdentifier) - .disabled(action == nil) - } - - private var foreground: Color { - isDestructive ? Color(uiColor: .systemRed) : GhosttySheetPalette.primary - } -} - -private struct GhosttySheetActionButtonStyle: ButtonStyle { - let isEnabled: Bool - - func makeBody(configuration: Configuration) -> some View { - configuration.label - .scaleEffect(configuration.isPressed && isEnabled ? 0.985 : 1) - .opacity(isEnabled ? 1 : 0.45) - .animation(.easeOut(duration: 0.12), value: configuration.isPressed) - } -} - private struct GhosttyRenderedPreviewSurface: View { let preview: GhosttyPanePreviewSession.RenderedPreview let size: CGSize @@ -677,15 +524,23 @@ private struct GhosttyWindowSelectionTile: View { height: layout.tilePointSize.height, alignment: .topLeading ) - .background(isSelected ? GhosttySheetPalette.rowSelected(chromeStyle) : GhosttySheetPalette.row) + .background(TerminalSelectionSheetPalette.row) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder( - isSelected ? GhosttySheetPalette.selectedStroke(chromeStyle) : GhosttySheetPalette.stroke, + isSelected + ? TerminalSelectionSheetPalette.selectedStroke(chromeStyle) + : TerminalSelectionSheetPalette.stroke, lineWidth: isSelected ? 1.25 : 1 ) } + .overlay(alignment: .topTrailing) { + if isSelected { + TerminalSelectionTileCheckmark(chromeStyle: chromeStyle) + .padding(6) + } + } .accessibilityElement(children: .ignore) .accessibilityLabel(accessibilityLabel) .accessibilityValue(previewState.accessibilityValue) @@ -717,12 +572,12 @@ private struct GhosttyWindowSelectionTile: View { Text("\(displayIndex)") .font(.system(size: 11, weight: .semibold)) .monospacedDigit() - .foregroundStyle(GhosttySheetPalette.tertiary) + .foregroundStyle(TerminalSelectionSheetPalette.tertiary) if !displayName.isEmpty { Text(displayName) .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(GhosttySheetPalette.primary) + .foregroundStyle(TerminalSelectionSheetPalette.primary) .lineLimit(1) .truncationMode(.tail) } @@ -738,7 +593,7 @@ private struct GhosttyWindowSelectionTile: View { Text("panes") } .font(.system(size: 11, weight: .medium)) - .foregroundStyle(GhosttySheetPalette.secondary) + .foregroundStyle(TerminalSelectionSheetPalette.secondary) .lineLimit(1) } } @@ -775,15 +630,23 @@ private struct GhosttyPaneSelectionTile: View { height: layout.tilePointSize.height, alignment: .topLeading ) - .background(isSelected ? GhosttySheetPalette.rowSelected(chromeStyle) : GhosttySheetPalette.row) + .background(TerminalSelectionSheetPalette.row) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder( - isSelected ? GhosttySheetPalette.selectedStroke(chromeStyle) : GhosttySheetPalette.stroke, + isSelected + ? TerminalSelectionSheetPalette.selectedStroke(chromeStyle) + : TerminalSelectionSheetPalette.stroke, lineWidth: isSelected ? 1.25 : 1 ) } + .overlay(alignment: .topTrailing) { + if isSelected { + TerminalSelectionTileCheckmark(chromeStyle: chromeStyle) + .padding(6) + } + } .accessibilityElement(children: .ignore) .accessibilityLabel(accessibilityLabel) .accessibilityValue(state.accessibilityValue) @@ -829,7 +692,7 @@ private struct GhosttyPaneSelectionTile: View { Text("\(displayIndex)") .font(.system(size: 11, weight: .semibold)) .monospacedDigit() - .foregroundStyle(GhosttySheetPalette.tertiary) + .foregroundStyle(TerminalSelectionSheetPalette.tertiary) Spacer(minLength: 0) } @@ -875,32 +738,4 @@ private extension View { } } - @ViewBuilder - func ghosttySheetActionSurface(isDestructive: Bool) -> some View { - let shape = RoundedRectangle(cornerRadius: 14, style: .continuous) - - if #available(iOS 26.0, *) { - self - .background( - isDestructive - ? GhosttySheetPalette.destructiveControlFill - : GhosttySheetPalette.controlFill, - in: shape - ) - .overlay { - shape.strokeBorder(GhosttySheetPalette.stroke, lineWidth: 1) - } - } else { - self - .background( - isDestructive - ? GhosttySheetPalette.destructiveControlFill - : GhosttySheetPalette.controlFill, - in: shape - ) - .overlay { - shape.strokeBorder(GhosttySheetPalette.stroke, lineWidth: 1) - } - } - } } diff --git a/RemuxApp/Sources/Ghostty/GhosttyTerminalPresentationProjector.swift b/RemuxApp/Sources/Ghostty/GhosttyTerminalPresentationProjector.swift index d490d157..fde1dd74 100644 --- a/RemuxApp/Sources/Ghostty/GhosttyTerminalPresentationProjector.swift +++ b/RemuxApp/Sources/Ghostty/GhosttyTerminalPresentationProjector.swift @@ -235,13 +235,11 @@ enum GhosttyTmuxTopologyActionInteractionEffect: Equatable, Sendable { struct GhosttyWindowSheetPresentationProjection: Equatable, Sendable { let previewLeafIDs: [UUID] - let cellCount: Int } struct GhosttyPaneSheetPresentationProjection: Equatable, Sendable { let topLevelID: UUID let previewLeafIDs: [UUID] - let paneCount: Int } struct GhosttyPaneSelectionSheetTopologyProjection: Equatable, Sendable { @@ -263,7 +261,6 @@ struct GhosttyWindowSelectionSheetRenderProjection: Equatable, Sendable { let windows: [Window] let selectedWindowID: UUID? let previewLeafIDs: [UUID] - let cellCount: Int } struct GhosttyPaneSelectionSheetRenderProjection: Equatable, Sendable { @@ -421,8 +418,7 @@ enum GhosttyTerminalPresentationProjector { guard !snapshot.topLevels.isEmpty else { return nil } return GhosttyWindowSheetPresentationProjection( - previewLeafIDs: snapshot.topLevels.compactMap(\.resolvedFocusedLeafID), - cellCount: windowSheetDetentCellCount(snapshot: snapshot) + previewLeafIDs: snapshot.topLevels.compactMap(\.resolvedFocusedLeafID) ) } @@ -433,22 +429,17 @@ enum GhosttyTerminalPresentationProjector { return GhosttyPaneSheetPresentationProjection( topLevelID: topLevel.id, - previewLeafIDs: topLevel.leafIDs, - paneCount: topLevel.leafIDs.count + previewLeafIDs: topLevel.leafIDs ) } - static func paneSheetDetentPaneCount( + static func paneCount( topLevelID: UUID, snapshot: GhosttyRuntimeSurfaceTopologySnapshot ) -> Int { snapshot.topLevels.first(where: { $0.id == topLevelID })?.leafIDs.count ?? 0 } - static func windowSheetDetentCellCount(snapshot: GhosttyRuntimeSurfaceTopologySnapshot) -> Int { - snapshot.topLevels.count - } - static func paneSelectionSheetTopologyProjection( topLevelID: UUID?, snapshot: GhosttyRuntimeSurfaceTopologySnapshot @@ -488,8 +479,7 @@ enum GhosttyTerminalPresentationProjector { return GhosttyWindowSelectionSheetRenderProjection( windows: windows, selectedWindowID: selectedWindowID, - previewLeafIDs: windows.compactMap(\.focusedPreviewPaneID), - cellCount: totalCount + previewLeafIDs: windows.compactMap(\.focusedPreviewPaneID) ) } diff --git a/RemuxApp/Sources/Ghostty/GhosttyTerminalScreenModeling.swift b/RemuxApp/Sources/Ghostty/GhosttyTerminalScreenModeling.swift index 7bf40df4..c9253790 100644 --- a/RemuxApp/Sources/Ghostty/GhosttyTerminalScreenModeling.swift +++ b/RemuxApp/Sources/Ghostty/GhosttyTerminalScreenModeling.swift @@ -149,8 +149,7 @@ protocol GhosttyTmuxSelectionModeling: ObservableObject { func windowSheetPresentationProjection() -> GhosttyWindowSheetPresentationProjection? func selectedPaneSheetPresentationProjection() -> GhosttyPaneSheetPresentationProjection? - func paneSheetDetentPaneCount(topLevelID: UUID) -> Int - func windowSheetDetentCellCount() -> Int + func paneCount(topLevelID: UUID) -> Int func paneSelectionSheetTopologyProjection( topLevelID: UUID? ) -> GhosttyPaneSelectionSheetTopologyProjection diff --git a/RemuxApp/Sources/Ghostty/PanePreviewLayout.swift b/RemuxApp/Sources/Ghostty/PanePreviewLayout.swift index 3c44b2b8..12d782a6 100644 --- a/RemuxApp/Sources/Ghostty/PanePreviewLayout.swift +++ b/RemuxApp/Sources/Ghostty/PanePreviewLayout.swift @@ -12,18 +12,45 @@ import UIKit /// sheet is open does not justify reissuing previews; we keep the originally /// requested image regardless. enum PanePreviewLayout { - enum SheetDetent: Equatable { - case fixed(CGFloat) - case large - } - struct Metrics: Equatable { let columnCount: Int let tilePointSize: CGSize let previewPointSize: CGSize - let sheetDetent: SheetDetent let gridSpacing: CGFloat let tilePadding: CGFloat + + func gridHeight(itemCount: Int) -> CGFloat { + guard itemCount > 0 else { return 0 } + let rows = (itemCount + columnCount - 1) / columnCount + return CGFloat(rows) * tilePointSize.height + + CGFloat(rows - 1) * gridSpacing + } + } + + /// Height for a selector sheet's scrollable grid. The whole grid shows + /// exactly whenever it fits within the height budget — the sheet grows + /// rather than hiding part of the final row. Only grids larger than the + /// budget scroll, showing complete rows plus half of the next tile so + /// the cut is an unmistakable scroll affordance. + @MainActor + static func gridIdealHeight(itemCount: Int, metrics: Metrics) -> CGFloat { + let fullHeight = metrics.gridHeight(itemCount: itemCount) + let budget = UIScreen.main.bounds.height * 0.72 + guard fullHeight > budget else { return fullHeight } + + let tile = metrics.tilePointSize.height + let spacing = metrics.gridSpacing + let peek = tile * 0.5 + + func height(fullRows: Int) -> CGFloat { + CGFloat(fullRows) * tile + CGFloat(fullRows - 1) * spacing + spacing + peek + } + + var rows = 1 + while height(fullRows: rows + 1) <= budget { + rows += 1 + } + return height(fullRows: rows) } private static let defaultSheetContentWidth: CGFloat = 361 @@ -33,7 +60,6 @@ enum PanePreviewLayout { private static let captionHeight: CGFloat = 14 private static let windowCaptionHeight: CGFloat = 30 private static let tileCaptionSpacing: CGFloat = 6 - private static let sheetChromeHeight: CGFloat = 162 private static let maxSingleTileWidth: CGFloat = 390 /// Window grid uses a fixed two-column layout. The "New Window" affordance @@ -41,7 +67,6 @@ enum PanePreviewLayout { /// scroll windows without hiding the create command. private static let windowGridColumnCount: Int = 2 private static let windowGridSpacing: CGFloat = 10 - private static let windowSheetChromeHeight: CGFloat = 162 static func metrics(for paneCount: Int) -> Metrics { metrics(for: paneCount, availableWidth: defaultSheetContentWidth) @@ -66,16 +91,10 @@ enum PanePreviewLayout { let previewWidth = max(1, tileWidth - tilePadding * 2) let previewHeight = ceil(previewWidth / defaultPreviewAspectRatio) let tileHeight = previewHeight + tileCaptionSpacing + captionHeight + tilePadding * 2 - let rowCount = Int(ceil(Double(paneCount) / Double(columnCount))) - let gridHeight = CGFloat(rowCount) * tileHeight + - CGFloat(max(rowCount - 1, 0)) * gridSpacing - let fixedHeight = ceil(sheetChromeHeight + gridHeight) - return .init( columnCount: columnCount, tilePointSize: CGSize(width: tileWidth, height: tileHeight), previewPointSize: CGSize(width: previewWidth, height: previewHeight), - sheetDetent: paneCount >= 5 ? .large : .fixed(fixedHeight), gridSpacing: gridSpacing, tilePadding: tilePadding ) @@ -101,15 +120,13 @@ enum PanePreviewLayout { } @MainActor - static func windowMetricsForCurrentScreen(cellCount: Int) -> Metrics { - windowMetrics(cellCount: cellCount, availableWidth: currentSheetContentWidth()) + static func windowMetricsForCurrentScreen() -> Metrics { + windowMetrics(availableWidth: currentSheetContentWidth()) } static func windowMetrics( - cellCount: Int, availableWidth: CGFloat ) -> Metrics { - let cellCount = max(cellCount, 1) let safeAvailableWidth = max(availableWidth, 1) let columnCount = windowGridColumnCount let totalGridSpacing = CGFloat(columnCount - 1) * windowGridSpacing @@ -120,17 +137,10 @@ enum PanePreviewLayout { let previewWidth = max(1, tileWidth - tilePadding * 2) let previewHeight = ceil(previewWidth / defaultPreviewAspectRatio) let tileHeight = previewHeight + tileCaptionSpacing + windowCaptionHeight + tilePadding * 2 - let rowCount = Int(ceil(Double(cellCount) / Double(columnCount))) - let gridHeight = CGFloat(rowCount) * tileHeight + - CGFloat(max(rowCount - 1, 0)) * windowGridSpacing - let fixedHeight = ceil(windowSheetChromeHeight + gridHeight) - let detent: SheetDetent = rowCount >= 4 ? .large : .fixed(fixedHeight) - return .init( columnCount: columnCount, tilePointSize: CGSize(width: tileWidth, height: tileHeight), previewPointSize: CGSize(width: previewWidth, height: previewHeight), - sheetDetent: detent, gridSpacing: windowGridSpacing, tilePadding: tilePadding ) @@ -179,7 +189,7 @@ enum PanePreviewLayout { availableWidth: CGFloat, scale: CGFloat ) -> (width: UInt32, height: UInt32) { - let metrics = windowMetrics(cellCount: windowGridColumnCount, availableWidth: availableWidth) + let metrics = windowMetrics(availableWidth: availableWidth) let safeScale = max(scale, 1) let widthPx = (metrics.previewPointSize.width * safeScale).rounded(.up) let heightPx = (metrics.previewPointSize.height * safeScale).rounded(.up) diff --git a/RemuxApp/Sources/Ghostty/TerminalSelectionSheetStyle.swift b/RemuxApp/Sources/Ghostty/TerminalSelectionSheetStyle.swift new file mode 100644 index 00000000..2bcbb68b --- /dev/null +++ b/RemuxApp/Sources/Ghostty/TerminalSelectionSheetStyle.swift @@ -0,0 +1,220 @@ +import SwiftUI +import UIKit + +enum TerminalSelectionSheetPalette { + static let row = Color(uiColor: .secondarySystemFill) + static let stroke = Color.primary.opacity(0.12) + static let controlFill = Color(uiColor: .secondarySystemFill) + static let primary = Color.primary.opacity(0.92) + static let secondary = Color.secondary.opacity(0.78) + static let tertiary = Color.secondary.opacity(0.56) + + static func selectedStroke(_ chromeStyle: GhosttyTerminalChromeStyle) -> Color { + chromeStyle.selectedStroke + } +} + +/// Single source of truth for selector-sheet chrome heights. The scaffold +/// lays its rows out from these tokens and `sheetHeight` sums the same +/// tokens for the presentation detent, so the sheet always cleanly fits +/// its content: the views and the height math cannot drift apart. +enum TerminalSelectionSheetLayout { + static let headerTopPadding: CGFloat = 14 + static let headerHeight: CGFloat = 36 + static let headerBottomPadding: CGFloat = 12 + static let contextHeight: CGFloat = 16 + static let contextToContentSpacing: CGFloat = 12 + static let contentToActionsSpacing: CGFloat = 16 + static let actionBarHeight: CGFloat = 44 + static let actionsBottomPadding: CGFloat = 8 + + /// The `.height()` detent excludes the bottom safe area (verified by + /// measurement: adding it produced exactly one safe-area of slack), so + /// the sum covers only the content rows the scaffold lays out. + static func sheetHeight(gridHeight: CGFloat) -> CGFloat { + headerTopPadding + headerHeight + headerBottomPadding + + contextHeight + contextToContentSpacing + + gridHeight + + contentToActionsSpacing + actionBarHeight + actionsBottomPadding + } +} + +// Existing non-selector sheets keep their established palette name and styling. +typealias GhosttySheetPalette = TerminalSelectionSheetPalette + +struct TerminalSelectionSheetContextLabel: View { + let text: String + + var body: some View { + Text(text) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(TerminalSelectionSheetPalette.secondary) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: TerminalSelectionSheetLayout.contextHeight) + } +} + +struct TerminalSelectionSheetCloseButton: View { + let title: String + let accessibilityIdentifier: String + let action: () -> Void + + var body: some View { + Button { + Haptic.tap() + action() + } label: { + Image(systemName: "xmark") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(TerminalSelectionSheetPalette.primary) + .frame(width: 36, height: 36) + .background(TerminalSelectionSheetPalette.controlFill, in: Circle()) + } + .accessibilityLabel("Close \(title)") + .accessibilityIdentifier(accessibilityIdentifier) + } +} + +struct TerminalSelectionTileCheckmark: View { + let chromeStyle: GhosttyTerminalChromeStyle + + var body: some View { + Image(systemName: "checkmark") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(chromeStyle.accentForeground) + .frame(width: 22, height: 22) + .background(chromeStyle.accent, in: Circle()) + .overlay { + Circle().strokeBorder(Color.white.opacity(0.24), lineWidth: 0.5) + } + .accessibilityHidden(true) + } +} + +/// Shared anatomy for the terminal selector sheets. Owns its header (title +/// and close button) as plain content — no navigation bar — so the sheet's +/// natural height is fully defined by views the app controls, which is what +/// lets fitted presentation size the sheet to its content. +struct TerminalSelectionSheetScaffold: View { + @Environment(\.dismiss) private var dismiss + + let title: String + let context: String + let closeAccessibilityIdentifier: String + let content: Content + let actions: Actions + + init( + title: String, + context: String, + closeAccessibilityIdentifier: String, + @ViewBuilder content: () -> Content, + @ViewBuilder actions: () -> Actions + ) { + self.title = title + self.context = context + self.closeAccessibilityIdentifier = closeAccessibilityIdentifier + self.content = content() + self.actions = actions() + } + + var body: some View { + VStack(spacing: 0) { + HStack { + TerminalSelectionSheetCloseButton( + title: title, + accessibilityIdentifier: closeAccessibilityIdentifier, + action: dismiss.callAsFunction + ) + + Spacer(minLength: 0) + } + .overlay { + Text(title) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(TerminalSelectionSheetPalette.primary) + .lineLimit(1) + } + .padding(.horizontal, 16) + .frame(height: TerminalSelectionSheetLayout.headerHeight) + .padding(.top, TerminalSelectionSheetLayout.headerTopPadding) + .padding(.bottom, TerminalSelectionSheetLayout.headerBottomPadding) + + VStack(alignment: .leading, spacing: TerminalSelectionSheetLayout.contextToContentSpacing) { + TerminalSelectionSheetContextLabel(text: context) + .padding(.horizontal, 16) + + content + .frame(maxWidth: .infinity, alignment: .top) + } + .frame(maxWidth: .infinity, alignment: .top) + + actions + .padding(.horizontal, 16) + .frame(maxWidth: .infinity) + .frame(height: TerminalSelectionSheetLayout.actionBarHeight) + .padding(.top, TerminalSelectionSheetLayout.contentToActionsSpacing) + .padding(.bottom, TerminalSelectionSheetLayout.actionsBottomPadding) + } + } +} + +struct TerminalSelectionSheetActionButton: View { + let title: String + let systemName: String + let accessibilityIdentifier: String + let action: (() -> Void)? + + var body: some View { + let button = Button { + Haptic.tap() + action?() + } label: { + Label(title, systemImage: systemName) + .font(.body.weight(.semibold)) + .foregroundStyle(TerminalSelectionSheetPalette.primary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .accessibilityIdentifier(accessibilityIdentifier) + .disabled(action == nil) + + if #available(iOS 26.0, *) { + button + .buttonStyle(.glass) + .buttonSizing(.flexible) + .controlSize(.regular) + } else { + button + .buttonStyle(.bordered) + .controlSize(.regular) + } + } +} + +extension View { + func terminalSelectionSheetPresentation( + colorScheme: ColorScheme, + chromeStyle: GhosttyTerminalChromeStyle + ) -> some View { + presentationDetents([.medium]) + .presentationContentInteraction(.scrolls) + .presentationDragIndicator(.hidden) + .terminalSelectionSheetPresentationBackground() + .ghosttyTerminalChromePresentation( + colorScheme, + chromeStyle: chromeStyle + ) + } + + @ViewBuilder + func terminalSelectionSheetPresentationBackground() -> some View { + if #available(iOS 26.0, *) { + self + } else { + self.presentationBackground(.regularMaterial) + } + } + +} diff --git a/RemuxApp/Sources/Tmux/TmuxTerminalScreenAdapter.swift b/RemuxApp/Sources/Tmux/TmuxTerminalScreenAdapter.swift index f733b51f..a45b3523 100644 --- a/RemuxApp/Sources/Tmux/TmuxTerminalScreenAdapter.swift +++ b/RemuxApp/Sources/Tmux/TmuxTerminalScreenAdapter.swift @@ -696,19 +696,13 @@ extension TmuxTerminalScreenAdapter: GhosttyTerminalScreenModeling { ) } - func paneSheetDetentPaneCount(topLevelID: UUID) -> Int { - GhosttyTerminalPresentationProjector.paneSheetDetentPaneCount( + func paneCount(topLevelID: UUID) -> Int { + GhosttyTerminalPresentationProjector.paneCount( topLevelID: topLevelID, snapshot: topologySnapshot ) } - func windowSheetDetentCellCount() -> Int { - GhosttyTerminalPresentationProjector.windowSheetDetentCellCount( - snapshot: topologySnapshot - ) - } - func paneSelectionSheetTopologyProjection( topLevelID: UUID? ) -> GhosttyPaneSelectionSheetTopologyProjection { diff --git a/RemuxAppTests/GhosttyPhoneChromeLayoutTests.swift b/RemuxAppTests/GhosttyPhoneChromeLayoutTests.swift index d161a4a6..d4030b70 100644 --- a/RemuxAppTests/GhosttyPhoneChromeLayoutTests.swift +++ b/RemuxAppTests/GhosttyPhoneChromeLayoutTests.swift @@ -80,37 +80,6 @@ final class GhosttyPhoneChromeLayoutTests: XCTestCase { ) } - func testSelectionSheetKeepsPreferredHeightWhenBottomStackIsShorter() { - let bottomReplacementHeight = GhosttySelectionSheetSizing.bottomReplacementHeight( - bottomChromeHeight: 92, - softwareKeyboardOverlapHeight: 0 - ) - - XCTAssertEqual( - GhosttySelectionSheetSizing.fixedDetentHeight( - preferredHeight: 310, - bottomReplacementHeight: bottomReplacementHeight - ), - 310 - ) - } - - func testSelectionSheetExpandsToReplaceKeyboardAndChromeStack() { - let bottomReplacementHeight = GhosttySelectionSheetSizing.bottomReplacementHeight( - bottomChromeHeight: 92.2, - softwareKeyboardOverlapHeight: 291.4 - ) - - XCTAssertEqual(bottomReplacementHeight, 385) - XCTAssertEqual( - GhosttySelectionSheetSizing.fixedDetentHeight( - preferredHeight: 310, - bottomReplacementHeight: bottomReplacementHeight - ), - 385 - ) - } - func testKeyboardChromeReplacementHeightExcludesBottomSafeArea() { XCTAssertEqual( GhosttyKeyboardChromeSizing.keyboardReplacementHeight( diff --git a/RemuxAppTests/GhosttyTerminalPresentationProjectorTests.swift b/RemuxAppTests/GhosttyTerminalPresentationProjectorTests.swift index 90dbe2c1..d6d0d4e2 100644 --- a/RemuxAppTests/GhosttyTerminalPresentationProjectorTests.swift +++ b/RemuxAppTests/GhosttyTerminalPresentationProjectorTests.swift @@ -4,109 +4,6 @@ import XCTest @MainActor final class GhosttyTerminalPresentationProjectorTests: XCTestCase { - func testSelectionSheetPresentationChangeCancelsAndResetsWhenDismissingPresentedSheet() { - let change = GhosttySelectionSheetPresentationChange( - currentKind: .windows, - nextKind: nil - ) - - XCTAssertTrue(change.shouldCancelCurrentPreviewSession) - XCTAssertTrue(change.shouldResetBottomReplacementHeight) - } - - func testSelectionSheetPresentationChangeResetsAbsentDismissalWithoutCancellation() { - let change = GhosttySelectionSheetPresentationChange( - currentKind: nil, - nextKind: nil - ) - - XCTAssertFalse(change.shouldCancelCurrentPreviewSession) - XCTAssertTrue(change.shouldResetBottomReplacementHeight) - } - - func testSelectionSheetPresentationChangeDoesNotCancelOrResetWhenPresenting() { - let change = GhosttySelectionSheetPresentationChange( - currentKind: nil, - nextKind: .panes - ) - - XCTAssertFalse(change.shouldCancelCurrentPreviewSession) - XCTAssertFalse(change.shouldResetBottomReplacementHeight) - } - - func testSelectionSheetPresentationChangePreservesPresentedReplacementBehavior() { - let change = GhosttySelectionSheetPresentationChange( - currentKind: .windows, - nextKind: .panes - ) - - XCTAssertFalse(change.shouldCancelCurrentPreviewSession) - XCTAssertFalse(change.shouldResetBottomReplacementHeight) - } - - func testSelectionSheetPresentationStatePreservesCapturedHeightWhenPresenting() { - var state = GhosttySelectionSheetPresentationState() - state.captureBottomReplacementHeight(128) - - let change = state.apply(nextKind: .windows) - - XCTAssertEqual(state.presentedKind, .windows) - XCTAssertEqual(state.bottomReplacementHeight, 128) - XCTAssertFalse(change.shouldCancelCurrentPreviewSession) - XCTAssertFalse(change.shouldResetBottomReplacementHeight) - } - - func testSelectionSheetPresentationStateDismissalResetsHeightAndRequestsCancellation() { - var state = GhosttySelectionSheetPresentationState() - state.captureBottomReplacementHeight(96) - _ = state.apply(nextKind: .windows) - - let change = state.apply(nextKind: nil) - - XCTAssertNil(state.presentedKind) - XCTAssertEqual(state.bottomReplacementHeight, 0) - XCTAssertTrue(change.shouldCancelCurrentPreviewSession) - XCTAssertTrue(change.shouldResetBottomReplacementHeight) - } - - func testSelectionSheetPresentationStatePreservesHeightWhenReplacingPresentedSheet() { - var state = GhosttySelectionSheetPresentationState() - state.captureBottomReplacementHeight(72) - _ = state.apply(nextKind: .windows) - - let change = state.apply(nextKind: .panes) - - XCTAssertEqual(state.presentedKind, .panes) - XCTAssertEqual(state.bottomReplacementHeight, 72) - XCTAssertFalse(change.shouldCancelCurrentPreviewSession) - XCTAssertFalse(change.shouldResetBottomReplacementHeight) - } - - func testSelectionSheetPresentationStatePreservesHeightWhenReplacingSameKind() { - var state = GhosttySelectionSheetPresentationState() - state.captureBottomReplacementHeight(64) - _ = state.apply(nextKind: .panes) - - let change = state.apply(nextKind: .panes) - - XCTAssertEqual(state.presentedKind, .panes) - XCTAssertEqual(state.bottomReplacementHeight, 64) - XCTAssertFalse(change.shouldCancelCurrentPreviewSession) - XCTAssertFalse(change.shouldResetBottomReplacementHeight) - } - - func testSelectionSheetPresentationStateAbsentDismissalResetsHeightWithoutCancellation() { - var state = GhosttySelectionSheetPresentationState() - state.captureBottomReplacementHeight(44) - - let change = state.apply(nextKind: nil) - - XCTAssertNil(state.presentedKind) - XCTAssertEqual(state.bottomReplacementHeight, 0) - XCTAssertFalse(change.shouldCancelCurrentPreviewSession) - XCTAssertTrue(change.shouldResetBottomReplacementHeight) - } - func testReadinessProjectionReportsRuntimeStateSemantics() { let reason = TerminalDisconnectReason( kind: .transportIO, diff --git a/RemuxAppTests/PanePreviewLayoutTests.swift b/RemuxAppTests/PanePreviewLayoutTests.swift index a77b56ef..c4a7a1a7 100644 --- a/RemuxAppTests/PanePreviewLayoutTests.swift +++ b/RemuxAppTests/PanePreviewLayoutTests.swift @@ -11,7 +11,6 @@ final class PanePreviewLayoutTests: XCTestCase { XCTAssertEqual(single.tilePointSize.width, 361) XCTAssertEqual(single.previewPointSize, CGSize(width: 345, height: 259)) XCTAssertGreaterThan(single.previewPointSize.width, grid.previewPointSize.width) - XCTAssertEqual(single.sheetDetent, .fixed(457)) } func testTwoPaneUsesTwoColumnGridLayout() { @@ -20,43 +19,22 @@ final class PanePreviewLayoutTests: XCTestCase { XCTAssertEqual(metrics.columnCount, 2) XCTAssertEqual(metrics.tilePointSize, CGSize(width: 175, height: 156)) XCTAssertEqual(metrics.previewPointSize, CGSize(width: 159, height: 120)) - XCTAssertEqual(metrics.sheetDetent, .fixed(318)) } - func testThreePaneUsesTwoRowsWithoutLargeDetentWaste() { + func testThreePaneKeepsTwoColumnTileGeometry() { let twoPane = PanePreviewLayout.metrics(for: 2, availableWidth: 361) let threePane = PanePreviewLayout.metrics(for: 3, availableWidth: 361) XCTAssertEqual(threePane.columnCount, 2) XCTAssertEqual(threePane.previewPointSize, twoPane.previewPointSize) - XCTAssertEqual(threePane.sheetDetent, .fixed(484)) - } - - func testFivePaneUsesLargeDetent() { - let metrics = PanePreviewLayout.metrics(for: 5, availableWidth: 361) - - XCTAssertEqual(metrics.columnCount, 2) - XCTAssertEqual(metrics.sheetDetent, .large) } func testWindowGridUsesTwoColumnTileBudget() { - let metrics = PanePreviewLayout.windowMetrics(cellCount: 2, availableWidth: 361) + let metrics = PanePreviewLayout.windowMetrics(availableWidth: 361) XCTAssertEqual(metrics.columnCount, 2) XCTAssertEqual(metrics.tilePointSize, CGSize(width: 175, height: 172)) XCTAssertEqual(metrics.previewPointSize, CGSize(width: 159, height: 120)) - XCTAssertEqual(metrics.sheetDetent, .fixed(334)) - } - - func testWindowGridUsesLargeDetentAfterThreeRows() { - XCTAssertEqual( - PanePreviewLayout.windowMetrics(cellCount: 6, availableWidth: 361).sheetDetent, - .fixed(698) - ) - XCTAssertEqual( - PanePreviewLayout.windowMetrics(cellCount: 7, availableWidth: 361).sheetDetent, - .large - ) } func testPhysicalPixelBudgetTracksLayoutMetrics() { diff --git a/RemuxAppTests/TmuxTerminalScreenAdapterTests.swift b/RemuxAppTests/TmuxTerminalScreenAdapterTests.swift index d5b09853..cd4c9dd4 100644 --- a/RemuxAppTests/TmuxTerminalScreenAdapterTests.swift +++ b/RemuxAppTests/TmuxTerminalScreenAdapterTests.swift @@ -120,7 +120,6 @@ final class TmuxTerminalScreenAdapterTests: XCTestCase { ) XCTAssertEqual(second.windows.first?.totalCount, 1) XCTAssertEqual(second.windows.first?.displayName, "renamed") - XCTAssertEqual(second.cellCount, 1) await session.shutdown() } From fa8592dce345e31322575f98bb0213704091548a Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:29:26 -0400 Subject: [PATCH 5/8] app: add active session switcher --- Remux.xcodeproj/project.pbxproj | 9 + .../App/ActiveSessionSwitcherView.swift | 277 ++++++++++++++++++ ...ActiveSessionSwitcherProjectionTests.swift | 77 +++++ 3 files changed, 363 insertions(+) create mode 100644 RemuxApp/Sources/App/ActiveSessionSwitcherView.swift create mode 100644 RemuxAppTests/ActiveSessionSwitcherProjectionTests.swift diff --git a/Remux.xcodeproj/project.pbxproj b/Remux.xcodeproj/project.pbxproj index 323f5496..136a290d 100644 --- a/Remux.xcodeproj/project.pbxproj +++ b/Remux.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 00667DCDFC19B202FCDFF8F7 /* GhosttySurfaceSelectionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA2520FAE4E2D228D25CADB /* GhosttySurfaceSelectionSheet.swift */; }; + 00D89A7D58890CC2E420D6E6 /* ActiveSessionSwitcherProjectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DF29AE5BA0A3DD1E7A9E9C5 /* ActiveSessionSwitcherProjectionTests.swift */; }; 017C744C31F059189FA8E489 /* GhosttyRuntimeSurfaceTopologySnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76BB5CC730EB26907A34E42 /* GhosttyRuntimeSurfaceTopologySnapshotTests.swift */; }; 034B17174ABCC1D1DF36C135 /* GhosttyKeyboardCursorTrackpadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DFA7B573241258BA93FCF29 /* GhosttyKeyboardCursorTrackpadTests.swift */; }; 036AB1FE9FEBF1EF99159FD1 /* ShortcutStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9987EA1F7B5FDC3A50ECEA /* ShortcutStore.swift */; }; @@ -27,6 +28,7 @@ 1B0B1376A686D255CD982880 /* GhosttySurfaceScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2788DE85F31E74E9F80A8293 /* GhosttySurfaceScreen.swift */; }; 1CC4024F89C0E668AA4682F0 /* StarterShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 771B767054F02C52F17EB92B /* StarterShortcuts.swift */; }; 1E4598594269BC31B1B28C21 /* RemuxRootModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C76A9A6D14624AC5E1155D8 /* RemuxRootModelTests.swift */; }; + 1F76C1466D9719F98F710A67 /* ActiveSessionSwitcherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C802E6FAF0F2D5CE9F8BCA34 /* ActiveSessionSwitcherView.swift */; }; 1F97978A518B2AD52F9A556A /* GhosttyKeyboardCursorTrackpad.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C7136948AF9803FAAD6F82 /* GhosttyKeyboardCursorTrackpad.swift */; }; 1FCDFBD088BC311CDCBA7C7B /* GhosttyKitRuntimeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BA8119EA500195F1FF488FF /* GhosttyKitRuntimeTests.swift */; }; 200EFC75E198CF325FB0784E /* GhosttyTerminalRuntimeStateReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375579FD92E3B9A605C7E9EC /* GhosttyTerminalRuntimeStateReporter.swift */; }; @@ -285,6 +287,7 @@ 6AA39774D73AE5B6A77D824C /* TerminalSettingsRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSettingsRepository.swift; sourceTree = ""; }; 6B19046A43739DE9328A3612 /* TmuxScreenModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxScreenModel.swift; sourceTree = ""; }; 6B657FFBCF25A6A1A77FD231 /* GhosttyAttachmentImagePreviewDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyAttachmentImagePreviewDataTests.swift; sourceTree = ""; }; + 6DF29AE5BA0A3DD1E7A9E9C5 /* ActiveSessionSwitcherProjectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionSwitcherProjectionTests.swift; sourceTree = ""; }; 7001FC8A87E77C3617046DA7 /* GhosttyRuntimeTrace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyRuntimeTrace.swift; sourceTree = ""; }; 700C6FB2B5DB6D0645B1F5FA /* DebugConnectionProfileSeeder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugConnectionProfileSeeder.swift; sourceTree = ""; }; 71B734F55FC22090646AD0D2 /* GhosttyPendingAttachmentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyPendingAttachmentTests.swift; sourceTree = ""; }; @@ -346,6 +349,7 @@ C6233E6291A38BAAE86C727D /* Remux.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Remux.app; sourceTree = BUILT_PRODUCTS_DIR; }; C6958616F9B9DFD9E0E38E6B /* GhosttySurfaceKeyEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttySurfaceKeyEvent.swift; sourceTree = ""; }; C7313F7C3049C269572F59B5 /* RemuxLibrarySSHPrewarmCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemuxLibrarySSHPrewarmCoordinatorTests.swift; sourceTree = ""; }; + C802E6FAF0F2D5CE9F8BCA34 /* ActiveSessionSwitcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionSwitcherView.swift; sourceTree = ""; }; C944FBD600E4357E517F2DD3 /* TrustedHostStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrustedHostStore.swift; sourceTree = ""; }; CBAB7FE3E70E32E69462BBA1 /* GhosttyTmuxActionTargetResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTmuxActionTargetResolver.swift; sourceTree = ""; }; CC23D872C331C7574737CCDA /* RemuxSSHRootService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemuxSSHRootService.swift; sourceTree = ""; }; @@ -442,6 +446,8 @@ 32334451C65B7C4F00E30040 /* App */ = { isa = PBXGroup; children = ( + C802E6FAF0F2D5CE9F8BCA34 /* ActiveSessionSwitcherView.swift */, + 5087B7872EECF17538D23AF9 /* ConnectionPresentation.swift */, 700C6FB2B5DB6D0645B1F5FA /* DebugConnectionProfileSeeder.swift */, 8926D94B4AAF192302FC58E4 /* Haptic.swift */, E683A3CF94359C176CCEA22A /* RemuxActiveSessionCollection.swift */, @@ -464,6 +470,7 @@ 34D2E4930A61ACCECA627727 /* RemuxAppTests */ = { isa = PBXGroup; children = ( + 6DF29AE5BA0A3DD1E7A9E9C5 /* ActiveSessionSwitcherProjectionTests.swift */, 28EE543D2B247AE6C6A8C472 /* ConnectionProfileRepositoryTests.swift */, DFD9AF0371D15A892B000530 /* DebugConnectionProfileSeederTests.swift */, 2A1CB6877AF169792137FA56 /* GhosttyAttachmentImageMarkupRendererTests.swift */, @@ -847,6 +854,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 1F76C1466D9719F98F710A67 /* ActiveSessionSwitcherView.swift in Sources */, 3F71D9F3D7782DCD769CF47E /* ApplicationStorage.swift in Sources */, 3C0580E8F8A18447EE09DB44 /* ConnectionPresentation.swift in Sources */, E35A4C6758E1D06E01EE5826 /* ConnectionProfileRepository.swift in Sources */, @@ -974,6 +982,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 00D89A7D58890CC2E420D6E6 /* ActiveSessionSwitcherProjectionTests.swift in Sources */, 8F75231F2CF7E597F4D0347F /* ConnectionProfileRepositoryTests.swift in Sources */, 434C420D221F1CAF2A633602 /* DebugConnectionProfileSeederTests.swift in Sources */, EE488E8B043F3160004A3C20 /* GhosttyAttachmentImageMarkupRendererTests.swift in Sources */, diff --git a/RemuxApp/Sources/App/ActiveSessionSwitcherView.swift b/RemuxApp/Sources/App/ActiveSessionSwitcherView.swift new file mode 100644 index 00000000..04c2230a --- /dev/null +++ b/RemuxApp/Sources/App/ActiveSessionSwitcherView.swift @@ -0,0 +1,277 @@ +import SwiftUI + +struct ActiveSessionSwitcherItem: Identifiable, Equatable { + let id: SavedWorkspace.ID + let sessionName: String + let serverName: String + let runtimeState: TerminalRuntimeState + let isSelected: Bool +} + +enum ActiveSessionSwitcherProjection { + static func items( + sessions: [ActiveTerminalSession], + selectedSessionID: SavedWorkspace.ID? + ) -> [ActiveSessionSwitcherItem] { + RemuxActiveSessionCollection.sortedForDisplay(sessions).map { session in + ActiveSessionSwitcherItem( + id: session.id, + sessionName: session.target.workspace.sessionName, + serverName: session.target.server.displayName, + runtimeState: session.runtimeState, + isSelected: session.id == selectedSessionID + ) + } + } + + static func orderedServers( + _ servers: [SavedServer], + currentServerID: SavedServer.ID? + ) -> [SavedServer] { + servers.sorted { lhs, rhs in + let lhsIsCurrent = lhs.id == currentServerID + let rhsIsCurrent = rhs.id == currentServerID + if lhsIsCurrent != rhsIsCurrent { + return lhsIsCurrent + } + return lhs.displayName.localizedStandardCompare(rhs.displayName) == .orderedAscending + } + } +} + +struct ActiveSessionSwitcherView: View { + private enum Route: Hashable { + case chooseServer + } + + @Environment(\.dismiss) private var dismiss + @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle + + let sessions: [ActiveSessionSwitcherItem] + let servers: [SavedServer] + let currentServerID: SavedServer.ID? + let onSelectSession: (SavedWorkspace.ID) -> Void + let onDisconnectSession: (SavedWorkspace.ID) -> Void + let onCreateSession: (SavedServer.ID) -> Void + + @State private var path: [Route] = [] + + var body: some View { + NavigationStack(path: $path) { + sessionList + .toolbar(.hidden, for: .navigationBar) + .navigationDestination(for: Route.self) { route in + switch route { + case .chooseServer: + NewSessionServerPickerView( + servers: ActiveSessionSwitcherProjection.orderedServers( + servers, + currentServerID: currentServerID + ), + currentServerID: currentServerID, + onSelect: beginNewSession + ) + } + } + } + .accessibilityIdentifier("terminal.sessions.sheet") + } + + private var sessionList: some View { + TerminalSelectionSheetScaffold( + title: "Sessions", + context: "\(sessions.count) active", + closeAccessibilityIdentifier: "terminal.sessions.close" + ) { + List { + sessionRows + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .animation(.snappy, value: sessions.map(\.id)) + .accessibilityIdentifier("terminal.sessions.list") + } actions: { + TerminalSelectionSheetActionButton( + title: "New Session…", + systemName: "plus", + accessibilityIdentifier: "terminal.sessions.new", + action: newSessionAction + ) + } + } + + @ViewBuilder + private var sessionRows: some View { + ForEach(sessions) { session in + sessionRow(session) + } + } + + private func sessionRow(_ session: ActiveSessionSwitcherItem) -> some View { + Button { + Haptic.selection() + onSelectSession(session.id) + dismiss() + } label: { + ActiveSessionSwitcherRow( + session: session, + chromeStyle: chromeStyle + ) + } + .buttonStyle(.plain) + .accessibilityIdentifier("terminal.sessions.session") + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + .listRowSeparator(.visible) + .listRowSeparatorTint(TerminalSelectionSheetPalette.stroke) + .listRowBackground(Color.clear) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + Haptic.warning() + onDisconnectSession(session.id) + } label: { + Label("Disconnect", systemImage: "bolt.slash") + } + .accessibilityIdentifier("terminal.sessions.disconnect") + } + .accessibilityAction(named: Text("Disconnect from Remux")) { + onDisconnectSession(session.id) + } + } + + private var newSessionAction: (() -> Void)? { + guard !servers.isEmpty else { return nil } + return showNewSessionFlow + } + + private func showNewSessionFlow() { + if servers.count == 1, let server = servers.first { + beginNewSession(server.id) + return + } + + path.append(.chooseServer) + } + + private func beginNewSession(_ serverID: SavedServer.ID) { + dismiss() + onCreateSession(serverID) + } +} + +private struct ActiveSessionSwitcherRow: View { + let session: ActiveSessionSwitcherItem + let chromeStyle: GhosttyTerminalChromeStyle + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "terminal") + .font(.callout.weight(.semibold)) + .foregroundStyle( + session.isSelected + ? chromeStyle.accent + : TerminalSelectionSheetPalette.secondary + ) + .frame(width: 28, height: 32) + + VStack(alignment: .leading, spacing: 4) { + Text(session.sessionName) + .font(.headline) + .foregroundStyle(TerminalSelectionSheetPalette.primary) + .lineLimit(1) + + HStack(spacing: 6) { + Text(session.serverName) + .lineLimit(1) + + Text("·") + .accessibilityHidden(true) + + TerminalRuntimeStateIndicator(state: session.runtimeState) + } + .font(.footnote) + .foregroundStyle(TerminalSelectionSheetPalette.secondary) + .lineLimit(1) + } + + Spacer(minLength: 8) + + if session.isSelected { + Image(systemName: "checkmark") + .font(.body.weight(.semibold)) + .foregroundStyle(chromeStyle.accent) + .accessibilityHidden(true) + } + } + .frame(minHeight: 52) + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + .accessibilityAddTraits(session.isSelected ? [.isSelected, .isButton] : .isButton) + } + + private var accessibilityLabel: String { + let status = TerminalRuntimeStatusPresentation.projection(for: session.runtimeState).label + let current = session.isSelected ? ", current session" : "" + return "\(session.sessionName), \(session.serverName), \(status)\(current)" + } +} + +private struct NewSessionServerPickerView: View { + let servers: [SavedServer] + let currentServerID: SavedServer.ID? + let onSelect: (SavedServer.ID) -> Void + + var body: some View { + List(servers) { server in + Button { + Haptic.selection() + onSelect(server.id) + } label: { + HStack(spacing: 12) { + Image(systemName: "server.rack") + .font(.callout.weight(.semibold)) + .foregroundStyle(TerminalSelectionSheetPalette.secondary) + .frame(width: 32, height: 32) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(server.displayName) + .font(.headline) + .foregroundStyle(TerminalSelectionSheetPalette.primary) + .lineLimit(1) + + if server.id == currentServerID { + Text("Current") + .font(.caption2.weight(.semibold)) + .foregroundStyle(TerminalSelectionSheetPalette.secondary) + } + } + + Text(server.displayAddress) + .font(.footnote.monospaced()) + .foregroundStyle(TerminalSelectionSheetPalette.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + + Spacer(minLength: 0) + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(TerminalSelectionSheetPalette.tertiary) + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .listRowBackground(Color.clear) + .listRowSeparatorTint(TerminalSelectionSheetPalette.stroke) + .accessibilityIdentifier("terminal.sessions.server") + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .navigationTitle("Choose Server") + .navigationBarTitleDisplayMode(.inline) + .accessibilityIdentifier("terminal.sessions.server-picker") + } +} diff --git a/RemuxAppTests/ActiveSessionSwitcherProjectionTests.swift b/RemuxAppTests/ActiveSessionSwitcherProjectionTests.swift new file mode 100644 index 00000000..37c0b5a8 --- /dev/null +++ b/RemuxAppTests/ActiveSessionSwitcherProjectionTests.swift @@ -0,0 +1,77 @@ +import Foundation +import XCTest +@testable import Remux + +final class ActiveSessionSwitcherProjectionTests: XCTestCase { + func testItemsUseRecentOpenOrderAndMarkOnlySelectedSession() { + let older = makeSession( + serverName: "Production", + sessionName: "api", + lastOpenedAt: Date(timeIntervalSince1970: 100) + ) + let selected = makeSession( + serverName: "Mac Mini", + sessionName: "codex", + lastOpenedAt: Date(timeIntervalSince1970: 200) + ) + + let items = ActiveSessionSwitcherProjection.items( + sessions: [older, selected], + selectedSessionID: selected.id + ) + + XCTAssertEqual(items.map(\.id), [selected.id, older.id]) + XCTAssertEqual(items.map(\.sessionName), ["codex", "api"]) + XCTAssertEqual(items.map(\.serverName), ["Mac Mini", "Production"]) + XCTAssertEqual(items.map(\.isSelected), [true, false]) + } + + func testOrderedServersPlacesCurrentServerFirstThenSortsByName() { + let production = makeServer(name: "Production") + let macMini = makeServer(name: "Mac Mini") + let staging = makeServer(name: "Staging") + + let ordered = ActiveSessionSwitcherProjection.orderedServers( + [production, staging, macMini], + currentServerID: staging.id + ) + + XCTAssertEqual(ordered.map(\.id), [staging.id, macMini.id, production.id]) + } + + private func makeSession( + serverName: String, + sessionName: String, + lastOpenedAt: Date + ) -> ActiveTerminalSession { + let server = makeServer(name: serverName) + let workspace = SavedWorkspace( + serverID: server.id, + sessionName: sessionName, + lastOpenedAt: lastOpenedAt + ) + let auth = ResolvedSSHAuth.password( + username: server.username, + password: "test-password", + identityID: server.identityID, + displayLabel: server.displayName + ) + return ActiveTerminalSession( + target: TmuxConnectionTarget( + server: server, + workspace: workspace, + sshAuth: auth + ), + runtimeState: .connected + ) + } + + private func makeServer(name: String) -> SavedServer { + SavedServer( + displayName: name, + host: "\(name.lowercased().replacingOccurrences(of: " ", with: "-")).example.test", + username: "tester", + identityID: UUID() + ) + } +} From f41c2ce8700174532fdd411557d067edc00a10a6 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:29:38 -0400 Subject: [PATCH 6/8] terminal: wire session switching into the phone dock --- RemuxApp/Sources/App/RootView.swift | 44 ++++++++++++++- .../Ghostty/GhosttyKeyboardChrome.swift | 53 ++++++++++++++----- .../Ghostty/GhosttySurfaceScreen.swift | 16 +++--- .../GhosttyPhoneChromeLayoutTests.swift | 12 ++++- RemuxAppUITests/RemuxAppUITests.swift | 12 +++++ 5 files changed, 115 insertions(+), 22 deletions(-) diff --git a/RemuxApp/Sources/App/RootView.swift b/RemuxApp/Sources/App/RootView.swift index 74086ba1..1807f0f1 100644 --- a/RemuxApp/Sources/App/RootView.swift +++ b/RemuxApp/Sources/App/RootView.swift @@ -59,6 +59,7 @@ private struct RemuxWorkspaceShell: View { @ObservedObject var model: RemuxRootModel let shortcutStore: ShortcutStore @State private var retainedTerminalID: SavedWorkspace.ID? + @State private var isSessionSwitcherPresented = false var body: some View { ZStack { @@ -91,6 +92,29 @@ private struct RemuxWorkspaceShell: View { retainedTerminalID = ids[0] } + .onChange(of: model.state) { _, state in + guard case .terminal = state else { + isSessionSwitcherPresented = false + return + } + } + .sheet(isPresented: $isSessionSwitcherPresented) { + ActiveSessionSwitcherView( + sessions: ActiveSessionSwitcherProjection.items( + sessions: model.activeSessions, + selectedSessionID: selectedTerminalID + ), + servers: model.library.servers, + currentServerID: selectedActiveSession?.target.server.id, + onSelectSession: model.showActiveSession, + onDisconnectSession: model.disconnectActiveSession, + onCreateSession: beginNewWorkspaceFromTerminal + ) + .terminalSelectionSheetPresentation( + colorScheme: model.terminalSettings.theme.terminalChromeColorScheme, + chromeStyle: model.terminalSettings.theme.terminalChromeStyle + ) + } } private var selectedTerminalID: SavedWorkspace.ID? { @@ -105,6 +129,11 @@ private struct RemuxWorkspaceShell: View { selectedTerminalID ?? retainedTerminalID ?? model.activeTerminalScreenEntries.first?.id } + private var selectedActiveSession: ActiveTerminalSession? { + guard let selectedTerminalID else { return nil } + return model.activeSessions.first { $0.id == selectedTerminalID } + } + private var activeTerminalLayer: some View { ZStack { ForEach(model.activeTerminalScreenEntries) { entry in @@ -113,6 +142,7 @@ private struct RemuxWorkspaceShell: View { ActiveTerminalSessionView( entry: entry, isSelected: isSelected, + isAppSheetPresented: isSessionSwitcherPresented && isSelected, shortcutStore: shortcutStore, onReconnect: { model.reconnectActiveSession(entry.id, source: .manualButton) @@ -130,6 +160,9 @@ private struct RemuxWorkspaceShell: View { onTrustHostKey: { model.trustHostKeyAndReconnect(entry.id) }, + onShowSessions: { + isSessionSwitcherPresented = true + }, onShowLibrary: { dismissKeyboard() Task { await model.showLibrary() } @@ -285,11 +318,13 @@ struct RemuxAppLifecycleProjection: Equatable { private struct ActiveTerminalSessionView: View { let entry: ActiveTerminalScreenEntry let isSelected: Bool + let isAppSheetPresented: Bool let shortcutStore: ShortcutStore let onReconnect: () -> Void let onUpdateCredentials: () -> Void let onEditServer: () -> Void let onTrustHostKey: () -> Void + let onShowSessions: () -> Void let onShowLibrary: () -> Void @StateObject private var previewSession: TerminalPreviewSession @@ -297,20 +332,24 @@ private struct ActiveTerminalSessionView: View { init( entry: ActiveTerminalScreenEntry, isSelected: Bool, + isAppSheetPresented: Bool, shortcutStore: ShortcutStore, onReconnect: @escaping () -> Void, onUpdateCredentials: @escaping () -> Void, onEditServer: @escaping () -> Void, onTrustHostKey: @escaping () -> Void, + onShowSessions: @escaping () -> Void, onShowLibrary: @escaping () -> Void ) { self.entry = entry self.isSelected = isSelected + self.isAppSheetPresented = isAppSheetPresented self.shortcutStore = shortcutStore self.onReconnect = onReconnect self.onUpdateCredentials = onUpdateCredentials self.onEditServer = onEditServer self.onTrustHostKey = onTrustHostKey + self.onShowSessions = onShowSessions self.onShowLibrary = onShowLibrary _previewSession = StateObject( wrappedValue: TerminalPreviewSession( @@ -326,12 +365,13 @@ private struct ActiveTerminalSessionView: View { model: entry.model.terminalScreenAdapter, presentation: entry.presentation, isSelected: isSelected, - isTerminalCovered: previewSession.isPresented, + isTerminalCovered: isAppSheetPresented || previewSession.isPresented, shortcutStore: shortcutStore, attachmentTransferServiceFactory: entry.attachmentTransferServiceFactory, onPreviewSelection: previewSelectionHandler, onReconnect: onReconnect, - onEditConnection: onShowLibrary, + onShowSessions: onShowSessions, + onShowLibrary: onShowLibrary, onUpdateCredentials: onUpdateCredentials, onEditServer: onEditServer, onTrustHostKey: onTrustHostKey diff --git a/RemuxApp/Sources/Ghostty/GhosttyKeyboardChrome.swift b/RemuxApp/Sources/Ghostty/GhosttyKeyboardChrome.swift index 3720dcef..3eb04f72 100644 --- a/RemuxApp/Sources/Ghostty/GhosttyKeyboardChrome.swift +++ b/RemuxApp/Sources/Ghostty/GhosttyKeyboardChrome.swift @@ -33,6 +33,7 @@ enum GhosttyKeyboardChromeMode: Equatable { enum GhosttyKeyboardChromeSizing { static let dockButtonHeight: CGFloat = 38 static let dockButtonWidth: CGFloat = 38 + static let compactDockButtonWidth: CGFloat = 35 static let dockButtonCornerRadius: CGFloat = 17 static func keyboardReplacementHeight( @@ -117,7 +118,8 @@ struct GhosttyKeyboardChrome: View { let isAttachmentControlActive: Bool let isAttachmentControlEnabled: Bool let pendingAttachmentCount: Int - let onShowHome: () -> Void + let onShowSessions: () -> Void + let onShowLibrary: () -> Void let onShowWindows: () -> Void let onShowPanes: () -> Void let onShowAttachments: () -> Void @@ -150,7 +152,7 @@ struct GhosttyKeyboardChrome: View { } private var selectorRowContent: some View { - HStack(spacing: isCompact ? 8 : 10) { + HStack(spacing: isCompact ? 6 : 10) { terminalKeyControls navigationControls inputControls @@ -160,23 +162,25 @@ struct GhosttyKeyboardChrome: View { private var navigationControls: some View { controlGroup { - HStack(spacing: 2) { + HStack(spacing: isCompact ? 1 : 2) { GhosttyKeyboardChromeDockButton( - systemName: "house", + systemName: "rectangle.stack", badge: nil, chromeStyle: chromeStyle, - accessibilityLabel: "Home", - accessibilityHint: "Return to the Remux session library.", - accessibilityIdentifier: "terminal.home", + width: dockButtonWidth, + accessibilityLabel: "Sessions", + accessibilityHint: "Switch active sessions or open the Remux library.", + accessibilityIdentifier: "terminal.sessions", isActive: false, isEnabled: true, - action: onShowHome + action: onShowSessions ) GhosttyKeyboardChromeDockButton( systemName: "rectangle.on.rectangle", badge: windowBadge, chromeStyle: chromeStyle, + width: dockButtonWidth, accessibilityLabel: windowAccessibilityLabel, accessibilityHint: windowDetail, accessibilityIdentifier: "terminal.windows", @@ -189,6 +193,7 @@ struct GhosttyKeyboardChrome: View { systemName: "square.split.2x1", badge: paneBadge, chromeStyle: chromeStyle, + width: dockButtonWidth, accessibilityLabel: paneAccessibilityLabel, accessibilityHint: paneDetail, accessibilityIdentifier: "terminal.panes", @@ -202,7 +207,7 @@ struct GhosttyKeyboardChrome: View { private var terminalKeyControls: some View { controlGroup { - HStack(spacing: 2) { + HStack(spacing: isCompact ? 1 : 2) { accessoryKey( title: "ctrl", accessibilityIdentifier: "terminal.ctrl", @@ -224,11 +229,25 @@ struct GhosttyKeyboardChrome: View { private var inputControls: some View { controlGroup { - HStack(spacing: 2) { + HStack(spacing: isCompact ? 1 : 2) { + GhosttyKeyboardChromeDockButton( + systemName: "house", + badge: nil, + chromeStyle: chromeStyle, + width: dockButtonWidth, + accessibilityLabel: "Home", + accessibilityHint: "Open the Remux library.", + accessibilityIdentifier: "terminal.home", + isActive: false, + isEnabled: true, + action: onShowLibrary + ) + GhosttyKeyboardChromeDockButton( systemName: "paperclip", badge: attachmentBadge, chromeStyle: chromeStyle, + width: dockButtonWidth, accessibilityLabel: attachmentAccessibilityLabel, accessibilityHint: attachmentAccessibilityHint, accessibilityIdentifier: "terminal.attachments", @@ -241,6 +260,7 @@ struct GhosttyKeyboardChrome: View { systemName: "keyboard", badge: nil, chromeStyle: chromeStyle, + width: dockButtonWidth, accessibilityLabel: keyboardMode == .hidden ? "Show keyboard controls" : "Hide keyboard controls", accessibilityHint: nil, accessibilityIdentifier: "terminal.keyboard", @@ -276,7 +296,7 @@ struct GhosttyKeyboardChrome: View { private func controlGroup(@ViewBuilder content: () -> Content) -> some View { content() - .padding(.horizontal, 5) + .padding(.horizontal, isCompact ? 3 : 5) .padding(.vertical, 4) .ghosttyToolbarGroupSurface() } @@ -293,7 +313,7 @@ struct GhosttyKeyboardChrome: View { accessibilityIdentifier: accessibilityIdentifier, chromeStyle: chromeStyle, fontSize: 12, - width: GhosttyKeyboardChromeSizing.dockButtonWidth, + width: dockButtonWidth, height: GhosttyKeyboardChromeSizing.dockButtonHeight, isActive: isActive, isEnabled: isEnabled, @@ -302,6 +322,12 @@ struct GhosttyKeyboardChrome: View { ) } + private var dockButtonWidth: CGFloat { + isCompact + ? GhosttyKeyboardChromeSizing.compactDockButtonWidth + : GhosttyKeyboardChromeSizing.dockButtonWidth + } + private var windowDetail: String? { windowCount > 1 ? "switch or create" : "create" } @@ -339,6 +365,7 @@ private struct GhosttyKeyboardChromeDockButton: View { let systemName: String let badge: String? let chromeStyle: GhosttyTerminalChromeStyle + let width: CGFloat let accessibilityLabel: String let accessibilityHint: String? let accessibilityIdentifier: String @@ -362,7 +389,7 @@ private struct GhosttyKeyboardChromeDockButton: View { isActive: isActive, isEnabled: isEnabled, chromeStyle: chromeStyle, - width: GhosttyKeyboardChromeSizing.dockButtonWidth, + width: width, height: GhosttyKeyboardChromeSizing.dockButtonHeight )) .disabled(!isEnabled) diff --git a/RemuxApp/Sources/Ghostty/GhosttySurfaceScreen.swift b/RemuxApp/Sources/Ghostty/GhosttySurfaceScreen.swift index 9a225d56..d4daf405 100644 --- a/RemuxApp/Sources/Ghostty/GhosttySurfaceScreen.swift +++ b/RemuxApp/Sources/Ghostty/GhosttySurfaceScreen.swift @@ -86,7 +86,8 @@ struct GhosttySurfaceScreen: View { @State private var attachmentNotice: GhosttyAttachmentNotice? private let onReconnect: () -> Void - private let onEditConnection: () -> Void + private let onShowSessions: () -> Void + private let onShowLibrary: () -> Void private let onUpdateCredentials: () -> Void private let onEditServer: () -> Void private let onTrustHostKey: () -> Void @@ -104,7 +105,8 @@ struct GhosttySurfaceScreen: View { attachmentTransferServiceFactory: @escaping @Sendable () -> any GhosttyAttachmentTransferService, onPreviewSelection: ((UUID, TerminalPreviewCandidate) -> Void)? = nil, onReconnect: @escaping () -> Void, - onEditConnection: @escaping () -> Void, + onShowSessions: @escaping () -> Void, + onShowLibrary: @escaping () -> Void, onUpdateCredentials: @escaping () -> Void, onEditServer: @escaping () -> Void, onTrustHostKey: @escaping () -> Void @@ -117,7 +119,8 @@ struct GhosttySurfaceScreen: View { self.attachmentTransferServiceFactory = attachmentTransferServiceFactory self.onPreviewSelection = onPreviewSelection self.onReconnect = onReconnect - self.onEditConnection = onEditConnection + self.onShowSessions = onShowSessions + self.onShowLibrary = onShowLibrary self.onUpdateCredentials = onUpdateCredentials self.onEditServer = onEditServer self.onTrustHostKey = onTrustHostKey @@ -250,7 +253,7 @@ struct GhosttySurfaceScreen: View { onReconnect: onReconnect, onUpdateCredentials: onUpdateCredentials, onEditServer: onEditServer, - onCancel: onEditConnection, + onCancel: onShowLibrary, onTrustHostKey: onTrustHostKey ) } @@ -348,7 +351,8 @@ struct GhosttySurfaceScreen: View { isAttachmentControlActive: isAttachmentTrayPresented, isAttachmentControlEnabled: !hasPendingAttachments, pendingAttachmentCount: pendingAttachments.count, - onShowHome: onEditConnection, + onShowSessions: onShowSessions, + onShowLibrary: onShowLibrary, onShowWindows: showWindows, onShowPanes: showPanes, onShowAttachments: toggleAttachmentTray, @@ -2243,7 +2247,7 @@ struct GhosttyPhoneChromeLayout: Equatable { } var isCompact: Bool { - isLandscape + isLandscape || screenSize.width < 420 } var surfaceHorizontalPadding: CGFloat { diff --git a/RemuxAppTests/GhosttyPhoneChromeLayoutTests.swift b/RemuxAppTests/GhosttyPhoneChromeLayoutTests.swift index d4030b70..69e8cc0f 100644 --- a/RemuxAppTests/GhosttyPhoneChromeLayoutTests.swift +++ b/RemuxAppTests/GhosttyPhoneChromeLayoutTests.swift @@ -3,11 +3,21 @@ import XCTest @testable import Remux final class GhosttyPhoneChromeLayoutTests: XCTestCase { - func testPortraitUsesStableExpandedChrome() { + func testNarrowPortraitUsesCompactChrome() { let layout = GhosttyPhoneChromeLayout( screenSize: CGSize(width: 390, height: 844) ) + XCTAssertTrue(layout.isCompact) + XCTAssertEqual(layout.surfaceHorizontalPadding, 8) + XCTAssertEqual(layout.bottomPadding, 2) + } + + func testWidePortraitUsesExpandedChrome() { + let layout = GhosttyPhoneChromeLayout( + screenSize: CGSize(width: 430, height: 932) + ) + XCTAssertFalse(layout.isCompact) XCTAssertEqual(layout.surfaceHorizontalPadding, 12) XCTAssertEqual(layout.bottomPadding, 4) diff --git a/RemuxAppUITests/RemuxAppUITests.swift b/RemuxAppUITests/RemuxAppUITests.swift index 07636c47..7875ab7b 100644 --- a/RemuxAppUITests/RemuxAppUITests.swift +++ b/RemuxAppUITests/RemuxAppUITests.swift @@ -3256,6 +3256,18 @@ final class RemuxAppUITests: XCTestCase { } private func dismissTopSheetIfPresent() { + for identifier in [ + "terminal.panes.close", + "terminal.windows.close", + "terminal.sessions.close", + ] { + let closeButton = app.buttons[identifier] + if closeButton.exists, closeButton.isHittable { + closeButton.tap() + return + } + } + let sheet = app.otherElements.matching(identifier: "PopoverDismissRegion").firstMatch if sheet.exists, sheet.isHittable { sheet.tap() From bedd1bddc5fcfc19db3d552d387f3a4535558ffb Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:40:14 -0400 Subject: [PATCH 7/8] terminal: clamp partial grid height to the full grid --- RemuxApp/Sources/Ghostty/PanePreviewLayout.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RemuxApp/Sources/Ghostty/PanePreviewLayout.swift b/RemuxApp/Sources/Ghostty/PanePreviewLayout.swift index 12d782a6..d7c660ab 100644 --- a/RemuxApp/Sources/Ghostty/PanePreviewLayout.swift +++ b/RemuxApp/Sources/Ghostty/PanePreviewLayout.swift @@ -50,7 +50,7 @@ enum PanePreviewLayout { while height(fullRows: rows + 1) <= budget { rows += 1 } - return height(fullRows: rows) + return min(height(fullRows: rows), fullHeight) } private static let defaultSheetContentWidth: CGFloat = 361 From 8440c855663a4443418a543f57055d3ac7fc9d78 Mon Sep 17 00:00:00 2001 From: henok3878 <81500872+henok3878@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:43:30 -0400 Subject: [PATCH 8/8] terminal: share selector tile chrome --- .../GhosttySurfaceSelectionSheet.swift | 36 ++----------------- .../Ghostty/TerminalSelectionSheetStyle.swift | 23 ++++++++++++ 2 files changed, 25 insertions(+), 34 deletions(-) diff --git a/RemuxApp/Sources/Ghostty/GhosttySurfaceSelectionSheet.swift b/RemuxApp/Sources/Ghostty/GhosttySurfaceSelectionSheet.swift index 2ce131a6..c42b5cff 100644 --- a/RemuxApp/Sources/Ghostty/GhosttySurfaceSelectionSheet.swift +++ b/RemuxApp/Sources/Ghostty/GhosttySurfaceSelectionSheet.swift @@ -524,23 +524,7 @@ private struct GhosttyWindowSelectionTile: View { height: layout.tilePointSize.height, alignment: .topLeading ) - .background(TerminalSelectionSheetPalette.row) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder( - isSelected - ? TerminalSelectionSheetPalette.selectedStroke(chromeStyle) - : TerminalSelectionSheetPalette.stroke, - lineWidth: isSelected ? 1.25 : 1 - ) - } - .overlay(alignment: .topTrailing) { - if isSelected { - TerminalSelectionTileCheckmark(chromeStyle: chromeStyle) - .padding(6) - } - } + .terminalSelectionTileChrome(isSelected: isSelected, chromeStyle: chromeStyle) .accessibilityElement(children: .ignore) .accessibilityLabel(accessibilityLabel) .accessibilityValue(previewState.accessibilityValue) @@ -630,23 +614,7 @@ private struct GhosttyPaneSelectionTile: View { height: layout.tilePointSize.height, alignment: .topLeading ) - .background(TerminalSelectionSheetPalette.row) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder( - isSelected - ? TerminalSelectionSheetPalette.selectedStroke(chromeStyle) - : TerminalSelectionSheetPalette.stroke, - lineWidth: isSelected ? 1.25 : 1 - ) - } - .overlay(alignment: .topTrailing) { - if isSelected { - TerminalSelectionTileCheckmark(chromeStyle: chromeStyle) - .padding(6) - } - } + .terminalSelectionTileChrome(isSelected: isSelected, chromeStyle: chromeStyle) .accessibilityElement(children: .ignore) .accessibilityLabel(accessibilityLabel) .accessibilityValue(state.accessibilityValue) diff --git a/RemuxApp/Sources/Ghostty/TerminalSelectionSheetStyle.swift b/RemuxApp/Sources/Ghostty/TerminalSelectionSheetStyle.swift index 2bcbb68b..3d487103 100644 --- a/RemuxApp/Sources/Ghostty/TerminalSelectionSheetStyle.swift +++ b/RemuxApp/Sources/Ghostty/TerminalSelectionSheetStyle.swift @@ -194,6 +194,29 @@ struct TerminalSelectionSheetActionButton: View { } extension View { + func terminalSelectionTileChrome( + isSelected: Bool, + chromeStyle: GhosttyTerminalChromeStyle + ) -> some View { + background(TerminalSelectionSheetPalette.row) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder( + isSelected + ? TerminalSelectionSheetPalette.selectedStroke(chromeStyle) + : TerminalSelectionSheetPalette.stroke, + lineWidth: isSelected ? 1.25 : 1 + ) + } + .overlay(alignment: .topTrailing) { + if isSelected { + TerminalSelectionTileCheckmark(chromeStyle: chromeStyle) + .padding(6) + } + } + } + func terminalSelectionSheetPresentation( colorScheme: ColorScheme, chromeStyle: GhosttyTerminalChromeStyle