gitbay/Repos/FileViewModel.swift
87 lines · 3379 bytes
1import Foundation
2import Observation
3
4/// One file via `repo cat`: text renders (highlighted when the language is
5/// known), binary says so, truncated says so — never a guess.
6@Observable
7@MainActor
8final class FileViewModel {
9
10 private(set) var state: LoadState<FileContent> = .loading
11 private(set) var actionError: String?
12 private(set) var working = false
13 /// The names the file uses that the symbol index defines, by name.
14 /// Empty when the server has no `repo definitions`, the ref is not the
15 /// indexed one, or the repository has no index: the file stays plain.
16 private(set) var definitions: [String: Definition] = [:]
17
18 private let client: GitbayClient
19 let repoPath: String
20 let filePath: String
21 let ref: String?
22
23 init(client: GitbayClient, repoPath: String, filePath: String, ref: String? = nil) {
24 self.client = client
25 self.repoPath = repoPath
26 self.filePath = filePath
27 self.ref = ref
28 }
29
30 var fileName: String {
31 String(filePath.split(separator: "/").last ?? "")
32 }
33
34 func load() async {
35 var argv = ["repo", "cat", repoPath, filePath]
36 var defs = ["repo", "definitions", repoPath, filePath]
37 if let ref {
38 argv.append(contentsOf: ["--ref", ref])
39 defs.append(contentsOf: ["--ref", ref])
40 }
41 do {
42 state = .loaded(try await client.read(argv, as: FileContent.self))
43 } catch {
44 state = .from(error)
45 return
46 }
47 // Prose is not code: a word in it that names a section or a
48 // function would link from every sentence it appears in.
49 guard MarkupPreview.format(forFile: fileName) == nil,
50 !["txt", "text"].contains((fileName as NSString).pathExtension.lowercased()) else { return }
51 // After the file, which draws first; the links follow.
52 let found = (try? await client.readList(defs, of: Definition.self)) ?? []
53 definitions = Dictionary(found.map { ($0.name, $0) }, uniquingKeysWith: { a, _ in a })
54 }
55
56 /// The branch an edit commits to. `repo cat` echoes the ref it read,
57 /// so editing writes back to whatever is on screen.
58 var editableBranch: String? {
59 guard let file = state.value, !file.binary, !file.isTruncated else { return nil }
60 return file.ref
61 }
62
63 /// `repo commit-file` — the server refuses when the repository wants
64 /// signed commits, when the account has no verified email, or when
65 /// the repo is archived, and says which.
66 func commit(content: String, message: String) async -> Bool {
67 guard let branch = editableBranch else { return false }
68 working = true
69 actionError = nil
70 defer { working = false }
71 var argv = ["repo", "commit-file", repoPath, filePath, "--ref", branch, "--file", "-"]
72 let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
73 if !trimmed.isEmpty {
74 argv.append(contentsOf: ["--message", trimmed])
75 }
76 do {
77 try await client.run(argv, stdin: content)
78 await load()
79 return true
80 } catch let error as GitbayError {
81 actionError = error.userFacingMessage
82 } catch {
83 actionError = GitbayError.transport(error).userFacingMessage
84 }
85 return false
86 }
87}