Repository navigation
Label SSH and Cloud workspaces by host in window titles and Task Manager #15270
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
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
437f2b3
Label SSH and Cloud workspaces by host in window titles and Task Manager
teamleaderleo e119861
Address review: IPv6-only brackets, Port= option, title refresh order
teamleaderleo 4b468bb
Merge origin/main into feat/workspace-host-labels
teamleaderleo 6eef198
Keep host label code in existing files to avoid project churn
teamleaderleo 3add325
Merge origin/main into feat/workspace-host-labels
teamleaderleo df2d111
Merge origin/main into feat/workspace-host-labels
teamleaderleo 3cba4f6
Merge origin/main into feat/workspace-host-labels
teamleaderleo 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
54 changes: 54 additions & 0 deletions
54
Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Workspace/SSHDestination.swift
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,54 @@ | ||
| import Foundation | ||
|
|
||
| /// The parts of an SSH destination that identify the host. | ||
| struct SSHDestination: Equatable { | ||
| let user: String? | ||
| /// Host name, alias or IP address, without IPv6 brackets. | ||
| let host: String | ||
| let port: Int? | ||
|
|
||
| init(user: String?, host: String, port: Int?) { | ||
| self.user = user | ||
| self.host = host | ||
| self.port = port | ||
| } | ||
|
|
||
| init?(_ rawValue: String) { | ||
| var rest = Substring(rawValue.trimmingCharacters(in: .whitespacesAndNewlines)) | ||
| var allowsPort = false | ||
| if rest.lowercased().hasPrefix("ssh://") { | ||
| rest = rest.dropFirst("ssh://".count) | ||
| if let slash = rest.firstIndex(of: "/") { rest = rest[..<slash] } | ||
| allowsPort = true | ||
| } | ||
|
|
||
| // OpenSSH splits user and host at the last "@". | ||
| var user: String? | ||
| if let at = rest.lastIndex(of: "@") { | ||
| let userPart = rest[..<at] | ||
| user = userPart.isEmpty ? nil : String(userPart) | ||
| rest = rest[rest.index(after: at)...] | ||
| } | ||
|
|
||
| var port: Int? | ||
| if rest.hasPrefix("[") { | ||
| guard let close = rest.firstIndex(of: "]") else { return nil } | ||
| let tail = rest[rest.index(after: close)...] | ||
| if tail.hasPrefix(":") { | ||
| port = Int(tail.dropFirst()) | ||
| } | ||
| rest = rest[rest.index(after: rest.startIndex)..<close] | ||
| } else if allowsPort, | ||
| let colon = rest.lastIndex(of: ":"), | ||
| rest.firstIndex(of: ":") == colon { | ||
| // Only a URI carries a port after a single colon. A bare destination | ||
| // like `host:2222` is not valid ssh syntax, and bare IPv6 has many colons. | ||
| port = Int(rest[rest.index(after: colon)...]) | ||
| rest = rest[..<colon] | ||
| } | ||
|
|
||
| let host = rest.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| guard !host.isEmpty else { return nil } | ||
| self.init(user: user, host: host, port: port) | ||
| } | ||
| } |
118 changes: 118 additions & 0 deletions
118
Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Workspace/WorkspaceHostLabel.swift
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,118 @@ | ||
| import Foundation | ||
|
|
||
| /// Where a workspace runs, derived from its connection rather than from the | ||
| /// title the user typed. | ||
| /// | ||
| /// Surfaces that label or group workspaces by host (window titles, the Task | ||
| /// Manager) share this one value so they agree. | ||
| /// | ||
| /// - SSH: built from the `cmux ssh` destination. The label is the host part | ||
| /// with the user and port removed (`leo@big-red:2222` shows as `big-red`). | ||
| /// An ssh_config alias stays as typed; cmux never resolves it to its | ||
| /// `HostName`, because the alias is the name the user knows the host by. | ||
| /// - Cloud: built from the Cloud machine. The label is the machine's name, | ||
| /// or its id when the name is unknown. | ||
| /// - Local: this Mac. It has no label; surfaces show nothing extra. | ||
| public struct WorkspaceHostLabel: Hashable, Sendable { | ||
| /// The kind of host a workspace runs on. | ||
| public enum Kind: String, Hashable, Sendable { | ||
| case local | ||
| case ssh | ||
| case cloud | ||
| } | ||
|
|
||
| /// The kind of host. | ||
| public let kind: Kind | ||
| /// Short display label (`big-red`, `my-vm`). Empty for local workspaces. | ||
| public let label: String | ||
| /// Full identity for tooltips and accessibility (`leo@big-red:2222`, | ||
| /// `my-vm (vm_123)`). Empty for local workspaces. | ||
| public let detail: String | ||
| /// Stable, case-insensitive key for grouping workspaces that share a host: | ||
| /// `local`, `ssh:<host>[:<port>]` (user ignored) or `cloud:<machine id>`. | ||
| public let groupingKey: String | ||
|
|
||
| /// This Mac. | ||
| public static let local = WorkspaceHostLabel(kind: .local, label: "", detail: "", groupingKey: "local") | ||
|
|
||
| /// True for SSH and Cloud workspaces. | ||
| public var isRemote: Bool { kind != .local } | ||
|
|
||
| private init(kind: Kind, label: String, detail: String, groupingKey: String) { | ||
| self.kind = kind | ||
| self.label = label | ||
| self.detail = detail | ||
| self.groupingKey = groupingKey | ||
| } | ||
|
|
||
| /// Label for an SSH workspace. | ||
| /// | ||
| /// - Parameters: | ||
| /// - destination: The SSH destination: `host`, `user@host`, `user@[::1]` | ||
| /// or `ssh://user@host:port`. | ||
| /// - port: The explicitly configured port, if any. It wins over a port in | ||
| /// an `ssh://` URI, matching `ssh -p`. | ||
| /// - Returns: `nil` when the destination has no host. | ||
| public static func ssh(destination: String, port: Int? = nil) -> WorkspaceHostLabel? { | ||
| guard let parsed = SSHDestination(destination) else { return nil } | ||
| let effectivePort = port ?? parsed.port | ||
| // Only IPv6 literals (two or more colons) need brackets before a port. | ||
| let isIPv6 = parsed.host.filter { $0 == ":" }.count >= 2 | ||
| let bracketedHost = isIPv6 ? "[\(parsed.host)]" : parsed.host | ||
| var detail = parsed.user.map { "\($0)@\(bracketedHost)" } ?? bracketedHost | ||
| var key = "ssh:" + bracketedHost.lowercased() | ||
| if let effectivePort { | ||
| detail += ":\(effectivePort)" | ||
| key += ":\(effectivePort)" | ||
| } | ||
| return WorkspaceHostLabel(kind: .ssh, label: parsed.host, detail: detail, groupingKey: key) | ||
| } | ||
|
|
||
| /// Label for a Cloud machine workspace. | ||
| /// | ||
| /// - Parameters: | ||
| /// - machineID: The Cloud machine id. | ||
| /// - machineName: The machine's display name, when known. | ||
| /// - Returns: `nil` when the id is empty. | ||
| public static func cloud(machineID: String, machineName: String?) -> WorkspaceHostLabel? { | ||
| let id = machineID.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| guard !id.isEmpty else { return nil } | ||
| let name = machineName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" | ||
| let label = name.isEmpty ? id : name | ||
| let detail = label == id ? id : "\(label) (\(id))" | ||
| return WorkspaceHostLabel(kind: .cloud, label: label, detail: detail, groupingKey: "cloud:" + id.lowercased()) | ||
| } | ||
|
|
||
| /// Appends the host label to a window title (`title · host`), unless the | ||
| /// workspace is local or the title already names the host. | ||
| /// | ||
| /// - Parameter title: The workspace's display title. | ||
| /// - Returns: The title to show in window chrome. | ||
| public func windowTitle(appendingTo title: String) -> String { | ||
| guard isRemote, !label.isEmpty else { return title } | ||
| let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| guard !trimmed.isEmpty else { return label } | ||
| if Self.title(trimmed, namesHost: label) { return trimmed } | ||
| return "\(trimmed) · \(label)" | ||
| } | ||
|
|
||
| /// Whether `title` already contains `host` as a whole word, so | ||
| /// `cmux ssh big-red --name "build @big-red"` does not become | ||
| /// `build @big-red · big-red`. | ||
| static func title(_ title: String, namesHost host: String) -> Bool { | ||
| var searchRange = title.startIndex..<title.endIndex | ||
| while let match = title.range(of: host, options: [.caseInsensitive], range: searchRange) { | ||
| let beforeIsBoundary = match.lowerBound == title.startIndex | ||
| || !isHostCharacter(title[title.index(before: match.lowerBound)]) | ||
| let afterIsBoundary = match.upperBound == title.endIndex | ||
| || !isHostCharacter(title[match.upperBound]) | ||
| if beforeIsBoundary && afterIsBoundary { return true } | ||
| searchRange = match.upperBound..<title.endIndex | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| private static func isHostCharacter(_ character: Character) -> Bool { | ||
| character.isLetter || character.isNumber || character == "-" || character == "_" || character == "." | ||
| } | ||
| } |
125 changes: 125 additions & 0 deletions
125
Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/WorkspaceHostLabelTests.swift
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,125 @@ | ||
| import Testing | ||
| @testable import CmuxFoundation | ||
|
|
||
| @Suite("Workspace host labels") | ||
| struct WorkspaceHostLabelTests { | ||
| @Test("labels user@host by its host") | ||
| func userAtHost() throws { | ||
| let label = try #require(WorkspaceHostLabel.ssh(destination: "leo@big-red")) | ||
| #expect(label.kind == .ssh) | ||
| #expect(label.label == "big-red") | ||
| #expect(label.detail == "leo@big-red") | ||
| #expect(label.groupingKey == "ssh:big-red") | ||
| #expect(label.isRemote) | ||
| } | ||
|
|
||
| @Test("labels a bare host or ssh_config alias as typed, without resolving it") | ||
| func bareHostAndAlias() throws { | ||
| let label = try #require(WorkspaceHostLabel.ssh(destination: " big-red \n")) | ||
| #expect(label.label == "big-red") | ||
| #expect(label.detail == "big-red") | ||
|
|
||
| let fqdn = try #require(WorkspaceHostLabel.ssh(destination: "dev.example.com")) | ||
| #expect(fqdn.label == "dev.example.com") | ||
| } | ||
|
|
||
| @Test("parses ssh:// URIs with user and port") | ||
| func sshURI() throws { | ||
| let label = try #require(WorkspaceHostLabel.ssh(destination: "ssh://leo@Big-Red:2222")) | ||
| #expect(label.label == "Big-Red") | ||
| #expect(label.detail == "leo@Big-Red:2222") | ||
| #expect(label.groupingKey == "ssh:big-red:2222") | ||
|
|
||
| let withPath = try #require(WorkspaceHostLabel.ssh(destination: "ssh://host/ignored")) | ||
| #expect(withPath.label == "host") | ||
| } | ||
|
|
||
| @Test("an explicit port wins over the URI port") | ||
| func explicitPort() throws { | ||
| let label = try #require(WorkspaceHostLabel.ssh(destination: "ssh://host:2222", port: 22)) | ||
| #expect(label.detail == "host:22") | ||
|
|
||
| let bare = try #require(WorkspaceHostLabel.ssh(destination: "leo@host", port: 2200)) | ||
| #expect(bare.label == "host") | ||
| #expect(bare.detail == "leo@host:2200") | ||
| #expect(bare.groupingKey == "ssh:host:2200") | ||
| } | ||
|
|
||
| @Test("handles IPv6 with and without brackets") | ||
| func ipv6() throws { | ||
| let bracketed = try #require(WorkspaceHostLabel.ssh(destination: "leo@[fe80::1]")) | ||
| #expect(bracketed.label == "fe80::1") | ||
| #expect(bracketed.detail == "leo@[fe80::1]") | ||
|
|
||
| let uri = try #require(WorkspaceHostLabel.ssh(destination: "ssh://root@[2001:db8::2]:2222")) | ||
| #expect(uri.label == "2001:db8::2") | ||
| #expect(uri.detail == "root@[2001:db8::2]:2222") | ||
| #expect(uri.groupingKey == "ssh:[2001:db8::2]:2222") | ||
|
|
||
| let bare = try #require(WorkspaceHostLabel.ssh(destination: "2001:db8::3")) | ||
| #expect(bare.label == "2001:db8::3") | ||
|
|
||
| let ipv4 = try #require(WorkspaceHostLabel.ssh(destination: "pi@10.0.0.5")) | ||
| #expect(ipv4.label == "10.0.0.5") | ||
| } | ||
|
|
||
| @Test("splits user and host at the last @") | ||
| func lastAtSign() throws { | ||
| let label = try #require(WorkspaceHostLabel.ssh(destination: "me@corp.com@jump")) | ||
| #expect(label.label == "jump") | ||
| #expect(label.detail == "me@corp.com@jump") | ||
| } | ||
|
|
||
| @Test("groups the same host across users, and keeps a bare host:port as typed") | ||
| func groupingIgnoresUser() throws { | ||
| let a = try #require(WorkspaceHostLabel.ssh(destination: "leo@big-red")) | ||
| let b = try #require(WorkspaceHostLabel.ssh(destination: "root@BIG-RED")) | ||
| #expect(a.groupingKey == b.groupingKey) | ||
|
|
||
| let hostColon = try #require(WorkspaceHostLabel.ssh(destination: "host:2222")) | ||
| #expect(hostColon.label == "host:2222") | ||
| #expect(hostColon.detail == "host:2222") | ||
| #expect(hostColon.groupingKey == "ssh:host:2222") | ||
| } | ||
|
|
||
| @Test("rejects destinations without a host", arguments: ["", " ", "leo@", "ssh://", "[::1"]) | ||
| func rejectsEmptyHost(_ destination: String) { | ||
| #expect(WorkspaceHostLabel.ssh(destination: destination) == nil) | ||
| } | ||
|
|
||
| @Test("labels a Cloud machine by name, falling back to its id") | ||
| func cloudMachine() throws { | ||
| let named = try #require(WorkspaceHostLabel.cloud(machineID: "vm_123", machineName: " my-vm ")) | ||
| #expect(named.kind == .cloud) | ||
| #expect(named.label == "my-vm") | ||
| #expect(named.detail == "my-vm (vm_123)") | ||
| #expect(named.groupingKey == "cloud:vm_123") | ||
|
|
||
| let unnamed = try #require(WorkspaceHostLabel.cloud(machineID: "vm_123", machineName: "")) | ||
| #expect(unnamed.label == "vm_123") | ||
| #expect(unnamed.detail == "vm_123") | ||
| #expect(unnamed.groupingKey == named.groupingKey) | ||
|
|
||
| #expect(WorkspaceHostLabel.cloud(machineID: " ", machineName: "x") == nil) | ||
| } | ||
|
|
||
| @Test("local workspaces carry no label") | ||
| func local() { | ||
| #expect(WorkspaceHostLabel.local.kind == .local) | ||
| #expect(WorkspaceHostLabel.local.label.isEmpty) | ||
| #expect(!WorkspaceHostLabel.local.isRemote) | ||
| #expect(WorkspaceHostLabel.local.windowTitle(appendingTo: "build") == "build") | ||
| } | ||
|
|
||
| @Test("appends the host to window titles unless the title already names it") | ||
| func windowTitle() throws { | ||
| let host = try #require(WorkspaceHostLabel.ssh(destination: "leo@big-red")) | ||
| #expect(host.windowTitle(appendingTo: "build") == "build · big-red") | ||
| #expect(host.windowTitle(appendingTo: " ") == "big-red") | ||
| #expect(host.windowTitle(appendingTo: "build @big-red") == "build @big-red") | ||
| #expect(host.windowTitle(appendingTo: " build @big-red ") == "build @big-red") | ||
| #expect(host.windowTitle(appendingTo: "Big-Red logs") == "Big-Red logs") | ||
| #expect(host.windowTitle(appendingTo: "big-redis") == "big-redis · big-red") | ||
| #expect(host.windowTitle(appendingTo: "not-big-red") == "not-big-red · big-red") | ||
| } | ||
| } |
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
Oops, something went wrong.
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.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 2443
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 40987
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 40664
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 10954
Keep the remote host in the custom titlebar for blank workspace titles.
When the selected workspace title is empty or whitespace,
resolvedWorkspaceWindowTitle(for:)returns an empty string before applying the host.ContentViewassigns that value totitlebarText, so its customText(titlebarText)displays no host. The nativeNSWindow.titleis unaffected because its separate fallback supplies the remote host. Apply the fallback at the changedSources/ContentView.swift:2368-2369path, using a shared resolver with the native window-title fallback policy.🤖 Prompt for AI Agents