Commit 23880331ce
23880331ce724e6c3a6d8a81fe2fbdbf6dfb04c8
parent: 0ef480e4cc
Unsigned
cmc <hello@cleberg.net> · 2026-09-30 04:27 UTC
Long files draw; notifications empty state and device rows (!150)
- File view drew nothing for long files (krz/gitbay CHANGELOG.org, 2,682 lines): it was one `Text` in a two-axis `ScrollView`. Now one `Text` per line in a `LazyVStack`, `defaultScrollAnchor(.topLeading)`. `codeLines` splits the highlighted text per line (4 tests in CodeLinesTests).
- Notifications: the empty-inbox message is a row, not an overlay over the whole list (it sat on top of the push section). Registered push devices get an iPhone icon, "added <relative> · <token>", and a section footer, so they no longer read as notifications.
577 unit tests, Release build, UI target compiles. Checked in stub-hosted snapshots with the real CHANGELOG.org payload and two registered devices.
Layout: unified · split
gitbay/Views/Account/NotificationsView.swift
+30 −8
| @@ -42,11 +42,20 @@ struct NotificationsView: View { |
| 42 | 42 | } |
| 43 | 43 | } |
| 44 | 44 | } |
| 45 | // An empty inbox is a row, not an overlay: the push section |
| 46 | // below still has content. |
| 47 | if case .empty(let message) = model.state { |
| 48 | Text(message) |
| 49 | .font(.gbSans(.subheadline)) |
| 50 | .foregroundStyle(.secondary) |
| 51 | } |
| 45 | 52 | PageFooter(list: model.list) |
| 46 | 53 | |
| 47 | 54 | pushSection |
| 48 | 55 | } |
| 49 | | .overlay { LoadStateOverlay(state: model.state) } |
| 56 | .overlay { |
| 57 | if case .empty = model.state {} else { LoadStateOverlay(state: model.state) } |
| 58 | } |
| 50 | 59 | .navigationTitle("Notifications") |
| 51 | 60 | .navigationBarTitleDisplayMode(.inline) |
| 52 | 61 | .toolbar { |
| @@ -92,7 +101,7 @@ struct NotificationsView: View { |
| 92 | 101 | /// install, and a prompt sprung by navigation earns a reflexive |
| 93 | 102 | /// "Don't Allow" that nothing afterwards can explain. |
| 94 | 103 | private var pushSection: some View { |
| 95 | | Section("Push notifications") { |
| 104 | Section { |
| 96 | 105 | Toggle("Push notifications", isOn: Binding( |
| 97 | 106 | get: { authorization == .authorized && model.pushEnabled }, |
| 98 | 107 | set: { on in Task { await setPush(on) } } |
| @@ -110,19 +119,32 @@ struct NotificationsView: View { |
| 110 | 119 | } |
| 111 | 120 | |
| 112 | 121 | ForEach(model.devices) { device in |
| 113 | | VStack(alignment: .leading, spacing: 2) { |
| 114 | | Text(device.label) |
| 115 | | .font(.gbSans(.subheadline)) |
| 116 | | Text(device.token) |
| 117 | | .font(.gbMono(.caption)) |
| 118 | | .foregroundStyle(.secondary) |
| 122 | Label { |
| 123 | VStack(alignment: .leading, spacing: 2) { |
| 124 | Text(device.label) |
| 125 | .font(.gbSans(.subheadline)) |
| 126 | Text("added \(AdminQueues.relative(device.added)) · \(device.token)") |
| 127 | .font(.gbSans(.caption)) |
| 128 | .foregroundStyle(.secondary) |
| 129 | .lineLimit(1) |
| 130 | } |
| 131 | } icon: { |
| 132 | Image(systemName: "iphone").foregroundStyle(.secondary) |
| 119 | 133 | } |
| 134 | .accessibilityElement(children: .ignore) |
| 135 | .accessibilityLabel("Registered device, \(device.label)") |
| 120 | 136 | .swipeActions { |
| 121 | 137 | Button("Remove", role: .destructive) { |
| 122 | 138 | Task { await model.removeDevice(device.id) } |
| 123 | 139 | } |
| 124 | 140 | } |
| 125 | 141 | } |
| 142 | } header: { |
| 143 | Text("Push notifications") |
| 144 | } footer: { |
| 145 | if !model.devices.isEmpty { |
| 146 | Text("Devices registered for push. Swipe one to remove it.") |
| 147 | } |
| 126 | 148 | } |
| 127 | 149 | } |
| 128 | 150 | |
gitbay/Views/Repos/FileView.swift
+29 −8
| @@ -89,29 +89,50 @@ struct FileView: View { |
| 89 | 89 | } |
| 90 | 90 | |
| 91 | 91 | /// Both-axis scrolling for code, highlighted when the language is known. |
| 92 | /// One Text per line in a lazy stack: a single Text of a long file (a |
| 93 | /// 2,700-line changelog) does not draw. |
| 92 | 94 | private struct CodeScrollView: View { |
| 93 | 95 | |
| 94 | 96 | let text: String |
| 95 | 97 | let fileName: String |
| 96 | 98 | let colorScheme: ColorScheme |
| 97 | 99 | |
| 100 | @State private var lines: [AttributedString] = [] |
| 101 | |
| 98 | 102 | var body: some View { |
| 99 | 103 | ScrollView([.horizontal, .vertical]) { |
| 100 | | code |
| 101 | | .padding(12) |
| 102 | | .frame(maxWidth: .infinity, alignment: .leading) |
| 104 | LazyVStack(alignment: .leading, spacing: 0) { |
| 105 | ForEach(lines.indices, id: \.self) { index in |
| 106 | Text(lines[index].characters.isEmpty ? " " : lines[index]) |
| 107 | .fixedSize() |
| 108 | } |
| 109 | } |
| 110 | .padding(12) |
| 103 | 111 | } |
| 112 | .defaultScrollAnchor(.topLeading) |
| 104 | 113 | .font(.gbMono(.caption)) |
| 114 | .task(id: colorScheme) { lines = highlightedLines() } |
| 105 | 115 | } |
| 106 | 116 | |
| 107 | | private var code: some View { |
| 117 | private func highlightedLines() -> [AttributedString] { |
| 108 | 118 | let size = UIFont.preferredFont(forTextStyle: .caption1).pointSize |
| 109 | 119 | let font = UIFont(name: "Atkinson Hyperlegible Mono", size: size) |
| 110 | 120 | ?? .monospacedSystemFont(ofSize: size, weight: .regular) |
| 111 | 121 | let highlighter = SyntaxHighlighter(colorScheme: colorScheme) |
| 112 | | if let highlighted = highlighter.highlight(text, fileName: fileName, font: font) { |
| 113 | | return Text(AttributedString(highlighted)) |
| 114 | | } |
| 115 | | return Text(text) |
| 122 | return codeLines(highlighter.highlight(text, fileName: fileName, font: font) |
| 123 | ?? NSAttributedString(string: text)) |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | /// A file's lines, each keeping its highlighting. A trailing newline ends |
| 128 | /// the last line rather than starting an empty one. |
| 129 | nonisolated func codeLines(_ source: NSAttributedString) -> [AttributedString] { |
| 130 | var lines: [AttributedString] = [] |
| 131 | let string = source.string as NSString |
| 132 | string.enumerateSubstrings( |
| 133 | in: NSRange(location: 0, length: string.length), options: [.byLines, .substringNotRequired] |
| 134 | ) { _, range, _, _ in |
| 135 | lines.append(AttributedString(source.attributedSubstring(from: range))) |
| 116 | 136 | } |
| 137 | return lines |
| 117 | 138 | } |
gitbayTests/CodeLinesTests.swift
added
+35
| @@ -0,0 +1,35 @@ |
| 1 | import Foundation |
| 2 | import Testing |
| 3 | import UIKit |
| 4 | @testable import gitbay |
| 5 | |
| 6 | struct CodeLinesTests { |
| 7 | |
| 8 | private func text(_ lines: [AttributedString]) -> [String] { |
| 9 | lines.map { String($0.characters) } |
| 10 | } |
| 11 | |
| 12 | @Test func splitsOnNewlinesKeepingBlankLines() { |
| 13 | let lines = codeLines(NSAttributedString(string: "a\n\nb\r\nc")) |
| 14 | #expect(text(lines) == ["a", "", "b", "c"]) |
| 15 | } |
| 16 | |
| 17 | @Test func aTrailingNewlineAddsNoLine() { |
| 18 | #expect(text(codeLines(NSAttributedString(string: "a\nb\n"))) == ["a", "b"]) |
| 19 | } |
| 20 | |
| 21 | @Test func keepsAttributesPerLine() { |
| 22 | let source = NSMutableAttributedString(string: "red\nplain") |
| 23 | source.addAttribute(.foregroundColor, value: UIColor.red, range: NSRange(location: 0, length: 3)) |
| 24 | let lines = codeLines(source) |
| 25 | #expect(lines[0].runs.first?.uiKit.foregroundColor == UIColor.red) |
| 26 | #expect(lines[1].runs.first?.uiKit.foregroundColor == nil) |
| 27 | } |
| 28 | |
| 29 | @Test func aLongFileKeepsEveryLine() { |
| 30 | let source = (1...2682).map { "line \($0)" }.joined(separator: "\n") |
| 31 | let lines = codeLines(NSAttributedString(string: source)) |
| 32 | #expect(lines.count == 2682) |
| 33 | #expect(text([lines.last!]) == ["line 2682"]) |
| 34 | } |
| 35 | } |