Long files draw; notifications empty state and device rows !150

merged merged by cmc on 2026-09-30 04:27 UTC · krz/gitbay-ios:file-lines-notifications into main

3 files changed, +94 −16

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 PageFooter(list: model.list) 52 PageFooter(list: model.list)
46 53
47 pushSection 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 .navigationTitle("Notifications") 59 .navigationTitle("Notifications")
51 .navigationBarTitleDisplayMode(.inline) 60 .navigationBarTitleDisplayMode(.inline)
52 .toolbar { 61 .toolbar {
@@ -92,7 +101,7 @@ struct NotificationsView: View {
92 /// install, and a prompt sprung by navigation earns a reflexive 101 /// install, and a prompt sprung by navigation earns a reflexive
93 /// "Don't Allow" that nothing afterwards can explain. 102 /// "Don't Allow" that nothing afterwards can explain.
94 private var pushSection: some View { 103 private var pushSection: some View {
95 Section("Push notifications") { 104 Section {
96 Toggle("Push notifications", isOn: Binding( 105 Toggle("Push notifications", isOn: Binding(
97 get: { authorization == .authorized && model.pushEnabled }, 106 get: { authorization == .authorized && model.pushEnabled },
98 set: { on in Task { await setPush(on) } } 107 set: { on in Task { await setPush(on) } }
@@ -110,19 +119,32 @@ struct NotificationsView: View {
110 } 119 }
111 120
112 ForEach(model.devices) { device in 121 ForEach(model.devices) { device in
113 VStack(alignment: .leading, spacing: 2) { 122 Label {
114 Text(device.label) 123 VStack(alignment: .leading, spacing: 2) {
115 .font(.gbSans(.subheadline)) 124 Text(device.label)
116 Text(device.token) 125 .font(.gbSans(.subheadline))
117 .font(.gbMono(.caption)) 126 Text("added \(AdminQueues.relative(device.added)) · \(device.token)")
118 .foregroundStyle(.secondary) 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 .swipeActions { 136 .swipeActions {
121 Button("Remove", role: .destructive) { 137 Button("Remove", role: .destructive) {
122 Task { await model.removeDevice(device.id) } 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/// Both-axis scrolling for code, highlighted when the language is known. 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.
92private struct CodeScrollView: View { 94private struct CodeScrollView: View {
93 95
94 let text: String 96 let text: String
95 let fileName: String 97 let fileName: String
96 let colorScheme: ColorScheme 98 let colorScheme: ColorScheme
97 99
100 @State private var lines: [AttributedString] = []
101
98 var body: some View { 102 var body: some View {
99 ScrollView([.horizontal, .vertical]) { 103 ScrollView([.horizontal, .vertical]) {
100 code 104 LazyVStack(alignment: .leading, spacing: 0) {
101 .padding(12) 105 ForEach(lines.indices, id: \.self) { index in
102 .frame(maxWidth: .infinity, alignment: .leading) 106 Text(lines[index].characters.isEmpty ? " " : lines[index])
107 .fixedSize()
108 }
109 }
110 .padding(12)
103 } 111 }
112 .defaultScrollAnchor(.topLeading)
104 .font(.gbMono(.caption)) 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 let size = UIFont.preferredFont(forTextStyle: .caption1).pointSize 118 let size = UIFont.preferredFont(forTextStyle: .caption1).pointSize
109 let font = UIFont(name: "Atkinson Hyperlegible Mono", size: size) 119 let font = UIFont(name: "Atkinson Hyperlegible Mono", size: size)
110 ?? .monospacedSystemFont(ofSize: size, weight: .regular) 120 ?? .monospacedSystemFont(ofSize: size, weight: .regular)
111 let highlighter = SyntaxHighlighter(colorScheme: colorScheme) 121 let highlighter = SyntaxHighlighter(colorScheme: colorScheme)
112 if let highlighted = highlighter.highlight(text, fileName: fileName, font: font) { 122 return codeLines(highlighter.highlight(text, fileName: fileName, font: font)
113 return Text(AttributedString(highlighted)) 123 ?? NSAttributedString(string: text))
114 } 124 }
115 return Text(text) 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)))
116 } 136 }
137 return lines
117} 138}
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}