Skip to content
Merged
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
14 changes: 9 additions & 5 deletions Foreman/Foreman/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,22 @@ closed. Closing the window just hides it — the app keeps running until Quit.

## What's in the window

- **Sidebar** — one row per discovered repo: status dot (gray stopped, yellow
stopping/restarting, green running, red failed — flipping a stopping worker
back on queues a restart for when the old process exits) and the worker's
on/off switch.
- **Sidebar** — repos grouped into an **Enabled** section on top and a
**Disabled** one below, with favorites floated to the top of each; rows glide
between groups as you toggle or favorite them. Each row shows a status dot
(gray stopped, yellow stopping/restarting, green running, red failed —
flipping a stopping worker back on queues a restart for when the old process
exits), the worker's on/off switch, and a star on favorited repos.
Right-click a row to favorite or unfavorite it.
- **Detail pane** (select a repo) — the worker's status, pid and live uptime,
the failure reason when it died, the repo path, the exact `cursor-agent`
command the next start will spawn, the options editor inline, and a live
tail of the worker's log (`~/Library/Logs/Foreman/<repo>.log`, refreshed
every second while the window is visible, with an Open File button). The
status row offers **Retry** when an enabled worker failed and **Restart**
while it's running (a fresh process with the saved options); neither
changes the on/off switch.
changes the on/off switch. A toolbar star favorites the repo (mirrors the
sidebar's right-click toggle).
- **Worker options** — mirrors the `cursor agent worker` CLI flags: display
name, pool mode + pool name, `key=value` labels, idle release timeout, and
verbose startup logs. Editable while the worker is stopped — options apply
Expand Down
6 changes: 6 additions & 0 deletions Foreman/Foreman/Sources/ForemanSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ final class ForemanSession {
services.repos
}

/// The discovered repos grouped for the sidebar: enabled on top, disabled
/// below, favorites floated to the top of each section.
var repoSections: [RepoSection] {
services.repoSections
}

/// Global settings; `SettingsView` writes through these observable
/// properties (persistence and rescans happen in Core).
var settings: AppSettings {
Expand Down
65 changes: 62 additions & 3 deletions Foreman/Foreman/Sources/MainWindowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,30 @@ struct MainWindowView: View {

private var sidebar: some View {
List(selection: $selection) {
ForEach(session.repos) { repo in
WorkerRowView(repo: repo)
.tag(repo.id)
// One flat ForEach — group headers and repo rows share a single
// identity space — so a repo moving between the Enabled and
// Disabled groups animates as one glide instead of a cross-Section
// remove/insert. Ordering is computed in ForemanCore; the view
// only flattens it into headers + rows and renders them.
ForEach(sidebarRows) { row in
switch row {
case let .header(kind):
Text(title(for: kind))
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.padding(.top, 6)
.selectionDisabled()
case let .repo(id, repo):
WorkerRowView(repo: repo)
.tag(id)
}
}
}
// Tween the diff whenever the flattened order changes — whatever the
// trigger (enable toggle moving a repo across groups, favorite floating
// it within one, or a rescan adding/removing repos).
.animation(.snappy, value: sidebarRows.map(\.id))
.overlay {
if session.repos.isEmpty {
ContentUnavailableView {
Expand All @@ -56,6 +75,46 @@ struct MainWindowView: View {
.navigationTitle("Foreman")
}

/// The sidebar's rows as one flat, stably-identified sequence: each
/// non-empty section contributes a group header followed by its repos.
/// A single identity space is what lets a repo glide across the group
/// boundary rather than fade out of one section and into another.
private var sidebarRows: [SidebarRow] {
session.repoSections.flatMap { section in
[SidebarRow.header(section.kind)] + section.repos.map { .repo(id: $0.id, repo: $0) }
}
}

private func title(for kind: RepoSection.Kind) -> String {
switch kind {
case .enabled: "Enabled"
case .disabled: "Disabled"
}
}

/// One rendered sidebar entry — a group header or a repo — carried in a
/// single `ForEach` so cross-group moves animate as a glide. Identity spans
/// both kinds: a header keys off its section kind, a repo off its `RepoID`.
///
/// The repo's `RepoID` is captured alongside the `Repo` so the nonisolated
/// `Identifiable.id` getter needn't touch the `@MainActor`-isolated `Repo`.
private enum SidebarRow: Identifiable {
case header(RepoSection.Kind)
case repo(id: RepoID, repo: Repo)

enum ID: Hashable {
case header(RepoSection.Kind)
case repo(RepoID)
}

var id: ID {
switch self {
case let .header(kind): .header(kind)
case let .repo(id, _): .repo(id)
}
}
}

@ViewBuilder
private var issueBanner: some View {
if let issue = session.issueMessage {
Expand Down
33 changes: 27 additions & 6 deletions Foreman/Foreman/Sources/PreviewSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,21 @@
session(repoNames: [])
}

/// A session with a few discovered repos, all stopped.
/// A session with a few discovered repos across both sidebar sections:
/// two enabled (one of them favorited) and a favorited-but-disabled one.
static func populatedSession() -> ForemanSession {
session(repoNames: ["Broadway", "Site", "Stuff"])
session(
repoNames: ["Broadway", "Site", "Stuff"],
enabled: ["Broadway", "Stuff"],
favorites: ["Broadway", "Site"],
)
}

private static func session(repoNames: [String]) -> ForemanSession {
private static func session(
repoNames: [String],
enabled: Set<String> = [],
favorites: Set<String> = [],
) -> ForemanSession {
let base = FileManager.default.temporaryDirectory
.appendingPathComponent("ForemanPreview-\(UUID().uuidString)")
let scanDirectory = base.appendingPathComponent("Development")
Expand All @@ -34,18 +43,30 @@
withIntermediateDirectories: true,
)
}
var repos: [RepoID: RepoConfiguration] = [:]
for name in enabled.union(favorites) {
let id = RepoID(rootURL: scanDirectory
.appendingPathComponent(name, isDirectory: true))
repos[id] = RepoConfiguration(
isEnabled: enabled.contains(name),
isFavorite: favorites.contains(name),
options: .standard,
)
}
let store = WorkerConfigStore(directory: base.appendingPathComponent("config"))
try store.save(ForemanConfiguration(
scanDirectory: scanDirectory,
agentExecutable: nil,
enabledRepoIDs: [],
repoOptions: [:],
repos: repos,
))
let session = ForemanSession(services: ForemanServices(
configStore: store,
logDirectory: base.appendingPathComponent("logs"),
))
session.start()
// Populate the tree from the saved config (seeding
// isEnabled/isFavorite) via a plain rescan — not start(), whose
// launch restore would spawn real workers. Previews never spawn.
session.rescan()
return session
} catch {
fatalError("Preview fixture setup failed: \(error)")
Expand Down
15 changes: 15 additions & 0 deletions Foreman/Foreman/Sources/WorkerDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,21 @@ struct WorkerDetailView: View {
.formStyle(.grouped)
.navigationTitle(repo.name)
.navigationSubtitle(repo.rootURL.path)
.toolbar {
ToolbarItem {
Button {
repo.isFavorite.toggle()
} label: {
Label(
repo.isFavorite ? "Remove from Favorites" : "Add to Favorites",
systemImage: repo.isFavorite ? "star.fill" : "star",
)
}
.help(repo.isFavorite
? "Remove this repo from favorites."
: "Pin this repo to the top of its section.")
}
}
}

/// The transient actions the enable toggle can't express, shown only in
Expand Down
15 changes: 15 additions & 0 deletions Foreman/Foreman/Sources/WorkerRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ struct WorkerRowView: View {
}
}

if repo.isFavorite {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.yellow)
.help("Favorite")
}

Spacer()

Toggle("Worker enabled", isOn: $repo.isEnabled)
Expand All @@ -27,6 +34,14 @@ struct WorkerRowView: View {
.controlSize(.small)
}
.padding(.vertical, 2)
.contextMenu {
Button(
repo.isFavorite ? "Remove from Favorites" : "Add to Favorites",
systemImage: repo.isFavorite ? "star.slash" : "star",
) {
repo.isFavorite.toggle()
}
}
}
}

Expand Down
22 changes: 17 additions & 5 deletions Foreman/ForemanCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,10 @@ let argv = repo.options.arguments(workerDirectory: repo.rootURL)
scan, failed save) on the observable `issueMessage`.
- **`Repo` / `RepoID`** — one discovered repository as an `@Observable`
object: identity (`name`, `rootURL`, typed `RepoID` = canonical absolute
path), the persisted intent (`isEnabled`, `options` — mutations start/stop
the worker and persist automatically), and its `Worker`. Transient intents
path), the persisted intent (`isEnabled`, `isFavorite`, `options` —
mutations persist automatically; `isEnabled` also starts/stops the worker,
while `isFavorite` is pure sidebar-ordering metadata), and its `Worker`.
Transient intents
that don't change the persisted desired state: `retry()` (fresh attempt
for an enabled repo in `.failed`) and `restart()` (respawn a running
worker with the current options — the apply path for options edited while
Expand All @@ -81,6 +83,11 @@ let argv = repo.options.arguments(workerDirectory: repo.rootURL)
directory (a subdirectory with a `.git` entry — directory or file, so
worktrees count) as `ScannedRepo` values. Hidden directories are skipped;
nesting is not searched. Throws when the scan directory can't be listed.
- **`RepoSection`** — the pure sidebar ordering rule. `sections(from:)` groups
the discovered repos into an `.enabled` section on top and a `.disabled` one
below, floating favorites to the top of each (stable — otherwise the
name-sorted order is preserved) and omitting an empty section. `ForemanServices`
exposes it as `repoSections`.
- **`AppSettings`** — observable global settings: `scanDirectory` (default
`~/Development` via `resolvedScanDirectory`) and `agentExecutable` (`nil`
= auto-locate). Assignments persist and — for the scan directory —
Expand All @@ -92,9 +99,14 @@ let argv = repo.options.arguments(workerDirectory: repo.rootURL)
(`--idle-release-timeout`), and `verbose` (`start --verbose`).
`arguments(workerDirectory:)` renders the full argv; `.standard` is the
CLI-default configuration.
- **`ForemanConfiguration`** — everything Foreman persists: the scan directory
(default `~/Development`), an explicit `cursor-agent` executable (or `nil`
for auto-locate), the enabled-repo set, and the per-repo options map.
- **`RepoConfiguration` / `ForemanConfiguration`** — everything Foreman
persists. `RepoConfiguration` is the single per-repo record (`isEnabled`,
`isFavorite`, `options`) — one struct instead of parallel maps keyed by
`RepoID`, so the flags and options can't drift apart. `ForemanConfiguration`
holds the scan directory (default `~/Development`), an explicit
`cursor-agent` executable (or `nil` for auto-locate), and
`repos: [RepoID: RepoConfiguration]`; a repo left at
`RepoConfiguration.standard` has no entry (absence reads identically).
`prune(discovered:under:)` drops entries for repos that vanished from the
scan directory while keeping entries outside it (another directory's
history, re-applied when the user switches back).
Expand Down
35 changes: 22 additions & 13 deletions Foreman/ForemanCore/Sources/ForemanServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ public final class ForemanServices {
discovery.repos
}

/// The discovered repos grouped for the sidebar: enabled on top, disabled
/// below, favorites floated to the top of each section. See ``RepoSection``.
public var repoSections: [RepoSection] {
RepoSection.sections(from: discovery.repos)
}

public var isAnyWorkerLive: Bool {
discovery.isAnyWorkerLive
}
Expand Down Expand Up @@ -165,10 +171,12 @@ public final class ForemanServices {
/// repo whose worker exits after the root is gone must degrade to a
/// no-op, not crash on a dangling reference.
private func makeRepo(_ scanned: ScannedRepo) -> Repo {
Repo(
let record = configuration.configuration(for: scanned.id)
return Repo(
scanned: scanned,
isEnabled: configuration.enabledRepoIDs.contains(scanned.id),
options: configuration.options(for: scanned.id),
isEnabled: record.isEnabled,
isFavorite: record.isFavorite,
options: record.options,
worker: Worker(
name: scanned.name,
workerDirectory: scanned.rootURL,
Expand All @@ -184,17 +192,18 @@ public final class ForemanServices {
}

private func repoDidChange(_ repo: Repo) {
if repo.isEnabled {
configuration.enabledRepoIDs.insert(repo.id)
} else {
configuration.enabledRepoIDs.remove(repo.id)
}
if repo.options == .standard {
// Standard options read identically to an absent entry; dropping
// the entry keeps the file from accumulating no-op records.
configuration.repoOptions.removeValue(forKey: repo.id)
// The repo is the source of truth for its whole persisted record.
let record = RepoConfiguration(
isEnabled: repo.isEnabled,
isFavorite: repo.isFavorite,
options: repo.options,
)
if record == .standard {
// A fully default record reads identically to an absent entry;
// dropping it keeps the file from accumulating no-op records.
configuration.repos.removeValue(forKey: repo.id)
} else {
configuration.repoOptions[repo.id] = repo.options
configuration.repos[repo.id] = record
}
persist()
}
Expand Down
20 changes: 18 additions & 2 deletions Foreman/ForemanCore/Sources/Repo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ public final class Repo: Identifiable {
}
}

/// Whether the user pinned this repo to the top of its sidebar section.
/// Pure presentation metadata — unlike `isEnabled` it has no worker side
/// effect, it only notifies the persistence funnel; reassigning the
/// current value is a no-op.
public var isFavorite: Bool {
didSet {
guard oldValue != isFavorite else { return }
onPersistentChange(self)
}
}

private static let logger = ForemanLog.channel(.repo)

private let resolveExecutable: @MainActor () throws -> URL
Expand All @@ -53,16 +64,20 @@ public final class Repo: Identifiable {
/// - scanned: The on-disk identity from ``RepoDiscovery``.
/// - isEnabled: The saved desired state. Assigning in `init` does not
/// start the worker — launch restore calls ``startIfEnabled()``.
/// - isFavorite: The saved favorite flag (pins the repo to the top of
/// its sidebar section).
/// - options: The saved worker options.
/// - worker: The repo's worker (the owning tree wires its
/// `onStateChange`).
/// - resolveExecutable: Locates the `cursor-agent` binary at start
/// time; a throw lands in the worker's `.failed` state.
/// - onPersistentChange: Invoked after every `isEnabled`/`options`
/// change so the owning tree can write through and save.
/// - onPersistentChange: Invoked after every
/// `isEnabled`/`isFavorite`/`options` change so the owning tree can
/// write through and save.
public init(
scanned: ScannedRepo,
isEnabled: Bool,
isFavorite: Bool,
options: WorkerOptions,
worker: Worker,
resolveExecutable: @escaping @MainActor () throws -> URL,
Expand All @@ -73,6 +88,7 @@ public final class Repo: Identifiable {
rootURL = scanned.rootURL
self.worker = worker
self.isEnabled = isEnabled
self.isFavorite = isFavorite
self.options = options
self.resolveExecutable = resolveExecutable
self.onPersistentChange = onPersistentChange
Expand Down
Loading
Loading