gitbay/Repos/FileViewModel.swift

v1.10.0
gitbay-ios/gitbay/Repos/FileViewModel.swift history · blame · raw

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}