Align port forwarding editors
This commit is contained in:
1 parent
18cae18320
commit
e50ea0c75e
9 files changed
+84
-146
No files matched your search
@@ -1432,12 +1432,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Add Port Forward" : {
|
||||
"Add Forwarding" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "添加端口转发"
|
||||
"value" : "添加转发"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5255,12 +5255,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Edit Port Forward" : {
|
||||
"Edit Forwarding" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "编辑端口转发"
|
||||
"value" : "编辑转发"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8599,12 +8599,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"New Port Forward" : {
|
||||
"New Forwarding" : {
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "新建端口转发"
|
||||
"value" : "新建转发"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -797,8 +797,9 @@ Desktop keeps its existing list switches and also exposes enable controls in all
|
||||
three detail editors; both controls write through the same feature model. Desktop
|
||||
Enabled controls follow Name (after Title and stable Name for Skills). The shared
|
||||
provider form uses Name then Enabled on both platforms; mobile Skill and MCP editors
|
||||
retain their leading enable row. Desktop forwarding details likewise group Name,
|
||||
Enabled and runtime status together, with only Name locked while forwarding is active.
|
||||
retain their leading enable row. Desktop and mobile forwarding editors place Enabled
|
||||
and runtime status above the shared, flat Port Forwarding form. Their contextual titles
|
||||
are New Forwarding or Edit Forwarding, and active forwarding locks the configuration fields.
|
||||
Terminal custom configuration has no Name field and keeps its enable control in the toolbar.
|
||||
|
||||
Mobile local uploads and desktop local uploads use one batch planner in Remote Infrastructure. File picking and
|
||||
@@ -816,7 +817,7 @@ covers all editors, AI pickers, Pro, history, transfer and Metrics selection she
|
||||
Mobile editor fields align by structure: editable values paired with a leading label
|
||||
align trailing, while standalone fields align leading. Save remains the trailingmost
|
||||
editor action. Skill Export/Delete and MCP Connect/Delete live in the adjacent More
|
||||
menu; Provider Fetch/Add, MCP Add Header, Host Add Port Forward, key file selection,
|
||||
menu; Provider Fetch/Add, MCP Add Header, Host Add Forwarding, key file selection,
|
||||
and Terminal configuration editing remain beside the content they affect. SFTP keeps
|
||||
Transfers visible and groups parent navigation, upload, and folder creation in More.
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ struct MobileHostEditor: View {
|
||||
.disabled(!state.canEdit)
|
||||
}
|
||||
}
|
||||
Button("Add Port Forward", systemImage: "plus") {
|
||||
Button("Add Forwarding", systemImage: "plus") {
|
||||
editingForwarding = PortForwardingRule(hostID: draft.id)
|
||||
}
|
||||
Text(
|
||||
@@ -106,7 +106,10 @@ struct MobileHostEditor: View {
|
||||
}
|
||||
)
|
||||
.sheet(item: $editingForwarding) { rule in
|
||||
MobileForwardingEditor(app: app, host: draft, rule: rule) { updated in
|
||||
MobileForwardingEditor(
|
||||
app: app, host: draft, rule: rule,
|
||||
isNew: !draftForwardings.contains { $0.id == rule.id }
|
||||
) { updated in
|
||||
if let index = draftForwardings.firstIndex(where: { $0.id == updated.id }) {
|
||||
draftForwardings[index] = updated
|
||||
} else {
|
||||
|
||||
@@ -11,23 +11,26 @@ struct MobileForwardingEditor: View {
|
||||
let app: MobileAppModel
|
||||
let host: Host
|
||||
let original: PortForwardingRule
|
||||
let isNew: Bool
|
||||
let save: (PortForwardingRule) -> Void
|
||||
@State private var draft: PortForwardingRule
|
||||
|
||||
init(
|
||||
app: MobileAppModel, host: Host, rule: PortForwardingRule,
|
||||
app: MobileAppModel, host: Host, rule: PortForwardingRule, isNew: Bool,
|
||||
save: @escaping (PortForwardingRule) -> Void
|
||||
) {
|
||||
self.app = app
|
||||
self.host = host
|
||||
original = rule
|
||||
self.isNew = isNew
|
||||
self.save = save
|
||||
_draft = State(initialValue: rule)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
MobileEditorSheet(
|
||||
title: String(localized: "Port Forwarding"), isDirty: draft != original,
|
||||
title: isNew ? String(localized: "New Forwarding") : String(localized: "Edit Forwarding"),
|
||||
isDirty: draft != original,
|
||||
canSave: draft.isValid && state.canEdit,
|
||||
save: { save(draft) },
|
||||
content: {
|
||||
|
||||
@@ -125,8 +125,11 @@ struct MobileIconRenderingTests {
|
||||
.environment(\.locale, locale)
|
||||
}
|
||||
try await capture("forwarding-editor-\(suffix)", language: language, scheme: scheme) {
|
||||
MobileForwardingEditor(app: app, host: forwardingHost, rule: forwardingRule) { _ in }
|
||||
.environment(\.locale, locale)
|
||||
MobileForwardingEditor(
|
||||
app: app, host: forwardingHost, rule: forwardingRule, isNew: false,
|
||||
save: { _ in }
|
||||
)
|
||||
.environment(\.locale, locale)
|
||||
}
|
||||
try await capture("add-\(suffix)", language: language, scheme: scheme) {
|
||||
MobileAddModelSheet(existing: [], add: { _ in }).environment(\.locale, locale)
|
||||
|
||||
@@ -39,13 +39,4 @@ struct PortForwardingEditorState {
|
||||
PortForwardingEditorPolicy.normalized(rule)
|
||||
}
|
||||
|
||||
/// Use the exact save candidate for validation and start eligibility, including hidden dynamic targets.
|
||||
static func normalized(
|
||||
_ rule: PortForwardingRule, hostID: Host.ID,
|
||||
bindPort: String, targetHost: String, targetPort: String
|
||||
) -> PortForwardingRule? {
|
||||
PortForwardingEditorPolicy.normalized(
|
||||
rule, hostID: hostID, bindPort: bindPort,
|
||||
targetHost: targetHost, targetPort: targetPort)
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ struct PortForwardingEditorSection: View {
|
||||
delete: { rules.removeAll { $0.id == rule.id } }
|
||||
)
|
||||
}
|
||||
FormNavigationAction(title: "Add Port Forward") {
|
||||
FormNavigationAction(title: "Add Forwarding") {
|
||||
openEditor(PortForwardingRule(hostID: host.id))
|
||||
}
|
||||
if !rules.isEmpty {
|
||||
@@ -222,10 +222,6 @@ struct PortForwardingEditorSheet: View {
|
||||
|
||||
@Environment(AppModel.self) private var app
|
||||
@State private var draft: PortForwardingRule
|
||||
@State private var bindPortDraft: String
|
||||
@State private var targetHostDraft: String
|
||||
@State private var targetPortDraft: String
|
||||
@State private var showsAdvanced = false
|
||||
|
||||
init(
|
||||
forwarding: PortForwardingRule, host: Host, isNew: Bool,
|
||||
@@ -239,23 +235,16 @@ struct PortForwardingEditorSheet: View {
|
||||
self.delete = delete
|
||||
self.save = save
|
||||
_draft = State(initialValue: forwarding)
|
||||
_bindPortDraft = State(initialValue: isNew ? "" : String(forwarding.bindPort))
|
||||
_targetHostDraft = State(initialValue: isNew ? "" : (forwarding.targetHost ?? ""))
|
||||
_targetPortDraft = State(
|
||||
initialValue: isNew ? "" : forwarding.targetPort.map(String.init) ?? ""
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VaultDetailScaffold(
|
||||
title: isNew ? "New Port Forward" : "Edit Port Forward",
|
||||
title: isNew ? "New Forwarding" : "Edit Forwarding",
|
||||
symbol: "arrow.left.arrow.right", close: back,
|
||||
closeLabel: "Back", closeSymbol: "chevron.backward"
|
||||
) {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Name", text: $draft.label, prompt: Text("Optional"))
|
||||
.disabled(!state.canEdit)
|
||||
Toggle("Enabled", isOn: enabled)
|
||||
.disabled(!state.canToggle)
|
||||
LabeledContent("Status", value: state.runtime.label)
|
||||
@@ -265,41 +254,8 @@ struct PortForwardingEditorSheet: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Section {
|
||||
PortForwardingDirectionPicker(kind: $draft.kind)
|
||||
Text(directionHelp).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.disabled(!state.canEdit)
|
||||
Section("SSH Host") {
|
||||
LabeledContent("Host", value: host.name.isEmpty ? host.host : host.name)
|
||||
if !host.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
Text(app.forwardings.routeSummary(for: host))
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Section("Forward") {
|
||||
TextField(bindPortLabel, text: $bindPortDraft)
|
||||
if draft.kind != .dynamic {
|
||||
TextField(targetHostLabel, text: $targetHostDraft)
|
||||
TextField("Destination Port", text: $targetPortDraft)
|
||||
}
|
||||
DisclosureGroup("Advanced", isExpanded: $showsAdvanced) {
|
||||
TextField(bindAddressLabel, text: $draft.bindHost)
|
||||
if exposesNetwork {
|
||||
Label(
|
||||
"This address may expose the listener to other devices.",
|
||||
systemImage: "exclamationmark.triangle.fill"
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(!state.canEdit)
|
||||
Section("Route Preview") {
|
||||
Text(routePreview).font(.body.monospaced()).textSelection(.enabled)
|
||||
}
|
||||
PortForwardingFields(rule: $draft)
|
||||
.disabled(!state.canEdit)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
} footer: {
|
||||
@@ -323,39 +279,8 @@ struct PortForwardingEditorSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var directionHelp: String {
|
||||
switch draft.kind {
|
||||
case .local: String(localized: "This Mac → SSH Host → Target")
|
||||
case .remote: String(localized: "SSH Host → This Mac → Target")
|
||||
case .dynamic: String(localized: "SOCKS5 on This Mac → SSH Host → Requested Target")
|
||||
}
|
||||
}
|
||||
|
||||
private var bindPortLabel: String {
|
||||
draft.kind == .dynamic ? String(localized: "SOCKS Listen Port") : String(localized: "Listen Port")
|
||||
}
|
||||
|
||||
private var bindAddressLabel: String {
|
||||
switch draft.kind {
|
||||
case .local: String(localized: "Listen Address on This Mac")
|
||||
case .remote: String(localized: "Listen Address on SSH Host")
|
||||
case .dynamic: String(localized: "SOCKS Listen Address on This Mac")
|
||||
}
|
||||
}
|
||||
|
||||
private var targetHostLabel: String {
|
||||
draft.kind == .remote
|
||||
? String(localized: "Destination Host on This Mac") : String(localized: "Destination Host from SSH Host")
|
||||
}
|
||||
|
||||
private var exposesNetwork: Bool {
|
||||
!["127.0.0.1", "::1", "localhost"].contains(draft.bindHost.lowercased())
|
||||
}
|
||||
|
||||
private var normalizedDraft: PortForwardingRule? {
|
||||
PortForwardingEditorState.normalized(
|
||||
draft, hostID: host.id, bindPort: bindPortDraft,
|
||||
targetHost: targetHostDraft, targetPort: targetPortDraft)
|
||||
PortForwardingEditorState.normalized(draft)
|
||||
}
|
||||
|
||||
private var state: PortForwardingEditorState {
|
||||
@@ -375,32 +300,6 @@ struct PortForwardingEditorSheet: View {
|
||||
})
|
||||
}
|
||||
|
||||
private var parsedBindPort: Int? {
|
||||
Int(bindPortDraft.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
|
||||
private var parsedTargetPort: Int? {
|
||||
Int(targetPortDraft.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
|
||||
private var routePreview: String {
|
||||
let start = previewAddress(draft.bindHost, port: parsedBindPort)
|
||||
let ssh = host.host.isEmpty ? String(localized: "SSH Host") : app.forwardings.routeSummary(for: host)
|
||||
let targetHost = targetHostDraft.isEmpty ? "—" : targetHostDraft
|
||||
let targetPort = parsedTargetPort.map(String.init) ?? "—"
|
||||
switch draft.kind {
|
||||
case .local: return "\(start) → \(ssh) → \(targetHost):\(targetPort)"
|
||||
case .remote:
|
||||
return String(localized: "\(ssh) → listen \(start) → This Mac → \(targetHost):\(targetPort)")
|
||||
case .dynamic: return String(localized: "SOCKS5 \(start) → \(ssh) → Requested Target")
|
||||
}
|
||||
}
|
||||
|
||||
private func previewAddress(_ host: String, port: Int?) -> String {
|
||||
let formattedHost = host.contains(":") ? "[\(host)]" : host
|
||||
return "\(formattedHost):\(port.map(String.init) ?? "—")"
|
||||
}
|
||||
|
||||
private func commit() {
|
||||
guard state.canEdit, let value = normalizedDraft else { return }
|
||||
save(value)
|
||||
|
||||
@@ -602,6 +602,39 @@ struct ArchitectureBoundaryTests {
|
||||
#expect(cloud.count == 3)
|
||||
}
|
||||
|
||||
@Test
|
||||
func forwardingEditorsShareTheFlatFormAndContextualLabels() throws {
|
||||
let desktop = try String(
|
||||
contentsOf: sourceRoot.appending(path: "Features/Ports/PortForwardingView.swift"), encoding: .utf8)
|
||||
let mobile = try String(
|
||||
contentsOf: sourceRoot.appending(path: "../TFI/Features/Ports/MobileForwardingEditor.swift"),
|
||||
encoding: .utf8)
|
||||
let mobileHost = try String(
|
||||
contentsOf: sourceRoot.appending(path: "../TFI/Features/HostEditor/MobileHostEditor.swift"),
|
||||
encoding: .utf8)
|
||||
let shared = try String(
|
||||
contentsOf: sourceRoot.appending(path: "../Frameworks/TermindPresentation/PortForwardingFields.swift"),
|
||||
encoding: .utf8)
|
||||
|
||||
#expect(shared.contains("Section(\"Port Forwarding\")"))
|
||||
for editor in [desktop, mobile] {
|
||||
#expect(editor.contains("PortForwardingFields(rule: $draft)"))
|
||||
#expect(editor.contains("New Forwarding"))
|
||||
#expect(editor.contains("Edit Forwarding"))
|
||||
}
|
||||
#expect(desktop.contains("FormNavigationAction(title: \"Add Forwarding\")"))
|
||||
#expect(mobileHost.contains("Button(\"Add Forwarding\", systemImage: \"plus\")"))
|
||||
#expect(mobileHost.contains("isNew: !draftForwardings.contains"))
|
||||
for removed in ["Add Port Forward", "New Port Forward", "Edit Port Forward"] {
|
||||
#expect(!desktop.contains(removed))
|
||||
#expect(!mobile.contains(removed))
|
||||
#expect(!mobileHost.contains(removed))
|
||||
}
|
||||
for removed in ["DisclosureGroup(\"Advanced\"", "Section(\"SSH Host\")", "Section(\"Route Preview\")"] {
|
||||
#expect(!desktop.contains(removed))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func sharedFrameworksCannotImportPlatformAppsOrUI() throws {
|
||||
for module in [
|
||||
|
||||
@@ -70,24 +70,32 @@ struct PortForwardingEditorTests {
|
||||
#expect(state.canToggle)
|
||||
}
|
||||
|
||||
@Test func normalizationMatchesSavedValuesAndRejectsInvalidPorts() throws {
|
||||
@Test func normalizationMatchesSavedValuesAndRejectsInvalidFields() throws {
|
||||
let host = Host(name: "Server", host: "server.invalid")
|
||||
let saved = PortForwardingRule(hostID: host.id)
|
||||
var draft = saved
|
||||
draft.bindHost = " 127.0.0.1\n"
|
||||
let normalized = try #require(
|
||||
PortForwardingEditorState.normalized(
|
||||
draft, hostID: host.id, bindPort: " 08080\n", targetHost: " localhost ", targetPort: " 80 "))
|
||||
draft.targetHost = " localhost "
|
||||
let normalized = try #require(PortForwardingEditorState.normalized(draft))
|
||||
#expect(normalized == saved)
|
||||
#expect(PortForwardingEditorState.normalized(draft) == saved)
|
||||
for invalid in ["", "abc", "0", "65536"] {
|
||||
#expect(
|
||||
PortForwardingEditorState.normalized(
|
||||
draft, hostID: host.id, bindPort: invalid, targetHost: "localhost", targetPort: "80") == nil)
|
||||
#expect(
|
||||
PortForwardingEditorState.normalized(
|
||||
draft, hostID: host.id, bindPort: "8080", targetHost: "localhost", targetPort: invalid) == nil)
|
||||
|
||||
for invalidPort in [0, 65_536] {
|
||||
var invalidBind = saved
|
||||
invalidBind.bindPort = invalidPort
|
||||
#expect(PortForwardingEditorState.normalized(invalidBind) == nil)
|
||||
|
||||
var invalidTarget = saved
|
||||
invalidTarget.targetPort = invalidPort
|
||||
#expect(PortForwardingEditorState.normalized(invalidTarget) == nil)
|
||||
}
|
||||
|
||||
var emptyBindHost = saved
|
||||
emptyBindHost.bindHost = " \n"
|
||||
#expect(PortForwardingEditorState.normalized(emptyBindHost) == nil)
|
||||
|
||||
var emptyTargetHost = saved
|
||||
emptyTargetHost.targetHost = " \n"
|
||||
#expect(PortForwardingEditorState.normalized(emptyTargetHost) == nil)
|
||||
}
|
||||
|
||||
@Test func dynamicDraftDiscardsHiddenTargetValuesBeforeComparingOrSaving() throws {
|
||||
@@ -96,13 +104,10 @@ struct PortForwardingEditorTests {
|
||||
var draft = saved
|
||||
draft.targetHost = "old-target.invalid"
|
||||
draft.targetPort = 443
|
||||
let normalized = try #require(
|
||||
PortForwardingEditorState.normalized(
|
||||
draft, hostID: host.id, bindPort: "8080", targetHost: "old-target.invalid", targetPort: "invalid"))
|
||||
let normalized = try #require(PortForwardingEditorState.normalized(draft))
|
||||
#expect(normalized == saved)
|
||||
#expect(normalized.targetHost == nil)
|
||||
#expect(normalized.targetPort == nil)
|
||||
#expect(PortForwardingEditorState.normalized(draft) == saved)
|
||||
}
|
||||
|
||||
@Test func savingHostAndRuleDoesNotStartForwardingAndOtherWindowEditsInvalidateStart() throws {
|
||||
|
||||
Reference in new issue
Block a user