Use the full width in the diff, and filter the tree by kind of difference

The file diff sat in a narrow centred column with empty space around it: cells
had a fixed minWidth inside a two-axis ScrollView, and the empty/notice states
were vertically centred. Now each side takes exactly half the width, content is
anchored top-leading, and scrolling is vertical only, with long lines wrapping
(a "Wrap lines" toggle turns that off for code, remembered across launches).

The tree's right column was pinned at 220pt regardless of window size; both name
columns now share the space evenly and only the status column is fixed.

The "only differences" checkbox became a filter with the modes worth having when
reviewing a real comparison: everything, only differences, only changed files,
only on the left, only on the right, only on one side. Folders survive filtering
only when something inside them does, so no empty branches are left behind.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
This commit is contained in:
alexandrev-tibco
2026-07-27 17:33:35 +02:00
parent f85c746af0
commit 7d7238ba33
7 changed files with 151 additions and 37 deletions
+3 -2
View File
@@ -18,8 +18,9 @@ final class ComparisonModel: Identifiable {
/// nil means "decide per file from its type" (JSON/XML semantic, text, binary).
var mode: ComparisonMode?
var textOptions = TextOptions()
/// Hide rows that are equal, which is how you actually review a big diff.
var showOnlyDifferences = false
/// Which rows the tree shows: everything, only differences, only orphans on
/// one side Reviewing a big comparison means slicing it.
var filter: RowFilter = .differences
var selection: DiffNode?
+11 -2
View File
@@ -55,8 +55,7 @@ struct SidePickers: View {
SideField(title: "Right", url: model.rightURL) { model.setSide(.right, to: $0) }
Divider().frame(height: 22)
modePicker
Toggle("Only differences", isOn: $model.showOnlyDifferences)
.toggleStyle(.checkbox)
filterPicker
}
.padding(10)
}
@@ -77,6 +76,16 @@ struct SidePickers: View {
})
}
private var filterPicker: some View {
Picker("", selection: $model.filter) {
ForEach(RowFilter.allCases) { option in
Text(option.label).tag(option)
}
}
.frame(width: 190)
.help("Which rows the tree shows")
}
private var modePicker: some View {
Picker("", selection: modeBinding) {
Text("Automatic").tag(ComparisonMode?.none)
+32 -21
View File
@@ -8,17 +8,15 @@ struct DisplayNode: Identifiable {
let node: DiffNode
let children: [DisplayNode]?
/// Builds the display tree, optionally keeping only what differs. Leaves get
/// nil children so SwiftUI doesn't draw a disclosure triangle on files.
static func build(_ node: DiffNode, onlyDifferences: Bool) -> DisplayNode? {
if onlyDifferences && !node.status.isDifference && !node.isDirectory { return nil }
/// Builds the display tree under a filter. Leaves get nil children so SwiftUI
/// doesn't draw a disclosure triangle on files. A folder survives only if
/// something inside it survives, so filtering never leaves empty branches.
static func build(_ node: DiffNode, filter: RowFilter) -> DisplayNode? {
guard node.isDirectory else {
return DisplayNode(id: node.path, node: node, children: nil)
return filter.matches(node.status) ? DisplayNode(id: node.path, node: node, children: nil) : nil
}
let kids = node.children.compactMap { build($0, onlyDifferences: onlyDifferences) }
// Hide folders that end up empty under the filter.
if onlyDifferences && kids.isEmpty { return nil }
let kids = node.children.compactMap { build($0, filter: filter) }
if filter.hidesRows && kids.isEmpty { return nil }
return DisplayNode(id: node.path.isEmpty ? "__root__" : node.path, node: node, children: kids)
}
}
@@ -29,21 +27,31 @@ struct DiffTreeView: View {
@Bindable var model: ComparisonModel
let root: DiffNode
/// The status column is fixed; the two name columns share everything else, so
/// a wide window is actually used.
private let statusWidth: CGFloat = 96
var body: some View {
VStack(spacing: 0) {
header
Divider()
List(rows, children: \.children, selection: selectionBinding) { row in
DiffRow(node: row.node)
.contentShape(Rectangle())
.onTapGesture { select(row.node) }
if rows.isEmpty {
Spacer()
Text("Nothing matches this filter").foregroundStyle(.secondary)
Spacer()
} else {
List(rows, children: \.children, selection: selectionBinding) { row in
DiffRow(node: row.node, statusWidth: statusWidth)
.contentShape(Rectangle())
.onTapGesture { select(row.node) }
}
.listStyle(.inset)
}
.listStyle(.inset)
}
}
private var rows: [DisplayNode] {
DisplayNode.build(root, onlyDifferences: model.showOnlyDifferences)?.children ?? []
DisplayNode.build(root, filter: model.filter)?.children ?? []
}
private var header: some View {
@@ -55,8 +63,8 @@ struct DiffTreeView: View {
Group {
if let name = model.rightURL?.lastPathComponent { Text(verbatim: name) } else { Text("Right") }
}
.frame(width: 220, alignment: .leading)
Text("Status").frame(width: 92, alignment: .trailing)
.frame(maxWidth: .infinity, alignment: .leading)
Text("Status").frame(width: statusWidth, alignment: .trailing)
}
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
@@ -74,9 +82,10 @@ struct DiffTreeView: View {
}
}
/// One row: name, the other side, and the verdict.
/// One row: the name on each side, and the verdict.
struct DiffRow: View {
let node: DiffNode
let statusWidth: CGFloat
var body: some View {
HStack(spacing: 0) {
@@ -84,18 +93,20 @@ struct DiffRow: View {
Image(systemName: icon)
.foregroundStyle(node.isArchive ? Color.orange : Color.secondary)
.frame(width: 16)
Text(verbatim: node.name)
// An em dash marks the side where the item simply isn't there.
Text(verbatim: node.left == nil ? "" : node.name)
.foregroundStyle(node.status == .onlyRight ? .secondary : .primary)
}
.frame(maxWidth: .infinity, alignment: .leading)
Text(verbatim: node.right == nil ? "" : node.name)
.foregroundStyle(node.status == .onlyLeft ? .secondary : .primary)
.frame(width: 220, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
statusTag.frame(width: 92, alignment: .trailing)
statusTag.frame(width: statusWidth, alignment: .trailing)
}
.lineLimit(1)
.truncationMode(.middle)
.padding(.vertical, 1)
}
+31 -12
View File
@@ -9,6 +9,9 @@ struct FileDiffView: View {
@State private var rows: [TextDiff.Row] = []
@State private var notice: String?
@State private var isLoading = false
/// Long lines wrap by default so nothing is hidden; turning it off gives one
/// line per row, which reads better for code.
@AppStorage("kotej.wrapLines") private var wrapLines = true
var body: some View {
VStack(spacing: 0) {
@@ -35,6 +38,11 @@ struct FileDiffView: View {
.truncationMode(.head)
Spacer()
if isLoading { ProgressView().controlSize(.small) }
if !rows.isEmpty {
Toggle("Wrap lines", isOn: $wrapLines)
.toggleStyle(.checkbox)
.font(.caption)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
@@ -42,22 +50,28 @@ struct FileDiffView: View {
@ViewBuilder private func content(_ node: DiffNode) -> some View {
if let notice {
Spacer()
Text(verbatim: notice).foregroundStyle(.secondary).padding()
Spacer()
Text(verbatim: notice)
.foregroundStyle(.secondary)
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if rows.isEmpty {
Spacer()
Group { if !isLoading { Text("Nothing to show") } }.foregroundStyle(.secondary)
Spacer()
Group { if !isLoading { Text("Nothing to show") } }
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView([.vertical, .horizontal]) {
// Vertical scrolling only: each side takes half the width and long
// lines wrap, so the panes fill the window instead of sitting in a
// narrow column with empty space around them.
ScrollView(.vertical) {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
DiffLine(row: row)
DiffLine(row: row, wrapLines: wrapLines)
}
}
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
@@ -110,12 +124,14 @@ struct FileDiffView: View {
}
}
/// One aligned line pair.
/// One aligned line pair. Both sides get exactly half the width, so the diff
/// spans the whole window.
struct DiffLine: View {
let row: TextDiff.Row
var wrapLines: Bool = true
var body: some View {
HStack(spacing: 0) {
HStack(alignment: .top, spacing: 0) {
cell(number: row.leftNumber, text: row.left, tint: leftTint)
Divider()
cell(number: row.rightNumber, text: row.right, tint: rightTint)
@@ -124,17 +140,20 @@ struct DiffLine: View {
}
private func cell(number: Int?, text: String, tint: Color) -> some View {
HStack(spacing: 8) {
HStack(alignment: .top, spacing: 8) {
Text(verbatim: number.map(String.init) ?? "")
.frame(width: 44, alignment: .trailing)
.foregroundStyle(.tertiary)
Text(verbatim: text.isEmpty ? " " : text)
.lineLimit(wrapLines ? nil : 1)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: wrapLines)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
.padding(.horizontal, 6)
.padding(.vertical, 1)
.frame(minWidth: 320, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
.background(tint)
}
+54
View File
@@ -0,0 +1,54 @@
import SwiftUI
import KotejEngine
/// What the tree shows. Reviewing a big comparison usually means looking at one
/// kind of difference at a time what changed, what only exists on one side
/// rather than everything at once.
enum RowFilter: String, CaseIterable, Identifiable {
/// Every row, differences or not.
case all
/// Anything that isn't identical.
case differences
/// Files present on both sides whose contents differ.
case changed
/// Present on the left only.
case orphansLeft
/// Present on the right only.
case orphansRight
/// Present on one side only, either side.
case orphans
var id: String { rawValue }
var label: LocalizedStringKey {
switch self {
case .all: return "Everything"
case .differences: return "Only differences"
case .changed: return "Only changed files"
case .orphansLeft: return "Only on the left"
case .orphansRight: return "Only on the right"
case .orphans: return "Only on one side"
}
}
func matches(_ status: DiffStatus) -> Bool {
switch self {
case .all:
return true
case .differences:
return status.isDifference
case .changed:
// Pending counts: it's undecided, not proven equal.
return status == .different || status == .pending
case .orphansLeft:
return status == .onlyLeft
case .orphansRight:
return status == .onlyRight
case .orphans:
return status == .onlyLeft || status == .onlyRight
}
}
/// True when the filter hides anything at all.
var hidesRows: Bool { self != .all }
}
+10
View File
@@ -51,3 +51,13 @@
"Couldn't read the contents." = "Couldn't read the contents.";
"Identical binary (%lld bytes)." = "Identical binary (%lld bytes).";
"Different binary (%lld vs %lld bytes)." = "Different binary (%lld vs %lld bytes).";
/* Tree filters */
"Everything" = "Everything";
"Only changed files" = "Only changed files";
"Only on the left" = "Only on the left";
"Only on the right" = "Only on the right";
"Only on one side" = "Only on one side";
"Which rows the tree shows" = "Which rows the tree shows";
"Nothing matches this filter" = "Nothing matches this filter";
"Wrap lines" = "Wrap lines";
+10
View File
@@ -51,3 +51,13 @@
"Couldn't read the contents." = "No se pudo leer el contenido.";
"Identical binary (%lld bytes)." = "Binario idéntico (%lld bytes).";
"Different binary (%lld vs %lld bytes)." = "Binario distinto (%lld vs %lld bytes).";
/* Filtros del árbol */
"Everything" = "Todo";
"Only changed files" = "Solo ficheros cambiados";
"Only on the left" = "Solo en la izquierda";
"Only on the right" = "Solo en la derecha";
"Only on one side" = "Solo en un lado";
"Which rows the tree shows" = "Qué filas muestra el árbol";
"Nothing matches this filter" = "Nada coincide con este filtro";
"Wrap lines" = "Ajustar líneas";