gitbay/Releases/ReleaseViewModels.swift
144 lines · 4529 bytes
1import Foundation
2import Observation
3
4/// `release list <owner/name>`.
5@Observable
6@MainActor
7final class ReleaseListViewModel {
8
9 private(set) var state: LoadState<[Release]> = .loading
10 private(set) var createError: String?
11 private(set) var working = false
12
13 private let client: GitbayClient
14 let repoPath: String
15
16 init(client: GitbayClient, repoPath: String) {
17 self.client = client
18 self.repoPath = repoPath
19 }
20
21 func load() async {
22 do {
23 let releases = try await client.readList(["release", "list", repoPath], of: Release.self)
24 state = releases.isEmpty
25 ? .empty("No releases. Tag a commit, push the tag, then release it.")
26 : .loaded(releases)
27 } catch {
28 state = .from(error)
29 }
30 }
31
32 /// `release create <owner/name> <tag> [--title <t>] [--file -]` —
33 /// the tag must already exist; the server's refusal says so.
34 func create(tag: String, title: String, notes: String) async -> Bool {
35 working = true
36 createError = nil
37 defer { working = false }
38 do {
39 var argv = ["release", "create", repoPath, tag]
40 if !title.isEmpty { argv.append(contentsOf: ["--title", title]) }
41 var stdin: String?
42 if !notes.isEmpty {
43 argv.append(contentsOf: ["--file", "-"])
44 stdin = notes
45 }
46 try await client.run(argv, stdin: stdin)
47 await load()
48 return true
49 } catch let error as GitbayError {
50 createError = error.userFacingMessage
51 } catch {
52 createError = GitbayError.transport(error).userFacingMessage
53 }
54 return false
55 }
56}
57
58/// One release: `release show`, plus title/notes editing.
59@Observable
60@MainActor
61final class ReleaseDetailViewModel {
62
63 private(set) var state: LoadState<Release> = .loading
64 private(set) var actionError: String?
65 private(set) var working = false
66
67 private let client: GitbayClient
68 let repoPath: String
69 let tag: String
70
71 init(client: GitbayClient, repoPath: String, tag: String) {
72 self.client = client
73 self.repoPath = repoPath
74 self.tag = tag
75 }
76
77 func load() async {
78 do {
79 state = .loaded(try await client.read(
80 ["release", "show", repoPath, tag], as: Release.self))
81 } catch {
82 state = .from(error)
83 }
84 }
85
86 func edit(title: String, notes: String) async {
87 working = true
88 actionError = nil
89 defer { working = false }
90 do {
91 try await client.run(
92 ["release", "edit", repoPath, tag, "--title", title, "--file", "-"],
93 stdin: notes
94 )
95 await load()
96 } catch let error as GitbayError {
97 actionError = error.userFacingMessage
98 } catch {
99 actionError = GitbayError.transport(error).userFacingMessage
100 }
101 }
102
103 /// `release asset remove <owner/name> <tag> <filename>`.
104 func removeAsset(named name: String) async {
105 working = true
106 actionError = nil
107 defer { working = false }
108 do {
109 try await client.run(["release", "asset", "remove", repoPath, tag, name])
110 await load()
111 } catch let error as GitbayError {
112 actionError = error.userFacingMessage
113 } catch {
114 actionError = GitbayError.transport(error).userFacingMessage
115 }
116 }
117
118 /// `release delete <owner/name> <tag> --yes` — `--yes` is required by
119 /// the command, not optional; omitting it is a usage error.
120 func delete() async -> Bool {
121 working = true
122 actionError = nil
123 defer { working = false }
124 do {
125 try await client.run(["release", "delete", repoPath, tag, "--yes"])
126 return true
127 } catch let error as GitbayError {
128 actionError = error.userFacingMessage
129 } catch {
130 actionError = GitbayError.transport(error).userFacingMessage
131 }
132 return false
133 }
134
135 /// Where a browser downloads this asset — the web's own download
136 /// route; the JSON surface has no binary-safe asset read.
137 func downloadURL(for asset: Release.Asset) -> URL {
138 client.instance.baseURL
139 .appending(path: repoPath)
140 .appending(path: "releases/download")
141 .appending(path: tag)
142 .appending(path: asset.name)
143 }
144}