Skip to content

Commit 9187e26

Browse files
committed
fix: handle IPv6 in ProxyJump parser, collapse Jump Hosts section
1 parent 9e731c1 commit 9187e26

File tree

3 files changed

+91
-62
lines changed

3 files changed

+91
-62
lines changed

TablePro/Core/SSH/SSHConfigParser.swift

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,9 +168,18 @@ final class SSHConfigParser {
168168
remaining = String(remaining[remaining.index(after: atIndex)...])
169169
}
170170

171-
// Extract :port suffix
172-
if let colonIndex = remaining.lastIndex(of: ":"),
173-
let port = Int(String(remaining[remaining.index(after: colonIndex)...])) {
171+
// Extract host and port (supports bracketed IPv6, e.g. [::1]:22)
172+
if remaining.hasPrefix("["),
173+
let closeBracket = remaining.firstIndex(of: "]") {
174+
jumpHost.host = String(remaining[remaining.index(after: remaining.startIndex)..<closeBracket])
175+
let afterBracket = remaining.index(after: closeBracket)
176+
if afterBracket < remaining.endIndex,
177+
remaining[afterBracket] == ":",
178+
let port = Int(String(remaining[remaining.index(after: afterBracket)...])) {
179+
jumpHost.port = port
180+
}
181+
} else if let colonIndex = remaining.lastIndex(of: ":"),
182+
let port = Int(String(remaining[remaining.index(after: colonIndex)...])) {
174183
jumpHost.host = String(remaining[remaining.startIndex..<colonIndex])
175184
jumpHost.port = port
176185
} else {

TablePro/Views/Connection/ConnectionFormView.swift

Lines changed: 61 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -380,77 +380,79 @@ struct ConnectionFormView: View {
380380
}
381381
}
382382

383-
Section(String(localized: "Jump Hosts")) {
384-
ForEach($jumpHosts) { $jumpHost in
385-
DisclosureGroup {
386-
TextField(
387-
String(localized: "Host"),
388-
text: $jumpHost.host,
389-
prompt: Text("bastion.example.com")
390-
)
391-
HStack {
392-
TextField(
393-
String(localized: "Port"),
394-
text: Binding(
395-
get: { String(jumpHost.port) },
396-
set: { jumpHost.port = Int($0) ?? 22 }
397-
),
398-
prompt: Text("22")
399-
)
400-
.frame(width: 80)
383+
Section {
384+
DisclosureGroup(String(localized: "Jump Hosts")) {
385+
ForEach($jumpHosts) { $jumpHost in
386+
DisclosureGroup {
401387
TextField(
402-
String(localized: "Username"),
403-
text: $jumpHost.username,
404-
prompt: Text("admin")
388+
String(localized: "Host"),
389+
text: $jumpHost.host,
390+
prompt: Text("bastion.example.com")
405391
)
406-
}
407-
Picker(String(localized: "Auth"), selection: $jumpHost.authMethod) {
408-
ForEach(SSHJumpAuthMethod.allCases) { method in
409-
Text(method.rawValue).tag(method)
392+
HStack {
393+
TextField(
394+
String(localized: "Port"),
395+
text: Binding(
396+
get: { String(jumpHost.port) },
397+
set: { jumpHost.port = Int($0) ?? 22 }
398+
),
399+
prompt: Text("22")
400+
)
401+
.frame(width: 80)
402+
TextField(
403+
String(localized: "Username"),
404+
text: $jumpHost.username,
405+
prompt: Text("admin")
406+
)
410407
}
411-
}
412-
if jumpHost.authMethod == .privateKey {
413-
LabeledContent(String(localized: "Key File")) {
414-
HStack {
415-
TextField("", text: $jumpHost.privateKeyPath, prompt: Text("~/.ssh/id_rsa"))
416-
Button(String(localized: "Browse")) {
417-
browseForJumpHostKey(jumpHost: $jumpHost)
408+
Picker(String(localized: "Auth"), selection: $jumpHost.authMethod) {
409+
ForEach(SSHJumpAuthMethod.allCases) { method in
410+
Text(method.rawValue).tag(method)
411+
}
412+
}
413+
if jumpHost.authMethod == .privateKey {
414+
LabeledContent(String(localized: "Key File")) {
415+
HStack {
416+
TextField("", text: $jumpHost.privateKeyPath, prompt: Text("~/.ssh/id_rsa"))
417+
Button(String(localized: "Browse")) {
418+
browseForJumpHostKey(jumpHost: $jumpHost)
419+
}
420+
.controlSize(.small)
418421
}
419-
.controlSize(.small)
420422
}
421423
}
422-
}
423-
} label: {
424-
HStack {
425-
Text(jumpHost.host.isEmpty ? String(localized: "New Jump Host") : "\(jumpHost.username)@\(jumpHost.host)")
426-
.foregroundStyle(jumpHost.host.isEmpty ? .secondary : .primary)
427-
Spacer()
428-
Button {
429-
let idToRemove = jumpHost.id
430-
withAnimation {
431-
jumpHosts.removeAll { $0.id == idToRemove }
424+
} label: {
425+
HStack {
426+
Text(jumpHost.host.isEmpty ? String(localized: "New Jump Host") : "\(jumpHost.username)@\(jumpHost.host)")
427+
.foregroundStyle(jumpHost.host.isEmpty ? .secondary : .primary)
428+
Spacer()
429+
Button {
430+
let idToRemove = jumpHost.id
431+
withAnimation {
432+
jumpHosts.removeAll { $0.id == idToRemove }
433+
}
434+
} label: {
435+
Image(systemName: "minus.circle.fill")
436+
.foregroundStyle(.red)
432437
}
433-
} label: {
434-
Image(systemName: "minus.circle.fill")
435-
.foregroundStyle(.red)
438+
.buttonStyle(.plain)
436439
}
437-
.buttonStyle(.plain)
438440
}
439441
}
440-
}
441-
.onMove { indices, destination in
442-
jumpHosts.move(fromOffsets: indices, toOffset: destination)
443-
}
442+
.onMove { indices, destination in
443+
jumpHosts.move(fromOffsets: indices, toOffset: destination)
444+
}
444445

445-
Button {
446-
jumpHosts.append(SSHJumpHost())
447-
} label: {
448-
Label(String(localized: "Add Jump Host"), systemImage: "plus")
449-
}
446+
Button {
447+
jumpHosts.append(SSHJumpHost())
448+
} label: {
449+
Label(String(localized: "Add Jump Host"), systemImage: "plus")
450+
}
450451

451-
Text("Jump hosts are connected in order before reaching the SSH server above. Only key and agent auth are supported for jumps.")
452-
.font(.caption)
453-
.foregroundStyle(.secondary)
452+
Text("Jump hosts are connected in order before reaching the SSH server above. Only key and agent auth are supported for jumps.")
453+
.font(.caption)
454+
.foregroundStyle(.secondary)
455+
}
454456
}
455457
}
456458
}

TableProTests/Core/SSH/SSHConfigParserTests.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,4 +426,22 @@ struct SSHConfigParserTests {
426426
#expect(jumpHosts[0].host == "bastion.com")
427427
#expect(jumpHosts[0].port == 22)
428428
}
429+
430+
@Test("parseProxyJump with bracketed IPv6 and port")
431+
func testParseProxyJumpIPv6WithPort() {
432+
let jumpHosts = SSHConfigParser.parseProxyJump("admin@[::1]:2222")
433+
#expect(jumpHosts.count == 1)
434+
#expect(jumpHosts[0].username == "admin")
435+
#expect(jumpHosts[0].host == "::1")
436+
#expect(jumpHosts[0].port == 2_222)
437+
}
438+
439+
@Test("parseProxyJump with bracketed IPv6 without port")
440+
func testParseProxyJumpIPv6WithoutPort() {
441+
let jumpHosts = SSHConfigParser.parseProxyJump("admin@[fe80::1]")
442+
#expect(jumpHosts.count == 1)
443+
#expect(jumpHosts[0].username == "admin")
444+
#expect(jumpHosts[0].host == "fe80::1")
445+
#expect(jumpHosts[0].port == 22)
446+
}
429447
}

0 commit comments

Comments
 (0)