Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions graphcode/Sources/Clients/FeatureRamps.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}
}
}
Expand Down
77 changes: 77 additions & 0 deletions graphcode/Sources/Features/App/AppFeature+StarAsk.swift
Original file line number Diff line number Diff line change
@@ -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<Self> {
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
}
}
}
}
41 changes: 31 additions & 10 deletions graphcode/Sources/Features/App/AppFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProjectFeature.State> = []
var openLoop: LoopWorkspaceFeature.State?
Expand Down Expand Up @@ -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<AvailableUpdate?, any Error>)
/// A bundle replaced underneath this running window — asked on activation, answered
/// with a relaunch prompt. See `BundleSwap.Action`.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<Action> {
.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.
///
Expand Down
9 changes: 9 additions & 0 deletions graphcode/Sources/Features/App/AppSidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
62 changes: 62 additions & 0 deletions graphcode/Sources/Features/App/TitlebarItems.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions graphcode/Tests/SidebarStarAskTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}