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 {
51 } 51 }
52 } 52 }
53 53
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
54 func unwatch() async { 61 func unwatch() async {
55 await perform(["repo", "unwatch", repoPath]) 62 await perform(["repo", "unwatch", repoPath])
56 if actionError == nil { 63 if actionError == nil {
57 notice = "Notifications for this repository are muted." 64 notice = "Notifications for this repository are back to the default."
58 } 65 }
59 } 66 }
60 67
gitbay/Repos/RepoModels.swift +6 −5
@@ -64,9 +64,9 @@ nonisolated struct RepoDetail: Decodable, Sendable, Hashable {
64} 64}
65 65
66/// The caller's watch/mute state on a repository, from `RepoDetail.watch`. 66/// The caller's watch/mute state on a repository, from `RepoDetail.watch`.
67/// Only two transitions are reachable from the server: `repo watch` sets 67/// `repo watch` sets `.watching`, `repo mute` sets `.muted`, and
68/// `.watching`, `repo unwatch` sets `.muted`. Nothing sets `.default` — 68/// `repo unwatch` clears either back to `.default`, so `available` offers
69/// it is only ever the starting state — so `available` never offers it. 69/// unwatch from both explicit states and never from the default.
70nonisolated enum WatchState: Sendable, Hashable { 70nonisolated enum WatchState: Sendable, Hashable {
71 case watching 71 case watching
72 case muted 72 case muted
@@ -84,8 +84,8 @@ nonisolated enum WatchState: Sendable, Hashable {
84 84
85 var available: [WatchAction] { 85 var available: [WatchAction] {
86 switch self { 86 switch self {
87 case .watching: [.mute] 87 case .watching: [.unwatch, .mute]
88 case .muted: [.watch] 88 case .muted: [.watch, .unwatch]
89 case .default: [.watch, .mute] 89 case .default: [.watch, .mute]
90 } 90 }
91 } 91 }
@@ -94,6 +94,7 @@ nonisolated enum WatchState: Sendable, Hashable {
94/// A transition `WatchState.available` may offer. 94/// A transition `WatchState.available` may offer.
95nonisolated enum WatchAction: Sendable, Hashable { 95nonisolated enum WatchAction: Sendable, Hashable {
96 case watch 96 case watch
97 case unwatch
97 case mute 98 case mute
98} 99}
99 100
gitbay/Views/Repos/RepoView.swift +15 −1
@@ -179,12 +179,26 @@ struct RepoView: View {
179 Label("Watch this repository", systemImage: "eye") 179 Label("Watch this repository", systemImage: "eye")
180 } 180 }
181 } 181 }
182 if watchState.available.contains(.mute) { 182 if watchState.available.contains(.unwatch) {
183 Button { 183 Button {
184 Task { 184 Task {
185 await actionsModel.unwatch() 185 await actionsModel.unwatch()
186 await model.reloadDetail() 186 await model.reloadDetail()
187 } 187 }
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 }
188 } label: { 202 } label: {
189 Label("Mute this repository", systemImage: "bell.slash") 203 Label("Mute this repository", systemImage: "bell.slash")
190 } 204 }
gitbayTests/RepoActionTests.swift +5 −1
@@ -41,7 +41,7 @@ struct RepoActionsTests {
41 #expect(stub.seen.count == before + 1) 41 #expect(stub.seen.count == before + 1)
42 } 42 }
43 43
44 @Test func watchAndUnwatchSendTheirOwnCommands() async throws { 44 @Test func watchMuteAndUnwatchSendTheirOwnCommands() async throws {
45 let (client, stub) = try makeClient() 45 let (client, stub) = try makeClient()
46 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") 46 let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay")
47 47
@@ -49,6 +49,10 @@ struct RepoActionsTests {
49 await model.watch() 49 await model.watch()
50 #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "watch", "krz/gitbay"]) 50 #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "watch", "krz/gitbay"])
51 51
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
52 stub.enqueue(.init(status: 200, json: ok)) 56 stub.enqueue(.init(status: 200, json: ok))
53 await model.unwatch() 57 await model.unwatch()
54 #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "unwatch", "krz/gitbay"]) 58 #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "unwatch", "krz/gitbay"])
gitbayTests/RepoStateTests.swift +4 −4
@@ -46,11 +46,11 @@ struct WatchStateTests {
46 #expect(WatchState("something-new") == .default) 46 #expect(WatchState("something-new") == .default)
47 } 47 }
48 48
49 /// Only two transitions exist. Nothing returns you to the default, 49 /// `repo unwatch` returns to the default from either state, so it is
50 /// so nothing may offer it. 50 /// offered from both and never from the default itself.
51 @Test func onlyTheReachableTransitionsAreOffered() { 51 @Test func onlyTheReachableTransitionsAreOffered() {
52 #expect(WatchState.watching.available == [.mute]) 52 #expect(WatchState.watching.available == [.unwatch, .mute])
53 #expect(WatchState.muted.available == [.watch]) 53 #expect(WatchState.muted.available == [.watch, .unwatch])
54 #expect(WatchState.default.available == [.watch, .mute]) 54 #expect(WatchState.default.available == [.watch, .mute])
55 } 55 }
56} 56}