diff --git a/apps/macos/Sources/Scout/ScoutKeyboardCheatsheet.swift b/apps/macos/Sources/Scout/ScoutKeyboardCheatsheet.swift index 9c617bb8..7ca88876 100644 --- a/apps/macos/Sources/Scout/ScoutKeyboardCheatsheet.swift +++ b/apps/macos/Sources/Scout/ScoutKeyboardCheatsheet.swift @@ -46,7 +46,7 @@ struct ScoutKeyboardCheatsheet: View { keyGroup("Navigate · when not typing", active: false) { kbd("j ↓ · k ↑", "next / previous item") - kbd("l · h", "Comms: next / prev · Agents: expand / collapse") + kbd("l · h", "Comms: next / prev · Agents/Repos: expand / collapse") kbd("g · ⇧G", "first / last item") kbd("⌘↑ ⌘↓", "next / previous (works while typing too)") } @@ -67,6 +67,12 @@ struct ScoutKeyboardCheatsheet: View { kbd("⌘O", "observe agent") } + keyGroup("Repos", active: section == .repos) { + kbd("j k · ↑ ↓", "walk repo · worktree") + kbd("l · h", "expand · collapse / parent") + kbd("⌘↩", "reveal worktree in Finder") + } + keyGroup("Global", active: false) { kbd("? · ⌘/", "toggle this help") kbd("Esc", "close help / dismiss suggestions") diff --git a/apps/macos/Sources/Scout/ScoutModels.swift b/apps/macos/Sources/Scout/ScoutModels.swift index 630f2aa1..a9ed026c 100644 --- a/apps/macos/Sources/Scout/ScoutModels.swift +++ b/apps/macos/Sources/Scout/ScoutModels.swift @@ -6,6 +6,7 @@ enum ScoutSection: String, CaseIterable, Identifiable { case comms case agents case tail + case repos var id: String { rawValue } @@ -14,6 +15,7 @@ enum ScoutSection: String, CaseIterable, Identifiable { case .comms: return "Comms" case .agents: return "Agents" case .tail: return "Tail" + case .repos: return "Repos" } } @@ -22,6 +24,7 @@ enum ScoutSection: String, CaseIterable, Identifiable { case .comms: return "bubble.left.and.bubble.right" case .agents: return "person.2" case .tail: return "waveform.path.ecg" + case .repos: return "arrow.triangle.branch" } } } diff --git a/apps/macos/Sources/Scout/ScoutRepoModels.swift b/apps/macos/Sources/Scout/ScoutRepoModels.swift new file mode 100644 index 00000000..c5763b60 --- /dev/null +++ b/apps/macos/Sources/Scout/ScoutRepoModels.swift @@ -0,0 +1,587 @@ +import Foundation + +/// Wire contract + derivations for the **Repo Watch** section. +/// +/// The structs mirror `RepoWatchSnapshot` exported from `@openscout/runtime` +/// (`packages/runtime/src/repo-watch/index.ts`) and served by the broker at +/// `GET /v1/repo-watch/snapshot`. The derivations below are the Swift twins of +/// the web surface's `scout/repo-watch/ui.ts` — kept semantically identical so +/// native and web read as one system (churn parse, agent dedupe, attention +/// rank, branch split, relative time, worktree state). +/// +/// Decoding follows the house style (`ScoutChannel`): explicit `CodingKeys` + +/// `init(from:)` with `decodeIfPresent` defaults, so a partial/forward-evolved +/// snapshot never hard-fails the section. + +// MARK: - Attention + +/// §6 Attention Rules — the backend's mechanical severity classifier. The UI +/// sorts by `rank` (lower = worse) without inventing product semantics. +enum RepoAttention: String, Decodable, Sendable, CaseIterable { + case critical // merge conflicts / unmerged + case attention // dirty main|master, diverged branch, or status errored + case active // dirty, ahead/behind, or a live agent/session attached + case quiet // clean and idle + case unknown // discovered but couldn't be scanned + + /// Lower = worse. Drives worst-first ordering everywhere. + var rank: Int { + switch self { + case .critical: return 0 + case .attention: return 1 + case .active: return 2 + case .quiet: return 3 + case .unknown: return 4 + } + } + + init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = RepoAttention(rawValue: raw) ?? .unknown + } +} + +// MARK: - Snapshot + +struct RepoWatchSnapshot: Decodable, Sendable { + /// Epoch **ms** the snapshot was generated. Relative-time anchor. + let generatedAt: Double + let projects: [RepoProject] + let totals: RepoTotals + let warnings: [String] + + enum CodingKeys: String, CodingKey { case generatedAt, projects, totals, warnings } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + generatedAt = try c.decodeIfPresent(Double.self, forKey: .generatedAt) ?? 0 + projects = try c.decodeIfPresent([RepoProject].self, forKey: .projects) ?? [] + totals = try c.decodeIfPresent(RepoTotals.self, forKey: .totals) ?? .empty + warnings = try c.decodeIfPresent([String].self, forKey: .warnings) ?? [] + } + + static let empty = RepoWatchSnapshot(generatedAt: 0, projects: [], totals: .empty, warnings: []) + + private init(generatedAt: Double, projects: [RepoProject], totals: RepoTotals, warnings: [String]) { + self.generatedAt = generatedAt + self.projects = projects + self.totals = totals + self.warnings = warnings + } +} + +struct RepoTotals: Decodable, Sendable { + let projects: Int + let worktrees: Int + let dirtyWorktrees: Int + let conflictedWorktrees: Int + let attentionWorktrees: Int + let attachedAgents: Int + let attachedSessions: Int + + static let empty = RepoTotals(projects: 0, worktrees: 0, dirtyWorktrees: 0, + conflictedWorktrees: 0, attentionWorktrees: 0, + attachedAgents: 0, attachedSessions: 0) + + enum CodingKeys: String, CodingKey { + case projects, worktrees, dirtyWorktrees, conflictedWorktrees + case attentionWorktrees, attachedAgents, attachedSessions + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + projects = try c.decodeIfPresent(Int.self, forKey: .projects) ?? 0 + worktrees = try c.decodeIfPresent(Int.self, forKey: .worktrees) ?? 0 + dirtyWorktrees = try c.decodeIfPresent(Int.self, forKey: .dirtyWorktrees) ?? 0 + conflictedWorktrees = try c.decodeIfPresent(Int.self, forKey: .conflictedWorktrees) ?? 0 + attentionWorktrees = try c.decodeIfPresent(Int.self, forKey: .attentionWorktrees) ?? 0 + attachedAgents = try c.decodeIfPresent(Int.self, forKey: .attachedAgents) ?? 0 + attachedSessions = try c.decodeIfPresent(Int.self, forKey: .attachedSessions) ?? 0 + } + + private init(projects: Int, worktrees: Int, dirtyWorktrees: Int, conflictedWorktrees: Int, + attentionWorktrees: Int, attachedAgents: Int, attachedSessions: Int) { + self.projects = projects + self.worktrees = worktrees + self.dirtyWorktrees = dirtyWorktrees + self.conflictedWorktrees = conflictedWorktrees + self.attentionWorktrees = attentionWorktrees + self.attachedAgents = attachedAgents + self.attachedSessions = attachedSessions + } +} + +// MARK: - Project + +struct RepoProject: Decodable, Identifiable, Sendable { + let id: String // `repo:${hash(commonGitDir)}` + let name: String + let root: String + let commonGitDir: String + let attention: RepoAttention + let attentionReasons: [String] + let worktrees: [RepoWorktree] + let stats: RepoProjectStats + let hints: [RepoHint] + + enum CodingKeys: String, CodingKey { + case id, name, root, commonGitDir, attention, attentionReasons, worktrees, stats, hints + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString + name = try c.decodeIfPresent(String.self, forKey: .name) ?? "—" + root = try c.decodeIfPresent(String.self, forKey: .root) ?? "" + commonGitDir = try c.decodeIfPresent(String.self, forKey: .commonGitDir) ?? "" + attention = try c.decodeIfPresent(RepoAttention.self, forKey: .attention) ?? .unknown + attentionReasons = try c.decodeIfPresent([String].self, forKey: .attentionReasons) ?? [] + worktrees = try c.decodeIfPresent([RepoWorktree].self, forKey: .worktrees) ?? [] + stats = try c.decodeIfPresent(RepoProjectStats.self, forKey: .stats) ?? .empty + hints = try c.decodeIfPresent([RepoHint].self, forKey: .hints) ?? [] + } +} + +struct RepoProjectStats: Decodable, Sendable { + let worktrees: Int + let dirtyWorktrees: Int + let conflictedWorktrees: Int + let attachedAgents: Int + let attachedSessions: Int + let staged: Int + let unstaged: Int + let untracked: Int + let conflicts: Int + + static let empty = RepoProjectStats(worktrees: 0, dirtyWorktrees: 0, conflictedWorktrees: 0, + attachedAgents: 0, attachedSessions: 0, staged: 0, + unstaged: 0, untracked: 0, conflicts: 0) + + enum CodingKeys: String, CodingKey { + case worktrees, dirtyWorktrees, conflictedWorktrees, attachedAgents + case attachedSessions, staged, unstaged, untracked, conflicts + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + worktrees = try c.decodeIfPresent(Int.self, forKey: .worktrees) ?? 0 + dirtyWorktrees = try c.decodeIfPresent(Int.self, forKey: .dirtyWorktrees) ?? 0 + conflictedWorktrees = try c.decodeIfPresent(Int.self, forKey: .conflictedWorktrees) ?? 0 + attachedAgents = try c.decodeIfPresent(Int.self, forKey: .attachedAgents) ?? 0 + attachedSessions = try c.decodeIfPresent(Int.self, forKey: .attachedSessions) ?? 0 + staged = try c.decodeIfPresent(Int.self, forKey: .staged) ?? 0 + unstaged = try c.decodeIfPresent(Int.self, forKey: .unstaged) ?? 0 + untracked = try c.decodeIfPresent(Int.self, forKey: .untracked) ?? 0 + conflicts = try c.decodeIfPresent(Int.self, forKey: .conflicts) ?? 0 + } + + private init(worktrees: Int, dirtyWorktrees: Int, conflictedWorktrees: Int, attachedAgents: Int, + attachedSessions: Int, staged: Int, unstaged: Int, untracked: Int, conflicts: Int) { + self.worktrees = worktrees + self.dirtyWorktrees = dirtyWorktrees + self.conflictedWorktrees = conflictedWorktrees + self.attachedAgents = attachedAgents + self.attachedSessions = attachedSessions + self.staged = staged + self.unstaged = unstaged + self.untracked = untracked + self.conflicts = conflicts + } +} + +// MARK: - Worktree + +struct RepoWorktree: Decodable, Identifiable, Sendable { + let id: String // `worktree:${hash(path)}` + let path: String + let name: String + let isBare: Bool + let branch: RepoBranch + let status: RepoStatus + let diff: RepoDiff + let attention: RepoAttention + let attentionReasons: [String] + let agents: [RepoAgentRef] + let sessions: [RepoSessionRef] + let hints: [RepoHint] + let lastCommitAt: Double? // epoch ms; nil unless includeLastCommit=1 + let lastTouchedAt: Double? // epoch ms; newest working-tree file mtime + let scannedAt: Double + let error: String? + + enum CodingKeys: String, CodingKey { + case id, path, name, isBare, branch, status, diff, attention, attentionReasons + case agents, sessions, hints, lastCommitAt, lastTouchedAt, scannedAt, error + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString + path = try c.decodeIfPresent(String.self, forKey: .path) ?? "" + name = try c.decodeIfPresent(String.self, forKey: .name) ?? "—" + isBare = try c.decodeIfPresent(Bool.self, forKey: .isBare) ?? false + branch = try c.decodeIfPresent(RepoBranch.self, forKey: .branch) ?? .empty + status = try c.decodeIfPresent(RepoStatus.self, forKey: .status) ?? .empty + diff = try c.decodeIfPresent(RepoDiff.self, forKey: .diff) ?? .empty + attention = try c.decodeIfPresent(RepoAttention.self, forKey: .attention) ?? .unknown + attentionReasons = try c.decodeIfPresent([String].self, forKey: .attentionReasons) ?? [] + agents = try c.decodeIfPresent([RepoAgentRef].self, forKey: .agents) ?? [] + sessions = try c.decodeIfPresent([RepoSessionRef].self, forKey: .sessions) ?? [] + hints = try c.decodeIfPresent([RepoHint].self, forKey: .hints) ?? [] + lastCommitAt = try c.decodeIfPresent(Double.self, forKey: .lastCommitAt) + lastTouchedAt = try c.decodeIfPresent(Double.self, forKey: .lastTouchedAt) + scannedAt = try c.decodeIfPresent(Double.self, forKey: .scannedAt) ?? 0 + error = try c.decodeIfPresent(String.self, forKey: .error) + } +} + +struct RepoBranch: Decodable, Sendable { + let name: String? + let upstream: String? + let head: String? + let detached: Bool + let ahead: Int + let behind: Int + let isMain: Bool + let diverged: Bool + + static let empty = RepoBranch(name: nil, upstream: nil, head: nil, detached: false, + ahead: 0, behind: 0, isMain: false, diverged: false) + + enum CodingKeys: String, CodingKey { + case name, upstream, head, detached, ahead, behind, isMain, diverged + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + name = try c.decodeIfPresent(String.self, forKey: .name) + upstream = try c.decodeIfPresent(String.self, forKey: .upstream) + head = try c.decodeIfPresent(String.self, forKey: .head) + detached = try c.decodeIfPresent(Bool.self, forKey: .detached) ?? false + ahead = try c.decodeIfPresent(Int.self, forKey: .ahead) ?? 0 + behind = try c.decodeIfPresent(Int.self, forKey: .behind) ?? 0 + isMain = try c.decodeIfPresent(Bool.self, forKey: .isMain) ?? false + diverged = try c.decodeIfPresent(Bool.self, forKey: .diverged) ?? false + } + + private init(name: String?, upstream: String?, head: String?, detached: Bool, + ahead: Int, behind: Int, isMain: Bool, diverged: Bool) { + self.name = name + self.upstream = upstream + self.head = head + self.detached = detached + self.ahead = ahead + self.behind = behind + self.isMain = isMain + self.diverged = diverged + } +} + +struct RepoStatus: Decodable, Sendable { + let clean: Bool + let staged: Int + let unstaged: Int + let untracked: Int + let conflicts: Int + let changedFiles: Int + let files: [RepoChangedFile] + + static let empty = RepoStatus(clean: true, staged: 0, unstaged: 0, untracked: 0, + conflicts: 0, changedFiles: 0, files: []) + + enum CodingKeys: String, CodingKey { + case clean, staged, unstaged, untracked, conflicts, changedFiles, files + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + clean = try c.decodeIfPresent(Bool.self, forKey: .clean) ?? true + staged = try c.decodeIfPresent(Int.self, forKey: .staged) ?? 0 + unstaged = try c.decodeIfPresent(Int.self, forKey: .unstaged) ?? 0 + untracked = try c.decodeIfPresent(Int.self, forKey: .untracked) ?? 0 + conflicts = try c.decodeIfPresent(Int.self, forKey: .conflicts) ?? 0 + changedFiles = try c.decodeIfPresent(Int.self, forKey: .changedFiles) ?? 0 + files = try c.decodeIfPresent([RepoChangedFile].self, forKey: .files) ?? [] + } + + private init(clean: Bool, staged: Int, unstaged: Int, untracked: Int, + conflicts: Int, changedFiles: Int, files: [RepoChangedFile]) { + self.clean = clean + self.staged = staged + self.unstaged = unstaged + self.untracked = untracked + self.conflicts = conflicts + self.changedFiles = changedFiles + self.files = files + } +} + +struct RepoChangedFile: Decodable, Identifiable, Sendable { + let path: String + let status: String // "untracked" | "conflict" | "staged" | "unstaged" | "staged+unstaged" | "changed" + + var id: String { path } + + enum CodingKeys: String, CodingKey { case path, status } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + path = try c.decodeIfPresent(String.self, forKey: .path) ?? "" + status = try c.decodeIfPresent(String.self, forKey: .status) ?? "changed" + } +} + +struct RepoDiff: Decodable, Sendable { + let unstagedShortstat: String? + let stagedShortstat: String? + + static let empty = RepoDiff(unstagedShortstat: nil, stagedShortstat: nil) + + enum CodingKeys: String, CodingKey { case unstagedShortstat, stagedShortstat } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + unstagedShortstat = try c.decodeIfPresent(String.self, forKey: .unstagedShortstat) + stagedShortstat = try c.decodeIfPresent(String.self, forKey: .stagedShortstat) + } + + private init(unstagedShortstat: String?, stagedShortstat: String?) { + self.unstagedShortstat = unstagedShortstat + self.stagedShortstat = stagedShortstat + } +} + +struct RepoAgentRef: Decodable, Identifiable, Sendable { + let id: String + let name: String? + let state: String? + let harness: String? + + /// `state === "active"` — the worktree has live Scout activity. + var live: Bool { (state ?? "").lowercased() == "active" } + + /// Display handle built from `name` (hyphenated) or falling back to `id`, + /// mirroring web `ui.ts` (no handle is sent over the wire). + var handle: String { + if let name = name?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { + let slug = name.lowercased() + .replacingOccurrences(of: #"[^a-z0-9]+"#, with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + return slug.isEmpty ? id : "@\(slug)" + } + return id + } + + /// Two-letter initials for dense agent chips ("Hudson Logo" → "HL"). + var initials: String { + let base = (name?.nilIfEmpty ?? id) + let words = base.split(whereSeparator: { !$0.isLetter && !$0.isNumber }) + let letters = words.prefix(2).compactMap { $0.first }.map { String($0) } + let joined = letters.joined().uppercased() + return joined.isEmpty ? String(base.prefix(2)).uppercased() : joined + } + + /// ACTIVE | IDLE | WAITING | OFFLINE | — + var stateWord: String { + guard let s = state?.trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty else { return "—" } + return s.uppercased() + } + + enum CodingKeys: String, CodingKey { case id, name, state, harness } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString + name = try c.decodeIfPresent(String.self, forKey: .name) + state = try c.decodeIfPresent(String.self, forKey: .state) + harness = try c.decodeIfPresent(String.self, forKey: .harness) + } +} + +struct RepoSessionRef: Decodable, Identifiable, Sendable { + let id: String + let source: String? + let harness: String? + + enum CodingKeys: String, CodingKey { case id, source, harness } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString + source = try c.decodeIfPresent(String.self, forKey: .source) + harness = try c.decodeIfPresent(String.self, forKey: .harness) + } +} + +struct RepoHint: Decodable, Sendable { + let path: String + let source: String + let sourceLabel: String? + let agentId: String? + let agentName: String? + let agentState: String? + let sessionId: String? + let harness: String? + + enum CodingKeys: String, CodingKey { + case path, source, sourceLabel, agentId, agentName, agentState, sessionId, harness + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + path = try c.decodeIfPresent(String.self, forKey: .path) ?? "" + source = try c.decodeIfPresent(String.self, forKey: .source) ?? "unknown" + sourceLabel = try c.decodeIfPresent(String.self, forKey: .sourceLabel) + agentId = try c.decodeIfPresent(String.self, forKey: .agentId) + agentName = try c.decodeIfPresent(String.self, forKey: .agentName) + agentState = try c.decodeIfPresent(String.self, forKey: .agentState) + sessionId = try c.decodeIfPresent(String.self, forKey: .sessionId) + harness = try c.decodeIfPresent(String.self, forKey: .harness) + } +} + +// MARK: - Derivations (Swift twins of scout/repo-watch/ui.ts) + +/// Parsed `git diff --shortstat` churn, summed across staged + unstaged. +struct RepoChurn: Sendable { + let add: Int + let del: Int + var total: Int { add + del } + var has: Bool { total > 0 } + + static let none = RepoChurn(add: 0, del: 0) +} + +/// Worktree state lens — mirrors web `wtState()`. +enum RepoWorktreeState: Sendable { + case error // scan failed + case live // a live agent is attached + case dirty // uncommitted changes or ahead/behind + case clean // quiet +} + +extension RepoWorktree { + /// Churn parsed from the diff shortstats (web `churnOf()`). + var churn: RepoChurn { + RepoChurnParser.parse(diff.unstagedShortstat, diff.stagedShortstat) + } + + /// One-line lens (web `wtState()`). + var state: RepoWorktreeState { + if error != nil { return .error } + if agents.contains(where: { $0.live }) { return .live } + if !status.clean || branch.ahead > 0 || branch.behind > 0 { return .dirty } + return .clean + } + + /// Worktrees with live agents, unsaved changes, drift, sessions, or a scan + /// error (web `hasActivity()`); everything else folds into the clean tray. + var hasActivity: Bool { + !status.clean + || branch.ahead > 0 + || branch.behind > 0 + || agents.contains(where: { $0.live }) + || !sessions.isEmpty + || error != nil + } + + /// Live-first, de-duplicated by display handle (web `uniqueAgents()`). + var uniqueAgents: [RepoAgentRef] { + var seen = Set() + let ordered = agents.sorted { lhs, rhs in + if lhs.live != rhs.live { return lhs.live } + return false + } + return ordered.filter { seen.insert($0.handle).inserted } + } + + /// Branch label split into a dimmed prefix and highlighted leaf, or a short + /// SHA when detached (web `branchParts()`). + var branchParts: RepoBranchParts { + if branch.detached { + let sha = (branch.head ?? "").prefix(7) + return RepoBranchParts(detached: true, sha: String(sha), prefix: "", leaf: String(sha)) + } + let name = branch.name ?? "" + if let slash = name.range(of: "/", options: .backwards) { + return RepoBranchParts(detached: false, sha: "", + prefix: String(name[.. String? { + guard let ts = lastCommitAt else { return nil } + return RepoRelativeTime.ago(fromMillis: ts, now: generatedAt) + } + + /// Relative time since the worktree was last *touched* (newest working-tree + /// file mtime) — "when did I last work here", distinct from the last commit. + func lastTouchedAgo(generatedAt: Double) -> String? { + guard let ts = lastTouchedAt else { return nil } + return RepoRelativeTime.ago(fromMillis: ts, now: generatedAt) + } + + /// Drift flag pill text (web Drift view). + var driftFlag: String { + if error != nil { return "SCAN ERR" } + if branch.ahead > 0 && branch.behind > 0 { return "DIVERGED" } + if branch.behind > 0 { return "REBASE" } + if branch.ahead > 0 { return "AHEAD \(branch.ahead)" } + return "IN SYNC" + } +} + +struct RepoBranchParts: Sendable { + let detached: Bool + let sha: String + let prefix: String + let leaf: String +} + +enum RepoChurnParser { + private static let insertions = try! NSRegularExpression(pattern: #"(\d+)\s+insertions?\(\+\)"#) + private static let deletions = try! NSRegularExpression(pattern: #"(\d+)\s+deletions?\(-\)"#) + + static func parse(_ shortstats: String?...) -> RepoChurn { + var add = 0 + var del = 0 + for stat in shortstats { + guard let stat, !stat.isEmpty else { continue } + add += firstInt(insertions, in: stat) + del += firstInt(deletions, in: stat) + } + return RepoChurn(add: add, del: del) + } + + private static func firstInt(_ regex: NSRegularExpression, in text: String) -> Int { + let range = NSRange(text.startIndex..., in: text) + guard let match = regex.firstMatch(in: text, range: range), + let captured = Range(match.range(at: 1), in: text) else { return 0 } + return Int(text[captured]) ?? 0 + } +} + +enum RepoRelativeTime { + /// "3s" / "5m" / "2h" / "1d" against a fixed `now` (both epoch ms). + static func ago(fromMillis ts: Double, now: Double) -> String { + let seconds = max(0, (now - ts) / 1000) + if seconds < 60 { return "\(Int(seconds))s" } + let minutes = seconds / 60 + if minutes < 60 { return "\(Int(minutes))m" } + let hours = minutes / 60 + if hours < 24 { return "\(Int(hours))h" } + return "\(Int(hours / 24))d" + } +} + +/// Abbreviate a path to its last `segments` components with a "…/" prefix when +/// longer (web `shortPath()`). +func repoShortPath(_ path: String, segments: Int = 3) -> String { + let parts = path.split(separator: "/").map(String.init) + guard parts.count > segments else { return path } + return "…/" + parts.suffix(segments).joined(separator: "/") +} diff --git a/apps/macos/Sources/Scout/ScoutRepoSample.swift b/apps/macos/Sources/Scout/ScoutRepoSample.swift new file mode 100644 index 00000000..71bbaf1b --- /dev/null +++ b/apps/macos/Sources/Scout/ScoutRepoSample.swift @@ -0,0 +1,235 @@ +import Foundation + +/// Preview-only sample snapshot for the Repos section. +/// +/// Activated by setting `OPENSCOUT_REPOS_SAMPLE=1` in the environment — when set, +/// `ScoutRepoStore` decodes this fixture instead of polling the broker, so the +/// section can be exercised (density, sorting, the clean-idle fold, drift flags, +/// agents) without a broker that serves `/v1/repo-watch/snapshot`. It is inert +/// unless the env var is present, so it never affects normal runs. +/// +/// The JSON is the real wire shape, decoded through the same `RepoWatchSnapshot` +/// path as live data. `generatedAt` and `lastCommitAt` are fixed epoch-ms so the +/// relative "ago" labels are deterministic. +enum ScoutRepoSample { + static var isEnabled: Bool { + ProcessInfo.processInfo.environment["OPENSCOUT_REPOS_SAMPLE"] != nil + } + + static func snapshot() -> RepoWatchSnapshot? { + guard let data = json.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(RepoWatchSnapshot.self, from: data) + } + + // generatedAt anchor: 2026-06-05T16:00:00Z = 1780156800000 ms. + private static let json = """ + { + "generatedAt": 1780156800000, + "totals": { + "projects": 3, + "worktrees": 7, + "dirtyWorktrees": 4, + "conflictedWorktrees": 1, + "attentionWorktrees": 2, + "attachedAgents": 3, + "attachedSessions": 1 + }, + "warnings": ["sample data — OPENSCOUT_REPOS_SAMPLE is set; this is not live broker data"], + "projects": [ + { + "id": "repo:hudson", + "name": "hudson", + "root": "/Users/art/dev/hudson", + "commonGitDir": "/Users/art/dev/hudson/.git", + "attention": "critical", + "attentionReasons": ["merge conflicts in main", "vantage worktree scan failed"], + "stats": { + "worktrees": 2, "dirtyWorktrees": 1, "conflictedWorktrees": 1, + "attachedAgents": 1, "attachedSessions": 0, + "staged": 0, "unstaged": 0, "untracked": 0, "conflicts": 2 + }, + "hints": [], + "worktrees": [ + { + "id": "wt:hudson-main", + "path": "/Users/art/dev/hudson", + "name": "hudson", + "isBare": false, + "branch": { "name": "main", "upstream": "origin/main", "head": "9f3c1a2b7e", "detached": false, "ahead": 0, "behind": 3, "isMain": true, "diverged": false }, + "status": { + "clean": false, "staged": 0, "unstaged": 0, "untracked": 0, "conflicts": 2, "changedFiles": 2, + "files": [ + { "path": "packages/native/apple/HudsonKit/Sources/HudsonUI/Tokens/HudPalette.swift", "status": "conflict" }, + { "path": "packages/native/apple/HudsonKit/Sources/HudsonUI/Primitives/HudBadge.swift", "status": "conflict" } + ] + }, + "diff": { "unstagedShortstat": null, "stagedShortstat": null }, + "attention": "critical", + "attentionReasons": ["2 merge conflicts", "behind origin/main by 3"], + "agents": [{ "id": "agent:codex:hudson", "name": "codex", "state": "active", "harness": "codex" }], + "sessions": [], + "hints": [], + "lastCommitAt": 1780146000000, + "scannedAt": 1780156800000, + "error": null + }, + { + "id": "wt:hudson-vantage", + "path": "/Users/art/dev/hudson/apps/vantage", + "name": "vantage", + "isBare": false, + "branch": { "name": null, "upstream": null, "head": "deadbeef12", "detached": true, "ahead": 0, "behind": 0, "isMain": false, "diverged": false }, + "status": { "clean": true, "staged": 0, "unstaged": 0, "untracked": 0, "conflicts": 0, "changedFiles": 0, "files": [] }, + "diff": { "unstagedShortstat": null, "stagedShortstat": null }, + "attention": "unknown", + "attentionReasons": ["scan failed"], + "agents": [], + "sessions": [], + "hints": [], + "lastCommitAt": null, + "scannedAt": 1780156800000, + "error": "fatal: not a git repository (or any parent up to mount point /)" + } + ] + }, + { + "id": "repo:openscout", + "name": "openscout", + "root": "/Users/art/dev/openscout", + "commonGitDir": "/Users/art/dev/openscout/.git", + "attention": "attention", + "attentionReasons": ["3 dirty worktrees", "feat/macos-repos diverged from upstream"], + "stats": { + "worktrees": 4, "dirtyWorktrees": 3, "conflictedWorktrees": 0, + "attachedAgents": 2, "attachedSessions": 1, + "staged": 3, "unstaged": 4, "untracked": 1, "conflicts": 0 + }, + "hints": [], + "worktrees": [ + { + "id": "wt:os-main", + "path": "/Users/art/dev/openscout", + "name": "openscout", + "isBare": false, + "branch": { "name": "main", "upstream": "origin/main", "head": "abc1234def", "detached": false, "ahead": 2, "behind": 0, "isMain": true, "diverged": false }, + "status": { + "clean": false, "staged": 0, "unstaged": 3, "untracked": 1, "conflicts": 0, "changedFiles": 4, + "files": [ + { "path": "packages/web/client/scout/repo-watch/ui.ts", "status": "unstaged" }, + { "path": "packages/runtime/src/broker-daemon.ts", "status": "unstaged" }, + { "path": "docs/agent/README.agent.md", "status": "unstaged" }, + { "path": "notes/scratch.md", "status": "untracked" } + ] + }, + "diff": { "unstagedShortstat": "4 files changed, 128 insertions(+), 12 deletions(-)", "stagedShortstat": null }, + "attention": "active", + "attentionReasons": ["dirty", "ahead of origin/main by 2"], + "agents": [{ "id": "agent:hudson-logo:os", "name": "Hudson Logo", "state": "active", "harness": "claude" }], + "sessions": [{ "id": "sess-aaa111bbb", "source": "claude", "harness": "claude" }], + "hints": [], + "lastCommitAt": 1780156440000, + "scannedAt": 1780156800000, + "error": null + }, + { + "id": "wt:os-web-design", + "path": "/Users/art/dev/openscout-web", + "name": "openscout-web", + "isBare": false, + "branch": { "name": "feat/web-design-system", "upstream": "origin/feat/web-design-system", "head": "77aa11cc99", "detached": false, "ahead": 1, "behind": 0, "isMain": false, "diverged": false }, + "status": { + "clean": false, "staged": 2, "unstaged": 1, "untracked": 0, "conflicts": 0, "changedFiles": 3, + "files": [ + { "path": "packages/web/client/scout/slots/Inspector.tsx", "status": "staged" }, + { "path": "packages/web/client/screens/PlanView.tsx", "status": "staged+unstaged" }, + { "path": "packages/web/client/lib/router.ts", "status": "unstaged" } + ] + }, + "diff": { "unstagedShortstat": "1 file changed, 9 insertions(+), 2 deletions(-)", "stagedShortstat": "2 files changed, 64 insertions(+), 8 deletions(-)" }, + "attention": "active", + "attentionReasons": ["dirty", "ahead of upstream by 1"], + "agents": [{ "id": "agent:claude:web", "name": "claude", "state": "active", "harness": "claude" }], + "sessions": [], + "hints": [], + "lastCommitAt": 1780155300000, + "scannedAt": 1780156800000, + "error": null + }, + { + "id": "wt:os-macos-repos", + "path": "/Users/art/dev/openscout-rw", + "name": "openscout-rw", + "isBare": false, + "branch": { "name": "feat/macos-repos", "upstream": "origin/feat/macos-repos", "head": "9d9142e4e3", "detached": false, "ahead": 1, "behind": 1, "isMain": false, "diverged": true }, + "status": { + "clean": false, "staged": 1, "unstaged": 0, "untracked": 0, "conflicts": 0, "changedFiles": 1, + "files": [ + { "path": "apps/macos/Sources/Scout/ScoutReposView.swift", "status": "staged" } + ] + }, + "diff": { "unstagedShortstat": null, "stagedShortstat": "1 file changed, 980 insertions(+)" }, + "attention": "attention", + "attentionReasons": ["diverged from origin/feat/macos-repos (ahead 1, behind 1)"], + "agents": [], + "sessions": [], + "hints": [], + "lastCommitAt": 1780156200000, + "scannedAt": 1780156800000, + "error": null + }, + { + "id": "wt:os-release", + "path": "/Users/art/dev/openscout-release", + "name": "openscout-release", + "isBare": false, + "branch": { "name": "release/2026.05", "upstream": "origin/release/2026.05", "head": "55ee44dd33", "detached": false, "ahead": 0, "behind": 0, "isMain": false, "diverged": false }, + "status": { "clean": true, "staged": 0, "unstaged": 0, "untracked": 0, "conflicts": 0, "changedFiles": 0, "files": [] }, + "diff": { "unstagedShortstat": null, "stagedShortstat": null }, + "attention": "quiet", + "attentionReasons": [], + "agents": [], + "sessions": [], + "hints": [], + "lastCommitAt": 1780070400000, + "scannedAt": 1780156800000, + "error": null + } + ] + }, + { + "id": "repo:vox", + "name": "vox", + "root": "/Users/art/dev/vox", + "commonGitDir": "/Users/art/dev/vox/.git", + "attention": "quiet", + "attentionReasons": [], + "stats": { + "worktrees": 1, "dirtyWorktrees": 0, "conflictedWorktrees": 0, + "attachedAgents": 0, "attachedSessions": 0, + "staged": 0, "unstaged": 0, "untracked": 0, "conflicts": 0 + }, + "hints": [], + "worktrees": [ + { + "id": "wt:vox-main", + "path": "/Users/art/dev/vox", + "name": "vox", + "isBare": false, + "branch": { "name": "main", "upstream": "origin/main", "head": "11bb22cc44", "detached": false, "ahead": 0, "behind": 0, "isMain": true, "diverged": false }, + "status": { "clean": true, "staged": 0, "unstaged": 0, "untracked": 0, "conflicts": 0, "changedFiles": 0, "files": [] }, + "diff": { "unstagedShortstat": null, "stagedShortstat": null }, + "attention": "quiet", + "attentionReasons": [], + "agents": [], + "sessions": [], + "hints": [], + "lastCommitAt": 1779984000000, + "scannedAt": 1780156800000, + "error": null + } + ] + } + ] + } + """ +} diff --git a/apps/macos/Sources/Scout/ScoutRepoStore.swift b/apps/macos/Sources/Scout/ScoutRepoStore.swift new file mode 100644 index 00000000..93e6e34b --- /dev/null +++ b/apps/macos/Sources/Scout/ScoutRepoStore.swift @@ -0,0 +1,191 @@ +import Combine +import Foundation + +/// Polls the broker's Repo Watch snapshot for the Repos section. Mirrors +/// `ScoutTailStore`'s lifecycle (start/stop/refresh + a single in-flight fetch) +/// but at a calmer cadence — a repo scan walks every worktree's git status, so +/// it is far heavier than the tail feed. Reuses the shared `ScoutBroker` URL +/// resolver. +@MainActor +final class ScoutRepoStore: ObservableObject { + @Published private(set) var snapshot: RepoWatchSnapshot = .empty + @Published private(set) var hasLoaded = false + @Published private(set) var isLoading = false + @Published private(set) var lastError: String? + @Published private(set) var lastFetchedAt: Date? + + /// The clean-&-idle tray is folded by default — unfinished work floats up. + @Published var showCleanIdle = false + + /// Which lens the worktree rows read through. Native leads with Table; Drift + /// is a toggle over the same model (ahead/behind/upstream instead of churn). + @Published var lens: ReposLens = .table + + private let decoder = JSONDecoder() + /// Calmer than Tail's 1.4s: a snapshot shells out to git per worktree. + private let pollInterval: TimeInterval = 4.0 + private var pollTask: Task? + private var fetchTask: Task? + + // MARK: Derived + + /// Projects worst-first, then alphabetically — the spine of every view. + var projects: [RepoProject] { + snapshot.projects.sorted { lhs, rhs in + if lhs.attention.rank != rhs.attention.rank { + return lhs.attention.rank < rhs.attention.rank + } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + } + + var totals: RepoTotals { snapshot.totals } + + var generatedAt: Double { snapshot.generatedAt } + + /// Resolve a worktree by id across all projects (the inspector follows the + /// cursor's selected id). + func worktree(id: String?) -> RepoWorktree? { + guard let id else { return nil } + for project in snapshot.projects { + if let match = project.worktrees.first(where: { $0.id == id }) { return match } + } + return nil + } + + func project(forWorktree id: String?) -> RepoProject? { + guard let id else { return nil } + return snapshot.projects.first { $0.worktrees.contains { $0.id == id } } + } + + func project(id: String?) -> RepoProject? { + guard let id else { return nil } + return snapshot.projects.first { $0.id == id } + } + + /// Clean-&-idle worktrees currently hidden by the fold — surfaced as the + /// count on the header's "show quiet" affordance. + var quietWorktreeCount: Int { + snapshot.projects.reduce(0) { acc, project in + acc + project.worktrees.filter { !$0.hasActivity }.count + } + } + + // MARK: Lifecycle + + func start() { + guard pollTask == nil else { + refresh() + return + } + refresh() + let interval = pollInterval + pollTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + self?.refresh() + } + } + } + + func stop() { + pollTask?.cancel() + pollTask = nil + fetchTask?.cancel() + fetchTask = nil + isLoading = false + } + + func refresh() { + if fetchTask != nil { return } + isLoading = !hasLoaded + fetchTask = Task { [weak self] in + await self?.fetchSnapshot() + } + } + + private func fetchSnapshot() async { + defer { + isLoading = false + fetchTask = nil + } + // Preview path: with OPENSCOUT_REPOS_SAMPLE set, serve the fixture so the + // section renders without a broker that implements the snapshot endpoint. + if ScoutRepoSample.isEnabled, let sample = ScoutRepoSample.snapshot() { + snapshot = sample + hasLoaded = true + lastFetchedAt = Date() + lastError = nil + return + } + do { + let url = ScoutBroker.baseURL() + .appending(path: "v1/repo-watch/snapshot") + .appending(queryItems: [ + URLQueryItem(name: "includeDiff", value: "1"), + URLQueryItem(name: "includeLastCommit", value: "1"), + ]) + let next = try await fetch(RepoWatchSnapshot.self, from: url) + snapshot = next + hasLoaded = true + lastFetchedAt = Date() + lastError = nil + } catch { + lastError = Self.userFacingError(error) + } + } + + private func fetch(_ type: T.Type, from url: URL) async throws -> T { + let (data, response) = try await URLSession.shared.data(from: url) + guard let http = response as? HTTPURLResponse else { + throw ScoutRepoError.invalidResponse + } + guard (200..<300).contains(http.statusCode) else { + throw ScoutRepoError.httpStatus(http.statusCode) + } + return try decoder.decode(type, from: data) + } + + private static func userFacingError(_ error: Error) -> String { + if let repoError = error as? ScoutRepoError { + return repoError.localizedDescription + } + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain { + switch nsError.code { + case NSURLErrorCannotConnectToHost, NSURLErrorNotConnectedToInternet, NSURLErrorTimedOut: + return "Could not connect to the Scout broker." + default: + break + } + } + return error.localizedDescription + } +} + +/// The lens the Repos worktree rows read through — one model, two readings. +enum ReposLens: String, CaseIterable, Sendable { + case table + case drift + + var label: String { + switch self { + case .table: return "Table" + case .drift: return "Drift" + } + } +} + +enum ScoutRepoError: LocalizedError { + case invalidResponse + case httpStatus(Int) + + var errorDescription: String? { + switch self { + case .invalidResponse: + return "Scout returned an invalid repo-watch response." + case .httpStatus(let status): + return "Repo Watch returned HTTP \(status)." + } + } +} diff --git a/apps/macos/Sources/Scout/ScoutReposView.swift b/apps/macos/Sources/Scout/ScoutReposView.swift new file mode 100644 index 00000000..37cfe952 --- /dev/null +++ b/apps/macos/Sources/Scout/ScoutReposView.swift @@ -0,0 +1,1471 @@ +import HudsonUI +import SwiftUI + +/// The **Repos** section — a keyboard-first repo→worktree tree fed by the +/// broker's Repo Watch snapshot. +/// +/// The spine is `repo → worktree`; agents and sessions are *attributes* on a +/// worktree, never the organizing axis. Ordering is attention-first everywhere +/// (`ScoutRepoStore.projects` is already sorted worst-first; worktrees re-sort +/// the same way inside the tree), and clean-&-idle worktrees fold away behind +/// the header's "quiet" toggle so unfinished work floats to the top. +/// +/// Interaction mirrors `ScoutAgentsTree` exactly — j/k step, h/l fold-or-parent +/// / expand-or-descend, g/⇧G to the edges, and the inspector follows the +/// cursor — so the two trees read as one system. Two lenses (Table, Drift) read +/// the *same* model: Table leads with churn/files/agents, Drift swaps the +/// trailing cluster for ahead/behind/upstream. + +// MARK: - Metrics + +private enum ScoutReposMetrics { + static let pageGutter: CGFloat = 20 + static let rowLeadingBase: CGFloat = 10 + static let indentStep: CGFloat = 16 + static let chevronSlot: CGFloat = 12 + + // Table columns — fixed widths so CHURN · FILES · DRIFT · AGENTS align down + // the list (the name column flexes), mirroring the web grid. + static let gaugeWidth: CGFloat = 56 + static let positionCellWidth: CGFloat = 104 + static let secondLineInset: CGFloat = 34 + + static let rowHeight: CGFloat = 30 + static let churnColWidth: CGFloat = 104 + static let filesColWidth: CGFloat = 44 + static let driftColWidth: CGFloat = 104 + static let agentsColWidth: CGFloat = 132 + static let touchedColWidth: CGFloat = 56 +} + +// MARK: - Severity → tone (one vocabulary, shared by tree + inspector) + +func reposAttentionColor(_ attention: RepoAttention) -> Color { + switch attention { + case .critical: return ScoutPalette.statusError + case .attention: return ScoutPalette.statusWarn + case .active: return ScoutPalette.accent + case .quiet: return ScoutPalette.dim + case .unknown: return ScoutPalette.muted + } +} + +func reposAttentionLive(_ attention: RepoAttention) -> Bool { + attention == .critical || attention == .attention || attention == .active +} + +func reposStateColor(_ state: RepoWorktreeState) -> Color { + switch state { + case .error: return ScoutPalette.statusError + case .live: return ScoutPalette.accent + case .dirty: return ScoutPalette.statusWarn + case .clean: return ScoutPalette.dim + } +} + +/// Tint for a `RepoWorktree.driftFlag` pill (`SCAN ERR`, `DIVERGED`, `REBASE`, +/// `AHEAD N`, `IN SYNC`). +func reposDriftTint(_ flag: String) -> Color { + if flag.hasPrefix("SCAN") { return ScoutPalette.statusError } + if flag == "DIVERGED" { return ScoutPalette.statusError } + if flag == "REBASE" { return ScoutPalette.statusWarn } + if flag.hasPrefix("AHEAD") { return ScoutPalette.statusInfo } + return ScoutPalette.muted +} + +// MARK: - Sort (mirrors the web table) + +enum ReposSortKey: String, CaseIterable, Sendable { + case attention, name, churn, files, drift, agents, touched + + var defaultDir: ReposSortDir { self == .name ? .asc : .desc } + + /// Column header label (the `name` column carries the repo/branch spine). + var label: String { + switch self { + case .attention: return "ATTN" + case .name: return "REPO / BRANCH · WORKTREE" + case .churn: return "CHURN" + case .files: return "FILES" + case .drift: return "DRIFT" + case .agents: return "AGENTS" + case .touched: return "TOUCHED" + } + } +} + +enum ReposSortDir: Sendable { case asc, desc } + +// MARK: - Tree model (repo → worktree) + +@MainActor +final class ScoutReposTreeModel: ObservableObject { + /// Projects default to expanded (empty = none collapsed) so the tree opens + /// at repo→worktree. + @Published var collapsedProjects: Set = [] + + @Published private(set) var selectedID: String? + @Published private(set) var selectedProjectID: String? + @Published private(set) var selectedWorktreeID: String? + + /// Sort key + direction, default attention/desc (worst-first). Clicking a + /// column header toggles direction or switches key — mirrors the web table. + @Published private(set) var sortKey: ReposSortKey = .attention + @Published private(set) var sortDir: ReposSortDir = .desc + + func toggleSort(_ key: ReposSortKey) { + if key == sortKey { + sortDir = sortDir == .asc ? .desc : .asc + } else { + sortKey = key + sortDir = key.defaultDir + } + } + + // MARK: Sort scoring (the web `sortRows` twin) + + private var sortSign: Double { sortDir == .asc ? 1 : -1 } + + /// Per-worktree score for the active key (higher = more significant); the + /// sign of `sortDir` flips it. `name` is handled by a string compare. + private func score(_ wt: RepoWorktree) -> Double { + switch sortKey { + case .attention: return Double(-wt.attention.rank) + case .churn: return Double(wt.churn.total) + case .files: return Double(wt.status.changedFiles) + case .drift: return Double(wt.branch.ahead + wt.branch.behind) + case .agents: + let live = wt.uniqueAgents.contains { $0.live } ? 1.0 : 0.0 + return live * 1_000_000 + Double(wt.uniqueAgents.count) + case .touched: return wt.lastTouchedAt ?? 0 + case .name: return 0 + } + } + + private func projectScore(_ project: RepoProject) -> Double { + project.worktrees.map { score($0) }.max() ?? -.greatestFiniteMagnitude + } + + private func compareNames(_ a: String, _ b: String) -> Double { + switch a.localizedCaseInsensitiveCompare(b) { + case .orderedAscending: return -1 + case .orderedDescending: return 1 + default: return 0 + } + } + + private func cmpWorktree(_ a: RepoWorktree, _ b: RepoWorktree) -> Double { + if sortKey == .name { + return compareNames(a.branchParts.leaf, b.branchParts.leaf) * sortSign + } + let d = (score(a) - score(b)) * sortSign + if d != 0 { return d } + let ar = Double(a.attention.rank - b.attention.rank) + if ar != 0 { return ar } + return compareNames(a.name, b.name) + } + + private func cmpProject(_ a: RepoProject, _ b: RepoProject) -> Double { + if sortKey == .name { + return compareNames(a.name, b.name) * sortSign + } + let d = (projectScore(a) - projectScore(b)) * sortSign + if d != 0 { return d } + return compareNames(a.name, b.name) + } + + /// Projects ordered by the active sort (project score = its worst worktree), + /// keeping each repo's worktrees adjacent — the web grouping rule. + func sortedProjects(_ projects: [RepoProject]) -> [RepoProject] { + projects.sorted { cmpProject($0, $1) < 0 } + } + + struct Row: Identifiable, Equatable { + enum Kind: Equatable { + case project(String) + case worktree(project: String, id: String) + } + + let kind: Kind + let depth: Int + + var id: String { + switch kind { + case .project(let p): return "p:\(p)" + case .worktree(_, let w): return "w:\(w)" + } + } + + var collapsible: Bool { + if case .worktree = kind { return false } + return true + } + + var worktreeID: String? { + if case .worktree(_, let w) = kind { return w } + return nil + } + + var projectID: String? { + switch kind { + case .project(let p): return p + case .worktree(let p, _): return p + } + } + } + + /// Worktrees ordered by the active sort, with clean-&-idle folded out unless + /// `showClean`. + func visibleWorktrees(_ project: RepoProject, showClean: Bool) -> [RepoWorktree] { + let sorted = project.worktrees.sorted { cmpWorktree($0, $1) < 0 } + return showClean ? sorted : sorted.filter { $0.hasActivity } + } + + func rows(_ projects: [RepoProject], showClean: Bool) -> [Row] { + var out: [Row] = [] + for project in sortedProjects(projects) { + out.append(Row(kind: .project(project.id), depth: 0)) + if collapsedProjects.contains(project.id) { continue } + for worktree in visibleWorktrees(project, showClean: showClean) { + out.append(Row(kind: .worktree(project: project.id, id: worktree.id), depth: 1)) + } + } + return out + } + + func selectRow(_ row: Row, projects: [RepoProject]) { + selectedID = row.id + selectedWorktreeID = row.worktreeID + selectedProjectID = row.projectID + } + + private func currentRow(_ projects: [RepoProject], _ showClean: Bool) -> Row? { + let rows = rows(projects, showClean: showClean) + return rows.first { $0.id == selectedID } ?? rows.first + } + + func move(_ delta: Int, projects: [RepoProject], showClean: Bool) { + let rows = rows(projects, showClean: showClean) + guard !rows.isEmpty else { return } + let current = rows.firstIndex { $0.id == selectedID } ?? 0 + let next = min(max(current + delta, 0), rows.count - 1) + selectRow(rows[next], projects: projects) + } + + func moveToEdge(last: Bool, projects: [RepoProject], showClean: Bool) { + let rows = rows(projects, showClean: showClean) + guard let target = last ? rows.last : rows.first else { return } + selectRow(target, projects: projects) + } + + func expandOrDescend(projects: [RepoProject], showClean: Bool) { + guard let row = currentRow(projects, showClean) else { return } + switch row.kind { + case .project(let id): + if collapsedProjects.contains(id) { + collapsedProjects.remove(id) + } else { + move(1, projects: projects, showClean: showClean) + } + case .worktree: + move(1, projects: projects, showClean: showClean) + } + } + + func collapseOrParent(projects: [RepoProject], showClean: Bool) { + let rows = rows(projects, showClean: showClean) + guard let row = rows.first(where: { $0.id == selectedID }) ?? rows.first else { return } + switch row.kind { + case .project(let id): + collapsedProjects.insert(id) + case .worktree(let projectID, _): + if let parent = rows.first(where: { $0.id == "p:\(projectID)" }) { + selectRow(parent, projects: projects) + } + } + } + + func toggle(_ row: Row) { + if case .project(let id) = row.kind { + if collapsedProjects.contains(id) { + collapsedProjects.remove(id) + } else { + collapsedProjects.insert(id) + } + } + } + + func isExpanded(_ row: Row) -> Bool { + if case .project(let id) = row.kind { return !collapsedProjects.contains(id) } + return false + } + + /// Seed (or repair) the selection when the snapshot first lands or the + /// visible set shifts under the cursor. + func ensureSelection(projects: [RepoProject], showClean: Bool) { + let rows = rows(projects, showClean: showClean) + if let selectedID, rows.contains(where: { $0.id == selectedID }) { return } + if let first = rows.first { selectRow(first, projects: projects) } + } +} + +// MARK: - Section content (header + tree) + +struct ScoutReposContent: View { + @ObservedObject var repos: ScoutRepoStore + @ObservedObject var tree: ScoutReposTreeModel + /// Enter / double-click on the focused row (reveal the path in Finder). + let onActivate: () -> Void + + var body: some View { + VStack(spacing: 0) { + header + if let error = repos.lastError { + errorBanner(error) + } + columnHeader + treeScroll + } + .background(ScoutDesign.bg) + } + + // MARK: Sortable column header + + private var columnHeader: some View { + HStack(spacing: 0) { + sortButton(.name, edge: .leading) + .frame(maxWidth: .infinity, alignment: .leading) + sortButton(.churn, edge: .trailing) + .frame(width: ScoutReposMetrics.churnColWidth, alignment: .trailing) + sortButton(.files, edge: .center) + .frame(width: ScoutReposMetrics.filesColWidth, alignment: .center) + sortButton(.drift, edge: .center) + .frame(width: ScoutReposMetrics.driftColWidth, alignment: .center) + sortButton(.agents, edge: .leading) + .frame(width: ScoutReposMetrics.agentsColWidth, alignment: .leading) + sortButton(.touched, edge: .trailing) + .frame(width: ScoutReposMetrics.touchedColWidth, alignment: .trailing) + } + .padding(.horizontal, ScoutReposMetrics.pageGutter) + .frame(height: 26) + .background(ScoutDesign.bg) + .overlay(alignment: .top) { HudDivider(color: ScoutDesign.hairline) } + .overlay(alignment: .bottom) { HudDivider(color: ScoutDesign.hairlineStrong) } + } + + private enum SortEdge { case leading, center, trailing } + + private func sortButton(_ key: ReposSortKey, edge: SortEdge) -> some View { + let on = tree.sortKey == key + return Button { + withAnimation(.easeOut(duration: 0.14)) { tree.toggleSort(key) } + } label: { + HStack(spacing: 4) { + if edge == .trailing { caret(on: on) } + Text(key.label) + .font(HudFont.mono(HudTextSize.micro, weight: on ? .bold : .medium)) + .tracking(0.7) + .foregroundStyle(on ? ScoutPalette.ink : ScoutPalette.dim) + if edge != .trailing { caret(on: on) } + } + } + .buttonStyle(.plain) + .scoutPointerCursor() + } + + @ViewBuilder + private func caret(on: Bool) -> some View { + Image(systemName: tree.sortDir == .asc ? "arrowtriangle.up.fill" : "arrowtriangle.down.fill") + .font(.system(size: 6)) + .foregroundStyle(ScoutPalette.muted) + .opacity(on ? 1 : 0) + } + + // MARK: Header + + private var header: some View { + ScoutColumnHeader(horizontalPadding: ScoutReposMetrics.pageGutter) { + titleCluster + } secondary: { + lensStrip + } trailing: { + commandStrip + } + .background(ScoutDesign.bg) + .overlay(alignment: .bottom) { + HudDivider(color: ScoutDesign.hairlineStrong) + } + } + + private var titleCluster: some View { + let totals = repos.totals + return HStack(spacing: HudSpacing.sm) { + Text("Repos") + .font(HudFont.ui(HudTextSize.xl, weight: .semibold)) + .foregroundStyle(ScoutPalette.ink) + + statusPill + + countCluster(totals.projects, "repos") + countCluster(totals.worktrees, "trees") + if totals.dirtyWorktrees > 0 { + countCluster(totals.dirtyWorktrees, "dirty", tint: ScoutPalette.statusWarn) + } + if totals.attentionWorktrees > 0 { + countCluster(totals.attentionWorktrees, "attn", tint: ScoutPalette.statusError) + } + + if repos.isLoading { + ProgressView() + .controlSize(.small) + .scaleEffect(0.86) + } + } + .fixedSize(horizontal: true, vertical: false) + } + + @ViewBuilder private var statusPill: some View { + if repos.lastError != nil { + HudBadge("Error", tint: ScoutPalette.statusError, dot: true) + } else if !repos.hasLoaded { + HudBadge("Scanning", tint: ScoutPalette.statusWarn, dot: true) + } else { + HudBadge("Live", tint: ScoutPalette.statusOk, dot: true) + } + } + + private func countCluster(_ value: Int, _ label: String, tint: Color? = nil) -> some View { + HStack(spacing: HudSpacing.xxs) { + Text("\(value)") + .font(HudFont.mono(HudTextSize.xs, weight: .semibold)) + .foregroundStyle(tint ?? ScoutPalette.ink) + .monospacedDigit() + Text(label) + .font(HudFont.ui(HudTextSize.xs, weight: .medium)) + .foregroundStyle(ScoutPalette.dim) + } + } + + private var lensStrip: some View { + HStack(spacing: HudSpacing.xs) { + ForEach(ReposLens.allCases, id: \.self) { lens in + Button { + repos.lens = lens + } label: { + Text(lens.label.uppercased()) + .font(HudFont.mono(HudTextSize.micro, weight: .bold)) + .tracking(0.6) + .foregroundStyle(repos.lens == lens ? ScoutPalette.ink : ScoutPalette.dim) + .padding(.horizontal, HudSpacing.sm) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: HudRadius.tight) + .fill(repos.lens == lens ? ScoutPalette.accentSoft : Color.clear) + ) + } + .buttonStyle(.plain) + .scoutPointerCursor() + } + } + } + + private var commandStrip: some View { + HStack(spacing: HudSpacing.md) { + if repos.quietWorktreeCount > 0 || repos.showCleanIdle { + Button { + withAnimation(.easeOut(duration: 0.16)) { repos.showCleanIdle.toggle() } + } label: { + HStack(spacing: HudSpacing.xxs) { + Image(systemName: repos.showCleanIdle ? "eye.fill" : "eye.slash") + .font(.system(size: 9, weight: .semibold)) + Text(repos.showCleanIdle ? "Quiet shown" : "Quiet \(repos.quietWorktreeCount)") + } + .font(HudFont.mono(HudTextSize.xxs, weight: .medium)) + .foregroundStyle(repos.showCleanIdle ? ScoutPalette.ink : ScoutPalette.dim) + } + .buttonStyle(.plain) + .scoutPointerCursor() + } + + Button { + repos.refresh() + } label: { + Image(systemName: "arrow.clockwise") + .font(HudFont.ui(HudTextSize.xs, weight: .semibold)) + .foregroundStyle(ScoutPalette.dim) + } + .buttonStyle(.plain) + .scoutPointerCursor() + .help("Rescan repositories") + } + } + + private func errorBanner(_ error: String) -> some View { + HStack(spacing: HudSpacing.sm) { + Image(systemName: "exclamationmark.triangle.fill") + .font(HudFont.ui(HudTextSize.xs, weight: .semibold)) + Text(error) + .font(HudFont.ui(HudTextSize.xs, weight: .medium)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 0) + } + .foregroundStyle(ScoutPalette.statusError) + .padding(.horizontal, ScoutReposMetrics.pageGutter) + .frame(height: 26) + .background(ScoutDesign.chrome) + .overlay(alignment: .bottom) { + HudDivider(color: ScoutDesign.hairline) + } + } + + // MARK: Tree + + private var treeScroll: some View { + ScrollViewReader { proxy in + ScrollView { + if repos.projects.isEmpty { + emptyState + .frame(maxWidth: .infinity, minHeight: 320) + } else { + ScoutReposTree( + model: tree, + projects: repos.projects, + generatedAt: repos.generatedAt, + showClean: repos.showCleanIdle, + lens: repos.lens, + onActivate: onActivate + ) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + .onChange(of: tree.selectedID) { _, id in + guard let id else { return } + withAnimation(.easeOut(duration: 0.1)) { proxy.scrollTo(id) } + } + .onAppear { + tree.ensureSelection(projects: repos.projects, showClean: repos.showCleanIdle) + } + .onChange(of: repos.projects.count) { _, _ in + tree.ensureSelection(projects: repos.projects, showClean: repos.showCleanIdle) + } + .onChange(of: repos.showCleanIdle) { _, _ in + tree.ensureSelection(projects: repos.projects, showClean: repos.showCleanIdle) + } + } + } + + @ViewBuilder private var emptyState: some View { + if !repos.hasLoaded { + VStack(spacing: HudSpacing.md) { + ProgressView().controlSize(.small) + Text("Scanning repositories…") + .font(HudFont.ui(HudTextSize.sm, weight: .medium)) + .foregroundStyle(ScoutPalette.muted) + } + } else { + HudEmptyState( + title: "No repositories", + subtitle: "Repo Watch found no git repositories in your tracked roots.", + icon: "arrow.triangle.branch" + ) + } + } +} + +// MARK: - Tree rows + +struct ScoutReposTree: View { + @ObservedObject var model: ScoutReposTreeModel + let projects: [RepoProject] + let generatedAt: Double + let showClean: Bool + let lens: ReposLens + let onActivate: () -> Void + + @Namespace private var selectionNamespace + private static let selectionMatchID = "repos.selection" + @State private var hoveredID: String? + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var rows: [ScoutReposTreeModel.Row] { model.rows(projects, showClean: showClean) } + private var projectsByID: [String: RepoProject] { + Dictionary(projects.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + } + private var moveAnimation: Animation? { + reduceMotion ? nil : .spring(response: 0.34, dampingFraction: 0.86) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(rows) { row in + rowView(row) + .id(row.id) + .transition(.opacity) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .animation(moveAnimation, value: model.selectedID) + } + + @ViewBuilder + private func rowView(_ row: ScoutReposTreeModel.Row) -> some View { + let selected = row.id == model.selectedID + let hovered = row.id == hoveredID + let isProject = row.worktreeID == nil + + HStack(spacing: 0) { + content(for: row) + } + .padding(.horizontal, ScoutReposMetrics.pageGutter) + .frame(height: ScoutReposMetrics.rowHeight) + .frame(maxWidth: .infinity, alignment: .leading) + .background(alignment: .leading) { + if selected { + ZStack(alignment: .leading) { + ScoutPalette.accent.opacity(0.10) + Rectangle().fill(ScoutPalette.accent).frame(width: 2) + } + .matchedGeometryEffect(id: Self.selectionMatchID, in: selectionNamespace) + } else if hovered { + ScoutPalette.surface + } else if isProject { + ScoutPalette.chrome.opacity(0.4) + } + } + .overlay(alignment: .bottom) { + Rectangle().fill(ScoutPalette.hairline).frame(height: 1) + } + .contentShape(Rectangle()) + .onHover { inside in hoveredID = inside ? row.id : (hoveredID == row.id ? nil : hoveredID) } + .onTapGesture(count: 2) { activate(row) } + .onTapGesture { select(row) } + } + + /// One grid row: a flexible name cell + the four fixed columns (CHURN · + /// FILES · DRIFT · AGENTS), so they align down the list and under the + /// sortable header. Project rows aggregate; worktree rows are per-tree. + @ViewBuilder + private func content(for row: ScoutReposTreeModel.Row) -> some View { + switch row.kind { + case .project(let id): + if let project = projectsByID[id] { + projectNameCell(project) + .frame(maxWidth: .infinity, alignment: .leading) + projectChurnCol(project) + Color.clear.frame(width: ScoutReposMetrics.filesColWidth) + Color.clear.frame(width: ScoutReposMetrics.driftColWidth) + projectAgentsCol(project) + projectTouchedCol(project) + } + case .worktree(let projectID, let worktreeID): + if let project = projectsByID[projectID], + let wt = project.worktrees.first(where: { $0.id == worktreeID }) { + worktreeNameCell(wt, isLast: isLastWorktree(wt, in: project)) + .frame(maxWidth: .infinity, alignment: .leading) + churnCol(wt) + filesCol(wt) + positionCell(wt) + agentsCol(wt) + touchedCol(wt) + } + } + } + + /// TOUCHED column — relative time since the worktree was last edited + /// (working-tree mtime), so you can sort the fleet by recency of work. + private func touchedCol(_ wt: RepoWorktree) -> some View { + Text(wt.lastTouchedAgo(generatedAt: generatedAt) ?? "—") + .font(HudFont.mono(HudTextSize.xxs)) + .foregroundStyle(ScoutPalette.dim) + .contentTransition(.numericText()) + .frame(width: ScoutReposMetrics.touchedColWidth, alignment: .trailing) + } + + private func projectTouchedCol(_ project: RepoProject) -> some View { + let newest = project.worktrees.compactMap { $0.lastTouchedAt }.max() + return Text(newest.map { RepoRelativeTime.ago(fromMillis: $0, now: generatedAt) } ?? "—") + .font(HudFont.mono(HudTextSize.xxs)) + .foregroundStyle(ScoutPalette.dim) + .frame(width: ScoutReposMetrics.touchedColWidth, alignment: .trailing) + } + + private func isLastWorktree(_ wt: RepoWorktree, in project: RepoProject) -> Bool { + model.visibleWorktrees(project, showClean: showClean).last?.id == wt.id + } + + @ViewBuilder + private func projectNameCell(_ project: RepoProject) -> some View { + HStack(spacing: HudSpacing.sm) { + chevron(for: ScoutReposTreeModel.Row(kind: .project(project.id), depth: 0)) + ScoutRepoStateDot( + color: reposAttentionColor(project.attention), + live: reposAttentionLive(project.attention), + size: 7 + ) + Text(project.name) + .font(HudFont.ui(HudTextSize.sm, weight: .semibold)) + .foregroundStyle(ScoutPalette.ink) + .lineLimit(1) + Text(repoShortPath(project.root, segments: 3)) + .font(HudFont.mono(HudTextSize.xxs)) + .foregroundStyle(ScoutPalette.dim) + .lineLimit(1) + .truncationMode(.middle) + repoTag("\(project.worktrees.count) wt", tint: ScoutPalette.muted) + Spacer(minLength: HudSpacing.sm) + } + } + + @ViewBuilder + private func projectChurnCol(_ project: RepoProject) -> some View { + let add = project.worktrees.reduce(0) { $0 + $1.churn.add } + let del = project.worktrees.reduce(0) { $0 + $1.churn.del } + Group { + if add > 0 || del > 0 { + HStack(spacing: 3) { + Text("+\(add)").foregroundStyle(ScoutPalette.statusOk.opacity(0.85)) + Text("−\(del)").foregroundStyle(ScoutPalette.statusError.opacity(0.85)) + } + .monospacedDigit() + } else { + Text("—").foregroundStyle(ScoutPalette.dim) + } + } + .font(HudFont.mono(HudTextSize.micro)) + .frame(width: ScoutReposMetrics.churnColWidth, alignment: .trailing) + } + + @ViewBuilder + private func projectAgentsCol(_ project: RepoProject) -> some View { + let live = project.worktrees.reduce(0) { $0 + $1.uniqueAgents.filter { $0.live }.count } + Group { + if live > 0 { + Text("\(live) live").foregroundStyle(ScoutPalette.accent) + } else if project.stats.attachedAgents > 0 { + Text("\(project.stats.attachedAgents) idle").foregroundStyle(ScoutPalette.dim) + } else { + Text("") + } + } + .font(HudFont.mono(HudTextSize.micro)) + .frame(width: ScoutReposMetrics.agentsColWidth, alignment: .leading) + } + + /// Driftline worktree row — identity (state dot + branch) on the left, then + /// three fixed-width columns (POSITION gauge · WORK · LAST) so they align + /// down the list. Active worktrees gain a quiet second line: upstream, a + /// descriptive position phrase, and any agents — never a harsh verdict pill. + /// Worktree name cell — tree guide, state dot, branch label, and any quiet + /// tags (SCAN ERR / DETACHED / LOCAL). The four columns to its right carry + /// churn, files, the drift gauge, and agents. + @ViewBuilder + private func worktreeNameCell(_ wt: RepoWorktree, isLast: Bool) -> some View { + HStack(spacing: HudSpacing.sm) { + RepoTreeGuide(isLast: isLast) + ScoutRepoStateDot(color: reposStateColor(wt.state), live: wt.state == .live, size: 7) + branchLabel(wt.branchParts) + tags(wt) + Spacer(minLength: HudSpacing.sm) + } + } + + @ViewBuilder + private func tags(_ wt: RepoWorktree) -> some View { + if wt.error != nil { + repoTag("SCAN ERR", tint: ScoutPalette.statusError) + } + if wt.branch.detached { + repoTag("DETACHED", tint: ScoutPalette.muted) + } else if wt.branch.upstream == nil && !wt.branch.isMain { + repoTag("LOCAL", tint: ScoutPalette.dim) + } + } + + private func repoTag(_ text: String, tint: Color) -> some View { + Text(text) + .font(HudFont.mono(HudTextSize.micro, weight: .bold)) + .tracking(0.4) + .foregroundStyle(tint) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(RoundedRectangle(cornerRadius: HudRadius.tight).fill(tint.opacity(0.12))) + } + + @ViewBuilder + private func branchLabel(_ parts: RepoBranchParts) -> some View { + HStack(spacing: 0) { + if !parts.prefix.isEmpty { + Text(parts.prefix).foregroundStyle(ScoutPalette.dim) + } + Text(parts.leaf).foregroundStyle(ScoutPalette.ink) + } + .font(HudFont.mono(HudTextSize.sm, weight: parts.detached ? .regular : .medium)) + .lineLimit(1) + .truncationMode(.middle) + } + + /// POSITION column — the hero. A calm behind◀upstream▶ahead gauge flanked by + /// the exact counts. Degrades to a quiet token for scan errors / detached / + /// upstream-less branches instead of an alarm. + @ViewBuilder + private func positionCell(_ wt: RepoWorktree) -> some View { + Group { + if wt.error != nil { + Text("scan failed") + .font(HudFont.mono(HudTextSize.micro)) + .foregroundStyle(ScoutPalette.dim) + } else if wt.branch.detached { + Text("@" + String((wt.branch.head ?? "").prefix(7))) + .font(HudFont.mono(HudTextSize.micro)) + .foregroundStyle(ScoutPalette.muted) + } else if wt.branch.upstream == nil && !wt.branch.isMain { + Text("local") + .font(HudFont.mono(HudTextSize.micro)) + .foregroundStyle(ScoutPalette.dim) + } else { + HStack(spacing: 3) { + Text(wt.branch.behind > 0 ? "↓\(wt.branch.behind)" : "") + .foregroundStyle(ScoutPalette.muted) + .frame(width: 20, alignment: .trailing) + RepoDriftGauge(ahead: wt.branch.ahead, behind: wt.branch.behind, width: ScoutReposMetrics.gaugeWidth) + Text(wt.branch.ahead > 0 ? "↑\(wt.branch.ahead)" : "") + .foregroundStyle(ScoutPalette.accent) + .frame(width: 20, alignment: .leading) + } + .font(HudFont.mono(HudTextSize.micro, weight: .semibold)) + .monospacedDigit() + } + } + .frame(width: ScoutReposMetrics.positionCellWidth, alignment: .center) + } + + /// CHURN column — +adds/−dels with a proportional split bar (web `Churn`). + @ViewBuilder + private func churnCol(_ wt: RepoWorktree) -> some View { + Group { + if wt.churn.has { + HStack(spacing: HudSpacing.xs) { + HStack(spacing: 3) { + Text("+\(wt.churn.add)").foregroundStyle(ScoutPalette.statusOk) + Text("−\(wt.churn.del)").foregroundStyle(ScoutPalette.statusError) + } + .monospacedDigit() + RepoChurnBar(add: wt.churn.add, del: wt.churn.del, width: 38) + } + } else { + Text("—").foregroundStyle(ScoutPalette.dim) + } + } + .font(HudFont.mono(HudTextSize.micro)) + .frame(width: ScoutReposMetrics.churnColWidth, alignment: .trailing) + } + + /// FILES column — changed-file count with a conflict warning when present. + @ViewBuilder + private func filesCol(_ wt: RepoWorktree) -> some View { + Group { + if wt.status.changedFiles > 0 { + HStack(spacing: 2) { + Text("\(wt.status.changedFiles)").foregroundStyle(ScoutPalette.muted) + if wt.status.conflicts > 0 { + Text("⚠\(wt.status.conflicts)").foregroundStyle(ScoutPalette.statusWarn) + } + } + .monospacedDigit() + } else { + Text("—").foregroundStyle(ScoutPalette.dim) + } + } + .font(HudFont.mono(HudTextSize.xxs)) + .frame(width: ScoutReposMetrics.filesColWidth, alignment: .center) + } + + /// AGENTS column — a live badge + up to two handles + overflow (web `Agents`). + @ViewBuilder + private func agentsCol(_ wt: RepoWorktree) -> some View { + Group { + if wt.uniqueAgents.isEmpty { + Text("—").foregroundStyle(ScoutPalette.dim) + } else { + let liveCount = wt.uniqueAgents.filter { $0.live }.count + HStack(spacing: HudSpacing.xs) { + if liveCount > 0 { + HStack(spacing: 2) { + Circle().fill(ScoutPalette.accent).frame(width: 5, height: 5) + Text("\(liveCount)").foregroundStyle(ScoutPalette.accent) + } + } + Text(wt.uniqueAgents.prefix(2).map { $0.handle }.joined(separator: " ")) + .foregroundStyle(ScoutPalette.muted) + .lineLimit(1) + .truncationMode(.tail) + if wt.uniqueAgents.count > 2 { + Text("+\(wt.uniqueAgents.count - 2)").foregroundStyle(ScoutPalette.dim) + } + } + } + } + .font(HudFont.mono(HudTextSize.micro)) + .frame(width: ScoutReposMetrics.agentsColWidth, alignment: .leading) + } + + private func chevron(for row: ScoutReposTreeModel.Row) -> some View { + Button { + withAnimation(reduceMotion ? nil : .spring(response: 0.32, dampingFraction: 0.82)) { + model.toggle(row) + } + } label: { + Image(systemName: "chevron.right") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(ScoutPalette.dim) + .rotationEffect(.degrees(model.isExpanded(row) ? 90 : 0)) + .frame(width: ScoutReposMetrics.chevronSlot, height: ScoutReposMetrics.chevronSlot) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .scoutPointerCursor() + } + + private func select(_ row: ScoutReposTreeModel.Row) { + model.selectRow(row, projects: projects) + } + + private func activate(_ row: ScoutReposTreeModel.Row) { + model.selectRow(row, projects: projects) + onActivate() + } +} + +// MARK: - Small primitives + +/// A calm, descriptive position phrase — a fact, never a verdict ("behind 2 · +/// ahead 5" instead of "DIVERGED"). Upstream / detached / error are surfaced by +/// the row separately. +func repoPositionPhrase(_ wt: RepoWorktree) -> String { + let b = wt.branch + if b.ahead > 0 && b.behind > 0 { return "behind \(b.behind) · ahead \(b.ahead)" } + if b.behind > 0 { return "behind \(b.behind)" } + if b.ahead > 0 { return "ahead \(b.ahead)" } + return "in sync" +} + +/// The behind◀upstream▶ahead position gauge — the redesign's hero. A base line +/// with a center tick at the fork point; a muted fill extends left for `behind` +/// commits and an accent fill extends right to a head marker for `ahead`. In +/// sync reads as a head dot sitting on the center line. Magnitudes are clamped +/// to `cap` commits; the exact counts live in the flanking labels. +struct RepoDriftGauge: View { + let ahead: Int + let behind: Int + var width: CGFloat = 56 + + private let cap = 10 + private let barHeight: CGFloat = 5 + + var body: some View { + let center = width / 2 + let half = width / 2 + let aheadLen = half * min(CGFloat(max(ahead, 0)), CGFloat(cap)) / CGFloat(cap) + let behindLen = half * min(CGFloat(max(behind, 0)), CGFloat(cap)) / CGFloat(cap) + ZStack(alignment: .leading) { + Capsule() + .fill(ScoutPalette.hairlineStrong) + .frame(width: width, height: 1.5) + if behind > 0 { + Capsule() + .fill(ScoutPalette.muted) + .frame(width: behindLen, height: barHeight) + .offset(x: center - behindLen) + } + if ahead > 0 { + Capsule() + .fill(ScoutPalette.accent) + .frame(width: aheadLen, height: barHeight) + .offset(x: center) + } + Rectangle() + .fill(ScoutPalette.dim) + .frame(width: 1, height: barHeight + 3) + .offset(x: center - 0.5) + Circle() + .fill(ahead > 0 ? ScoutPalette.accent : ScoutPalette.muted) + .frame(width: 5, height: 5) + .offset(x: center + aheadLen - 2.5) + } + .frame(width: width, height: barHeight + 4) + } +} + +/// Tree connector for an indented worktree — a faint vertical spine + an L-stub +/// into the row. The spine stops at the row's middle for the last worktree. +struct RepoTreeGuide: View { + var isLast: Bool = false + private let guideWidth: CGFloat = 16 + + var body: some View { + let h = ScoutReposMetrics.rowHeight + ZStack(alignment: .topLeading) { + Rectangle() + .fill(ScoutPalette.hairlineStrong) + .frame(width: 1, height: isLast ? h / 2 : h) + .offset(x: 6) + Rectangle() + .fill(ScoutPalette.hairlineStrong) + .frame(width: 7, height: 1) + .offset(x: 6, y: h / 2) + } + .frame(width: guideWidth, height: h) + } +} + +/// Proportional churn split bar — accent adds / error dels (web `.cbar`). +struct RepoChurnBar: View { + let add: Int + let del: Int + var width: CGFloat = 38 + + var body: some View { + let tot = CGFloat(max(add + del, 1)) + HStack(spacing: 0) { + Rectangle().fill(ScoutPalette.statusOk).frame(width: width * CGFloat(add) / tot) + Rectangle().fill(ScoutPalette.statusError).frame(width: width * CGFloat(del) / tot) + } + .frame(width: width, height: 3) + .clipShape(Capsule()) + .background(Capsule().fill(ScoutPalette.hairlineStrong)) + } +} + +/// State dot with the shared sonar-ping for live nodes (matches the Agents +/// tree's `ScoutTreeStateDot`, but keyed on a resolved color so both attention +/// and worktree-state rows reuse it). +struct ScoutRepoStateDot: View { + let color: Color + var live: Bool = false + var size: CGFloat = 7 + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var animate = false + + var body: some View { + Circle() + .fill(color) + .frame(width: size, height: size) + .overlay { + if live, !reduceMotion { + Circle() + .stroke(color, lineWidth: 1) + .scaleEffect(animate ? 2.2 : 1) + .opacity(animate ? 0 : 0.5) + } + } + .onAppear { + guard live, !reduceMotion else { return } + withAnimation(.easeOut(duration: 1.7).repeatForever(autoreverses: false)) { animate = true } + } + } +} + +struct ScoutRepoChurnLabel: View { + let churn: RepoChurn + + var body: some View { + HStack(spacing: 3) { + if churn.add > 0 { + Text("+\(churn.add)").foregroundStyle(ScoutPalette.statusOk) + } + if churn.del > 0 { + Text("−\(churn.del)").foregroundStyle(ScoutPalette.statusError) + } + } + .font(HudFont.mono(HudTextSize.xxs, weight: .semibold)) + .monospacedDigit() + } +} + +struct ScoutRepoAgentChip: View { + let agent: RepoAgentRef + + var body: some View { + Text(agent.initials) + .font(HudFont.mono(HudTextSize.micro, weight: .bold)) + .foregroundStyle(agent.live ? ScoutPalette.accent : ScoutPalette.muted) + .frame(minWidth: 16) + .padding(.horizontal, HudSpacing.xs) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: HudRadius.tight) + .fill(agent.live ? ScoutPalette.accentSoft : ScoutPalette.surface) + ) + .overlay( + RoundedRectangle(cornerRadius: HudRadius.tight) + .stroke(ScoutPalette.hairlineStrong, lineWidth: 0.5) + ) + } +} + +// MARK: - Inspector (Context pane — follows the cursor) + +struct ScoutReposInspector: View { + @ObservedObject var repos: ScoutRepoStore + @ObservedObject var tree: ScoutReposTreeModel + + var body: some View { + if let worktree = repos.worktree(id: tree.selectedWorktreeID) { + worktreeContext(worktree) + } else if let project = repos.project(id: tree.selectedProjectID) { + projectContext(project) + } else { + HudEmptyState( + title: "Nothing selected", + subtitle: "Select a repo or worktree to inspect.", + icon: "sidebar.right" + ) + } + } + + // MARK: Worktree context + + private func worktreeContext(_ worktree: RepoWorktree) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: HudSpacing.xl) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: HudSpacing.sm) { + ScoutRepoStateDot(color: reposStateColor(worktree.state), live: worktree.state == .live) + Text(worktree.branchParts.leaf) + .font(HudFont.ui(HudTextSize.lg, weight: .semibold)) + .foregroundStyle(ScoutPalette.ink) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + } + Text(repoShortPath(worktree.path, segments: 4)) + .font(HudFont.mono(HudTextSize.xxs)) + .foregroundStyle(ScoutPalette.dim) + .textSelection(.enabled) + } + + HudDivider(color: ScoutDesign.hairline) + + section("Position") { + positionBlock(worktree) + } + + section("Status") { + if worktree.status.clean && worktree.error == nil { + Text("Clean working tree") + .font(HudFont.ui(HudTextSize.xs, weight: .medium)) + .foregroundStyle(ScoutPalette.muted) + } else { + statusRows(worktree.status) + } + } + + if worktree.churn.has { + section("Churn") { + HStack(spacing: HudSpacing.md) { + Text("+\(worktree.churn.add)") + .foregroundStyle(ScoutPalette.statusOk) + Text("−\(worktree.churn.del)") + .foregroundStyle(ScoutPalette.statusError) + Text("\(worktree.churn.total) total") + .foregroundStyle(ScoutPalette.dim) + } + .font(HudFont.mono(HudTextSize.sm, weight: .semibold)) + .monospacedDigit() + } + } + + if !worktree.status.files.isEmpty { + section("Changed files (\(worktree.status.files.count))") { + changedFiles(worktree.status.files) + } + } + + section("Activity") { + keyValue("Last touched", worktree.lastTouchedAgo(generatedAt: repos.generatedAt).map { "\($0) ago" } ?? "—") + keyValue("Last commit", worktree.lastCommitAgo(generatedAt: repos.generatedAt).map { "\($0) ago" } ?? "—") + } + + if !worktree.uniqueAgents.isEmpty { + section("Agents (\(worktree.uniqueAgents.count))") { + agentRows(worktree.uniqueAgents) + } + } + + if !worktree.sessions.isEmpty { + section("Sessions (\(worktree.sessions.count))") { + sessionRows(worktree.sessions) + } + } + + if let error = worktree.error { + section("Scan error") { + VStack(alignment: .leading, spacing: HudSpacing.xs) { + Text("Scout ran git here and it failed — usually the folder was moved or deleted, or it isn't a git worktree anymore. The raw git output:") + .font(HudFont.ui(HudTextSize.xs)) + .foregroundStyle(ScoutPalette.muted) + .fixedSize(horizontal: false, vertical: true) + Text(error) + .font(HudFont.mono(HudTextSize.xxs)) + .foregroundStyle(ScoutPalette.statusError) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + .padding(.horizontal, ScoutReposMetrics.pageGutter) + .padding(.vertical, HudSpacing.lg) + } + } + + // MARK: Project context + + private func projectContext(_ project: RepoProject) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: HudSpacing.xl) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: HudSpacing.sm) { + ScoutRepoStateDot( + color: reposAttentionColor(project.attention), + live: reposAttentionLive(project.attention) + ) + Text(project.name) + .font(HudFont.ui(HudTextSize.lg, weight: .semibold)) + .foregroundStyle(ScoutPalette.ink) + .lineLimit(1) + Spacer(minLength: HudSpacing.sm) + HudBadge(project.attention.rawValue, tint: reposAttentionColor(project.attention)) + } + Text(repoShortPath(project.root, segments: 4)) + .font(HudFont.mono(HudTextSize.xxs)) + .foregroundStyle(ScoutPalette.dim) + .textSelection(.enabled) + } + + HudDivider(color: ScoutDesign.hairline) + + if !project.attentionReasons.isEmpty { + section("Why") { + VStack(alignment: .leading, spacing: HudSpacing.xxs) { + ForEach(project.attentionReasons, id: \.self) { reason in + Text("• \(reason)") + .font(HudFont.ui(HudTextSize.xs)) + .foregroundStyle(ScoutPalette.muted) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + + section("Worktrees") { + VStack(alignment: .leading, spacing: HudSpacing.xs) { + keyValue("Total", "\(project.stats.worktrees)") + if project.stats.dirtyWorktrees > 0 { + keyValue("Dirty", "\(project.stats.dirtyWorktrees)", tint: ScoutPalette.statusWarn) + } + if project.stats.conflictedWorktrees > 0 { + keyValue("Conflicted", "\(project.stats.conflictedWorktrees)", tint: ScoutPalette.statusError) + } + } + } + + section("Changes") { + VStack(alignment: .leading, spacing: HudSpacing.xs) { + keyValue("Staged", "\(project.stats.staged)", tint: project.stats.staged > 0 ? ScoutPalette.statusOk : nil) + keyValue("Unstaged", "\(project.stats.unstaged)", tint: project.stats.unstaged > 0 ? ScoutPalette.statusWarn : nil) + keyValue("Untracked", "\(project.stats.untracked)") + if project.stats.conflicts > 0 { + keyValue("Conflicts", "\(project.stats.conflicts)", tint: ScoutPalette.statusError) + } + } + } + + section("Attached") { + VStack(alignment: .leading, spacing: HudSpacing.xs) { + keyValue("Agents", "\(project.stats.attachedAgents)") + keyValue("Sessions", "\(project.stats.attachedSessions)") + } + } + } + .padding(.horizontal, ScoutReposMetrics.pageGutter) + .padding(.vertical, HudSpacing.lg) + } + } + + // MARK: Inspector building blocks + + @ViewBuilder + private func section(_ title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: HudSpacing.sm) { + HudSectionLabel(title) + content() + } + } + + private func keyValue(_ key: String, _ value: String, tint: Color? = nil) -> some View { + HStack(spacing: HudSpacing.md) { + Text(key) + .font(HudFont.ui(HudTextSize.xs, weight: .medium)) + .foregroundStyle(ScoutPalette.muted) + Spacer(minLength: HudSpacing.md) + Text(value) + .font(HudFont.mono(HudTextSize.xs, weight: .semibold)) + .foregroundStyle(tint ?? ScoutPalette.ink) + .monospacedDigit() + .lineLimit(1) + .truncationMode(.middle) + } + } + + @ViewBuilder + private func statusRows(_ status: RepoStatus) -> some View { + VStack(alignment: .leading, spacing: HudSpacing.xs) { + if status.staged > 0 { + keyValue("Staged", "\(status.staged)", tint: ScoutPalette.statusOk) + } + if status.unstaged > 0 { + keyValue("Unstaged", "\(status.unstaged)", tint: ScoutPalette.statusWarn) + } + if status.untracked > 0 { + keyValue("Untracked", "\(status.untracked)", tint: ScoutPalette.muted) + } + if status.conflicts > 0 { + keyValue("Conflicts", "\(status.conflicts)", tint: ScoutPalette.statusError) + } + keyValue("Changed files", "\(status.changedFiles)") + } + } + + /// The Position block — the gauge + a descriptive phrase + the branch facts, + /// leading the inspector the same way the row's POSITION column leads the list. + @ViewBuilder + private func positionBlock(_ wt: RepoWorktree) -> some View { + VStack(alignment: .leading, spacing: HudSpacing.sm) { + if wt.error != nil { + Text("Scan failed — position unavailable") + .font(HudFont.ui(HudTextSize.xs, weight: .medium)) + .foregroundStyle(ScoutPalette.muted) + } else { + HStack(spacing: HudSpacing.sm) { + Text(wt.branch.behind > 0 ? "↓\(wt.branch.behind)" : "") + .foregroundStyle(ScoutPalette.muted) + .frame(width: 24, alignment: .trailing) + RepoDriftGauge(ahead: wt.branch.ahead, behind: wt.branch.behind, width: 104) + Text(wt.branch.ahead > 0 ? "↑\(wt.branch.ahead)" : "") + .foregroundStyle(ScoutPalette.accent) + .frame(width: 24, alignment: .leading) + } + .font(HudFont.mono(HudTextSize.xs, weight: .semibold)) + .monospacedDigit() + Text(repoPositionPhrase(wt)) + .font(HudFont.ui(HudTextSize.sm, weight: .medium)) + .foregroundStyle(ScoutPalette.ink) + } + VStack(alignment: .leading, spacing: HudSpacing.xs) { + if let name = wt.branch.name { + keyValue(wt.branch.isMain ? "Branch · default" : "Branch", name) + } + if wt.branch.detached { + keyValue("Detached", String((wt.branch.head ?? "").prefix(7))) + } else if let upstream = wt.branch.upstream { + keyValue("Upstream", upstream) + } else { + keyValue("Upstream", "— local only") + } + if let head = wt.branch.head, !wt.branch.detached { + keyValue("HEAD", String(head.prefix(10))) + } + } + } + } + + @ViewBuilder + private func changedFiles(_ files: [RepoChangedFile]) -> some View { + VStack(alignment: .leading, spacing: HudSpacing.xxs) { + ForEach(Array(files.prefix(14))) { file in + HStack(spacing: HudSpacing.sm) { + Text(fileStatusTag(file.status)) + .font(HudFont.mono(HudTextSize.micro, weight: .bold)) + .tracking(0.4) + .foregroundStyle(fileStatusTint(file.status)) + .frame(width: 26, alignment: .leading) + Text(repoShortPath(file.path, segments: 3)) + .font(HudFont.mono(HudTextSize.xxs)) + .foregroundStyle(ScoutPalette.ink) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + } + } + if files.count > 14 { + Text("+\(files.count - 14) more") + .font(HudFont.mono(HudTextSize.xxs)) + .foregroundStyle(ScoutPalette.dim) + } + } + } + + @ViewBuilder + private func agentRows(_ agents: [RepoAgentRef]) -> some View { + VStack(alignment: .leading, spacing: HudSpacing.xs) { + ForEach(agents) { agent in + HStack(spacing: HudSpacing.sm) { + ScoutRepoAgentChip(agent: agent) + Text(agent.handle) + .font(HudFont.mono(HudTextSize.xxs)) + .foregroundStyle(ScoutPalette.ink) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: HudSpacing.sm) + Text(agent.stateWord) + .font(HudFont.mono(HudTextSize.micro, weight: .bold)) + .tracking(0.5) + .foregroundStyle(agent.live ? ScoutPalette.accent : ScoutPalette.dim) + } + } + } + } + + @ViewBuilder + private func sessionRows(_ sessions: [RepoSessionRef]) -> some View { + VStack(alignment: .leading, spacing: HudSpacing.xs) { + ForEach(sessions) { session in + HStack(spacing: HudSpacing.sm) { + Text(session.source ?? session.harness ?? "session") + .font(HudFont.mono(HudTextSize.xxs)) + .foregroundStyle(ScoutPalette.muted) + .lineLimit(1) + Spacer(minLength: HudSpacing.sm) + Text(String(session.id.prefix(8))) + .font(HudFont.mono(HudTextSize.micro)) + .foregroundStyle(ScoutPalette.dim) + } + } + } + } + + private func fileStatusTag(_ status: String) -> String { + switch status { + case "untracked": return "??" + case "conflict": return "!!" + case "staged": return "S" + case "unstaged": return "M" + case "staged+unstaged": return "SM" + default: return "•" + } + } + + private func fileStatusTint(_ status: String) -> Color { + switch status { + case "conflict": return ScoutPalette.statusError + case "staged": return ScoutPalette.statusOk + case "unstaged": return ScoutPalette.statusWarn + case "staged+unstaged": return ScoutPalette.statusInfo + case "untracked": return ScoutPalette.muted + default: return ScoutPalette.dim + } + } +} diff --git a/apps/macos/Sources/Scout/ScoutRootView.swift b/apps/macos/Sources/Scout/ScoutRootView.swift index 2b329a63..9c58a96d 100644 --- a/apps/macos/Sources/Scout/ScoutRootView.swift +++ b/apps/macos/Sources/Scout/ScoutRootView.swift @@ -11,6 +11,7 @@ import UniformTypeIdentifiers struct ScoutRootView: View { @StateObject private var store = ScoutCommsStore() @StateObject private var tail = ScoutTailStore() + @StateObject private var repos = ScoutRepoStore() @ObservedObject private var voice = ScoutVoiceService.shared @State private var section: ScoutSection = .comms @AppStorage("scout.navigationSidebar.compact") private var railCompact = false @@ -79,6 +80,10 @@ struct ScoutRootView: View { /// `store.selectedAgentId` so the inspector follows. @StateObject private var agentsTree = ScoutAgentsTreeModel() + /// Expansion + selection for the Repos repo·worktree tree. Same chords; the + /// inspector reads its selection directly. + @StateObject private var reposTree = ScoutReposTreeModel() + private var manifest: HudAppManifest { HudAppManifest( name: "Scout", @@ -151,10 +156,12 @@ struct ScoutRootView: View { .onAppear { store.start() tail.start() + repos.start() } .onDisappear { store.stop() tail.stop() + repos.stop() } .onChange(of: store.selectedCId) { oldCId, newCId in // Preserve the in-progress draft for the chat we're leaving and @@ -271,6 +278,8 @@ struct ScoutRootView: View { store.selectChannel(channels[next].cId) case .agents: treeMove(delta) + case .repos: + reposTreeMove(delta) case .tail: break } @@ -285,6 +294,8 @@ struct ScoutRootView: View { store.selectChannel(target.cId) case .agents: treeEdge(last: last) + case .repos: + reposTreeEdge(last: last) case .tail: break } @@ -327,18 +338,44 @@ struct ScoutRootView: View { pushTreeSelection() } + // MARK: Repos tree navigation + + private func reposTreeMove(_ delta: Int) { + reposTree.move(delta, projects: repos.projects, showClean: repos.showCleanIdle) + } + + private func reposTreeEdge(last: Bool) { + reposTree.moveToEdge(last: last, projects: repos.projects, showClean: repos.showCleanIdle) + } + /// `l` / → — expand a collapsed node, else descend. private func moveRight() { - guard section == .agents else { moveSelection(1); return } - withAnimation(.easeOut(duration: 0.16)) { agentsTree.expandOrDescend(groups: treeGroups) } - pushTreeSelection() + switch section { + case .agents: + withAnimation(.easeOut(duration: 0.16)) { agentsTree.expandOrDescend(groups: treeGroups) } + pushTreeSelection() + case .repos: + withAnimation(.easeOut(duration: 0.16)) { + reposTree.expandOrDescend(projects: repos.projects, showClean: repos.showCleanIdle) + } + default: + moveSelection(1) + } } /// `h` / ← — collapse an expanded node, else step to the parent. private func moveLeft() { - guard section == .agents else { moveSelection(-1); return } - withAnimation(.easeOut(duration: 0.16)) { agentsTree.collapseOrParent(groups: treeGroups) } - pushTreeSelection() + switch section { + case .agents: + withAnimation(.easeOut(duration: 0.16)) { agentsTree.collapseOrParent(groups: treeGroups) } + pushTreeSelection() + case .repos: + withAnimation(.easeOut(duration: 0.16)) { + reposTree.collapseOrParent(projects: repos.projects, showClean: repos.showCleanIdle) + } + default: + moveSelection(-1) + } } private func focusSearch() { @@ -360,6 +397,10 @@ struct ScoutRootView: View { /// Jump into the selected row's conversation (⌘↩, Agents page) — the focused /// session if a session row is selected, else the agent's channel. private func openSelectedAgentChannel() { + if section == .repos { + revealSelectedRepoInFinder() + return + } guard section == .agents else { return } if let cId = agentsTree.selectedSessionCId { store.selectChannel(cId) @@ -371,6 +412,21 @@ struct ScoutRootView: View { section = .comms } + /// ⌘↩ / double-click on the Repos page — reveal the focused worktree (or + /// project root) in Finder. + private func revealSelectedRepoInFinder() { + let path: String? + if let worktree = repos.worktree(id: reposTree.selectedWorktreeID) { + path = worktree.path + } else if let project = repos.project(id: reposTree.selectedProjectID) { + path = project.root + } else { + path = nil + } + guard let path, !path.isEmpty else { return } + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) + } + private func startNewConversation() { sessionDraft = ScoutSessionDraft( title: "New conversation", @@ -447,6 +503,7 @@ struct ScoutRootView: View { [ .item(HudSidebarItem(id: .comms, title: "Comms", icon: "bubble.left.and.bubble.right", selectedIcon: "bubble.left.and.bubble.right.fill")), .item(HudSidebarItem(id: .agents, title: "Agents", icon: "person.2", selectedIcon: "person.2.fill")), + .item(HudSidebarItem(id: .repos, title: "Repos", icon: "arrow.triangle.branch", selectedIcon: "arrow.triangle.branch")), .item(HudSidebarItem(id: .tail, title: "Tail", icon: "waveform.path.ecg", selectedIcon: "waveform.path.ecg")), ] } @@ -489,6 +546,8 @@ struct ScoutRootView: View { commsContent case .agents: agentsContent + case .repos: + reposContent case .tail: tailContent } @@ -1339,16 +1398,46 @@ struct ScoutRootView: View { ScoutTailContent(tail: tail) } + private var reposContent: some View { + ScoutReposContent(repos: repos, tree: reposTree, onActivate: { revealSelectedRepoInFinder() }) + } + private var inspectorHeader: some View { let multiAgent = section == .comms && channelAgentMembers.count >= 2 return HStack(spacing: HudSpacing.md) { - HudSectionLabel(section == .tail ? "Tail" : (multiAgent ? "Agents" : (store.selectedAgent == nil ? "Context" : "Agent"))) + HudSectionLabel(inspectorTitle(multiAgent: multiAgent)) Spacer() - if section == .tail { - HudBadge(tail.isFollowing ? "Live" : "Paused", tint: tail.isFollowing ? ScoutPalette.statusOk : ScoutPalette.muted, dot: tail.isFollowing) - } else if !multiAgent, let agent = store.selectedAgent { - HudBadge(agent.state.label, tint: agent.state.tint, dot: true) + inspectorHeaderBadge(multiAgent: multiAgent) + } + } + + private func inspectorTitle(multiAgent: Bool) -> String { + switch section { + case .tail: + return "Tail" + case .repos: + if repos.worktree(id: reposTree.selectedWorktreeID) != nil { return "Worktree" } + if repos.project(id: reposTree.selectedProjectID) != nil { return "Repo" } + return "Context" + default: + return multiAgent ? "Agents" : (store.selectedAgent == nil ? "Context" : "Agent") + } + } + + @ViewBuilder + private func inspectorHeaderBadge(multiAgent: Bool) -> some View { + if section == .tail { + HudBadge(tail.isFollowing ? "Live" : "Paused", tint: tail.isFollowing ? ScoutPalette.statusOk : ScoutPalette.muted, dot: tail.isFollowing) + } else if section == .repos { + // No verdict pill for a worktree — the inspector's Position block + // carries current state calmly. Keep a project-level attention + // summary, which reads as a roll-up rather than a per-branch verdict. + if repos.worktree(id: reposTree.selectedWorktreeID) == nil, + let project = repos.project(id: reposTree.selectedProjectID) { + HudBadge(project.attention.rawValue, tint: reposAttentionColor(project.attention), dot: reposAttentionLive(project.attention)) } + } else if !multiAgent, let agent = store.selectedAgent { + HudBadge(agent.state.label, tint: agent.state.tint, dot: true) } } @@ -1495,6 +1584,8 @@ struct ScoutRootView: View { VStack(alignment: .leading, spacing: HudSpacing.xl) { if section == .tail { ScoutTailInspector(tail: tail) + } else if section == .repos { + ScoutReposInspector(repos: repos, tree: reposTree) } else { if section == .agents { if let agent = store.selectedAgent { @@ -1771,6 +1862,14 @@ struct ScoutRootView: View { .font(HudFont.mono(HudTextSize.xxs)) .foregroundStyle(ScoutPalette.muted) + Text("·") + .font(HudFont.mono(HudTextSize.xxs)) + .foregroundStyle(ScoutPalette.dim) + + Text("\(repos.totals.worktrees) trees") + .font(HudFont.mono(HudTextSize.xxs)) + .foregroundStyle(ScoutPalette.muted) + if let error = store.lastError { Text("·") .font(HudFont.mono(HudTextSize.xxs)) @@ -1801,6 +1900,16 @@ struct ScoutRootView: View { .lineLimit(1) } + if let error = repos.lastError { + Text("·") + .font(HudFont.mono(HudTextSize.xxs)) + .foregroundStyle(ScoutPalette.dim) + Text(error) + .font(HudFont.mono(HudTextSize.xxs)) + .foregroundStyle(ScoutPalette.statusError) + .lineLimit(1) + } + Spacer() } .padding(.horizontal, HudSpacing.xxl)