-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat: tmux control mode integration — session picker, layout sync, reconciler #2690
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
+3,088
−6
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| } | ||
| } | ||
|
|
||
| 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) | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isSubmittingcan get permanently stuck on a silent early-returnisSubmittingis only cleared whenremoteDaemonStatus.state == .error.Workspace.selectTmuxSessioncan return early without publishing any daemon error ifremoteSessionControlleris nil or if the controller'sdaemonRemotePathis nil at dispatch time (e.g., a concurrent disconnect between discovery and the user tapping Create/Select). In that pathisSubmittingstaystrueindefinitely, leaving all picker buttons permanently disabled until the sheet is closed and reopened.