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. |
| 70 | nonisolated enum WatchState: Sendable, Hashable { |
70 | nonisolated 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. |
| 95 | nonisolated enum WatchAction: Sendable, Hashable { |
95 | nonisolated 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 | } |