diff --git a/ShadowsocksX-NG2/App/PluginOptionsDraft.swift b/ShadowsocksX-NG2/App/PluginOptionsDraft.swift index 09cd1af5..21b20415 100644 --- a/ShadowsocksX-NG2/App/PluginOptionsDraft.swift +++ b/ShadowsocksX-NG2/App/PluginOptionsDraft.swift @@ -144,6 +144,22 @@ final class PluginOptionsDraft: ObservableObject { normalize() } + /// 单行上移/下移一格(评审追加):只换行序不改行内容——未修改行仍逐字 + /// 回写原文片段,故移动不算结构修改、既有末尾分号保留。末尾快速添加行 + /// 不可移动,也不得把行移到它之后;越界目标是无操作。 + func moveRow(_ id: UUID, by offset: Int) { + guard mode == .table, offset == -1 || offset == 1 else { return } + guard let index = rows.firstIndex(where: { $0.id == id }), !rows[index].isBlank else { + return + } + var target = index + offset + if rows.last?.isBlank ?? false { + target = min(target, rows.count - 2) + } + guard target >= 0, target != index else { return } + rows.swapAt(index, target) + } + /// 「添加参数」入口语义:末尾空行即快速添加行,返回其身份供聚焦。 var quickAddRowID: UUID { rows[rows.count - 1].id diff --git a/ShadowsocksX-NG2/App/ServerPluginSection.swift b/ShadowsocksX-NG2/App/ServerPluginSection.swift index 909480fd..6bcf30c5 100644 --- a/ShadowsocksX-NG2/App/ServerPluginSection.swift +++ b/ShadowsocksX-NG2/App/ServerPluginSection.swift @@ -145,6 +145,7 @@ private struct PluginOptionsEditor: View { @State private var editingValue: EditingValue? @FocusState private var focusedRowID: UUID? + @FocusState private var focusedValueRowID: UUID? var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -204,6 +205,7 @@ private struct PluginOptionsEditor: View { ) .textFieldStyle(.plain) .focused($focusedRowID, equals: row.id) + .simultaneousGesture(TapGesture().onEnded { focusedRowID = row.id }) if row.isUnfinished { Image(systemName: "exclamationmark.circle.fill") .font(.caption) @@ -224,9 +226,29 @@ private struct PluginOptionsEditor: View { .lineLimit(1) .truncationMode(.tail) .disabled(row.kind == .flag) + .focused($focusedValueRowID, equals: row.id) + .simultaneousGesture(TapGesture().onEnded { focusedValueRowID = row.id }) } TableColumn("操作") { row in - HStack(spacing: 10) { + HStack(spacing: 8) { + Button { + draft.moveRow(row.id, by: -1) + } label: { + Image(systemName: "chevron.up") + } + .buttonStyle(.borderless) + .help("上移") + .disabled(!canMoveRow(row, by: -1)) + + Button { + draft.moveRow(row.id, by: 1) + } label: { + Image(systemName: "chevron.down") + } + .buttonStyle(.borderless) + .help("下移") + .disabled(!canMoveRow(row, by: 1)) + Button { editingValue = EditingValue(id: row.id, text: row.valueText) } label: { @@ -243,7 +265,7 @@ private struct PluginOptionsEditor: View { .buttonStyle(.borderless) } } - .width(min: 150, ideal: 156, max: 176) + .width(min: 196, ideal: 208, max: 224) } .tableStyle(.inset) .frame(height: editTableHeight) @@ -254,6 +276,19 @@ private struct PluginOptionsEditor: View { max(min(52 + CGFloat(draft.rows.count) * 30, 440), 112) } + /// 移动可行性(按钮禁用态):空行不可移,首行不可上移,末尾空行前不可 + /// 下移。 + private func canMoveRow(_ row: PluginOptionsDraft.Row, by offset: Int) -> Bool { + guard let index = draft.rows.firstIndex(where: { $0.id == row.id }), !row.isBlank else { + return false + } + var target = index + offset + if draft.rows.last?.isBlank ?? false { + target = min(target, draft.rows.count - 2) + } + return target >= 0 && target != index + } + // MARK: - 原始文本 private var rawTextView: some View { diff --git a/ShadowsocksX-NG2/Tests/ServerFormPluginOptionsTests.swift b/ShadowsocksX-NG2/Tests/ServerFormPluginOptionsTests.swift index 8f5a3f9f..960afd9c 100644 --- a/ShadowsocksX-NG2/Tests/ServerFormPluginOptionsTests.swift +++ b/ShadowsocksX-NG2/Tests/ServerFormPluginOptionsTests.swift @@ -157,6 +157,58 @@ struct ServerFormPluginOptionsTests { #expect(other.pluginOptions.composedString == "host=a") } + // MARK: - 行顺序调整 + + @Test + func movingARowReordersVerbatimSlicesOnly() throws { + let fields = Self.loadedFields("tls;host=a;path=/p") + let host = try #require(Self.formalRows(fields).first { $0.keyText == "host" }) + fields.pluginOptions.moveRow(host.id, by: 1) + #expect(fields.pluginOptions.composedString == "tls;path=/p;host=a") + #expect(fields.hasChanges, "重排改变拼装串顺序") + } + + @Test + func editedRowCarriesItsEncodingWhenMoved() throws { + let fields = Self.loadedFields("tls;host=a;path=/p") + let path = try #require(Self.formalRows(fields).first { $0.keyText == "path" }) + fields.pluginOptions.updateValue("x;y", of: path.id) + fields.pluginOptions.moveRow(path.id, by: -1) + #expect(fields.pluginOptions.composedString == "tls;path=x\\;y;host=a") + } + + @Test + func trailingSeparatorSurvivesRowMoves() throws { + let fields = Self.loadedFields("a=1;b=2;") + let first = try #require(Self.formalRows(fields).first) + fields.pluginOptions.moveRow(first.id, by: 1) + #expect(fields.pluginOptions.composedString == "b=2;a=1;", "移动不算结构修改,末尾分号保留") + } + + @Test + func movesAreBoundedByFormalRowsAndTheQuickAddRowStaysLast() throws { + let fields = Self.loadedFields("a=1;b=2") + let rows = Self.formalRows(fields) + let firstRow = try #require(rows.first { $0.keyText == "a" }) + let secondRow = try #require(rows.first { $0.keyText == "b" }) + let blank = fields.pluginOptions.quickAddRowID + + // 空行不可移动;末尾正式行不可再下移(不得越过空行)。 + fields.pluginOptions.moveRow(blank, by: -1) + fields.pluginOptions.moveRow(secondRow.id, by: 1) + #expect(fields.pluginOptions.composedString == "a=1;b=2") + #expect(fields.pluginOptions.rows.last?.id == blank) + + // 首行不可上移。 + fields.pluginOptions.moveRow(firstRow.id, by: -1) + #expect(fields.pluginOptions.composedString == "a=1;b=2") + + // 有效下移仍可用。 + fields.pluginOptions.moveRow(firstRow.id, by: 1) + #expect(fields.pluginOptions.composedString == "b=2;a=1") + #expect(fields.pluginOptions.rows.last?.id == blank) + } + // MARK: - 底部空行与未完成草稿 @Test