Commit 34e12c4d2c
Unsigned
Layout: unified · split
Hutch/Views/Inbox/ThreadDetailView.swift +2 −5
| @@ -266,11 +266,8 @@ private struct InboxMessageRow: View { | |||
| 266 | .frame(maxWidth: .infinity, alignment: .leading) | 266 | .frame(maxWidth: .infinity, alignment: .leading) |
| 267 | .fixedSize(horizontal: false, vertical: true) | 267 | .fixedSize(horizontal: false, vertical: true) |
| 268 | case .diff(let diff): | 268 | case .diff(let diff): |
| 269 | ScrollView(.horizontal) { | 269 | DiffView(diff: diff) |
| 270 | DiffView(diff: diff) | 270 | .textSelection(.enabled) |
| 271 | .textSelection(.enabled) | ||
| 272 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 273 | } | ||
| 274 | } | 271 | } |
| 275 | } | 272 | } |
| 276 | } | 273 | } |
Hutch/Views/Repositories/DiffView.swift +109 −5
| @@ -76,16 +76,118 @@ private struct DiffBlockView: View { | |||
| 76 | let lines: [String] | 76 | let lines: [String] |
| 77 | 77 | ||
| 78 | var body: some View { | 78 | var body: some View { |
| 79 | let hunks = DiffHunk.split(lines: lines) | ||
| 80 | LazyVStack(alignment: .leading, spacing: 0) { | ||
| 81 | ForEach(hunks) { hunk in | ||
| 82 | DiffHunkView(hunk: hunk) | ||
| 83 | } | ||
| 84 | } | ||
| 85 | .font(.system(.caption, design: .monospaced)) | ||
| 86 | .background(Color(.secondarySystemBackground)) | ||
| 87 | } | ||
| 88 | } | ||
| 89 | |||
| 90 | private struct DiffHunk: Identifiable { | ||
| 91 | let id: Int | ||
| 92 | let header: String? | ||
| 93 | let lines: [String] | ||
| 94 | let isFileHeader: Bool | ||
| 95 | |||
| 96 | static func split(lines: [String]) -> [DiffHunk] { | ||
| 97 | var hunks: [DiffHunk] = [] | ||
| 98 | var current: [String] = [] | ||
| 99 | var hunkIndex = 0 | ||
| 100 | var headerLines: [String] = [] | ||
| 101 | var passedFirstHunk = false | ||
| 102 | |||
| 103 | for line in lines { | ||
| 104 | if line.hasPrefix("@@") { | ||
| 105 | if !passedFirstHunk { | ||
| 106 | // Collect file header lines before first hunk | ||
| 107 | if !current.isEmpty { | ||
| 108 | headerLines = current | ||
| 109 | hunks.append(DiffHunk(id: hunkIndex, header: nil, lines: headerLines, isFileHeader: true)) | ||
| 110 | hunkIndex += 1 | ||
| 111 | } | ||
| 112 | current = [line] | ||
| 113 | passedFirstHunk = true | ||
| 114 | } else { | ||
| 115 | // End previous hunk, start new one | ||
| 116 | if !current.isEmpty { | ||
| 117 | let header = current.first | ||
| 118 | hunks.append(DiffHunk(id: hunkIndex, header: header, lines: current, isFileHeader: false)) | ||
| 119 | hunkIndex += 1 | ||
| 120 | } | ||
| 121 | current = [line] | ||
| 122 | } | ||
| 123 | } else { | ||
| 124 | current.append(line) | ||
| 125 | } | ||
| 126 | } | ||
| 127 | |||
| 128 | if !current.isEmpty { | ||
| 129 | if passedFirstHunk { | ||
| 130 | let header = current.first(where: { $0.hasPrefix("@@") }) ?? current.first | ||
| 131 | hunks.append(DiffHunk(id: hunkIndex, header: header, lines: current, isFileHeader: false)) | ||
| 132 | } else { | ||
| 133 | hunks.append(DiffHunk(id: hunkIndex, header: nil, lines: current, isFileHeader: true)) | ||
| 134 | } | ||
| 135 | } | ||
| 136 | |||
| 137 | return hunks | ||
| 138 | } | ||
| 139 | } | ||
| 140 | |||
| 141 | private struct DiffHunkView: View { | ||
| 142 | let hunk: DiffHunk | ||
| 143 | @State private var isExpanded = true | ||
| 144 | |||
| 145 | private var isCollapsible: Bool { | ||
| 146 | !hunk.isFileHeader && hunk.lines.count > 1 | ||
| 147 | } | ||
| 148 | |||
| 149 | var body: some View { | ||
| 150 | if isCollapsible { | ||
| 151 | Button { | ||
| 152 | withAnimation(.snappy(duration: 0.2)) { | ||
| 153 | isExpanded.toggle() | ||
| 154 | } | ||
| 155 | } label: { | ||
| 156 | HStack(spacing: 6) { | ||
| 157 | Image(systemName: isExpanded ? "chevron.down" : "chevron.right") | ||
| 158 | .font(.system(size: 8, weight: .bold, design: .monospaced)) | ||
| 159 | .foregroundStyle(.secondary) | ||
| 160 | .frame(width: 10) | ||
| 161 | |||
| 162 | Text(hunk.header ?? "") | ||
| 163 | .foregroundStyle(.secondary) | ||
| 164 | .lineLimit(1) | ||
| 165 | } | ||
| 166 | .padding(.horizontal, 8) | ||
| 167 | .padding(.vertical, 4) | ||
| 168 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 169 | .contentShape(Rectangle()) | ||
| 170 | } | ||
| 171 | .buttonStyle(.plain) | ||
| 172 | .background(Color(.systemBackground).opacity(0.5)) | ||
| 173 | |||
| 174 | if isExpanded { | ||
| 175 | hunkContent(lines: hunk.lines.dropFirst().map { $0 }) | ||
| 176 | } | ||
| 177 | } else { | ||
| 178 | hunkContent(lines: hunk.lines) | ||
| 179 | } | ||
| 180 | } | ||
| 181 | |||
| 182 | @ViewBuilder | ||
| 183 | private func hunkContent(lines: [String]) -> some View { | ||
| 79 | ScrollView(.horizontal, showsIndicators: false) { | 184 | ScrollView(.horizontal, showsIndicators: false) { |
| 80 | LazyVStack(alignment: .leading, spacing: 0) { | 185 | VStack(alignment: .leading, spacing: 0) { |
| 81 | ForEach(Array(lines.enumerated()), id: \.offset) { _, line in | 186 | ForEach(Array(lines.enumerated()), id: \.offset) { _, line in |
| 82 | DiffLineView(line: line) | 187 | DiffLineView(line: line) |
| 83 | } | 188 | } |
| 84 | } | 189 | } |
| 85 | .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) | ||
| 86 | } | 190 | } |
| 87 | .font(.system(.caption, design: .monospaced)) | ||
| 88 | .background(Color(.secondarySystemBackground)) | ||
| 89 | } | 191 | } |
| 90 | } | 192 | } |
| 91 | 193 | ||
| @@ -171,8 +273,10 @@ private struct DiffLineView: View { | |||
| 171 | 273 | ||
| 172 | var body: some View { | 274 | var body: some View { |
| 173 | Text(line.isEmpty ? " " : line) | 275 | Text(line.isEmpty ? " " : line) |
| 174 | .frame(maxWidth: .infinity, alignment: .leading) | 276 | .fixedSize(horizontal: true, vertical: false) |
| 175 | .padding(.horizontal, 8) | 277 | .padding(.horizontal, 8) |
| 278 | .padding(.vertical, 1) | ||
| 279 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 176 | .background(backgroundColor) | 280 | .background(backgroundColor) |
| 177 | .foregroundStyle(foregroundColor) | 281 | .foregroundStyle(foregroundColor) |
| 178 | .fontWeight(isHeader ? .semibold : .regular) | 282 | .fontWeight(isHeader ? .semibold : .regular) |