chore: refactored organization 3d55c3b5
Steve · 2025-12-20 23:56 6 file(s) · +390 −394
Gemini/ContentView.swift (deleted) +0 −394
1 -
//
2 -
//  ContentView.swift
3 -
//  Gemini
4 -
//
5 -
//  Created by Steve Simkins on 12/20/25.
6 -
//
7 -
8 -
// ContentView.swift (or your main view file)
9 -
import SwiftUI
10 -
11 -
// MARK: - Gemini Content Parser
12 -
13 -
enum GeminiLine {
14 -
    case text(String)
15 -
    case link(url: String, label: String)
16 -
    case heading1(String)
17 -
    case heading2(String)
18 -
    case heading3(String)
19 -
    case listItem(String)
20 -
    case quote(String)
21 -
    case preformattedToggle(alt: String)
22 -
    case preformatted(String)
23 -
}
24 -
25 -
struct GeminiParser {
26 -
    static func parse(_ content: String, baseURL: String) -> [GeminiLine] {
27 -
        var lines: [GeminiLine] = []
28 -
        var inPreformatted = false
29 -
30 -
        for line in content.components(separatedBy: .newlines) {
31 -
            if line.hasPrefix("```") {
32 -
                inPreformatted.toggle()
33 -
                let alt = String(line.dropFirst(3))
34 -
                lines.append(.preformattedToggle(alt: alt))
35 -
                continue
36 -
            }
37 -
38 -
            if inPreformatted {
39 -
                lines.append(.preformatted(line))
40 -
                continue
41 -
            }
42 -
43 -
            if line.hasPrefix("###") {
44 -
                lines.append(.heading3(String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)))
45 -
            } else if line.hasPrefix("##") {
46 -
                lines.append(.heading2(String(line.dropFirst(2)).trimmingCharacters(in: .whitespaces)))
47 -
            } else if line.hasPrefix("#") {
48 -
                lines.append(.heading1(String(line.dropFirst(1)).trimmingCharacters(in: .whitespaces)))
49 -
            } else if line.hasPrefix("=>") {
50 -
                let linkContent = String(line.dropFirst(2)).trimmingCharacters(in: .whitespaces)
51 -
                let (url, label) = parseLink(linkContent, baseURL: baseURL)
52 -
                lines.append(.link(url: url, label: label))
53 -
            } else if line.hasPrefix("* ") {
54 -
                lines.append(.listItem(String(line.dropFirst(2))))
55 -
            } else if line.hasPrefix(">") {
56 -
                lines.append(.quote(String(line.dropFirst(1))))
57 -
            } else {
58 -
                lines.append(.text(line))
59 -
            }
60 -
        }
61 -
62 -
        return lines
63 -
    }
64 -
65 -
    private static func parseLink(_ content: String, baseURL: String) -> (url: String, label: String) {
66 -
        // Split on any whitespace (spaces, tabs, etc.)
67 -
        let trimmed = content.trimmingCharacters(in: .whitespaces)
68 -
        let components = trimmed.components(separatedBy: .whitespaces).filter { !$0.isEmpty }
69 -
70 -
        let rawURL = components.first ?? ""
71 -
        let label = components.count > 1 ? components.dropFirst().joined(separator: " ") : rawURL
72 -
73 -
        // Resolve relative URLs
74 -
        let resolvedURL: String
75 -
        if rawURL.contains("://") {
76 -
            resolvedURL = rawURL
77 -
        } else if let base = URL(string: baseURL),
78 -
                  let resolved = URL(string: rawURL, relativeTo: base) {
79 -
            resolvedURL = resolved.absoluteString
80 -
        } else {
81 -
            resolvedURL = rawURL
82 -
        }
83 -
84 -
        return (resolvedURL, label)
85 -
    }
86 -
}
87 -
88 -
// MARK: - Gemini Content View
89 -
90 -
struct GeminiContentView: View {
91 -
    let content: String
92 -
    let baseURL: String
93 -
    let onLinkTap: (String) -> Void
94 -
95 -
    init(content: String, baseURL: String = "", onLinkTap: @escaping (String) -> Void) {
96 -
        self.content = content
97 -
        self.baseURL = baseURL
98 -
        self.onLinkTap = onLinkTap
99 -
    }
100 -
101 -
    var body: some View {
102 -
        let lines = GeminiParser.parse(content, baseURL: baseURL)
103 -
104 -
        LazyVStack(alignment: .leading, spacing: 4) {
105 -
            ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
106 -
                lineView(for: line)
107 -
            }
108 -
        }
109 -
        .padding(8)
110 -
    }
111 -
112 -
    @ViewBuilder
113 -
    private func lineView(for line: GeminiLine) -> some View {
114 -
        switch line {
115 -
        case .text(let text):
116 -
            Text(text)
117 -
                .font(.system(.body, design: .monospaced))
118 -
119 -
        case .link(let url, let label):
120 -
            Button(action: { onLinkTap(url) }) {
121 -
                HStack(alignment:.top, spacing: 4) {
122 -
                    Text("=>")
123 -
                        .font(.system(size: 14, design: .monospaced))
124 -
                    Text(label)
125 -
                        .multilineTextAlignment(.leading)
126 -
                        .font(.system(size: 14, design: .monospaced))
127 -
                }
128 -
            }
129 -
            .foregroundColor(.blue)
130 -
131 -
        case .heading1(let text):
132 -
            Text(text)
133 -
                .font(.system(.title, design: .monospaced))
134 -
                .fontWeight(.bold)
135 -
                .padding(.top, 8)
136 -
137 -
        case .heading2(let text):
138 -
            Text(text)
139 -
                .font(.system(.title2, design: .monospaced))
140 -
                .fontWeight(.semibold)
141 -
                .padding(.top, 6)
142 -
143 -
        case .heading3(let text):
144 -
            Text(text)
145 -
                .font(.system(.title3, design: .monospaced))
146 -
                .fontWeight(.medium)
147 -
                .padding(.top, 4)
148 -
149 -
        case .listItem(let text):
150 -
            HStack(alignment: .top, spacing: 8) {
151 -
                Text("\u{2022}")
152 -
                Text(text)
153 -
            }
154 -
            .font(.system(.body, design: .monospaced))
155 -
156 -
        case .quote(let text):
157 -
            Text(text)
158 -
                .font(.system(.body, design: .monospaced))
159 -
                .italic()
160 -
                .foregroundColor(.secondary)
161 -
                .padding(.leading, 12)
162 -
163 -
        case .preformattedToggle:
164 -
            EmptyView()
165 -
166 -
        case .preformatted(let text):
167 -
            Text(text)
168 -
                .font(.system(.caption, design: .monospaced))
169 -
                .foregroundColor(.secondary)
170 -
                .padding(.leading, 8)
171 -
        }
172 -
    }
173 -
}
174 -
175 -
// MARK: - Main Content View
176 -
177 -
struct ContentView: View {
178 -
    @State private var urlText = "gemini://gemini.circumlunar.space/"
179 -
    @State private var responseText = ""
180 -
    @State private var isLoading = false
181 -
182 -
    // Input prompt state
183 -
    @State private var showInputPrompt = false
184 -
    @State private var inputPromptText = ""
185 -
    @State private var inputValue = ""
186 -
    @State private var inputIsSensitive = false
187 -
    @State private var pendingInputURL = ""
188 -
189 -
    // Navigation history
190 -
    @State private var history: [String] = []
191 -
    @State private var historyIndex = -1
192 -
193 -
    private let maxRedirects = 5
194 -
195 -
    var body: some View {
196 -
        VStack(spacing: 12) {
197 -
            ScrollView {
198 -
                GeminiContentView(content: responseText, baseURL: urlText, onLinkTap: { url in
199 -
                    navigateTo(url)
200 -
                })
201 -
                .frame(maxWidth: .infinity, alignment: .leading)
202 -
            }
203 -
204 -
            HStack(spacing: 8) {
205 -
                // Back button
206 -
                Button(action: goBack) {
207 -
                    Image(systemName: "chevron.left")
208 -
                        .font(.title2)
209 -
                }
210 -
                .disabled(!canGoBack || isLoading)
211 -
212 -
                // Forward button
213 -
                Button(action: goForward) {
214 -
                    Image(systemName: "chevron.right")
215 -
                        .font(.title2)
216 -
                }
217 -
                .disabled(!canGoForward || isLoading)
218 -
219 -
                TextField("Enter Gemini URL", text: $urlText)
220 -
                    .textFieldStyle(.roundedBorder)
221 -
                    .autocapitalization(.none)
222 -
                    .disableAutocorrection(true)
223 -
                    .keyboardType(.URL)
224 -
                    .onSubmit {
225 -
                        navigateTo(urlText)
226 -
                    }
227 -
228 -
                Button(action: { navigateTo(urlText) }) {
229 -
                    if isLoading {
230 -
                        ProgressView()
231 -
                            .progressViewStyle(CircularProgressViewStyle())
232 -
                    } else {
233 -
                        Image(systemName: "arrow.right.circle.fill")
234 -
                            .font(.title2)
235 -
                    }
236 -
                }
237 -
                .disabled(isLoading || urlText.isEmpty)
238 -
            }
239 -
        }
240 -
        .padding()
241 -
        .onAppear {
242 -
            navigateTo(urlText)
243 -
        }
244 -
        .alert("Input Required", isPresented: $showInputPrompt) {
245 -
            if inputIsSensitive {
246 -
                SecureField("Enter input", text: $inputValue)
247 -
            } else {
248 -
                TextField("Enter input", text: $inputValue)
249 -
            }
250 -
            Button("Cancel", role: .cancel) {
251 -
                inputValue = ""
252 -
            }
253 -
            Button("Submit") {
254 -
                submitInput()
255 -
            }
256 -
        } message: {
257 -
            Text(inputPromptText)
258 -
        }
259 -
    }
260 -
261 -
    private func submitInput() {
262 -
        guard !inputValue.isEmpty else { return }
263 -
264 -
        // URL-encode the input (spaces become %20, etc.)
265 -
        let encoded = inputValue.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? inputValue
266 -
267 -
        // Append query string to URL
268 -
        let urlWithQuery = pendingInputURL + "?" + encoded
269 -
        inputValue = ""
270 -
271 -
        // Fetch with the input
272 -
        navigateTo(urlWithQuery)
273 -
    }
274 -
275 -
    // MARK: - Navigation History
276 -
277 -
    private var canGoBack: Bool {
278 -
        historyIndex > 0
279 -
    }
280 -
281 -
    private var canGoForward: Bool {
282 -
        historyIndex < history.count - 1
283 -
    }
284 -
285 -
    private func navigateTo(_ url: String) {
286 -
        // Remove forward history when navigating to new page
287 -
        if historyIndex < history.count - 1 {
288 -
            history = Array(history.prefix(historyIndex + 1))
289 -
        }
290 -
291 -
        urlText = url
292 -
        fetchContent(addToHistory: true)
293 -
    }
294 -
295 -
    private func goBack() {
296 -
        guard canGoBack else { return }
297 -
        historyIndex -= 1
298 -
        urlText = history[historyIndex]
299 -
        fetchContent(addToHistory: false)
300 -
    }
301 -
302 -
    private func goForward() {
303 -
        guard canGoForward else { return }
304 -
        historyIndex += 1
305 -
        urlText = history[historyIndex]
306 -
        fetchContent(addToHistory: false)
307 -
    }
308 -
309 -
    private func fetchContent(addToHistory: Bool = true) {
310 -
        isLoading = true
311 -
        Task {
312 -
            do {
313 -
                let (response, finalURL) = try await fetchWithRedirects(urlString: urlText, redirectCount: 0)
314 -
315 -
                // Update URL bar if we followed redirects
316 -
                if finalURL != urlText {
317 -
                    urlText = finalURL
318 -
                }
319 -
320 -
                switch response.statusCategory {
321 -
                case .success:
322 -
                    responseText = response.bodyText ?? "(empty response)"
323 -
                    // Add to history on successful navigation
324 -
                    if addToHistory {
325 -
                        history.append(finalURL)
326 -
                        historyIndex = history.count - 1
327 -
                    }
328 -
                case .input:
329 -
                    // Show input prompt
330 -
                    pendingInputURL = finalURL
331 -
                    inputPromptText = response.meta
332 -
                    inputIsSensitive = response.statusCode == 11
333 -
                    showInputPrompt = true
334 -
                case .redirect:
335 -
                    responseText = "Too many redirects"
336 -
                case .temporaryFailure:
337 -
                    responseText = "Temporary failure (\(response.statusCode)): \(response.meta)"
338 -
                case .permanentFailure:
339 -
                    responseText = "Error (\(response.statusCode)): \(response.meta)"
340 -
                case .clientCertificate:
341 -
                    responseText = "Client certificate required: \(response.meta)"
342 -
                }
343 -
            } catch {
344 -
                responseText = "Error: \(error.localizedDescription)"
345 -
            }
346 -
            isLoading = false
347 -
        }
348 -
    }
349 -
350 -
    private func fetchWithRedirects(urlString: String, redirectCount: Int) async throws -> (GeminiResponse, String) {
351 -
        guard let url = URL(string: urlString),
352 -
              let host = url.host else {
353 -
            throw GeminiError.invalidURL
354 -
        }
355 -
356 -
        let client = GeminiClient(rejectUnauthorized: false)
357 -
        let port = url.port ?? 1965
358 -
        let response = try await client.connect(
359 -
            hostname: host,
360 -
            port: port,
361 -
            urlString: urlString
362 -
        )
363 -
364 -
        // Handle redirects
365 -
        if response.statusCategory == .redirect {
366 -
            guard redirectCount < maxRedirects else {
367 -
                return (response, urlString)
368 -
            }
369 -
370 -
            // Resolve relative URLs against current URL
371 -
            let redirectTarget: String
372 -
            if response.meta.hasPrefix("gemini://") {
373 -
                redirectTarget = response.meta
374 -
            } else {
375 -
                // Relative URL - resolve against current
376 -
                if let baseURL = URL(string: urlString),
377 -
                   let resolved = URL(string: response.meta, relativeTo: baseURL) {
378 -
                    redirectTarget = resolved.absoluteString
379 -
                } else {
380 -
                    redirectTarget = response.meta
381 -
                }
382 -
            }
383 -
384 -
            print("↪️ Redirecting to: \(redirectTarget)")
385 -
            return try await fetchWithRedirects(urlString: redirectTarget, redirectCount: redirectCount + 1)
386 -
        }
387 -
388 -
        return (response, urlString)
389 -
    }
390 -
}
391 -
392 -
#Preview {
393 -
    ContentView()
394 -
}
Gemini/GeminiClient.swift → Gemini/Services/GeminiClient.swift +0 −0
Gemini/Models/GeminiLine.swift (added) +18 −0
1 +
//
2 +
//  GeminiLine.swift
3 +
//  Gemini
4 +
//
5 +
6 +
import Foundation
7 +
8 +
enum GeminiLine {
9 +
    case text(String)
10 +
    case link(url: String, label: String)
11 +
    case heading1(String)
12 +
    case heading2(String)
13 +
    case heading3(String)
14 +
    case listItem(String)
15 +
    case quote(String)
16 +
    case preformattedToggle(alt: String)
17 +
    case preformatted(String)
18 +
}
Gemini/Services/GeminiParser.swift (added) +67 −0
1 +
//
2 +
//  GeminiParser.swift
3 +
//  Gemini
4 +
//
5 +
6 +
import Foundation
7 +
8 +
struct GeminiParser {
9 +
    static func parse(_ content: String, baseURL: String) -> [GeminiLine] {
10 +
        var lines: [GeminiLine] = []
11 +
        var inPreformatted = false
12 +
13 +
        for line in content.components(separatedBy: .newlines) {
14 +
            if line.hasPrefix("```") {
15 +
                inPreformatted.toggle()
16 +
                let alt = String(line.dropFirst(3))
17 +
                lines.append(.preformattedToggle(alt: alt))
18 +
                continue
19 +
            }
20 +
21 +
            if inPreformatted {
22 +
                lines.append(.preformatted(line))
23 +
                continue
24 +
            }
25 +
26 +
            if line.hasPrefix("###") {
27 +
                lines.append(.heading3(String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)))
28 +
            } else if line.hasPrefix("##") {
29 +
                lines.append(.heading2(String(line.dropFirst(2)).trimmingCharacters(in: .whitespaces)))
30 +
            } else if line.hasPrefix("#") {
31 +
                lines.append(.heading1(String(line.dropFirst(1)).trimmingCharacters(in: .whitespaces)))
32 +
            } else if line.hasPrefix("=>") {
33 +
                let linkContent = String(line.dropFirst(2)).trimmingCharacters(in: .whitespaces)
34 +
                let (url, label) = parseLink(linkContent, baseURL: baseURL)
35 +
                lines.append(.link(url: url, label: label))
36 +
            } else if line.hasPrefix("* ") {
37 +
                lines.append(.listItem(String(line.dropFirst(2))))
38 +
            } else if line.hasPrefix(">") {
39 +
                lines.append(.quote(String(line.dropFirst(1))))
40 +
            } else {
41 +
                lines.append(.text(line))
42 +
            }
43 +
        }
44 +
45 +
        return lines
46 +
    }
47 +
48 +
    private static func parseLink(_ content: String, baseURL: String) -> (url: String, label: String) {
49 +
        let trimmed = content.trimmingCharacters(in: .whitespaces)
50 +
        let components = trimmed.components(separatedBy: .whitespaces).filter { !$0.isEmpty }
51 +
52 +
        let rawURL = components.first ?? ""
53 +
        let label = components.count > 1 ? components.dropFirst().joined(separator: " ") : rawURL
54 +
55 +
        let resolvedURL: String
56 +
        if rawURL.contains("://") {
57 +
            resolvedURL = rawURL
58 +
        } else if let base = URL(string: baseURL),
59 +
                  let resolved = URL(string: rawURL, relativeTo: base) {
60 +
            resolvedURL = resolved.absoluteString
61 +
        } else {
62 +
            resolvedURL = rawURL
63 +
        }
64 +
65 +
        return (resolvedURL, label)
66 +
    }
67 +
}
Gemini/Views/ContentView.swift (added) +214 −0
1 +
//
2 +
//  ContentView.swift
3 +
//  Gemini
4 +
//
5 +
6 +
import SwiftUI
7 +
8 +
struct ContentView: View {
9 +
    @State private var urlText = "gemini://geminiprotocol.net/"
10 +
    @State private var responseText = ""
11 +
    @State private var isLoading = false
12 +
13 +
    // Input prompt state
14 +
    @State private var showInputPrompt = false
15 +
    @State private var inputPromptText = ""
16 +
    @State private var inputValue = ""
17 +
    @State private var inputIsSensitive = false
18 +
    @State private var pendingInputURL = ""
19 +
20 +
    // Navigation history
21 +
    @State private var history: [String] = []
22 +
    @State private var historyIndex = -1
23 +
24 +
    private let maxRedirects = 5
25 +
26 +
    var body: some View {
27 +
        VStack(spacing: 12) {
28 +
            ScrollView {
29 +
                GeminiContentView(content: responseText, baseURL: urlText, onLinkTap: { url in
30 +
                    navigateTo(url)
31 +
                })
32 +
                .frame(maxWidth: .infinity, alignment: .leading)
33 +
            }
34 +
35 +
            HStack(spacing: 8) {
36 +
                // Back button
37 +
                Button(action: goBack) {
38 +
                    Image(systemName: "chevron.left")
39 +
                        .font(.title2)
40 +
                }
41 +
                .disabled(!canGoBack || isLoading)
42 +
43 +
                // Forward button
44 +
                Button(action: goForward) {
45 +
                    Image(systemName: "chevron.right")
46 +
                        .font(.title2)
47 +
                }
48 +
                .disabled(!canGoForward || isLoading)
49 +
50 +
                TextField("Enter Gemini URL", text: $urlText)
51 +
                    .textFieldStyle(.roundedBorder)
52 +
                    .autocapitalization(.none)
53 +
                    .disableAutocorrection(true)
54 +
                    .keyboardType(.URL)
55 +
                    .onSubmit {
56 +
                        navigateTo(urlText)
57 +
                    }
58 +
59 +
                Button(action: { navigateTo(urlText) }) {
60 +
                    if isLoading {
61 +
                        ProgressView()
62 +
                            .progressViewStyle(CircularProgressViewStyle())
63 +
                    } else {
64 +
                        Image(systemName: "arrow.right.circle.fill")
65 +
                            .font(.title2)
66 +
                    }
67 +
                }
68 +
                .disabled(isLoading || urlText.isEmpty)
69 +
            }
70 +
        }
71 +
        .padding()
72 +
        .onAppear {
73 +
            navigateTo(urlText)
74 +
        }
75 +
        .alert("Input Required", isPresented: $showInputPrompt) {
76 +
            if inputIsSensitive {
77 +
                SecureField("Enter input", text: $inputValue)
78 +
            } else {
79 +
                TextField("Enter input", text: $inputValue)
80 +
            }
81 +
            Button("Cancel", role: .cancel) {
82 +
                inputValue = ""
83 +
            }
84 +
            Button("Submit") {
85 +
                submitInput()
86 +
            }
87 +
        } message: {
88 +
            Text(inputPromptText)
89 +
        }
90 +
    }
91 +
92 +
    private func submitInput() {
93 +
        guard !inputValue.isEmpty else { return }
94 +
95 +
        let encoded = inputValue.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? inputValue
96 +
        let urlWithQuery = pendingInputURL + "?" + encoded
97 +
        inputValue = ""
98 +
99 +
        navigateTo(urlWithQuery)
100 +
    }
101 +
102 +
    // MARK: - Navigation History
103 +
104 +
    private var canGoBack: Bool {
105 +
        historyIndex > 0
106 +
    }
107 +
108 +
    private var canGoForward: Bool {
109 +
        historyIndex < history.count - 1
110 +
    }
111 +
112 +
    private func navigateTo(_ url: String) {
113 +
        if historyIndex < history.count - 1 {
114 +
            history = Array(history.prefix(historyIndex + 1))
115 +
        }
116 +
117 +
        urlText = url
118 +
        fetchContent(addToHistory: true)
119 +
    }
120 +
121 +
    private func goBack() {
122 +
        guard canGoBack else { return }
123 +
        historyIndex -= 1
124 +
        urlText = history[historyIndex]
125 +
        fetchContent(addToHistory: false)
126 +
    }
127 +
128 +
    private func goForward() {
129 +
        guard canGoForward else { return }
130 +
        historyIndex += 1
131 +
        urlText = history[historyIndex]
132 +
        fetchContent(addToHistory: false)
133 +
    }
134 +
135 +
    private func fetchContent(addToHistory: Bool = true) {
136 +
        isLoading = true
137 +
        Task {
138 +
            do {
139 +
                let (response, finalURL) = try await fetchWithRedirects(urlString: urlText, redirectCount: 0)
140 +
141 +
                if finalURL != urlText {
142 +
                    urlText = finalURL
143 +
                }
144 +
145 +
                switch response.statusCategory {
146 +
                case .success:
147 +
                    responseText = response.bodyText ?? "(empty response)"
148 +
                    if addToHistory {
149 +
                        history.append(finalURL)
150 +
                        historyIndex = history.count - 1
151 +
                    }
152 +
                case .input:
153 +
                    pendingInputURL = finalURL
154 +
                    inputPromptText = response.meta
155 +
                    inputIsSensitive = response.statusCode == 11
156 +
                    showInputPrompt = true
157 +
                case .redirect:
158 +
                    responseText = "Too many redirects"
159 +
                case .temporaryFailure:
160 +
                    responseText = "Temporary failure (\(response.statusCode)): \(response.meta)"
161 +
                case .permanentFailure:
162 +
                    responseText = "Error (\(response.statusCode)): \(response.meta)"
163 +
                case .clientCertificate:
164 +
                    responseText = "Client certificate required: \(response.meta)"
165 +
                }
166 +
            } catch {
167 +
                responseText = "Error: \(error.localizedDescription)"
168 +
            }
169 +
            isLoading = false
170 +
        }
171 +
    }
172 +
173 +
    private func fetchWithRedirects(urlString: String, redirectCount: Int) async throws -> (GeminiResponse, String) {
174 +
        guard let url = URL(string: urlString),
175 +
              let host = url.host else {
176 +
            throw GeminiError.invalidURL
177 +
        }
178 +
179 +
        let client = GeminiClient(rejectUnauthorized: false)
180 +
        let port = url.port ?? 1965
181 +
        let response = try await client.connect(
182 +
            hostname: host,
183 +
            port: port,
184 +
            urlString: urlString
185 +
        )
186 +
187 +
        if response.statusCategory == .redirect {
188 +
            guard redirectCount < maxRedirects else {
189 +
                return (response, urlString)
190 +
            }
191 +
192 +
            let redirectTarget: String
193 +
            if response.meta.hasPrefix("gemini://") {
194 +
                redirectTarget = response.meta
195 +
            } else {
196 +
                if let baseURL = URL(string: urlString),
197 +
                   let resolved = URL(string: response.meta, relativeTo: baseURL) {
198 +
                    redirectTarget = resolved.absoluteString
199 +
                } else {
200 +
                    redirectTarget = response.meta
201 +
                }
202 +
            }
203 +
204 +
            print("↪️ Redirecting to: \(redirectTarget)")
205 +
            return try await fetchWithRedirects(urlString: redirectTarget, redirectCount: redirectCount + 1)
206 +
        }
207 +
208 +
        return (response, urlString)
209 +
    }
210 +
}
211 +
212 +
#Preview {
213 +
    ContentView()
214 +
}
Gemini/Views/GeminiContentView.swift (added) +91 −0
1 +
//
2 +
//  GeminiContentView.swift
3 +
//  Gemini
4 +
//
5 +
6 +
import SwiftUI
7 +
8 +
struct GeminiContentView: View {
9 +
    let content: String
10 +
    let baseURL: String
11 +
    let onLinkTap: (String) -> Void
12 +
13 +
    init(content: String, baseURL: String = "", onLinkTap: @escaping (String) -> Void) {
14 +
        self.content = content
15 +
        self.baseURL = baseURL
16 +
        self.onLinkTap = onLinkTap
17 +
    }
18 +
19 +
    var body: some View {
20 +
        let lines = GeminiParser.parse(content, baseURL: baseURL)
21 +
22 +
        LazyVStack(alignment: .leading, spacing: 4) {
23 +
            ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
24 +
                lineView(for: line)
25 +
            }
26 +
        }
27 +
        .padding(8)
28 +
    }
29 +
30 +
    @ViewBuilder
31 +
    private func lineView(for line: GeminiLine) -> some View {
32 +
        switch line {
33 +
        case .text(let text):
34 +
            Text(text)
35 +
                .font(.system(.body, design: .monospaced))
36 +
37 +
        case .link(let url, let label):
38 +
            Button(action: { onLinkTap(url) }) {
39 +
                HStack(alignment:.top, spacing: 4) {
40 +
                    Text("=>")
41 +
                        .font(.system(size: 14, design: .monospaced))
42 +
                    Text(label)
43 +
                        .multilineTextAlignment(.leading)
44 +
                        .font(.system(size: 14, design: .monospaced))
45 +
                }
46 +
            }
47 +
            .foregroundColor(.blue)
48 +
49 +
        case .heading1(let text):
50 +
            Text(text)
51 +
                .font(.system(.title, design: .monospaced))
52 +
                .fontWeight(.bold)
53 +
                .padding(.top, 8)
54 +
55 +
        case .heading2(let text):
56 +
            Text(text)
57 +
                .font(.system(.title2, design: .monospaced))
58 +
                .fontWeight(.semibold)
59 +
                .padding(.top, 6)
60 +
61 +
        case .heading3(let text):
62 +
            Text(text)
63 +
                .font(.system(.title3, design: .monospaced))
64 +
                .fontWeight(.medium)
65 +
                .padding(.top, 4)
66 +
67 +
        case .listItem(let text):
68 +
            HStack(alignment: .top, spacing: 8) {
69 +
                Text("\u{2022}")
70 +
                Text(text)
71 +
            }
72 +
            .font(.system(.body, design: .monospaced))
73 +
74 +
        case .quote(let text):
75 +
            Text(text)
76 +
                .font(.system(.body, design: .monospaced))
77 +
                .italic()
78 +
                .foregroundColor(.secondary)
79 +
                .padding(.leading, 12)
80 +
81 +
        case .preformattedToggle:
82 +
            EmptyView()
83 +
84 +
        case .preformatted(let text):
85 +
            Text(text)
86 +
                .font(.system(.caption, design: .monospaced))
87 +
                .foregroundColor(.secondary)
88 +
                .padding(.leading, 8)
89 +
        }
90 +
    }
91 +
}