Commit 9825c340cb

9825c340cb3ee760bb660ce7c534e59ff8d1047e

parent: b1fc78b637

Unsigned

cmc <hello@cleberg.net> · 2026-09-07 05:52 UTC

Stop watching, or unmute, a repository (!62)

Follows krz/gitbay!323: `repo unwatch` returns to the default, `repo mute` silences. The repository menu offers "Stop watching" while watching and "Unmute this repository" while muted, both via `repo unwatch`; Mute sends `repo mute`. Requires the server change to be deployed before Mute works in a dev build.

Layout: unified · split

gitbay/Repos/RepoActionsViewModel.swift +8 −1
@@ -51,10 +51,17 @@ final class RepoActionsViewModel {
5151 }
5252 }
5353
54 func mute() async {
55 await perform(["repo", "mute", repoPath])
56 if actionError == nil {
57 notice = "Notifications for this repository are muted."
58 }
59 }
60
5461 func unwatch() async {
5562 await perform(["repo", "unwatch", repoPath])
5663 if actionError == nil {
57 notice = "Notifications for this repository are muted."
64 notice = "Notifications for this repository are back to the default."
5865 }
5966 }
6067
gitbay/Repos/RepoModels.swift +6 −5
@@ -64,9 +64,9 @@ nonisolated struct RepoDetail: Decodable, Sendable, Hashable {
6464}
6565
6666/// The caller's watch/mute state on a repository, from `RepoDetail.watch`.
67/// Only two transitions are reachable from the server: `repo watch` sets
68/// `.watching`, `repo unwatch` sets `.muted`. Nothing sets `.default` —
69/// it is only ever the starting state — so `available` never offers it.
67/// `repo watch` sets `.watching`, `repo mute` sets `.muted`, and
68/// `repo unwatch` clears either back to `.default`, so `available` offers
69/// unwatch from both explicit states and never from the default.
7070nonisolated enum WatchState: Sendable, Hashable {
7171 case watching
7272 case muted
@@ -84,8 +84,8 @@ nonisolated enum WatchState: Sendable, Hashable {
8484
8585 var available: [WatchAction] {
8686 switch self {
87 case .watching: [.mute]
88 case .muted: [.watch]
87 case .watching: [.unwatch, .mute]
88 case .muted: [.watch, .unwatch]
8989 case .default: [.watch, .mute]
9090 }
9191 }
@@ -94,6 +94,7 @@ nonisolated enum WatchState: Sendable, Hashable {
9494/// A transition `WatchState.available` may offer.
9595nonisolated enum WatchAction: Sendable, Hashable {
9696 case watch
97 case unwatch
9798 case mute
9899}
99100
gitbay/Views/Repos/RepoView.swift +15 −1
@@ -179,12 +179,26 @@ struct RepoView: View {
179179 Label("Watch this repository", systemImage: "eye")
180180 }
181181 }
182 if watchState.available.contains(.mute) {
182 if watchState.available.contains(.unwatch) {
183183 Button {
184184 Task {
185185 await actionsModel.unwatch()
186186 await model.reloadDetail()
187187 }
188 } label: {
189 if watchState == .muted {
190 Label("Unmute this repository", systemImage: "bell")
191 } else {
192 Label("Stop watching", systemImage: "eye.slash")
193 }
194 }
195 }
196 if watchState.available.contains(.mute) {
197 Button {
198 Task {
199 await actionsModel.mute()
200 await model.reloadDetail()
201 }
188202 } label: {
189203 Label("Mute this repository", systemImage: "bell.slash")
190204 }
gitbayTests/RepoActionTests.swift +5 −1
@@ -41,7 +41,7 @@ struct RepoActionsTests {
4141 #expect(stub.seen.count == before + 1)
4242 }
4343
44 @Test func watchAndUnwatchSendTheirOwnCommands() async throws {
44 @Test func watchMuteAndUnwatchSendTheirOwnCommands() async throws {
4545 let (client, stub) = try makeClient()
4646 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
4747
@@ -49,6 +49,10 @@ struct RepoActionsTests {
4949 await model.watch()
5050 #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "watch", "krz/gitbay"])
5151
52 stub.enqueue(.init(status: 200, json: ok))
53 await model.mute()
54 #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "mute", "krz/gitbay"])
55
5256 stub.enqueue(.init(status: 200, json: ok))
5357 await model.unwatch()
5458 #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "unwatch", "krz/gitbay"])
gitbayTests/RepoStateTests.swift +4 −4
@@ -46,11 +46,11 @@ struct WatchStateTests {
4646 #expect(WatchState("something-new") == .default)
4747 }
4848
49 /// Only two transitions exist. Nothing returns you to the default,
50 /// so nothing may offer it.
49 /// `repo unwatch` returns to the default from either state, so it is
50 /// offered from both and never from the default itself.
5151 @Test func onlyTheReachableTransitionsAreOffered() {
52 #expect(WatchState.watching.available == [.mute])
53 #expect(WatchState.muted.available == [.watch])
52 #expect(WatchState.watching.available == [.unwatch, .mute])
53 #expect(WatchState.muted.available == [.watch, .unwatch])
5454 #expect(WatchState.default.available == [.watch, .mute])
5555 }
5656}