Align port forwarding editors

This commit is contained in:
Kaede Akino committed 2026-09-12 15:46:27 +08:00
1 parent 18cae18320
commit e50ea0c75e
9 files changed
+84 -146

No files matched your search

+6 -6
View File
@@ -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" : "新建转发"
}
}
}
+4 -3
View File
@@ -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: {
+5 -2
View File
@@ -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)
}
}
+5 -106
View File
@@ -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)
+33
View File
@@ -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 [
+21 -16
View File
@@ -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 {