| @@ -14,31 +14,31 @@ struct BuildTaskLogView: View { |
| 14 | 14 | Group { |
| 15 | 15 | if let task { |
| 16 | 16 | if let logText = viewModel.displayedLogText(for: task) { |
| 17 | | BuildLogTextView(text: logText) |
| 18 | | .safeAreaInset(edge: .bottom) { |
| 19 | | if viewModel.isShowingBuildLogFallback(for: task) { |
| 20 | | Text("Showing the live build log until a task-specific log is available.") |
| 21 | | .font(.footnote) |
| 22 | | .foregroundStyle(.secondary) |
| 23 | | .frame(maxWidth: .infinity, alignment: .leading) |
| 24 | | .padding(.horizontal) |
| 25 | | .padding(.vertical, 8) |
| 26 | | .background(.thinMaterial) |
| 27 | | } |
| 28 | | } |
| 29 | | .toolbar { |
| 30 | | ToolbarItem(placement: .topBarTrailing) { |
| 31 | | ShareLink(item: logText) |
| 17 | BuildTaskLogContentView(text: logText) |
| 18 | .safeAreaInset(edge: .bottom) { |
| 19 | if viewModel.isShowingBuildLogFallback(for: task) { |
| 20 | Text("Showing the live build log until a task-specific log is available.") |
| 21 | .font(.footnote) |
| 22 | .foregroundStyle(.secondary) |
| 23 | .frame(maxWidth: .infinity, alignment: .leading) |
| 24 | .padding(.horizontal) |
| 25 | .padding(.vertical, 8) |
| 26 | .background(.thinMaterial) |
| 27 | } |
| 32 | 28 | } |
| 33 | | ToolbarItem(placement: .topBarTrailing) { |
| 34 | | Button { |
| 35 | | UIPasteboard.general.string = logText |
| 36 | | } label: { |
| 37 | | Image(systemName: "doc.on.doc") |
| 29 | .toolbar { |
| 30 | ToolbarItem(placement: .topBarTrailing) { |
| 31 | ShareLink(item: logText) |
| 32 | } |
| 33 | ToolbarItem(placement: .topBarTrailing) { |
| 34 | Button { |
| 35 | UIPasteboard.general.string = logText |
| 36 | } label: { |
| 37 | Image(systemName: "doc.on.doc") |
| 38 | } |
| 39 | .accessibilityLabel("Copy log to clipboard") |
| 38 | 40 | } |
| 39 | | .accessibilityLabel("Copy log to clipboard") |
| 40 | 41 | } |
| 41 | | } |
| 42 | 42 | } else if viewModel.loadingTaskLogs.contains(task.logCacheKey) { |
| 43 | 43 | SRHTLoadingStateView(message: "Loading log…") |
| 44 | 44 | } else if task.log == nil { |
| @@ -89,8 +89,209 @@ struct BuildTaskLogView: View { |
| 89 | 89 | } |
| 90 | 90 | } |
| 91 | 91 | |
| 92 | private struct BuildTaskLogContentView: View { |
| 93 | let text: String |
| 94 | |
| 95 | @State private var searchQuery = "" |
| 96 | @State private var matches: [LogTextRange] = [] |
| 97 | @State private var anchors: [LogAnchor] = [] |
| 98 | @State private var selectedMatchIndex: Int? |
| 99 | @State private var scrollTarget: LogScrollTarget? |
| 100 | |
| 101 | private var selectedMatch: LogTextRange? { |
| 102 | guard let selectedMatchIndex, matches.indices.contains(selectedMatchIndex) else { return nil } |
| 103 | return matches[selectedMatchIndex] |
| 104 | } |
| 105 | |
| 106 | var body: some View { |
| 107 | VStack(spacing: 0) { |
| 108 | searchControls |
| 109 | |
| 110 | if !anchors.isEmpty { |
| 111 | anchorBar |
| 112 | } |
| 113 | |
| 114 | BuildLogTextView( |
| 115 | text: text, |
| 116 | highlights: matches, |
| 117 | selectedHighlight: selectedMatch, |
| 118 | scrollTarget: scrollTarget |
| 119 | ) |
| 120 | } |
| 121 | .task(id: text) { |
| 122 | await refreshAnchors() |
| 123 | await refreshSearch() |
| 124 | } |
| 125 | .task(id: searchQuery) { |
| 126 | await refreshSearch() |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | private var searchControls: some View { |
| 131 | VStack(spacing: 10) { |
| 132 | HStack(spacing: 10) { |
| 133 | HStack(spacing: 8) { |
| 134 | Image(systemName: "magnifyingglass") |
| 135 | .foregroundStyle(.secondary) |
| 136 | |
| 137 | TextField("Search log", text: $searchQuery) |
| 138 | .textInputAutocapitalization(.never) |
| 139 | .autocorrectionDisabled() |
| 140 | |
| 141 | if !searchQuery.isEmpty { |
| 142 | Button { |
| 143 | searchQuery = "" |
| 144 | } label: { |
| 145 | Image(systemName: "xmark.circle.fill") |
| 146 | .foregroundStyle(.secondary) |
| 147 | } |
| 148 | .buttonStyle(.plain) |
| 149 | .accessibilityLabel("Clear search") |
| 150 | } |
| 151 | } |
| 152 | .padding(.horizontal, 10) |
| 153 | .padding(.vertical, 8) |
| 154 | .background { |
| 155 | RoundedRectangle(cornerRadius: 12, style: .continuous) |
| 156 | .fill(.thinMaterial) |
| 157 | } |
| 158 | |
| 159 | HStack(spacing: 4) { |
| 160 | Button { |
| 161 | moveSelection(step: -1) |
| 162 | } label: { |
| 163 | Image(systemName: "chevron.up") |
| 164 | } |
| 165 | .disabled(matches.isEmpty) |
| 166 | |
| 167 | Button { |
| 168 | moveSelection(step: 1) |
| 169 | } label: { |
| 170 | Image(systemName: "chevron.down") |
| 171 | } |
| 172 | .disabled(matches.isEmpty) |
| 173 | } |
| 174 | .buttonStyle(.borderless) |
| 175 | .labelStyle(.iconOnly) |
| 176 | } |
| 177 | |
| 178 | HStack { |
| 179 | if searchQuery.isEmpty { |
| 180 | Text("\(anchors.count) error anchors") |
| 181 | } else if matches.isEmpty { |
| 182 | Text("No matches") |
| 183 | } else if let selectedMatchIndex { |
| 184 | Text("\(selectedMatchIndex + 1) of \(matches.count) matches") |
| 185 | } |
| 186 | |
| 187 | Spacer() |
| 188 | } |
| 189 | .font(.footnote) |
| 190 | .foregroundStyle(.secondary) |
| 191 | } |
| 192 | .padding(.horizontal) |
| 193 | .padding(.top, 12) |
| 194 | .padding(.bottom, 10) |
| 195 | .background(.bar) |
| 196 | } |
| 197 | |
| 198 | private var anchorBar: some View { |
| 199 | ScrollView(.horizontal, showsIndicators: false) { |
| 200 | HStack(spacing: 8) { |
| 201 | ForEach(anchors) { anchor in |
| 202 | Button { |
| 203 | requestScroll(to: anchor.range) |
| 204 | } label: { |
| 205 | VStack(alignment: .leading, spacing: 2) { |
| 206 | Text("Line \(anchor.lineNumber)") |
| 207 | .font(.caption.weight(.semibold)) |
| 208 | Text(anchor.label) |
| 209 | .font(.caption2) |
| 210 | .lineLimit(1) |
| 211 | } |
| 212 | .padding(.horizontal, 10) |
| 213 | .padding(.vertical, 8) |
| 214 | .frame(maxWidth: 220, alignment: .leading) |
| 215 | .background { |
| 216 | RoundedRectangle(cornerRadius: 10, style: .continuous) |
| 217 | .fill(Color.red.opacity(0.12)) |
| 218 | } |
| 219 | } |
| 220 | .buttonStyle(.plain) |
| 221 | } |
| 222 | } |
| 223 | .padding(.horizontal) |
| 224 | .padding(.bottom, 10) |
| 225 | } |
| 226 | .background(.bar) |
| 227 | } |
| 228 | |
| 229 | private func refreshAnchors() async { |
| 230 | let text = text |
| 231 | let newAnchors = await Task.detached(priority: .userInitiated) { |
| 232 | detectLogAnchors(in: text) |
| 233 | }.value |
| 234 | |
| 235 | guard !Task.isCancelled else { return } |
| 236 | anchors = newAnchors |
| 237 | } |
| 238 | |
| 239 | private func refreshSearch() async { |
| 240 | let query = searchQuery |
| 241 | let text = text |
| 242 | |
| 243 | if !query.isEmpty { |
| 244 | try? await Task.sleep(for: .milliseconds(150)) |
| 245 | } |
| 246 | guard !Task.isCancelled else { return } |
| 247 | |
| 248 | let newMatches = await Task.detached(priority: .userInitiated) { |
| 249 | logMatchRanges(in: text, query: query) |
| 250 | }.value |
| 251 | |
| 252 | guard !Task.isCancelled else { return } |
| 253 | |
| 254 | let previousSelectedRange = selectedMatch |
| 255 | matches = newMatches |
| 256 | |
| 257 | if newMatches.isEmpty { |
| 258 | selectedMatchIndex = nil |
| 259 | return |
| 260 | } |
| 261 | |
| 262 | if let previousSelectedRange, |
| 263 | let newIndex = newMatches.firstIndex(of: previousSelectedRange) { |
| 264 | selectedMatchIndex = newIndex |
| 265 | return |
| 266 | } |
| 267 | |
| 268 | selectedMatchIndex = 0 |
| 269 | requestScroll(to: newMatches[0]) |
| 270 | } |
| 271 | |
| 272 | private func moveSelection(step: Int) { |
| 273 | guard !matches.isEmpty else { return } |
| 274 | let currentIndex = selectedMatchIndex ?? 0 |
| 275 | let nextIndex = (currentIndex + step + matches.count) % matches.count |
| 276 | selectedMatchIndex = nextIndex |
| 277 | requestScroll(to: matches[nextIndex]) |
| 278 | } |
| 279 | |
| 280 | private func requestScroll(to range: LogTextRange) { |
| 281 | let nextID = (scrollTarget?.id ?? 0) + 1 |
| 282 | scrollTarget = LogScrollTarget(id: nextID, range: range) |
| 283 | } |
| 284 | } |
| 285 | |
| 92 | 286 | private struct BuildLogTextView: UIViewRepresentable { |
| 93 | 287 | let text: String |
| 288 | let highlights: [LogTextRange] |
| 289 | let selectedHighlight: LogTextRange? |
| 290 | let scrollTarget: LogScrollTarget? |
| 291 | |
| 292 | func makeCoordinator() -> Coordinator { |
| 293 | Coordinator() |
| 294 | } |
| 94 | 295 | |
| 95 | 296 | func makeUIView(context _: Context) -> UITextView { |
| 96 | 297 | let textView = UITextView() |
| @@ -105,13 +306,177 @@ private struct BuildLogTextView: UIViewRepresentable { |
| 105 | 306 | textView.textContainerInset = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) |
| 106 | 307 | textView.textContainer.lineFragmentPadding = 0 |
| 107 | 308 | textView.textContainer.widthTracksTextView = false |
| 108 | | textView.font = UIFont.monospacedSystemFont(ofSize: UIFont.preferredFont(forTextStyle: .caption2).pointSize, weight: .regular) |
| 309 | textView.font = UIFont.monospacedSystemFont( |
| 310 | ofSize: UIFont.preferredFont(forTextStyle: .caption2).pointSize, |
| 311 | weight: .regular |
| 312 | ) |
| 109 | 313 | textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) |
| 110 | 314 | return textView |
| 111 | 315 | } |
| 112 | 316 | |
| 113 | | func updateUIView(_ uiView: UITextView, context _: Context) { |
| 114 | | guard uiView.text != text else { return } |
| 115 | | uiView.text = text |
| 317 | func updateUIView(_ uiView: UITextView, context: Context) { |
| 318 | let coordinator = context.coordinator |
| 319 | |
| 320 | if coordinator.lastText != text { |
| 321 | uiView.attributedText = NSAttributedString(string: text, attributes: baseAttributes(for: uiView)) |
| 322 | coordinator.lastText = text |
| 323 | coordinator.lastHighlights = [] |
| 324 | coordinator.lastSelectedHighlight = nil |
| 325 | } |
| 326 | |
| 327 | if coordinator.lastHighlights != highlights || coordinator.lastSelectedHighlight != selectedHighlight { |
| 328 | applyHighlights(to: uiView) |
| 329 | coordinator.lastHighlights = highlights |
| 330 | coordinator.lastSelectedHighlight = selectedHighlight |
| 331 | } |
| 332 | |
| 333 | if coordinator.lastScrollTarget != scrollTarget, let scrollTarget { |
| 334 | scroll(to: scrollTarget.range.nsRange, in: uiView) |
| 335 | coordinator.lastScrollTarget = scrollTarget |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | private func applyHighlights(to textView: UITextView) { |
| 340 | let textStorage = textView.textStorage |
| 341 | let fullRange = NSRange(location: 0, length: textStorage.length) |
| 342 | |
| 343 | textStorage.beginEditing() |
| 344 | textStorage.removeAttribute(.backgroundColor, range: fullRange) |
| 345 | textStorage.removeAttribute(.foregroundColor, range: fullRange) |
| 346 | textStorage.addAttributes(baseAttributes(for: textView), range: fullRange) |
| 347 | |
| 348 | for range in highlights { |
| 349 | textStorage.addAttribute( |
| 350 | .backgroundColor, |
| 351 | value: UIColor.systemYellow.withAlphaComponent(0.28), |
| 352 | range: range.nsRange |
| 353 | ) |
| 354 | } |
| 355 | |
| 356 | if let selectedHighlight { |
| 357 | textStorage.addAttributes([ |
| 358 | .backgroundColor: UIColor.systemOrange.withAlphaComponent(0.5), |
| 359 | .foregroundColor: UIColor.label |
| 360 | ], range: selectedHighlight.nsRange) |
| 361 | } |
| 362 | textStorage.endEditing() |
| 363 | } |
| 364 | |
| 365 | private func scroll(to range: NSRange, in textView: UITextView) { |
| 366 | guard range.location != NSNotFound else { return } |
| 367 | textView.selectedRange = range |
| 368 | textView.scrollRangeToVisible(range) |
| 369 | } |
| 370 | |
| 371 | private func baseAttributes(for textView: UITextView) -> [NSAttributedString.Key: Any] { |
| 372 | [ |
| 373 | .font: textView.font as Any, |
| 374 | .foregroundColor: UIColor.label |
| 375 | ] |
| 376 | } |
| 377 | |
| 378 | final class Coordinator { |
| 379 | var lastText = "" |
| 380 | var lastHighlights: [LogTextRange] = [] |
| 381 | var lastSelectedHighlight: LogTextRange? |
| 382 | var lastScrollTarget: LogScrollTarget? |
| 383 | } |
| 384 | } |
| 385 | |
| 386 | struct LogTextRange: Hashable, Equatable, Sendable { |
| 387 | let location: Int |
| 388 | let length: Int |
| 389 | |
| 390 | var nsRange: NSRange { |
| 391 | NSRange(location: location, length: length) |
| 116 | 392 | } |
| 117 | 393 | } |
| 394 | |
| 395 | struct LogAnchor: Identifiable, Equatable, Sendable { |
| 396 | let lineNumber: Int |
| 397 | let label: String |
| 398 | let range: LogTextRange |
| 399 | |
| 400 | var id: String { |
| 401 | "\(lineNumber):\(range.location)" |
| 402 | } |
| 403 | } |
| 404 | |
| 405 | private struct LogScrollTarget: Equatable { |
| 406 | let id: Int |
| 407 | let range: LogTextRange |
| 408 | } |
| 409 | |
| 410 | func logMatchRanges(in text: String, query: String, limit: Int = 2_000) -> [LogTextRange] { |
| 411 | let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) |
| 412 | guard !trimmedQuery.isEmpty else { return [] } |
| 413 | |
| 414 | let nsText = text as NSString |
| 415 | var searchRange = NSRange(location: 0, length: nsText.length) |
| 416 | var matches: [LogTextRange] = [] |
| 417 | |
| 418 | while searchRange.length > 0, matches.count < limit { |
| 419 | let foundRange = nsText.range( |
| 420 | of: trimmedQuery, |
| 421 | options: [.caseInsensitive, .diacriticInsensitive], |
| 422 | range: searchRange |
| 423 | ) |
| 424 | |
| 425 | guard foundRange.location != NSNotFound else { break } |
| 426 | matches.append(LogTextRange(location: foundRange.location, length: foundRange.length)) |
| 427 | |
| 428 | let nextLocation = foundRange.location + max(foundRange.length, 1) |
| 429 | guard nextLocation <= nsText.length else { break } |
| 430 | searchRange = NSRange(location: nextLocation, length: nsText.length - nextLocation) |
| 431 | } |
| 432 | |
| 433 | return matches |
| 434 | } |
| 435 | |
| 436 | func detectLogAnchors(in text: String, limit: Int = 24) -> [LogAnchor] { |
| 437 | let nsText = text as NSString |
| 438 | let strongMarkers = [ |
| 439 | "fatal error", |
| 440 | "fatal:", |
| 441 | "error:", |
| 442 | "exception:", |
| 443 | "uncaught exception", |
| 444 | "traceback", |
| 445 | "panic:", |
| 446 | "undefined reference", |
| 447 | "segmentation fault", |
| 448 | "assertion failed", |
| 449 | "failed:" |
| 450 | ] |
| 451 | |
| 452 | var anchors: [LogAnchor] = [] |
| 453 | var lineNumber = 1 |
| 454 | var cursor = 0 |
| 455 | var lastAnchorLine: Int? |
| 456 | |
| 457 | while cursor < nsText.length, anchors.count < limit { |
| 458 | let lineRange = nsText.lineRange(for: NSRange(location: cursor, length: 0)) |
| 459 | let rawLine = nsText.substring(with: lineRange) |
| 460 | let trimmedLine = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) |
| 461 | let foldedLine = trimmedLine.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) |
| 462 | |
| 463 | let isMatch = !trimmedLine.isEmpty && strongMarkers.contains { foldedLine.contains($0) } |
| 464 | let isDistinctFromPrevious = lastAnchorLine.map { lineNumber - $0 > 1 } ?? true |
| 465 | |
| 466 | if isMatch, isDistinctFromPrevious { |
| 467 | anchors.append( |
| 468 | LogAnchor( |
| 469 | lineNumber: lineNumber, |
| 470 | label: String(trimmedLine.prefix(100)), |
| 471 | range: LogTextRange(location: lineRange.location, length: lineRange.length) |
| 472 | ) |
| 473 | ) |
| 474 | lastAnchorLine = lineNumber |
| 475 | } |
| 476 | |
| 477 | cursor = lineRange.upperBound |
| 478 | lineNumber += 1 |
| 479 | } |
| 480 | |
| 481 | return anchors |
| 482 | } |