From e50ea0c75e395ab98ab2dfb3f3e680a6d334055d Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 12 Sep 2026 15:46:27 +0800 Subject: [PATCH] Align port forwarding editors --- Frameworks/Resources/Localizable.xcstrings | 12 +- Memory/ARCHITECTURE.md | 7 +- .../HostEditor/MobileHostEditor.swift | 7 +- .../Ports/MobileForwardingEditor.swift | 7 +- TFITests/MobileIconRenderingTests.swift | 7 +- .../Ports/PortForwardingEditorState.swift | 9 -- TFM/Features/Ports/PortForwardingView.swift | 111 +----------------- TFMTests/ArchitectureBoundaryTests.swift | 33 ++++++ TFMTests/PortForwardingEditorTests.swift | 37 +++--- 9 files changed, 84 insertions(+), 146 deletions(-) diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index bb2bc57e..15360487 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -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" : "新建转发" } } } diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index c914d0bb..6a616aa6 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -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. diff --git a/TFI/Features/HostEditor/MobileHostEditor.swift b/TFI/Features/HostEditor/MobileHostEditor.swift index 907293fd..a9b780ac 100644 --- a/TFI/Features/HostEditor/MobileHostEditor.swift +++ b/TFI/Features/HostEditor/MobileHostEditor.swift @@ -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 { diff --git a/TFI/Features/Ports/MobileForwardingEditor.swift b/TFI/Features/Ports/MobileForwardingEditor.swift index 1dd7c153..cfc72da6 100644 --- a/TFI/Features/Ports/MobileForwardingEditor.swift +++ b/TFI/Features/Ports/MobileForwardingEditor.swift @@ -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: { diff --git a/TFITests/MobileIconRenderingTests.swift b/TFITests/MobileIconRenderingTests.swift index 84b9eca3..3dfeab57 100644 --- a/TFITests/MobileIconRenderingTests.swift +++ b/TFITests/MobileIconRenderingTests.swift @@ -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) diff --git a/TFM/Features/Ports/PortForwardingEditorState.swift b/TFM/Features/Ports/PortForwardingEditorState.swift index d75e39b8..ea49dde0 100644 --- a/TFM/Features/Ports/PortForwardingEditorState.swift +++ b/TFM/Features/Ports/PortForwardingEditorState.swift @@ -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) - } } diff --git a/TFM/Features/Ports/PortForwardingView.swift b/TFM/Features/Ports/PortForwardingView.swift index dae12e5f..32adf30f 100644 --- a/TFM/Features/Ports/PortForwardingView.swift +++ b/TFM/Features/Ports/PortForwardingView.swift @@ -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) diff --git a/TFMTests/ArchitectureBoundaryTests.swift b/TFMTests/ArchitectureBoundaryTests.swift index 5495b207..064397c7 100644 --- a/TFMTests/ArchitectureBoundaryTests.swift +++ b/TFMTests/ArchitectureBoundaryTests.swift @@ -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 [ diff --git a/TFMTests/PortForwardingEditorTests.swift b/TFMTests/PortForwardingEditorTests.swift index 16108f76..3bac4cda 100644 --- a/TFMTests/PortForwardingEditorTests.swift +++ b/TFMTests/PortForwardingEditorTests.swift @@ -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 {