Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
A5001501 /* UITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001511 /* UITestRecorder.swift */; };
A5001226 /* SocketControlSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001225 /* SocketControlSettings.swift */; };
A5001601 /* SentryHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001600 /* SentryHelper.swift */; };
A5001602 /* SentrySymbolizerStub.c in Sources */ = {isa = PBXBuildFile; fileRef = A5001603 /* SentrySymbolizerStub.c */; };
A5001604 /* TmuxSessionPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001605 /* TmuxSessionPickerSheet.swift */; };
A5001606 /* TmuxControlParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001607 /* TmuxControlParser.swift */; };
A5001608 /* TmuxLayoutReconciler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001609 /* TmuxLayoutReconciler.swift */; };
A5001621 /* AppleScriptSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001620 /* AppleScriptSupport.swift */; };
D1320AA0D1320AA0D1320AA1 /* AppIconDockTilePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */; };
D1320AA0D1320AA0D1320AA2 /* CmuxDockTilePlugin.plugin in Copy Dock Tile Plugin */ = {isa = PBXBuildFile; fileRef = D1320AA0D1320AA0D1320AA5 /* CmuxDockTilePlugin.plugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
Expand Down Expand Up @@ -122,6 +126,8 @@
1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */; };
46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */; };
6B524A0BA34FD46A771335AB /* WorkspaceUnitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */; };
F5ADBBF1059E95176B66E842 /* TmuxControlParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3176B9EF001F07720476B3CE /* TmuxControlParserTests.swift */; };
E816ABB9D0A581FCAD1BAF19 /* TmuxLayoutReconcilerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 657304DC9CA7CF79E3E9463E /* TmuxLayoutReconcilerTests.swift */; };
063BC42CEE257D6213A2E30C /* WindowAndDragTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */; };
1521D55DC63D5E5FC4955E31 /* ShortcutAndCommandPaletteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */; };
CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */; };
Expand Down Expand Up @@ -225,6 +231,10 @@
A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = "<group>"; };
A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = "<group>"; };
A5001600 /* SentryHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryHelper.swift; sourceTree = "<group>"; };
A5001603 /* SentrySymbolizerStub.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = SentrySymbolizerStub.c; sourceTree = "<group>"; };
A5001605 /* TmuxSessionPickerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TmuxSessionPickerSheet.swift; sourceTree = "<group>"; };
A5001607 /* TmuxControlParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxControlParser.swift; sourceTree = "<group>"; };
A5001609 /* TmuxLayoutReconciler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxLayoutReconciler.swift; sourceTree = "<group>"; };
A5001620 /* AppleScriptSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptSupport.swift; sourceTree = "<group>"; };
D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconDockTilePlugin.swift; sourceTree = "<group>"; };
A5001510 /* CmuxWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CmuxWebView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -320,6 +330,8 @@
58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelTests.swift; sourceTree = "<group>"; };
02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAndGhosttyTests.swift; sourceTree = "<group>"; };
71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceUnitTests.swift; sourceTree = "<group>"; };
3176B9EF001F07720476B3CE /* TmuxControlParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxControlParserTests.swift; sourceTree = "<group>"; };
657304DC9CA7CF79E3E9463E /* TmuxLayoutReconcilerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxLayoutReconcilerTests.swift; sourceTree = "<group>"; };
BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAndDragTests.swift; sourceTree = "<group>"; };
6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutAndCommandPaletteTests.swift; sourceTree = "<group>"; };
BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarOrderingTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -479,6 +491,10 @@
A5001545 /* TerminalSSHSessionDetector.swift */,
A5001225 /* SocketControlSettings.swift */,
A5001600 /* SentryHelper.swift */,
A5001603 /* SentrySymbolizerStub.c */,
A5001605 /* TmuxSessionPickerSheet.swift */,
A5001607 /* TmuxControlParser.swift */,
A5001609 /* TmuxLayoutReconciler.swift */,
A5001620 /* AppleScriptSupport.swift */,
D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */,
A5001090 /* AppDelegate.swift */,
Expand Down Expand Up @@ -600,6 +616,8 @@
58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */,
02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */,
71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */,
3176B9EF001F07720476B3CE /* TmuxControlParserTests.swift */,
657304DC9CA7CF79E3E9463E /* TmuxLayoutReconcilerTests.swift */,
BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */,
6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */,
BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */,
Expand Down Expand Up @@ -805,6 +823,10 @@
A5001543 /* TerminalSSHSessionDetector.swift in Sources */,
A5001226 /* SocketControlSettings.swift in Sources */,
A5001601 /* SentryHelper.swift in Sources */,
A5001602 /* SentrySymbolizerStub.c in Sources */,
A5001604 /* TmuxSessionPickerSheet.swift in Sources */,
A5001606 /* TmuxControlParser.swift in Sources */,
A5001608 /* TmuxLayoutReconciler.swift in Sources */,
A5001621 /* AppleScriptSupport.swift in Sources */,
A5001093 /* AppDelegate.swift in Sources */,
A5001094 /* NotificationsPage.swift in Sources */,
Expand Down Expand Up @@ -900,6 +922,8 @@
1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */,
46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */,
6B524A0BA34FD46A771335AB /* WorkspaceUnitTests.swift in Sources */,
F5ADBBF1059E95176B66E842 /* TmuxControlParserTests.swift in Sources */,
E816ABB9D0A581FCAD1BAF19 /* TmuxLayoutReconcilerTests.swift in Sources */,
063BC42CEE257D6213A2E30C /* WindowAndDragTests.swift in Sources */,
1521D55DC63D5E5FC4955E31 /* ShortcutAndCommandPaletteTests.swift in Sources */,
CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */,
Expand Down
152 changes: 152 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -87289,6 +87289,158 @@
}
}
}
},
"tmux.picker.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Connect to tmux Session"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "tmuxセッションに接続"
}
}
}
},
"tmux.picker.subtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "This remote host has tmux. Select a session to attach to, or create a new one. Your shells will survive network disconnects."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "このリモートホストはtmuxが使えます。接続するセッションを選択するか、新規作成してください。シェルはネットワーク切断後も維持されます。"
}
}
}
},
"tmux.picker.existing": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Existing sessions"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "既存のセッション"
}
}
}
},
"tmux.picker.windows.one": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": { "state": "translated", "value": "1 window" }
},
"ja": {
"stringUnit": { "state": "translated", "value": "1ウィンドウ" }
}
}
},
"tmux.picker.windows.many": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": { "state": "translated", "value": "%lld windows" }
},
"ja": {
"stringUnit": { "state": "translated", "value": "%lldウィンドウ" }
}
}
},
"tmux.picker.attached": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": { "state": "translated", "value": " · attached" }
},
"ja": {
"stringUnit": { "state": "translated", "value": " · 接続中" }
}
}
},
"tmux.picker.new": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Create new session"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "新規セッションを作成"
}
}
}
},
"tmux.picker.create": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Create"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "作成"
}
}
}
},
"tmux.picker.skip": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Skip"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "スキップ"
}
}
}
},
"tmux.picker.invalid_name": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Session names may only contain letters, digits, - and _."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "セッション名には英数字、-、_のみ使用できます。"
}
}
}
}
}
}
157 changes: 157 additions & 0 deletions Sources/Panels/TmuxSessionPickerSheet.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import SwiftUI

/// Sheet shown after connecting to a remote that has tmux available.
/// Lets the user pick an existing session or create a new one.
struct TmuxSessionPickerSheet: View {
@ObservedObject var workspace: Workspace
@Environment(\.dismiss) private var dismiss

@State private var newSessionName: String = ""
/// Prevents double-submission if the user taps Create rapidly.
@State private var isSubmitting: Bool = false

private var suggestedNewName: String {
"cmux-\(workspace.id.uuidString.prefix(8).lowercased())"
}

/// Returns true when the effective new-session name is acceptable to tmux.
private var newNameIsValid: Bool {
let name = effectiveNewName
return name.range(of: #"^[a-zA-Z0-9_-]+$"#, options: .regularExpression) != nil
}

var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text(String(localized: "tmux.picker.title", defaultValue: "Connect to tmux Session"))
.font(.title3.weight(.semibold))

Text(
String(
localized: "tmux.picker.subtitle",
defaultValue: "This remote host has tmux. Select a session to attach to, or create a new one. Your shells will survive network disconnects."
)
)
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)

if !workspace.remoteTmuxSessions.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Text(String(localized: "tmux.picker.existing", defaultValue: "Existing sessions"))
.font(.subheadline.weight(.medium))

VStack(spacing: 2) {
ForEach(workspace.remoteTmuxSessions) { session in
Button {
guard !isSubmitting else { return }
isSubmitting = true
// Do NOT call dismiss() here. The sheet is presented via
// `showTmuxSessionPicker` and will be dismissed automatically
// when `applyRemoteTmuxSession` sets that flag to false on
// a successful attach. This keeps the picker open if attach fails.
workspace.selectTmuxSession(session.name)
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(session.name)
.font(.system(size: 13, design: .monospaced))
Text(sessionInfoLine(session))
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "arrow.right.circle")
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(nsColor: .controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
.disabled(isSubmitting)
}
}
}
}

Divider()

VStack(alignment: .leading, spacing: 8) {
Text(String(localized: "tmux.picker.new", defaultValue: "Create new session"))
.font(.subheadline.weight(.medium))

HStack(spacing: 8) {
TextField(suggestedNewName, text: $newSessionName)
.font(.system(size: 13, design: .monospaced))
.textFieldStyle(.roundedBorder)
.onSubmit { createSession() }
.disabled(isSubmitting)

Button(String(localized: "tmux.picker.create", defaultValue: "Create")) {
createSession()
}
.keyboardShortcut(.defaultAction)
.disabled(isSubmitting || !newNameIsValid)
}

if !newSessionName.isEmpty && !newNameIsValid {
Text(String(
localized: "tmux.picker.invalid_name",
defaultValue: "Session names may only contain letters, digits, - and _."
))
.font(.system(size: 11))
.foregroundStyle(.red)
}
}

HStack {
Spacer()
Button(String(localized: "tmux.picker.skip", defaultValue: "Skip")) {
// Use skipTmuxPicker() rather than dismiss() so the workspace can
// record that the user explicitly opted out. Without this, subsequent
// terminal lifecycle events would immediately re-show the picker.
workspace.skipTmuxPicker()
}
.keyboardShortcut(.cancelAction)
.disabled(isSubmitting)
}
}
.padding(24)
.frame(width: 460)
.onChange(of: workspace.remoteDaemonStatus) { status in
// If the daemon reports an error after we submitted, the attach failed.
// Reset isSubmitting so the user can retry or choose a different session.
if isSubmitting, status.state == .error {
isSubmitting = false
}
}
Comment on lines +122 to +128
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 isSubmitting can get permanently stuck on a silent early-return

isSubmitting is only cleared when remoteDaemonStatus.state == .error. Workspace.selectTmuxSession can return early without publishing any daemon error if remoteSessionController is nil or if the controller's daemonRemotePath is nil at dispatch time (e.g., a concurrent disconnect between discovery and the user tapping Create/Select). In that path isSubmitting stays true indefinitely, leaving all picker buttons permanently disabled until the sheet is closed and reopened.

}

private func sessionInfoLine(_ session: RemoteTmuxSession) -> String {
let windowPart = session.windows == 1
? String(localized: "tmux.picker.windows.one", defaultValue: "1 window")
: String(format: String(localized: "tmux.picker.windows.many", defaultValue: "%lld windows"),
Int64(session.windows))
if session.attached {
return windowPart + String(localized: "tmux.picker.attached", defaultValue: " · attached")
}
return windowPart
}

private var effectiveNewName: String {
let trimmed = newSessionName.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? suggestedNewName : trimmed
}

private func createSession() {
guard !isSubmitting, newNameIsValid else { return }
let name = effectiveNewName
guard !name.isEmpty else { return }
isSubmitting = true
// Do NOT call dismiss() here. The sheet is dismissed automatically when
// `applyRemoteTmuxSession` sets `showTmuxSessionPicker = false` on success.
// This keeps the picker visible (with the spinner) if attachment fails.
workspace.selectTmuxSession(name)
}
}
Loading