gitbay/Views/Releases/ReleaseListView.swift
175 lines · 6898 bytes
2 symbols in this file
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 {
109 TextEditor(text: $notes)
110 .frame(minHeight: 140)
111 .autocorrectionDisabled()
112 Button("Summarize changes") {
113 Task {
114 // Appended to the notes as they are when the lines
115 // arrive, so typing meanwhile is kept.
116 if let lines = await model.summary(tag: tag.trimmingCharacters(in: .whitespaces)) {
117 notes = ReleaseListViewModel.appending(lines, to: notes)
118 }
119 }
120 }
121 .disabled(tag.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
122 .accessibilityIdentifier("release-summarize")
123 // release create sends no --format, and release notes
124 // render as markdown.
125 MarkupPreview(text: notes, format: "md", identifier: "release-notes-preview")
126 } header: {
127 Text("Notes")
128 } footer: {
129 if let note = model.summaryNote {
130 Text(note)
131 } else {
132 Text("Summarize changes adds a line for each merge request since the previous release.")
133 }
134 }
135 if let error = model.createError {
136 Section {
137 GBNotice(error)
138 }
139 }
140 }
141 .scrollDismissesKeyboard(.interactively)
142 .navigationTitle("New Release")
143 .navigationBarTitleDisplayMode(.inline)
144 .toolbar {
145 ToolbarItem(placement: .cancellationAction) {
146 Button("Cancel") { dismiss() }
147 }
148 ToolbarItem(placement: .confirmationAction) {
149 if model.working {
150 ProgressView()
151 } else {
152 Button("Create") {
153 Task {
154 if await model.create(
155 tag: tag.trimmingCharacters(in: .whitespaces),
156 title: title, notes: notes
157 ) {
158 onCreated()
159 }
160 }
161 }
162 .disabled(tag.trimmingCharacters(in: .whitespaces).isEmpty)
163 .accessibilityIdentifier("release-submit")
164 }
165 }
166 }
167 .interactiveDismissDisabled(model.working)
168 }
169 }
170}
171
172nonisolated enum ReleaseRoute: Hashable {
173 case list(repo: String)
174 case release(repo: String, tag: String)
175}