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 | 61 | func unwatch() async { |
| 55 | 62 | await perform(["repo", "unwatch", repoPath]) |
| 56 | 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 | 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 |
| 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. |
| 70 | 70 | nonisolated enum WatchState: Sendable, Hashable { |
| 71 | 71 | case watching |
| 72 | 72 | case muted |
| @@ -84,8 +84,8 @@ nonisolated enum WatchState: Sendable, Hashable { |
| 84 | 84 | |
| 85 | 85 | var available: [WatchAction] { |
| 86 | 86 | switch self { |
| 87 | | case .watching: [.mute] |
| 88 | | case .muted: [.watch] |
| 87 | case .watching: [.unwatch, .mute] |
| 88 | case .muted: [.watch, .unwatch] |
| 89 | 89 | case .default: [.watch, .mute] |
| 90 | 90 | } |
| 91 | 91 | } |
| @@ -94,6 +94,7 @@ nonisolated enum WatchState: Sendable, Hashable { |
| 94 | 94 | /// A transition `WatchState.available` may offer. |
| 95 | 95 | nonisolated enum WatchAction: Sendable, Hashable { |
| 96 | 96 | case watch |
| 97 | case unwatch |
| 97 | 98 | case mute |
| 98 | 99 | } |
| 99 | 100 | |
gitbay/Views/Repos/RepoView.swift
+15 −1
| @@ -179,12 +179,26 @@ struct RepoView: View { |
| 179 | 179 | Label("Watch this repository", systemImage: "eye") |
| 180 | 180 | } |
| 181 | 181 | } |
| 182 | | if watchState.available.contains(.mute) { |
| 182 | if watchState.available.contains(.unwatch) { |
| 183 | 183 | Button { |
| 184 | 184 | Task { |
| 185 | 185 | await actionsModel.unwatch() |
| 186 | 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 | 202 | } label: { |
| 189 | 203 | Label("Mute this repository", systemImage: "bell.slash") |
| 190 | 204 | } |
gitbayTests/RepoActionTests.swift
+5 −1
| @@ -41,7 +41,7 @@ struct RepoActionsTests { |
| 41 | 41 | #expect(stub.seen.count == before + 1) |
| 42 | 42 | } |
| 43 | 43 | |
| 44 | | @Test func watchAndUnwatchSendTheirOwnCommands() async throws { |
| 44 | @Test func watchMuteAndUnwatchSendTheirOwnCommands() async throws { |
| 45 | 45 | let (client, stub) = try makeClient() |
| 46 | 46 | let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") |
| 47 | 47 | |
| @@ -49,6 +49,10 @@ struct RepoActionsTests { |
| 49 | 49 | await model.watch() |
| 50 | 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 | 56 | stub.enqueue(.init(status: 200, json: ok)) |
| 53 | 57 | await model.unwatch() |
| 54 | 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 | 46 | #expect(WatchState("something-new") == .default) |
| 47 | 47 | } |
| 48 | 48 | |
| 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. |
| 51 | 51 | @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]) |
| 54 | 54 | #expect(WatchState.default.available == [.watch, .mute]) |
| 55 | 55 | } |
| 56 | 56 | } |