From 8d36c17936d494b8ea48474dd5126c4bac329c30 Mon Sep 17 00:00:00 2001 From: scgopi Date: Fri, 28 Aug 2026 06:48:48 -0700 Subject: [PATCH] feat: an earned, one-time in-app ask to star the repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The install path routes around github.com — brew install --cask puts the app on disk without the user ever seeing the repo — so the README and the site only ever ask people who already found it. This asks the population that actually installs. Earned rather than nagging: nothing appears until three loops have resolved on this machine, it shows once, and a tap or a dismiss retires it for good. It sits in the update banner's slot and yields to it, since news the user asked for outranks an ask aimed at them. Behind a FeatureRamps ramp so it has a kill switch. Counting is crossings-only — counting resolved nodes outright would re-count the same loop on every broadcast. Also moves the delete-from-disk effect into the trailing extension, which is what keeps AppFeature's type body inside swiftlint's 350-line budget. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0168f4VtAUJQgZsyRg8einV5 --- graphcode/Sources/Clients/FeatureRamps.swift | 4 + .../Features/App/AppFeature+StarAsk.swift | 77 +++++++++++++++++++ .../Sources/Features/App/AppFeature.swift | 41 +++++++--- .../Sources/Features/App/AppSidebarView.swift | 9 +++ .../Sources/Features/App/TitlebarItems.swift | 62 +++++++++++++++ graphcode/Tests/SidebarStarAskTests.swift | 75 ++++++++++++++++++ 6 files changed, 258 insertions(+), 10 deletions(-) create mode 100644 graphcode/Sources/Features/App/AppFeature+StarAsk.swift create mode 100644 graphcode/Tests/SidebarStarAskTests.swift diff --git a/graphcode/Sources/Clients/FeatureRamps.swift b/graphcode/Sources/Clients/FeatureRamps.swift index 30bb3de8..b69edb18 100644 --- a/graphcode/Sources/Clients/FeatureRamps.swift +++ b/graphcode/Sources/Clients/FeatureRamps.swift @@ -23,12 +23,16 @@ enum FeatureRamps { enum Feature: String { case codespaces + case starAsk /// What answers when no ramps.json has ever been fetched (and when the fetch /// fails): beta installs opted into rough edges, stable waits for the ramp. var defaultPercents: [String: Int] { switch self { case .codespaces: return ["beta": 100, "stable": 0] + // On everywhere by default: the ask exists to reach the installed base, and the + // ramp is here as the kill switch rather than as a rollout. + case .starAsk: return ["beta": 100, "stable": 100] } } } diff --git a/graphcode/Sources/Features/App/AppFeature+StarAsk.swift b/graphcode/Sources/Features/App/AppFeature+StarAsk.swift new file mode 100644 index 00000000..61e112ce --- /dev/null +++ b/graphcode/Sources/Features/App/AppFeature+StarAsk.swift @@ -0,0 +1,77 @@ +import ComposableArchitecture +import Foundation + +/// The one-time ask to star the project on GitHub. +/// +/// The install path routes around github.com entirely — `brew install --cask` puts the +/// app on disk without the user ever seeing the repository — so every surface that asks +/// (README, site) reaches only the people who already found it. This is the ask aimed at +/// the population that actually installs. +/// +/// It is *earned*: nothing appears until three loops have resolved on this machine, so +/// the first time the app asks for anything it has already done the thing it claims to +/// do. It is shown once, it never returns once answered, and it yields to the update +/// banner — news the user asked for outranks an ask aimed at them. +struct StarAskState: Equatable { + @Shared(.appStorage(StarAsk.resolvedCountKey)) var resolvedLoopCount = 0 + @Shared(.appStorage(StarAsk.answeredKey)) var isAnswered = false + + /// Shown only past the threshold, only once, and only while the ramp allows it. + var isEarned: Bool { + !isAnswered && resolvedLoopCount >= StarAsk.threshold && FeatureRamps.isEnabled(.starAsk) + } +} + +enum StarAsk { + static let threshold = 3 + static let resolvedCountKey = "starAskResolvedLoopCount" + static let answeredKey = "starAskAnswered" + static let repositoryURL = URL(string: "https://github.com/scgopi/GraphCode")! + + @CasePathable + enum Action: Equatable { + /// A tap is an answer either way: the star itself happens on a page this app cannot + /// see, so asking again after sending someone there would be asking twice. + case tapped + case dismissed + } +} + +/// Counts resolutions and answers the banner's two actions. Placed before the main +/// `Reduce` for the same reason `AppWorktreesReducer` is: the count comes from diffing +/// the incoming graph against the previous one, which the main reducer replaces. +struct StarAskReducer: Reducer { + typealias State = AppFeature.State + typealias Action = AppFeature.Action + + @Dependency(\.openURL) var openURL + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .daemonEvent(.graphChanged(let graph)): + guard !state.starAsk.isAnswered else { return .none } + // Crossings only. Counting resolved nodes outright would re-count the same loop + // on every broadcast and reach the threshold on the first graph that has one. + let previous = state.projects[id: graph.project.path]?.graph + let resolutions = graph.nodes.filter { node in + node.isResolved && previous?.nodes[id: node.id].map { !$0.isResolved } == true + }.count + guard resolutions > 0 else { return .none } + state.starAsk.$resolvedLoopCount.withLock { $0 += resolutions } + return .none + + case .starAsk(.tapped): + state.starAsk.$isAnswered.withLock { $0 = true } + return .run { [openURL] _ in await openURL(StarAsk.repositoryURL) } + + case .starAsk(.dismissed): + state.starAsk.$isAnswered.withLock { $0 = true } + return .none + + default: + return .none + } + } + } +} diff --git a/graphcode/Sources/Features/App/AppFeature.swift b/graphcode/Sources/Features/App/AppFeature.swift index bcb9d1ce..d66154a6 100644 --- a/graphcode/Sources/Features/App/AppFeature.swift +++ b/graphcode/Sources/Features/App/AppFeature.swift @@ -30,6 +30,8 @@ struct AppFeature { @ObservableState struct State: Equatable { + /// The one-time GitHub star ask — see `AppFeature+StarAsk.swift`. + var starAsk = StarAskState() var welcome = WelcomeFeature.State() var projects: IdentifiedArrayOf = [] var openLoop: LoopWorkspaceFeature.State? @@ -229,6 +231,8 @@ struct AppFeature { case updateFoundInBackground(AvailableUpdate?) /// The sidebar update banner — re-presents the offer alert the banner stands for. case updateBannerTapped + /// The sidebar star ask — see `AppFeature+StarAsk.swift`. + case starAsk(StarAsk.Action) case updateCheckCompleted(Result) /// A bundle replaced underneath this running window — asked on activation, answered /// with a relaunch prompt. See `BundleSwap.Action`. @@ -301,6 +305,9 @@ struct AppFeature { // graph, which the main reducer replaces. See `AppFeature+Worktrees.swift`. AppWorktreesReducer() .ifLet(\.worktreeSweep, action: \.worktrees.sweep) { WorktreeSweepFeature() } + // Before the main Reduce for the same reason: its resolution count comes from + // diffing against the previous graph. + StarAskReducer() AppWorkspacesReducer() Reduce { state, action in switch action { @@ -408,16 +415,7 @@ struct AppFeature { else { return .none } removeFromSidebar(&state, path: path) state.welcome.recentProjects.removeAll { $0.path == path } - return .run { send in - try? await orchestratorClient.send(.deleteProjectGraph(path: path)) - try? await orchestratorClient.send(.forgetProject(path: path)) - do { - try FileManager.default.trashItem( - at: URL(fileURLWithPath: path), resultingItemURL: nil) - } catch { - await send(.projectDeleteFromDiskFailed(String(describing: error))) - } - } + return deleteProjectFromDisk(path: path) case .projectDeleteFromDiskFailed(let message): state.welcome.errorMessage = "Couldn't move the folder to the Trash: \(message)" @@ -488,6 +486,11 @@ struct AppFeature { case .historyBackTapped, .historyForwardTapped: return .none + // Handled by `StarAskReducer`, in `AppFeature+StarAsk.swift` — listed here only so + // this switch stays exhaustive. + case .starAsk: + return .none + // Every update action is handled by `updatesReducer`, in // `AppFeature+Updates.swift` — listed here only so this switch stays exhaustive. case .checkForUpdatesTapped, .checkForUpdatesInBackground, .updateFoundInBackground, @@ -622,6 +625,24 @@ struct AppFeature { // the state and actions above keep growing. extension AppFeature { + /// Deleting a folder: the graph and the recents entry go through the orchestrator, + /// the folder itself to the Trash — never `removeItem`, so a mistaken tap is + /// recoverable from the Finder. + /// + /// In the extension rather than the switch for the reason above. + func deleteProjectFromDisk(path: String) -> Effect { + .run { send in + try? await orchestratorClient.send(.deleteProjectGraph(path: path)) + try? await orchestratorClient.send(.forgetProject(path: path)) + do { + try FileManager.default.trashItem( + at: URL(fileURLWithPath: path), resultingItemURL: nil) + } catch { + await send(.projectDeleteFromDiskFailed(String(describing: error))) + } + } + } + /// Everything a launch has to do: restore the app-local lists, decide whether the /// primer is due, and open the daemon subscription the whole app hangs off. /// diff --git a/graphcode/Sources/Features/App/AppSidebarView.swift b/graphcode/Sources/Features/App/AppSidebarView.swift index aa0f4ad0..949076f5 100644 --- a/graphcode/Sources/Features/App/AppSidebarView.swift +++ b/graphcode/Sources/Features/App/AppSidebarView.swift @@ -205,6 +205,15 @@ struct AppSidebarView: View { store.send(.updateBannerTapped) } .padding(8) + } else if store.starAsk.isEarned { + // Second in line behind the update banner on purpose: news the user asked for + // outranks an ask aimed at them, and two stacked banners is a wall. + SidebarStarBanner(resolvedLoopCount: store.starAsk.resolvedLoopCount) { + store.send(.starAsk(.tapped)) + } onDismiss: { + store.send(.starAsk(.dismissed)) + } + .padding(8) } if let errorMessage = store.welcome.errorMessage { Text(errorMessage) diff --git a/graphcode/Sources/Features/App/TitlebarItems.swift b/graphcode/Sources/Features/App/TitlebarItems.swift index f655482b..fb099bee 100644 --- a/graphcode/Sources/Features/App/TitlebarItems.swift +++ b/graphcode/Sources/Features/App/TitlebarItems.swift @@ -136,6 +136,68 @@ struct SidebarUpdateBanner: View { private static let surface = Color(red: 0.145, green: 0.149, blue: 0.161) } +/// The sidebar's one-time ask to star the project, in the same slot and shape as +/// `SidebarUpdateBanner` — amber rather than blue, because unlike an update this one +/// wants something from the human. +/// +/// It only appears once `StarAskState.isEarned` is true (three loops resolved on this +/// machine), and it says so: the subtitle is the work the app has already done, not a +/// pitch. Tapping opens the repository, the × retires it, and either way it never comes +/// back — an ask that repeats is a nag. +struct SidebarStarBanner: View { + let resolvedLoopCount: Int + let action: () -> Void + let onDismiss: () -> Void + + var body: some View { + HStack(spacing: 6) { + Button(action: action) { + HStack(spacing: 8) { + Image(systemName: "star.fill") + .font(.system(size: 14)) + .foregroundStyle(Self.accent) + VStack(alignment: .leading, spacing: 1) { + Text("Star GraphCode on GitHub") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white.opacity(0.92)) + Text("\(resolvedLoopCount) loops resolved here · a star helps") + .font(.system(size: 10.5)) + .foregroundStyle(.white.opacity(0.55)) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Open the GraphCode repository on GitHub") + + Button(action: onDismiss) { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white.opacity(0.42)) + } + .buttonStyle(.plain) + .help("Don't ask again") + } + .padding(.vertical, 7) + .padding(.horizontal, 10) + .frame(maxWidth: .infinity, alignment: .leading) + // Two layers for the same reason the update banner has two: the tint alone lets the + // desktop read straight through the sidebar's glass, and 10.5pt secondary text over + // a moving background is text nobody can read. + .background(Self.accent.opacity(0.16), in: RoundedRectangle(cornerRadius: 8)) + .background(Self.surface, in: RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8).stroke(Self.accent.opacity(0.45), lineWidth: 1) + } + } + + /// The attention orange every surface in this app that wants a human already wears. + private static let accent = Color(red: 1.0, green: 0.624, blue: 0.039) // #FF9F0A + private static let surface = Color(red: 0.145, green: 0.149, blue: 0.161) +} + /// The ⌘K affordance, in the titlebar where a search field belongs. /// /// A shortcut nobody can see is a shortcut nobody uses. This is the discoverable half of diff --git a/graphcode/Tests/SidebarStarAskTests.swift b/graphcode/Tests/SidebarStarAskTests.swift new file mode 100644 index 00000000..3844477e --- /dev/null +++ b/graphcode/Tests/SidebarStarAskTests.swift @@ -0,0 +1,75 @@ +import ComposableArchitecture +import Foundation +import Testing + +@testable import graphcode + +/// The star ask's reducer half: it stays silent until the threshold, a tap opens the +/// repository, and once answered it never shows again. +@Suite +struct SidebarStarAskTests { + private actor OpenedURLsBox { + private(set) var urls: [URL] = [] + func append(_ url: URL) { urls.append(url) } + } + + private func state(count: Int, answered: Bool = false) -> AppFeature.State { + let state = AppFeature.State() + state.starAsk.$resolvedLoopCount.withLock { $0 = count } + state.starAsk.$isAnswered.withLock { $0 = answered } + return state + } + + @Test + func theAskStaysHiddenUntilThreeLoopsHaveResolved() { + #expect(!state(count: StarAsk.threshold - 1).starAsk.isEarned) + #expect(state(count: StarAsk.threshold).starAsk.isEarned) + } + + @Test + func anAnswerRetiresTheAskForGood() { + #expect(!state(count: StarAsk.threshold + 9, answered: true).starAsk.isEarned) + } + + @Test + @MainActor + func tappingTheBannerOpensTheRepositoryAndAnswersIt() async { + let opened = OpenedURLsBox() + let store = TestStore(initialState: state(count: StarAsk.threshold)) { + AppFeature() + } withDependencies: { + $0.openURL = OpenURLEffect { url in + await opened.append(url) + return true + } + } + store.exhaustivity = .off + + await store.send(.starAsk(.tapped)) { $0.starAsk.$isAnswered.withLock { $0 = true } } + await store.finish() + + #expect(await opened.urls == [StarAsk.repositoryURL]) + #expect(!store.state.starAsk.isEarned) + } + + @Test + @MainActor + func dismissingTheBannerAnswersItWithoutOpeningTheRepository() async { + let opened = OpenedURLsBox() + let store = TestStore(initialState: state(count: StarAsk.threshold)) { + AppFeature() + } withDependencies: { + $0.openURL = OpenURLEffect { url in + await opened.append(url) + return true + } + } + store.exhaustivity = .off + + await store.send(.starAsk(.dismissed)) { $0.starAsk.$isAnswered.withLock { $0 = true } } + await store.finish() + + #expect(await opened.urls.isEmpty) + #expect(!store.state.starAsk.isEarned) + } +}