| @@ -14,31 +14,31 @@ struct BuildTaskLogView: View { |
| 14 | Group { |
14 | Group { |
| 15 | if let task { |
15 | if let task { |
| 16 | if let logText = viewModel.displayedLogText(for: task) { |
16 | if let logText = viewModel.displayedLogText(for: task) { |
| 17 | BuildLogTextView(text: logText) |
17 | BuildTaskLogContentView(text: logText) |
| 18 | .safeAreaInset(edge: .bottom) { |
18 | .safeAreaInset(edge: .bottom) { |
| 19 | if viewModel.isShowingBuildLogFallback(for: task) { |
19 | if viewModel.isShowingBuildLogFallback(for: task) { |
| 20 | Text("Showing the live build log until a task-specific log is available.") |
20 | Text("Showing the live build log until a task-specific log is available.") |
| 21 | .font(.footnote) |
21 | .font(.footnote) |
| 22 | .foregroundStyle(.secondary) |
22 | .foregroundStyle(.secondary) |
| 23 | .frame(maxWidth: .infinity, alignment: .leading) |
23 | .frame(maxWidth: .infinity, alignment: .leading) |
| 24 | .padding(.horizontal) |
24 | .padding(.horizontal) |
| 25 | .padding(.vertical, 8) |
25 | .padding(.vertical, 8) |
| 26 | .background(.thinMaterial) |
26 | .background(.thinMaterial) |
| 27 | } |
27 | } |
| 28 | } |
| |
| 29 | .toolbar { |
| |
| 30 | ToolbarItem(placement: .topBarTrailing) { |
| |
| 31 | ShareLink(item: logText) |
| |
| 32 | } |
28 | } |
| 33 | ToolbarItem(placement: .topBarTrailing) { |
29 | .toolbar { |
| 34 | Button { |
30 | ToolbarItem(placement: .topBarTrailing) { |
| 35 | UIPasteboard.general.string = logText |
31 | ShareLink(item: logText) |
| 36 | } label: { |
32 | } |
| 37 | Image(systemName: "doc.on.doc") |
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 | } else if viewModel.loadingTaskLogs.contains(task.logCacheKey) { |
42 | } else if viewModel.loadingTaskLogs.contains(task.logCacheKey) { |
| 43 | SRHTLoadingStateView(message: "Loading log…") |
43 | SRHTLoadingStateView(message: "Loading log…") |
| 44 | } else if task.log == nil { |
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 | private struct BuildLogTextView: UIViewRepresentable { |
286 | private struct BuildLogTextView: UIViewRepresentable { |
| 93 | let text: String |
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 | func makeUIView(context _: Context) -> UITextView { |
296 | func makeUIView(context _: Context) -> UITextView { |
| 96 | let textView = UITextView() |
297 | let textView = UITextView() |
| @@ -105,13 +306,177 @@ private struct BuildLogTextView: UIViewRepresentable { |
| 105 | textView.textContainerInset = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) |
306 | textView.textContainerInset = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) |
| 106 | textView.textContainer.lineFragmentPadding = 0 |
307 | textView.textContainer.lineFragmentPadding = 0 |
| 107 | textView.textContainer.widthTracksTextView = false |
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 | textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) |
313 | textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) |
| 110 | return textView |
314 | return textView |
| 111 | } |
315 | } |
| 112 | |
316 | |
| 113 | func updateUIView(_ uiView: UITextView, context _: Context) { |
317 | func updateUIView(_ uiView: UITextView, context: Context) { |
| 114 | guard uiView.text != text else { return } |
318 | let coordinator = context.coordinator |
| 115 | uiView.text = text |
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 | } |