Commit b959973eb4

b959973eb4e994a84b8408253f49f73d0146d25d

parent: 0ef480e4cc

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-30 04:27 UTC

Long files draw; notifications empty state and device rows

- File view: one Text per line in a lazy stack, anchored top-leading. A
  single Text of krz/gitbay's CHANGELOG.org (2,682 lines) drew nothing.
  codeLines splits the highlighted text, keeping attributes per line.
- Notifications: an empty inbox is a row instead of an overlay across
  the whole list. Registered push devices show an iPhone icon, when they
  were added and the shortened token, with a footer saying what they are.

Layout: unified · split

gitbay/Views/Account/NotificationsView.swift +30 −8
@@ -42,11 +42,20 @@ struct NotificationsView: View {
4242 }
4343 }
4444 }
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 }
4552 PageFooter(list: model.list)
4653
4754 pushSection
4855 }
49 .overlay { LoadStateOverlay(state: model.state) }
56 .overlay {
57 if case .empty = model.state {} else { LoadStateOverlay(state: model.state) }
58 }
5059 .navigationTitle("Notifications")
5160 .navigationBarTitleDisplayMode(.inline)
5261 .toolbar {
@@ -92,7 +101,7 @@ struct NotificationsView: View {
92101 /// install, and a prompt sprung by navigation earns a reflexive
93102 /// "Don't Allow" that nothing afterwards can explain.
94103 private var pushSection: some View {
95 Section("Push notifications") {
104 Section {
96105 Toggle("Push notifications", isOn: Binding(
97106 get: { authorization == .authorized && model.pushEnabled },
98107 set: { on in Task { await setPush(on) } }
@@ -110,19 +119,32 @@ struct NotificationsView: View {
110119 }
111120
112121 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)
119133 }
134 .accessibilityElement(children: .ignore)
135 .accessibilityLabel("Registered device, \(device.label)")
120136 .swipeActions {
121137 Button("Remove", role: .destructive) {
122138 Task { await model.removeDevice(device.id) }
123139 }
124140 }
125141 }
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 }
126148 }
127149 }
128150
gitbay/Views/Repos/FileView.swift +29 −8
@@ -89,29 +89,50 @@ struct FileView: View {
8989}
9090
9191/// 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.
9294private struct CodeScrollView: View {
9395
9496 let text: String
9597 let fileName: String
9698 let colorScheme: ColorScheme
9799
100 @State private var lines: [AttributedString] = []
101
98102 var body: some View {
99103 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)
103111 }
112 .defaultScrollAnchor(.topLeading)
104113 .font(.gbMono(.caption))
114 .task(id: colorScheme) { lines = highlightedLines() }
105115 }
106116
107 private var code: some View {
117 private func highlightedLines() -> [AttributedString] {
108118 let size = UIFont.preferredFont(forTextStyle: .caption1).pointSize
109119 let font = UIFont(name: "Atkinson Hyperlegible Mono", size: size)
110120 ?? .monospacedSystemFont(ofSize: size, weight: .regular)
111121 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.
129nonisolated 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)))
116136 }
137 return lines
117138}
gitbayTests/CodeLinesTests.swift added +35
@@ -0,0 +1,35 @@
1import Foundation
2import Testing
3import UIKit
4@testable import gitbay
5
6struct 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}