gitbay/Views/Releases/ReleaseListView.swift
156 lines · 5893 bytes
1import SwiftUI
2
3struct ReleaseListView: View {
4
5 @State private var model: ReleaseListViewModel
6 @State private var composing = false
7 @State private var draftTag = ""
8 @State private var draftTitle = ""
9 @State private var draftNotes = ""
10 @State private var hasLoadedOnce = false
11
12 init(client: GitbayClient, repo: String) {
13 _model = State(initialValue: ReleaseListViewModel(client: client, repoPath: repo))
14 }
15
16 var body: some View {
17 List {
18 ForEach(model.state.value ?? []) { release in
19 NavigationLink(value: ReleaseRoute.release(repo: model.repoPath, tag: release.tag)) {
20 VStack(alignment: .leading, spacing: 3) {
21 Text(release.title.isEmpty ? release.tag : release.title)
22 .font(.gbSans(.subheadline).weight(.medium))
23 .lineLimit(2)
24 HStack(spacing: 6) {
25 Text(release.tag)
26 .font(.gbMono(.caption))
27 .foregroundStyle(.secondary)
28 if let author = release.author {
29 Text("by \(author)")
30 .font(.gbSans(.caption))
31 .foregroundStyle(.secondary)
32 }
33 Spacer()
34 Text(release.createdAt, format: .relative(presentation: .named))
35 .font(.gbSans(.caption))
36 .foregroundStyle(.tertiary)
37 }
38 }
39 .padding(.vertical, 2)
40 }
41 }
42 }
43 .overlay { LoadStateOverlay(state: model.state) }
44 .navigationTitle("Releases")
45 .navigationBarTitleDisplayMode(.inline)
46 .toolbar {
47 ToolbarItem(placement: .topBarTrailing) {
48 Button {
49 composing = true
50 } label: {
51 Image(systemName: "plus")
52 }
53 .accessibilityLabel("New Release")
54 .accessibilityIdentifier("release-create-button")
55 }
56 }
57 .sheet(isPresented: $composing) {
58 ReleaseCreateSheet(
59 model: model,
60 tag: $draftTag, title: $draftTitle, notes: $draftNotes
61 ) {
62 draftTag = ""
63 draftTitle = ""
64 draftNotes = ""
65 composing = false
66 }
67 }
68 .task {
69 await model.load()
70 hasLoadedOnce = true
71 }
72 // A deleted release pops back to this list; `.task` does not
73 // re-run on that pop, so a manual refresh here is what drops the
74 // now-gone row. Guarded so the initial `.task` load isn't repeated.
75 .onAppear {
76 guard hasLoadedOnce else { return }
77 Task { await model.load() }
78 }
79 .refreshable { await model.load() }
80 }
81}
82
83/// The tag is typed and must already exist on the server — the refusal
84/// ("push the tag first") is the explanation.
85private struct ReleaseCreateSheet: View {
86
87 let model: ReleaseListViewModel
88 @Binding var tag: String
89 @Binding var title: String
90 @Binding var notes: String
91 let onCreated: () -> Void
92
93 @Environment(\.dismiss) private var dismiss
94
95 var body: some View {
96 NavigationStack {
97 Form {
98 Section("Tag") {
99 TextField("v1.0.0", text: $tag)
100 .autocorrectionDisabled()
101 .textInputAutocapitalization(.never)
102 .accessibilityIdentifier("release-tag")
103 }
104 Section("Title") {
105 TextField("Title (optional)", text: $title)
106 .autocorrectionDisabled()
107 }
108 Section("Notes") {
109 TextEditor(text: $notes)
110 .frame(minHeight: 140)
111 .autocorrectionDisabled()
112 // release create sends no --format, and release notes
113 // render as markdown.
114 MarkupPreview(text: notes, format: "md", identifier: "release-notes-preview")
115 }
116 if let error = model.createError {
117 Section {
118 GBNotice(error)
119 }
120 }
121 }
122 .scrollDismissesKeyboard(.interactively)
123 .navigationTitle("New Release")
124 .navigationBarTitleDisplayMode(.inline)
125 .toolbar {
126 ToolbarItem(placement: .cancellationAction) {
127 Button("Cancel") { dismiss() }
128 }
129 ToolbarItem(placement: .confirmationAction) {
130 if model.working {
131 ProgressView()
132 } else {
133 Button("Create") {
134 Task {
135 if await model.create(
136 tag: tag.trimmingCharacters(in: .whitespaces),
137 title: title, notes: notes
138 ) {
139 onCreated()
140 }
141 }
142 }
143 .disabled(tag.trimmingCharacters(in: .whitespaces).isEmpty)
144 .accessibilityIdentifier("release-submit")
145 }
146 }
147 }
148 .interactiveDismissDisabled(model.working)
149 }
150 }
151}
152
153nonisolated enum ReleaseRoute: Hashable {
154 case list(repo: String)
155 case release(repo: String, tag: String)
156}