Skip to content

Commit 5e37a49

Browse files
committed
fix(tray/macos): mirror web before/after word-diff in ServerDetailView
Bring the macOS tray's Tool Quarantine diff in line with the web UI's new before/after layout. Previously the tray stacked a red "minus" box above a green "plus" box containing the raw old/new text, which gave no hint about which specific words actually changed. Now the diff section renders two labelled boxes ("Before (approved)" and "After (current)") built from a longest-common-subsequence word diff; each side only shows its own words with removals/additions highlighted inside their respective boxes via AttributedString background color. Ports the same LCS used in ServerDetail.vue (split-on-whitespace tokens, backtrack to build parts, merge consecutive parts of the same kind). Adds ToolDiffTests covering identical inputs, empty sides, the real gcore short→docstring expansion, and reconstruction invariants. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 52f4bdf commit 5e37a49

2 files changed

Lines changed: 239 additions & 25 deletions

File tree

native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift

Lines changed: 155 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1158,12 +1158,7 @@ struct ToolRow: View {
11581158
.foregroundStyle(.secondary)
11591159

11601160
if oldDesc != newDesc {
1161-
if !oldDesc.isEmpty {
1162-
diffLine(text: oldDesc, isOld: true)
1163-
}
1164-
if !newDesc.isEmpty {
1165-
diffLine(text: newDesc, isOld: false)
1166-
}
1161+
diffBeforeAfter(previous: oldDesc, current: newDesc)
11671162
} else {
11681163
Text("No description changes")
11691164
.font(.scaled(.caption, scale: fontScale))
@@ -1188,12 +1183,7 @@ struct ToolRow: View {
11881183
.padding(.top, 4)
11891184

11901185
if oldSchema != newSchema {
1191-
if !oldSchema.isEmpty {
1192-
diffLine(text: oldSchema, isOld: true)
1193-
}
1194-
if !newSchema.isEmpty {
1195-
diffLine(text: newSchema, isOld: false)
1196-
}
1186+
diffBeforeAfter(previous: oldSchema, current: newSchema)
11971187
} else {
11981188
Text("No schema changes")
11991189
.font(.scaled(.caption, scale: fontScale))
@@ -1219,20 +1209,78 @@ struct ToolRow: View {
12191209
}
12201210

12211211
@ViewBuilder
1222-
private func diffLine(text: String, isOld: Bool) -> some View {
1223-
HStack(alignment: .top, spacing: 4) {
1224-
Image(systemName: isOld ? "minus.circle.fill" : "plus.circle.fill")
1225-
.font(.scaled(.caption2, scale: fontScale))
1226-
.foregroundStyle(isOld ? .red : .green)
1227-
Text(text)
1228-
.font(.scaledMonospaced(.caption, scale: fontScale))
1229-
.foregroundStyle(isOld ? .secondary : .primary)
1230-
.lineLimit(isOld ? 6 : nil)
1212+
private func diffBeforeAfter(previous: String, current: String) -> some View {
1213+
let parts = computeWordDiff(previous, current)
1214+
VStack(alignment: .leading, spacing: 6) {
1215+
diffBox(
1216+
label: "BEFORE (APPROVED)",
1217+
attributed: renderDiffSide(parts, keep: .removed),
1218+
accent: .red,
1219+
isEmpty: previous.isEmpty
1220+
)
1221+
diffBox(
1222+
label: "AFTER (CURRENT)",
1223+
attributed: renderDiffSide(parts, keep: .added),
1224+
accent: .green,
1225+
isEmpty: current.isEmpty
1226+
)
12311227
}
1232-
.padding(4)
1233-
.frame(maxWidth: .infinity, alignment: .leading)
1234-
.background((isOld ? Color.red : Color.green).opacity(0.08))
1235-
.cornerRadius(4)
1228+
}
1229+
1230+
@ViewBuilder
1231+
private func diffBox(label: String, attributed: AttributedString, accent: Color, isEmpty: Bool) -> some View {
1232+
VStack(alignment: .leading, spacing: 3) {
1233+
Text(label)
1234+
.font(.system(size: 9 * fontScale, weight: .semibold))
1235+
.tracking(0.5)
1236+
.foregroundStyle(.secondary)
1237+
if isEmpty {
1238+
Text("(empty)")
1239+
.font(.scaledMonospaced(.caption, scale: fontScale))
1240+
.foregroundStyle(.tertiary)
1241+
.padding(6)
1242+
.frame(maxWidth: .infinity, alignment: .leading)
1243+
.background(accent.opacity(0.04))
1244+
.overlay(
1245+
RoundedRectangle(cornerRadius: 4)
1246+
.strokeBorder(accent.opacity(0.25), lineWidth: 1)
1247+
)
1248+
.cornerRadius(4)
1249+
} else {
1250+
Text(attributed)
1251+
.font(.scaledMonospaced(.caption, scale: fontScale))
1252+
.textSelection(.enabled)
1253+
.frame(maxWidth: .infinity, alignment: .leading)
1254+
.padding(6)
1255+
.background(accent.opacity(0.04))
1256+
.overlay(
1257+
RoundedRectangle(cornerRadius: 4)
1258+
.strokeBorder(accent.opacity(0.25), lineWidth: 1)
1259+
)
1260+
.cornerRadius(4)
1261+
}
1262+
}
1263+
}
1264+
1265+
/// Build AttributedString for one side of the diff. `keep` is the change-type
1266+
/// (removed or added) that belongs in this side; same-type parts render plain
1267+
/// in both sides; the opposite change-type is dropped.
1268+
private func renderDiffSide(_ parts: [ToolDiffPart], keep: ToolDiffPart.Kind) -> AttributedString {
1269+
var result = AttributedString()
1270+
for part in parts {
1271+
switch part.kind {
1272+
case .same:
1273+
result += AttributedString(part.text)
1274+
case .added, .removed:
1275+
if part.kind != keep { continue }
1276+
var span = AttributedString(part.text)
1277+
let accent: Color = part.kind == .removed ? .red : .green
1278+
span.backgroundColor = accent.opacity(0.25)
1279+
span.foregroundColor = accent
1280+
result += span
1281+
}
1282+
}
1283+
return result
12361284
}
12371285

12381286
private func loadDiff() {
@@ -1314,6 +1362,88 @@ struct ToolRow: View {
13141362
}
13151363
}
13161364

1365+
// MARK: - Tool Description Word Diff
1366+
1367+
struct ToolDiffPart {
1368+
enum Kind { case same, added, removed }
1369+
let kind: Kind
1370+
let text: String
1371+
}
1372+
1373+
/// Split a string into tokens, preserving runs of whitespace as their own tokens
1374+
/// (matches the web UI's `split(/(\s+)/)` so the LCS behaves identically).
1375+
private func splitPreservingWhitespace(_ s: String) -> [String] {
1376+
var tokens: [String] = []
1377+
var buffer = ""
1378+
var bufferIsWhitespace: Bool? = nil
1379+
for ch in s {
1380+
let chIsWhitespace = ch.isWhitespace
1381+
if bufferIsWhitespace == nil {
1382+
bufferIsWhitespace = chIsWhitespace
1383+
buffer.append(ch)
1384+
} else if bufferIsWhitespace == chIsWhitespace {
1385+
buffer.append(ch)
1386+
} else {
1387+
tokens.append(buffer)
1388+
buffer = String(ch)
1389+
bufferIsWhitespace = chIsWhitespace
1390+
}
1391+
}
1392+
if !buffer.isEmpty { tokens.append(buffer) }
1393+
return tokens
1394+
}
1395+
1396+
/// Word-level diff via longest common subsequence. Returns parts in original order.
1397+
func computeWordDiff(_ oldText: String, _ newText: String) -> [ToolDiffPart] {
1398+
let oldTokens = splitPreservingWhitespace(oldText)
1399+
let newTokens = splitPreservingWhitespace(newText)
1400+
let m = oldTokens.count
1401+
let n = newTokens.count
1402+
if m == 0 && n == 0 { return [] }
1403+
if m == 0 { return [ToolDiffPart(kind: .added, text: newText)] }
1404+
if n == 0 { return [ToolDiffPart(kind: .removed, text: oldText)] }
1405+
1406+
var dp = Array(repeating: Array(repeating: 0, count: n + 1), count: m + 1)
1407+
for i in 1...m {
1408+
for j in 1...n {
1409+
if oldTokens[i - 1] == newTokens[j - 1] {
1410+
dp[i][j] = dp[i - 1][j - 1] + 1
1411+
} else {
1412+
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
1413+
}
1414+
}
1415+
}
1416+
1417+
var parts: [ToolDiffPart] = []
1418+
var i = m
1419+
var j = n
1420+
while i > 0 || j > 0 {
1421+
if i > 0 && j > 0 && oldTokens[i - 1] == newTokens[j - 1] {
1422+
parts.append(ToolDiffPart(kind: .same, text: oldTokens[i - 1]))
1423+
i -= 1
1424+
j -= 1
1425+
} else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) {
1426+
parts.append(ToolDiffPart(kind: .added, text: newTokens[j - 1]))
1427+
j -= 1
1428+
} else {
1429+
parts.append(ToolDiffPart(kind: .removed, text: oldTokens[i - 1]))
1430+
i -= 1
1431+
}
1432+
}
1433+
parts.reverse()
1434+
1435+
// Merge consecutive parts of the same kind to minimize attribute spans.
1436+
var merged: [ToolDiffPart] = []
1437+
for part in parts {
1438+
if let last = merged.last, last.kind == part.kind {
1439+
merged[merged.count - 1] = ToolDiffPart(kind: last.kind, text: last.text + part.text)
1440+
} else {
1441+
merged.append(part)
1442+
}
1443+
}
1444+
return merged
1445+
}
1446+
13171447
// MARK: - Flow Layout (for wrapping annotation badges)
13181448

13191449
/// A simple horizontal flow layout that wraps items to the next line.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import XCTest
2+
@testable import MCPProxy
3+
4+
final class ToolDiffTests: XCTestCase {
5+
6+
private func partsByKind(_ parts: [ToolDiffPart]) -> (same: String, added: String, removed: String) {
7+
var same = ""
8+
var added = ""
9+
var removed = ""
10+
for p in parts {
11+
switch p.kind {
12+
case .same: same += p.text
13+
case .added: added += p.text
14+
case .removed: removed += p.text
15+
}
16+
}
17+
return (same, added, removed)
18+
}
19+
20+
func testIdenticalStringsProduceOnlySameParts() {
21+
let text = "Get the list of Alibaba Cloud regions."
22+
let parts = computeWordDiff(text, text)
23+
let buckets = partsByKind(parts)
24+
XCTAssertEqual(buckets.same, text)
25+
XCTAssertEqual(buckets.added, "")
26+
XCTAssertEqual(buckets.removed, "")
27+
}
28+
29+
func testEmptyBeforeMarksEverythingAdded() {
30+
let parts = computeWordDiff("", "Hello world")
31+
XCTAssertEqual(parts.count, 1)
32+
XCTAssertEqual(parts[0].kind, .added)
33+
XCTAssertEqual(parts[0].text, "Hello world")
34+
}
35+
36+
func testEmptyAfterMarksEverythingRemoved() {
37+
let parts = computeWordDiff("Hello world", "")
38+
XCTAssertEqual(parts.count, 1)
39+
XCTAssertEqual(parts[0].kind, .removed)
40+
XCTAssertEqual(parts[0].text, "Hello world")
41+
}
42+
43+
func testExpansionOfAlibabaRegions() {
44+
// Real case from gcore-mcp-server: short one-liner expanded to docstring.
45+
let before = "Get the list of Alibaba Cloud regions."
46+
let after = "Get the list of Alibaba Cloud regions. Args: limit: Maximum number of items."
47+
let parts = computeWordDiff(before, after)
48+
let buckets = partsByKind(parts)
49+
50+
// The common prefix must survive intact in "same" parts.
51+
XCTAssertTrue(buckets.same.contains("Get the list of Alibaba Cloud regions."))
52+
// The trailing docstring must be in the "added" bucket.
53+
XCTAssertTrue(buckets.added.contains("Args:"))
54+
XCTAssertTrue(buckets.added.contains("limit:"))
55+
// Nothing should be removed in this direction.
56+
XCTAssertEqual(buckets.removed, "")
57+
}
58+
59+
func testReconstructionFromAddedAndRemoved() {
60+
// Reconstructing before = same + removed; after = same + added (in order)
61+
let before = "alpha beta gamma"
62+
let after = "alpha delta gamma"
63+
let parts = computeWordDiff(before, after)
64+
65+
let reconstructedBefore = parts.compactMap { p -> String? in
66+
p.kind == .added ? nil : p.text
67+
}.joined()
68+
let reconstructedAfter = parts.compactMap { p -> String? in
69+
p.kind == .removed ? nil : p.text
70+
}.joined()
71+
72+
XCTAssertEqual(reconstructedBefore, before)
73+
XCTAssertEqual(reconstructedAfter, after)
74+
}
75+
76+
func testConsecutivePartsOfSameKindAreMerged() {
77+
let parts = computeWordDiff("one two three", "one two three four five")
78+
// Trailing addition should be a single merged part, not many tokens.
79+
let addedParts = parts.filter { $0.kind == .added }
80+
XCTAssertEqual(addedParts.count, 1, "Consecutive added tokens must merge")
81+
XCTAssertTrue(addedParts[0].text.contains("four"))
82+
XCTAssertTrue(addedParts[0].text.contains("five"))
83+
}
84+
}

0 commit comments

Comments
 (0)