diff --git a/apps/macos/App/HackDesktopApp.swift b/apps/macos/App/HackDesktopApp.swift index 789be029..0ea97241 100644 --- a/apps/macos/App/HackDesktopApp.swift +++ b/apps/macos/App/HackDesktopApp.swift @@ -15,6 +15,7 @@ import Sparkle struct HackDesktopApp: App { @State private var model = DashboardModel(client: HackCLIClient()) @State private var didSyncBundledCLI = false + @AppStorage("hackDesktop.preferences.theme") private var appearanceThemeRaw = "system" #if RELEASE private let updaterController = SPUStandardUpdaterController( @@ -27,6 +28,7 @@ struct HackDesktopApp: App { var body: some Scene { WindowGroup { DashboardView() + .preferredColorScheme(preferredColorScheme) .environment(model) #if RELEASE .task { @@ -54,6 +56,17 @@ struct HackDesktopApp: App { #endif } + private var preferredColorScheme: ColorScheme? { + switch appearanceThemeRaw { + case "light": + return .light + case "dark": + return .dark + default: + return nil + } + } + @MainActor private func syncBundledCLIIfNeeded() async { if didSyncBundledCLI { diff --git a/apps/macos/Package.resolved b/apps/macos/Package.resolved new file mode 100644 index 00000000..f075e852 --- /dev/null +++ b/apps/macos/Package.resolved @@ -0,0 +1,41 @@ +{ + "pins" : [ + { + "identity" : "highlightr", + "kind" : "remoteSourceControl", + "location" : "https://github.com/raspu/Highlightr.git", + "state" : { + "revision" : "05e7fcc63b33925cd0c1faaa205cdd5681e7bbef", + "version" : "2.3.0" + } + }, + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe", + "version" : "0.7.1" + } + }, + { + "identity" : "swift-markdown-ui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swift-markdown-ui", + "state" : { + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" + } + } + ], + "version" : 2 +} diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index 4122d350..79898a57 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -10,6 +10,10 @@ let package = Package( .library(name: "GhosttyTerminal", targets: ["GhosttyTerminal"]), .library(name: "DashboardFeature", targets: ["DashboardFeature"]) ], + dependencies: [ + .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.1"), + .package(url: "https://github.com/raspu/Highlightr.git", from: "2.3.0") + ], targets: [ .target( name: "HackDesktopModels", @@ -26,7 +30,13 @@ let package = Package( ), .target( name: "DashboardFeature", - dependencies: ["HackCLIService", "HackDesktopModels", "GhosttyTerminal"], + dependencies: [ + "HackCLIService", + "HackDesktopModels", + "GhosttyTerminal", + .product(name: "MarkdownUI", package: "swift-markdown-ui"), + .product(name: "Highlightr", package: "Highlightr") + ], path: "Packages/Features/DashboardFeature/Sources/DashboardFeature" ), .testTarget( diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/AdaptiveStyles.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/AdaptiveStyles.swift index 7b51848f..37513831 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/AdaptiveStyles.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/AdaptiveStyles.swift @@ -113,32 +113,14 @@ extension View { } } - /// Adaptive window background - transparent on macOS 26+ - @ViewBuilder + /// Adaptive window background with transparent toolbar chrome on macOS 26+ func adaptiveWindowBackground() -> some View { - if #available(macOS 26, *) { - self - .background(.clear) - // Keep the toolbar visible, but visually clear. We tune the underlying NSToolbar to avoid - // per-item "pill" backplates (see WindowToolbarTuner). - .toolbarBackground(.clear, for: .windowToolbar) - .toolbarBackgroundVisibility(.visible, for: .windowToolbar) - } else { - self - .background(.ultraThinMaterial) - .toolbarBackground(.ultraThinMaterial, for: .windowToolbar) - .toolbarBackground(.visible, for: .windowToolbar) - } + modifier(AdaptiveWindowBackgroundModifier()) } /// Adaptive detail view background - @ViewBuilder func adaptiveDetailBackground() -> some View { - if #available(macOS 26, *) { - self.background(.regularMaterial) - } else { - self.background(.ultraThinMaterial) - } + modifier(AdaptiveDetailBackgroundModifier()) } /// Adaptive sidebar background @@ -147,11 +129,19 @@ extension View { if #available(macOS 26, *) { self .scrollContentBackground(.hidden) - .background(.regularMaterial) + .background( + Rectangle() + .fill(.regularMaterial) + .ignoresSafeArea(edges: .top) + ) } else { self .scrollContentBackground(.hidden) - .background(.ultraThinMaterial) + .background( + Rectangle() + .fill(.ultraThinMaterial) + .ignoresSafeArea(edges: .top) + ) } } @@ -187,6 +177,76 @@ extension View { } } +private struct AdaptiveWindowBackgroundModifier: ViewModifier { + @Environment(\.colorScheme) private var colorScheme + + func body(content: Content) -> some View { + if #available(macOS 26, *) { + content + .background( + ZStack { + Rectangle() + .fill(baseColor) + Rectangle() + .fill(.ultraThinMaterial) + .opacity(materialOpacity) + } + ) + // Keep the toolbar visible, but visually clear. We tune the underlying NSToolbar to avoid + // per-item "pill" backplates (see WindowToolbarTuner). + .toolbarBackground(.clear, for: .windowToolbar) + .toolbarBackgroundVisibility(.visible, for: .windowToolbar) + } else { + content + .background(baseColor) + .toolbarBackground(.ultraThinMaterial, for: .windowToolbar) + .toolbarBackground(.visible, for: .windowToolbar) + } + } + + private var baseColor: Color { + colorScheme == .dark ? .black : .white + } + + private var materialOpacity: Double { + colorScheme == .dark ? 0.24 : 0.4 + } +} + +private struct AdaptiveDetailBackgroundModifier: ViewModifier { + @Environment(\.colorScheme) private var colorScheme + + func body(content: Content) -> some View { + if #available(macOS 26, *) { + content.background( + ZStack { + Rectangle() + .fill(baseColor) + Rectangle() + .fill(.regularMaterial) + .opacity(materialOpacity) + Rectangle() + .fill(edgeTint) + } + ) + } else { + content.background(baseColor) + } + } + + private var baseColor: Color { + colorScheme == .dark ? .black : .white + } + + private var materialOpacity: Double { + colorScheme == .dark ? 0.2 : 0.3 + } + + private var edgeTint: Color { + colorScheme == .dark ? Color.white.opacity(0.03) : Color.black.opacity(0.02) + } +} + struct PressableIconButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CodingAgentIntegration.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CodingAgentIntegration.swift new file mode 100644 index 00000000..ed9df472 --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CodingAgentIntegration.swift @@ -0,0 +1,90 @@ +import Foundation + +enum CodingAgentIntegration { + enum AgentApp: String, CaseIterable, Identifiable { + case codex + case cursor + case gemini + case opencode + + var id: String { rawValue } + + var displayName: String { + switch self { + case .codex: + return "Codex" + case .cursor: + return "Cursor" + case .gemini: + return "Gemini CLI" + case .opencode: + return "OpenCode" + } + } + + var executableCandidates: [String] { + switch self { + case .codex: + return ["codex"] + case .cursor: + return ["cursor-agent", "cursor"] + case .gemini: + return ["gemini", "gemini-cli"] + case .opencode: + return ["opencode"] + } + } + } + + static func installedAgents() -> [AgentApp] { + AgentApp.allCases.filter { resolvedBinaryPath(for: $0, overridePath: nil) != nil } + } + + static func resolvedBinaryPath(for agent: AgentApp, overridePath: String?) -> String? { + let normalizedOverride = overridePath?.trimmingCharacters(in: .whitespacesAndNewlines) + if let normalizedOverride, !normalizedOverride.isEmpty { + return normalizedOverride + } + let fileManager = FileManager.default + let envPath = ProcessInfo.processInfo.environment["PATH"] ?? "" + for entry in envPath.split(separator: ":") { + for candidate in agent.executableCandidates { + let path = "\(entry)/\(candidate)" + if fileManager.isExecutableFile(atPath: path) { + return path + } + } + } + + let fallbackDirectories = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"] + for directory in fallbackDirectories { + for candidate in agent.executableCandidates { + let path = "\(directory)/\(candidate)" + if fileManager.isExecutableFile(atPath: path) { + return path + } + } + } + + return nil + } + + static func launchCommand( + projectPath: String, + agent: AgentApp, + binaryOverridePath: String? + ) -> String { + let quotedPath = shellQuote(projectPath) + let resolvedBinary = resolvedBinaryPath(for: agent, overridePath: binaryOverridePath) + let fallbackBinary = agent.executableCandidates.first ?? agent.rawValue + let quotedBinary = shellQuote(resolvedBinary ?? fallbackBinary) + return "cd \(quotedPath) && \(quotedBinary)" + } + + private static func shellQuote(_ value: String) -> String { + if value.isEmpty { + return "''" + } + return "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift index 8ad908ff..c08aa844 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift @@ -1,6 +1,8 @@ import AppKit import SwiftUI +import HackDesktopModels + extension Notification.Name { public static let hackCommandPaletteRequested = Notification.Name("hack.commandPalette.requested") public static let hackRefreshRequested = Notification.Name("hack.refresh.requested") @@ -16,6 +18,8 @@ struct CommandPaletteAction: Identifiable { struct CommandPaletteView: View { @Environment(DashboardModel.self) private var model @Environment(\.dismiss) private var dismiss + @AppStorage("hackDesktop.preferences.defaultCodingAgent") private var preferredCodingAgentRaw = CodingAgentIntegration.AgentApp.codex.rawValue + @AppStorage("hackDesktop.preferences.defaultCodingAgentBinaryPath") private var preferredCodingAgentBinaryPathRaw = "" @State private var query = "" var body: some View { @@ -65,13 +69,44 @@ struct CommandPaletteView: View { ) actions.append( - CommandPaletteAction(title: "Go to Runtime", subtitle: "System status") { - model.selectedItem = .runtime + CommandPaletteAction(title: "Go to Dashboard", subtitle: "Home overview") { + model.selectedItem = .home + } + ) + + actions.append( + CommandPaletteAction(title: "Go to Runtime Settings", subtitle: "System status + daemon") { + NotificationCenter.default.post( + name: .hackSettingsRequested, + object: nil, + userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.runtime.rawValue] + ) } ) actions.append( - CommandPaletteAction(title: "Go to Gateway", subtitle: "Gateway configuration") { - model.selectedItem = .gateway + CommandPaletteAction(title: "Go to Gateway Settings", subtitle: "Gateway configuration") { + NotificationCenter.default.post( + name: .hackSettingsRequested, + object: nil, + userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.gateway.rawValue] + ) + } + ) + actions.append( + CommandPaletteAction(title: "Go to Permissions Settings", subtitle: "Automation + local network access") { + NotificationCenter.default.post( + name: .hackSettingsRequested, + object: nil, + userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.permissions.rawValue] + ) + } + ) + actions.append( + CommandPaletteAction( + title: model.globalInfraRunning ? "Global: Stop services" : "Global: Start services", + subtitle: model.globalInfraRunning ? "Runs `hack global down`" : "Runs `hack global up`" + ) { + Task { await model.toggleGlobalInfrastructure() } } ) @@ -84,6 +119,16 @@ struct CommandPaletteView: View { } if let project = model.selectedProject { + if let projectPath = projectAgentPath(project) { + actions.append( + CommandPaletteAction( + title: "Project: Open in \(preferredCodingAgent.displayName)", + subtitle: "Starts your preferred coding agent" + ) { + openPreferredCodingAgent(for: project, path: projectPath) + } + ) + } actions.append( CommandPaletteAction(title: "Project: Overview", subtitle: project.name) { model.selectedProjectTab = .overview @@ -163,4 +208,39 @@ struct CommandPaletteView: View { return actions } + + private var preferredCodingAgent: CodingAgentIntegration.AgentApp { + if let explicit = CodingAgentIntegration.AgentApp(rawValue: preferredCodingAgentRaw) { + return explicit + } + return .codex + } + + private func projectAgentPath(_ project: ProjectSummary) -> String? { + if let repoRoot = project.repoRoot, !repoRoot.isEmpty { + return repoRoot + } + if let projectDir = project.projectDir, !projectDir.isEmpty { + return projectDir + } + return nil + } + + private func openPreferredCodingAgent(for project: ProjectSummary, path: String) { + let command = CodingAgentIntegration.launchCommand( + projectPath: path, + agent: preferredCodingAgent, + binaryOverridePath: preferredCodingAgentBinaryPathRaw + ) + NotificationCenter.default.post( + name: .hackTerminalOpenRequested, + object: nil, + userInfo: [ + TerminalOpenRequest.projectIdKey: project.id, + TerminalOpenRequest.kindKey: TerminalDrawerModel.Kind.shell.rawValue, + TerminalOpenRequest.commandKey: command, + TerminalOpenRequest.titleKey: "\(preferredCodingAgent.displayName) - \(project.name)" + ] + ) + } } diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift index ab527a36..107f8e97 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift @@ -5,12 +5,15 @@ import HackCLIService import HackDesktopModels public enum SidebarItem: Hashable, Identifiable { + case home case runtime case gateway case project(String) public var id: String { switch self { + case .home: + return "home" case .runtime: return "runtime" case .gateway: @@ -23,16 +26,34 @@ public enum SidebarItem: Hashable, Identifiable { public enum ProjectTab: String, CaseIterable { case overview = "Overview" + case branches = "Branches" + case sessions = "Sessions" case logs = "Logs" case shell = "Shell" case tickets = "Tickets" } +public enum ProjectLifecycleAction { + case starting + case stopping +} + +public enum GlobalLifecycleAction { + case starting + case stopping +} + +public enum RuntimeHealthState { + case healthy + case degraded + case down + case unknown +} + @Observable @MainActor public final class DashboardModel { public private(set) var projects: [ProjectSummary] = [] - public private(set) var projectMetaById: [String: ProjectMeta] = [:] public private(set) var daemonStatus: DaemonStatus? = nil public private(set) var globalStatus: GlobalStatusResponse? = nil public private(set) var runtimeOk: Bool? = nil @@ -42,15 +63,22 @@ public final class DashboardModel { public private(set) var runtimeResetAt: String? = nil public private(set) var runtimeResetCount: Int? = nil public private(set) var lastUpdated: Date? = nil - public var selectedItem: SidebarItem? = .runtime + public var selectedItem: SidebarItem? = .home { + didSet { + handleSelectedItemChange(previous: oldValue, current: selectedItem) + } + } public var selectedProjectTab: ProjectTab = .overview public var errorMessage: String? = nil public var statusMessage: String? = nil public var isRefreshing = false + public private(set) var projectLifecycleActions: [String: ProjectLifecycleAction] = [:] + public private(set) var globalLifecycleAction: GlobalLifecycleAction? = nil private let client: HackCLIClient // Tickets should not be blocked by global refresh/status calls. private let ticketsClient: HackCLIClient + private var lastSelectedProjectId: String? = nil private var refreshTask: Task? = nil private var statusClearTask: Task? = nil @@ -74,12 +102,31 @@ public final class DashboardModel { return runtimeOk } + public var runtimeHealthState: RuntimeHealthState { + if runtimeOverallOk == true { return .healthy } + if globalInfraDown { return .down } + if runtimeOverallOk == false { return .degraded } + return .unknown + } + + public var globalInfraRunning: Bool { + globalInfraRunningState == true + } + + public var globalInfraDown: Bool { + globalInfraRunningState == false + } + var gatewaySummaryState: GatewaySummaryState? { let gatewayEnabled = globalStatus?.gateway?.gatewayEnabled ?? globalStatus?.summary.gatewayEnabled if globalStatus?.gateway == nil && gatewayEnabled == nil && gatewayExposures.isEmpty { return nil } - return GatewaySummaryState.resolve(exposures: gatewayExposures, gatewayEnabled: gatewayEnabled) + return GatewaySummaryState.resolve( + exposures: gatewayExposures, + gatewayEnabled: gatewayEnabled, + globalInfraRunning: globalInfraRunningState + ) } public func start() { @@ -102,13 +149,11 @@ public final class DashboardModel { lastUpdated = Date() } - let selectedProjectForMeta = selectedProject async let projectsTask = fetchProjects() - async let metaTask = fetchProjectMeta(for: selectedProjectForMeta) async let daemonTask = fetchDaemonStatus() async let globalTask = fetchGlobalStatus() - let errors = await [projectsTask, metaTask, daemonTask, globalTask].compactMap { $0 } + let errors = await [projectsTask, daemonTask, globalTask].compactMap { $0 } if !errors.isEmpty { errorMessage = errors.joined(separator: "\n") } @@ -143,6 +188,8 @@ public final class DashboardModel { errorMessage = "Missing project path for \(project.name)" return } + projectLifecycleActions[project.id] = .starting + defer { projectLifecycleActions.removeValue(forKey: project.id) } await runAction(message: "Starting \(project.name)…") { try await self.client.startProject(path: path) } @@ -153,35 +200,31 @@ public final class DashboardModel { errorMessage = "Missing project path for \(project.name)" return } + projectLifecycleActions[project.id] = .stopping + defer { projectLifecycleActions.removeValue(forKey: project.id) } await runAction(message: "Stopping \(project.name)…") { try await self.client.stopProject(path: path) } } - public func stopSession(sessionName: String) async { - let trimmed = sessionName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - errorMessage = "Missing session name" - return - } - - await runAction(message: "Stopping session…") { - try await self.client.stopSession(sessionName: trimmed) - } - } - - public func showLogs(for project: ProjectSummary) { + public func showLogs(for project: ProjectSummary, branch: String? = nil) { selectedItem = .project(project.id) if selectedProjectTab == .logs { selectedProjectTab = .overview } + let normalizedBranch = branch?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedBranch = (normalizedBranch?.isEmpty == false) ? normalizedBranch : nil + var userInfo: [String: String] = [ + TerminalOpenRequest.projectIdKey: project.id, + TerminalOpenRequest.kindKey: TerminalDrawerModel.Kind.logs.rawValue + ] + if let resolvedBranch { + userInfo[TerminalOpenRequest.branchKey] = resolvedBranch + } NotificationCenter.default.post( name: .hackTerminalOpenRequested, object: nil, - userInfo: [ - TerminalOpenRequest.projectIdKey: project.id, - TerminalOpenRequest.kindKey: TerminalDrawerModel.Kind.logs.rawValue - ] + userInfo: userInfo ) } @@ -280,10 +323,192 @@ public final class DashboardModel { return result ?? false } + public func startBranch(for project: ProjectSummary, branch: String) async { + guard let path = resolveProjectPath(project) else { + errorMessage = "Missing project path for \(project.name)" + return + } + await runAction(message: "Starting \(project.name) [\(branch)]…") { + try await self.client.startBranch(path: path, branch: branch) + } + } + + public func stopBranch(for project: ProjectSummary, branch: String) async { + guard let path = resolveProjectPath(project) else { + errorMessage = "Missing project path for \(project.name)" + return + } + await runAction(message: "Stopping \(project.name) [\(branch)]…") { + try await self.client.stopBranch(path: path, branch: branch) + } + } + + public func addBranch(for project: ProjectSummary, name: String, note: String?) async -> Bool { + guard let path = resolveProjectPath(project) else { + errorMessage = "Missing project path for \(project.name)" + return false + } + let result: Bool? = await runActionResult(message: "Adding branch \(name)…") { + try await self.client.addBranch(path: path, name: name, note: note) + return true + } + return result ?? false + } + + public func removeBranch(for project: ProjectSummary, name: String) async { + guard let path = resolveProjectPath(project) else { + errorMessage = "Missing project path for \(project.name)" + return + } + await runAction(message: "Removing branch \(name)…") { + try await self.client.removeBranch(path: path, name: name) + } + } + + public func stopSession(name: String) async { + await runAction(message: "Stopping session \(name)…") { + try await self.client.stopSession(name: name) + } + } + + public func startSession(for project: ProjectSummary) async { + await runAction(message: "Starting session for \(project.name)…") { + try await self.client.startSession(projectName: project.name, detached: true) + } + } + + @discardableResult + public func setGlobalConfig(key: String, value: String) async -> Bool { + let result: Bool? = await runActionResult(message: "Updating \(key)…") { + try await self.client.setGlobalConfig(key: key, value: value) + return true + } + return result ?? false + } + + public func fetchGatewayTokens() async -> [GatewayTokenRecord] { + do { + return try await client.listGatewayTokens().tokens + } catch { + errorMessage = error.localizedDescription + return [] + } + } + + public func createGatewayToken( + scope: GatewayTokenScope, + label: String? + ) async -> GatewayTokenCreateResponse? { + await runActionResult(message: "Creating gateway token…") { + try await self.client.createGatewayToken(scope: scope, label: label) + } + } + + public func revokeGatewayToken(id: String) async -> Bool { + let response: GatewayTokenRevokeResponse? = await runActionResult(message: "Revoking gateway token…") { + try await self.client.revokeGatewayToken(id: id) + } + guard let response else { + return false + } + return response.revoked + } + + public func startCloudflareTunnel() async -> Bool { + let result: Bool? = await runActionResult(message: "Starting cloudflared tunnel…") { + try await self.client.startCloudflareTunnel() + return true + } + return result ?? false + } + + public func stopCloudflareTunnel() async -> Bool { + let result: Bool? = await runActionResult(message: "Stopping cloudflared tunnel…") { + try await self.client.stopCloudflareTunnel() + return true + } + return result ?? false + } + + public func inspectTailscale() async -> TailscaleInspectResponse? { + do { + return try await client.inspectTailscale() + } catch { + let message = error.localizedDescription + errorMessage = message + return TailscaleInspectResponse( + installed: false, + binaryPath: nil, + connected: false, + backendState: nil, + tailnetName: nil, + magicDnsSuffix: nil, + authUrl: nil, + currentExitNodeId: nil, + currentExitNodeName: nil, + selfDevice: nil, + peers: [], + onlinePeerCount: 0, + exitNodes: [], + health: [], + error: message + ) + } + } + + public func toggleGlobalInfrastructure() async { + if globalInfraRunning { + await globalDown() + } else { + await globalUp() + } + } + + public func globalUp() async { + globalLifecycleAction = .starting + defer { globalLifecycleAction = nil } + await runGlobalCommand( + message: "Starting global services…", + fallbackCommand: "hack global up" + ) { + try await self.client.globalUp() + } + } + + public func globalDown() async { + globalLifecycleAction = .stopping + defer { globalLifecycleAction = nil } + await runGlobalCommand( + message: "Stopping global services…", + fallbackCommand: "hack global down" + ) { + try await self.client.globalDown() + } + } + private func resolveProjectPath(_ project: ProjectSummary) -> String? { project.repoRoot ?? project.projectDir } + private func handleSelectedItemChange(previous: SidebarItem?, current: SidebarItem?) { + guard let currentProjectId = projectId(from: current) else { + return + } + + let previousProjectId = projectId(from: previous) ?? lastSelectedProjectId + if previousProjectId != currentProjectId { + selectedProjectTab = .overview + } + lastSelectedProjectId = currentProjectId + } + + private func projectId(from item: SidebarItem?) -> String? { + guard case let .project(id) = item else { + return nil + } + return id + } + private func fetchProjects() async -> String? { do { let response = try await client.fetchProjects(includeGlobal: true) @@ -295,23 +520,10 @@ public final class DashboardModel { runtimeResetAt = response.runtimeResetAt runtimeResetCount = response.runtimeResetCount if selectedItem == nil { - selectedItem = .runtime + selectedItem = .home } if case let .project(id) = selectedItem, !projects.contains(where: { $0.id == id }) { - selectedItem = projects.first.map { .project($0.id) } ?? .runtime - } - return nil - } catch { - return error.localizedDescription - } - } - - private func fetchProjectMeta(for project: ProjectSummary?) async -> String? { - guard let project else { return nil } - - do { - if let meta = try await client.fetchProjectMeta(projectName: project.name) { - projectMetaById[project.id] = meta + selectedItem = .home } return nil } catch { @@ -324,6 +536,7 @@ public final class DashboardModel { daemonStatus = try await client.daemonStatus() return nil } catch { + daemonStatus = nil return error.localizedDescription } } @@ -333,10 +546,19 @@ public final class DashboardModel { globalStatus = try await client.fetchGlobalStatus() return nil } catch { + globalStatus = nil return error.localizedDescription } } + private var globalInfraRunningState: Bool? { + guard let status = globalStatus else { return nil } + let caddyOk = status.caddy?.ok ?? status.summary.caddyOk + let loggingOk = status.logging?.ok ?? status.summary.loggingOk + let networksOk = status.networks?.ok ?? status.summary.networksOk + return caddyOk && loggingOk && networksOk + } + private func runAction(message: String, action: @escaping () async throws -> Void) async { statusMessage = message statusClearTask?.cancel() @@ -380,4 +602,47 @@ public final class DashboardModel { return nil } } + + private func runGlobalCommand( + message: String, + fallbackCommand: String, + action: @escaping () async throws -> Void + ) async { + statusMessage = message + statusClearTask?.cancel() + + do { + try await action() + statusMessage = "Done" + await refresh() + scheduleStatusClear() + } catch let error { + await refresh() + if shouldFallbackToTerminal(for: error) { + TerminalIntegration.openTerminalWithCommand(fallbackCommand) + statusMessage = "Opened Terminal for \(fallbackCommand)" + scheduleStatusClear() + return + } + statusMessage = nil + errorMessage = error.localizedDescription + } + } + + private func shouldFallbackToTerminal(for error: Error) -> Bool { + let message = error.localizedDescription.lowercased() + return message.contains("sudo") + || message.contains("permission denied") + || message.contains("operation not permitted") + || message.contains("not permitted") + || message.contains("no tty") + || message.contains("password") + } + + private func scheduleStatusClear() { + statusClearTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(2)) + self?.statusMessage = nil + } + } } diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift index c26f8ec3..058b1c6f 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift @@ -6,11 +6,15 @@ import HackDesktopModels public struct DashboardView: View { @Environment(DashboardModel.self) private var model + @Environment(\.colorScheme) private var colorScheme @State private var showCommandPalette = false @State private var showTerminalDrawer = false + @State private var showSettingsOverlay = false + @State private var selectedSettingsItem: SettingsSidebarItem = .runtime @State private var terminalDrawerHeight: CGFloat = 360 @State private var terminalDrawerInitialHeight: CGFloat? = nil @State private var terminalDrawerModel = TerminalDrawerModel(globalShellProject: Self.makeGlobalShellProject()) + @State private var dismissedGlobalRecoveryOverlay = false public init() {} @@ -18,39 +22,97 @@ public struct DashboardView: View { @Bindable var model = model GeometryReader { proxy in - VSplitView { - mainSplitView - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - - if showTerminalDrawer { - terminalDrawer - .frame(maxHeight: proxy.size.height * 0.92) - .onPreferenceChange(TerminalDrawerView.heightPreferenceKey) { newHeight in - // Let users drag the split divider all the way down to close. - let closeThreshold: CGFloat = 84 - if newHeight > 0, newHeight < closeThreshold, showTerminalDrawer { - showTerminalDrawer = false - return - } - if newHeight > closeThreshold { - terminalDrawerHeight = newHeight - } + ZStack { + VSplitView { + mainSplitView + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + + if showTerminalDrawer { + terminalDrawer + .frame(maxHeight: proxy.size.height * 0.92) + .onPreferenceChange(TerminalDrawerView.heightPreferenceKey) { newHeight in + // Let users drag the split divider all the way down to close. + let closeThreshold: CGFloat = 84 + if newHeight > 0, newHeight < closeThreshold, showTerminalDrawer { + showTerminalDrawer = false + return + } + if newHeight > closeThreshold { + terminalDrawerHeight = newHeight + } + } + .transition(.move(edge: .bottom).combined(with: .opacity)) } - .transition(.move(edge: .bottom).combined(with: .opacity)) + } + .background(alignment: .top) { + topHeaderChrome + } + + if showSettingsOverlay { + SettingsOverlayView( + selection: $selectedSettingsItem, + onClose: { showSettingsOverlay = false } + ) + .environment(model) + .transition(.opacity.combined(with: .scale(scale: 0.995))) + .zIndex(20) + } + + if shouldShowGlobalRecoveryOverlay { + globalRecoveryOverlay + .transition(.opacity) + .zIndex(30) } } // Attach toolbar at the window root. Nested toolbars inside split views can disappear // when additional container views are introduced (e.g. a bottom terminal panel). .toolbar { + ToolbarItem(placement: .navigation) { + ToolbarIconButton( + systemImage: "square.grid.2x2", + help: "Go to dashboard", + accessibilityLabel: "Go to dashboard", + symbolTint: titlebarNeutralIconTint, + hoverSymbolTint: titlebarNeutralIconHoverTint, + action: { + showSettingsOverlay = false + model.selectedItem = .home + } + ) + } ToolbarItem(placement: .principal) { GlobalStatusStrip(placement: .titlebar) .frame(maxWidth: .infinity, alignment: .center) } - ToolbarItem(placement: .primaryAction) { + ToolbarItemGroup(placement: .primaryAction) { + ToolbarIconButton( + systemImage: "gearshape", + help: "Open settings", + accessibilityLabel: "Open settings", + symbolTint: titlebarNeutralIconTint, + hoverSymbolTint: titlebarNeutralIconHoverTint, + action: { + openSettings(.runtime) + } + ) + ToolbarIconButton( + systemImage: globalToggleIcon, + hoverSystemImage: globalToggleHoverIcon, + help: globalToggleHelp, + accessibilityLabel: globalToggleAccessibilityLabel, + symbolTint: globalToggleTint, + hoverSymbolTint: globalToggleHoverTint, + action: { + guard !globalToggleIsBusy else { return } + Task { await model.toggleGlobalInfrastructure() } + } + ) ToolbarIconButton( systemImage: "terminal", help: "Toggle terminal", accessibilityLabel: "Toggle terminal", + symbolTint: titlebarNeutralIconTint, + hoverSymbolTint: titlebarNeutralIconHoverTint, action: { if showTerminalDrawer { showTerminalDrawer = false @@ -84,33 +146,274 @@ public struct DashboardView: View { else { return } + let branch = (userInfo[TerminalOpenRequest.branchKey] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let initialCommand = (userInfo[TerminalOpenRequest.commandKey] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let titleOverride = (userInfo[TerminalOpenRequest.titleKey] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) guard let project = model.projects.first(where: { $0.id == projectId }) else { return } if !showTerminalDrawer { terminalDrawerInitialHeight = terminalDrawerHeight showTerminalDrawer = true } - terminalDrawerModel.openOrSelect(project: project, kind: kind) + terminalDrawerModel.openOrSelect( + project: project, + kind: kind, + branch: branch, + initialCommand: initialCommand, + titleOverride: titleOverride + ) + } + .onReceive(NotificationCenter.default.publisher(for: .hackSettingsRequested)) { notification in + if let userInfo = notification.userInfo, + let rawPane = userInfo[SettingsNavigationRequest.paneKey] as? String, + let pane = SettingsSidebarItem(rawValue: rawPane) { + openSettings(pane) + } else { + openSettings(.runtime) + } } .sheet(isPresented: $showCommandPalette) { CommandPaletteView() .environment(model) } + .onChange(of: model.globalInfraDown) { _, isDown in + if !isDown { + dismissedGlobalRecoveryOverlay = false + } + } .animation(.easeInOut(duration: 0.18), value: showTerminalDrawer) } } private var mainSplitView: some View { - NavigationSplitView { - sidebar - } detail: { - detail - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .adaptiveDetailBackground() + detail + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(.top, detailTopPadding) + .adaptiveDetailBackground() + .controlSize(.small) + } + + private var detailTopPadding: CGFloat { + if case .project = model.selectedItem { + return 0 + } + return 12 + } + + private var topHeaderChrome: some View { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.ultraThinMaterial) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill( + colorScheme == .dark + ? Color.white.opacity(0.08) + : Color.white.opacity(0.24) + ) + } + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke( + colorScheme == .dark + ? Color.white.opacity(0.24) + : Color.white.opacity(0.62), + lineWidth: 1 + ) + ) + .frame(height: 46) + .padding(.top, 8) + .padding(.horizontal, 12) + .allowsHitTesting(false) + } + + private var globalToggleIcon: String { + if globalToggleIsBusy { + return "hourglass" + } + return model.globalInfraRunning ? "bolt.fill" : "power.circle" + } + + private var globalToggleHoverIcon: String? { + if globalToggleIsBusy { + return nil + } + return model.globalInfraRunning ? "power.circle.fill" : "bolt.fill" + } + + private var globalToggleTint: NSColor { + if globalToggleIsBusy { + return .secondaryLabelColor + } + return model.globalInfraRunning ? NSColor.systemGreen : NSColor.systemRed + } + + private var globalToggleHoverTint: NSColor? { + if globalToggleIsBusy { + return nil + } + return model.globalInfraRunning ? NSColor.systemRed : NSColor.systemGreen + } + + private var globalToggleHelp: String { + if let action = model.globalLifecycleAction { + return action == .starting ? "Starting global services…" : "Stopping global services…" + } + if model.globalInfraRunning { + return "Global services are running. Click to stop (`hack global down`)." + } + return "Global services are stopped. Click to start (`hack global up`)." + } + + private var globalToggleAccessibilityLabel: String { + if let action = model.globalLifecycleAction { + return action == .starting ? "Starting global services" : "Stopping global services" + } + return model.globalInfraRunning ? "Stop global services" : "Start global services" + } + + private var globalToggleIsBusy: Bool { + model.globalLifecycleAction != nil + } + + private var titlebarNeutralIconTint: NSColor { + titlebarIconTint(lightOpacity: 0.72, darkOpacity: 0.90) + } + + private var titlebarNeutralIconHoverTint: NSColor { + titlebarIconTint(lightOpacity: 0.86, darkOpacity: 1.0) + } + + private func titlebarIconTint(lightOpacity: CGFloat, darkOpacity: CGFloat) -> NSColor { + NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + if isDark { + return NSColor.white.withAlphaComponent(darkOpacity) + } + return NSColor.black.withAlphaComponent(lightOpacity) + } + } + + private var shouldShowGlobalRecoveryOverlay: Bool { + if dismissedGlobalRecoveryOverlay { + return false + } + if model.globalLifecycleAction == .starting { + return true + } + return model.globalInfraDown + && (model.daemonStatus?.resolvedLabel == .running || model.daemonStatus?.resolvedLabel == .starting) + } + + private var globalRecoveryOverlay: some View { + ZStack { + Rectangle() + .fill(Color.black.opacity(colorScheme == .dark ? 0.35 : 0.22)) + .ignoresSafeArea() + + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Label("Global services are down", systemImage: "bolt.slash.fill") + .font(.mono(.headline, weight: .semibold)) + Spacer() + Button { + dismissedGlobalRecoveryOverlay = true + } label: { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .bold)) + .frame(width: 20, height: 20) + } + .buttonStyle(PressableCircleButtonStyle()) + .help("Dismiss") + } + + Text("Hackd is running, but Caddy/logging/gateway are not fully healthy. Restart global infra to recover local DNS/TLS routing.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 8) { + statusRow( + title: "Daemon", + healthy: model.daemonStatus?.resolvedLabel == .running, + value: model.daemonStatus?.resolvedLabel.rawValue.capitalized ?? "Unknown" + ) + statusRow( + title: "Caddy", + healthy: (model.globalStatus?.caddy?.ok ?? model.globalStatus?.summary.caddyOk) == true, + value: (model.globalStatus?.caddy?.ok ?? model.globalStatus?.summary.caddyOk) == true ? "Running" : "Down" + ) + statusRow( + title: "Logging", + healthy: (model.globalStatus?.logging?.ok ?? model.globalStatus?.summary.loggingOk) == true, + value: (model.globalStatus?.logging?.ok ?? model.globalStatus?.summary.loggingOk) == true ? "Running" : "Down" + ) + statusRow( + title: "Networks", + healthy: (model.globalStatus?.networks?.ok ?? model.globalStatus?.summary.networksOk) == true, + value: (model.globalStatus?.networks?.ok ?? model.globalStatus?.summary.networksOk) == true ? "Healthy" : "Missing" + ) + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(colorScheme == .dark ? Color.white.opacity(0.05) : Color.black.opacity(0.03)) + ) + + HStack(spacing: 8) { + Button { + Task { await model.globalUp() } + } label: { + HStack(spacing: 6) { + if model.globalLifecycleAction == .starting { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.triangle.2.circlepath") + } + Text(model.globalLifecycleAction == .starting ? "Restarting…" : "Restart global services") + .lineLimit(1) + } + } + .adaptiveToolbarButtonProminent() + .disabled(model.globalLifecycleAction != nil) + + Button("Runtime details") { + openSettings(.runtime) + dismissedGlobalRecoveryOverlay = true + } + .adaptiveToolbarButton() + } + } + .padding(16) + .frame(maxWidth: 680) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.thinMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.primary.opacity(0.12), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.18), radius: 24, x: 0, y: 14) + .padding(20) + } + } + + @ViewBuilder + private func statusRow(title: String, healthy: Bool, value: String) -> some View { + HStack(spacing: 8) { + Circle() + .fill(healthy ? Color.green : Color.orange) + .frame(width: 7, height: 7) + Text(title) + .font(.mono(.caption, weight: .semibold)) + Spacer() + Text(value) + .font(.mono(.caption)) + .foregroundStyle(.secondary) } - .navigationSplitViewStyle(.balanced) - .navigationSplitViewColumnWidth(min: 240, ideal: 320, max: 460) - .controlSize(.small) } @ViewBuilder @@ -153,7 +456,6 @@ public struct DashboardView: View { runtimeConfigured: nil, runtimeStatus: nil, runtime: nil, - meta: nil, kind: .unregistered, status: .unknown ) @@ -162,17 +464,15 @@ public struct DashboardView: View { private var sidebar: some View { @Bindable var model = model - let extensionProjects = model.projects.filter { $0.isExtensionOnly } - let runtimeProjects = model.projects.filter { !$0.isExtensionOnly } + let hiddenProjectIds = Set( + model.projects + .filter { isLikelyEphemeralWorktreeRegistration(project: $0, allProjects: model.projects) } + .map(\.id) + ) + let visibleProjects = model.projects.filter { !hiddenProjectIds.contains($0.id) } + let extensionProjects = visibleProjects.filter { $0.isExtensionOnly } + let runtimeProjects = visibleProjects.filter { !$0.isExtensionOnly } return List(selection: $model.selectedItem) { - Section("System") { - RuntimeRowView(isHealthy: model.runtimeOverallOk) - .tag(SidebarItem.runtime) - .contextMenu { runtimeContextMenu } - GatewayRowView(state: model.gatewaySummaryState) - .tag(SidebarItem.gateway) - .contextMenu { gatewayContextMenu } - } Section("Projects") { if runtimeProjects.isEmpty { VStack(alignment: .leading, spacing: 4) { @@ -210,13 +510,44 @@ public struct DashboardView: View { } } + private func isLikelyEphemeralWorktreeRegistration( + project: ProjectSummary, + allProjects: [ProjectSummary] + ) -> Bool { + guard project.status == .missing else { return false } + guard project.runtime == nil else { return false } + guard project.runtimeConfigured != true else { return false } + guard (project.features ?? []).isEmpty else { return false } + guard (project.extensionsEnabled ?? []).isEmpty else { return false } + guard let repoRoot = project.repoRoot?.lowercased() else { return false } + let looksLikeWorktree = + repoRoot.contains("/.codex/worktrees/") || repoRoot.contains("/.git/worktrees/") + guard looksLikeWorktree else { return false } + + return allProjects.contains { candidate in + guard candidate.id != project.id else { return false } + guard candidate.isRuntimeConfigured else { return false } + return project.name.hasPrefix("\(candidate.name)-") + } + } + private var detail: some View { Group { switch model.selectedItem { + case .home: + HomeDashboardView() case .runtime: - RuntimeDetailView() + settingsRedirectView( + title: "Runtime moved to Settings", + subtitle: "Open Settings to view runtime health, daemon controls, and global services.", + pane: .runtime + ) case .gateway: - GatewayDetailView() + settingsRedirectView( + title: "Gateway moved to Settings", + subtitle: "Open Settings to manage gateway status, exposures, and gateway configuration.", + pane: .gateway + ) case let .project(id): if let project = model.projects.first(where: { $0.id == id }) { ProjectDetailView(project: project) @@ -229,6 +560,32 @@ public struct DashboardView: View { } } + private func settingsRedirectView( + title: String, + subtitle: String, + pane: SettingsSidebarItem + ) -> some View { + VStack(alignment: .center, spacing: 10) { + Text(title) + .font(.mono(.headline, weight: .semibold)) + Text(subtitle) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + Button { + openSettings(pane) + } label: { + Label("Open Settings", systemImage: "gearshape") + } + .adaptiveToolbarButtonProminent() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + + private func openSettings(_ pane: SettingsSidebarItem) { + selectedSettingsItem = pane + showSettingsOverlay = true + } + private var footer: some View { VStack(alignment: .leading, spacing: 8) { if let errorMessage = model.errorMessage { @@ -252,16 +609,19 @@ public struct DashboardView: View { } private var runtimeLabel: String { - if model.runtimeOverallOk == true { + switch model.runtimeHealthState { + case .healthy: return "Runtime: ok" - } - if model.runtimeOk == false, let error = model.runtimeError, !error.isEmpty { - return "Runtime: \(error)" - } - if model.runtimeOverallOk == false { + case .down: + return "Runtime: down" + case .degraded: + if model.runtimeOk == false, let error = model.runtimeError, !error.isEmpty { + return "Runtime: \(error)" + } return "Runtime: degraded" + case .unknown: + return "Runtime: unknown" } - return "Runtime: unknown" } private var daemonIsRunning: Bool { diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/EditorIntegration.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/EditorIntegration.swift new file mode 100644 index 00000000..7c682cc8 --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/EditorIntegration.swift @@ -0,0 +1,190 @@ +import AppKit +import Foundation + +enum EditorIntegration { + enum EditorApp: String, CaseIterable, Identifiable { + case cursor + case vscode + case zed + case antigravity + case intellij + case neovim + case vim + + var id: String { rawValue } + + var displayName: String { + switch self { + case .cursor: + return "Cursor" + case .vscode: + return "VS Code" + case .zed: + return "Zed" + case .antigravity: + return "Antigravity" + case .intellij: + return "IntelliJ" + case .neovim: + return "Neovim" + case .vim: + return "Vim" + } + } + + fileprivate var launchKind: EditorLaunchKind { + switch self { + case .neovim, .vim: + return .terminalCommand + case .cursor, .vscode, .zed, .antigravity, .intellij: + return .application + } + } + + fileprivate var bundleIdentifiers: [String] { + switch self { + case .cursor: + return ["com.todesktop.230313mzl4w4u92"] + case .vscode: + return ["com.microsoft.VSCode", "com.microsoft.VSCodeInsiders"] + case .zed: + return ["dev.zed.Zed"] + case .antigravity: + return ["com.antigravity.editor"] + case .intellij: + return ["com.jetbrains.intellij", "com.jetbrains.intellij.ce"] + case .neovim, .vim: + return [] + } + } + + fileprivate var fallbackPaths: [String] { + switch self { + case .cursor: + return ["/Applications/Cursor.app"] + case .vscode: + return ["/Applications/Visual Studio Code.app", "/Applications/Visual Studio Code - Insiders.app"] + case .zed: + return ["/Applications/Zed.app"] + case .antigravity: + return ["/Applications/Antigravity.app"] + case .intellij: + return ["/Applications/IntelliJ IDEA.app", "/Applications/IntelliJ IDEA CE.app"] + case .neovim, .vim: + return [] + } + } + + fileprivate var executableCandidates: [String] { + switch self { + case .neovim: + return ["nvim"] + case .vim: + return ["vim"] + case .cursor: + return ["cursor"] + case .vscode: + return ["code"] + case .zed: + return ["zed"] + case .antigravity: + return ["antigravity"] + case .intellij: + return ["idea"] + } + } + } + + static func installedEditors() -> [EditorApp] { + EditorApp.allCases.filter { resolvedLocation(for: $0) != nil } + } + + static func resolvedLocation(for editor: EditorApp) -> String? { + switch editor.launchKind { + case .application: + return resolveEditorAppURL(for: editor)?.path + case .terminalCommand: + return resolveExecutable(for: editor) + } + } + + static func openProject( + path: String, + editor: EditorApp, + terminalApp: TerminalIntegration.ExternalTerminalApp + ) { + let projectPath = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !projectPath.isEmpty else { return } + + switch editor.launchKind { + case .application: + guard let appURL = resolveEditorAppURL(for: editor) else { return } + let projectURL = URL(fileURLWithPath: projectPath) + let configuration = NSWorkspace.OpenConfiguration() + NSWorkspace.shared.open( + [projectURL], + withApplicationAt: appURL, + configuration: configuration + ) { _, _ in } + case .terminalCommand: + guard let executable = resolveExecutable(for: editor) else { return } + let command = "\(shellQuote(executable)) \(shellQuote(projectPath))" + TerminalIntegration.openExternalTerminalWithCommand(command, app: terminalApp) + } + } + + private static func resolveEditorAppURL(for editor: EditorApp) -> URL? { + let workspace = NSWorkspace.shared + for bundleIdentifier in editor.bundleIdentifiers { + if let url = workspace.urlForApplication(withBundleIdentifier: bundleIdentifier) { + return url + } + } + for path in editor.fallbackPaths where FileManager.default.fileExists(atPath: path) { + return URL(fileURLWithPath: path) + } + return nil + } + + private static func resolveExecutable(for editor: EditorApp) -> String? { + for candidate in editor.executableCandidates { + if let path = findExecutable(named: candidate) { + return path + } + } + return nil + } + + private static func findExecutable(named command: String) -> String? { + let fileManager = FileManager.default + let envPath = ProcessInfo.processInfo.environment["PATH"] ?? "" + for entry in envPath.split(separator: ":") { + let candidate = "\(entry)/\(command)" + if fileManager.isExecutableFile(atPath: candidate) { + return candidate + } + } + + let fallbackDirectories = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"] + for directory in fallbackDirectories { + let candidate = "\(directory)/\(command)" + if fileManager.isExecutableFile(atPath: candidate) { + return candidate + } + } + + return nil + } + + private static func shellQuote(_ value: String) -> String { + if value.isEmpty { + return "''" + } + return "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" + } +} + +private enum EditorLaunchKind { + case application + case terminalCommand +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayDetailView.swift index 342f5128..0becd69d 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayDetailView.swift @@ -7,6 +7,11 @@ struct GatewayDetailView: View { @Environment(\.openURL) private var openURL @AppStorage("hackDesktop.setupGuidance.gateway.dismissed") private var setupDismissed = false @State private var showSetupAssistant = false + @State private var gatewayTokens: [GatewayTokenRecord] = [] + @State private var isLoadingTokens = false + @State private var latestIssuedToken: String? = nil + @State private var issuingTokenScope: GatewayTokenScope = .read + @State private var newTokenLabel = "" var body: some View { NavigationStack { @@ -30,6 +35,14 @@ struct GatewayDetailView: View { SetupAssistantView(initialSection: .gateway) .environment(model) } + .task { + await refreshGatewayTokens() + } + .onChange(of: model.lastUpdated) { _, _ in + Task { + await refreshGatewayTokens() + } + } } } @@ -99,10 +112,19 @@ struct GatewayDetailView: View { } else { VStack(alignment: .leading, spacing: 12) { ForEach(Array(exposures.enumerated()), id: \.element.id) { index, exposure in - NavigationLink(value: exposure) { - exposureRow(exposure) + if let destination = settingsItem(for: exposure) { + Button { + openSettings(destination) + } label: { + exposureRow(exposure: exposure, showChevron: true) + } + .buttonStyle(.plain) + } else { + NavigationLink(value: exposure) { + exposureRow(exposure: exposure, showChevron: true) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) if index < exposures.count - 1 { Divider() } @@ -142,13 +164,73 @@ struct GatewayDetailView: View { } private var tokensCard: some View { - let rows = tokenRows - return Group { - if rows.isEmpty { - EmptyView() - } else { - GlassCard(title: "Tokens", systemImage: "key") { - DetailRows(rows: rows) + GlassCard(title: "Tokens", systemImage: "key") { + VStack(alignment: .leading, spacing: 12) { + if let latestIssuedToken { + InlineCallout( + tone: .good, + title: "New token issued", + message: "Store this token now. It will not be shown again.", + actions: [ + InlineCalloutAction(label: "Copy token", systemImage: "doc.on.doc") { + TerminalIntegration.copyToClipboard(latestIssuedToken) + } + ] + ) + } + + HStack(spacing: 10) { + Picker("Scope", selection: $issuingTokenScope) { + Text("Read").tag(GatewayTokenScope.read) + Text("Write").tag(GatewayTokenScope.write) + } + .pickerStyle(.segmented) + .frame(width: 180) + + TextField("Optional label", text: $newTokenLabel) + .textFieldStyle(.roundedBorder) + .font(.mono(.caption)) + + Button { + Task { await createGatewayToken() } + } label: { + Label("Create token", systemImage: "plus") + } + .adaptiveToolbarButtonProminent() + + Button { + Task { await refreshGatewayTokens() } + } label: { + Label("Reload", systemImage: "arrow.clockwise") + } + .adaptiveToolbarButton() + + Spacer() + } + + if isLoadingTokens { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Loading tokens…") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + } + + tokenSummaryRow + + if sortedTokens.isEmpty { + Text("No tokens found.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } else { + tokenTableHeader + ForEach(sortedTokens) { token in + tokenTableRow(token) + Divider() + .opacity(0.18) + } } } } @@ -208,21 +290,16 @@ struct GatewayDetailView: View { ] } - private var tokenRows: [DetailRowItem] { - var rows: [DetailRowItem] = [] - if let tokensActive = gateway?.tokensActive { - rows.append(DetailRowItem(label: "Active", value: String(tokensActive))) - } - if let tokensRead = gateway?.tokensRead { - rows.append(DetailRowItem(label: "Read", value: String(tokensRead))) - } - if let tokensWrite = gateway?.tokensWrite { - rows.append(DetailRowItem(label: "Write", value: String(tokensWrite))) - } - if let tokensRevoked = gateway?.tokensRevoked { - rows.append(DetailRowItem(label: "Revoked", value: String(tokensRevoked))) + private var sortedTokens: [GatewayTokenRecord] { + gatewayTokens.sorted { lhs, rhs in + if lhs.revokedAt == nil, rhs.revokedAt != nil { + return true + } + if lhs.revokedAt != nil, rhs.revokedAt == nil { + return false + } + return lhs.createdAt > rhs.createdAt } - return rows } private var gatewayWarnings: [String] { @@ -252,7 +329,7 @@ struct GatewayDetailView: View { } } - private func exposureRow(_ exposure: GatewayExposure) -> some View { + private func exposureRow(exposure: GatewayExposure, showChevron: Bool) -> some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { Label(exposure.label, systemImage: exposureIcon(exposure)) @@ -263,9 +340,11 @@ struct GatewayDetailView: View { BadgePill(label: dependencyLabel, tint: dependencyColor) } StatusPill(text: exposure.statusLabel, tone: exposure.statusTone) - Image(systemName: "chevron.right") - .font(.mono(.caption)) - .foregroundStyle(.tertiary) + if showChevron { + Image(systemName: "chevron.right") + .font(.mono(.caption)) + .foregroundStyle(.tertiary) + } } if let detail = exposure.detail, !detail.isEmpty { Text(detail) @@ -280,6 +359,179 @@ struct GatewayDetailView: View { } .padding(.vertical, 4) } + + @ViewBuilder + private var tokenSummaryRow: some View { + HStack(spacing: 8) { + tokenSummaryBadge(label: "Active", value: gateway?.tokensActive ?? activeTokenCount) + tokenSummaryBadge(label: "Read", value: gateway?.tokensRead ?? readTokenCount) + tokenSummaryBadge(label: "Write", value: gateway?.tokensWrite ?? writeTokenCount) + tokenSummaryBadge(label: "Revoked", value: gateway?.tokensRevoked ?? revokedTokenCount) + Spacer() + } + } + + private var tokenTableHeader: some View { + HStack(spacing: 10) { + Text("ID") + .frame(minWidth: 160, alignment: .leading) + Text("Scope") + .frame(width: 54, alignment: .leading) + Text("Label") + .frame(minWidth: 120, alignment: .leading) + Text("Created") + .frame(width: 160, alignment: .leading) + Text("Last used") + .frame(width: 120, alignment: .leading) + Text("State") + .frame(width: 76, alignment: .leading) + Spacer(minLength: 0) + Text("Actions") + .frame(width: 128, alignment: .trailing) + } + .font(.mono(.caption2, weight: .semibold)) + .foregroundStyle(.secondary) + } + + private func tokenTableRow(_ token: GatewayTokenRecord) -> some View { + HStack(spacing: 10) { + Text(token.id) + .font(.mono(.caption2)) + .lineLimit(1) + .truncationMode(.middle) + .frame(minWidth: 160, alignment: .leading) + Text(token.scope.rawValue) + .font(.mono(.caption2)) + .frame(width: 54, alignment: .leading) + Text(token.label ?? "—") + .font(.mono(.caption2)) + .lineLimit(1) + .truncationMode(.tail) + .frame(minWidth: 120, alignment: .leading) + Text(formatTimestamp(token.createdAt)) + .font(.mono(.caption2)) + .frame(width: 160, alignment: .leading) + Text(formatRelativeTimestamp(token.lastUsedAt)) + .font(.mono(.caption2)) + .frame(width: 120, alignment: .leading) + Text(token.revokedAt == nil ? "Active" : "Revoked") + .font(.mono(.caption2)) + .foregroundStyle(token.revokedAt == nil ? Color.green : Color.orange) + .frame(width: 76, alignment: .leading) + Spacer(minLength: 0) + HStack(spacing: 8) { + Button("Copy ID") { + TerminalIntegration.copyToClipboard(token.id) + } + .adaptiveToolbarButton() + if token.revokedAt == nil { + Button("Revoke") { + Task { await revokeToken(token) } + } + .adaptiveToolbarButton() + } + } + .frame(width: 128, alignment: .trailing) + } + } + + private func tokenSummaryBadge(label: String, value: Int) -> some View { + HStack(spacing: 6) { + Text(label) + .font(.mono(.caption2)) + Text(String(value)) + .font(.mono(.caption2, weight: .semibold)) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + Capsule(style: .continuous) + .fill(.thinMaterial) + ) + .overlay( + Capsule(style: .continuous) + .stroke(Color.primary.opacity(0.14), lineWidth: 1) + ) + } + + private var activeTokenCount: Int { + gatewayTokens.filter { $0.revokedAt == nil }.count + } + + private var revokedTokenCount: Int { + gatewayTokens.filter { $0.revokedAt != nil }.count + } + + private var readTokenCount: Int { + gatewayTokens.filter { $0.revokedAt == nil && $0.scope == .read }.count + } + + private var writeTokenCount: Int { + gatewayTokens.filter { $0.revokedAt == nil && $0.scope == .write }.count + } + + private func refreshGatewayTokens() async { + isLoadingTokens = true + defer { isLoadingTokens = false } + gatewayTokens = await model.fetchGatewayTokens() + } + + private func createGatewayToken() async { + let trimmedLabel = newTokenLabel.trimmingCharacters(in: .whitespacesAndNewlines) + guard let issued = await model.createGatewayToken( + scope: issuingTokenScope, + label: trimmedLabel.isEmpty ? nil : trimmedLabel + ) else { + return + } + newTokenLabel = "" + latestIssuedToken = issued.token + await refreshGatewayTokens() + } + + private func revokeToken(_ token: GatewayTokenRecord) async { + let revoked = await model.revokeGatewayToken(id: token.id) + guard revoked else { return } + await refreshGatewayTokens() + } + + private func formatTimestamp(_ value: String?) -> String { + guard let value, let date = ISO8601DateFormatter().date(from: value) else { + return value ?? "—" + } + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: date) + } + + private func formatRelativeTimestamp(_ value: String?) -> String { + guard let value, let date = ISO8601DateFormatter().date(from: value) else { + return "—" + } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter.localizedString(for: date, relativeTo: Date()) + } + + private func settingsItem(for exposure: GatewayExposure) -> SettingsSidebarItem? { + switch exposure.id { + case "cloudflare": + return .cloudflare + case "tailscale": + return .tailscale + default: + return nil + } + } + + private func openSettings(_ item: SettingsSidebarItem) { + NotificationCenter.default.post( + name: .hackSettingsRequested, + object: nil, + userInfo: [SettingsNavigationRequest.paneKey: item.rawValue] + ) + } } #if DEBUG diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayExposure+Status.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayExposure+Status.swift index 646e6041..b639caa3 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayExposure+Status.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayExposure+Status.swift @@ -29,7 +29,7 @@ extension GatewayExposure { var statusTone: StatusTone { if isLanLoopbackLocalOnly { - return .neutral + return .good } switch resolvedState { case .running: @@ -43,7 +43,7 @@ extension GatewayExposure { var statusColor: Color { if isLanLoopbackLocalOnly { - return .secondary + return .green } switch resolvedState { case .running: @@ -152,7 +152,7 @@ extension GatewayExposure { } } - private var isLanLoopbackLocalOnly: Bool { + fileprivate var isLanLoopbackLocalOnly: Bool { id == "lan" && resolvedState == .blocked && (detail ?? "").lowercased().contains("loopback") @@ -160,27 +160,62 @@ extension GatewayExposure { } enum GatewaySummaryState { - case running - case configured + case localOnly + case lan + case tailscale + case cloudflare + case mixed + case needsSetup case disabled + case down case unknown - static func resolve(exposures: [GatewayExposure], gatewayEnabled: Bool?) -> GatewaySummaryState { + static func resolve( + exposures: [GatewayExposure], + gatewayEnabled: Bool?, + globalInfraRunning: Bool? + ) -> GatewaySummaryState { + if gatewayEnabled == false { + return .disabled + } + + if globalInfraRunning == false { + return .down + } + if exposures.isEmpty { - if gatewayEnabled == true { return .configured } - if gatewayEnabled == false { return .disabled } + if gatewayEnabled == true { return .needsSetup } return .unknown } - if exposures.contains(where: { $0.resolvedState == .running }) { - return .running + let runningExposureIds = Set( + exposures + .filter { $0.resolvedState == .running } + .map(\.id) + ) + if runningExposureIds.contains("cloudflare") { + return .cloudflare + } + if runningExposureIds.contains("tailscale") { + return .tailscale + } + if runningExposureIds.contains("lan") { + return .lan + } + if !runningExposureIds.isEmpty { + return .mixed } + + if exposures.contains(where: \.isLanLoopbackLocalOnly) { + return .localOnly + } + if exposures.contains(where: { [.configured, .needsConfig, .blocked].contains($0.resolvedState) }) { - return .configured + return .needsSetup } + if exposures.allSatisfy({ $0.resolvedState == .disabled }) { - if gatewayEnabled == true { return .configured } - if gatewayEnabled == false { return .disabled } + return .disabled } return .unknown @@ -188,12 +223,22 @@ enum GatewaySummaryState { var label: String { switch self { - case .running: - return "Enabled" - case .configured: - return "Configured" + case .localOnly: + return "Local-only" + case .lan: + return "LAN" + case .tailscale: + return "Tailscale" + case .cloudflare: + return "Cloudflare" + case .mixed: + return "Multi" + case .needsSetup: + return "Needs setup" case .disabled: return "Disabled" + case .down: + return "Down" case .unknown: return "Unknown" } @@ -201,22 +246,22 @@ enum GatewaySummaryState { var tone: StatusTone { switch self { - case .running: + case .localOnly, .lan, .tailscale, .cloudflare, .mixed: return .good - case .configured: + case .needsSetup, .disabled, .down: return .warn - case .disabled, .unknown: + case .unknown: return .neutral } } var statusDotColor: Color? { switch self { - case .running: + case .localOnly, .lan, .tailscale, .cloudflare, .mixed: return .green - case .configured: + case .needsSetup, .disabled, .down: return .orange - case .disabled, .unknown: + case .unknown: return nil } } diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GhosttyTerminalSession.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GhosttyTerminalSession.swift index f322889e..2d753363 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GhosttyTerminalSession.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GhosttyTerminalSession.swift @@ -17,7 +17,7 @@ final class GhosttyTerminalSession { } enum Mode { - case logs(path: String) + case logs(path: String, branch: String?) case shell(workingDirectory: URL) case sessionAttach(sessionName: String, workingDirectory: URL?) } @@ -56,9 +56,21 @@ final class GhosttyTerminalSession { init(project: ProjectSummary) { self.project = project if let path = project.projectDir ?? project.repoRoot { - self.mode = .logs(path: path) + self.mode = .logs(path: path, branch: nil) } else { - self.mode = .logs(path: "") + self.mode = .logs(path: "", branch: nil) + } + configureTerminal() + } + + init(project: ProjectSummary, branch: String?) { + self.project = project + let normalizedBranch = branch?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedBranch = (normalizedBranch?.isEmpty == false) ? normalizedBranch : nil + if let path = project.projectDir ?? project.repoRoot { + self.mode = .logs(path: path, branch: resolvedBranch) + } else { + self.mode = .logs(path: "", branch: resolvedBranch) } configureTerminal() } @@ -296,7 +308,7 @@ final class GhosttyTerminalSession { private func resolveCommand(in environment: [String: String]) -> TerminalCommand { switch mode { - case let .logs(path): + case let .logs(path, branch): if path.isEmpty { return TerminalCommand( executableURL: URL(fileURLWithPath: "/usr/bin/env"), @@ -307,17 +319,25 @@ final class GhosttyTerminalSession { } if let hackPath = HackCLILocator.resolveHackExecutable(in: environment) { + var args = ["logs", "--pretty", "--path", path] + if let branch, !branch.isEmpty { + args.append(contentsOf: ["--branch", branch]) + } return TerminalCommand( executableURL: URL(fileURLWithPath: hackPath), - arguments: ["logs", "--pretty", "--path", path], + arguments: args, environment: environment, workingDirectory: URL(fileURLWithPath: path) ) } + var args = ["hack", "logs", "--pretty", "--path", path] + if let branch, !branch.isEmpty { + args.append(contentsOf: ["--branch", branch]) + } return TerminalCommand( executableURL: URL(fileURLWithPath: "/usr/bin/env"), - arguments: ["hack", "logs", "--pretty", "--path", path], + arguments: args, environment: environment, workingDirectory: URL(fileURLWithPath: path) ) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GlassCard.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GlassCard.swift index 2d703cc4..6d9c6b5e 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GlassCard.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GlassCard.swift @@ -31,26 +31,53 @@ struct GlassCard: View { } private extension View { - @ViewBuilder func cardBackground() -> some View { + modifier(AdaptiveCardBackgroundModifier()) + } +} + +private struct AdaptiveCardBackgroundModifier: ViewModifier { + @Environment(\.colorScheme) private var colorScheme + + func body(content: Content) -> some View { if #available(macOS 26, *) { - self - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(.thinMaterial) - ) + content + .background(cardBackground) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - .shadow(color: .black.opacity(0.04), radius: 8, y: 2) + .shadow(color: .black.opacity(colorScheme == .dark ? 0.22 : 0.05), radius: 10, y: 3) } else { - self - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(.thinMaterial) - ) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(.primary.opacity(0.08), lineWidth: 1) - ) + content + .background(cardBackground) } } + + private var cardBackground: some View { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(baseFill) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(materialFill) + .opacity(materialOpacity) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(borderColor, lineWidth: 1) + ) + } + + private var baseFill: Color { + colorScheme == .dark ? Color.black.opacity(0.44) : Color.white.opacity(0.86) + } + + private var materialFill: Material { + colorScheme == .dark ? .ultraThinMaterial : .thinMaterial + } + + private var materialOpacity: Double { + colorScheme == .dark ? 0.42 : 0.62 + } + + private var borderColor: Color { + colorScheme == .dark ? Color.white.opacity(0.16) : Color.black.opacity(0.08) + } } diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GlobalStatusStrip.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GlobalStatusStrip.swift index e2fb2947..412f4b6e 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GlobalStatusStrip.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GlobalStatusStrip.swift @@ -18,7 +18,7 @@ struct GlobalStatusStrip: View { } var body: some View { - HStack(spacing: placement == .titlebar ? 10 : 10) { + HStack(spacing: 10) { selectorPill if placement != .titlebar { Spacer() @@ -28,7 +28,10 @@ struct GlobalStatusStrip: View { Text(lastUpdatedText) .font(.mono(.caption2)) .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) } + titlebarProjectActionControl if placement == .titlebar { Divider() .frame(height: 14) @@ -94,12 +97,12 @@ struct GlobalStatusStrip: View { } label: { Image(systemName: "ellipsis") .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.primary.opacity(0.85)) + .foregroundStyle(titlebarIconForeground) .frame(width: 22, height: 22) .background( Circle() .strokeBorder( - colorScheme == .dark ? Color.white.opacity(0.14) : Color.black.opacity(0.10), + titlebarIconStroke, lineWidth: 1 ) .background(Circle().fill(Color.clear)) @@ -114,15 +117,29 @@ struct GlobalStatusStrip: View { .padding(.horizontal, placement == .titlebar ? 10 : 0) .padding(.vertical, placement == .titlebar ? 4 : 6) .background(titlebarPillBackground) + .fixedSize(horizontal: placement == .titlebar, vertical: false) + .animation(.easeInOut(duration: 0.18), value: stripLayoutSignature) } private var selectorPill: some View { Menu { - Button("System: Runtime") { - model.selectedItem = .runtime + Button("Dashboard") { + model.selectedItem = .home } - Button("System: Gateway") { - model.selectedItem = .gateway + Divider() + Button("Settings: Runtime") { + NotificationCenter.default.post( + name: .hackSettingsRequested, + object: nil, + userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.runtime.rawValue] + ) + } + Button("Settings: Gateway") { + NotificationCenter.default.post( + name: .hackSettingsRequested, + object: nil, + userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.gateway.rawValue] + ) } Divider() ForEach(model.projects) { project in @@ -137,12 +154,13 @@ struct GlobalStatusStrip: View { Text(selectorLabel) .font(.mono(.caption, weight: .semibold)) .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: placement == .titlebar ? 220 : .infinity, alignment: .leading) Image(systemName: "chevron.down") .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.secondary) .offset(y: 0.5) } - .frame(minWidth: placement == .titlebar ? 160 : 0, alignment: .leading) .padding(.horizontal, placement == .titlebar ? 4 : 12) .padding(.vertical, placement == .titlebar ? 2 : 6) .background(selectorBackground) @@ -164,15 +182,19 @@ struct GlobalStatusStrip: View { private var titlebarPillBackground: some View { if placement == .titlebar { RoundedRectangle(cornerRadius: 999, style: .continuous) - .fill(colorScheme == .dark ? AnyShapeStyle(.regularMaterial) : AnyShapeStyle(Color.white.opacity(0.78))) + .fill(.regularMaterial) + .overlay( + RoundedRectangle(cornerRadius: 999, style: .continuous) + .fill(titlebarPillTint) + ) .overlay( RoundedRectangle(cornerRadius: 999, style: .continuous) .strokeBorder( - colorScheme == .dark ? Color.white.opacity(0.10) : Color.black.opacity(0.08), + titlebarPillStroke, lineWidth: 1 ) ) - .shadow(color: Color.black.opacity(0.10), radius: 18, x: 0, y: 10) + .shadow(color: titlebarPillShadow, radius: 18, x: 0, y: 10) } else { EmptyView() } @@ -207,6 +229,11 @@ struct GlobalStatusStrip: View { @ViewBuilder private var statusCluster: some View { switch model.selectedItem { + case .home: + HStack(spacing: 8) { + StatusPill(text: runtimeLabel, tone: runtimeTone) + StatusPill(text: gatewayLabel, tone: gatewayTone) + } case .runtime: HStack(spacing: 8) { StatusPill(text: runtimeLabel, tone: runtimeTone) @@ -217,17 +244,13 @@ struct GlobalStatusStrip: View { case let .project(id): if let project = model.projects.first(where: { $0.id == id }) { if project.isRuntimeConfigured { - HStack(spacing: 6) { - Text(project.runtimeStatusLabel) - .font(.mono(.caption)) - .foregroundStyle(.secondary) - RuntimeStatusDot( - status: project.runtimeStatus ?? fallbackRuntimeStatus(for: project), - runtimeHealthy: model.runtimeOverallOk - ) - } - } else { + projectRuntimeCluster(for: project) + } else if project.status == .missing { + StatusPill(text: "Project missing", tone: .warn) + } else if project.isExtensionOnly { LabelBadge(label: project.featureLabel ?? "Extensions", color: .purple) + } else { + StatusPill(text: "Runtime not configured", tone: .neutral) } } else { StatusPill(text: "Project: unknown", tone: .neutral) @@ -237,16 +260,90 @@ struct GlobalStatusStrip: View { } } + private func projectRuntimeCluster(for project: ProjectSummary) -> some View { + HStack(spacing: 8) { + Text(project.runtimeStatusLabel) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + RuntimeStatusDot( + status: project.runtimeStatus ?? fallbackRuntimeStatus(for: project), + runtimeHealthy: model.runtimeOverallOk + ) + } + } + + @ViewBuilder + private var titlebarProjectActionControl: some View { + if placement == .titlebar, + let project = selectedProject, + project.isRuntimeConfigured { + projectActionControl(for: project) + } + } + + @ViewBuilder + private func projectActionControl(for project: ProjectSummary) -> some View { + if let action = model.projectLifecycleActions[project.id] { + HStack(spacing: 4) { + ProgressView() + .controlSize(.small) + Text(action == .starting ? "Starting…" : "Stopping…") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background( + Capsule(style: .continuous) + .fill(titlebarActionChipFill) + ) + } else if canStopProject(project) { + Button { + Task { await model.stopProject(project) } + } label: { + Image(systemName: "stop.fill") + .font(.system(size: 10, weight: .semibold)) + .frame(width: 18, height: 18) + } + .buttonStyle(PressableCircleButtonStyle()) + .help("Stop project") + } else if canStartProject(project) { + Button { + Task { await model.startProject(project) } + } label: { + Image(systemName: "play.fill") + .font(.system(size: 10, weight: .semibold)) + .frame(width: 18, height: 18) + } + .buttonStyle(PressableCircleButtonStyle()) + .help("Start project") + } + } + private var runtimeLabel: String { - if model.runtimeOverallOk == true { return "Runtime: healthy" } - if model.runtimeOverallOk == false { return "Runtime: degraded" } - return "Runtime: unknown" + switch model.runtimeHealthState { + case .healthy: + return "Runtime: healthy" + case .down: + return "Runtime: down" + case .degraded: + return "Runtime: degraded" + case .unknown: + return "Runtime: unknown" + } } private var runtimeTone: StatusTone { - if model.runtimeOverallOk == true { return .good } - if model.runtimeOverallOk == false { return .warn } - return .neutral + switch model.runtimeHealthState { + case .healthy: + return .good + case .down, .degraded: + return .warn + case .unknown: + return .neutral + } } private var daemonLabel: String { @@ -313,6 +410,33 @@ struct GlobalStatusStrip: View { return model.projects.first(where: { $0.id == id }) } + private var stripLayoutSignature: String { + let selectedKey: String = switch model.selectedItem { + case .home: + "home" + case .runtime: + "runtime" + case .gateway: + "gateway" + case let .project(id): + "project:\(id)" + case .none: + "none" + } + let projectAction = selectedProject.flatMap { project in + model.projectLifecycleActions[project.id] + } + let actionKey: String = switch projectAction { + case .starting: + "starting" + case .stopping: + "stopping" + case .none: + "idle" + } + return "\(selectedKey)|\(selectorLabel)|\(actionKey)" + } + private func canStartProject(_ project: ProjectSummary) -> Bool { project.isRuntimeConfigured && (project.status == .stopped || project.status == .unknown || project.status == .unregistered) @@ -348,6 +472,9 @@ struct GlobalStatusStrip: View { let project = model.projects.first(where: { $0.id == id }) { return project.name } + if model.selectedItem == .home { + return "Dashboard" + } if model.selectedItem == .gateway { return "Gateway" } @@ -356,6 +483,8 @@ struct GlobalStatusStrip: View { private var selectorIcon: String { switch model.selectedItem { + case .home: + return "square.grid.2x2" case .gateway: return "dot.radiowaves.left.and.right" case .runtime: @@ -381,5 +510,53 @@ struct GlobalStatusStrip: View { return .unknown } } -} + private var titlebarIconForeground: Color { + dynamicColor( + light: NSColor.black.withAlphaComponent(0.74), + dark: NSColor.white.withAlphaComponent(0.92) + ) + } + + private var titlebarIconStroke: Color { + dynamicColor( + light: NSColor.black.withAlphaComponent(0.12), + dark: NSColor.white.withAlphaComponent(0.20) + ) + } + + private var titlebarPillTint: Color { + dynamicColor( + light: NSColor.white.withAlphaComponent(0.56), + dark: NSColor.black.withAlphaComponent(0.40) + ) + } + + private var titlebarPillStroke: Color { + dynamicColor( + light: NSColor.black.withAlphaComponent(0.10), + dark: NSColor.white.withAlphaComponent(0.14) + ) + } + + private var titlebarPillShadow: Color { + dynamicColor( + light: NSColor.black.withAlphaComponent(0.12), + dark: NSColor.black.withAlphaComponent(0.28) + ) + } + + private var titlebarActionChipFill: Color { + dynamicColor( + light: NSColor.black.withAlphaComponent(0.05), + dark: NSColor.white.withAlphaComponent(0.09) + ) + } + + private func dynamicColor(light: NSColor, dark: NSColor) -> Color { + Color(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + return isDark ? dark : light + }) + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/HomeDashboardView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/HomeDashboardView.swift new file mode 100644 index 00000000..7dd50749 --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/HomeDashboardView.swift @@ -0,0 +1,256 @@ +import SwiftUI + +import HackDesktopModels + +struct HomeDashboardView: View { + @Environment(DashboardModel.self) private var model + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + healthCard + projectsCard + } + .padding(16) + } + } + + private var healthCard: some View { + GlassCard(title: "System health", systemImage: "waveform.path.ecg") { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + HealthMetricChip(title: "Runtime", value: runtimeState.label, tone: runtimeState.tone) + HealthMetricChip(title: "Daemon", value: daemonState.label, tone: daemonState.tone) + HealthMetricChip(title: "Gateway", value: gatewayState.label, tone: gatewayState.tone) + HealthMetricChip(title: "Global", value: globalState.label, tone: globalState.tone) + Spacer(minLength: 0) + } + Text("\(model.projects.count) project\(model.projects.count == 1 ? "" : "s") registered") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + } + } + + private var projectsCard: some View { + GlassCard(title: "Projects", systemImage: "shippingbox") { + VStack(alignment: .leading, spacing: 0) { + projectGroupSection( + title: "Running", + count: runningProjects.count, + projects: runningProjects, + emptyMessage: "No running projects." + ) + Divider() + .opacity(0.24) + .padding(.vertical, 8) + projectGroupSection( + title: "Not running", + count: stoppedProjects.count, + projects: stoppedProjects, + emptyMessage: "No stopped projects." + ) + } + } + } + + @ViewBuilder + private func projectGroupSection( + title: String, + count: Int, + projects: [ProjectSummary], + emptyMessage: String + ) -> some View { + HStack(alignment: .center, spacing: 8) { + Text(title) + .font(.mono(.caption, weight: .semibold)) + .foregroundStyle(.secondary) + Text("\(count)") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 2) + .padding(.bottom, 8) + + if projects.isEmpty { + Text(emptyMessage) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + .padding(.bottom, 8) + } else { + ForEach(Array(projects.enumerated()), id: \.element.id) { index, project in + ProjectListRow(project: project) { + model.selectedItem = .project(project.id) + } + if index != projects.count - 1 { + Divider() + .opacity(0.2) + } + } + } + } + + private var runningProjects: [ProjectSummary] { + model.projects.filter { isProjectRunning($0) } + } + + private var stoppedProjects: [ProjectSummary] { + model.projects.filter { !isProjectRunning($0) } + } + + private func isProjectRunning(_ project: ProjectSummary) -> Bool { + project.status == .running || project.runtimeStatus == .running + } + + private var runtimeState: (label: String, tone: HealthMetricChip.Tone) { + switch model.runtimeHealthState { + case .healthy: + return ("Healthy", .good) + case .down: + return ("Down", .warn) + case .degraded: + return ("Degraded", .warn) + case .unknown: + return ("Unknown", .neutral) + } + } + + private var daemonState: (label: String, tone: HealthMetricChip.Tone) { + switch model.daemonStatus?.resolvedLabel { + case .running: + return ("Running", .good) + case .starting: + return ("Starting", .warn) + case .stale: + return ("Stale", .warn) + case .stopped: + return ("Stopped", .warn) + case nil: + return ("Unknown", .neutral) + } + } + + private var gatewayState: (label: String, tone: HealthMetricChip.Tone) { + if let state = model.gatewaySummaryState { + switch state.tone { + case .good: + return (state.label, .good) + case .warn: + return (state.label, .warn) + case .neutral: + return (state.label, .neutral) + } + } + return ("Unknown", .neutral) + } + + private var globalState: (label: String, tone: HealthMetricChip.Tone) { + if model.globalInfraRunning { + return ("Running", .good) + } + if model.globalInfraDown { + return ("Down", .warn) + } + return ("Unknown", .neutral) + } +} + +private struct ProjectListRow: View { + let project: ProjectSummary + let action: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: action) { + HStack(spacing: 10) { + Circle() + .fill(statusColor) + .frame(width: 7, height: 7) + + VStack(alignment: .leading, spacing: 3) { + Text(project.name) + .font(.mono(.subheadline, weight: .semibold)) + if let host = project.devHost, !host.isEmpty { + Text(host) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + } + Spacer() + Text(statusText) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 2) + .padding(.vertical, 9) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(isHovered ? Color.primary.opacity(0.06) : .clear) + ) + } + .buttonStyle(.plain) + .onHover { hovering in + isHovered = hovering + } + } + + private var statusColor: Color { + if project.status == .running || project.runtimeStatus == .running { return .green } + if project.status == .missing { return .orange } + return .secondary + } + + private var statusText: String { + if project.status == .running || project.runtimeStatus == .running { return "Running" } + if project.status == .missing { return "Missing" } + return "Stopped" + } +} + +private struct HealthMetricChip: View { + enum Tone { + case good + case warn + case neutral + } + + let title: String + let value: String + let tone: Tone + + var body: some View { + HStack(spacing: 6) { + Circle() + .fill(indicatorColor) + .frame(width: 7, height: 7) + Text(title) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + Text(value) + .font(.mono(.caption, weight: .semibold)) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + Capsule(style: .continuous) + .fill(.thinMaterial) + ) + .overlay( + Capsule(style: .continuous) + .stroke(Color.primary.opacity(0.12), lineWidth: 1) + ) + } + + private var indicatorColor: Color { + switch tone { + case .good: + return .green + case .warn: + return .orange + case .neutral: + return .secondary + } + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/LabelBadge.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/LabelBadge.swift index 34034216..c23765ff 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/LabelBadge.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/LabelBadge.swift @@ -8,6 +8,8 @@ struct BadgePill: View { if #available(macOS 26, *) { Text(label) .font(.mono(.caption2, weight: .semibold)) + .lineLimit(1) + .truncationMode(.tail) .padding(.horizontal, 8) .padding(.vertical, 4) .foregroundStyle(tint) @@ -19,6 +21,8 @@ struct BadgePill: View { } else { Text(label) .font(.mono(.caption2, weight: .semibold)) + .lineLimit(1) + .truncationMode(.tail) .padding(.horizontal, 8) .padding(.vertical, 4) .foregroundStyle(tint) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/MenuBarView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/MenuBarView.swift index 3881061f..dea71829 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/MenuBarView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/MenuBarView.swift @@ -165,15 +165,27 @@ public struct MenuBarView: View { // MARK: - Status Helpers private var runtimeStatusText: String { - if model.runtimeOverallOk == true { return "Healthy" } - if model.runtimeOverallOk == false { return "Degraded" } - return "Unknown" + switch model.runtimeHealthState { + case .healthy: + return "Healthy" + case .down: + return "Down" + case .degraded: + return "Degraded" + case .unknown: + return "Unknown" + } } private var runtimeStatusIcon: String { - if model.runtimeOverallOk == true { return "checkmark.circle.fill" } - if model.runtimeOverallOk == false { return "exclamationmark.triangle.fill" } - return "questionmark.circle" + switch model.runtimeHealthState { + case .healthy: + return "checkmark.circle.fill" + case .down, .degraded: + return "exclamationmark.triangle.fill" + case .unknown: + return "questionmark.circle" + } } private var gatewayStatusText: String { @@ -183,10 +195,16 @@ public struct MenuBarView: View { private var gatewayStatusIcon: String { guard let state = model.gatewaySummaryState else { return "questionmark.circle" } switch state { - case .running: return "checkmark.circle.fill" - case .configured: return "gear.circle" - case .disabled: return "minus.circle" - case .unknown: return "questionmark.circle" + case .localOnly, .lan, .tailscale, .cloudflare, .mixed: + return "checkmark.circle.fill" + case .needsSetup: + return "exclamationmark.triangle.fill" + case .disabled: + return "minus.circle" + case .down: + return "xmark.circle.fill" + case .unknown: + return "questionmark.circle" } } diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift index e05f948d..5363f6d9 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import HackDesktopModels @@ -5,36 +6,36 @@ import HackDesktopModels struct ProjectDetailView: View { @Environment(DashboardModel.self) private var model @Environment(\.openURL) private var openURL + @Environment(\.colorScheme) private var colorScheme + @AppStorage("hackDesktop.preferences.defaultTerminal") private var preferredExternalTerminalRaw = TerminalIntegration.ExternalTerminalApp.terminal.rawValue + @AppStorage("hackDesktop.sessions.preferredExternalTerminal") private var legacyPreferredExternalTerminalRaw = TerminalIntegration.ExternalTerminalApp.terminal.rawValue + @AppStorage("hackDesktop.preferences.defaultIDE") private var preferredEditorRaw = EditorIntegration.EditorApp.cursor.rawValue + @AppStorage("hackDesktop.preferences.defaultCodingAgent") private var preferredCodingAgentRaw = CodingAgentIntegration.AgentApp.codex.rawValue + @AppStorage("hackDesktop.preferences.defaultCodingAgentBinaryPath") private var preferredCodingAgentBinaryPathRaw = "" let project: ProjectSummary - @State private var showInspectorSidebar = true + @State private var showOverviewSidebar = true @State private var selectedService: String? = nil @State private var hoveredService: String? = nil @State private var isControlBarHovered = false @State private var hoveredControl: ProjectTab? = nil @State private var isStartHovered = false @State private var isStopHovered = false - @State private var activeSession: MuxSessionSummary? = nil - @State private var pendingStopSession: MuxSessionSummary? = nil + @State private var showInfoPanel = false + @State private var expandedBranches: Set = [] + @State private var showAddBranchSheet = false + @State private var newBranchName = "" + @State private var newBranchNote = "" var body: some View { @Bindable var model = model - ZStack(alignment: .top) { - VStack(alignment: .leading, spacing: 0) { - tabContent - .id(effectiveTab) - .transition(.opacity.combined(with: .move(edge: .trailing))) - .animation(.easeInOut(duration: 0.2), value: effectiveTab) - .padding(.top, headerHeight + 8) - .padding(.bottom, 72) - } - - header - .padding(.horizontal, 24) - .padding(.top, 10) - .padding(.bottom, 8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(topFadeOverlay) + VStack(alignment: .leading, spacing: 0) { + projectPageHeader + tabContent + .id(effectiveTab) + .transition(.opacity.combined(with: .move(edge: .trailing))) + .animation(.easeInOut(duration: 0.2), value: effectiveTab) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } .overlay(alignment: .bottom) { bottomControlBar @@ -47,55 +48,143 @@ struct ProjectDetailView: View { .onChange(of: model.selectedProjectTab) { _, _ in ensureSelectedTab() } - .sheet(item: $activeSession) { session in - SessionAttachView(project: project, session: session) + .sheet(isPresented: $showAddBranchSheet) { + addBranchSheet } - .confirmationDialog( - "Stop session?", - isPresented: Binding( - get: { pendingStopSession != nil }, - set: { value in - if value == false { pendingStopSession = nil } + } + + private var projectPageHeader: some View { + VStack(alignment: .leading, spacing: 12) { + headerBreadcrumb + + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Image(systemName: project.isRuntimeConfigured ? "cube.transparent" : "puzzlepiece") + .font(.mono(.subheadline, weight: .semibold)) + .foregroundStyle(.secondary) + Text(project.name) + .font(.mono(.headline, weight: .semibold)) + .lineLimit(1) + .truncationMode(.tail) + RuntimeStatusBadge(status: runtimeStatus, runtimeHealthy: runtimeHealthy) + } + if let host = projectHost { + Button { + openServiceHost(host) + } label: { + Label(host, systemImage: "lock.shield") + .font(.mono(.caption)) + .lineLimit(1) + .truncationMode(.middle) + } + .buttonStyle(.plain) + .linkHover() + } } - ) - ) { - Button("Stop", role: .destructive) { - guard let session = pendingStopSession else { return } - pendingStopSession = nil - Task { - await model.stopSession(sessionName: session.name) - await model.refresh() + Spacer(minLength: 8) + HStack(spacing: 8) { + if isProjectLifecycleBusy { + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + Text(projectLifecycleLabel) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background( + Capsule(style: .continuous) + .fill(colorScheme == .dark ? Color.white.opacity(0.08) : Color.black.opacity(0.04)) + ) + } else { + if canStart { + Button { + Task { await model.startProject(project) } + } label: { + Label("Start", systemImage: "play.fill") + } + .buttonStyle(PressableIconButtonStyle()) + } + + if canStop { + Button { + Task { await model.stopProject(project) } + } label: { + Label("Stop", systemImage: "stop.fill") + } + .buttonStyle(PressableIconButtonStyle()) + } + } + + if projectOpenPath != nil { + openInProjectButton + codingAgentQuickAccessButton + } + + if !sessionEntries.isEmpty { + sessionQuickAccessButton + } } } - Button("Cancel", role: .cancel) { - pendingStopSession = nil - } - } message: { - if let session = pendingStopSession { - Text("This will kill \(session.name).") + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + headerMetricPill("Services", value: "\(runningServiceCount)/\(serviceNames.count) running") + headerMetricPill("Branches", value: "\(branchEntries.count)") + headerMetricPill("Sessions", value: "\(sessionEntries.count)") + if project.supportsTickets { + headerMetricPill("Tickets", value: "Enabled") + } + } } } + .padding(.horizontal, 24) + .padding(.top, 12) + .padding(.bottom, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(projectHeaderShape.fill(projectHeaderBackground)) + .overlay(alignment: .bottom) { + Rectangle() + .fill(projectHeaderStrokeColor) + .frame(height: 1) + } + .clipShape(projectHeaderShape) + .padding(.bottom, 8) + } + + private var headerBreadcrumb: some View { + HStack(spacing: 6) { + Text(project.name) + .font(.mono(.caption, weight: .semibold)) + .foregroundStyle(.primary) + Image(systemName: "chevron.right") + .font(.mono(.caption2, weight: .semibold)) + .foregroundStyle(.secondary) + Text(breadcrumbLabel(for: effectiveTab)) + .font(.mono(.caption, weight: .medium)) + .foregroundStyle(.secondary) + } + .lineLimit(1) + .truncationMode(.tail) } @ViewBuilder private var tabContent: some View { switch effectiveTab { case .overview: - projectTabContainer { - overviewContent - } + overviewContent + case .branches: + branchesContent + case .sessions: + sessionsContent case .logs: - projectTabContainer { - terminalMovedCard(kind: .logs) - } + terminalMovedCard(kind: .logs) case .shell: - projectTabContainer { - terminalMovedCard(kind: .shell) - } + terminalMovedCard(kind: .shell) case .tickets: - projectTabContainer { - TicketsView(project: project) - } + TicketsView(project: project) } } @@ -114,170 +203,43 @@ struct ProjectDetailView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - private func openTerminal(kind: TerminalDrawerModel.Kind) { - switch kind { - case .logs: - model.showLogs(for: project) - case .shell: - model.showShell(for: project) - } - } - - private func projectTabContainer(@ViewBuilder content: () -> some View) -> some View { - HStack(alignment: .top, spacing: 0) { - content() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - - if showInspectorSidebar { - Divider() - .opacity(0.2) - .transition(.opacity) - - ProjectInspectorColumn( - project: project, - meta: projectMeta, - selectedService: $selectedService, - onAttachSession: { session in - activeSession = session - }, - onStopSession: { session in - pendingStopSession = session - }, - onShowLogs: { - model.showLogs(for: project) - }, - onShowShell: { - model.showShell(for: project) - } - ) - .frame( - minWidth: 260, - idealWidth: 320, - maxWidth: 380, - maxHeight: .infinity, - alignment: .topLeading - ) - .transition(.move(edge: .trailing).combined(with: .opacity)) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(Color.white.opacity(0.06), lineWidth: 1) - ) - ) - .padding(.horizontal, 24) - .padding(.bottom, 32) - .animation(.easeInOut(duration: 0.2), value: showInspectorSidebar) - } - private var overviewContent: some View { ScrollView { - VStack(alignment: .leading, spacing: 20) { - if !project.isRuntimeConfigured { - runtimeNotConfiguredCard - } - servicesSection - } - .padding(24) - .frame(maxWidth: .infinity, alignment: .topLeading) - } - } - - private var header: some View { - HStack(alignment: .center, spacing: 12) { - Image(systemName: project.isRuntimeConfigured ? "cube.transparent.fill" : "puzzlepiece.extension.fill") - .font(.mono(.title2)) - .foregroundStyle(project.isRuntimeConfigured ? .blue : .purple) - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .center, spacing: 8) { - Text(project.name) - .font(.mono(.headline, weight: .semibold)) - headerStatus - } - if let headerSubtitle { - Text(headerSubtitle) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - } - Spacer() - primaryActionsBar - Button { - withAnimation(.easeInOut(duration: 0.2)) { - showInspectorSidebar.toggle() + HStack(alignment: .top, spacing: 24) { + VStack(alignment: .leading, spacing: 20) { + if !project.isRuntimeConfigured { + runtimeNotConfiguredCard + } + servicesSection + if showInfoPanel { + infoSection + .transition(.move(edge: .bottom).combined(with: .opacity)) + } } - } label: { - Image(systemName: "sidebar.trailing") - .font(.mono(.title3)) - } - .buttonStyle(PressableCircleButtonStyle()) - .accessibilityLabel(showInspectorSidebar ? "Hide details sidebar" : "Show details sidebar") - } - } - - @ViewBuilder - private var headerStatus: some View { - if project.isRuntimeConfigured { - RuntimeStatusBadge(status: runtimeStatus, runtimeHealthy: runtimeHealthy) - } else { - if let label = project.featureLabel { - LabelBadge(label: label, color: .purple) - } else { - LabelBadge(label: "Extensions", color: .purple) - } - } - } - - private var headerSubtitle: String? { - if let devHost = project.devHost { - return devHost - } - if let featureSummary = project.featureSummary { - return featureSummary - } - return nil - } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - private var primaryActionsBar: some View { - Menu { - Button("Refresh") { - Task { await model.refresh() } - } - if canStart { - Button("Start") { - Task { await model.startProject(project) } + if showOverviewSidebar, selectedService != nil { + Divider() + .opacity(0.2) + .transition(.opacity) + serviceDetailPanel + .frame(minWidth: 260, idealWidth: 300, maxWidth: 360, maxHeight: .infinity, alignment: .topLeading) + .transition(.move(edge: .trailing).combined(with: .opacity)) } } - if canStop { - Button("Stop") { - Task { await model.stopProject(project) } - } - } - if let url = devUrl { - Button("Open in Browser") { - openURL(url) - } - } - Divider() - Button("View Logs") { - model.showLogs(for: project) - } - Button("Open Shell") { - model.showShell(for: project) - } - if project.supportsTickets { - Button("Open Tickets") { - model.showTickets(for: project) - } - } - } label: { - Image(systemName: "ellipsis.circle") + .padding(24) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(Color.white.opacity(0.06), lineWidth: 1) + ) + ) + .padding(.horizontal, 24) + .padding(.bottom, 32) } - .buttonStyle(PressableIconButtonStyle()) } private var runtimeNotConfiguredCard: some View { @@ -371,7 +333,7 @@ struct ProjectDetailView: View { .onTapGesture { withAnimation(.easeInOut(duration: 0.2)) { selectedService = service - showInspectorSidebar = true + showOverviewSidebar = true } } .onHover { hovering in @@ -391,142 +353,825 @@ struct ProjectDetailView: View { } } - private var headerHeight: CGFloat { - 56 - } - - private var topFadeOverlay: some View { - Rectangle() - .fill(.ultraThinMaterial) - .frame(height: headerHeight + 20) - .mask( - LinearGradient( - colors: [Color.white, Color.white.opacity(0)], - startPoint: .top, - endPoint: .bottom - ) + private var branchesContent: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + branchesSection + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(Color.white.opacity(0.06), lineWidth: 1) + ) ) - .allowsHitTesting(false) + .padding(.horizontal, 24) + .padding(.bottom, 32) + } } - private var devUrl: URL? { - guard let host = project.devHost, !host.isEmpty else { return nil } - if host.contains("://") { - return URL(string: host) + private var sessionsContent: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + sessionsSection + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(Color.white.opacity(0.06), lineWidth: 1) + ) + ) + .padding(.horizontal, 24) + .padding(.bottom, 32) } - return URL(string: "https://\(host)") } - private var canStart: Bool { - project.isRuntimeConfigured && (project.status == .stopped || project.status == .unknown || project.status == .unregistered) - } + private var branchesSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Image(systemName: "arrow.triangle.branch") + .foregroundStyle(.secondary) + Text("Branch Instances") + .font(.mono(.headline, weight: .semibold)) + Spacer() + Button { + showAddBranchSheet = true + } label: { + Label("New branch", systemImage: "plus") + .font(.mono(.caption)) + } + .buttonStyle(PressableIconButtonStyle()) + } + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color.white.opacity(0.08)) + .frame(height: 1) + .offset(y: 8) + } - private var canStop: Bool { - project.isRuntimeConfigured && project.status == .running + if branchEntries.isEmpty { + Text("No branch instances found.") + .font(.mono(.subheadline)) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 10) { + ForEach(branchEntries, id: \.branch) { entry in + branchRow(entry) + Divider() + .opacity(0.2) + } + } + } + } } - private var runtimeStatus: ProjectRuntimeStatus { - project.runtimeStatus ?? fallbackRuntimeStatus - } + private var sessionsSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Image(systemName: "rectangle.3.group.bubble.left") + .foregroundStyle(.secondary) + Text("Sessions") + .font(.mono(.headline, weight: .semibold)) + Spacer() + Button { + Task { await model.startSession(for: project) } + } label: { + Label("Start session", systemImage: "plus") + .font(.mono(.caption)) + } + .buttonStyle(PressableIconButtonStyle()) + } + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color.white.opacity(0.08)) + .frame(height: 1) + .offset(y: 8) + } - private var projectMeta: ProjectMeta? { - project.meta ?? model.projectMetaById[project.id] + if sessionEntries.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("No active sessions found for this project.") + .font(.mono(.subheadline)) + .foregroundStyle(.secondary) + Text("Run `hack session start \(project.name)` in a terminal to create one.") + .font(.mono(.caption)) + .foregroundStyle(.tertiary) + } + } else { + VStack(alignment: .leading, spacing: 10) { + ForEach(sessionEntries, id: \.id) { session in + sessionRow(session) + Divider() + .opacity(0.2) + } + } + } + } } - private var runtimeHealthy: Bool? { - model.runtimeOverallOk - } + private var addBranchSheet: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Create Branch Instance") + .font(.mono(.headline, weight: .semibold)) - private struct ServiceStatus { - let label: String - let color: Color - let detail: String? - let urlLabel: String? - } + TextField("Branch name (e.g. fix-seat-geometry)", text: $newBranchName) + .textFieldStyle(.roundedBorder) - private var runtimeServicesByName: [String: RuntimeService] { - guard let runtime = project.runtime else { return [:] } - return Dictionary( - runtime.services.map { ($0.service, $0) }, - uniquingKeysWith: { first, _ in first } - ) - } + TextField("Optional note", text: $newBranchNote) + .textFieldStyle(.roundedBorder) - private var serviceHostsByName: [String: [String]] { - project.serviceHosts ?? [:] + HStack { + Spacer() + Button("Cancel") { + resetBranchDraft() + showAddBranchSheet = false + } + Button("Create & Start") { + let branch = newBranchName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !branch.isEmpty else { return } + let note = newBranchNote.trimmingCharacters(in: .whitespacesAndNewlines) + Task { + let didAdd = await model.addBranch( + for: project, + name: branch, + note: note.isEmpty ? nil : note + ) + if didAdd { + await model.startBranch(for: project, branch: branch) + } + } + resetBranchDraft() + showAddBranchSheet = false + } + .keyboardShortcut(.defaultAction) + .disabled(newBranchName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(18) + .frame(minWidth: 420) } - private var serviceNames: [String] { - let defined = project.definedServices ?? [] - let runtime = runtimeServicesByName.keys - let hosts = serviceHostsByName.keys - return Array(Set(defined).union(runtime).union(hosts)).sorted() + private func resetBranchDraft() { + newBranchName = "" + newBranchNote = "" } - private func serviceStatus(for service: String) -> ServiceStatus { - guard let runtime = runtimeServicesByName[service] else { - return ServiceStatus(label: "Not running", color: .secondary, detail: nil, urlLabel: serviceHostLabel(for: service)) - } - let total = runtime.containers.count - let running = runtime.containers.filter { $0.state.lowercased() == "running" }.count - let ports = runtime.containers.first(where: { !$0.ports.isEmpty })?.ports - let detail = ports?.isEmpty == false ? ports : nil - if total == 0 { - return ServiceStatus(label: "Not running", color: .secondary, detail: detail, urlLabel: serviceHostLabel(for: service)) - } - if running == total { - return ServiceStatus(label: "Running", color: .green, detail: detail, urlLabel: serviceHostLabel(for: service)) - } - if running > 0 { - return ServiceStatus(label: "\(running)/\(total) running", color: .orange, detail: detail, urlLabel: serviceHostLabel(for: service)) - } - return ServiceStatus(label: "Stopped", color: .orange, detail: detail, urlLabel: serviceHostLabel(for: service)) + private var branchEntries: [BranchRuntime] { + (project.branchRuntime ?? []).sorted { $0.branch.localizedCaseInsensitiveCompare($1.branch) == .orderedAscending } } - private func serviceHostLabel(for service: String) -> String? { - if let hosts = serviceHostsByName[service], let first = hosts.first { - if hosts.count > 1 { - return "\(first) +\(hosts.count - 1)" + private var sessionEntries: [ProjectSessionSummary] { + (project.sessions ?? []).sorted { lhs, rhs in + if lhs.source != rhs.source { + return lhs.source == .hack } - return first + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending } - guard let host = project.devHost, !host.isEmpty else { return nil } - return "\(service).\(host)" } - private func openServiceHost(_ host: String) { - let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - let urlString = trimmed.contains("://") ? trimmed : "https://\(trimmed)" - if let url = URL(string: urlString) { - openURL(url) + private var runningServiceCount: Int { + serviceNames.reduce(into: 0) { count, name in + let state = serviceStatus(for: name).runState + if state == .running || state == .partial { + count += 1 + } } } - private var fallbackRuntimeStatus: ProjectRuntimeStatus { - switch project.status { - case .running: - return .running - case .stopped: - return .stopped - case .missing: - return .missing - case .unknown: - return .unknown - case .unregistered: - return .unknown - } + private var projectHost: String? { + let host = project.devHost?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let host, !host.isEmpty else { return nil } + return host } - private var availableTabs: [ProjectTab] { - var tabs: [ProjectTab] = [.overview] - if project.isRuntimeConfigured { - tabs.append(.logs) - tabs.append(.shell) - } - if project.supportsTickets { - tabs.append(.tickets) - } + private func branchRow(_ entry: BranchRuntime) -> some View { + let status = branchStatus(for: entry.runtime) + return VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Button { + toggleBranchExpansion(entry.branch) + } label: { + Image(systemName: expandedBranches.contains(entry.branch) ? "chevron.down" : "chevron.right") + .font(.mono(.caption, weight: .semibold)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + + Text(entry.branch) + .font(.mono(.subheadline, weight: .semibold)) + + Spacer() + Text(status.label) + .font(.mono(.caption)) + .foregroundStyle(status.color) + } + + HStack(spacing: 10) { + Text("\(status.runningServices)/\(status.totalServices) services running") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + if let branchHost = branchHost(for: entry.branch) { + Button { + openServiceHost(branchHost) + } label: { + Text(branchHost) + .font(.mono(.caption2)) + } + .buttonStyle(.plain) + .linkHover() + } + Spacer() + } + + HStack(spacing: 8) { + if status.runningServices > 0 { + Button("Stop") { + Task { await model.stopBranch(for: project, branch: entry.branch) } + } + .buttonStyle(PressableIconButtonStyle()) + } else { + Button("Start") { + Task { await model.startBranch(for: project, branch: entry.branch) } + } + .buttonStyle(PressableIconButtonStyle()) + } + + Button("Logs") { + model.showLogs(for: project, branch: entry.branch) + } + .buttonStyle(PressableIconButtonStyle()) + + Button("Remove Alias") { + Task { await model.removeBranch(for: project, name: entry.branch) } + } + .buttonStyle(PressableIconButtonStyle()) + } + + if expandedBranches.contains(entry.branch) { + VStack(alignment: .leading, spacing: 6) { + ForEach(entry.runtime.services.sorted(by: { $0.service < $1.service }), id: \.service) { service in + let running = service.containers.filter { $0.state.lowercased() == "running" }.count + Text("\(service.service): \(running)/\(service.containers.count) running") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + } + .padding(.top, 2) + } + } + .padding(.vertical, 8) + .padding(.horizontal, 4) + } + + private func sessionRow(_ session: ProjectSessionSummary) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Text(session.name) + .font(.mono(.subheadline, weight: .semibold)) + Spacer() + BadgePill(label: session.backend.rawValue, tint: .secondary) + BadgePill(label: session.source == .hack ? "hack" : "external", tint: .secondary) + BadgePill(label: session.attached ? "attached" : "detached", tint: session.attached ? .green : .orange) + } + + HStack(spacing: 10) { + if let path = session.path, !path.isEmpty { + Text(path) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } else { + Text("Path unavailable") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + Spacer() + if let windows = session.windows { + Text("\(windows) window\(windows == 1 ? "" : "s")") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + } + + HStack(spacing: 8) { + Button("Attach (Drawer)") { + openTerminal( + kind: .shell, + command: attachCommand(for: session), + title: "\(session.name) (attached)" + ) + } + .buttonStyle(PressableIconButtonStyle()) + + Menu { + ForEach(installedExternalTerminalApps, id: \.self) { terminalApp in + Button { + preferredExternalTerminalRaw = terminalApp.rawValue + legacyPreferredExternalTerminalRaw = terminalApp.rawValue + openSession(session, terminalApp: terminalApp) + } label: { + Label( + "Open in \(terminalApp.displayName)", + systemImage: terminalApp == preferredExternalTerminal ? "checkmark.circle.fill" : "circle" + ) + } + } + } label: { + Label("Open In", systemImage: "arrow.up.right.square") + } + .buttonStyle(PressableIconButtonStyle()) + + Button("Stop") { + Task { await model.stopSession(name: session.name) } + } + .buttonStyle(PressableIconButtonStyle()) + } + } + .padding(.vertical, 8) + .padding(.horizontal, 4) + } + + private func toggleBranchExpansion(_ branch: String) { + if expandedBranches.contains(branch) { + expandedBranches.remove(branch) + } else { + expandedBranches.insert(branch) + } + } + + private func branchHost(for branch: String) -> String? { + guard let host = project.devHost, !host.isEmpty else { return nil } + return "\(branch).\(host)" + } + + private func branchStatus(for runtime: RuntimeProject) -> ( + label: String, + color: Color, + runningServices: Int, + totalServices: Int + ) { + let totalServices = runtime.services.count + let runningServices = runtime.services.reduce(into: 0) { count, service in + if service.containers.contains(where: { $0.state.lowercased() == "running" }) { + count += 1 + } + } + + if totalServices == 0 { + return ("No services", .secondary, runningServices, totalServices) + } + if runningServices == 0 { + return ("Stopped", .orange, runningServices, totalServices) + } + if runningServices == totalServices { + return ("Running", .green, runningServices, totalServices) + } + return ("Partial", .orange, runningServices, totalServices) + } + + private var featuresList: [String] { + project.features ?? project.extensionsEnabled ?? [] + } + + private var infoSection: some View { + VStack(alignment: .leading, spacing: 16) { + if !overviewRows.isEmpty { + sectionHeader("Meta") + DetailRows(rows: overviewRows) + } + if !pathRows.isEmpty { + sectionHeader("Paths") + DetailRows(rows: pathRows) + } + if !featuresList.isEmpty { + sectionHeader("Features") + LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 8)], alignment: .leading, spacing: 8) { + ForEach(featuresList, id: \.self) { feature in + BadgePill(label: feature, tint: .secondary) + } + } + } + } + } + + private func sectionHeader(_ title: String) -> some View { + Text(title) + .instrumentLabel() + } + + private var serviceDetailPanel: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Details") + .font(.mono(.headline, weight: .semibold)) + Spacer() + Button("All") { + withAnimation(.easeInOut(duration: 0.2)) { + self.selectedService = nil + } + } + .font(.mono(.caption)) + .buttonStyle(PressableIconButtonStyle()) + } + if let selectedService { + serviceDetailCard(for: selectedService) + } + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.white.opacity(0.04)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + ) + ) + } + + private var overviewRows: [DetailRowItem] { + var rows: [DetailRowItem] = [] + if let devHost = project.devHost { + rows.append(DetailRowItem(label: "Dev host", value: devHost)) + } + if let featureSummary = project.featureSummary { + rows.append(DetailRowItem(label: "Features", value: featureSummary)) + } + if project.isRuntimeConfigured { + rows.append(DetailRowItem(label: "Runtime", value: runtimeStatusValue)) + rows.append(DetailRowItem(label: "Kind", value: project.kind.rawValue)) + rows.append(DetailRowItem(label: "Status", value: project.status.rawValue)) + } else { + rows.append(DetailRowItem(label: "Runtime", value: "Not configured")) + rows.append(DetailRowItem(label: "Kind", value: project.kind.rawValue)) + } + return rows + } + + private var pathRows: [DetailRowItem] { + var rows: [DetailRowItem] = [] + if let repoRoot = project.repoRoot { + rows.append(DetailRowItem(label: "Repo root", value: repoRoot)) + } + if let projectDir = project.projectDir, projectDir != project.repoRoot { + rows.append(DetailRowItem(label: "Project dir", value: projectDir)) + } + return rows + } + + private var canStart: Bool { + project.isRuntimeConfigured + && !isProjectLifecycleBusy + && (project.status == .stopped || project.status == .unknown || project.status == .unregistered) + } + + private var canStop: Bool { + project.isRuntimeConfigured && !isProjectLifecycleBusy && project.status == .running + } + + private var projectLifecycleAction: ProjectLifecycleAction? { + model.projectLifecycleActions[project.id] + } + + private var isProjectLifecycleBusy: Bool { + projectLifecycleAction != nil + } + + private var projectLifecycleLabel: String { + switch projectLifecycleAction { + case .starting: + return "Starting…" + case .stopping: + return "Stopping…" + case .none: + return "Working…" + } + } + + private var runtimeStatus: ProjectRuntimeStatus { + project.runtimeStatus ?? fallbackRuntimeStatus + } + + private var runtimeHealthy: Bool? { + model.runtimeOverallOk + } + + private var runtimeStatusValue: String { + let base = project.runtimeStatusLabel + if runtimeHealthy == false, runtimeStatus == .running { + return "\(base) (degraded)" + } + return base + } + + private enum ServiceRunState { + case running + case partial + case stopped + case notRunning + } + + private struct ServiceStatus { + let runState: ServiceRunState + let label: String + let color: Color + let detail: String? + let urlLabel: String? + } + + private var runtimeServicesByName: [String: RuntimeService] { + guard let runtime = project.runtime else { return [:] } + return Dictionary(uniqueKeysWithValues: runtime.services.map { ($0.service, $0) }) + } + + private var serviceHostsByName: [String: [String]] { + project.serviceHosts ?? [:] + } + + private var serviceNames: [String] { + let defined = project.definedServices ?? [] + let runtime = runtimeServicesByName.keys + let hosts = serviceHostsByName.keys + return Array(Set(defined).union(runtime).union(hosts)).sorted { lhs, rhs in + let lhsStatus = serviceStatus(for: lhs) + let rhsStatus = serviceStatus(for: rhs) + let lhsRank = serviceSortRank(lhsStatus.runState) + let rhsRank = serviceSortRank(rhsStatus.runState) + if lhsRank == rhsRank { + return lhs.localizedCaseInsensitiveCompare(rhs) == .orderedAscending + } + return lhsRank < rhsRank + } + } + + private func serviceSortRank(_ state: ServiceRunState) -> Int { + switch state { + case .running: + return 0 + case .partial: + return 1 + case .stopped: + return 2 + case .notRunning: + return 3 + } + } + + private func serviceStatus(for service: String) -> ServiceStatus { + guard let runtime = runtimeServicesByName[service] else { + return ServiceStatus( + runState: .notRunning, + label: "Not running", + color: .secondary, + detail: nil, + urlLabel: serviceHostLabel(for: service) + ) + } + let total = runtime.containers.count + let running = runtime.containers.filter { $0.state.lowercased() == "running" }.count + let ports = runtime.containers.first(where: { !$0.ports.isEmpty })?.ports + let detail = ports?.isEmpty == false ? ports : nil + if total == 0 { + return ServiceStatus( + runState: .notRunning, + label: "Not running", + color: .secondary, + detail: detail, + urlLabel: serviceHostLabel(for: service) + ) + } + if running == total { + return ServiceStatus( + runState: .running, + label: "Running", + color: .green, + detail: detail, + urlLabel: serviceHostLabel(for: service) + ) + } + if running > 0 { + return ServiceStatus( + runState: .partial, + label: "\(running)/\(total) running", + color: .orange, + detail: detail, + urlLabel: serviceHostLabel(for: service) + ) + } + return ServiceStatus( + runState: .stopped, + label: "Stopped", + color: .orange, + detail: detail, + urlLabel: serviceHostLabel(for: service) + ) + } + + private func serviceHostLabel(for service: String) -> String? { + if let hosts = serviceHostsByName[service], let first = hosts.first { + if hosts.count > 1 { + return "\(first) +\(hosts.count - 1)" + } + return first + } + guard let host = project.devHost, !host.isEmpty else { return nil } + return "\(service).\(host)" + } + + private func serviceDetailCard(for service: String) -> some View { + let runtime = runtimeServicesByName[service] + let containers = runtime?.containers ?? [] + return VStack(alignment: .leading, spacing: 10) { + Text(service) + .font(.mono(.subheadline, weight: .semibold)) + if let hostLabel = serviceHostLabel(for: service) { + Button { + openServiceHost(hostLabel) + } label: { + Text(hostLabel) + .font(.mono(.caption)) + } + .buttonStyle(.plain) + .linkHover() + } + if let hosts = serviceHostsByName[service], hosts.count > 1 { + VStack(alignment: .leading, spacing: 4) { + ForEach(hosts, id: \.self) { host in + Button { + openServiceHost(host) + } label: { + Text(host) + .font(.mono(.caption2)) + } + .buttonStyle(.plain) + .linkHover() + } + } + } + if runtime != nil { + let runningCount = containers.filter { $0.state.lowercased() == "running" }.count + DetailRows(rows: [ + DetailRowItem(label: "Containers", value: "\(containers.count)"), + DetailRowItem(label: "Running", value: "\(runningCount)") + ]) + ForEach(containers, id: \.id) { container in + VStack(alignment: .leading, spacing: 6) { + Text(container.name) + .font(.mono(.caption, weight: .semibold)) + + DetailRows( + rows: containerOverviewRows(container), + labelWidth: 96 + ) + + if let networks = container.networks, !networks.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Networks") + .font(.mono(.caption2, weight: .semibold)) + .foregroundStyle(.secondary) + ForEach(networks, id: \.name) { network in + Text(networkSummary(network)) + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + .textSelection(.enabled) + } + } + } + + if let mounts = container.mounts, !mounts.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Mounts") + .font(.mono(.caption2, weight: .semibold)) + .foregroundStyle(.secondary) + ForEach(Array(mounts.enumerated()), id: \.offset) { _, mount in + Text(mountSummary(mount)) + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + .textSelection(.enabled) + } + } + } + + if let labels = container.labels, !labels.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Labels") + .font(.mono(.caption2, weight: .semibold)) + .foregroundStyle(.secondary) + ForEach(sortedLabels(labels), id: \.key) { entry in + Text("\(entry.key)=\(entry.value)") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + .textSelection(.enabled) + } + } + } + } + Divider() + .opacity(0.2) + } + } else { + Text("No running containers.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + } + } + + private func containerOverviewRows(_ container: RuntimeContainer) -> [DetailRowItem] { + var rows = [ + DetailRowItem(label: "ID", value: shortContainerID(container.id)), + DetailRowItem(label: "Status", value: container.status), + DetailRowItem(label: "State", value: container.state) + ] + if let image = container.image, !image.isEmpty { + rows.append(DetailRowItem(label: "Image", value: image)) + } + if !container.ports.isEmpty { + rows.append(DetailRowItem(label: "Ports", value: container.ports)) + } + if let workingDir = container.workingDir, !workingDir.isEmpty { + rows.append(DetailRowItem(label: "Workdir", value: workingDir)) + } + return rows + } + + private func shortContainerID(_ id: String) -> String { + let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > 12 else { return trimmed } + return String(trimmed.prefix(12)) + } + + private func networkSummary(_ network: RuntimeContainerNetwork) -> String { + var parts: [String] = [network.name] + if let ipAddress = network.ipAddress, !ipAddress.isEmpty { + parts.append(ipAddress) + } + if let aliases = network.aliases, !aliases.isEmpty { + parts.append("aliases: \(aliases.joined(separator: ", "))") + } + return parts.joined(separator: " | ") + } + + private func mountSummary(_ mount: RuntimeContainerMount) -> String { + var suffix = mount.mode + if let rw = mount.rw { + suffix = suffix.isEmpty ? (rw ? "rw" : "ro") : "\(suffix),\(rw ? "rw" : "ro")" + } + if suffix.isEmpty { + return "\(mount.source) -> \(mount.destination)" + } + return "\(mount.source) -> \(mount.destination) [\(suffix)]" + } + + private func sortedLabels(_ labels: [String: String]) -> [(key: String, value: String)] { + labels + .map { (key: $0.key, value: $0.value) } + .sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending } + } + + private func openServiceHost(_ host: String) { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let urlString = trimmed.contains("://") ? trimmed : "https://\(trimmed)" + if let url = URL(string: urlString) { + openURL(url) + } + } + + private var fallbackRuntimeStatus: ProjectRuntimeStatus { + switch project.status { + case .running: + return .running + case .stopped: + return .stopped + case .missing: + return .missing + case .unknown: + return .unknown + case .unregistered: + return .unknown + } + } + + private var availableTabs: [ProjectTab] { + var tabs: [ProjectTab] = [.overview] + if project.kind == .registered { + tabs.append(.branches) + tabs.append(.sessions) + } + if project.isRuntimeConfigured { + tabs.append(contentsOf: [.logs, .shell]) + } + if project.supportsTickets { + tabs.append(.tickets) + } return tabs } @@ -543,19 +1188,190 @@ struct ProjectDetailView: View { } } + private var projectHeaderBackground: some ShapeStyle { + if colorScheme == .dark { + return AnyShapeStyle(.regularMaterial) + } + return AnyShapeStyle(Color.white.opacity(0.82)) + } + + private var projectHeaderShape: UnevenRoundedRectangle { + UnevenRoundedRectangle( + cornerRadii: .init( + topLeading: 0, + bottomLeading: 14, + bottomTrailing: 14, + topTrailing: 0 + ), + style: .continuous + ) + } + + private var projectHeaderStrokeColor: Color { + colorScheme == .dark ? Color.white.opacity(0.14) : Color.black.opacity(0.08) + } + + private var sessionQuickAccessButton: some View { + Menu { + if sessionEntries.count == 1, let session = sessionEntries.first { + sessionOpenMenuItems(for: session) + } else { + ForEach(sessionEntries, id: \.id) { session in + Menu(session.name) { + sessionOpenMenuItems(for: session) + } + } + } + Divider() + Button("Manage sessions") { + activateTab(.sessions) + } + } label: { + Label( + sessionEntries.count == 1 ? "Open session" : "\(sessionEntries.count) sessions", + systemImage: sessionEntries.count == 1 ? "terminal" : "rectangle.3.group.bubble.left" + ) + } + .buttonStyle(PressableIconButtonStyle()) + } + + private var openInProjectButton: some View { + Menu { + if let path = projectOpenPath { + Button { + openProjectInEditor(preferredEditor) + } label: { + Label("Open in \(preferredEditor.displayName)", systemImage: "checkmark.circle.fill") + } + + Divider() + + ForEach(availableEditors, id: \.rawValue) { editor in + Button { + preferredEditorRaw = editor.rawValue + openProjectInEditor(editor) + } label: { + Label( + editor.displayName, + systemImage: editor == preferredEditor ? "checkmark.circle.fill" : "circle" + ) + } + } + + Divider() + + Button("Reveal in Finder") { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path) + } + } else { + Text("Project path unavailable") + } + } label: { + Label("Open in", systemImage: "arrow.up.right.square") + } + .buttonStyle(PressableIconButtonStyle()) + } + + private var codingAgentQuickAccessButton: some View { + Menu { + if let path = projectOpenPath { + Button { + openProjectInCodingAgent(preferredCodingAgent, projectPath: path) + } label: { + Label( + "Open in \(preferredCodingAgent.displayName)", + systemImage: "checkmark.circle.fill" + ) + } + + Divider() + + ForEach(availableCodingAgents, id: \.rawValue) { agent in + Button { + preferredCodingAgentRaw = agent.rawValue + openProjectInCodingAgent(agent, projectPath: path) + } label: { + Label( + agent.displayName, + systemImage: agent == preferredCodingAgent ? "checkmark.circle.fill" : "circle" + ) + } + } + + Divider() + + Button("Print init prompt") { + openTerminal( + kind: .shell, + command: "hack agent init --path \(shellQuote(path)) --client print", + title: "\(project.name) init prompt" + ) + } + } else { + Text("Project path unavailable") + } + } label: { + Label("Agent", systemImage: "sparkles") + } + .buttonStyle(PressableIconButtonStyle()) + } + + @ViewBuilder + private func sessionOpenMenuItems(for session: ProjectSessionSummary) -> some View { + ForEach(installedExternalTerminalApps, id: \.self) { terminalApp in + Button { + preferredExternalTerminalRaw = terminalApp.rawValue + legacyPreferredExternalTerminalRaw = terminalApp.rawValue + openSession(session, terminalApp: terminalApp) + } label: { + Label( + "Open in \(terminalApp.displayName)", + systemImage: terminalApp == preferredExternalTerminal ? "checkmark.circle.fill" : "circle" + ) + } + } + } + + private func headerMetricPill(_ title: String, value: String) -> some View { + HStack(spacing: 6) { + Text(title) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + Text(value) + .font(.mono(.caption, weight: .semibold)) + .lineLimit(1) + .truncationMode(.tail) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + Capsule(style: .continuous) + .fill(colorScheme == .dark ? Color.white.opacity(0.08) : Color.black.opacity(0.03)) + ) + .overlay( + Capsule(style: .continuous) + .stroke(colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08), lineWidth: 1) + ) + } + + private func activateTab(_ tab: ProjectTab) { + if tab == .logs { + openTerminal(kind: .logs) + return + } + if tab == .shell { + openTerminal(kind: .shell) + return + } + model.selectedProjectTab = tab + } + private var bottomControlBar: some View { HStack(spacing: 12) { HStack(spacing: 6) { ForEach(availableTabs, id: \.self) { tab in Button { - switch tab { - case .logs: - openTerminal(kind: .logs) - case .shell: - openTerminal(kind: .shell) - case .overview, .tickets: - model.selectedProjectTab = tab - } + activateTab(tab) } label: { Image(systemName: tabIcon(tab)) .font(.mono(.caption, weight: .semibold)) @@ -565,82 +1381,162 @@ struct ProjectDetailView: View { Circle() .fill(tab == effectiveTab ? Color.accentColor : hoveredControl == tab ? Color.white.opacity(0.08) : .clear) ) - .accessibilityLabel(tab.rawValue) + .accessibilityLabel(tabLabel(tab)) + .overlay(alignment: .top) { + if hoveredControl == tab { + iconTooltip(tabLabel(tab)) + .fixedSize(horizontal: true, vertical: true) + .offset(y: -30) + .transition( + .asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.92, anchor: .bottom)), + removal: .opacity + ) + ) + } + } } .buttonStyle(PressableCircleButtonStyle()) .onHover { hovering in - hoveredControl = hovering ? tab : nil + withAnimation(.easeOut(duration: 0.16)) { + hoveredControl = hovering ? tab : nil + } } + .zIndex(hoveredControl == tab ? 20 : 0) } } Divider() .frame(height: 18) - if canStart { - Button { - Task { await model.startProject(project) } - } label: { - Image(systemName: "play.fill") - .font(.mono(.caption, weight: .semibold)) - .padding(8) - .background( - Circle() - .fill(isStartHovered ? Color.white.opacity(0.08) : .clear) - ) + if isProjectLifecycleBusy { + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + Text(projectLifecycleLabel) + .font(.mono(.caption2, weight: .semibold)) + .foregroundStyle(.secondary) } - .buttonStyle(PressableCircleButtonStyle()) - .contentShape(Circle()) - .onHover { hovering in - isStartHovered = hovering + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + Capsule(style: .continuous) + .fill(colorScheme == .dark ? Color.white.opacity(0.08) : Color.black.opacity(0.04)) + ) + } else { + if canStart { + Button { + Task { await model.startProject(project) } + } label: { + Image(systemName: "play.fill") + .font(.mono(.caption, weight: .semibold)) + .padding(8) + .background( + Circle() + .fill(isStartHovered ? Color.white.opacity(0.08) : .clear) + ) + .overlay(alignment: .top) { + if isStartHovered { + iconTooltip("Start project") + .fixedSize(horizontal: true, vertical: true) + .offset(y: -30) + .transition( + .asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.92, anchor: .bottom)), + removal: .opacity + ) + ) + } + } + } + .buttonStyle(PressableCircleButtonStyle()) + .contentShape(Circle()) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.16)) { + isStartHovered = hovering + } + } + .accessibilityLabel("Start project") } - .accessibilityLabel("Start project") - } - if canStop { - Button { - Task { await model.stopProject(project) } - } label: { - Image(systemName: "stop.fill") - .font(.mono(.caption, weight: .semibold)) - .padding(8) - .background( - Circle() - .fill(isStopHovered ? Color.white.opacity(0.08) : .clear) - ) - } - .buttonStyle(PressableCircleButtonStyle()) - .contentShape(Circle()) - .onHover { hovering in - isStopHovered = hovering + if canStop { + Button { + Task { await model.stopProject(project) } + } label: { + Image(systemName: "stop.fill") + .font(.mono(.caption, weight: .semibold)) + .padding(8) + .background( + Circle() + .fill(isStopHovered ? Color.white.opacity(0.08) : .clear) + ) + .overlay(alignment: .top) { + if isStopHovered { + iconTooltip("Stop project") + .fixedSize(horizontal: true, vertical: true) + .offset(y: -30) + .transition( + .asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.92, anchor: .bottom)), + removal: .opacity + ) + ) + } + } + } + .buttonStyle(PressableCircleButtonStyle()) + .contentShape(Circle()) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.16)) { + isStopHovered = hovering + } + } + .accessibilityLabel("Stop project") } - .accessibilityLabel("Stop project") } } .padding(.horizontal, 14) .padding(.vertical, 8) .background( - Capsule(style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - Capsule(style: .continuous) - .fill(isControlBarHovered ? Color.white.opacity(0.06) : .clear) - ) - .overlay( - Capsule(style: .continuous) - .stroke(Color.white.opacity(0.12), lineWidth: 1) - ) + controlBarBackground ) .onHover { hovering in isControlBarHovered = hovering } .animation(.easeInOut(duration: 0.12), value: isControlBarHovered) + .animation(.easeOut(duration: 0.16), value: hoveredControl) + .animation(.easeOut(duration: 0.16), value: isStartHovered) + .animation(.easeOut(duration: 0.16), value: isStopHovered) + } + + @ViewBuilder + private var controlBarBackground: some View { + let shape = Capsule(style: .continuous) + if colorScheme == .dark { + shape + .fill(.regularMaterial) + .overlay( + shape.stroke(Color.white.opacity(0.10), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.22), radius: 18, x: 0, y: 10) + } else { + shape + .fill(Color.white.opacity(0.78)) + .overlay( + shape.stroke(Color.black.opacity(0.08), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.10), radius: 18, x: 0, y: 10) + } } private func tabIcon(_ tab: ProjectTab) -> String { switch tab { case .overview: return "square.grid.2x2" + case .branches: + return "arrow.triangle.branch" + case .sessions: + return "rectangle.3.group.bubble.left" case .logs: return "text.alignleft" case .shell: @@ -650,4 +1546,223 @@ struct ProjectDetailView: View { } } + private func tabLabel(_ tab: ProjectTab) -> String { + switch tab { + case .overview: + return "Overview" + case .branches: + return "Branches" + case .sessions: + return "Sessions" + case .logs: + return "Logs" + case .shell: + return "Shell" + case .tickets: + return "Tickets" + } + } + + private func iconTooltip(_ title: String) -> some View { + Text(title) + .font(.mono(.caption2, weight: .semibold)) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .foregroundStyle(.primary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(colorScheme == .dark ? Color.white.opacity(0.18) : Color.black.opacity(0.14), lineWidth: 1) + ) + ) + .allowsHitTesting(false) + .shadow(color: Color.black.opacity(colorScheme == .dark ? 0.25 : 0.12), radius: 6, y: 2) + } + + private func breadcrumbLabel(for tab: ProjectTab) -> String { + switch tab { + case .overview: + return "Dashboard" + case .branches: + return "Branches" + case .sessions: + return "Sessions" + case .logs: + return "Logs" + case .shell: + return "Shell" + case .tickets: + return "Tickets" + } + } + + private func openTerminal( + kind: TerminalDrawerModel.Kind, + branch: String? = nil, + command: String? = nil, + title: String? = nil + ) { + let normalizedBranch = branch?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedBranch = (normalizedBranch?.isEmpty == false) ? normalizedBranch : nil + let normalizedCommand = command?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedCommand = (normalizedCommand?.isEmpty == false) ? normalizedCommand : nil + let normalizedTitle = title?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedTitle = (normalizedTitle?.isEmpty == false) ? normalizedTitle : nil + var userInfo: [String: String] = [ + TerminalOpenRequest.projectIdKey: project.id, + TerminalOpenRequest.kindKey: kind.rawValue + ] + if let resolvedBranch { + userInfo[TerminalOpenRequest.branchKey] = resolvedBranch + } + if let resolvedCommand { + userInfo[TerminalOpenRequest.commandKey] = resolvedCommand + } + if let resolvedTitle { + userInfo[TerminalOpenRequest.titleKey] = resolvedTitle + } + NotificationCenter.default.post( + name: .hackTerminalOpenRequested, + object: nil, + userInfo: userInfo + ) + } + + private var preferredExternalTerminal: TerminalIntegration.ExternalTerminalApp { + if let explicit = TerminalIntegration.ExternalTerminalApp(rawValue: preferredExternalTerminalRaw) { + return explicit + } + if let legacy = TerminalIntegration.ExternalTerminalApp(rawValue: legacyPreferredExternalTerminalRaw) { + return legacy + } + return .terminal + } + + private var installedExternalTerminalApps: [TerminalIntegration.ExternalTerminalApp] { + let installed = TerminalIntegration.installedExternalTerminalApps() + if installed.isEmpty { + return [.terminal] + } + if installed.contains(preferredExternalTerminal) { + return installed + } + return [preferredExternalTerminal] + installed + } + + private var preferredEditor: EditorIntegration.EditorApp { + if let explicit = EditorIntegration.EditorApp(rawValue: preferredEditorRaw) { + return explicit + } + return .cursor + } + + private var availableEditors: [EditorIntegration.EditorApp] { + let installed = EditorIntegration.installedEditors() + let fallback: [EditorIntegration.EditorApp] = [.cursor, .vscode, .zed, .neovim, .vim] + var seen: Set = [] + var ordered: [EditorIntegration.EditorApp] = [] + for editor in [preferredEditor] + installed + fallback where seen.insert(editor).inserted { + ordered.append(editor) + } + return ordered + } + + private var preferredCodingAgent: CodingAgentIntegration.AgentApp { + if let explicit = CodingAgentIntegration.AgentApp(rawValue: preferredCodingAgentRaw) { + return explicit + } + return .codex + } + + private var availableCodingAgents: [CodingAgentIntegration.AgentApp] { + let installed = CodingAgentIntegration.installedAgents() + var seen: Set = [] + var ordered: [CodingAgentIntegration.AgentApp] = [] + for agent in [preferredCodingAgent] + installed + CodingAgentIntegration.AgentApp.allCases + where seen.insert(agent).inserted { + ordered.append(agent) + } + return ordered + } + + private var projectOpenPath: String? { + if let repoRoot = project.repoRoot, !repoRoot.isEmpty { + return repoRoot + } + if let projectDir = project.projectDir, !projectDir.isEmpty { + return projectDir + } + return nil + } + + private func openProjectInEditor(_ editor: EditorIntegration.EditorApp) { + guard let path = projectOpenPath else { return } + EditorIntegration.openProject( + path: path, + editor: editor, + terminalApp: preferredExternalTerminal + ) + } + + private func openProjectInCodingAgent( + _ agent: CodingAgentIntegration.AgentApp, + projectPath: String + ) { + let command = CodingAgentIntegration.launchCommand( + projectPath: projectPath, + agent: agent, + binaryOverridePath: preferredCodingAgentBinaryPathRaw + ) + openTerminal( + kind: .shell, + command: command, + title: "\(agent.displayName) - \(project.name)" + ) + } + + private func attachCommand(for session: ProjectSessionSummary) -> String { + switch session.backend { + case .tmux: + return "env -u TMUX tmux attach -d -t \(shellQuote(session.name))" + case .zellij: + return "zellij attach \(shellQuote(session.name))" + } + } + + private func openSession( + _ session: ProjectSessionSummary, + terminalApp: TerminalIntegration.ExternalTerminalApp + ) { + if terminalApp == .hackDesktop { + openTerminal( + kind: .shell, + command: attachCommand(for: session), + title: "\(session.name) (attached)" + ) + return + } + openSessionExternally(session, terminalApp: terminalApp) + } + + private func openSessionExternally( + _ session: ProjectSessionSummary, + terminalApp: TerminalIntegration.ExternalTerminalApp + ) { + TerminalIntegration.openExternalTerminalWithCommand( + attachCommand(for: session), + app: terminalApp + ) + } + + private func shellQuote(_ value: String) -> String { + if value.isEmpty { + return "''" + } + return "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" + } + } diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectInspectorColumn.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectInspectorColumn.swift deleted file mode 100644 index c80dd666..00000000 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectInspectorColumn.swift +++ /dev/null @@ -1,836 +0,0 @@ -import SwiftUI - -import HackDesktopModels - -struct ProjectInspectorColumn: View { - @Environment(\.openURL) private var openURL - - let project: ProjectSummary - let meta: ProjectMeta? - @Binding var selectedService: String? - let onAttachSession: (MuxSessionSummary) -> Void - let onStopSession: (MuxSessionSummary) -> Void - let onShowLogs: () -> Void - let onShowShell: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 12) { - headerRow - projectCard - } - .padding(16) - - Divider() - .opacity(0.2) - - ScrollView { - VStack(alignment: .leading, spacing: 14) { - if let selectedService { - serviceCard(service: selectedService) - serviceEnvCard(service: selectedService) - serviceBuildCard(service: selectedService) - } - - metaSection - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .topLeading) - } - .frame(maxHeight: .infinity, alignment: .topLeading) - } - } - - private var headerRow: some View { - HStack(alignment: .center, spacing: 10) { - Text("Details") - .font(.mono(.headline, weight: .semibold)) - if let selectedService { - BadgePill(label: selectedService, tint: .secondary) - } - Spacer() - if selectedService != nil { - Button("All") { - withAnimation(.easeInOut(duration: 0.2)) { - selectedService = nil - } - } - .font(.mono(.caption, weight: .semibold)) - .buttonStyle(PressableIconButtonStyle()) - .accessibilityLabel("Show all services") - } - } - } - - private var projectCard: some View { - GlassCard(title: "Project", systemImage: "cube.transparent") { - VStack(alignment: .leading, spacing: 12) { - if !projectRows.isEmpty { - DetailRows(rows: projectRows) - } - - if let featureSummary = project.featureSummary, !featureSummary.isEmpty { - Divider() - .opacity(0.2) - VStack(alignment: .leading, spacing: 6) { - Text("Features") - .instrumentLabel() - Text(featureSummary) - .font(.mono(.caption)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - } - } - - private var projectRows: [DetailRowItem] { - var rows: [DetailRowItem] = [] - rows.append(DetailRowItem(label: "Status", value: pretty(project.status.rawValue))) - rows.append(DetailRowItem(label: "Runtime", value: project.runtimeStatusLabel)) - rows.append(DetailRowItem(label: "Kind", value: pretty(project.kind.rawValue))) - if let devHost = project.devHost, !devHost.isEmpty { - rows.append(DetailRowItem(label: "Dev host", value: devHost)) - } - if let repoRoot = project.repoRoot, !repoRoot.isEmpty { - rows.append(DetailRowItem(label: "Repo root", value: repoRoot)) - } - if let projectDir = project.projectDir, !projectDir.isEmpty, projectDir != project.repoRoot { - rows.append(DetailRowItem(label: "Project dir", value: projectDir)) - } - return rows - } - - private func serviceCard(service: String) -> some View { - GlassCard(title: "Service", systemImage: "shippingbox") { - VStack(alignment: .leading, spacing: 12) { - if let runtime = runtimeServicesByName[service] { - let containers = runtime.containers - let runningCount = containers.filter { $0.state.lowercased() == "running" }.count - let images = Array(Set(containers.compactMap(\.image))).sorted() - let ips = Array(Set(containers.compactMap(\.ip))).sorted() - let mounts = containers.first?.mounts ?? [] - let labels = containers.first?.labels ?? [:] - - let domainHosts = (serviceHostsByName[service] ?? []).sorted() - - HStack(spacing: 8) { - if let primaryHost = domainHosts.first { - Button("Open") { - openServiceHost(primaryHost) - } - .font(.mono(.caption, weight: .semibold)) - .buttonStyle(PressableIconButtonStyle()) - } - - Button("Logs") { - onShowLogs() - } - .font(.mono(.caption, weight: .semibold)) - .buttonStyle(PressableIconButtonStyle()) - - Button("Shell") { - onShowShell() - } - .font(.mono(.caption, weight: .semibold)) - .buttonStyle(PressableIconButtonStyle()) - - Spacer() - } - - if !domainHosts.isEmpty { - VStack(alignment: .leading, spacing: 6) { - Text("Domains") - .instrumentLabel() - ForEach(limited(domainHosts, limit: 6), id: \.self) { host in - Button { - openServiceHost(host) - } label: { - Text(host) - .font(.mono(.caption)) - } - .buttonStyle(.plain) - .linkHover() - } - let hiddenCount = max(0, domainHosts.count - 6) - if hiddenCount > 0 { - Text("+\(hiddenCount) more") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - } - } - - DetailRows( - rows: [ - DetailRowItem(label: "Containers", value: "\(containers.count)"), - DetailRowItem(label: "Running", value: "\(runningCount)"), - DetailRowItem(label: "Image", value: images.count == 1 ? (images.first ?? "—") : "\(images.count) images"), - DetailRowItem(label: "IP", value: ips.count == 1 ? (ips.first ?? "—") : (ips.isEmpty ? "—" : "\(ips.count) IPs")), - ], - labelWidth: 120 - ) - - Divider() - .opacity(0.2) - - if containers.isEmpty { - Text("No containers.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } else { - VStack(alignment: .leading, spacing: 8) { - ForEach(limited(containers, limit: 8), id: \.id) { container in - VStack(alignment: .leading, spacing: 4) { - Text(container.name) - .font(.mono(.caption, weight: .semibold)) - HStack(spacing: 8) { - Text(shortId(container.id)) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - if let image = container.image, !image.isEmpty { - Text(image) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - } - Text(container.status) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - if !container.ports.isEmpty { - Text(container.ports) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - if let ip = container.ip, !ip.isEmpty { - Text("IP: \(ip)") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - } - Divider() - .opacity(0.2) - } - - let hiddenCount = max(0, containers.count - 8) - if hiddenCount > 0 { - Text("+\(hiddenCount) more") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - } - } - - if !mounts.isEmpty { - Divider() - .opacity(0.2) - VStack(alignment: .leading, spacing: 6) { - Text("Mounts") - .instrumentLabel() - ForEach(limited(mounts, limit: 6).indices, id: \.self) { idx in - let mount = mounts[idx] - let source = mount.source ?? "—" - let destination = mount.destination ?? "—" - HStack(spacing: 8) { - Text(source) - .font(.mono(.caption2, weight: .semibold)) - .lineLimit(1) - .truncationMode(.middle) - Text("→") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - Text(destination) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - } - let hiddenCount = max(0, mounts.count - 6) - if hiddenCount > 0 { - Text("+\(hiddenCount) more") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - } - } - - if !labels.isEmpty { - Divider() - .opacity(0.2) - VStack(alignment: .leading, spacing: 6) { - Text("Labels") - .instrumentLabel() - ForEach(limited(labels.keys.sorted(), limit: 10), id: \.self) { key in - let value = labels[key] ?? "" - VStack(alignment: .leading, spacing: 2) { - Text(key) - .font(.mono(.caption2, weight: .semibold)) - .foregroundStyle(.secondary) - Text(value) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - .lineLimit(2) - .truncationMode(.middle) - .textSelection(.enabled) - } - } - let hiddenCount = max(0, labels.keys.count - 10) - if hiddenCount > 0 { - Text("+\(hiddenCount) more") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - } - } - } else { - Text("No running containers.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - } - } - } - - private func serviceEnvCard(service: String) -> some View { - GlassCard(title: "Env", systemImage: "key") { - if let meta { - let vars = envVars(for: service, meta: meta) - let missingRequired = vars.filter { $0.required && !$0.hasValue }.map(\.key) - - VStack(alignment: .leading, spacing: 12) { - DetailRows( - rows: [ - DetailRowItem(label: "Vars", value: "\(vars.count)"), - DetailRowItem(label: "Missing", value: "\(missingRequired.count)"), - ], - labelWidth: 120 - ) - - if !missingRequired.isEmpty { - Divider() - .opacity(0.2) - VStack(alignment: .leading, spacing: 6) { - Text("Missing required") - .instrumentLabel() - ForEach(limited(missingRequired, limit: 10), id: \.self) { key in - Text(key) - .font(.mono(.caption)) - .foregroundStyle(.orange) - .textSelection(.enabled) - } - let hiddenCount = max(0, missingRequired.count - 10) - if hiddenCount > 0 { - Text("+\(hiddenCount) more") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - } - } - - if !vars.isEmpty { - Divider() - .opacity(0.2) - VStack(alignment: .leading, spacing: 8) { - Text("Variables") - .instrumentLabel() - ForEach(limited(vars, limit: 12)) { variable in - envVarRow(variable) - Divider() - .opacity(0.2) - } - let hiddenCount = max(0, vars.count - 12) - if hiddenCount > 0 { - Text("+\(hiddenCount) more") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - } - } - } - } else { - metaLoadingRow - } - } - } - - private func serviceBuildCard(service: String) -> some View { - GlassCard(title: "Build", systemImage: "hammer") { - if let meta { - if let serviceMeta = meta.composeBuild.services.first(where: { $0.service == service }) { - VStack(alignment: .leading, spacing: 12) { - let rows = [ - DetailRowItem(label: "Build", value: serviceMeta.build ? "Yes" : "No"), - DetailRowItem(label: "Context", value: serviceMeta.context ?? "—"), - DetailRowItem(label: "Dockerfile", value: serviceMeta.dockerfile ?? "—"), - DetailRowItem(label: "Path", value: serviceMeta.dockerfilePath ?? "—"), - ] - DetailRows(rows: rows, labelWidth: 120) - if serviceMeta.dockerfilePath != nil { - let exists = serviceMeta.dockerfileExists == true - StatusPill(text: exists ? "Dockerfile found" : "Dockerfile missing", tone: exists ? .good : .warn) - } - } - } else { - Text("No build metadata for this service.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - } else { - metaLoadingRow - } - } - } - - @ViewBuilder - private var metaSection: some View { - if let meta { - gitCard(meta: meta) - sessionsCard(meta: meta) - envCard(meta: meta) - composeBuildCard(meta: meta) - hackBranchesCard(meta: meta) - } else { - GlassCard(title: "Meta", systemImage: "info.circle") { - metaLoadingRow - } - } - } - - private func gitCard(meta: ProjectMeta) -> some View { - GlassCard(title: "Git", systemImage: "arrow.triangle.branch") { - VStack(alignment: .leading, spacing: 12) { - DetailRows(rows: gitRows(meta: meta), labelWidth: 120) - if let worktrees = meta.git.worktrees, !worktrees.isEmpty { - Divider() - .opacity(0.2) - VStack(alignment: .leading, spacing: 6) { - Text("Worktrees") - .instrumentLabel() - ForEach(limited(worktrees, limit: 8), id: \.path) { worktree in - VStack(alignment: .leading, spacing: 2) { - Text(worktree.path) - .font(.mono(.caption, weight: .semibold)) - .textSelection(.enabled) - if let branch = worktree.branch { - Text(branch) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } else if worktree.detached, let head = worktree.head { - Text("Detached @ \(head)") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - } - Divider() - .opacity(0.2) - } - let hiddenCount = max(0, worktrees.count - 8) - if hiddenCount > 0 { - Text("+\(hiddenCount) more") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - } - } - } - } - } - - private func gitRows(meta: ProjectMeta) -> [DetailRowItem] { - var rows: [DetailRowItem] = [] - rows.append(DetailRowItem(label: "Repo", value: meta.git.isRepo ? "Yes" : "No")) - if let branch = meta.git.branch, !branch.isEmpty { - rows.append(DetailRowItem(label: "Branch", value: branch)) - } - if let head = meta.git.head, !head.isEmpty { - rows.append(DetailRowItem(label: "HEAD", value: head)) - } - if let detached = meta.git.detached { - rows.append(DetailRowItem(label: "Detached", value: detached ? "Yes" : "No")) - } - if let dirty = meta.git.dirty { - rows.append(DetailRowItem(label: "Dirty", value: dirty ? "Yes" : "No")) - } - if let localCount = meta.git.localBranchCount { - rows.append(DetailRowItem(label: "Branches", value: "\(localCount)")) - } - if let worktrees = meta.git.worktrees { - rows.append(DetailRowItem(label: "Worktrees", value: "\(worktrees.count)")) - } - if let error = meta.git.error, !error.isEmpty { - rows.append(DetailRowItem(label: "Error", value: error)) - } - return rows - } - - private func sessionsCard(meta: ProjectMeta) -> some View { - GlassCard(title: "Sessions", systemImage: "rectangle.split.3x1") { - VStack(alignment: .leading, spacing: 12) { - DetailRows( - rows: [ - DetailRowItem(label: "Count", value: "\(meta.sessions.sessions.count)"), - ], - labelWidth: 120 - ) - if meta.sessions.sessions.isEmpty { - Text("No sessions detected.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } else { - Divider() - .opacity(0.2) - VStack(alignment: .leading, spacing: 8) { - ForEach(limited(meta.sessions.sessions, limit: 10)) { session in - sessionRow(session) - Divider() - .opacity(0.2) - } - let hiddenCount = max(0, meta.sessions.sessions.count - 10) - if hiddenCount > 0 { - Text("+\(hiddenCount) more") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - } - } - } - } - } - - private func sessionRow(_ session: MuxSessionSummary) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 10) { - VStack(alignment: .leading, spacing: 2) { - Text(session.name) - .font(.mono(.caption, weight: .semibold)) - .textSelection(.enabled) - if let path = session.path { - Text(path) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .textSelection(.enabled) - } - } - Spacer() - BadgePill(label: session.backend, tint: .secondary) - if let windows = session.windows { - BadgePill(label: "\(windows)w", tint: .secondary) - } - if session.attached == true { - StatusPill(text: "Attached", tone: .good) - } - HStack(spacing: 6) { - Button("Attach") { - onAttachSession(session) - } - .font(.mono(.caption, weight: .semibold)) - .buttonStyle(PressableIconButtonStyle()) - .accessibilityLabel("Attach to session \(session.name)") - - Button("Stop") { - onStopSession(session) - } - .font(.mono(.caption, weight: .semibold)) - .buttonStyle(PressableIconButtonStyle()) - .accessibilityLabel("Stop session \(session.name)") - } - } - } - - private func envCard(meta: ProjectMeta) -> some View { - GlassCard(title: "Env", systemImage: "key") { - VStack(alignment: .leading, spacing: 12) { - DetailRows( - rows: [ - DetailRowItem(label: "Contract", value: meta.env.contractExists ? "Found" : "Missing"), - DetailRowItem(label: "Vars", value: "\(meta.env.vars.count)"), - DetailRowItem(label: "Missing", value: "\(meta.env.missingRequired.count)"), - ], - labelWidth: 120 - ) - - if let parseError = meta.env.contractParseError, !parseError.isEmpty { - Divider() - .opacity(0.2) - Text(parseError) - .font(.mono(.caption)) - .foregroundStyle(.orange) - .fixedSize(horizontal: false, vertical: true) - } - - if !meta.env.missingRequired.isEmpty { - Divider() - .opacity(0.2) - VStack(alignment: .leading, spacing: 6) { - Text("Missing required") - .instrumentLabel() - ForEach(limited(meta.env.missingRequired, limit: 10), id: \.self) { key in - Text(key) - .font(.mono(.caption)) - .foregroundStyle(.orange) - .textSelection(.enabled) - } - let hiddenCount = max(0, meta.env.missingRequired.count - 10) - if hiddenCount > 0 { - Text("+\(hiddenCount) more") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - } - } - - Divider() - .opacity(0.2) - VStack(alignment: .leading, spacing: 8) { - Text("Variables") - .instrumentLabel() - ForEach(limited(meta.env.vars, limit: 12)) { variable in - envVarRow(variable) - Divider() - .opacity(0.2) - } - let hiddenCount = max(0, meta.env.vars.count - 12) - if hiddenCount > 0 { - Text("+\(hiddenCount) more") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - } - } - } - } - - private func envVarRow(_ variable: EnvVarMeta) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 10) { - VStack(alignment: .leading, spacing: 2) { - Text(variable.key) - .font(.mono(.caption, weight: .semibold)) - .textSelection(.enabled) - if let services = variable.services, !services.isEmpty { - Text(services.sorted().joined(separator: ", ")) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - } else if let description = variable.description, !description.isEmpty { - Text(description) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - .lineLimit(2) - } - } - Spacer() - - if variable.required { - BadgePill(label: "Required", tint: .orange) - } - - BadgePill(label: variable.source == .keychain ? "Keychain" : "Env", tint: .secondary) - - let tone: StatusTone = variable.hasValue ? .good : (variable.required ? .warn : .neutral) - StatusPill(text: variable.hasValue ? "Set" : "Missing", tone: tone) - } - } - - private func composeBuildCard(meta: ProjectMeta) -> some View { - GlassCard(title: "Compose build", systemImage: "hammer") { - let services = meta.composeBuild.services.sorted { $0.service < $1.service } - VStack(alignment: .leading, spacing: 12) { - DetailRows( - rows: [ - DetailRowItem(label: "Services", value: "\(services.count)"), - ], - labelWidth: 120 - ) - if services.isEmpty { - Text("No build config detected.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } else { - Divider() - .opacity(0.2) - VStack(alignment: .leading, spacing: 8) { - ForEach(limited(services, limit: 10)) { service in - composeBuildRow(service) - Divider() - .opacity(0.2) - } - let hiddenCount = max(0, services.count - 10) - if hiddenCount > 0 { - Text("+\(hiddenCount) more") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - } - } - } - } - } - - private func composeBuildRow(_ service: ComposeBuildServiceMeta) -> some View { - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 10) { - Text(service.service) - .font(.mono(.caption, weight: .semibold)) - Spacer() - StatusPill(text: service.build ? "Build" : "No build", tone: service.build ? .good : .neutral) - } - if let dockerfilePath = service.dockerfilePath, !dockerfilePath.isEmpty { - Text(dockerfilePath) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .textSelection(.enabled) - } else if let context = service.context, !context.isEmpty { - Text(context) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .textSelection(.enabled) - } - } - } - - private func hackBranchesCard(meta: ProjectMeta) -> some View { - GlassCard(title: "Hack branches", systemImage: "point.3.filled.connected.trianglepath.dotted") { - let branches = meta.hackBranches.branches - VStack(alignment: .leading, spacing: 12) { - DetailRows( - rows: [ - DetailRowItem(label: "File", value: meta.hackBranches.path), - DetailRowItem(label: "Branches", value: "\(branches.count)"), - ], - labelWidth: 120 - ) - - if let parseError = meta.hackBranches.parseError, !parseError.isEmpty { - Divider() - .opacity(0.2) - Text(parseError) - .font(.mono(.caption)) - .foregroundStyle(.orange) - .fixedSize(horizontal: false, vertical: true) - } - - if branches.isEmpty { - Text("No branches detected.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } else { - Divider() - .opacity(0.2) - VStack(alignment: .leading, spacing: 8) { - ForEach(limited(branches, limit: 10)) { branch in - hackBranchRow(branch) - Divider() - .opacity(0.2) - } - let hiddenCount = max(0, branches.count - 10) - if hiddenCount > 0 { - Text("+\(hiddenCount) more") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - } - } - } - } - } - - private func hackBranchRow(_ branch: HackBranchEntry) -> some View { - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 10) { - Text(branch.name) - .font(.mono(.caption, weight: .semibold)) - Spacer() - BadgePill(label: branch.slug, tint: .secondary) - } - if let note = branch.note, !note.isEmpty { - Text(note) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - if let lastUsedAt = branch.lastUsedAt { - Text("Last used: \(lastUsedAt)") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } else if let createdAt = branch.createdAt { - Text("Created: \(createdAt)") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - } - } - - private var metaLoadingRow: some View { - HStack(spacing: 10) { - ProgressView() - .controlSize(.small) - Text("Fetching meta…") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - private var runtimeServicesByName: [String: RuntimeService] { - guard let runtime = project.runtime else { return [:] } - return Dictionary( - runtime.services.map { ($0.service, $0) }, - uniquingKeysWith: { first, _ in first } - ) - } - - private var serviceHostsByName: [String: [String]] { - project.serviceHosts ?? [:] - } - - private func serviceHostLabel(for service: String) -> String? { - if let hosts = serviceHostsByName[service], let first = hosts.first { - if hosts.count > 1 { - return "\(first) +\(hosts.count - 1)" - } - return first - } - guard let host = project.devHost, !host.isEmpty else { return nil } - return "\(service).\(host)" - } - - private func envVars(for service: String, meta: ProjectMeta) -> [EnvVarMeta] { - meta.env.vars - .filter { $0.services == nil || $0.services?.contains(service) == true } - .sorted { $0.key < $1.key } - } - - private func pretty(_ value: String) -> String { - value.replacingOccurrences(of: "_", with: " ").capitalized - } - - private func openServiceHost(_ host: String) { - let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - let urlString = trimmed.contains("://") ? trimmed : "https://\(trimmed)" - if let url = URL(string: urlString) { - openURL(url) - } - } - - private func limited(_ items: [Element], limit: Int) -> [Element] { - if items.count <= limit { return items } - return Array(items.prefix(limit)) - } - - private func shortId(_ id: String) -> String { - let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.count > 12 else { return trimmed } - return String(trimmed.prefix(12)) - } -} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/RuntimeDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/RuntimeDetailView.swift index 5c929410..81c740c5 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/RuntimeDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/RuntimeDetailView.swift @@ -447,15 +447,27 @@ struct RuntimeDetailView: View { } private var runtimeStatusText: String { - if model.runtimeOverallOk == true { return "Healthy" } - if model.runtimeOverallOk == false { return "Degraded" } - return "Unknown" + switch model.runtimeHealthState { + case .healthy: + return "Healthy" + case .down: + return "Down" + case .degraded: + return "Degraded" + case .unknown: + return "Unknown" + } } private var runtimeStatusTone: StatusTone { - if model.runtimeOverallOk == true { return .good } - if model.runtimeOverallOk == false { return .warn } - return .neutral + switch model.runtimeHealthState { + case .healthy: + return .good + case .down, .degraded: + return .warn + case .unknown: + return .neutral + } } private var lastUpdatedText: String { diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SessionAttachView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SessionAttachView.swift deleted file mode 100644 index 28bc927d..00000000 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SessionAttachView.swift +++ /dev/null @@ -1,85 +0,0 @@ -import SwiftUI - -import HackDesktopModels - -struct SessionAttachView: View { - let project: ProjectSummary - let session: MuxSessionSummary - @Environment(\.dismiss) private var dismiss - @State private var terminal: GhosttyTerminalSession - - init(project: ProjectSummary, session: MuxSessionSummary) { - self.project = project - self.session = session - - let workingDirectory: URL? - if let path = session.path, !path.isEmpty { - workingDirectory = URL(fileURLWithPath: path) - } else if let path = project.repoRoot ?? project.projectDir { - workingDirectory = URL(fileURLWithPath: path) - } else { - workingDirectory = nil - } - - _terminal = State( - initialValue: GhosttyTerminalSession( - project: project, - mode: .sessionAttach(sessionName: session.name, workingDirectory: workingDirectory) - ) - ) - } - - var body: some View { - @Bindable var terminal = terminal - - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .center, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text("Session") - .font(.mono(.headline, weight: .semibold)) - Text(session.name) - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - Spacer() - Button("Close") { - dismiss() - } - .adaptiveToolbarButton() - } - - Text(terminal.statusMessage) - .font(.mono(.caption)) - .foregroundStyle(.secondary) - - if terminal.isAvailable { - GhosttyTerminalView(session: terminal) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .terminalSurface() - } else { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - Text("Terminal unavailable") - .font(.mono(.subheadline, weight: .medium)) - } - Text("Run `bun run macos:ghostty:setup` to build the Ghostty VT library.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(.ultraThinMaterial) - ) - } - } - .padding(20) - .frame(minWidth: 820, minHeight: 520) - .onAppear { terminal.start() } - .onDisappear { terminal.stop() } - } -} - diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift new file mode 100644 index 00000000..0edf84d2 --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift @@ -0,0 +1,2804 @@ +import AppKit +import Darwin +import SwiftUI + +import GhosttyTerminal +import HackDesktopModels + +enum SettingsSidebarItem: String, Hashable, Identifiable { + case preferences + case runtime + case gateway + case global + case supervisor + case permissions + case extensions + case cloudflare + case tailscale + case certificates + case logging + + var id: String { rawValue } + + var title: String { + switch self { + case .preferences: + return "Preferences" + case .runtime: + return "Runtime" + case .gateway: + return "Gateway" + case .global: + return "Global" + case .supervisor: + return "Supervisor" + case .permissions: + return "Permissions" + case .extensions: + return "Extensions" + case .cloudflare: + return "Cloudflare" + case .tailscale: + return "Tailscale" + case .certificates: + return "Certificates" + case .logging: + return "Logging" + } + } + + var icon: String { + switch self { + case .preferences: + return "slider.horizontal.3" + case .runtime: + return "gauge.with.dots.needle.50percent" + case .gateway: + return "dot.radiowaves.left.and.right" + case .global: + return "slider.horizontal.3" + case .supervisor: + return "cpu" + case .permissions: + return "hand.raised.fill" + case .extensions: + return "puzzlepiece.extension" + case .cloudflare: + return "cloud" + case .tailscale: + return "network" + case .certificates: + return "checkmark.shield" + case .logging: + return "text.alignleft" + } + } +} + +extension Notification.Name { + public static let hackSettingsRequested = Notification.Name("hack.settings.requested") +} + +enum SettingsNavigationRequest { + static let paneKey = "pane" +} + +struct SettingsOverlayView: View { + @Environment(DashboardModel.self) private var model + @Environment(\.colorScheme) private var colorScheme + @Binding var selection: SettingsSidebarItem + let onClose: () -> Void + + var body: some View { + VStack(spacing: 0) { + topBar + Divider() + .opacity(0.2) + HStack(spacing: 0) { + settingsSidebar + Divider() + .opacity(0.2) + settingsDetail + } + } + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(overlayBaseFillColor) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(.regularMaterial) + .opacity(colorScheme == .dark ? 0.58 : 0.72) + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(overlayStrokeColor, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + .shadow(color: .black.opacity(colorScheme == .dark ? 0.4 : 0.08), radius: 20, y: 10) + .padding(12) + .onExitCommand { + onClose() + } + } + + private var topBar: some View { + HStack(spacing: 10) { + Label("Settings", systemImage: "gearshape") + .font(.mono(.subheadline, weight: .semibold)) + Spacer() + Button { + onClose() + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .semibold)) + .frame(width: 24, height: 24) + } + .buttonStyle(PressableCircleButtonStyle()) + .help("Close settings") + .accessibilityLabel("Close settings") + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + Rectangle() + .fill(.ultraThinMaterial) + .overlay( + Rectangle() + .fill(topBarTintColor) + ) + ) + } + + private var settingsSidebar: some View { + List(selection: $selection) { + Section("Preferences") { + settingsRow(.preferences) + } + Section("System") { + settingsRow(.runtime) + settingsRow(.gateway) + } + Section("Control Plane") { + settingsRow(.global) + settingsRow(.supervisor) + settingsRow(.permissions) + settingsRow(.logging) + settingsRow(.certificates) + } + Section("Extensions") { + settingsRow(.extensions) + settingsRow(.cloudflare) + settingsRow(.tailscale) + } + } + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + .background( + Rectangle() + .fill(sidebarFillColor) + .overlay( + Rectangle() + .fill(.thinMaterial) + .opacity(colorScheme == .dark ? 0.38 : 0.56) + ) + ) + .frame(minWidth: 230, idealWidth: 250, maxWidth: 280) + } + + private var settingsDetail: some View { + Group { + switch selection { + case .preferences: + PreferencesSettingsView() + case .runtime: + RuntimeDetailView() + case .gateway: + GatewayDetailView() + case .global: + GlobalSettingsView() + case .supervisor: + SupervisorSettingsView() + case .permissions: + PermissionsSettingsView() + case .extensions: + ExtensionsSettingsView(selection: $selection) + case .cloudflare: + CloudflareExtensionSettingsView() + case .tailscale: + TailscaleExtensionSettingsView() + case .certificates: + CertificatesSettingsView() + case .logging: + LoggingSettingsView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background( + Rectangle() + .fill(detailFillColor) + .overlay( + Rectangle() + .fill(detailTintColor) + ) + ) + } + + private func settingsRow(_ item: SettingsSidebarItem) -> some View { + Label(item.title, systemImage: item.icon) + .tag(item) + .font(.mono(.subheadline)) + } + + private var overlayBaseFillColor: Color { + colorScheme == .dark ? Color.black.opacity(0.72) : Color.white.opacity(0.86) + } + + private var overlayStrokeColor: Color { + colorScheme == .dark ? Color.white.opacity(0.16) : Color.white.opacity(0.72) + } + + private var topBarTintColor: Color { + colorScheme == .dark ? Color.white.opacity(0.03) : Color.white.opacity(0.18) + } + + private var sidebarFillColor: Color { + colorScheme == .dark ? Color.black.opacity(0.46) : Color.white.opacity(0.82) + } + + private var detailFillColor: Color { + colorScheme == .dark ? Color.black.opacity(0.34) : Color.white.opacity(0.74) + } + + private var detailTintColor: Color { + colorScheme == .dark ? Color.white.opacity(0.03) : Color.white.opacity(0.28) + } +} + +private struct SettingsSectionHeader: View { + let breadcrumb: String + let title: String + let subtitle: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(breadcrumb) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + Text(title) + .font(.mono(.headline, weight: .semibold)) + Text(subtitle) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + } +} + +private enum AppearanceThemeOption: String, CaseIterable, Identifiable { + case system + case light + case dark + + var id: String { rawValue } + + var label: String { + switch self { + case .system: + return "System" + case .light: + return "Light" + case .dark: + return "Dark" + } + } +} + +private enum SessionProviderOption: String, CaseIterable, Identifiable { + case tmux + case zellij + + var id: String { rawValue } + + var label: String { + rawValue.uppercased() + } + + var executableCandidates: [String] { + switch self { + case .tmux: + return ["tmux"] + case .zellij: + return ["zellij"] + } + } +} + +private enum ContainerRuntimeOption: String, CaseIterable, Identifiable { + case docker + case orbstack + case dockerDesktop = "docker-desktop" + case colima + case rancherDesktop = "rancher-desktop" + case podman + + var id: String { rawValue } + + var label: String { + switch self { + case .docker: + return "Docker CLI" + case .orbstack: + return "OrbStack" + case .dockerDesktop: + return "Docker Desktop" + case .colima: + return "Colima" + case .rancherDesktop: + return "Rancher Desktop" + case .podman: + return "Podman" + } + } + + var executableCandidates: [String] { + switch self { + case .docker, .orbstack, .dockerDesktop, .rancherDesktop: + return ["docker"] + case .colima: + return ["colima"] + case .podman: + return ["podman"] + } + } + + var appBundleIdentifiers: [String] { + switch self { + case .orbstack: + return ["dev.kdrag0n.OrbStack"] + case .dockerDesktop: + return ["com.docker.docker"] + case .rancherDesktop: + return ["io.rancherdesktop.app"] + case .docker, .colima, .podman: + return [] + } + } + + var appFallbackPaths: [String] { + switch self { + case .orbstack: + return ["/Applications/OrbStack.app"] + case .dockerDesktop: + return ["/Applications/Docker.app"] + case .rancherDesktop: + return ["/Applications/Rancher Desktop.app"] + case .docker, .colima, .podman: + return [] + } + } +} + +private struct PreferencesSettingsView: View { + @Environment(DashboardModel.self) private var model + + @AppStorage("hackDesktop.preferences.theme") private var appearanceThemeRaw = AppearanceThemeOption.system.rawValue + @AppStorage("hackDesktop.preferences.defaultTerminal") private var preferredTerminalRaw = TerminalIntegration.ExternalTerminalApp.terminal.rawValue + @AppStorage("hackDesktop.sessions.preferredExternalTerminal") private var legacyPreferredTerminalRaw = TerminalIntegration.ExternalTerminalApp.terminal.rawValue + @AppStorage("hackDesktop.preferences.defaultIDE") private var preferredEditorRaw = EditorIntegration.EditorApp.cursor.rawValue + @AppStorage("hackDesktop.preferences.defaultCodingAgent") private var preferredCodingAgentRaw = CodingAgentIntegration.AgentApp.codex.rawValue + @AppStorage("hackDesktop.preferences.defaultCodingAgentBinaryPath") private var preferredCodingAgentBinaryPathRaw = "" + + @State private var isLoadingConfig = false + @State private var sessionProvider: SessionProviderOption = .tmux + @State private var sessionBinaryPath = "" + @State private var containerRuntime: ContainerRuntimeOption = .docker + @State private var containerBinaryPath = "" + @State private var codingAgentBinaryPath = "" + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + SettingsSectionHeader( + breadcrumb: "Settings / Preferences", + title: "Preferences", + subtitle: "UI defaults for theme, terminal, editor, sessions, containers, and coding agents" + ) + + GlassCard(title: "Defaults", systemImage: "slider.horizontal.3") { + VStack(alignment: .leading, spacing: 12) { + preferencePicker( + title: "Appearance", + helper: "Choose whether the desktop UI follows system, light, or dark mode.", + selectionLabel: appearanceTheme.label + ) { + Picker("Appearance", selection: $appearanceThemeRaw) { + ForEach(AppearanceThemeOption.allCases) { option in + Text(option.label).tag(option.rawValue) + } + } + .pickerStyle(.segmented) + } + + preferencePicker( + title: "Preferred terminal", + helper: "Used for Open In terminal actions and session attach flows.", + selectionLabel: preferredTerminal.displayName + ) { + Picker("Preferred terminal", selection: $preferredTerminalRaw) { + ForEach(terminalOptions, id: \.rawValue) { app in + Text(app.displayName).tag(app.rawValue) + } + } + .pickerStyle(.menu) + } + + preferencePicker( + title: "Preferred IDE", + helper: "Used by Project header Open In actions.", + selectionLabel: preferredEditor.displayName + ) { + Picker("Preferred IDE", selection: $preferredEditorRaw) { + ForEach(editorOptions, id: \.rawValue) { app in + Text(app.displayName).tag(app.rawValue) + } + } + .pickerStyle(.menu) + } + } + } + + GlassCard(title: "Session multiplexer", systemImage: "rectangle.3.group.bubble.left") { + VStack(alignment: .leading, spacing: 12) { + preferencePicker( + title: "Provider", + helper: "Default backend when creating or attaching session workflows.", + selectionLabel: sessionProvider.label + ) { + Picker("Session provider", selection: $sessionProvider) { + ForEach(SessionProviderOption.allCases) { option in + Text(option.label).tag(option) + } + } + .pickerStyle(.segmented) + } + pathEditor( + title: "Binary path override", + helper: "Leave empty to auto-detect from PATH.", + text: $sessionBinaryPath, + detectedPath: detectedSessionBinaryPath + ) + } + } + + GlassCard(title: "Container runtime", systemImage: "shippingbox") { + VStack(alignment: .leading, spacing: 12) { + preferencePicker( + title: "Provider", + helper: "Controls how the app and future CLI tooling interpret your container stack defaults.", + selectionLabel: containerRuntime.label + ) { + Picker("Container runtime", selection: $containerRuntime) { + ForEach(ContainerRuntimeOption.allCases) { option in + Text(option.label).tag(option) + } + } + .pickerStyle(.menu) + } + pathEditor( + title: "Binary path override", + helper: "Leave empty to auto-detect from PATH.", + text: $containerBinaryPath, + detectedPath: detectedContainerBinaryPath + ) + if let appPath = detectedContainerAppPath { + Text("Detected app: \(appPath)") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + .textSelection(.enabled) + } + } + } + + GlassCard(title: "Coding agent", systemImage: "sparkles") { + VStack(alignment: .leading, spacing: 12) { + preferencePicker( + title: "Default coding agent", + helper: "Used when opening agent-assisted workflows from Desktop and future CLI integrations.", + selectionLabel: preferredCodingAgent.displayName + ) { + Picker("Default coding agent", selection: $preferredCodingAgentRaw) { + ForEach(codingAgentOptions) { option in + Text(option.displayName).tag(option.rawValue) + } + } + .pickerStyle(.menu) + } + pathEditor( + title: "Binary path override", + helper: "Leave empty to auto-detect from PATH for the selected coding agent.", + text: $codingAgentBinaryPath, + detectedPath: detectedCodingAgentBinaryPath + ) + } + } + + HStack(spacing: 10) { + Button { + Task { await savePreferences() } + } label: { + Label("Save preferences", systemImage: "checkmark") + } + .adaptiveToolbarButtonProminent() + + Button { + Task { await loadConfigFromDisk() } + } label: { + Label("Reload", systemImage: "arrow.clockwise") + } + .adaptiveToolbarButton() + + if isLoadingConfig { + ProgressView() + .controlSize(.small) + } + Spacer() + } + + InlineCallout( + tone: .neutral, + title: "Preference scope", + message: "These defaults are saved in global config and in desktop app storage so project actions and future CLI/runtime integrations can share a single preference source.", + actions: [] + ) + } + .padding(16) + } + .task { + await loadConfigFromDisk() + } + .onChange(of: model.lastUpdated) { _, _ in + Task { await loadConfigFromDisk() } + } + } + + private var appearanceTheme: AppearanceThemeOption { + AppearanceThemeOption(rawValue: appearanceThemeRaw) ?? .system + } + + private var preferredTerminal: TerminalIntegration.ExternalTerminalApp { + if let explicit = TerminalIntegration.ExternalTerminalApp(rawValue: preferredTerminalRaw) { + return explicit + } + return .terminal + } + + private var preferredEditor: EditorIntegration.EditorApp { + if let explicit = EditorIntegration.EditorApp(rawValue: preferredEditorRaw) { + return explicit + } + return .cursor + } + + private var preferredCodingAgent: CodingAgentIntegration.AgentApp { + if let explicit = CodingAgentIntegration.AgentApp(rawValue: preferredCodingAgentRaw) { + return explicit + } + return .codex + } + + private var terminalOptions: [TerminalIntegration.ExternalTerminalApp] { + let installed = TerminalIntegration.installedExternalTerminalApps() + let fallback = [TerminalIntegration.ExternalTerminalApp.terminal] + return dedupePreservingOrder([preferredTerminal] + installed + fallback) + } + + private var editorOptions: [EditorIntegration.EditorApp] { + let installed = EditorIntegration.installedEditors() + let fallback = [EditorIntegration.EditorApp.cursor, .vscode, .zed, .neovim, .vim] + return dedupePreservingOrder([preferredEditor] + installed + fallback) + } + + private var codingAgentOptions: [CodingAgentIntegration.AgentApp] { + let installed = CodingAgentIntegration.installedAgents() + return dedupePreservingOrder( + [preferredCodingAgent] + installed + CodingAgentIntegration.AgentApp.allCases + ) + } + + private var detectedSessionBinaryPath: String? { + if let manual = normalizedPath(sessionBinaryPath) { + return manual + } + return resolveExecutablePath(candidates: sessionProvider.executableCandidates) + } + + private var detectedContainerBinaryPath: String? { + if let manual = normalizedPath(containerBinaryPath) { + return manual + } + return resolveExecutablePath(candidates: containerRuntime.executableCandidates) + } + + private var detectedContainerAppPath: String? { + resolveInstalledApplicationPath( + bundleIdentifiers: containerRuntime.appBundleIdentifiers, + fallbackPaths: containerRuntime.appFallbackPaths + ) + } + + private var detectedCodingAgentBinaryPath: String? { + CodingAgentIntegration.resolvedBinaryPath( + for: preferredCodingAgent, + overridePath: normalizedPath(codingAgentBinaryPath) + ) + } + + private func loadConfigFromDisk() async { + isLoadingConfig = true + defer { isLoadingConfig = false } + + let snapshot = GlobalConfigSnapshot.load() + if let theme = snapshot.preferencesTheme, !theme.isEmpty { + appearanceThemeRaw = theme + } + if let terminalApp = snapshot.preferencesTerminalApp, !terminalApp.isEmpty { + preferredTerminalRaw = terminalApp + legacyPreferredTerminalRaw = terminalApp + } else { + legacyPreferredTerminalRaw = preferredTerminalRaw + } + if let editorApp = snapshot.preferencesEditorApp, !editorApp.isEmpty { + preferredEditorRaw = editorApp + } + if let codingAgentApp = snapshot.preferencesCodingAgentApp, !codingAgentApp.isEmpty { + preferredCodingAgentRaw = codingAgentApp + } + if let providerRaw = snapshot.preferencesSessionProvider, let provider = SessionProviderOption(rawValue: providerRaw) { + sessionProvider = provider + } + if let binary = snapshot.preferencesSessionBinaryPath { + sessionBinaryPath = binary + } + if let containerRaw = snapshot.preferencesContainerProvider, let provider = ContainerRuntimeOption(rawValue: containerRaw) { + containerRuntime = provider + } + if let binary = snapshot.preferencesContainerBinaryPath { + containerBinaryPath = binary + } + if let binary = snapshot.preferencesCodingAgentBinaryPath { + codingAgentBinaryPath = binary + preferredCodingAgentBinaryPathRaw = binary + } else if !preferredCodingAgentBinaryPathRaw.isEmpty { + codingAgentBinaryPath = preferredCodingAgentBinaryPathRaw + } + } + + private func savePreferences() async { + isLoadingConfig = true + defer { isLoadingConfig = false } + + legacyPreferredTerminalRaw = preferredTerminalRaw + preferredCodingAgentBinaryPathRaw = normalizedPath(codingAgentBinaryPath) ?? "" + + let didSaveTheme = await model.setGlobalConfig( + key: "controlPlane.preferences.appearance.theme", + value: appearanceThemeRaw + ) + let didSaveTerminal = await model.setGlobalConfig( + key: "controlPlane.preferences.terminal.defaultApp", + value: preferredTerminalRaw + ) + let didSaveEditor = await model.setGlobalConfig( + key: "controlPlane.preferences.editor.defaultApp", + value: preferredEditorRaw + ) + let didSaveAgent = await model.setGlobalConfig( + key: "controlPlane.preferences.agents.defaultApp", + value: preferredCodingAgentRaw + ) + let didSaveSessionProvider = await model.setGlobalConfig( + key: "controlPlane.preferences.sessions.provider", + value: sessionProvider.rawValue + ) + let didSaveSessionBinaryPath = await model.setGlobalConfig( + key: "controlPlane.preferences.sessions.binaryPath", + value: normalizedPath(sessionBinaryPath) ?? "" + ) + let didSaveContainerProvider = await model.setGlobalConfig( + key: "controlPlane.preferences.containers.provider", + value: containerRuntime.rawValue + ) + let didSaveContainerBinaryPath = await model.setGlobalConfig( + key: "controlPlane.preferences.containers.binaryPath", + value: normalizedPath(containerBinaryPath) ?? "" + ) + let didSaveAgentBinaryPath = await model.setGlobalConfig( + key: "controlPlane.preferences.agents.binaryPath", + value: preferredCodingAgentBinaryPathRaw + ) + + guard [ + didSaveTheme, + didSaveTerminal, + didSaveEditor, + didSaveAgent, + didSaveSessionProvider, + didSaveSessionBinaryPath, + didSaveContainerProvider, + didSaveContainerBinaryPath, + didSaveAgentBinaryPath + ].allSatisfy({ $0 }) else { + return + } + + await model.refresh() + await loadConfigFromDisk() + } + + private func preferencePicker( + title: String, + helper: String, + selectionLabel: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(title) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + Text(selectionLabel) + .font(.mono(.caption, weight: .semibold)) + .foregroundStyle(.primary) + } + content() + Text(helper) + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + } + + private func pathEditor( + title: String, + helper: String, + text: Binding, + detectedPath: String? + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + HStack(spacing: 8) { + TextField("Optional custom path", text: text) + .textFieldStyle(.roundedBorder) + .font(.mono(.caption)) + Button("Browse") { + if let path = openExecutablePanel() { + text.wrappedValue = path + } + } + .adaptiveToolbarButton() + } + Text("Detected: \(detectedPath ?? "Not found")") + .font(.mono(.caption2)) + .foregroundStyle(detectedPath == nil ? Color.orange : Color.secondary) + .textSelection(.enabled) + Text(helper) + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + } + + private func normalizedPath(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func dedupePreservingOrder(_ values: [T]) -> [T] { + var seen: Set = [] + var ordered: [T] = [] + for value in values { + if seen.insert(value).inserted { + ordered.append(value) + } + } + return ordered + } +} + +private struct GlobalSettingsView: View { + @Environment(DashboardModel.self) private var model + @State private var isLoadingConfig = false + @State private var launchAtLoad = false + @State private var gatewayBind = "127.0.0.1" + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + SettingsSectionHeader( + breadcrumb: "Settings / Global", + title: "Global Configuration", + subtitle: "Machine-wide defaults for daemon startup and local gateway networking" + ) + globalDefaultsCard + InlineCallout( + tone: .neutral, + title: "Extension settings moved", + message: "Cloudflare, Tailscale, and Supervisor settings now live on their dedicated pages so each extension has focused setup and diagnostics.", + actions: [] + ) + quickActionsCard + } + .padding(16) + } + .task { + await loadConfigFromDisk() + } + .onChange(of: model.lastUpdated) { _, _ in + Task { await loadConfigFromDisk() } + } + } + + private var globalDefaultsCard: some View { + GlassCard(title: "Editable settings", systemImage: "slider.horizontal.3") { + VStack(alignment: .leading, spacing: 12) { + Text("These controls apply globally to this machine.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + + Toggle("Launch daemon at login", isOn: $launchAtLoad) + .font(.mono(.subheadline)) + + Divider() + .opacity(0.24) + + VStack(alignment: .leading, spacing: 8) { + Text("Gateway bind") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + Text("Local only keeps access on this Mac. LAN exposes gateway access to your local network.") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + Picker("Gateway bind", selection: $gatewayBind) { + Text("Local only (127.0.0.1)").tag("127.0.0.1") + Text("LAN (0.0.0.0)").tag("0.0.0.0") + } + .pickerStyle(.segmented) + } + + HStack(spacing: 10) { + Button { + Task { + await applyGlobalSettings() + } + } label: { + Label("Save settings", systemImage: "checkmark") + } + .adaptiveToolbarButtonProminent() + + Button { + Task { + await loadConfigFromDisk() + } + } label: { + Label("Reload", systemImage: "arrow.clockwise") + } + .adaptiveToolbarButton() + + if isLoadingConfig { + ProgressView() + .controlSize(.small) + } + Spacer() + } + } + } + } + + private var quickActionsCard: some View { + InlineCallout( + tone: .neutral, + title: "Global setup + trust", + message: "Use these commands when setting up a machine or rotating local TLS trust.", + actions: [ + InlineCalloutAction(label: "Copy install", systemImage: "doc.on.doc") { + TerminalIntegration.copyToClipboard("hack global install") + }, + InlineCalloutAction(label: "Run trust", systemImage: "terminal") { + TerminalIntegration.openTerminalWithCommand("hack global trust") + }, + InlineCalloutAction(label: "Restart daemon", systemImage: "arrow.clockwise") { + TerminalIntegration.openTerminalWithCommand("hack daemon restart") + } + ] + ) + } + + private func loadConfigFromDisk() async { + isLoadingConfig = true + defer { isLoadingConfig = false } + + let snapshot = GlobalConfigSnapshot.load() + if let launchd = snapshot.daemonLaunchdRunAtLoad { + launchAtLoad = launchd + } + if let bind = snapshot.gatewayBind, !bind.isEmpty { + gatewayBind = bind + } else if let fallbackBind = model.globalStatus?.gateway?.gatewayBind, !fallbackBind.isEmpty { + gatewayBind = fallbackBind + } + } + + private func applyGlobalSettings() async { + isLoadingConfig = true + defer { isLoadingConfig = false } + + await model.setGlobalConfig( + key: "controlPlane.daemon.launchd.runAtLoad", + value: launchAtLoad ? "true" : "false" + ) + await model.setGlobalConfig( + key: "controlPlane.gateway.bind", + value: gatewayBind + ) + + await model.refresh() + await loadConfigFromDisk() + } +} + +private struct SupervisorSettingsView: View { + @Environment(DashboardModel.self) private var model + @State private var isLoadingConfig = false + @State private var enabled = true + @State private var maxConcurrentJobs = 4 + @State private var logsMaxMegabytes = 5 + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + SettingsSectionHeader( + breadcrumb: "Settings / Supervisor", + title: "Supervisor", + subtitle: "Global queue + worker controls for background control-plane jobs" + ) + GlassCard(title: "Supervisor status", systemImage: "cpu") { + HStack(spacing: 8) { + StatusPill(text: enabled ? "Enabled" : "Disabled", tone: enabled ? .good : .warn) + Spacer() + if isLoadingConfig { + ProgressView() + .controlSize(.small) + } + } + Text("Supervisor coordinates background jobs used by control-plane operations. Disable only for troubleshooting.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + GlassCard(title: "Runtime controls", systemImage: "slider.horizontal.3") { + VStack(alignment: .leading, spacing: 12) { + Toggle("Enable supervisor globally", isOn: $enabled) + .font(.mono(.subheadline)) + + Divider() + .opacity(0.2) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Max concurrent jobs") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + Spacer() + Text("\(maxConcurrentJobs)") + .font(.mono(.caption)) + .foregroundStyle(.tertiary) + } + Stepper(value: $maxConcurrentJobs, in: 1...32) { + Text("Worker slots") + .font(.mono(.subheadline)) + } + } + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Log retention budget") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + Spacer() + Text(logBudgetLabel) + .font(.mono(.caption)) + .foregroundStyle(.tertiary) + } + Stepper(value: $logsMaxMegabytes, in: 1...100, step: 1) { + Text("Max log cache (MB)") + .font(.mono(.subheadline)) + } + } + + HStack(spacing: 10) { + Button { + Task { await saveSupervisorSettings() } + } label: { + Label("Save supervisor settings", systemImage: "checkmark") + } + .adaptiveToolbarButtonProminent() + + Button { + Task { await loadConfigFromDisk() } + } label: { + Label("Reload", systemImage: "arrow.clockwise") + } + .adaptiveToolbarButton() + + Spacer() + } + } + } + InlineCallout( + tone: .neutral, + title: "Need more diagnostics?", + message: "Open daemon logs when debugging queue stalls or job retries.", + actions: [ + InlineCalloutAction(label: "Open daemon logs", systemImage: "terminal") { + openGlobalCommandInTerminalPanel( + command: "tail -f \"$HOME/.hack/daemon/hackd.log\"", + title: "daemon log tail" + ) + }, + InlineCalloutAction(label: "Restart daemon", systemImage: "arrow.clockwise") { + TerminalIntegration.openTerminalWithCommand("hack daemon restart") + } + ] + ) + } + .padding(16) + } + .task { + await loadConfigFromDisk() + } + .onChange(of: model.lastUpdated) { _, _ in + Task { await loadConfigFromDisk() } + } + } + + private var logBudgetLabel: String { + "\(logsMaxMegabytes) MB (\(logsMaxBytesValue) bytes)" + } + + private var logsMaxBytesValue: Int { + max(1, logsMaxMegabytes) * 1_000_000 + } + + private func loadConfigFromDisk() async { + isLoadingConfig = true + defer { isLoadingConfig = false } + + let snapshot = GlobalConfigSnapshot.load() + enabled = snapshot.supervisorEnabled ?? true + maxConcurrentJobs = max(1, snapshot.supervisorMaxConcurrentJobs ?? 4) + let rawBytes = max(1, snapshot.supervisorLogsMaxBytes ?? 5_000_000) + logsMaxMegabytes = max(1, (rawBytes + 999_999) / 1_000_000) + } + + private func saveSupervisorSettings() async { + isLoadingConfig = true + defer { isLoadingConfig = false } + + await model.setGlobalConfig( + key: "controlPlane.supervisor.enabled", + value: enabled ? "true" : "false" + ) + await model.setGlobalConfig( + key: "controlPlane.supervisor.maxConcurrentJobs", + value: String(maxConcurrentJobs) + ) + await model.setGlobalConfig( + key: "controlPlane.supervisor.logsMaxBytes", + value: String(logsMaxBytesValue) + ) + + await model.refresh() + await loadConfigFromDisk() + } +} + +private struct CloudflareExtensionSettingsView: View { + private enum FocusedField: Hashable { + case hostname + case sshHostname + } + + @Environment(DashboardModel.self) private var model + @State private var isLoadingConfig = false + @State private var isSavingToggle = false + @State private var isTunnelActionInFlight = false + @State private var suppressEnabledToggleChange = false + @State private var enabled = false + @State private var hostname = "" + @State private var sshHostname = "" + @State private var tunnelIsRunning = false + @State private var tunnelPid: Int? = nil + @State private var loadedEnabled = false + @State private var loadedHostname = "" + @State private var loadedSSHHostname = "" + @FocusState private var focusedField: FocusedField? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + SettingsSectionHeader( + breadcrumb: "Settings / Extensions / Cloudflare", + title: "Cloudflare Extension", + subtitle: "Public tunnel exposure via cloudflared for HTTPS and SSH entrypoints" + ) + GlassCard(title: "Extension status", systemImage: "cloud") { + HStack(alignment: .center, spacing: 8) { + StatusPill(text: enabled ? "Enabled" : "Disabled", tone: enabled ? .good : .neutral) + StatusPill( + text: tunnelIsRunning ? "Tunnel running" : "Tunnel stopped", + tone: tunnelStatusTone + ) + Spacer() + Toggle("Enabled", isOn: $enabled) + .labelsHidden() + .toggleStyle(.switch) + .onChange(of: enabled) { _, newValue in + guard !suppressEnabledToggleChange else { return } + Task { + await applyCloudflareEnabledToggle(newValue) + } + } + if isLoadingConfig || isTunnelActionInFlight { + ProgressView() + .controlSize(.small) + } + if isSavingToggle { + ProgressView() + .controlSize(.small) + } + } + if let configuredHost = normalizedHost(hostname) { + Text("Hostname: \(configuredHost)") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } else { + Text("Hostname not configured") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + if tunnelIsRunning, let tunnelPid { + Text("Tunnel PID: \(tunnelPid)") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } else { + Text("Tunnel process is not running") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + if let detail = cloudflareExposure?.detail, !detail.isEmpty { + Text(detail) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + } + GlassCard(title: "Configuration", systemImage: "slider.horizontal.3") { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text("cloudflared binary") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + StatusPill( + text: cloudflaredInstalled ? "Installed" : "Missing", + tone: cloudflaredInstalled ? .good : .warn + ) + } + Text(cloudflaredPathLabel) + .font(.mono(.caption2)) + .foregroundStyle(cloudflaredInstalled ? .secondary : Color.orange) + .textSelection(.enabled) + } + + Divider() + .opacity(0.2) + + VStack(alignment: .leading, spacing: 6) { + Text("Primary hostname") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + TextField("gateway.example.com", text: $hostname) + .textFieldStyle(.roundedBorder) + .font(.mono(.subheadline)) + .focused($focusedField, equals: .hostname) + Text("Used for gateway HTTPS routes.") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + + VStack(alignment: .leading, spacing: 6) { + Text("SSH hostname (optional)") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + TextField("ssh.example.com", text: $sshHostname) + .textFieldStyle(.roundedBorder) + .font(.mono(.subheadline)) + .focused($focusedField, equals: .sshHostname) + Text("Used for SSH routing when remote workflows are enabled.") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + + HStack(spacing: 10) { + Button { + Task { await saveCloudflareSettings() } + } label: { + Label("Save Cloudflare settings", systemImage: "checkmark") + } + .adaptiveToolbarButtonProminent() + + Button { + Task { await loadConfigFromDisk() } + } label: { + Label("Reload", systemImage: "arrow.clockwise") + } + .adaptiveToolbarButton() + + Button { + Task { await startCloudflareTunnel() } + } label: { + Label("Start tunnel", systemImage: "play.fill") + } + .adaptiveToolbarButton() + .disabled(!enabled || !cloudflaredInstalled || tunnelIsRunning || isTunnelActionInFlight) + + Button { + Task { await stopCloudflareTunnel() } + } label: { + Label("Stop tunnel", systemImage: "stop.fill") + } + .adaptiveToolbarButton() + .disabled((!tunnelIsRunning && !isTunnelActionInFlight) || isLoadingConfig) + + Spacer() + } + } + } + InlineCallout( + tone: .neutral, + title: "Cloudflare requirements", + message: "Install `cloudflared` locally and set hostnames before enabling. Missing hostname or binary will keep status at Needs setup.", + actions: [ + InlineCalloutAction(label: "Open tunnel logs", systemImage: "terminal") { + openGlobalCommandInTerminalPanel( + command: "hack gateway status --json && hack global logs caddy --follow", + title: "cloudflare diagnostics" + ) + } + ] + ) + } + .padding(16) + } + .task { + await loadConfigFromDisk() + } + .onChange(of: model.lastUpdated) { _, _ in + guard shouldReloadFromRefresh else { return } + Task { await loadConfigFromDisk() } + } + } + + private var cloudflareExposure: GatewayExposure? { + model.globalStatus?.gateway?.exposures?.first(where: { $0.id == "cloudflare" }) + } + + private var tunnelStatusTone: StatusTone { + if tunnelIsRunning { + return .good + } + if enabled { + return .warn + } + return .neutral + } + + private var cloudflaredPath: String? { + resolveExecutablePath(candidates: ["cloudflared"]) + } + + private var cloudflaredInstalled: Bool { + cloudflaredPath != nil + } + + private var cloudflaredPathLabel: String { + "cloudflared path: \(cloudflaredPath ?? "Not found in PATH")" + } + + private func loadConfigFromDisk() async { + isLoadingConfig = true + defer { isLoadingConfig = false } + + let snapshot = GlobalConfigSnapshot.load() + let nextEnabled = snapshot.cloudflareExtensionEnabled ?? false + let nextHostname = snapshot.cloudflareHostname ?? "" + let nextSSHHostname = snapshot.cloudflareSSHHostname ?? "" + suppressEnabledToggleChange = true + enabled = nextEnabled + hostname = nextHostname + sshHostname = nextSSHHostname + suppressEnabledToggleChange = false + loadedEnabled = nextEnabled + loadedHostname = nextHostname + loadedSSHHostname = nextSSHHostname + refreshCloudflareTunnelState() + } + + private func applyCloudflareEnabledToggle(_ isEnabled: Bool) async { + isSavingToggle = true + defer { isSavingToggle = false } + + let didUpdate = await model.setGlobalConfig( + key: "controlPlane.extensions[\"dance.hack.cloudflare\"].enabled", + value: isEnabled ? "true" : "false" + ) + guard didUpdate else { + await loadConfigFromDisk() + return + } + if !isEnabled, tunnelIsRunning { + isTunnelActionInFlight = true + _ = await model.stopCloudflareTunnel() + isTunnelActionInFlight = false + } + await loadConfigFromDisk() + } + + private func saveCloudflareSettings() async { + isLoadingConfig = true + defer { isLoadingConfig = false } + + let didSaveEnabled = await model.setGlobalConfig( + key: "controlPlane.extensions[\"dance.hack.cloudflare\"].enabled", + value: enabled ? "true" : "false" + ) + guard didSaveEnabled else { + await loadConfigFromDisk() + return + } + let didSaveHostname = await model.setGlobalConfig( + key: "controlPlane.extensions[\"dance.hack.cloudflare\"].config.hostname", + value: hostname.trimmingCharacters(in: .whitespacesAndNewlines) + ) + guard didSaveHostname else { + await loadConfigFromDisk() + return + } + let didSaveSSHHostname = await model.setGlobalConfig( + key: "controlPlane.extensions[\"dance.hack.cloudflare\"].config.sshHostname", + value: sshHostname.trimmingCharacters(in: .whitespacesAndNewlines) + ) + guard didSaveSSHHostname else { + await loadConfigFromDisk() + return + } + + await loadConfigFromDisk() + } + + private func startCloudflareTunnel() async { + guard enabled, cloudflaredInstalled else { return } + isTunnelActionInFlight = true + defer { isTunnelActionInFlight = false } + let started = await model.startCloudflareTunnel() + guard started else { + await loadConfigFromDisk() + return + } + await model.refresh() + await loadConfigFromDisk() + } + + private func stopCloudflareTunnel() async { + isTunnelActionInFlight = true + defer { isTunnelActionInFlight = false } + let stopped = await model.stopCloudflareTunnel() + guard stopped else { + await loadConfigFromDisk() + return + } + await model.refresh() + await loadConfigFromDisk() + } + + private func refreshCloudflareTunnelState() { + let pidPath = cloudflaredPidPath + guard let pidRaw = try? String(contentsOfFile: pidPath, encoding: .utf8) else { + tunnelPid = nil + tunnelIsRunning = false + return + } + let trimmed = pidRaw.trimmingCharacters(in: .whitespacesAndNewlines) + guard let pid = Int(trimmed), pid > 0 else { + tunnelPid = nil + tunnelIsRunning = false + return + } + tunnelPid = pid + tunnelIsRunning = isProcessRunning(pid: pid) + if !tunnelIsRunning { + tunnelPid = nil + } + } + + private var cloudflaredPidPath: String { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".hack/cloudflare/cloudflared.pid") + .path + } + + private func isProcessRunning(pid: Int) -> Bool { + guard pid > 0 else { return false } + if kill(pid_t(pid), 0) == 0 { + return true + } + return errno == EPERM + } + + private func normalizedHost(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private var shouldReloadFromRefresh: Bool { + if isLoadingConfig || isSavingToggle || isTunnelActionInFlight { + return false + } + if focusedField != nil { + return false + } + return !hasUnsavedChanges + } + + private var hasUnsavedChanges: Bool { + if enabled != loadedEnabled { + return true + } + if normalizedValue(hostname) != normalizedValue(loadedHostname) { + return true + } + if normalizedValue(sshHostname) != normalizedValue(loadedSSHHostname) { + return true + } + return false + } + + private func normalizedValue(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +private struct TailscaleExtensionSettingsView: View { + @Environment(DashboardModel.self) private var model + @State private var isLoadingConfig = false + @State private var isSavingToggle = false + @State private var isLoadingDiagnostics = false + @State private var suppressEnabledToggleChange = false + @State private var enabled = false + @State private var diagnostics: TailscaleInspectResponse? = nil + @State private var selectedExitNodeId = "" + @State private var lastDiagnosticsRefreshAt: Date? = nil + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + SettingsSectionHeader( + breadcrumb: "Settings / Extensions / Tailscale", + title: "Tailscale Extension", + subtitle: "Tailnet-based secure access and remote routing for gateway projects" + ) + GlassCard(title: "Extension status", systemImage: "network") { + HStack(alignment: .center, spacing: 8) { + StatusPill(text: enabled ? "Enabled" : "Disabled", tone: enabled ? .good : .neutral) + StatusPill( + text: tailscaleInstalled ? "tailscale installed" : "tailscale missing", + tone: tailscaleInstalled ? .good : .warn + ) + StatusPill( + text: tailscaleConnected ? "Tailnet connected" : "Tailnet disconnected", + tone: tailscaleConnected ? .good : .warn + ) + if let backendState = diagnostics?.backendState, !backendState.isEmpty { + StatusPill( + text: "Backend \(backendState)", + tone: tailscaleConnected ? .good : .neutral + ) + } + StatusPill( + text: selfDeviceOnline ? "Host online" : "Host offline", + tone: selfDeviceOnline ? .good : .warn + ) + Spacer() + Toggle("Enabled", isOn: $enabled) + .labelsHidden() + .toggleStyle(.switch) + .onChange(of: enabled) { _, newValue in + guard !suppressEnabledToggleChange else { return } + Task { + await applyTailscaleEnabledToggle(newValue) + } + } + if isLoadingConfig || isLoadingDiagnostics { + ProgressView() + .controlSize(.small) + } + if isSavingToggle { + ProgressView() + .controlSize(.small) + } + } + Text("tailscale path: \(tailscalePathLabel)") + .font(.mono(.caption2)) + .foregroundStyle(tailscaleInstalled ? .secondary : Color.orange) + .textSelection(.enabled) + if let tailnetLabel { + Text("Tailnet: \(tailnetLabel)") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + if let selfDevice = diagnostics?.selfDevice { + Text("This device: \(selfDevice.hostname)\(selfDevice.tailscaleIp.map { " (\($0))" } ?? "")") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + if let lastDiagnosticsRefreshAt { + Text("Last checked \(lastDiagnosticsRefreshAt.formatted(date: .abbreviated, time: .shortened))") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + if let authUrl = diagnostics?.authUrl, !authUrl.isEmpty { + Text("Login required: \(authUrl)") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + if let error = diagnostics?.error, !error.isEmpty { + Text(error) + .font(.mono(.caption)) + .foregroundStyle(Color.orange) + } else if let detail = tailscaleExposure?.detail, !detail.isEmpty { + Text(detail) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + } + GlassCard(title: "Tailnet controls", systemImage: "slider.horizontal.3") { + VStack(alignment: .leading, spacing: 12) { + Text("The extension controls gateway exposure behavior, while Tailscale runtime state is shown here regardless of extension enablement.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + HStack(spacing: 10) { + Button { + Task { await refreshTailscaleDiagnostics() } + } label: { + Label("Refresh state", systemImage: "arrow.clockwise") + } + .adaptiveToolbarButtonProminent() + + Button { + if tailscaleConnected { + openGlobalCommandInTerminalPanel( + command: "tailscale down", + title: "tailscale down" + ) + } else { + openGlobalCommandInTerminalPanel( + command: "tailscale up", + title: "tailscale up" + ) + } + } label: { + Label( + tailscaleConnected ? "Disconnect tailnet" : "Connect tailnet", + systemImage: tailscaleConnected ? "stop.fill" : "play.fill" + ) + } + .adaptiveToolbarButton() + .disabled(!tailscaleInstalled) + + Button { + openGlobalCommandInTerminalPanel( + command: "tailscale status", + title: "tailscale status" + ) + } label: { + Label("Open CLI status", systemImage: "terminal") + } + .adaptiveToolbarButton() + + Button { + openGlobalCommandInTerminalPanel( + command: "tailscale status --json", + title: "tailscale status --json" + ) + } label: { + Label("Open status JSON", systemImage: "doc.plaintext") + } + .adaptiveToolbarButton() + Spacer() + } + + if !exitNodes.isEmpty { + Divider() + .opacity(0.2) + + HStack(alignment: .center, spacing: 10) { + Picker("Exit node", selection: $selectedExitNodeId) { + Text("No exit node").tag("") + ForEach(exitNodes, id: \.id) { peer in + Text(peer.hostname).tag(peer.id) + } + } + .pickerStyle(.menu) + .frame(minWidth: 220, alignment: .leading) + + Button { + applySelectedExitNode() + } label: { + Label("Use selected node", systemImage: "location.north.line.fill") + } + .adaptiveToolbarButton() + .disabled(!tailscaleInstalled || selectedExitNodeId.isEmpty) + + Button { + clearSelectedExitNode() + } label: { + Label("Clear exit node", systemImage: "location.slash") + } + .adaptiveToolbarButton() + .disabled(!tailscaleInstalled || !hasCurrentExitNode) + + Spacer() + } + + if let currentExitNodeName = diagnostics?.currentExitNodeName, !currentExitNodeName.isEmpty { + Text("Current exit node: \(currentExitNodeName)") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + } + } + } + tailscaleNetworkCard + } + .padding(16) + } + .task { + await loadConfigFromDisk() + } + .onChange(of: model.lastUpdated) { _, _ in + Task { await loadConfigFromDisk() } + } + } + + private var tailscaleExposure: GatewayExposure? { + model.globalStatus?.gateway?.exposures?.first(where: { $0.id == "tailscale" }) + } + + @ViewBuilder + private var tailscaleNetworkCard: some View { + GlassCard(title: "Tailnet state", systemImage: "point.3.connected.trianglepath.dotted") { + if !tailscaleInstalled { + Text("Install Tailscale to populate nodes, tags, and exit-node controls.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } else { + if let diagnostics, let error = diagnostics.error, !error.isEmpty { + Text(error) + .font(.mono(.caption)) + .foregroundStyle(Color.orange) + } + + if let selfDevice = diagnostics?.selfDevice { + VStack(alignment: .leading, spacing: 4) { + Text("This device") + .font(.mono(.caption, weight: .semibold)) + .foregroundStyle(.secondary) + Text(selfDevice.hostname) + .font(.mono(.subheadline, weight: .semibold)) + if let ip = selfDevice.tailscaleIp { + Text(ip) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + if let dnsName = selfDevice.dnsName, !dnsName.isEmpty { + Text(dnsName) + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + if !selfDevice.tags.isEmpty { + Text("Tags: \(selfDevice.tags.joined(separator: ", "))") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + if let os = selfDevice.os, !os.isEmpty { + Text("OS: \(os)") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + } + } + + if !healthMessages.isEmpty { + Divider() + .opacity(0.2) + + VStack(alignment: .leading, spacing: 6) { + Text("Health checks") + .font(.mono(.caption, weight: .semibold)) + .foregroundStyle(.secondary) + ForEach(Array(healthMessages.enumerated()), id: \.offset) { _, message in + Text("• \(message)") + .font(.mono(.caption2)) + .foregroundStyle(Color.orange) + } + } + } + + Divider() + .opacity(0.2) + + VStack(alignment: .leading, spacing: 6) { + Text("Exit nodes") + .font(.mono(.caption, weight: .semibold)) + .foregroundStyle(.secondary) + if let currentExitNodeName = diagnostics?.currentExitNodeName, !currentExitNodeName.isEmpty { + Text("Current: \(currentExitNodeName)") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } else { + Text("Current: none") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + + let exitNodes = diagnostics?.exitNodes ?? [] + if exitNodes.isEmpty { + Text("No exit-node capable peers detected.") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } else { + ForEach(Array(exitNodes.prefix(6)), id: \.id) { peer in + tailscalePeerRow(peer) + } + } + } + + if !taggedPeers.isEmpty { + Divider() + .opacity(0.2) + + VStack(alignment: .leading, spacing: 6) { + Text("Tagged devices (\(taggedPeers.filter(\.online).count)/\(taggedPeers.count) online)") + .font(.mono(.caption, weight: .semibold)) + .foregroundStyle(.secondary) + ForEach(Array(taggedPeers.prefix(12)), id: \.id) { peer in + tailscalePeerRow(peer) + } + } + } + + Divider() + .opacity(0.2) + + VStack(alignment: .leading, spacing: 6) { + Text("Devices (\(diagnostics?.onlinePeerCount ?? 0)/\(diagnostics?.peers.count ?? 0) online)") + .font(.mono(.caption, weight: .semibold)) + .foregroundStyle(.secondary) + if personalPeers.isEmpty { + Text("No untagged devices reported by tailscale status.") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } else { + ForEach(Array(personalPeers.prefix(12)), id: \.id) { peer in + tailscalePeerRow(peer) + } + } + } + } + } + } + + private func tailscalePeerRow(_ peer: TailscaleInspectPeer) -> some View { + HStack(alignment: .center, spacing: 8) { + Circle() + .fill(peer.online ? Color.green : Color.secondary.opacity(0.7)) + .frame(width: 6, height: 6) + Text(peer.hostname) + .font(.mono(.caption)) + .lineLimit(1) + .truncationMode(.tail) + if peer.isExitNodeOption || peer.isExitNode { + StatusPill(text: "Exit node", tone: .neutral) + } + if !peer.tags.isEmpty { + Text(peer.tags.joined(separator: ", ")) + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer(minLength: 0) + if let ip = peer.tailscaleIp { + Text(ip) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + } + } + + private var tailscaleConnected: Bool { + diagnostics?.connected == true + } + + private var selfDeviceOnline: Bool { + diagnostics?.selfDevice?.online == true + } + + private var tailnetLabel: String? { + guard let tailnetName = diagnostics?.tailnetName, !tailnetName.isEmpty else { + return nil + } + guard let suffix = diagnostics?.magicDnsSuffix, !suffix.isEmpty else { + return tailnetName + } + return "\(tailnetName) (\(suffix))" + } + + private var tailscalePath: String? { + resolveExecutablePath(candidates: ["tailscale"]) + } + + private var tailscaleInstalled: Bool { + diagnostics?.installed ?? (tailscalePath != nil) + } + + private var tailscalePathLabel: String { + diagnostics?.binaryPath ?? tailscalePath ?? "Not found in PATH" + } + + private var exitNodes: [TailscaleInspectPeer] { + diagnostics?.exitNodes ?? [] + } + + private var taggedPeers: [TailscaleInspectPeer] { + let peers = diagnostics?.peers ?? [] + return peers.filter { !$0.tags.isEmpty } + } + + private var personalPeers: [TailscaleInspectPeer] { + let peers = diagnostics?.peers ?? [] + return peers.filter(\.tags.isEmpty) + } + + private var healthMessages: [String] { + diagnostics?.health ?? [] + } + + private var hasCurrentExitNode: Bool { + guard let currentExitNodeId = diagnostics?.currentExitNodeId else { return false } + return !currentExitNodeId.isEmpty + } + + private func loadConfigFromDisk() async { + isLoadingConfig = true + let snapshot = GlobalConfigSnapshot.load() + suppressEnabledToggleChange = true + enabled = snapshot.tailscaleExtensionEnabled ?? false + suppressEnabledToggleChange = false + isLoadingConfig = false + await refreshTailscaleDiagnostics() + } + + private func refreshTailscaleDiagnostics() async { + isLoadingDiagnostics = true + defer { isLoadingDiagnostics = false } + diagnostics = await model.inspectTailscale() + lastDiagnosticsRefreshAt = Date() + syncExitNodeSelection() + } + + private func applyTailscaleEnabledToggle(_ isEnabled: Bool) async { + isSavingToggle = true + defer { isSavingToggle = false } + + let didUpdate = await model.setGlobalConfig( + key: "controlPlane.extensions[\"dance.hack.tailscale\"].enabled", + value: isEnabled ? "true" : "false" + ) + guard didUpdate else { + await loadConfigFromDisk() + return + } + await loadConfigFromDisk() + } + + private func syncExitNodeSelection() { + guard let diagnostics else { + selectedExitNodeId = "" + return + } + if diagnostics.exitNodes.contains(where: { $0.id == selectedExitNodeId }) { + return + } + selectedExitNodeId = diagnostics.currentExitNodeId ?? "" + } + + private func applySelectedExitNode() { + guard !selectedExitNodeId.isEmpty else { return } + openGlobalCommandInTerminalPanel( + command: "tailscale set --exit-node=\(shellQuote(selectedExitNodeId))", + title: "tailscale set --exit-node" + ) + } + + private func clearSelectedExitNode() { + openGlobalCommandInTerminalPanel( + command: "tailscale set --exit-node=", + title: "tailscale clear exit node" + ) + } + + private func shellQuote(_ value: String) -> String { + let escaped = value.replacingOccurrences(of: "'", with: "'\\''") + return "'\(escaped)'" + } +} + +private struct PermissionsSettingsView: View { + @AppStorage("hackDesktop.permissions.terminalAutomationChecked") private var automationChecked = false + @AppStorage("hackDesktop.permissions.terminalAutomationGranted") private var storedAutomationGranted = false + @State private var lastAutomationCheckAt: Date? = nil + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + SettingsSectionHeader( + breadcrumb: "Settings / Permissions", + title: "Permissions + Access", + subtitle: "Required OS access for terminal automation, networking, and embedded terminal features" + ) + + GlassCard(title: "Permission matrix", systemImage: "hand.raised.fill") { + VStack(alignment: .leading, spacing: 12) { + permissionStatusRow( + title: "Terminal automation", + detail: automationStatusDetail, + granted: automationGranted, + actionLabel: automationGranted == true ? nil : "Request", + action: automationGranted == true + ? nil + : { + let granted = TerminalIntegration.requestTerminalAutomationPermission() + storedAutomationGranted = granted + automationChecked = true + lastAutomationCheckAt = Date() + } + ) + permissionStatusRow( + title: "Embedded terminal", + detail: GhosttyVTRuntime.shared.isAvailable + ? "Ghostty VT runtime is available." + : "Ghostty VT runtime is unavailable on this system.", + granted: GhosttyVTRuntime.shared.isAvailable + ) + permissionStatusRow( + title: "Filesystem access", + detail: "Desktop app is running in developer tooling mode (sandbox disabled).", + granted: true + ) + permissionStatusRow( + title: "Local network", + detail: "Managed by macOS and prompted on first local-network access.", + granted: nil + ) + } + } + + InlineCallout( + tone: automationGranted == true ? .good : .warn, + title: automationGranted == true ? "Permissions look good" : "Terminal automation still missing", + message: automationGranted == true + ? "Automation access is approved. macOS still prompts local-network permission when first needed." + : "Grant Terminal automation so the app can execute setup and maintenance commands without manual copy/paste.", + actions: [ + InlineCalloutAction(label: "Open Automation privacy", systemImage: "gearshape") { + openSystemSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") + }, + InlineCalloutAction(label: "Open Local Network privacy", systemImage: "network") { + openSystemSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork") + } + ] + ) + + GlassCard(title: "What each permission does", systemImage: "questionmark.circle") { + VStack(alignment: .leading, spacing: 8) { + helperLine( + title: "Terminal automation", + body: "Needed for one-click setup flows that open/run commands in Terminal." + ) + helperLine( + title: "Embedded terminal", + body: "Powers inline shells/log tails without leaving the desktop app." + ) + helperLine( + title: "Local network", + body: "Required when exposing gateway to LAN or connecting to local service hostnames." + ) + } + } + + GlassCard(title: "Setup helpers", systemImage: "wand.and.stars") { + VStack(alignment: .leading, spacing: 10) { + Text("Use setup commands when permissions or trust state changes:") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + HStack(spacing: 10) { + Button("Run global install") { + TerminalIntegration.openTerminalWithCommand("hack global install") + } + .adaptiveToolbarButton() + Button("Run global trust") { + TerminalIntegration.openTerminalWithCommand("hack global trust") + } + .adaptiveToolbarButton() + Button("Restart daemon") { + TerminalIntegration.openTerminalWithCommand("hack daemon restart") + } + .adaptiveToolbarButton() + Spacer() + } + } + } + } + .padding(16) + } + } + + private var automationGranted: Bool? { + automationChecked ? storedAutomationGranted : nil + } + + private var automationStatusDetail: String { + let base = automationChecked + ? (storedAutomationGranted ? "Granted." : "Denied or not granted.") + : "Not checked yet." + if let lastAutomationCheckAt { + return "\(base) Last check \(relativeTimeString(from: lastAutomationCheckAt))." + } + return base + } + + private func permissionStatusRow( + title: String, + detail: String, + granted: Bool?, + actionLabel: String? = nil, + action: (() -> Void)? = nil + ) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: permissionIcon(granted: granted)) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(permissionColor(granted: granted)) + .padding(.top, 1) + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.mono(.subheadline, weight: .semibold)) + Text(detail) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + Spacer() + if let actionLabel, let action { + Button(actionLabel) { + action() + } + .adaptiveToolbarButton() + } else { + Text(permissionLabel(granted: granted)) + .font(.mono(.caption2, weight: .semibold)) + .foregroundStyle(permissionColor(granted: granted)) + } + } + } + + private func permissionIcon(granted: Bool?) -> String { + switch granted { + case true: + return "checkmark.circle.fill" + case false: + return "xmark.circle.fill" + case .none: + return "questionmark.circle.fill" + } + } + + private func permissionLabel(granted: Bool?) -> String { + switch granted { + case true: + return "Approved" + case false: + return "Missing" + case .none: + return "System-managed" + } + } + + private func permissionColor(granted: Bool?) -> Color { + switch granted { + case true: + return .green + case false: + return .orange + case .none: + return .secondary + } + } + + private func openSystemSettings(_ urlString: String) { + guard let url = URL(string: urlString) else { return } + NSWorkspace.shared.open(url) + } + + private func relativeTimeString(from date: Date) -> String { + RelativeDateTimeFormatter().localizedString(for: date, relativeTo: Date()) + } + + private func helperLine(title: String, body: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.mono(.subheadline, weight: .semibold)) + Text(body) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + } +} + +private struct ExtensionsSettingsView: View { + @Environment(DashboardModel.self) private var model + @Binding var selection: SettingsSidebarItem + @State private var isLoading = false + @State private var suppressToggleChange = false + @State private var cloudflareEnabled = false + @State private var tailscaleEnabled = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + SettingsSectionHeader( + breadcrumb: "Settings / Extensions", + title: "Extensions", + subtitle: "External connectivity integrations available in this workspace" + ) + InlineCallout( + tone: .neutral, + title: "Built-ins are hidden", + message: "Internal components like gateway, tickets, and supervisor are configured on dedicated system pages and are intentionally omitted from the extension list.", + actions: [] + ) + GlassCard(title: "Managed extensions", systemImage: "puzzlepiece.extension") { + VStack(alignment: .leading, spacing: 12) { + extensionSummaryRow( + item: .cloudflare, + name: "Cloudflare", + description: "Public HTTPS + SSH tunnel exposure via cloudflared.", + projectCount: projectCount(for: "dance.hack.cloudflare"), + exposure: extensionExposure(id: "cloudflare"), + isOn: $cloudflareEnabled + ) + Divider() + .opacity(0.2) + extensionSummaryRow( + item: .tailscale, + name: "Tailscale", + description: "Tailnet exposure and secure remote access.", + projectCount: projectCount(for: "dance.hack.tailscale"), + exposure: extensionExposure(id: "tailscale"), + isOn: $tailscaleEnabled + ) + if isLoading { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Saving extension settings…") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + } + } + } + } + .padding(16) + } + .task { + await loadConfigFromDisk() + } + .onChange(of: model.lastUpdated) { _, _ in + Task { await loadConfigFromDisk() } + } + } + + private func extensionSummaryRow( + item: SettingsSidebarItem, + name: String, + description: String, + projectCount: Int, + exposure: GatewayExposure?, + isOn: Binding + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(name) + .font(.mono(.subheadline, weight: .semibold)) + Spacer() + Toggle("", isOn: isOn) + .toggleStyle(.switch) + .labelsHidden() + .onTapGesture { + // Prevent row navigation tap when toggling inline. + } + .onChange(of: isOn.wrappedValue) { _, newValue in + guard !suppressToggleChange else { return } + Task { + await setExtensionEnabled(item: item, enabled: newValue) + } + } + if let exposure { + StatusPill(text: exposure.statusLabel, tone: exposure.statusTone) + } else { + StatusPill(text: "Unknown", tone: .neutral) + } + Image(systemName: "chevron.right") + .font(.mono(.caption)) + .foregroundStyle(.tertiary) + } + Text(description) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + Text("\(projectCount) project\(projectCount == 1 ? "" : "s") enabled") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + if let detail = exposure?.detail, !detail.isEmpty { + Text(detail) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + } + .contentShape(Rectangle()) + .onTapGesture { + selection = item + } + } + + private func projectCount(for extensionId: String) -> Int { + model.projects.filter { project in + canonicalExtensionIds(for: project).contains(extensionId) + }.count + } + + private func canonicalExtensionIds(for project: ProjectSummary) -> Set { + var ids: Set = [] + for value in (project.extensionsEnabled ?? []) + (project.features ?? []) { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch normalized { + case "cloudflare", "dance.hack.cloudflare": + ids.insert("dance.hack.cloudflare") + case "tailscale", "dance.hack.tailscale": + ids.insert("dance.hack.tailscale") + default: + continue + } + } + return ids + } + + private func extensionExposure(id: String) -> GatewayExposure? { + model.globalStatus?.gateway?.exposures?.first(where: { $0.id == id }) + } + + private func loadConfigFromDisk() async { + let snapshot = GlobalConfigSnapshot.load() + suppressToggleChange = true + cloudflareEnabled = snapshot.cloudflareExtensionEnabled ?? false + tailscaleEnabled = snapshot.tailscaleExtensionEnabled ?? false + suppressToggleChange = false + } + + private func setExtensionEnabled( + item: SettingsSidebarItem, + enabled: Bool + ) async { + isLoading = true + defer { isLoading = false } + + let key: String + switch item { + case .cloudflare: + key = "controlPlane.extensions[\"dance.hack.cloudflare\"].enabled" + case .tailscale: + key = "controlPlane.extensions[\"dance.hack.tailscale\"].enabled" + default: + return + } + + let didUpdate = await model.setGlobalConfig( + key: key, + value: enabled ? "true" : "false" + ) + guard didUpdate else { + await loadConfigFromDisk() + return + } + await loadConfigFromDisk() + } +} + +private struct CertificatesSettingsView: View { + @Environment(\.openURL) private var openURL + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + SettingsSectionHeader( + breadcrumb: "Settings / Certificates", + title: "Certificates", + subtitle: "Inspect local trust assets and run trust tooling" + ) + GlassCard(title: "Certificate files", systemImage: "checkmark.shield") { + DetailRows(rows: certificateRows, labelWidth: 170) + } + InlineCallout( + tone: .neutral, + title: "Certificate management", + message: "Use `hack global trust` to install or refresh local trust chain. Generated cert files are kept under ~/.hack and mkcert paths.", + actions: [ + InlineCalloutAction(label: "Run trust", systemImage: "terminal") { + TerminalIntegration.openTerminalWithCommand("hack global trust") + }, + InlineCalloutAction(label: "Open cert folder", systemImage: "folder") { + openURL(URL(fileURLWithPath: certDirectoryPath)) + }, + InlineCalloutAction(label: "Copy trust command", systemImage: "doc.on.doc") { + TerminalIntegration.copyToClipboard("hack global trust") + } + ] + ) + } + .padding(16) + } + } + + private var certDirectoryPath: String { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".hack/caddy/pki") + .path + } + + private var certificateRows: [DetailRowItem] { + certificateFiles.map { cert in + let exists = FileManager.default.fileExists(atPath: cert.path) ? "Yes" : "No" + return DetailRowItem(label: cert.label, value: "\(cert.path) (\(exists))") + } + } + + private var certificateFiles: [(label: String, path: String)] { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return [ + ("Hack local CA", "\(home)/.hack/caddy/pki/caddy-local-authority.crt"), + ("Hack local CA key", "\(home)/.hack/caddy/pki/caddy-local-authority.key"), + ("mkcert root CA", "\(home)/Library/Application Support/mkcert/rootCA.pem"), + ("mkcert root key", "\(home)/Library/Application Support/mkcert/rootCA-key.pem") + ] + } +} + +private struct LoggingSettingsView: View { + @Environment(DashboardModel.self) private var model + @Environment(\.openURL) private var openURL + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + SettingsSectionHeader( + breadcrumb: "Settings / Logging", + title: "Logging + Global Services", + subtitle: "Grafana + Loki stack, credentials, and Caddy/CoreDNS diagnostics" + ) + InlineCallout( + tone: .neutral, + title: "Global logging architecture", + message: "Global logging runs Loki + Alloy + Grafana. Logs from project containers are shipped into Loki and explored in Grafana.", + actions: [] + ) + GlassCard(title: "Grafana + Loki access", systemImage: "waveform.path.ecg") { + DetailRows(rows: grafanaRows) + HStack(spacing: 10) { + if let grafanaURL { + Button("Open Grafana") { + openURL(grafanaURL) + } + .adaptiveToolbarButtonProminent() + } + Button("Open Grafana logs") { + openGlobalCommandInTerminalPanel( + command: "hack global logs grafana --follow", + title: "grafana logs" + ) + } + .adaptiveToolbarButton() + Button("Open Loki logs") { + openGlobalCommandInTerminalPanel( + command: "hack global logs loki --follow", + title: "loki logs" + ) + } + .adaptiveToolbarButton() + Button("Copy LogQL example") { + TerminalIntegration.copyToClipboard("{project=\"event-agent\"}") + } + .adaptiveToolbarButton() + Spacer() + } + Text("Logging is part of global infrastructure and currently has no separate enable/disable toggle.") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + GlassCard(title: "Grafana credentials", systemImage: "person.crop.circle.badge.key") { + DetailRows(rows: credentialRows, labelWidth: 190) + Text("Default template credentials are `admin` / `admin`. Override by editing the logging compose environment and restarting global services.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + HStack(spacing: 10) { + Button("Reset Grafana admin password") { + openGlobalCommandInTerminalPanel( + command: """ + read -r -s -p "New Grafana admin password: " GRAFANA_PASSWORD; echo + docker compose -f "$HOME/.hack/logging/docker-compose.yml" exec grafana grafana-cli admin reset-admin-password "$GRAFANA_PASSWORD" + """, + title: "reset grafana password" + ) + } + .adaptiveToolbarButton() + Button("Open logging compose") { + openURL(URL(fileURLWithPath: loggingComposePath)) + } + .adaptiveToolbarButton() + Button("Copy logging compose path") { + TerminalIntegration.copyToClipboard(loggingComposePath) + } + .adaptiveToolbarButton() + Spacer() + } + } + GlassCard(title: "Caddy + CoreDNS", systemImage: "network") { + DetailRows(rows: caddyRows) + Text("Advanced Caddy/CoreDNS settings can be edited in ~/.hack/caddy/docker-compose.yml and ~/.hack/caddy/Corefile.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + HStack(spacing: 10) { + Button("Open Caddy logs") { + openGlobalCommandInTerminalPanel( + command: "hack global logs caddy --follow", + title: "caddy logs" + ) + } + .adaptiveToolbarButton() + Button("Open CoreDNS logs") { + openGlobalCommandInTerminalPanel( + command: "docker compose -f \"$HOME/.hack/caddy/docker-compose.yml\" logs -f coredns", + title: "coredns logs" + ) + } + .adaptiveToolbarButton() + Button("Open Caddy compose") { + openURL(URL(fileURLWithPath: caddyComposePath)) + } + .adaptiveToolbarButton() + Spacer() + } + } + GlassCard(title: "Global summary", systemImage: "waveform.path.ecg") { + DetailRows(rows: summaryRows) + } + if let caddy = model.globalStatus?.caddy { + composeGroupCard(title: "Caddy", group: caddy) + } + if let logging = model.globalStatus?.logging { + composeGroupCard(title: "Logging", group: logging) + } + if let daemonLogPath = model.daemonStatus?.logPath, !daemonLogPath.isEmpty { + GlassCard(title: "Daemon log", systemImage: "doc.text") { + DetailRows(rows: [DetailRowItem(label: "Path", value: daemonLogPath)]) + HStack(spacing: 10) { + Button("Open log file") { + openURL(URL(fileURLWithPath: daemonLogPath)) + } + .adaptiveToolbarButton() + Button("Copy path") { + TerminalIntegration.copyToClipboard(daemonLogPath) + } + .adaptiveToolbarButton() + } + } + } + } + .padding(16) + } + } + + private var homeDirectoryPath: String { + FileManager.default.homeDirectoryForCurrentUser.path + } + + private var loggingComposePath: String { + "\(homeDirectoryPath)/.hack/logging/docker-compose.yml" + } + + private var caddyComposePath: String { + "\(homeDirectoryPath)/.hack/caddy/docker-compose.yml" + } + + private var grafanaURL: URL? { + URL(string: "https://logs.hack") + } + + private var grafanaRows: [DetailRowItem] { + [ + DetailRowItem(label: "Grafana URL", value: "https://logs.hack"), + DetailRowItem(label: "Loki URL", value: "https://loki.hack"), + DetailRowItem(label: "Stack health", value: okUnknown(model.globalStatus?.summary.loggingOk)), + DetailRowItem(label: "Generated", value: model.globalStatus?.generatedAt ?? "—") + ] + } + + private var credentialRows: [DetailRowItem] { + [ + DetailRowItem(label: "Default username", value: "admin"), + DetailRowItem(label: "Default password", value: "admin"), + DetailRowItem(label: "Compose file", value: loggingComposePath) + ] + } + + private var caddyRows: [DetailRowItem] { + let gatewayBind = model.globalStatus?.gateway?.gatewayBind ?? "127.0.0.1" + let gatewayPort = model.globalStatus?.gateway?.gatewayPort.map(String.init) ?? "7788" + let devNetwork = model.globalStatus?.networks?.networks.first(where: { $0.name == "hack-dev" }) + return [ + DetailRowItem(label: "Gateway bind", value: gatewayBind), + DetailRowItem(label: "Gateway port", value: gatewayPort), + DetailRowItem(label: "Caddy default IP", value: "172.30.0.2"), + DetailRowItem(label: "CoreDNS default IP", value: "172.30.0.53"), + DetailRowItem(label: "Ingress network", value: devNetwork?.name ?? "hack-dev"), + DetailRowItem(label: "Ingress driver", value: devNetwork?.driver ?? "bridge"), + DetailRowItem(label: "Caddy compose", value: caddyComposePath) + ] + } + + private var summaryRows: [DetailRowItem] { + let summary = model.globalStatus?.summary + return [ + DetailRowItem(label: "Overall", value: okUnknown(summary?.ok)), + DetailRowItem(label: "Caddy", value: okUnknown(summary?.caddyOk)), + DetailRowItem(label: "Logging", value: okUnknown(summary?.loggingOk)), + DetailRowItem(label: "Networks", value: okUnknown(summary?.networksOk)), + DetailRowItem(label: "Generated", value: model.globalStatus?.generatedAt ?? "—") + ] + } + + private func composeGroupCard(title: String, group: ComposeStatusGroup) -> some View { + GlassCard(title: title, systemImage: "shippingbox") { + if group.services.isEmpty { + Text("No services reported.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 10) { + ForEach(group.services, id: \.name) { service in + HStack(spacing: 10) { + Text(service.name) + .font(.mono(.subheadline, weight: .medium)) + Spacer() + Text(service.status) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + if !service.ports.isEmpty { + Text(service.ports) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + Divider() + .opacity(0.2) + } + } + } + } + } + + private func okUnknown(_ value: Bool?) -> String { + guard let value else { return "Unknown" } + return value ? "Healthy" : "Degraded" + } +} + +private struct GlobalConfigSnapshot { + let daemonLaunchdRunAtLoad: Bool? + let cloudflareExtensionEnabled: Bool? + let tailscaleExtensionEnabled: Bool? + let gatewayBind: String? + let cloudflareHostname: String? + let cloudflareSSHHostname: String? + let supervisorEnabled: Bool? + let supervisorMaxConcurrentJobs: Int? + let supervisorLogsMaxBytes: Int? + let preferencesTheme: String? + let preferencesTerminalApp: String? + let preferencesEditorApp: String? + let preferencesCodingAgentApp: String? + let preferencesSessionProvider: String? + let preferencesSessionBinaryPath: String? + let preferencesContainerProvider: String? + let preferencesContainerBinaryPath: String? + let preferencesCodingAgentBinaryPath: String? + + static func load() -> Self { + let home = FileManager.default.homeDirectoryForCurrentUser + let environment = ProcessInfo.processInfo.environment + let overridePath = (environment["HACK_GLOBAL_CONFIG_PATH"] ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + let configPath: String + if overridePath.isEmpty { + configPath = home.appendingPathComponent(".hack/hack.config.json").path + } else { + configPath = NSString(string: overridePath).expandingTildeInPath + } + guard + let data = FileManager.default.contents(atPath: configPath), + let object = try? JSONSerialization.jsonObject(with: data), + let root = object as? [String: Any] + else { + return .empty + } + + let controlPlane = dictionary(root, key: "controlPlane") + let daemon = dictionary(controlPlane, key: "daemon") + let launchd = dictionary(daemon, key: "launchd") + let supervisor = dictionary(controlPlane, key: "supervisor") + let gateway = dictionary(controlPlane, key: "gateway") + let preferences = dictionary(controlPlane, key: "preferences") + let appearancePreferences = dictionary(preferences, key: "appearance") + let terminalPreferences = dictionary(preferences, key: "terminal") + let editorPreferences = dictionary(preferences, key: "editor") + let agentPreferences = dictionary(preferences, key: "agents") + let sessionPreferences = dictionary(preferences, key: "sessions") + let containerPreferences = dictionary(preferences, key: "containers") + let extensions = dictionary(controlPlane, key: "extensions") + let cloudflareExt = dictionary(extensions, key: "dance.hack.cloudflare") + let tailscaleExt = dictionary(extensions, key: "dance.hack.tailscale") + let cloudflareConfig = dictionary(cloudflareExt, key: "config") + + return Self( + daemonLaunchdRunAtLoad: launchd["runAtLoad"] as? Bool, + cloudflareExtensionEnabled: cloudflareExt["enabled"] as? Bool, + tailscaleExtensionEnabled: tailscaleExt["enabled"] as? Bool, + gatewayBind: gateway["bind"] as? String, + cloudflareHostname: cloudflareConfig["hostname"] as? String, + cloudflareSSHHostname: cloudflareConfig["sshHostname"] as? String, + supervisorEnabled: supervisor["enabled"] as? Bool, + supervisorMaxConcurrentJobs: supervisor["maxConcurrentJobs"] as? Int, + supervisorLogsMaxBytes: supervisor["logsMaxBytes"] as? Int, + preferencesTheme: appearancePreferences["theme"] as? String, + preferencesTerminalApp: terminalPreferences["defaultApp"] as? String, + preferencesEditorApp: editorPreferences["defaultApp"] as? String, + preferencesCodingAgentApp: agentPreferences["defaultApp"] as? String, + preferencesSessionProvider: sessionPreferences["provider"] as? String, + preferencesSessionBinaryPath: sessionPreferences["binaryPath"] as? String, + preferencesContainerProvider: containerPreferences["provider"] as? String, + preferencesContainerBinaryPath: containerPreferences["binaryPath"] as? String, + preferencesCodingAgentBinaryPath: agentPreferences["binaryPath"] as? String + ) + } + + static var empty: Self { + Self( + daemonLaunchdRunAtLoad: nil, + cloudflareExtensionEnabled: nil, + tailscaleExtensionEnabled: nil, + gatewayBind: nil, + cloudflareHostname: nil, + cloudflareSSHHostname: nil, + supervisorEnabled: nil, + supervisorMaxConcurrentJobs: nil, + supervisorLogsMaxBytes: nil, + preferencesTheme: nil, + preferencesTerminalApp: nil, + preferencesEditorApp: nil, + preferencesCodingAgentApp: nil, + preferencesSessionProvider: nil, + preferencesSessionBinaryPath: nil, + preferencesContainerProvider: nil, + preferencesContainerBinaryPath: nil, + preferencesCodingAgentBinaryPath: nil + ) + } + + private static func dictionary(_ source: [String: Any], key: String) -> [String: Any] { + source[key] as? [String: Any] ?? [:] + } +} + +private func openGlobalCommandInTerminalPanel(command: String, title: String) { + NotificationCenter.default.post( + name: .hackTerminalOpenRequested, + object: nil, + userInfo: [ + TerminalOpenRequest.projectIdKey: "global-shell", + TerminalOpenRequest.kindKey: TerminalDrawerModel.Kind.shell.rawValue, + TerminalOpenRequest.commandKey: command, + TerminalOpenRequest.titleKey: title + ] + ) +} + +private func resolveExecutablePath(candidates: [String]) -> String? { + let fileManager = FileManager.default + let envPath = ProcessInfo.processInfo.environment["PATH"] ?? "" + for entry in envPath.split(separator: ":") { + for candidate in candidates { + let path = "\(entry)/\(candidate)" + if fileManager.isExecutableFile(atPath: path) { + return path + } + } + } + + let fallbackDirectories = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"] + for directory in fallbackDirectories { + for candidate in candidates { + let path = "\(directory)/\(candidate)" + if fileManager.isExecutableFile(atPath: path) { + return path + } + } + } + + return nil +} + +private func resolveInstalledApplicationPath( + bundleIdentifiers: [String], + fallbackPaths: [String] +) -> String? { + let workspace = NSWorkspace.shared + for bundleIdentifier in bundleIdentifiers { + if let url = workspace.urlForApplication(withBundleIdentifier: bundleIdentifier) { + return url.path + } + } + for path in fallbackPaths where FileManager.default.fileExists(atPath: path) { + return path + } + return nil +} + +private func openExecutablePanel() -> String? { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.canCreateDirectories = false + panel.prompt = "Select" + panel.title = "Choose executable" + + guard panel.runModal() == .OK else { return nil } + return panel.url?.path +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SetupAssistantView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SetupAssistantView.swift index 1a723712..dd17549f 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SetupAssistantView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SetupAssistantView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import GhosttyTerminal @@ -19,6 +20,7 @@ struct SetupAssistantView: View { @State private var showEmbeddedTerminal = false @State private var embeddedCommand: String? = nil + @State private var terminalAutomationGranted: Bool? = nil init(initialSection: SetupAssistantSection) { self.initialSection = initialSection @@ -73,6 +75,8 @@ struct SetupAssistantView: View { @ViewBuilder private var runtimeSteps: some View { + permissionsCallout + SetupAssistantStepCard( tone: model.globalStatus == nil ? .warn : .good, title: "Install global services", @@ -114,6 +118,8 @@ struct SetupAssistantView: View { @ViewBuilder private var gatewaySteps: some View { + permissionsCallout + SetupAssistantStepCard( tone: model.globalStatus == nil ? .warn : .neutral, title: "Install global services", @@ -179,6 +185,30 @@ struct SetupAssistantView: View { model.daemonStatus?.resolvedLabel == .running } + private var permissionsCallout: some View { + InlineCallout( + tone: terminalAutomationTone, + title: "Permissions", + message: "Grant Terminal automation once so Hack Desktop can open Terminal to run setup commands that may require sudo.", + actions: [ + InlineCalloutAction(label: "Request Terminal automation", systemImage: "terminal") { + terminalAutomationGranted = TerminalIntegration.requestTerminalAutomationPermission() + }, + InlineCalloutAction(label: "Open Automation privacy", systemImage: "gearshape") { + guard + let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") + else { return } + NSWorkspace.shared.open(url) + } + ] + ) + } + + private var terminalAutomationTone: StatusTone { + guard let granted = terminalAutomationGranted else { return .neutral } + return granted ? .good : .warn + } + private var gatewayIsConfigured: Bool { model.globalStatus?.summary.gatewayEnabled == true || model.gatewaySummaryState != nil } @@ -208,7 +238,6 @@ struct SetupAssistantView: View { runtimeConfigured: nil, runtimeStatus: nil, runtime: nil, - meta: nil, kind: .unregistered, status: .unknown ) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TerminalDrawerModel.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TerminalDrawerModel.swift index bafff65e..422349da 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TerminalDrawerModel.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TerminalDrawerModel.swift @@ -14,6 +14,7 @@ final class TerminalDrawerModel { struct TabKey: Hashable { let projectId: String let kind: Kind + let branch: String? } struct Tab: Identifiable, Equatable { @@ -40,7 +41,7 @@ final class TerminalDrawerModel { let session = Self.makeShellSession(project: globalShellProject, initialCommand: nil) let tab = Tab( id: UUID(), - key: TabKey(projectId: globalShellProject.id, kind: .shell), + key: TabKey(projectId: globalShellProject.id, kind: .shell, branch: nil), title: Self.makeTabTitle(project: globalShellProject, kind: .shell), session: session ) @@ -69,8 +70,33 @@ final class TerminalDrawerModel { tabs.first(where: { $0.id == selectedTabId })?.session } - func openOrSelect(project: ProjectSummary, kind: Kind) { - let key = TabKey(projectId: project.id, kind: kind) + func openOrSelect( + project: ProjectSummary, + kind: Kind, + branch: String? = nil, + initialCommand: String? = nil, + titleOverride: String? = nil + ) { + let normalizedBranch = branch?.trimmingCharacters(in: .whitespacesAndNewlines) + let tabBranch = (normalizedBranch?.isEmpty == false) ? normalizedBranch : nil + let normalizedCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedCommand = (normalizedCommand?.isEmpty == false) ? normalizedCommand : nil + let normalizedTitle = titleOverride?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedTitle = (normalizedTitle?.isEmpty == false) ? normalizedTitle : nil + + if kind == .shell, let resolvedCommand { + let session = Self.makeShellSession(project: project, initialCommand: resolvedCommand) + let title = resolvedTitle ?? "\(Self.tabBaseTitle(for: project)) command" + let tab = Tab(id: UUID(), key: nil, title: title, session: session) + tabs.append(tab) + selectedTabId = tab.id + if isActive { + session.start() + } + return + } + + let key = TabKey(projectId: project.id, kind: kind, branch: tabBranch) if let existing = tabs.first(where: { $0.key == key }) { selectedTabId = existing.id return @@ -79,7 +105,7 @@ final class TerminalDrawerModel { let session: GhosttyTerminalSession switch kind { case .logs: - session = GhosttyTerminalSession(project: project) + session = GhosttyTerminalSession(project: project, branch: tabBranch) case .shell: session = Self.makeShellSession(project: project, initialCommand: nil) } @@ -87,7 +113,7 @@ final class TerminalDrawerModel { let tab = Tab( id: UUID(), key: key, - title: Self.makeTabTitle(project: project, kind: kind), + title: Self.makeTabTitle(project: project, kind: kind, branch: tabBranch), session: session ) tabs.append(tab) @@ -149,13 +175,22 @@ final class TerminalDrawerModel { ) } - private static func makeTabTitle(project: ProjectSummary, kind: Kind) -> String { + private static func makeTabTitle( + project: ProjectSummary, + kind: Kind, + branch: String? = nil + ) -> String { + let branchSuffix = { + guard let branch, !branch.isEmpty else { return "" } + return " [\(branch)]" + }() + switch kind { case .shell: return makeShellTitle(project: project, ordinal: 1) case .logs: let base = tabBaseTitle(for: project) - return "\(base) logs" + return "\(base)\(branchSuffix) logs" } } @@ -204,4 +239,3 @@ final class TerminalDrawerModel { return (trimmed, 1) } } - diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TerminalIntegration.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TerminalIntegration.swift index dbbc6a7c..179fa1ce 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TerminalIntegration.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TerminalIntegration.swift @@ -2,12 +2,138 @@ import AppKit import Foundation enum TerminalIntegration { + enum ExternalTerminalApp: String, CaseIterable, Identifiable { + case hackDesktop = "hack-desktop" + case terminal = "terminal" + case iTerm = "iterm" + case ghostty = "ghostty" + case warp = "warp" + case alacritty = "alacritty" + case wezTerm = "wezterm" + case kitty = "kitty" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .hackDesktop: + return "Hack Desktop" + case .terminal: + return "Terminal" + case .iTerm: + return "iTerm" + case .ghostty: + return "Ghostty" + case .warp: + return "Warp" + case .alacritty: + return "Alacritty" + case .wezTerm: + return "WezTerm" + case .kitty: + return "Kitty" + } + } + + var bundleIdentifiers: [String] { + switch self { + case .hackDesktop: + return [] + case .terminal: + return ["com.apple.Terminal"] + case .iTerm: + return ["com.googlecode.iterm2"] + case .ghostty: + return ["com.mitchellh.ghostty"] + case .warp: + return ["dev.warp.Warp-Stable", "dev.warp.Warp"] + case .alacritty: + return ["org.alacritty"] + case .wezTerm: + return ["com.github.wez.wezterm"] + case .kitty: + return ["net.kovidgoyal.kitty"] + } + } + + var fallbackPaths: [String] { + switch self { + case .hackDesktop: + return [] + case .terminal: + return ["/System/Applications/Utilities/Terminal.app"] + case .iTerm: + return ["/Applications/iTerm.app"] + case .ghostty: + return ["/Applications/Ghostty.app"] + case .warp: + return ["/Applications/Warp.app"] + case .alacritty: + return ["/Applications/Alacritty.app"] + case .wezTerm: + return ["/Applications/WezTerm.app"] + case .kitty: + return ["/Applications/kitty.app"] + } + } + } + + static func installedExternalTerminalApps() -> [ExternalTerminalApp] { + ExternalTerminalApp.allCases + .filter { $0 != .hackDesktop } + .filter { app in + resolveAppURL(for: app) != nil + } + } + + static func resolvedExternalTerminalPath(for app: ExternalTerminalApp) -> String? { + resolveAppURL(for: app)?.path + } + static func copyToClipboard(_ text: String) { NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) } static func openTerminalWithCommand(_ command: String) { + openExternalTerminalWithCommand(command, app: .terminal) + } + + /// Triggers the macOS Automation permission flow for controlling Terminal via AppleScript. + /// Returns true when the script executed successfully. + static func requestTerminalAutomationPermission() -> Bool { + openAppleTerminal("echo \"Hack Desktop terminal automation access confirmed.\"") + } + + static func openExternalTerminalWithCommand(_ command: String, app: ExternalTerminalApp) { + switch app { + case .hackDesktop: + return + case .terminal: + if openAppleTerminal(command) { + return + } + case .iTerm: + if openITerm(command) { + return + } + case .ghostty, .warp, .alacritty, .wezTerm, .kitty: + break + } + + copyToClipboard(command) + if let appURL = resolveAppURL(for: app) { + NSWorkspace.shared.openApplication(at: appURL, configuration: NSWorkspace.OpenConfiguration()) + return + } + + // Final fallback: open Apple's Terminal and keep the command on clipboard. + if let fallbackURL = resolveAppURL(for: .terminal) { + NSWorkspace.shared.openApplication(at: fallbackURL, configuration: NSWorkspace.OpenConfiguration()) + } + } + + private static func openAppleTerminal(_ command: String) -> Bool { // Prefer an AppleScript "do script" so the user sees an actual command they can edit and re-run. // This will prompt for Automation permission (Terminal) the first time. let escaped = command @@ -26,13 +152,51 @@ enum TerminalIntegration { if let appleScript = NSAppleScript(source: script) { _ = appleScript.executeAndReturnError(&errorDict) if errorDict == nil { - return + return true } } - // Fallback: open Terminal and copy command to clipboard. - copyToClipboard(command) - NSWorkspace.shared.openApplication(at: URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app"), configuration: NSWorkspace.OpenConfiguration()) + return false + } + + private static func openITerm(_ command: String) -> Bool { + let escaped = command + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + + let script = """ + tell application "iTerm2" + activate + if (count of windows) = 0 then + create window with default profile command "\(escaped)" + else + tell current window + create tab with default profile + tell current session to write text "\(escaped)" + end tell + end if + end tell + """ + + var errorDict: NSDictionary? + if let appleScript = NSAppleScript(source: script) { + _ = appleScript.executeAndReturnError(&errorDict) + return errorDict == nil + } + return false } -} + private static func resolveAppURL(for app: ExternalTerminalApp) -> URL? { + let workspace = NSWorkspace.shared + for bundleIdentifier in app.bundleIdentifiers { + if let url = workspace.urlForApplication(withBundleIdentifier: bundleIdentifier) { + return url + } + } + for path in app.fallbackPaths where FileManager.default.fileExists(atPath: path) { + return URL(fileURLWithPath: path) + } + return nil + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TerminalNotifications.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TerminalNotifications.swift index 2c4de14c..aafc2526 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TerminalNotifications.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TerminalNotifications.swift @@ -7,5 +7,7 @@ extension Notification.Name { enum TerminalOpenRequest { static let projectIdKey = "projectId" static let kindKey = "kind" + static let branchKey = "branch" + static let commandKey = "command" + static let titleKey = "title" } - diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketMarkdownCodeSyntaxHighlighter.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketMarkdownCodeSyntaxHighlighter.swift new file mode 100644 index 00000000..47d1e49b --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketMarkdownCodeSyntaxHighlighter.swift @@ -0,0 +1,68 @@ +import Highlightr +import MarkdownUI +import SwiftUI + +struct TicketMarkdownCodeSyntaxHighlighter: CodeSyntaxHighlighter { + private let highlightr: Highlightr? + private let supportedLanguages: Set + private let languageAliases: [String: String] = [ + "c#": "cs", + "c++": "cpp", + "docker": "dockerfile", + "js": "javascript", + "jsx": "javascript", + "md": "markdown", + "obj-c": "objectivec", + "objc": "objectivec", + "py": "python", + "rb": "ruby", + "rs": "rust", + "sh": "bash", + "shell": "bash", + "ts": "typescript", + "tsx": "typescript", + "yml": "yaml", + "zsh": "bash" + ] + + init(colorScheme: ColorScheme) { + let engine = Highlightr() + if let engine { + let preferredThemes = colorScheme == .dark + ? ["atom-one-dark", "github-dark", "monokai-sublime"] + : ["atom-one-light", "github", "xcode"] + for theme in preferredThemes { + if engine.setTheme(to: theme) { + break + } + } + } + self.highlightr = engine + self.supportedLanguages = Set(engine?.supportedLanguages().map { $0.lowercased() } ?? []) + } + + func highlightCode(_ code: String, language: String?) -> Text { + guard let highlightr else { + return Text(verbatim: code) + } + let resolvedLanguage = resolveLanguage(language) + if let highlighted = highlightr.highlight(code, as: resolvedLanguage) { + return Text(AttributedString(highlighted)) + } + if let autoDetected = highlightr.highlight(code, as: nil) { + return Text(AttributedString(autoDetected)) + } + return Text(verbatim: code) + } + + private func resolveLanguage(_ language: String?) -> String? { + guard let language else { return nil } + let normalized = language + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "language-", with: "") + guard !normalized.isEmpty else { return nil } + let mapped = languageAliases[normalized] ?? normalized + return supportedLanguages.contains(mapped) ? mapped : nil + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift index 86b47641..43600073 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift @@ -1,9 +1,11 @@ +import MarkdownUI import SwiftUI import HackDesktopModels struct TicketsView: View { @Environment(DashboardModel.self) private var model + @Environment(\.colorScheme) private var colorScheme let project: ProjectSummary @@ -17,11 +19,12 @@ struct TicketsView: View { @State private var showCreateSheet = false @State private var selectedFilter: TicketFilter = .all @State private var hasLoadedOnce = false - @State private var expandedSections: Set = Set(TicketStatus.allCases) + @State private var isPropertiesExpanded = false + @State private var isHistoryExpanded = false @State private var searchText = "" @State private var loadNotice: String? = nil @State private var hoveredTicketId: String? = nil - @State private var hoveredSection: TicketStatus? = nil + @FocusState private var ticketsListFocused: Bool var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -33,12 +36,15 @@ struct TicketsView: View { content } } + .fontDesign(.monospaced) .padding(.horizontal, 24) + .padding(.top, 12) .padding(.bottom, 24) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .task { loadCachedTickets() await refreshTickets() + ticketsListFocused = true } .task(id: selectedTicketId) { await loadTicketDetail() @@ -59,35 +65,29 @@ struct TicketsView: View { } private var header: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .center, spacing: 12) { - Text("Tickets") - .font(.mono(.headline, weight: .semibold)) - BadgePill(label: "\(filteredTickets.count)", tint: .secondary) - filterRow - Spacer() - if let loadNotice { - Text(loadNotice) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) + HStack(alignment: .center, spacing: 12) { + statusFilterMenu + searchField + Spacer(minLength: 12) + if let loadNotice { + Text(loadNotice) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + newTicketButton + Menu { + Button("Refresh") { + Task { await refreshTickets() } } - Button("New") { - showCreateSheet = true + Button("Sync") { + Task { await syncTickets() } } - .adaptiveToolbarButtonProminent() - Menu { - Button("Refresh") { - Task { await refreshTickets() } - } - Button("Sync") { - Task { await syncTickets() } - } - } label: { - Image(systemName: "ellipsis") - .font(.mono(.title3)) - } - .buttonStyle(.plain) + } label: { + Image(systemName: "ellipsis.circle") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.primary.opacity(0.7)) } + .buttonStyle(PressableIconButtonStyle()) } } @@ -95,219 +95,226 @@ struct TicketsView: View { selectedTicketId != nil } - private var filterRow: some View { - ViewThatFits(in: .horizontal) { - HStack(spacing: 6) { - ForEach(TicketFilter.allCases, id: \.self) { filter in - filterPill(filter) + private var statusFilterMenu: some View { + Menu { + ForEach(TicketFilter.allCases, id: \.self) { filter in + Button { + selectedFilter = filter + } label: { + if selectedFilter == filter { + Label("\(filter.label) (\(filter.count(in: tickets)))", systemImage: "checkmark") + } else { + Text("\(filter.label) (\(filter.count(in: tickets)))") + } } - TextField("Search", text: $searchText) - .textFieldStyle(.roundedBorder) - .controlSize(.small) - .frame(maxWidth: 200) } + } label: { HStack(spacing: 8) { - Menu { - ForEach(TicketFilter.allCases, id: \.self) { filter in - Button { - selectedFilter = filter - } label: { - if selectedFilter == filter { - Label("\(filter.label) (\(filter.count(in: tickets)))", systemImage: "checkmark") - } else { - Text("\(filter.label) (\(filter.count(in: tickets)))") - } - } - } + Image(systemName: "line.3.horizontal.decrease.circle") + .font(.system(size: 13, weight: .semibold)) + Text(selectedFilter.label) + .font(.system(size: 13, weight: .semibold)) + Text("\(selectedFilter.count(in: tickets))") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background { + headerControlCapsuleBackground + } + } + .menuStyle(.borderlessButton) + } + + private var searchField: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + TextField("Search title or ID", text: $searchText) + .textFieldStyle(.plain) + .font(.system(size: 14, weight: .medium)) + .frame(minWidth: 180, maxWidth: 320) + if !searchText.isEmpty { + Button { + searchText = "" } label: { - HStack(spacing: 6) { - Text("Filter") - .font(.mono(.caption, weight: .semibold)) - Text(selectedFilter.label) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - Image(systemName: "chevron.down") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background( - Capsule(style: .continuous) - .fill(Color.secondary.opacity(0.1)) - ) + Image(systemName: "xmark.circle.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) } - .menuStyle(.borderlessButton) - - TextField("Search", text: $searchText) - .textFieldStyle(.roundedBorder) - .controlSize(.small) - .frame(maxWidth: 180) + .buttonStyle(.plain) } } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background { + headerControlFieldBackground + } } private var content: some View { HStack(alignment: .top, spacing: 16) { ticketsListCard .frame( - minWidth: 280, - maxWidth: hasSelection ? 360 : .infinity, + minWidth: 320, + maxWidth: hasSelection ? 430 : .infinity, maxHeight: .infinity, alignment: .topLeading ) if hasSelection { ticketDetailCard .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - ticketInspectorColumn - .frame(minWidth: 220, idealWidth: 260, maxWidth: 320, maxHeight: .infinity, alignment: .topLeading) } } .frame(maxHeight: .infinity, alignment: .top) + .onMoveCommand(perform: moveSelection) .animation(.easeInOut(duration: 0.2), value: hasSelection) } private var ticketsListCard: some View { - GlassCard(title: "Tickets", systemImage: "tray") { - ScrollView { - if isLoading && !hasLoadedOnce { - skeletonList - } else if filteredTickets.isEmpty { - emptyTicketsView - } else if selectedFilter == .all { - LazyVStack(alignment: .leading, spacing: 10) { - ForEach(TicketStatus.allCases, id: \.self) { status in - ticketSection(status: status) - } + ticketPanel(contentPadding: 0) { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Text("\(filteredTickets.count) issue\(filteredTickets.count == 1 ? "" : "s")") + .font(.system(size: 14, weight: .semibold)) + Spacer() + if !filteredTickets.isEmpty { + Text("Use ↑ ↓ to navigate") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) } - } else { - LazyVStack(alignment: .leading, spacing: 8) { - ForEach(filteredTickets) { ticket in - ticketRow(ticket) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + Divider() + .opacity(0.22) + ScrollView { + if isLoading && !hasLoadedOnce { + skeletonList + } else if filteredTickets.isEmpty { + emptyTicketsView + } else if selectedFilter == .all { + LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { + ForEach(TicketStatus.allCases, id: \.self) { status in + ticketSection(status: status) + } + } + } else { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(filteredTickets) { ticket in + ticketRow(ticket) + } } } } + .frame(maxHeight: .infinity, alignment: .topLeading) } - .frame(maxHeight: .infinity, alignment: .topLeading) } + .focusable() + .focused($ticketsListFocused) + .focusEffectDisabled() } private var ticketDetailCard: some View { - GlassCard(title: "Detail", systemImage: "doc.text") { - ScrollView { - if let detailErrorMessage { + ticketDetailPanel { + if let detailErrorMessage { + ScrollView { ticketsErrorCard(message: detailErrorMessage) - } else if isDetailLoading { - ProgressView() - .frame(maxWidth: .infinity, alignment: .leading) - } else if let detail = ticketDetail { - ticketDetailView(detail) - } else { - Text("Select a ticket to see details.") - .font(.mono(.subheadline)) - .foregroundStyle(.secondary) + .padding(16) } - } - .frame(maxHeight: .infinity, alignment: .topLeading) - } - } - - private var ticketInspectorCard: some View { - GlassCard(title: "Properties", systemImage: "slider.horizontal.3") { - if let detail = ticketDetail { - ScrollView { - VStack(alignment: .leading, spacing: 14) { - statusMenu(ticket: detail.ticket) - inspectorGroup(title: "Dates", rows: inspectorDateRows(for: detail.ticket)) - inspectorGroup(title: "Links", rows: inspectorLinkRows(for: detail.ticket)) - inspectorGroup(title: "Dependencies", rows: inspectorDependencyRows(for: detail.ticket)) + } else if isDetailLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(16) + } else if let detail = ticketDetail { + VStack(spacing: 0) { + ScrollView { + ticketDetailView(detail) + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 20) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + + Divider() + .opacity(0.22) + + ticketDetailFooter(detail) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(ticketDetailFooterFillColor) } } else { - Text("Select a ticket.") - .font(.mono(.caption)) + Text("Select a ticket to see details.") + .font(.mono(.subheadline)) .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(16) } } } - private var ticketInspectorColumn: some View { - VStack(spacing: 0) { - ticketInspectorCard - } - .padding(.top, 2) - .padding(.horizontal, 2) - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(.ultraThinMaterial) - ) - .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) - } - private var emptyTicketsView: some View { VStack(alignment: .leading, spacing: 8) { Text("No tickets found.") - .font(.mono(.subheadline, weight: .medium)) + .font(.system(size: 15, weight: .semibold)) Text("Try another filter or create a new ticket.") - .font(.mono(.caption)) + .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) Button("New ticket") { showCreateSheet = true } .adaptiveToolbarButton() } + .padding(.horizontal, 14) + .padding(.top, 8) } private func ticketRow(_ ticket: TicketSummary) -> some View { Button { - selectedTicketId = ticket.ticketId + if selectedTicketId == ticket.ticketId { + selectedTicketId = nil + ticketDetail = nil + detailErrorMessage = nil + } else { + selectedTicketId = ticket.ticketId + } + ticketsListFocused = true } label: { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Circle() - .fill(ticket.status.color) - .frame(width: 8, height: 8) - Text(ticket.title) - .font(.mono(.subheadline, weight: .semibold)) - .lineLimit(1) - Spacer() - Menu { - Button("Open details") { - selectedTicketId = ticket.ticketId - } - Divider() - ForEach(TicketStatus.allCases, id: \.self) { status in - Button(status.label) { - Task { - await updateStatus(ticketId: ticket.ticketId, status: status) - } - } - } - } label: { - HStack(spacing: 6) { - Text(ticket.status.label) - .font(.mono(.caption)) - Image(systemName: "chevron.down") - .font(.mono(.caption2)) - } - .foregroundStyle(.secondary) + HStack(alignment: .top, spacing: 10) { + Circle() + .fill(ticket.status.color) + .frame(width: 8, height: 8) + .padding(.top, 8) + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Text(ticket.ticketId) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + ticketStatusBadge(ticket.status) + Spacer() + Text(humanReadableListDate(ticket.updatedAt)) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) } - .menuStyle(.borderlessButton) - } - HStack(spacing: 8) { - Text(ticket.ticketId) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - Spacer() - Text(ticket.updatedAt) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) + Text(ticket.title) + .font(.system(size: 15, weight: .semibold)) + .lineLimit(2) + .multilineTextAlignment(.leading) } + Spacer(minLength: 0) } - .padding(.horizontal, 10) - .padding(.vertical, 6) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) .background(selectionBackground(for: ticket.ticketId)) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay(alignment: .bottom) { + Rectangle() + .fill(selectionBorder(for: ticket.ticketId)) + .frame(height: 1) + } } .buttonStyle(.plain) .contentShape(Rectangle()) @@ -318,47 +325,99 @@ struct TicketsView: View { } private func ticketDetailView(_ detail: TicketDetailResponse) -> some View { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 20) { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 6) { Text(detail.ticket.title) - .font(.mono(.headline)) - Text(detail.ticket.ticketId) - .font(.mono(.caption)) - .foregroundStyle(.secondary) + .font(.system(size: 29, weight: .bold, design: .rounded)) + HStack(spacing: 10) { + Text(detail.ticket.ticketId) + .font(.system(size: 13, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + Text("Updated \(humanReadableDetailDate(detail.ticket.updatedAt))") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + } } Spacer() + statusMenu(ticket: detail.ticket) } if let body = detail.ticket.body, !body.isEmpty { VStack(alignment: .leading, spacing: 6) { Text("Body") - .font(.mono(.caption, weight: .semibold)) + .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.secondary) - Text(body) - .font(.mono(.subheadline)) - .textSelection(.enabled) + markdownBody(body) } } + } + } - if !detail.events.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("History") - .font(.mono(.caption, weight: .semibold)) + private func ticketDetailFooter(_ detail: TicketDetailResponse) -> some View { + VStack(alignment: .leading, spacing: 12) { + DisclosureGroup(isExpanded: $isHistoryExpanded) { + if detail.events.isEmpty { + Text("No history events for this ticket.") + .font(.system(size: 12, weight: .medium)) .foregroundStyle(.secondary) - ForEach(detail.events) { event in - HStack(spacing: 8) { - Text(event.type) - .font(.mono(.caption)) - Spacer() - Text(event.tsIso) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) + .padding(.top, 6) + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(detail.events) { event in + HStack(spacing: 8) { + Text(event.type.replacingOccurrences(of: ".", with: " ")) + .textCase(nil) + .multilineTextAlignment(.leading) + .lineLimit(1) + .truncationMode(.tail) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.primary) + Spacer() + Text(humanReadableDetailDate(event.tsIso)) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + } } } + .padding(.top, 6) + } + } label: { + Text("History") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.secondary) + } + + Divider() + .opacity(0.25) + + DisclosureGroup(isExpanded: $isPropertiesExpanded) { + VStack(alignment: .leading, spacing: 14) { + inspectorGroup(title: "Dates", rows: inspectorDateRows(for: detail.ticket)) + inspectorGroup(title: "Links", rows: inspectorLinkRows(for: detail.ticket)) + inspectorGroup(title: "Dependencies", rows: inspectorDependencyRows(for: detail.ticket)) } + .padding(.top, 6) + } label: { + Text("Properties") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.secondary) } } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func markdownBody(_ markdown: String) -> some View { + Markdown(markdown) + .markdownTheme(.gitHub) + .markdownTextStyle { + FontFamilyVariant(.monospaced) + FontSize(.em(0.95)) + } + .markdownCodeSyntaxHighlighter(TicketMarkdownCodeSyntaxHighlighter(colorScheme: colorScheme)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 2) + .textSelection(.enabled) } private func statusMenu(ticket: TicketSummary) -> some View { @@ -376,25 +435,23 @@ struct TicketsView: View { .fill(ticket.status.color) .frame(width: 8, height: 8) Text(ticket.status.label) - .font(.mono(.caption, weight: .semibold)) - Image(systemName: "chevron.down") - .font(.mono(.caption2)) + .font(.system(size: 12, weight: .semibold)) } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + Capsule(style: .continuous) + .fill(ticketBadgeFill(for: ticket.status)) + ) + .overlay( + Capsule(style: .continuous) + .stroke(ticketBadgeStroke(for: ticket.status), lineWidth: 1) + ) } .menuStyle(.borderlessButton) .help("Change status") } - private func inspectorRows(for ticket: TicketSummary) -> [DetailRowItem] { - [ - DetailRowItem(label: "Status", value: ticket.status.label), - DetailRowItem(label: "Created", value: ticket.createdAt), - DetailRowItem(label: "Updated", value: ticket.updatedAt), - DetailRowItem(label: "Depends on", value: ticket.dependsOn.joined(separator: ", ").nilIfEmpty ?? "—"), - DetailRowItem(label: "Blocks", value: ticket.blocks.joined(separator: ", ").nilIfEmpty ?? "—") - ] - } - private func ticketsErrorCard(message: String) -> some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { @@ -463,37 +520,43 @@ struct TicketsView: View { private func selectionBackground(for ticketId: String) -> Color { if ticketId == selectedTicketId { - return Color.accentColor.opacity(0.15) + return Color.accentColor.opacity(0.2) + } + if ticketId == hoveredTicketId { + return colorScheme == .dark ? Color.white.opacity(0.06) : Color.black.opacity(0.04) + } + return .clear + } + + private func selectionBorder(for ticketId: String) -> Color { + if ticketId == selectedTicketId { + return Color.accentColor.opacity(0.35) } if ticketId == hoveredTicketId { - return Color.white.opacity(0.06) + return Color.primary.opacity(0.16) } - return Color.clear + return Color.primary.opacity(0.08) } private func refreshTickets() async { isLoading = true errorMessage = nil loadNotice = nil - do { - let result = await loadTicketsWithTimeout(seconds: 4) - switch result { - case let .success(fetched): - tickets = fetched - updateSelectionAfterRefresh() - hasLoadedOnce = true - persistCachedTickets() - case .timedOut: - if hasLoadedOnce { - loadNotice = "Showing cached tickets. Refresh timed out." - } else { - errorMessage = "Timed out loading tickets. Try Sync, or open a shell and run `hack x tickets list --json` (note the `x`)." - } - case let .failure(message): - errorMessage = message + let result = await loadTicketsWithTimeout(seconds: 4) + switch result { + case let .success(fetched): + tickets = fetched + updateSelectionAfterRefresh() + hasLoadedOnce = true + persistCachedTickets() + case .timedOut: + if hasLoadedOnce { + loadNotice = "Showing cached tickets. Refresh timed out." + } else { + errorMessage = "Timed out loading tickets. Try Sync, or open a shell and run `hack x tickets list --json` (note the `x`)." } - } catch { - errorMessage = error.localizedDescription + case let .failure(message): + errorMessage = message } isLoading = false } @@ -501,10 +564,14 @@ struct TicketsView: View { private func loadTicketDetail() async { guard let selectedTicketId else { ticketDetail = nil + detailErrorMessage = nil + isDetailLoading = false return } isDetailLoading = true detailErrorMessage = nil + isHistoryExpanded = false + isPropertiesExpanded = false do { ticketDetail = try await model.showTicket(for: project, ticketId: selectedTicketId) } catch { @@ -517,9 +584,9 @@ struct TicketsView: View { let filtered = filteredTickets if let selectedTicketId, !filtered.contains(where: { $0.ticketId == selectedTicketId }) { - self.selectedTicketId = filtered.first?.ticketId - } else if selectedTicketId == nil { - selectedTicketId = filtered.first?.ticketId + self.selectedTicketId = nil + ticketDetail = nil + detailErrorMessage = nil } } @@ -584,66 +651,58 @@ struct TicketsView: View { private func ticketSection(status: TicketStatus) -> some View { let items = filteredTickets.filter { $0.status == status } if !items.isEmpty { - let isHovered = hoveredSection == status - DisclosureGroup( - isExpanded: Binding( - get: { expandedSections.contains(status) }, - set: { isExpanded in - if isExpanded { - expandedSections.insert(status) - } else { - expandedSections.remove(status) - } - } - ) - ) { - VStack(alignment: .leading, spacing: 8) { + Section(content: { + VStack(alignment: .leading, spacing: 0) { ForEach(items) { ticket in ticketRow(ticket) } } - .padding(.top, 6) - } label: { + }, header: { HStack(spacing: 8) { Circle() .fill(status.color) .frame(width: 8, height: 8) Text(status.label) - .font(.mono(.caption, weight: .semibold)) + .font(.system(size: 13, weight: .semibold)) Text("\(items.count)") - .font(.mono(.caption2)) + .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.secondary) } - .padding(.vertical, 4) - .padding(.horizontal, 6) + .padding(.vertical, 8) + .padding(.horizontal, 12) .frame(maxWidth: .infinity, alignment: .leading) .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(isHovered ? Color.white.opacity(0.06) : .clear) + ZStack { + Rectangle() + .fill(.ultraThinMaterial) + Rectangle() + .fill(status.color.opacity(colorScheme == .dark ? 0.18 : 0.08)) + } ) - .contentShape(Rectangle()) - .onHover { hovering in - hoveredSection = hovering ? status : nil + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color.primary.opacity(0.12)) + .frame(height: 1) } - .animation(.easeInOut(duration: 0.12), value: isHovered) - } - .disclosureGroupStyle(.automatic) + .zIndex(1) + }) + .textCase(nil) } } private func inspectorGroup(title: String, rows: [DetailRowItem]) -> some View { VStack(alignment: .leading, spacing: 6) { Text(title) - .font(.mono(.caption, weight: .semibold)) + .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.secondary) - DetailRows(rows: rows, labelWidth: 70) + DetailRows(rows: rows, labelWidth: 88) } } private func inspectorDateRows(for ticket: TicketSummary) -> [DetailRowItem] { [ - DetailRowItem(label: "Created", value: ticket.createdAt), - DetailRowItem(label: "Updated", value: ticket.updatedAt) + DetailRowItem(label: "Created", value: humanReadableDetailDate(ticket.createdAt)), + DetailRowItem(label: "Updated", value: humanReadableDetailDate(ticket.updatedAt)) ] } @@ -661,6 +720,31 @@ struct TicketsView: View { ] } + private func moveSelection(_ direction: MoveCommandDirection) { + let visibleTickets = filteredTickets + guard !visibleTickets.isEmpty else { return } + let nextTicket: TicketSummary? + switch direction { + case .up: + if let selectedTicketId, + let index = visibleTickets.firstIndex(where: { $0.ticketId == selectedTicketId }) { + nextTicket = visibleTickets[max(0, index - 1)] + } else { + nextTicket = visibleTickets.last + } + case .down: + if let selectedTicketId, + let index = visibleTickets.firstIndex(where: { $0.ticketId == selectedTicketId }) { + nextTicket = visibleTickets[min(visibleTickets.count - 1, index + 1)] + } else { + nextTicket = visibleTickets.first + } + default: + return + } + selectedTicketId = nextTicket?.ticketId + } + private func cacheKey() -> String { "tickets.cache.\(project.id)" } @@ -712,48 +796,196 @@ struct TicketsView: View { } } - private func filterPill(_ filter: TicketFilter) -> some View { - let isSelected = selectedFilter == filter - return FilterPillButton( - label: filter.label, - count: filter.count(in: tickets), - isSelected: isSelected, - onTap: { selectedFilter = filter } - ) + private func ticketStatusBadge(_ status: TicketStatus) -> some View { + Text(status.label) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(status.color) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule(style: .continuous) + .fill(ticketBadgeFill(for: status)) + ) + .overlay( + Capsule(style: .continuous) + .stroke(ticketBadgeStroke(for: status), lineWidth: 1) + ) } -} -private struct FilterPillButton: View { - let label: String - let count: Int - let isSelected: Bool - let onTap: () -> Void + private func ticketPanel( + contentPadding: CGFloat = 14, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 0) { + content() + } + .padding(contentPadding) + .background( + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(panelBaseFillColor) + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.thinMaterial) + .opacity(colorScheme == .dark ? 0.42 : 0.64) + } + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(panelStrokeColor, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } - @State private var isHovered = false + private func ticketDetailPanel( + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 0) { + content() + } + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(ticketDetailPanelFillColor) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(ticketDetailPanelStrokeColor, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } - var body: some View { + private var newTicketButton: some View { Button { - onTap() + showCreateSheet = true } label: { HStack(spacing: 6) { - Text(label) - .font(.mono(.caption, weight: .semibold)) - Text("\(count)") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) + Image(systemName: "plus") + .font(.system(size: 12, weight: .bold)) + Text("New") + .font(.system(size: 13, weight: .semibold)) } - .padding(.horizontal, 10) - .padding(.vertical, 6) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .foregroundStyle(.white) .background( - Capsule(style: .continuous) - .fill(isSelected ? Color.accentColor.opacity(0.15) : isHovered ? Color.white.opacity(0.06) : Color.secondary.opacity(0.1)) + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(newButtonFillColor) ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(newButtonStrokeColor, lineWidth: 1) + ) + .shadow(color: .black.opacity(colorScheme == .dark ? 0.35 : 0.14), radius: 6, y: 2) } .buttonStyle(.plain) - .onHover { hovering in - isHovered = hovering + } + + private var headerControlFillColor: Color { + colorScheme == .dark ? Color.white.opacity(0.08) : Color.white.opacity(0.76) + } + + private var headerControlStrokeColor: Color { + colorScheme == .dark ? Color.white.opacity(0.14) : Color.black.opacity(0.08) + } + + @ViewBuilder + private var headerControlCapsuleBackground: some View { + if colorScheme == .dark { + Capsule(style: .continuous) + .fill(.regularMaterial) + .overlay( + Capsule(style: .continuous) + .stroke(headerControlStrokeColor, lineWidth: 1) + ) + } else { + Capsule(style: .continuous) + .fill(headerControlFillColor) + .overlay( + Capsule(style: .continuous) + .stroke(headerControlStrokeColor, lineWidth: 1) + ) + } + } + + @ViewBuilder + private var headerControlFieldBackground: some View { + if colorScheme == .dark { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(.regularMaterial) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(headerControlStrokeColor, lineWidth: 1) + ) + } else { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(headerControlFillColor) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(headerControlStrokeColor, lineWidth: 1) + ) } - .animation(.easeInOut(duration: 0.12), value: isHovered) + } + + private var panelBaseFillColor: Color { + colorScheme == .dark ? Color.black.opacity(0.46) : Color.white.opacity(0.72) + } + + private var panelStrokeColor: Color { + colorScheme == .dark ? Color.white.opacity(0.14) : Color.white.opacity(0.82) + } + + private var ticketDetailPanelFillColor: Color { + colorScheme == .dark ? Color.black.opacity(0.66) : Color.white + } + + private var ticketDetailPanelStrokeColor: Color { + colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08) + } + + private var ticketDetailFooterFillColor: Color { + colorScheme == .dark ? Color.black.opacity(0.18) : Color.white + } + + private var newButtonFillColor: Color { + colorScheme == .dark ? Color.accentColor.opacity(0.94) : Color.accentColor + } + + private var newButtonStrokeColor: Color { + colorScheme == .dark ? Color.white.opacity(0.16) : Color.black.opacity(0.08) + } + + private func ticketBadgeFill(for status: TicketStatus) -> Color { + if colorScheme == .dark { + return status.color.opacity(0.28) + } + return status.color.opacity(0.14) + } + + private func ticketBadgeStroke(for status: TicketStatus) -> Color { + if colorScheme == .dark { + return status.color.opacity(0.42) + } + return status.color.opacity(0.24) + } + + private func humanReadableListDate(_ isoString: String) -> String { + guard let date = parseDate(isoString) else { + return isoString + } + return TicketDateFormatter.listFormatter.string(from: date) + } + + private func humanReadableDetailDate(_ isoString: String) -> String { + guard let date = parseDate(isoString) else { + return isoString + } + return TicketDateFormatter.detailFormatter.string(from: date) + } + + private func parseDate(_ value: String) -> Date? { + if let date = TicketDateFormatter.isoWithFractional.date(from: value) { + return date + } + return TicketDateFormatter.isoBasic.date(from: value) } } @@ -800,6 +1032,36 @@ private enum TicketFilter: String, CaseIterable { } } +private enum TicketDateFormatter { + static let isoWithFractional: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + static let isoBasic: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + static let listFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + formatter.doesRelativeDateFormatting = true + return formatter + }() + + static let detailFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + formatter.doesRelativeDateFormatting = true + return formatter + }() +} + private struct TicketCreateInput { let title: String let body: String @@ -898,9 +1160,9 @@ private extension TicketStatus { var color: Color { switch self { case .open: - return .secondary - case .inProgress: return .blue + case .inProgress: + return .indigo case .blocked: return .orange case .done: diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ToolbarIconButton.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ToolbarIconButton.swift index 57f48a80..6754e941 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ToolbarIconButton.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ToolbarIconButton.swift @@ -5,7 +5,17 @@ import SwiftUI /// while still providing a subtle hover/pressed affordance. struct ToolbarIconButton: NSViewRepresentable { final class HoverButton: NSButton { + static let hitTargetSize = NSSize(width: 32, height: 32) + static let hitTargetCornerRadius: CGFloat = 9 + var onPress: (() -> Void)? + var normalSystemImage = "" + var hoverSystemImage: String? + var normalSymbolTint: NSColor? + var hoverSymbolTint: NSColor? + var symbolPointSize: CGFloat = 12 + var symbolWeight: NSFont.Weight = .regular + var accessibilityText = "" private var isHovered = false { didSet { updateAppearance() } @@ -17,6 +27,10 @@ struct ToolbarIconButton: NSViewRepresentable { private var trackingArea: NSTrackingArea? + override var intrinsicContentSize: NSSize { + Self.hitTargetSize + } + override init(frame frameRect: NSRect) { super.init(frame: frameRect) commonInit() @@ -29,13 +43,22 @@ struct ToolbarIconButton: NSViewRepresentable { private func commonInit() { wantsLayer = true - layer?.cornerRadius = 7 + layer?.cornerRadius = Self.hitTargetCornerRadius + layer?.cornerCurve = .continuous layer?.masksToBounds = true isBordered = false bezelStyle = .regularSquare imagePosition = .imageOnly + imageScaling = .scaleProportionallyDown setButtonType(.momentaryChange) + focusRingType = .none + setFrameSize(Self.hitTargetSize) + frame.size = Self.hitTargetSize + setContentHuggingPriority(.required, for: .horizontal) + setContentHuggingPriority(.required, for: .vertical) + setContentCompressionResistancePriority(.required, for: .horizontal) + setContentCompressionResistancePriority(.required, for: .vertical) target = self action = #selector(handlePress) @@ -43,6 +66,10 @@ struct ToolbarIconButton: NSViewRepresentable { updateAppearance() } + func refreshAppearance() { + updateAppearance() + } + override func updateTrackingAreas() { super.updateTrackingAreas() if let trackingArea { @@ -76,9 +103,16 @@ struct ToolbarIconButton: NSViewRepresentable { } private func updateAppearance() { - let base = NSColor.labelColor - let hover = base.withAlphaComponent(0.06) - let pressed = base.withAlphaComponent(0.10) + let isDarkAppearance = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + let hover = isDarkAppearance + ? NSColor.white.withAlphaComponent(0.16) + : NSColor.black.withAlphaComponent(0.08) + let pressed = isDarkAppearance + ? NSColor.white.withAlphaComponent(0.24) + : NSColor.black.withAlphaComponent(0.14) + let stroke = isDarkAppearance + ? NSColor.white.withAlphaComponent(0.22) + : NSColor.black.withAlphaComponent(0.12) if isPressed { layer?.backgroundColor = pressed.cgColor @@ -87,6 +121,32 @@ struct ToolbarIconButton: NSViewRepresentable { } else { layer?.backgroundColor = NSColor.clear.cgColor } + layer?.borderWidth = (isHovered || isPressed) ? 1 : 0 + layer?.borderColor = stroke.cgColor + + updateSymbolAppearance() + } + + private func updateSymbolAppearance() { + let isDarkAppearance = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + let symbolName = (isHovered ? hoverSystemImage : nil) ?? normalSystemImage + let config = NSImage.SymbolConfiguration(pointSize: symbolPointSize, weight: symbolWeight) + image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityText)? + .withSymbolConfiguration(config) + if isHovered, let hoverSymbolTint { + contentTintColor = hoverSymbolTint + } else if let normalSymbolTint { + contentTintColor = normalSymbolTint + } else { + contentTintColor = defaultSymbolTint(isDarkAppearance: isDarkAppearance, isHovered: isHovered) + } + } + + private func defaultSymbolTint(isDarkAppearance: Bool, isHovered: Bool) -> NSColor { + if isDarkAppearance { + return NSColor.white.withAlphaComponent(isHovered ? 1.0 : 0.90) + } + return NSColor.black.withAlphaComponent(isHovered ? 0.86 : 0.72) } @objc private func handlePress() { @@ -95,26 +155,36 @@ struct ToolbarIconButton: NSViewRepresentable { } let systemImage: String + var hoverSystemImage: String? = nil let help: String let accessibilityLabel: String + var symbolTint: NSColor? = nil + var hoverSymbolTint: NSColor? = nil let action: () -> Void func makeNSView(context: Context) -> HoverButton { - let button = HoverButton(frame: .init(x: 0, y: 0, width: 26, height: 26)) + let button = HoverButton(frame: .init(origin: .zero, size: HoverButton.hitTargetSize)) button.onPress = action + button.normalSystemImage = systemImage + button.hoverSystemImage = hoverSystemImage + button.normalSymbolTint = symbolTint + button.hoverSymbolTint = hoverSymbolTint + button.accessibilityText = accessibilityLabel button.toolTip = help button.setAccessibilityLabel(accessibilityLabel) + button.refreshAppearance() return button } func updateNSView(_ nsView: HoverButton, context: Context) { nsView.onPress = action + nsView.normalSystemImage = systemImage + nsView.hoverSystemImage = hoverSystemImage + nsView.normalSymbolTint = symbolTint + nsView.hoverSymbolTint = hoverSymbolTint + nsView.accessibilityText = accessibilityLabel nsView.toolTip = help nsView.setAccessibilityLabel(accessibilityLabel) - - let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .regular) - nsView.image = NSImage(systemSymbolName: systemImage, accessibilityDescription: accessibilityLabel)? - .withSymbolConfiguration(config) - nsView.contentTintColor = NSColor.labelColor.withAlphaComponent(0.85) + nsView.refreshAppearance() } } diff --git a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift index 43b851d8..bb1469e7 100644 --- a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift +++ b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift @@ -29,16 +29,7 @@ public actor HackCLIClient { } let result = try await run(args) - return try decode(ProjectListResponse.self, from: result.stdout) - } - - public func fetchProjectMeta(projectName: String) async throws -> ProjectMeta? { - let trimmed = projectName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - let result = try await run(["projects", "--json", "--include-global", "--project", trimmed, "--meta"]) - let response = try decode(ProjectListResponse.self, from: result.stdout) - return response.projects.first?.meta + return try decodeLenient(ProjectListResponse.self, from: result.stdout) } public func daemonStatus() async throws -> DaemonStatus { @@ -51,6 +42,14 @@ public actor HackCLIClient { return try decodeJsonOrThrow(GlobalStatusResponse.self, result: result) } + public func globalUp() async throws { + _ = try await run(["global", "up"]) + } + + public func globalDown() async throws { + _ = try await run(["global", "down"]) + } + public func startDaemon() async throws { _ = try await run(["daemon", "start"]) } @@ -75,12 +74,83 @@ public actor HackCLIClient { _ = try await run(["down", "--path", path]) } - public func stopSession(sessionName: String) async throws { - let trimmed = sessionName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - throw HackCLIError.commandFailed(exitCode: 1, stderr: "Missing session name") + public func startBranch(path: String, branch: String) async throws { + _ = try await run(["up", "--path", path, "--branch", branch, "--detach"]) + } + + public func stopBranch(path: String, branch: String) async throws { + _ = try await run(["down", "--path", path, "--branch", branch]) + } + + public func addBranch(path: String, name: String, note: String?) async throws { + var args = ["branch", "add", name, "--path", path] + if let note, !note.isEmpty { + args.append(contentsOf: ["--note", note]) + } + _ = try await run(args) + } + + public func removeBranch(path: String, name: String) async throws { + _ = try await run(["branch", "remove", name, "--path", path]) + } + + public func stopSession(name: String) async throws { + _ = try await run(["session", "stop", name]) + } + + public func startSession(projectName: String, detached: Bool = true) async throws { + var args = ["session", "start", projectName] + if detached { + args.append("--detach") + } + _ = try await run(args) + } + + public func setGlobalConfig(key: String, value: String) async throws { + _ = try await run(["config", "set", key, value, "--global"]) + } + + public func listGatewayTokens() async throws -> GatewayTokenListResponse { + let result = try await run(["x", "gateway", "token-list", "--json"], allowNonZeroExit: true) + return try decodeJsonOrThrow(GatewayTokenListResponse.self, result: result) + } + + public func createGatewayToken( + scope: GatewayTokenScope, + label: String? + ) async throws -> GatewayTokenCreateResponse { + var args = ["x", "gateway", "token-create", "--scope", scope.rawValue, "--json"] + if let label, !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args.append(contentsOf: ["--label", label]) + } + let result = try await run(args, allowNonZeroExit: true) + return try decodeJsonOrThrow(GatewayTokenCreateResponse.self, result: result) + } + + public func revokeGatewayToken(id: String) async throws -> GatewayTokenRevokeResponse { + let result = try await run(["x", "gateway", "token-revoke", id, "--json"], allowNonZeroExit: true) + return try decodeJsonOrThrow(GatewayTokenRevokeResponse.self, result: result) + } + + public func startCloudflareTunnel() async throws { + _ = try await run(["x", "cloudflare", "tunnel-start"]) + } + + public func stopCloudflareTunnel() async throws { + _ = try await run(["x", "cloudflare", "tunnel-stop"]) + } + + public func inspectTailscale() async throws -> TailscaleInspectResponse { + do { + let result = try await run(["x", "tailscale", "inspect", "--json"], allowNonZeroExit: true) + return try decodeJsonOrThrow(TailscaleInspectResponse.self, result: result) + } catch is CancellationError { + throw CancellationError() + } catch { + // If hack inspect cannot return machine JSON (stale CLI, disabled extension gate, etc), + // fall back to direct `tailscale status --json` so settings still reflect host reality. + return try await inspectTailscaleDirect() } - _ = try await run(["session", "stop", trimmed]) } public func listTickets(path: String) async throws -> TicketsListResponse { @@ -197,25 +267,81 @@ public actor HackCLIClient { return decoded } - if let snippet = extractJsonSnippet(from: trimmed), - let data = snippet.data(using: .utf8), - let decoded = try? decoder.decode(T.self, from: data) { - return decoded + for snippet in extractJsonSnippets(from: trimmed) { + if let data = snippet.data(using: .utf8), + let decoded = try? decoder.decode(T.self, from: data) { + return decoded + } } throw HackCLIError.invalidJson } - private func extractJsonSnippet(from text: String) -> String? { - guard let startIndex = text.firstIndex(where: { $0 == "{" || $0 == "[" }) else { - return nil + private func extractJsonSnippets(from text: String) -> [String] { + var snippets: [String] = [] + var seen: Set = [] + for index in text.indices { + let char = text[index] + guard char == "{" || char == "[" else { + continue + } + guard let snippet = extractBalancedJson(from: text, startAt: index) else { + continue + } + if seen.insert(snippet).inserted { + snippets.append(snippet) + } } + return snippets + } + + private func extractBalancedJson(from text: String, startAt startIndex: String.Index) -> String? { let startChar = text[startIndex] - let endChar: Character = startChar == "{" ? "}" : "]" - guard let endIndex = text.lastIndex(of: endChar), endIndex >= startIndex else { + guard startChar == "{" || startChar == "[" else { return nil } - return String(text[startIndex...endIndex]) + + var stack: [Character] = [startChar == "{" ? "}" : "]"] + var insideString = false + var escaped = false + var index = text.index(after: startIndex) + + while index < text.endIndex { + let char = text[index] + + if insideString { + if escaped { + escaped = false + } else if char == "\\" { + escaped = true + } else if char == "\"" { + insideString = false + } + } else { + switch char { + case "\"": + insideString = true + case "{": + stack.append("}") + case "[": + stack.append("]") + case "}", "]": + guard let expected = stack.last, char == expected else { + return nil + } + _ = stack.removeLast() + if stack.isEmpty { + return String(text[startIndex...index]) + } + default: + break + } + } + + index = text.index(after: index) + } + + return nil } private func run( @@ -246,22 +372,20 @@ public actor HackCLIClient { process.standardError = stderrPipe return try await withTaskCancellationHandler(operation: { - let exitCode = await withCheckedContinuation { continuation in - process.terminationHandler = { proc in - continuation.resume(returning: Int(proc.terminationStatus)) - } - - do { - try process.run() - } catch { - stdoutPipe.fileHandleForReading.closeFile() - stderrPipe.fileHandleForReading.closeFile() - continuation.resume(returning: 127) - } + do { + try process.run() + } catch { + stdoutPipe.fileHandleForReading.closeFile() + stderrPipe.fileHandleForReading.closeFile() + throw HackCLIError.commandFailed(exitCode: 127, stderr: error.localizedDescription) } async let stdoutData = stdoutPipe.fileHandleForReading.readToEnd() async let stderrData = stderrPipe.fileHandleForReading.readToEnd() + let exitCode = await Task.detached(priority: nil) { + process.waitUntilExit() + return Int(process.terminationStatus) + }.value let stdoutBytes: Data? let stderrBytes: Data? @@ -319,6 +443,287 @@ public actor HackCLIClient { throw error } } + + private func inspectTailscaleDirect() async throws -> TailscaleInspectResponse { + let environment = HackCLILocator.buildEnvironment() + guard let binaryPath = HackCLILocator.resolveExecutable(named: "tailscale", in: environment) else { + return TailscaleInspectResponse( + installed: false, + binaryPath: nil, + connected: false, + backendState: nil, + tailnetName: nil, + magicDnsSuffix: nil, + authUrl: nil, + currentExitNodeId: nil, + currentExitNodeName: nil, + selfDevice: nil, + peers: [], + onlinePeerCount: 0, + exitNodes: [], + health: [], + error: "tailscale not found in PATH" + ) + } + + let result = try await runExecutable( + executablePath: binaryPath, + args: ["status", "--json"], + allowNonZeroExit: true, + cwd: nil + ) + if result.exitCode != 0 { + let stderr = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines) + return TailscaleInspectResponse( + installed: true, + binaryPath: binaryPath, + connected: false, + backendState: nil, + tailnetName: nil, + magicDnsSuffix: nil, + authUrl: nil, + currentExitNodeId: nil, + currentExitNodeName: nil, + selfDevice: nil, + peers: [], + onlinePeerCount: 0, + exitNodes: [], + health: [], + error: stderr.isEmpty ? "tailscale status failed" : stderr + ) + } + + let rawStatus = try decode(RawTailscaleStatus.self, from: result.stdout) + let selfDevice = rawStatus.selfPeer.map { + mapRawPeer( + id: $0.id ?? "self", + peer: $0, + treatAsSelf: true + ) + } + + let peers = rawStatus.peers + .map { key, value in + mapRawPeer(id: value.id ?? key, peer: value, treatAsSelf: false) + } + .sorted { lhs, rhs in + if lhs.online != rhs.online { + return lhs.online && !rhs.online + } + return lhs.hostname.localizedCaseInsensitiveCompare(rhs.hostname) == .orderedAscending + } + + let exitNodes = peers.filter { $0.isExitNode || $0.isExitNodeOption } + let currentExitNodeName = rawStatus.currentExitNodeId.flatMap { id in + peers.first(where: { $0.id == id })?.hostname + } + + return TailscaleInspectResponse( + installed: true, + binaryPath: binaryPath, + connected: rawStatus.backendState == "Running", + backendState: rawStatus.backendState, + tailnetName: rawStatus.currentTailnet?.name, + magicDnsSuffix: rawStatus.currentTailnet?.magicDnsSuffix, + authUrl: rawStatus.authUrl, + currentExitNodeId: rawStatus.currentExitNodeId, + currentExitNodeName: currentExitNodeName, + selfDevice: selfDevice.map { + TailscaleInspectSelf( + id: $0.id, + hostname: $0.hostname, + dnsName: $0.dnsName, + tailscaleIp: $0.tailscaleIp, + online: $0.online, + os: $0.os, + tags: $0.tags, + isExitNode: $0.isExitNode + ) + }, + peers: peers, + onlinePeerCount: peers.filter(\.online).count, + exitNodes: exitNodes, + health: rawStatus.health, + error: nil + ) + } + + private func mapRawPeer( + id: String, + peer: RawTailscalePeer, + treatAsSelf: Bool + ) -> TailscaleInspectPeer { + TailscaleInspectPeer( + id: id, + hostname: peer.hostName ?? id, + dnsName: normalizeDNS(peer.dnsName), + tailscaleIp: peer.tailscaleIPs.first, + online: peer.online ?? false, + os: peer.os, + tags: peer.tags ?? [], + isExitNode: peer.exitNode ?? false, + isExitNodeOption: treatAsSelf ? false : (peer.exitNodeOption ?? false) + ) + } + + private func normalizeDNS(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + if value.hasSuffix(".") { + return String(value.dropLast()) + } + return value + } + + private func runExecutable( + executablePath: String, + args: [String], + allowNonZeroExit: Bool, + cwd: String? + ) async throws -> CLIResult { + try Task.checkCancellation() + + let process = Process() + process.environment = HackCLILocator.buildEnvironment() + if let cwd, !cwd.isEmpty { + process.currentDirectoryURL = URL(fileURLWithPath: cwd) + } + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = args + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + return try await withTaskCancellationHandler(operation: { + do { + try process.run() + } catch { + stdoutPipe.fileHandleForReading.closeFile() + stderrPipe.fileHandleForReading.closeFile() + throw HackCLIError.commandFailed(exitCode: 127, stderr: error.localizedDescription) + } + + async let stdoutData = stdoutPipe.fileHandleForReading.readToEnd() + async let stderrData = stderrPipe.fileHandleForReading.readToEnd() + let exitCode = await Task.detached(priority: nil) { + process.waitUntilExit() + return Int(process.terminationStatus) + }.value + + let stdoutBytes: Data? + let stderrBytes: Data? + + do { + stdoutBytes = try await stdoutData + } catch { + stdoutBytes = nil + } + + do { + stderrBytes = try await stderrData + } catch { + stderrBytes = nil + } + + try Task.checkCancellation() + + let stdout = String(decoding: stdoutBytes ?? Data(), as: UTF8.self) + let stderr = String(decoding: stderrBytes ?? Data(), as: UTF8.self) + if exitCode != 0 && !allowNonZeroExit { + throw HackCLIError.commandFailed( + exitCode: exitCode, + stderr: stderr.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + return CLIResult(stdout: stdout, stderr: stderr, exitCode: exitCode) + }, onCancel: { + if process.isRunning { + process.terminate() + } + stdoutPipe.fileHandleForReading.closeFile() + stderrPipe.fileHandleForReading.closeFile() + }) + } +} + +private struct RawTailscaleStatus: Decodable { + let backendState: String? + let currentTailnet: RawTailscaleTailnet? + let authUrl: String? + let currentExitNodeId: String? + let selfPeer: RawTailscalePeer? + let peers: [String: RawTailscalePeer] + let health: [String] + + enum CodingKeys: String, CodingKey { + case backendState = "BackendState" + case currentTailnet = "CurrentTailnet" + case authUrl = "AuthURL" + case currentExitNodeId = "ExitNodeID" + case selfPeer = "Self" + case peers = "Peer" + case health = "Health" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + backendState = try container.decodeIfPresent(String.self, forKey: .backendState) + currentTailnet = try container.decodeIfPresent(RawTailscaleTailnet.self, forKey: .currentTailnet) + authUrl = try container.decodeIfPresent(String.self, forKey: .authUrl) + currentExitNodeId = try container.decodeIfPresent(String.self, forKey: .currentExitNodeId) + selfPeer = try container.decodeIfPresent(RawTailscalePeer.self, forKey: .selfPeer) + peers = try container.decodeIfPresent([String: RawTailscalePeer].self, forKey: .peers) ?? [:] + health = try container.decodeIfPresent([String].self, forKey: .health) ?? [] + } +} + +private struct RawTailscaleTailnet: Decodable { + let name: String? + let magicDnsSuffix: String? + + enum CodingKeys: String, CodingKey { + case name = "Name" + case magicDnsSuffix = "MagicDNSSuffix" + } +} + +private struct RawTailscalePeer: Decodable { + let id: String? + let hostName: String? + let dnsName: String? + let tailscaleIPs: [String] + let online: Bool? + let os: String? + let tags: [String]? + let exitNode: Bool? + let exitNodeOption: Bool? + + enum CodingKeys: String, CodingKey { + case id = "ID" + case hostName = "HostName" + case dnsName = "DNSName" + case tailscaleIPs = "TailscaleIPs" + case online = "Online" + case os = "OS" + case tags = "Tags" + case exitNode = "ExitNode" + case exitNodeOption = "ExitNodeOption" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(String.self, forKey: .id) + hostName = try container.decodeIfPresent(String.self, forKey: .hostName) + dnsName = try container.decodeIfPresent(String.self, forKey: .dnsName) + tailscaleIPs = try container.decodeIfPresent([String].self, forKey: .tailscaleIPs) ?? [] + online = try container.decodeIfPresent(Bool.self, forKey: .online) + os = try container.decodeIfPresent(String.self, forKey: .os) + tags = try container.decodeIfPresent([String].self, forKey: .tags) + exitNode = try container.decodeIfPresent(Bool.self, forKey: .exitNode) + exitNodeOption = try container.decodeIfPresent(Bool.self, forKey: .exitNodeOption) + } } private struct CLIResult { diff --git a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLILocator.swift b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLILocator.swift index 4ff81e52..827bcc67 100644 --- a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLILocator.swift +++ b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLILocator.swift @@ -4,104 +4,123 @@ public enum HackCLILocator { public static func buildEnvironment() -> [String: String] { var env = ProcessInfo.processInfo.environment let home = (env["HOME"] ?? NSHomeDirectory()).trimmingCharacters(in: .whitespacesAndNewlines) - - // NOTE: GUI-launched apps often have a minimal PATH that doesn't include user-installed tools. - // We try to start with the existing PATH, but fall back to `path_helper` when PATH is missing/empty. - let existing = env["PATH"]?.split(separator: ":").map(String.init) ?? [] - let base = existing.isEmpty ? resolvePathHelperPaths() : existing - - var extras: [String] = [] + var homeBinPaths: [String] = [] if !home.isEmpty { - extras.append(contentsOf: [ + homeBinPaths = [ "\(home)/.hack/bin", "\(home)/.local/bin", "\(home)/.bun/bin", "\(home)/.cargo/bin", + "\(home)/.local/share/mise/shims", "\(home)/.asdf/shims", - "\(home)/.volta/bin", - "\(home)/.nix-profile/bin", - "\(home)/.local/share/mise/shims" - ]) - } - - if let bunInstall = env["BUN_INSTALL"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !bunInstall.isEmpty { - extras.append("\(bunInstall)/bin") + "\(home)/.volta/bin" + ] + if let miseBunBin = resolveLatestMiseBunBin(home: home) { + homeBinPaths.append(miseBunBin) + } } - - let defaults = [ + let defaultPaths = [ "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", - "/sbin", - "/run/current-system/sw/bin" + "/sbin" ] - - // De-dupe while preserving order. - var seen = Set() - func push(_ path: String) { - let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - guard !seen.contains(trimmed) else { return } - seen.insert(trimmed) - extrasMerged.append(trimmed) - } - - var extrasMerged: [String] = [] - for p in base { push(p) } - for p in extras { push(p) } - for p in defaults { push(p) } - - env["PATH"] = extrasMerged.joined(separator: ":") + let existing = env["PATH"]?.split(separator: ":").map(String.init) ?? [] + let merged = existing + + homeBinPaths.filter { !existing.contains($0) } + + defaultPaths.filter { !existing.contains($0) } + env["PATH"] = merged.joined(separator: ":") return env } public static func resolveHackExecutable(in env: [String: String]) -> String? { let fileManager = FileManager.default if let override = env["HACK_CLI_PATH"], fileManager.isExecutableFile(atPath: override) { - return override + return normalizeHackCandidate(override, env: env) ?? override } guard let pathValue = env["PATH"] else { return nil } for entry in pathValue.split(separator: ":") { let candidate = String(entry) + "/hack" - if fileManager.isExecutableFile(atPath: candidate) { - return candidate + guard fileManager.isExecutableFile(atPath: candidate) else { + continue + } + if let resolved = normalizeHackCandidate(candidate, env: env) { + return resolved } } return nil } - private static func resolvePathHelperPaths() -> [String] { - let url = URL(fileURLWithPath: "/usr/libexec/path_helper") - guard FileManager.default.isExecutableFile(atPath: url.path) else { return [] } + private static func normalizeHackCandidate(_ candidate: String, env: [String: String]) -> String? { + if bunIsRequiredByWrapper(candidate), + resolveExecutable(named: "bun", in: env) == nil { + if let fallback = resolveWrapperDistBinary(candidate), FileManager.default.isExecutableFile(atPath: fallback) { + return fallback + } + return nil + } + return candidate + } - let process = Process() - process.executableURL = url - process.arguments = ["-s"] + private static func bunIsRequiredByWrapper(_ path: String) -> Bool { + guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { + return false + } + return content.contains("exec bun ") + } - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = Pipe() + private static func resolveWrapperDistBinary(_ path: String) -> String? { + guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { + return nil + } - do { - try process.run() - process.waitUntilExit() - } catch { - return [] + let pattern = #"exec\s+bun\s+"([^"]+/index\.ts)""# + guard let regex = try? NSRegularExpression(pattern: pattern) else { + return nil + } + let range = NSRange(content.startIndex.. 1, + let indexRange = Range(match.range(at: 1), in: content) else { + return nil + } + let indexPath = String(content[indexRange]) + guard indexPath.hasSuffix("/index.ts") else { + return nil } + return String(indexPath.dropLast("/index.ts".count)) + "/dist/hack" + } - let data = pipe.fileHandleForReading.readDataToEndOfFile() - guard let text = String(data: data, encoding: .utf8) else { return [] } + public static func resolveExecutable(named name: String, in env: [String: String]) -> String? { + guard let pathValue = env["PATH"] else { return nil } + let fileManager = FileManager.default + for entry in pathValue.split(separator: ":") { + let candidate = String(entry) + "/\(name)" + if fileManager.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } - // `path_helper -s` prints shell code like: - // PATH="..."; export PATH; - guard let range = text.range(of: "PATH=\"") else { return [] } - let after = text[range.upperBound...] - guard let end = after.firstIndex(of: "\"") else { return [] } - let pathValue = String(after[.. String? { + let root = "\(home)/.local/share/mise/installs/bun" + let fileManager = FileManager.default + guard let entries = try? fileManager.contentsOfDirectory(atPath: root) else { + return nil + } + let versions = entries.sorted { lhs, rhs in + lhs.localizedStandardCompare(rhs) == .orderedDescending + } + for version in versions { + let candidate = "\(root)/\(version)/bin" + if fileManager.isExecutableFile(atPath: "\(candidate)/bun") { + return candidate + } + } + return nil } } diff --git a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift index ee8562ed..7626733f 100644 --- a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift +++ b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift @@ -21,6 +21,16 @@ public enum ProjectKind: String, Decodable { case unregistered } +public enum ProjectSessionBackend: String, Decodable { + case tmux + case zellij +} + +public enum ProjectSessionSource: String, Decodable { + case hack + case external +} + public struct ProjectSummary: Decodable, Identifiable, Hashable { public let projectId: String? public let name: String @@ -34,7 +44,8 @@ public struct ProjectSummary: Decodable, Identifiable, Hashable { public let runtimeConfigured: Bool? public let runtimeStatus: ProjectRuntimeStatus? public let runtime: RuntimeProject? - public let meta: ProjectMeta? + public let branchRuntime: [BranchRuntime]? + public let sessions: [ProjectSessionSummary]? public let kind: ProjectKind public let status: ProjectStatus @@ -53,7 +64,8 @@ public struct ProjectSummary: Decodable, Identifiable, Hashable { runtimeConfigured: Bool?, runtimeStatus: ProjectRuntimeStatus?, runtime: RuntimeProject?, - meta: ProjectMeta?, + branchRuntime: [BranchRuntime]? = nil, + sessions: [ProjectSessionSummary]? = nil, kind: ProjectKind, status: ProjectStatus ) { @@ -69,113 +81,53 @@ public struct ProjectSummary: Decodable, Identifiable, Hashable { self.runtimeConfigured = runtimeConfigured self.runtimeStatus = runtimeStatus self.runtime = runtime - self.meta = meta + self.branchRuntime = branchRuntime + self.sessions = sessions self.kind = kind self.status = status } } -public enum HackEnvSource: String, Decodable, Hashable { - case plainEnv = "plain_env" - case keychain -} - -public enum EnvResolvedFrom: String, Decodable, Hashable { - case dotenv - case process - case keychain -} - -public struct ProjectMeta: Decodable, Hashable { - public let git: GitMeta - public let hackBranches: HackBranchesMeta - public let env: EnvMeta - public let sessions: SessionsMeta - public let composeBuild: ComposeBuildMeta -} - -public struct GitMeta: Decodable, Hashable { - public let isRepo: Bool - public let head: String? - public let branch: String? - public let detached: Bool? - public let dirty: Bool? - public let localBranchCount: Int? - public let worktrees: [GitWorktreeMeta]? - public let error: String? -} +public struct BranchRuntime: Decodable, Hashable, Identifiable { + public let branch: String + public let runtime: RuntimeProject -public struct GitWorktreeMeta: Decodable, Hashable { - public let path: String - public let head: String? - public let branch: String? - public let detached: Bool -} + public var id: String { branch } -public struct HackBranchesMeta: Decodable, Hashable { - public let path: String - public let parseError: String? - public let branches: [HackBranchEntry] -} - -public struct HackBranchEntry: Decodable, Hashable, Identifiable { - public let name: String - public let slug: String - public let note: String? - public let createdAt: String? - public let lastUsedAt: String? - - public var id: String { slug } -} - -public struct EnvMeta: Decodable, Hashable { - public let contractPath: String - public let contractExists: Bool - public let contractParseError: String? - public let vars: [EnvVarMeta] - public let missingRequired: [String] -} - -public struct EnvVarMeta: Decodable, Hashable, Identifiable { - public let key: String - public let required: Bool - public let source: HackEnvSource - public let services: [String]? - public let description: String? - public let resolvedFrom: EnvResolvedFrom? - public let hasValue: Bool - - public var id: String { key } -} - -public struct SessionsMeta: Decodable, Hashable { - public let sessions: [MuxSessionSummary] + public init(branch: String, runtime: RuntimeProject) { + self.branch = branch + self.runtime = runtime + } } -public struct MuxSessionSummary: Decodable, Hashable, Identifiable { - public let backend: String +public struct ProjectSessionSummary: Decodable, Hashable, Identifiable { public let name: String - public let attached: Bool? + public let backend: ProjectSessionBackend + public let source: ProjectSessionSource + public let attached: Bool public let path: String? public let windows: Int? - public let createdAt: String? - - public var id: String { "\(backend):\(name)" } -} - -public struct ComposeBuildMeta: Decodable, Hashable { - public let services: [ComposeBuildServiceMeta] -} + public let createdAt: Int? -public struct ComposeBuildServiceMeta: Decodable, Hashable, Identifiable { - public let service: String - public let build: Bool - public let context: String? - public let dockerfile: String? - public let dockerfilePath: String? - public let dockerfileExists: Bool? + public var id: String { "\(backend.rawValue):\(name)" } - public var id: String { service } + public init( + name: String, + backend: ProjectSessionBackend, + source: ProjectSessionSource, + attached: Bool, + path: String?, + windows: Int?, + createdAt: Int? + ) { + self.name = name + self.backend = backend + self.source = source + self.attached = attached + self.path = path + self.windows = windows + self.createdAt = createdAt + } } public struct RuntimeProject: Decodable, Hashable { @@ -212,11 +164,11 @@ public struct RuntimeContainer: Decodable, Hashable { public let status: String public let name: String public let ports: String + public let workingDir: String? public let image: String? - public let ip: String? - public let mounts: [RuntimeMount]? public let labels: [String: String]? - public let workingDir: String? + public let mounts: [RuntimeContainerMount]? + public let networks: [RuntimeContainerNetwork]? public init( id: String, @@ -224,22 +176,22 @@ public struct RuntimeContainer: Decodable, Hashable { status: String, name: String, ports: String, + workingDir: String?, image: String?, - ip: String?, - mounts: [RuntimeMount]?, labels: [String: String]?, - workingDir: String? + mounts: [RuntimeContainerMount]?, + networks: [RuntimeContainerNetwork]? ) { self.id = id self.state = state self.status = status self.name = name self.ports = ports + self.workingDir = workingDir self.image = image - self.ip = ip - self.mounts = mounts self.labels = labels - self.workingDir = workingDir + self.mounts = mounts + self.networks = networks } private enum CodingKeys: String, CodingKey { @@ -248,21 +200,43 @@ public struct RuntimeContainer: Decodable, Hashable { case status case name case ports + case workingDir = "working_dir" case image - case ip - case mounts case labels - case workingDir = "working_dir" + case mounts + case networks } } -public struct RuntimeMount: Decodable, Hashable { - public let source: String? - public let destination: String? +public struct RuntimeContainerMount: Decodable, Hashable { + public let type: String + public let source: String + public let destination: String + public let mode: String + public let rw: Bool? - public init(source: String?, destination: String?) { + public init(type: String, source: String, destination: String, mode: String, rw: Bool?) { + self.type = type self.source = source self.destination = destination + self.mode = mode + self.rw = rw + } +} + +public struct RuntimeContainerNetwork: Decodable, Hashable, Identifiable { + public let name: String + public let ipAddress: String? + public let gateway: String? + public let aliases: [String]? + + public var id: String { name } + + public init(name: String, ipAddress: String?, gateway: String?, aliases: [String]?) { + self.name = name + self.ipAddress = ipAddress + self.gateway = gateway + self.aliases = aliases } } @@ -469,6 +443,7 @@ public struct GatewayStatus: Decodable { public let tokensRevoked: Int? public let tokensWrite: Int? public let tokensRead: Int? + public let tokens: [GatewayTokenRecord]? public let gatewayProjects: String? public let exposures: [GatewayExposure]? public let warnings: [String]? @@ -485,6 +460,7 @@ public struct GatewayStatus: Decodable { tokensRevoked: Int?, tokensWrite: Int?, tokensRead: Int?, + tokens: [GatewayTokenRecord]?, gatewayProjects: String?, exposures: [GatewayExposure]?, warnings: [String]? @@ -500,12 +476,206 @@ public struct GatewayStatus: Decodable { self.tokensRevoked = tokensRevoked self.tokensWrite = tokensWrite self.tokensRead = tokensRead + self.tokens = tokens self.gatewayProjects = gatewayProjects self.exposures = exposures self.warnings = warnings } } +public enum GatewayTokenScope: String, Decodable { + case read + case write +} + +public struct GatewayTokenRecord: Decodable, Identifiable, Hashable { + public let id: String + public let scope: GatewayTokenScope + public let label: String? + public let createdAt: String + public let lastUsedAt: String? + public let revokedAt: String? + + public init( + id: String, + scope: GatewayTokenScope, + label: String?, + createdAt: String, + lastUsedAt: String?, + revokedAt: String? + ) { + self.id = id + self.scope = scope + self.label = label + self.createdAt = createdAt + self.lastUsedAt = lastUsedAt + self.revokedAt = revokedAt + } +} + +public struct GatewayTokenListResponse: Decodable { + public let tokens: [GatewayTokenRecord] + + public init(tokens: [GatewayTokenRecord]) { + self.tokens = tokens + } +} + +public struct GatewayTokenCreateResponse: Decodable { + public let token: String + public let record: GatewayTokenRecord + + public init(token: String, record: GatewayTokenRecord) { + self.token = token + self.record = record + } +} + +public struct GatewayTokenRevokeResponse: Decodable { + public let id: String + public let revoked: Bool + + public init(id: String, revoked: Bool) { + self.id = id + self.revoked = revoked + } +} + +public struct TailscaleInspectResponse: Decodable { + public let installed: Bool + public let binaryPath: String? + public let connected: Bool + public let backendState: String? + public let tailnetName: String? + public let magicDnsSuffix: String? + public let authUrl: String? + public let currentExitNodeId: String? + public let currentExitNodeName: String? + public let selfDevice: TailscaleInspectSelf? + public let peers: [TailscaleInspectPeer] + public let onlinePeerCount: Int + public let exitNodes: [TailscaleInspectPeer] + public let health: [String] + public let error: String? + + public init( + installed: Bool, + binaryPath: String?, + connected: Bool, + backendState: String?, + tailnetName: String?, + magicDnsSuffix: String?, + authUrl: String?, + currentExitNodeId: String?, + currentExitNodeName: String?, + selfDevice: TailscaleInspectSelf?, + peers: [TailscaleInspectPeer], + onlinePeerCount: Int, + exitNodes: [TailscaleInspectPeer], + health: [String], + error: String? + ) { + self.installed = installed + self.binaryPath = binaryPath + self.connected = connected + self.backendState = backendState + self.tailnetName = tailnetName + self.magicDnsSuffix = magicDnsSuffix + self.authUrl = authUrl + self.currentExitNodeId = currentExitNodeId + self.currentExitNodeName = currentExitNodeName + self.selfDevice = selfDevice + self.peers = peers + self.onlinePeerCount = onlinePeerCount + self.exitNodes = exitNodes + self.health = health + self.error = error + } + + private enum CodingKeys: String, CodingKey { + case installed + case binaryPath + case connected + case backendState + case tailnetName + case magicDnsSuffix + case authUrl + case currentExitNodeId + case currentExitNodeName + case selfDevice = "self" + case peers + case onlinePeerCount + case exitNodes + case health + case error + } +} + +public struct TailscaleInspectSelf: Decodable, Hashable, Identifiable { + public let id: String + public let hostname: String + public let dnsName: String? + public let tailscaleIp: String? + public let online: Bool + public let os: String? + public let tags: [String] + public let isExitNode: Bool + + public init( + id: String, + hostname: String, + dnsName: String?, + tailscaleIp: String?, + online: Bool, + os: String?, + tags: [String], + isExitNode: Bool + ) { + self.id = id + self.hostname = hostname + self.dnsName = dnsName + self.tailscaleIp = tailscaleIp + self.online = online + self.os = os + self.tags = tags + self.isExitNode = isExitNode + } +} + +public struct TailscaleInspectPeer: Decodable, Hashable, Identifiable { + public let id: String + public let hostname: String + public let dnsName: String? + public let tailscaleIp: String? + public let online: Bool + public let os: String? + public let tags: [String] + public let isExitNode: Bool + public let isExitNodeOption: Bool + + public init( + id: String, + hostname: String, + dnsName: String?, + tailscaleIp: String?, + online: Bool, + os: String?, + tags: [String], + isExitNode: Bool, + isExitNodeOption: Bool + ) { + self.id = id + self.hostname = hostname + self.dnsName = dnsName + self.tailscaleIp = tailscaleIp + self.online = online + self.os = os + self.tags = tags + self.isExitNode = isExitNode + self.isExitNodeOption = isExitNodeOption + } +} + public struct GatewayExposure: Decodable, Identifiable, Hashable { public enum State: String, Decodable { case disabled diff --git a/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/ProjectListResponseTests.swift b/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/ProjectListResponseTests.swift index 1c6902be..38574ee0 100644 --- a/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/ProjectListResponseTests.swift +++ b/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/ProjectListResponseTests.swift @@ -27,6 +27,81 @@ final class ProjectListResponseTests: XCTestCase { }, "runtime_configured": true, "runtime_status": "running", + "sessions": [ + { + "name": "hack-cli", + "backend": "tmux", + "source": "hack", + "attached": true, + "path": "/repo", + "windows": 2, + "created_at": 1735000000 + }, + { + "name": "manual-scratch", + "backend": "tmux", + "source": "external", + "attached": false, + "path": "/repo", + "windows": 1, + "created_at": 1735000100 + }, + { + "name": "hack-cli:research", + "backend": "zellij", + "source": "hack", + "attached": false, + "path": null, + "windows": null, + "created_at": null + } + ], + "branch_runtime": [ + { + "branch": "fix-seat-geometry", + "runtime": { + "project": "hack-cli--fix-seat-geometry", + "working_dir": "/repo/.hack", + "services": [ + { + "service": "api", + "containers": [ + { + "id": "abc123", + "state": "running", + "status": "Up 5m", + "name": "hack-cli--fix-seat-geometry-api-1", + "ports": "3000/tcp", + "working_dir": "/repo/.hack", + "image": "imbios/bun-node:latest", + "labels": { + "com.docker.compose.project": "hack-cli--fix-seat-geometry", + "com.docker.compose.service": "api" + }, + "mounts": [ + { + "type": "bind", + "source": "/repo", + "destination": "/app", + "mode": "", + "rw": true + } + ], + "networks": [ + { + "name": "default", + "ip_address": "172.30.0.10", + "gateway": "172.30.0.1", + "aliases": ["api"] + } + ] + } + ] + } + ] + } + } + ], "kind": "registered", "status": "running" } @@ -47,5 +122,15 @@ final class ProjectListResponseTests: XCTestCase { XCTAssertEqual(response.projects.first?.status, .running) XCTAssertEqual(response.projects.first?.runtimeStatus, .running) XCTAssertEqual(response.projects.first?.serviceHosts?["api"], ["api.hack-cli.test", "api.hack-cli.test.gy"]) + XCTAssertEqual(response.projects.first?.sessions?.count, 3) + XCTAssertEqual(response.projects.first?.sessions?.first?.name, "hack-cli") + XCTAssertEqual(response.projects.first?.sessions?.first?.backend, .tmux) + XCTAssertEqual(response.projects.first?.sessions?.first?.source, .hack) + XCTAssertEqual(response.projects.first?.sessions?[2].backend, .zellij) + XCTAssertEqual(response.projects.first?.branchRuntime?.first?.branch, "fix-seat-geometry") + XCTAssertEqual(response.projects.first?.branchRuntime?.first?.runtime.project, "hack-cli--fix-seat-geometry") + XCTAssertEqual(response.projects.first?.branchRuntime?.first?.runtime.services.first?.containers.first?.image, "imbios/bun-node:latest") + XCTAssertEqual(response.projects.first?.branchRuntime?.first?.runtime.services.first?.containers.first?.mounts?.first?.destination, "/app") + XCTAssertEqual(response.projects.first?.branchRuntime?.first?.runtime.services.first?.containers.first?.networks?.first?.ipAddress, "172.30.0.10") } } diff --git a/src/commands/config.ts b/src/commands/config.ts index ba5a07a0..2800c6bb 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -91,7 +91,9 @@ const handleConfigGet: CommandHandlerFor = async ({ throw new CliUsageError("Missing required argument: key"); } - const parsedKey = parseKeyPath({ raw: key }); + const parsedKey = normalizeControlPlaneExtensionPath({ + path: parseKeyPath({ raw: key }), + }); if (parsedKey.length === 0) { throw new CliUsageError("Invalid config key."); } @@ -132,7 +134,9 @@ const handleConfigSet: CommandHandlerFor = async ({ if (key.length === 0) { throw new CliUsageError("Missing required argument: key"); } - const parsedKey = parseKeyPath({ raw: key }); + const parsedKey = normalizeControlPlaneExtensionPath({ + path: parseKeyPath({ raw: key }), + }); if (parsedKey.length === 0) { throw new CliUsageError("Invalid config key."); } @@ -162,6 +166,10 @@ const handleConfigSet: CommandHandlerFor = async ({ logger.error({ message: update.error }); return 1; } + pruneLegacyControlPlaneExtensionEntry({ + target: read.value, + path: parsedKey, + }); const nextText = `${JSON.stringify(read.value, null, 2)}\n`; if (project.scope === "global") { @@ -484,7 +492,7 @@ function handleRootChar(opts: { if (ch === "[") { const hasBuffer = buffer.trim().length > 0; return { - buffer: "", + buffer, state: { ...state, inBracket: true }, push: hasBuffer, }; @@ -579,3 +587,40 @@ function parseValue(opts: { readonly raw: string }): unknown { return opts.raw; } } + +function normalizeControlPlaneExtensionPath(opts: { + readonly path: readonly string[]; +}): readonly string[] { + const [root, second, ...rest] = opts.path; + if (root !== "controlPlane") { + return opts.path; + } + if (second === "extensions") { + return opts.path; + } + if (!second?.startsWith("dance.hack.") || rest.length === 0) { + return opts.path; + } + return ["controlPlane", "extensions", second, ...rest]; +} + +function pruneLegacyControlPlaneExtensionEntry(opts: { + readonly target: Record; + readonly path: readonly string[]; +}): void { + const [root, section, extensionId] = opts.path; + if (root !== "controlPlane" || section !== "extensions") { + return; + } + if (!extensionId?.startsWith("dance.hack.")) { + return; + } + const controlPlane = opts.target.controlPlane; + if (!isRecord(controlPlane)) { + return; + } + if (!(extensionId in controlPlane)) { + return; + } + delete controlPlane[extensionId]; +} diff --git a/src/commands/global.ts b/src/commands/global.ts index d05d28e2..094ac986 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -649,14 +649,39 @@ export async function globalUp(): Promise { }); if (reservedIps.length > 0) { const conflicts = await findIngressIpConflicts({ reservedIps }); - const blockers = conflicts.filter( + let blockers = conflicts.filter( (conflict) => !isGlobalProxyContainer({ name: conflict.containerName }) ); if (blockers.length > 0) { - logger.error({ - message: renderIngressConflictMessage({ conflicts: blockers }), + logger.warn({ + message: [ + `Reserved ingress IPs are currently occupied on ${DEFAULT_INGRESS_NETWORK}.`, + "Attempting to reassign conflicting containers and retry global startup…", + ].join("\n"), + }); + + await reassignIngressIpConflicts({ + conflicts: blockers, + reservedIps, + }); + + const remainingConflicts = await findIngressIpConflicts({ + reservedIps, + }); + blockers = remainingConflicts.filter( + (conflict) => !isGlobalProxyContainer({ name: conflict.containerName }) + ); + + if (blockers.length > 0) { + logger.error({ + message: renderIngressConflictMessage({ conflicts: blockers }), + }); + return 1; + } + + logger.success({ + message: "Recovered reserved ingress IP conflicts; continuing startup.", }); - return 1; } } @@ -718,6 +743,13 @@ type IngressIpConflict = { readonly containerName: string; }; +type IngressNetworkSnapshot = { + readonly subnet: string | null; + readonly gateway: string | null; + readonly usedIps: ReadonlySet; + readonly containerIpByName: ReadonlyMap; +}; + async function resolveReservedIngressIps(opts: { readonly composePath: string; }): Promise { @@ -743,6 +775,24 @@ async function findIngressIpConflicts(opts: { return []; } + const snapshot = await inspectIngressNetworkSnapshot(); + if (!snapshot) { + return []; + } + + const reservedIps = new Set(opts.reservedIps); + const conflicts: IngressIpConflict[] = []; + for (const [containerName, ip] of snapshot.containerIpByName.entries()) { + if (!reservedIps.has(ip)) { + continue; + } + conflicts.push({ ip, containerName }); + } + + return conflicts; +} + +async function inspectIngressNetworkSnapshot(): Promise { const inspect = await exec( ["docker", "network", "inspect", DEFAULT_INGRESS_NETWORK], { @@ -750,98 +800,282 @@ async function findIngressIpConflicts(opts: { } ); if (inspect.exitCode !== 0) { - return []; - } - - const parsed = parseDockerNetworkInspect({ stdout: inspect.stdout }); - if (!parsed) { - return []; + return null; } - const records = extractIngressContainerRecords({ inspect: parsed }); - return records - .map((record) => ({ - ip: extractIpv4Address({ raw: record.ipv4AddressRaw }), - containerName: record.name, - })) - .filter((conflict) => opts.reservedIps.includes(conflict.ip)); -} - -function extractIpv4Address(opts: { readonly raw: string }): string { - return opts.raw.split("/")[0] ?? ""; -} - -type IngressContainerRecord = { - readonly name: string; - readonly ipv4AddressRaw: string; -}; - -function parseDockerNetworkInspect(opts: { - readonly stdout: string; -}): readonly unknown[] | null { let parsed: unknown; try { - parsed = JSON.parse(opts.stdout); + parsed = JSON.parse(inspect.stdout); } catch { return null; } - return Array.isArray(parsed) ? parsed : null; -} + if (!Array.isArray(parsed)) { + return null; + } -function extractIngressContainerRecords(opts: { - readonly inspect: readonly unknown[]; -}): IngressContainerRecord[] { - const records: IngressContainerRecord[] = []; + let subnet: string | null = null; + let gateway: string | null = null; + const usedIps = new Set(); + const containerIpByName = new Map(); + for (const entry of parsed) { + if (!entry || typeof entry !== "object") { + continue; + } + if (subnet === null) { + const ipamConfig = ( + entry as { + IPAM?: { + Config?: Array<{ Subnet?: unknown; Gateway?: unknown }>; + }; + } + ).IPAM?.Config; + if (Array.isArray(ipamConfig)) { + for (const config of ipamConfig) { + if (subnet === null && typeof config?.Subnet === "string") { + subnet = config.Subnet; + } + if (gateway === null && typeof config?.Gateway === "string") { + gateway = config.Gateway; + } + if (subnet !== null && gateway !== null) { + break; + } + } + } + } - for (const entry of opts.inspect) { - const containers = readInspectContainers({ entry }); - if (!containers) { + const containers = (entry as { Containers?: Record }) + .Containers; + if (!containers || typeof containers !== "object") { continue; } for (const info of Object.values(containers)) { - const record = readInspectContainerRecord({ info }); - if (!record) { + if (!info || typeof info !== "object") { + continue; + } + const record = info as { Name?: unknown; IPv4Address?: unknown }; + const name = typeof record.Name === "string" ? record.Name : ""; + const ipRaw = + typeof record.IPv4Address === "string" ? record.IPv4Address : ""; + if (!(name && ipRaw)) { + continue; + } + const ip = extractIpv4Address({ raw: ipRaw }); + if (ip.length === 0) { continue; } - records.push(record); + usedIps.add(ip); + containerIpByName.set(name, ip); } } - return records; + return { + subnet, + gateway, + usedIps, + containerIpByName, + }; } -function readInspectContainers(opts: { - readonly entry: unknown; -}): Record | null { - if (!opts.entry || typeof opts.entry !== "object") { +async function reassignIngressIpConflicts(opts: { + readonly conflicts: readonly IngressIpConflict[]; + readonly reservedIps: readonly string[]; +}): Promise { + const snapshot = await inspectIngressNetworkSnapshot(); + const usedIps = new Set(snapshot?.usedIps ?? []); + const reservedIps = new Set(opts.reservedIps); + const containerIpByName = new Map(snapshot?.containerIpByName ?? []); + const conflictIpsByContainer = new Map( + opts.conflicts.map((conflict) => [conflict.containerName, conflict.ip]) + ); + const containerNames = [ + ...new Set(opts.conflicts.map((conflict) => conflict.containerName)), + ]; + + for (const containerName of containerNames) { + logger.step({ + message: `Reassigning ${containerName} on ${DEFAULT_INGRESS_NETWORK}…`, + }); + + const disconnect = await exec( + [ + "docker", + "network", + "disconnect", + "-f", + DEFAULT_INGRESS_NETWORK, + containerName, + ], + { + stdin: "ignore", + } + ); + if (disconnect.exitCode !== 0) { + logger.warn({ + message: [ + `Failed to disconnect ${containerName} from ${DEFAULT_INGRESS_NETWORK} (exit ${disconnect.exitCode}).`, + trimShellError({ text: disconnect.stderr }), + ].join("\n"), + }); + continue; + } + + const previousIp = + conflictIpsByContainer.get(containerName) ?? + containerIpByName.get(containerName) ?? + null; + if (previousIp !== null) { + usedIps.delete(previousIp); + containerIpByName.delete(containerName); + } + + const desiredIp = pickAvailableIngressIp({ + subnet: snapshot?.subnet ?? null, + gateway: snapshot?.gateway ?? null, + usedIps, + reservedIps, + }); + + const connectCommand = desiredIp + ? [ + "docker", + "network", + "connect", + "--ip", + desiredIp, + DEFAULT_INGRESS_NETWORK, + containerName, + ] + : [ + "docker", + "network", + "connect", + DEFAULT_INGRESS_NETWORK, + containerName, + ]; + const connect = await exec(connectCommand, { + stdin: "ignore", + }); + if (connect.exitCode !== 0) { + logger.warn({ + message: [ + `Failed to reconnect ${containerName} to ${DEFAULT_INGRESS_NETWORK} (exit ${connect.exitCode}).`, + trimShellError({ text: connect.stderr }), + ].join("\n"), + }); + continue; + } + + if (desiredIp) { + usedIps.add(desiredIp); + containerIpByName.set(containerName, desiredIp); + } + } +} + +function pickAvailableIngressIp(opts: { + readonly subnet: string | null; + readonly gateway: string | null; + readonly usedIps: ReadonlySet; + readonly reservedIps: ReadonlySet; +}): string | null { + if (!opts.subnet) { + return null; + } + const cidr = parseIpv4Cidr(opts.subnet); + if (!cidr) { + return null; + } + if (cidr.prefix >= 31) { + return null; + } + + const hostCapacity = 2 ** (32 - cidr.prefix); + const firstHost = cidr.network + 1; + const lastHost = cidr.network + hostCapacity - 2; + const maxCandidates = Math.min(4096, Math.max(0, lastHost - firstHost + 1)); + + for (let offset = 0; offset < maxCandidates; offset += 1) { + const candidate = intToIpv4(firstHost + offset); + if (candidate === opts.gateway) { + continue; + } + if (opts.reservedIps.has(candidate)) { + continue; + } + if (opts.usedIps.has(candidate)) { + continue; + } + return candidate; + } + return null; +} + +function parseIpv4Cidr( + cidr: string +): { readonly network: number; readonly prefix: number } | null { + const [ipText, prefixText] = cidr.split("/"); + if (!(ipText && prefixText)) { return null; } - const containers = (opts.entry as { Containers?: unknown }).Containers; - if (!containers || typeof containers !== "object") { + const prefix = Number.parseInt(prefixText, 10); + if (!Number.isFinite(prefix) || prefix < 0 || prefix > 32) { return null; } - return containers as Record; + const ip = ipv4ToInt(ipText); + if (ip === null) { + return null; + } + + let mask = 0; + if (prefix === 0) { + mask = 0; + } else if (prefix === 32) { + mask = 0xff_ff_ff_ff; + } else { + mask = (0xff_ff_ff_ff << (32 - prefix)) >>> 0; + } + + return { + network: (ip & mask) >>> 0, + prefix, + }; } -function readInspectContainerRecord(opts: { - readonly info: unknown; -}): IngressContainerRecord | null { - if (!opts.info || typeof opts.info !== "object") { +function ipv4ToInt(ip: string): number | null { + const octets = ip.split("."); + if (octets.length !== 4) { return null; } - const record = opts.info as { Name?: unknown; IPv4Address?: unknown }; - const name = typeof record.Name === "string" ? record.Name : ""; - const ipv4AddressRaw = - typeof record.IPv4Address === "string" ? record.IPv4Address : ""; - if (!(name.length > 0 && ipv4AddressRaw.length > 0)) { + const numbers = octets.map((octet) => Number.parseInt(octet, 10)); + if ( + numbers.some((octet) => !Number.isFinite(octet) || octet < 0 || octet > 255) + ) { return null; } - return { name, ipv4AddressRaw }; + const a = numbers[0] ?? 0; + const b = numbers[1] ?? 0; + const c = numbers[2] ?? 0; + const d = numbers[3] ?? 0; + return (((a << 24) >>> 0) | (b << 16) | (c << 8) | d) >>> 0; +} + +function intToIpv4(value: number): string { + return [ + (value >>> 24) & 255, + (value >>> 16) & 255, + (value >>> 8) & 255, + value & 255, + ].join("."); +} + +function extractIpv4Address(opts: { readonly raw: string }): string { + return opts.raw.split("/")[0] ?? ""; } function isGlobalProxyContainer(opts: { readonly name: string }): boolean { @@ -873,6 +1107,11 @@ function renderIngressConflictMessage(opts: { return lines.join("\n"); } +function trimShellError(opts: { readonly text: string }): string { + const trimmed = opts.text.trim(); + return trimmed.length > 0 ? trimmed : "(no stderr output)"; +} + async function globalDown(): Promise { await ensureDockerRunning(); const paths = getGlobalPaths(); @@ -1017,11 +1256,21 @@ type GatewayStatusPayload = { readonly tokens_revoked: number; readonly tokens_write: number; readonly tokens_read: number; + readonly tokens: readonly GatewayTokenPayload[]; readonly gateway_projects?: string; readonly exposures: readonly GatewayExposurePayload[]; readonly warnings: readonly string[]; }; +type GatewayTokenPayload = { + readonly id: string; + readonly scope: "read" | "write"; + readonly label?: string; + readonly created_at: string; + readonly last_used_at?: string; + readonly revoked_at?: string; +}; + async function readComposeStatus( composeFile: string ): Promise { @@ -1102,6 +1351,14 @@ async function collectGatewayStatus(): Promise { const revokedTokens = tokens.filter((token) => token.revokedAt); const writeTokens = activeTokens.filter((token) => token.scope === "write"); const readTokens = activeTokens.filter((token) => token.scope === "read"); + const serializedTokens: GatewayTokenPayload[] = tokens.map((token) => ({ + id: token.id, + scope: token.scope, + ...(token.label ? { label: token.label } : {}), + created_at: token.createdAt, + ...(token.lastUsedAt ? { last_used_at: token.lastUsedAt } : {}), + ...(token.revokedAt ? { revoked_at: token.revokedAt } : {}), + })); const payload: GatewayStatusPayload = { config_path: configPath, @@ -1115,6 +1372,7 @@ async function collectGatewayStatus(): Promise { tokens_revoked: revokedTokens.length, tokens_write: writeTokens.length, tokens_read: readTokens.length, + tokens: serializedTokens, exposures, warnings: gatewayResolution.warnings, }; @@ -2087,12 +2345,21 @@ async function ensureMacDnsmasqRunning(): Promise { : "dnsmasq is not started; starting it as root so it can bind :53", }); - const exit = await run(["sudo", "brew", "services", "restart", "dnsmasq"], { - stdin: "inherit", + const interactive = process.stdin.isTTY && process.stdout.isTTY; + const sudoCommand = interactive + ? ["sudo", "brew", "services", "restart", "dnsmasq"] + : ["sudo", "-n", "brew", "services", "restart", "dnsmasq"]; + const exit = await run(sudoCommand, { + stdin: interactive ? "inherit" : "ignore", }); if (exit !== 0) { logger.warn({ - message: `Failed to start dnsmasq (exit ${exit}). *.${DEFAULT_PROJECT_TLD} may not resolve.`, + message: interactive + ? `Failed to start dnsmasq (exit ${exit}). *.${DEFAULT_PROJECT_TLD} may not resolve.` + : [ + `Failed to start dnsmasq without interactive sudo (exit ${exit}).`, + "Open a terminal and run: hack global up", + ].join("\n"), }); } } diff --git a/src/commands/session.ts b/src/commands/session.ts index 5e9f9e07..5d1a7a71 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -7,31 +7,9 @@ import type { } from "../cli/command.ts"; import { defineCommand, defineOption, withHandler } from "../cli/command.ts"; import { optJson, optPretty } from "../cli/options.ts"; -import { - PROJECT_COMPOSE_FILENAME, - PROJECT_CONFIG_FILENAME, - PROJECT_ENV_FILENAME, -} from "../constants.ts"; -import { type ProjectContext, sanitizeBranchSlug } from "../lib/project.ts"; import type { RegisteredProject } from "../lib/projects-registry.ts"; import { readProjectsRegistry } from "../lib/projects-registry.ts"; import { exec, run } from "../lib/shell.ts"; -import type { MuxBackendName, MuxSession } from "../mux/mux-backend.ts"; -import { - listMuxSessions, - resolveDefaultBackendName, - resolveMux, -} from "../mux/mux-resolver.ts"; -import { - buildSessionName, - getNextNumericSessionSuffix, - parseSessionBase, -} from "../mux/session-names.ts"; -import { attachTmuxSession, createTmuxBackend } from "../mux/tmux-backend.ts"; -import { - attachZellijSession, - createZellijBackend, -} from "../mux/zellij-backend.ts"; import { logger } from "../ui/logger.ts"; import { buildSessionPanesEndEvent, @@ -44,13 +22,18 @@ import { buildSessionStreamStartEvent, diffNewLines, parseTmuxPanesOutput, - type SessionStreamContext, splitLines, writeSessionStreamEvent, } from "./session-utils.ts"; -const tmuxBackend = createTmuxBackend(); -const zellijBackend = createZellijBackend(); +/** + * Parsed tmux session info. + */ +interface TmuxSession { + readonly name: string; + readonly attached: boolean; + readonly path: string | null; +} const optUp = defineOption({ name: "up", @@ -73,6 +56,14 @@ const optName = defineOption({ description: "Custom suffix for new session (e.g., agent-1)", } as const); +const optDetach = defineOption({ + name: "detach", + type: "boolean", + long: "--detach", + short: "-d", + description: "Create/switch session without attaching (for GUI/non-TTY use)", +} as const); + const optTarget = defineOption({ name: "target", type: "string", @@ -111,7 +102,7 @@ const optMaxMs = defineOption({ // Subcommand specs const listSpec = defineCommand({ name: "list", - summary: "List active sessions", + summary: "List active tmux sessions", group: "Project", options: [], positionals: [], @@ -122,7 +113,7 @@ const startSpec = defineCommand({ name: "start", summary: "Start or attach to a session for a project", group: "Project", - options: [optUp, optNew, optName], + options: [optUp, optNew, optName, optDetach], positionals: [ { name: "project", description: "Project name or path", required: false }, ], @@ -131,7 +122,7 @@ const startSpec = defineCommand({ const stopSpec = defineCommand({ name: "stop", - summary: "Stop (kill) a session", + summary: "Stop (kill) a tmux session", group: "Project", options: [], positionals: [ @@ -142,7 +133,7 @@ const stopSpec = defineCommand({ const attachSpec = defineCommand({ name: "attach", - summary: "Attach to an existing session", + summary: "Attach to an existing tmux session", group: "Project", options: [], positionals: [ @@ -153,7 +144,7 @@ const attachSpec = defineCommand({ const execSpec = defineCommand({ name: "exec", - summary: "Execute a command in a session", + summary: "Execute a command in a tmux session", group: "Project", options: [], positionals: [ @@ -235,295 +226,186 @@ type TailArgs = CommandArgs< * Uses clack prompts with grouped options for sessions and projects. */ async function handleSessionPicker(): Promise { - const mux = await resolveMux({ project: null }); - const sessions = await listMuxSessions({ - mode: mux.mode, - backends: mux.backends, - }); + const sessions = await listTmuxSessions(); const registry = await readProjectsRegistry(); const projects = registry.projects; p.intro("Sessions"); - const options = buildSessionPickerOptions({ - sessions, - projects, - home: process.env.HOME ?? "", - }); - - if (options.length === 0) { - p.log.warn( - "No sessions or projects found. Run 'hack init' in a project first." - ); - p.outro(""); - return 1; - } - - const selection = await p.select({ - message: "Select session or project", - options, - }); - - if (p.isCancel(selection)) { - p.outro("Cancelled"); - return 0; - } - - const parsed = parseSessionPickerSelection({ selection }); - if (!parsed) { - p.log.error("Invalid selection"); - return 1; - } - - if (parsed.kind === "session") { - return await handlePickedSession({ - selection: parsed, - sessions, - projects, - }); - } + const sessionNames = new Set(sessions.map((s) => s.name)); + const home = process.env.HOME ?? ""; - const project = projects.find( - (proj: RegisteredProject) => proj.name === parsed.name - ); - if (!project) { - p.log.error(`Project not found: ${parsed.name}`); - return 1; - } - - return await startProjectSession({ - project, - forceNew: false, - runUp: false, - customSuffix: null, - }); -} - -type SessionPickerOption = { - readonly value: string; - readonly label: string; - readonly hint?: string; -}; - -type SessionPickerSelection = - | { - readonly kind: "session"; - readonly backend: MuxBackendName; - readonly name: string; - } - | { readonly kind: "project"; readonly name: string }; - -function buildSessionPickerOptions(opts: { - readonly sessions: readonly MuxSession[]; - readonly projects: readonly RegisteredProject[]; - readonly home: string; -}): SessionPickerOption[] { - const sessionNames = new Set(opts.sessions.map((s) => s.name)); + // Helper to shorten paths with ~/ const shortenPath = (path: string): string => { - if (opts.home && path.startsWith(opts.home)) { - return `~${path.slice(opts.home.length)}`; + if (home && path.startsWith(home)) { + return `~${path.slice(home.length)}`; } return path; }; - const options: SessionPickerOption[] = []; + // Build options for clack select + type SessionOption = { + value: string; + label: string; + hint?: string; + }; - const attachedSessions = opts.sessions.filter((s) => s.attached === true); - const detachedSessions = opts.sessions.filter((s) => s.attached !== true); + const options: SessionOption[] = []; + + // Active sessions + const attachedSessions = sessions.filter((s) => s.attached); + const detachedSessions = sessions.filter((s) => !s.attached); for (const session of attachedSessions) { options.push({ - value: `session:${session.backend}:${session.name}`, + value: `session:${session.name}`, label: session.name, - hint: formatSessionHint({ - backend: session.backend, - status: "attached", - path: session.path ? shortenPath(session.path) : null, - }), + hint: `attached${session.path ? ` • ${shortenPath(session.path)}` : ""}`, }); } for (const session of detachedSessions) { - const status = session.attached === false ? "detached" : "unknown"; options.push({ - value: `session:${session.backend}:${session.name}`, + value: `session:${session.name}`, label: session.name, - hint: formatSessionHint({ - backend: session.backend, - status, - path: session.path ? shortenPath(session.path) : null, - }), + hint: session.path ? shortenPath(session.path) : "detached", }); } - for (const project of opts.projects) { - const base = project.name; - const hasSessions = [...sessionNames].some( - (name) => name === base || name.startsWith(`${base}--`) - ); + // Projects without active sessions + const availableProjects = projects.filter( + (proj: RegisteredProject) => !sessionNames.has(proj.name) + ); + + for (const project of availableProjects) { options.push({ value: `project:${project.name}`, label: project.name, - hint: `${hasSessions ? "sessions" : "new"} • ${shortenPath(project.repoRoot)}`, + hint: `new • ${shortenPath(project.repoRoot)}`, }); } - return options; -} - -function formatSessionHint(opts: { - readonly backend: MuxBackendName; - readonly status: "attached" | "detached" | "unknown"; - readonly path: string | null; -}): string | undefined { - const parts = [opts.backend, opts.status, opts.path].filter( - (part): part is string => typeof part === "string" && part.length > 0 - ); - return parts.length > 0 ? parts.join(" • ") : undefined; -} - -function parseSessionPickerSelection(opts: { - readonly selection: string; -}): SessionPickerSelection | null { - const [kind, a, ...rest] = opts.selection.split(":"); - - if (kind === "project") { - const name = [a, ...rest].join(":"); - return name.length > 0 ? { kind: "project", name } : null; + if (options.length === 0) { + p.log.warn( + "No sessions or projects found. Run 'hack init' in a project first." + ); + p.outro(""); + return 1; } - if (kind === "session") { - const backend = - a === "tmux" || a === "zellij" ? (a as MuxBackendName) : null; - const name = rest.join(":"); - if (!backend || name.length === 0) { - return null; - } - return { kind: "session", backend, name }; + const selection = await p.select({ + message: "Select session or project", + options, + }); + + if (p.isCancel(selection)) { + p.outro("Cancelled"); + return 0; } - return null; -} + // Parse selection + const [type, ...rest] = selection.split(":"); + const name = rest.join(":"); // Handle names with colons like "project:2" -async function handlePickedSession(opts: { - readonly selection: Extract; - readonly sessions: readonly MuxSession[]; - readonly projects: readonly RegisteredProject[]; -}): Promise { - const session = - opts.sessions.find( - (s) => - s.name === opts.selection.name && s.backend === opts.selection.backend - ) ?? null; - if (!session) { - p.log.error(`Session not found: ${opts.selection.name}`); + if (!name) { + p.log.error("Invalid selection"); return 1; } - const handled = await maybeHandleAttachedTmuxSession({ - session, - sessions: opts.sessions, - projects: opts.projects, - }); - if (handled !== null) { - return handled; - } + if (type === "session") { + const session = sessions.find((s) => s.name === name); - return await attachToSession({ - backend: opts.selection.backend, - name: opts.selection.name, - }); -} + // If session is attached elsewhere, offer choice + if (session?.attached) { + const nextNum = getNextSessionNumber(sessions, name); -async function maybeHandleAttachedTmuxSession(opts: { - readonly session: MuxSession; - readonly sessions: readonly MuxSession[]; - readonly projects: readonly RegisteredProject[]; -}): Promise { - if (!(opts.session.backend === "tmux" && opts.session.attached === true)) { - return null; - } + const action = await p.select({ + message: `Session '${name}' is attached elsewhere`, + options: [ + { value: "attach", label: "Attach", hint: "detaches other clients" }, + { value: "new", label: "Create new", hint: `${name}:${nextNum}` }, + ], + }); - const base = parseSessionBase({ name: opts.session.name }); - const nextNum = getNextNumericSessionSuffix({ - sessions: opts.sessions, - base, - }); - const newName = buildSessionName({ base, suffix: String(nextNum) }); + if (p.isCancel(action)) { + p.outro("Cancelled"); + return 0; + } + + if (action === "new") { + const project = projects.find( + (proj: RegisteredProject) => proj.name === name + ); + const cwd = project?.repoRoot ?? session.path ?? process.cwd(); + return await createAndAttachSession({ + name: `${name}:${nextNum}`, + cwd, + }); + } + } - const action = await p.select({ - message: `Session '${opts.session.name}' is attached elsewhere`, - options: [ - { value: "attach", label: "Attach", hint: "detaches other clients" }, - { value: "new", label: "Create new", hint: newName }, - ], - }); - if (p.isCancel(action)) { - p.outro("Cancelled"); - return 0; - } - if (action !== "new") { - return null; + return await attachToSession(name); } - const project = opts.projects.find( - (proj: RegisteredProject) => proj.name === base + // Create new session for project + const project = projects.find( + (proj: RegisteredProject) => proj.name === name ); - const cwd = project?.repoRoot ?? opts.session.path ?? process.cwd(); + if (!project) { + p.log.error(`Project not found: ${name}`); + return 1; + } return await createAndAttachSession({ - backend: opts.session.backend, - name: newName, - cwd, + name: project.name, + cwd: project.repoRoot, }); } -function buildProjectContext(project: RegisteredProject): ProjectContext { - return { - projectRoot: project.repoRoot, - projectDirName: project.projectDirName, - projectDir: project.projectDir, - composeFile: resolve(project.projectDir, PROJECT_COMPOSE_FILENAME), - envFile: resolve(project.projectDir, PROJECT_ENV_FILENAME), - configFile: resolve(project.projectDir, PROJECT_CONFIG_FILENAME), - }; +/** + * Get the next available session number for a base name. + */ +function getNextSessionNumber( + sessions: TmuxSession[], + baseName: string +): number { + const existing = sessions.filter( + (s) => s.name === baseName || s.name.startsWith(`${baseName}:`) + ); + let n = 2; + while (existing.some((s) => s.name === `${baseName}:${n}`)) { + n++; + } + return n; } const handleList: CommandHandlerFor< typeof listSpec > = async (): Promise => { - const mux = await resolveMux({ project: null }); - const sessions = await listMuxSessions({ - mode: mux.mode, - backends: mux.backends, - }); + const sessions = await listTmuxSessions(); const registry = await readProjectsRegistry(); const projects = registry.projects; if (sessions.length === 0) { - logger.info({ message: "No active sessions" }); + logger.info({ message: "No active tmux sessions" }); return 0; } console.log( - `${"Session".padEnd(26) + "Backend".padEnd(10) + "Project".padEnd(20)}Status` + `${"Session".padEnd(20) + "Project".padEnd(20) + "Node".padEnd(10)}Status` ); console.log("-".repeat(60)); for (const session of sessions) { - const base = parseSessionBase({ name: session.name }); - const project = projects.find((p: RegisteredProject) => p.name === base); + const project = projects.find( + (p: RegisteredProject) => p.name === session.name + ); const projectName = project?.name ?? "-"; - let status = "unknown"; - if (session.attached === true) { - status = "attached"; - } else if (session.attached === false) { - status = "detached"; - } + const status = session.attached ? "attached" : "detached"; console.log( - session.name.padEnd(26) + - session.backend.padEnd(10) + + session.name.padEnd(20) + projectName.padEnd(20) + + "local".padEnd(10) + status ); } @@ -541,6 +423,7 @@ const handleStart = async ({ const forceNew = args.options.new === true; const runUp = args.options.up === true; const customName = args.options.name; + const detach = args.options.detach === true; // Find project const registry = await readProjectsRegistry(); @@ -572,11 +455,61 @@ const handleStart = async ({ return 1; } - return await startProjectSession({ - project, - forceNew, - runUp, - customSuffix: typeof customName === "string" ? customName : null, + const baseName = project.name; + let sessionName = baseName; + + if (forceNew || customName) { + if (customName) { + sessionName = `${baseName}:${customName}`; + } else { + // Find next available number + const sessions = await listTmuxSessions(); + const existing = sessions.filter( + (s) => s.name === baseName || s.name.startsWith(`${baseName}:`) + ); + if (existing.length > 0) { + let n = 2; + while (existing.some((s) => s.name === `${baseName}:${n}`)) { + n++; + } + sessionName = `${baseName}:${n}`; + } + } + } else { + // Check if session exists + const sessions = await listTmuxSessions(); + const existing = sessions.find((s) => s.name === baseName); + if (existing) { + if (detach) { + logger.info({ message: `Session ready: ${baseName}` }); + } else { + logger.info({ message: `Attaching to existing session: ${baseName}` }); + } + if (runUp) { + await runHackUp(project.projectDir); + } + if (detach) { + return 0; + } + return await attachToSession(baseName); + } + } + + // Run hack up if requested + if (runUp) { + await runHackUp(project.repoRoot); + } + + // Use repoRoot (project root), not projectDir (.hack/) + if (detach) { + return await createSessionDetached({ + name: sessionName, + cwd: project.repoRoot, + }); + } + return await createAndAttachSession({ + name: sessionName, + cwd: project.repoRoot, }); }; @@ -588,14 +521,9 @@ const handleStop = async ({ }): Promise => { const sessionName = args.positionals.session; - const session = await findSession({ name: sessionName }); - if (!session) { - logger.error({ message: `Session not found: ${sessionName}` }); - return 1; - } - - const backend = session.backend === "tmux" ? tmuxBackend : zellijBackend; - const result = await backend.killSession({ name: sessionName }); + const result = await exec(["tmux", "kill-session", "-t", sessionName], { + stdin: "ignore", + }); if (result.exitCode !== 0) { logger.error({ message: `Failed to stop session: ${sessionName}` }); return 1; @@ -612,12 +540,7 @@ const handleAttach = async ({ readonly args: AttachArgs; }): Promise => { const sessionName = args.positionals.session; - const session = await findSession({ name: sessionName }); - if (!session) { - logger.error({ message: `Session not found: ${sessionName}` }); - return 1; - } - return await attachToSession({ backend: session.backend, name: sessionName }); + return await attachToSession(sessionName); }; const handleExec = async ({ @@ -629,20 +552,21 @@ const handleExec = async ({ const sessionName = args.positionals.session; const command = args.positionals.command; - const session = await findSession({ name: sessionName }); - if (!session) { - logger.error({ message: `Session not found: ${sessionName}` }); - return 1; - } + const result = await exec( + ["tmux", "send-keys", "-t", sessionName, command, "Enter"], + { + stdin: "ignore", + } + ); - const backend = session.backend === "tmux" ? tmuxBackend : zellijBackend; - const result = await backend.execInSession({ name: sessionName, command }); if (result.exitCode !== 0) { - logger.error({ message: `Failed to execute in session: ${sessionName}` }); + logger.error({ + message: `Failed to send command to session: ${sessionName}`, + }); return 1; } - logger.success({ message: `Executed in ${sessionName}: ${command}` }); + logger.success({ message: `Sent command to ${sessionName}: ${command}` }); return 0; }; @@ -653,17 +577,6 @@ const handlePanes = async ({ readonly args: PanesArgs; }): Promise => { const sessionName = args.positionals.session; - const session = await findSession({ name: sessionName }); - if (!session) { - process.stderr.write(`Session not found: ${sessionName}\n`); - return 1; - } - if (session.backend !== "tmux") { - process.stderr.write( - `Session panes are only supported for tmux sessions (got ${session.backend}).\n` - ); - return 1; - } const pretty = args.options.pretty === true; const json = args.options.json === true || !pretty; @@ -723,17 +636,6 @@ const handleCapture = async ({ readonly args: CaptureArgs; }): Promise => { const sessionName = args.positionals.session; - const session = await findSession({ name: sessionName }); - if (!session) { - process.stderr.write(`Session not found: ${sessionName}\n`); - return 1; - } - if (session.backend !== "tmux") { - process.stderr.write( - `Session capture is only supported for tmux sessions (got ${session.backend}).\n` - ); - return 1; - } const target = args.options.target ?? (await resolveActiveTarget(sessionName)); const lines = args.options.lines ?? 200; @@ -797,207 +699,101 @@ const handleTail = async ({ readonly args: TailArgs; }): Promise => { const sessionName = args.positionals.session; - const resolved = await resolveTailSession({ sessionName }); - if (!resolved.ok) { - process.stderr.write(`${resolved.error}\n`); - return 1; - } - - const outputMode = resolveTailOutputMode({ - json: args.options.json === true, - pretty: args.options.pretty === true, - }); - if (!outputMode.ok) { - process.stderr.write(`${outputMode.error}\n`); - return 1; - } - const target = args.options.target ?? (await resolveActiveTarget(sessionName)); const lines = args.options.lines ?? 200; const intervalMs = args.options.intervalMs ?? 500; const maxMs = args.options.maxMs ?? 5000; + const pretty = args.options.pretty === true; + const json = args.options.json === true || !pretty; - return await runTailStream({ - sessionName, - target, - lines, - intervalMs, - maxMs, - json: outputMode.json, - }); -}; - -async function resolveTailSession(opts: { - readonly sessionName: string; -}): Promise< - { readonly ok: true } | { readonly ok: false; readonly error: string } -> { - const session = await findSession({ name: opts.sessionName }); - if (!session) { - return { ok: false, error: `Session not found: ${opts.sessionName}` }; - } - - if (session.backend !== "tmux") { - return { - ok: false, - error: `Session tail is only supported for tmux sessions (got ${session.backend}).`, - }; - } - - return { ok: true }; -} - -function resolveTailOutputMode(opts: { - readonly json: boolean; - readonly pretty: boolean; -}): - | { readonly ok: true; readonly json: boolean } - | { readonly ok: false; readonly error: string } { - const json = opts.json || !opts.pretty; - if (json && opts.pretty) { - return { ok: false, error: "Cannot combine --json with --pretty." }; + if (json && pretty) { + process.stderr.write("Cannot combine --json with --pretty.\n"); + return 1; } - return { ok: true, json }; -} - -async function runTailStream(opts: { - readonly sessionName: string; - readonly target: string; - readonly lines: number; - readonly intervalMs: number; - readonly maxMs: number; - readonly json: boolean; -}): Promise { const context = { - session: opts.sessionName, - target: opts.target, - lines: opts.lines, + session: sessionName, + target, + lines, follow: true, - intervalMs: opts.intervalMs, - maxMs: opts.maxMs, + intervalMs, + maxMs, }; - if (opts.json) { + if (json) { writeSessionStreamEvent({ event: buildSessionStreamStartEvent({ context }), }); } - const initial = await capturePaneOrError({ - sessionName: opts.sessionName, - target: opts.target, - lines: opts.lines, - }); - if (!initial.ok) { - return renderTailCaptureError({ - context, - json: opts.json, - message: initial.error, - }); + const initial = await capturePane({ target, lines }); + if (initial.exitCode !== 0) { + const message = initial.stderr || `Failed to capture ${sessionName}`; + if (json) { + writeSessionStreamEvent({ + event: buildSessionStreamErrorEvent({ context, message }), + }); + writeSessionStreamEvent({ + event: buildSessionStreamEndEvent({ context, reason: "error" }), + }); + } else { + console.error(message); + } + return 1; } let lastOutput = initial.stdout; const start = Date.now(); - while (Date.now() - start < opts.maxMs) { - await delay(opts.intervalMs); - - const result = await capturePaneOrError({ - sessionName: opts.sessionName, - target: opts.target, - lines: opts.lines, - }); - if (!result.ok) { - return renderTailCaptureError({ - context, - json: opts.json, - message: result.error, - }); + while (Date.now() - start < maxMs) { + await delay(intervalMs); + + const result = await capturePane({ target, lines }); + if (result.exitCode !== 0) { + const message = result.stderr || `Failed to capture ${sessionName}`; + if (json) { + writeSessionStreamEvent({ + event: buildSessionStreamErrorEvent({ context, message }), + }); + writeSessionStreamEvent({ + event: buildSessionStreamEndEvent({ context, reason: "error" }), + }); + } else { + console.error(message); + } + return 1; } - const suffix = diffNewLines({ previous: lastOutput, next: result.stdout }); + const nextOutput = result.stdout; + const suffix = diffNewLines({ previous: lastOutput, next: nextOutput }); if (suffix) { - writeTailOutput({ - json: opts.json, - context, - output: suffix, - }); + if (json) { + for (const line of splitLines(suffix)) { + writeSessionStreamEvent({ + event: buildSessionStreamLogEvent({ context, line }), + }); + } + } else { + process.stdout.write(suffix); + } } - lastOutput = result.stdout; + lastOutput = nextOutput; } - if (opts.json) { + if (json) { writeSessionStreamEvent({ event: buildSessionStreamEndEvent({ context, reason: "timeout" }), }); } return 0; -} - -async function capturePaneOrError(opts: { - readonly sessionName: string; - readonly target: string; - readonly lines: number; -}): Promise< - | { readonly ok: true; readonly stdout: string } - | { readonly ok: false; readonly error: string } -> { - const result = await capturePane({ target: opts.target, lines: opts.lines }); - if (result.exitCode !== 0) { - const message = result.stderr || `Failed to capture ${opts.sessionName}`; - return { ok: false, error: message }; - } - return { ok: true, stdout: result.stdout }; -} - -function renderTailCaptureError(opts: { - readonly context: SessionStreamContext; - readonly json: boolean; - readonly message: string; -}): number { - if (opts.json) { - writeSessionStreamEvent({ - event: buildSessionStreamErrorEvent({ - context: opts.context, - message: opts.message, - }), - }); - writeSessionStreamEvent({ - event: buildSessionStreamEndEvent({ - context: opts.context, - reason: "error", - }), - }); - } else { - console.error(opts.message); - } - return 1; -} - -function writeTailOutput(opts: { - readonly json: boolean; - readonly context: SessionStreamContext; - readonly output: string; -}): void { - if (opts.json) { - for (const line of splitLines(opts.output)) { - writeSessionStreamEvent({ - event: buildSessionStreamLogEvent({ context: opts.context, line }), - }); - } - return; - } - - process.stdout.write(opts.output); -} +}; export const sessionCommand = defineCommand({ name: "session", - summary: "Manage terminal sessions for hack projects", + summary: "Manage tmux sessions for hack projects", group: "Project", options: [], positionals: [], @@ -1081,239 +877,121 @@ function delay(ms: number): Promise { }); } -function resolveBackend(backend: MuxBackendName) { - return backend === "tmux" ? tmuxBackend : zellijBackend; -} - -async function listAllSessions(): Promise { - const out: MuxSession[] = []; - if (tmuxBackend.available) { - out.push(...(await tmuxBackend.listSessions())); - } - if (zellijBackend.available) { - out.push(...(await zellijBackend.listSessions())); - } - return out; -} - -async function findSession(opts: { - readonly name: string; -}): Promise { - const sessions = await listAllSessions(); - return sessions.find((s) => s.name === opts.name) ?? null; -} - -async function attachToSession(opts: { - readonly backend: MuxBackendName; - readonly name: string; -}): Promise { - if (opts.backend === "tmux") { - return await attachTmuxSession({ name: opts.name, run }); - } - return await attachZellijSession({ - name: opts.name, - createIfMissing: false, - run, +/** + * List all tmux sessions. + */ +async function listTmuxSessions(): Promise { + const separator = "|||HACK_SESSION_FIELD|||"; + const format = [ + "#{session_name}", + "#{session_attached}", + "#{session_path}", + ].join(separator); + const result = await exec(["tmux", "list-sessions", "-F", format], { + stdin: "ignore", }); -} -async function createAndAttachSession(opts: { - readonly backend: MuxBackendName; - readonly name: string; - readonly cwd: string; -}): Promise { - const backend = resolveBackend(opts.backend); - if (!backend.available) { - logger.error({ message: `${opts.backend} is not available` }); - return 1; + if (result.exitCode !== 0) { + return []; } - const create = await backend.createSession({ - name: opts.name, - cwd: opts.cwd, - }); - if (!create.ok) { - logger.error({ - message: `Failed to create session: ${opts.name}`, - fields: { error: create.error }, - }); - if (create.stderr) { - logger.error({ message: create.stderr }); + const sessions: TmuxSession[] = []; + for (const line of result.stdout.split("\n")) { + if (!line.trim()) { + continue; + } + const fields = parseTmuxSessionFields(line, separator, 3); + if (!fields) { + continue; + } + const [name, attached, path] = fields; + if (name) { + sessions.push({ + name, + attached: attached === "1", + path: path || null, + }); } - return 1; } - logger.info({ message: `Created session: ${opts.name}` }); - return await attachToSession({ backend: opts.backend, name: opts.name }); + return sessions; } -async function startProjectSession(opts: { - readonly project: RegisteredProject; - readonly forceNew: boolean; - readonly runUp: boolean; - readonly customSuffix: string | null; -}): Promise { - const ctx = buildProjectContext(opts.project); - const mux = await resolveMux({ project: ctx }); - - if (mux.mode === "none") { - logger.error({ - message: - "Sessions are disabled (sessions.mux=none). Set sessions.mux to auto|tmux|zellij to enable.", - }); - return 1; +function parseTmuxSessionFields( + line: string, + separator: string, + expectedCount: number +): readonly string[] | null { + const bySeparator = line.split(separator); + if (bySeparator.length === expectedCount) { + return bySeparator; } - - const sessions = await listMuxSessions({ - mode: mux.mode, - backends: mux.backends, - }); - const baseName = opts.project.name; - - const baseSession = sessions.find((s) => s.name === baseName) ?? null; - - const desiredName = resolveDesiredSessionName({ - baseName, - sessions, - baseSession, - forceNew: opts.forceNew, - customSuffix: opts.customSuffix, - }); - if (!desiredName.ok) { - logger.error({ message: desiredName.error }); - return 1; - } - - const defaultBackend = resolveDefaultBackendName({ - mode: mux.mode, - backends: mux.backends, - }); - const backend = resolveProjectSessionBackend({ - mode: mux.mode, - backends: mux.backends, - baseSession, - defaultBackend, - }); - if (!backend.ok) { - logger.error({ message: backend.error }); - return 1; + const byTab = line.split("\t"); + if (byTab.length === expectedCount) { + return byTab; } + return null; +} - if (opts.runUp) { - await runHackUp(opts.project.repoRoot); - } +/** + * Attach to or switch to an existing tmux session. + * Uses switch-client when already inside tmux to avoid nesting. + * Uses -d to detach other clients (avoids size conflicts from different terminals). + */ +async function attachToSession(name: string): Promise { + const insideTmux = Boolean(process.env.TMUX); - const existing = - sessions.find( - (s) => s.backend === backend.value && s.name === desiredName.value - ) ?? null; - if ( - existing && - shouldAttachToExistingProjectSession({ - baseName, - desiredName: desiredName.value, - forceNew: opts.forceNew, - customSuffix: opts.customSuffix, - }) - ) { - logger.info({ - message: `Attaching to existing session: ${desiredName.value}`, - }); - return await attachToSession({ - backend: backend.value, - name: desiredName.value, + if (insideTmux) { + // Already in tmux - switch to the session instead of nesting + const exitCode = await run(["tmux", "switch-client", "-t", name], { + stdin: "inherit", }); + return exitCode; } - return await createAndAttachSession({ - backend: backend.value, - name: desiredName.value, - cwd: opts.project.repoRoot, + // Outside tmux - attach with -d to detach other clients + const exitCode = await run(["tmux", "attach", "-d", "-t", name], { + stdin: "inherit", }); + return exitCode; } -type ParseResult = - | { readonly ok: true; readonly value: T } - | { readonly ok: false; readonly error: string }; - -function resolveDesiredSessionName(opts: { - readonly baseName: string; - readonly sessions: readonly MuxSession[]; - readonly baseSession: MuxSession | null; - readonly forceNew: boolean; - readonly customSuffix: string | null; -}): ParseResult { - if (opts.customSuffix) { - const suffix = sanitizeBranchSlug(opts.customSuffix); - if (suffix.length === 0) { - return { ok: false, error: "Invalid --name (empty after sanitization)." }; - } - return { - ok: true, - value: buildSessionName({ base: opts.baseName, suffix }), - }; - } - - if (!opts.forceNew) { - return { ok: true, value: opts.baseName }; - } +/** + * Create a new tmux session and attach/switch to it. + */ +async function createAndAttachSession(opts: { + readonly name: string; + readonly cwd: string; +}): Promise { + const createExitCode = await createSessionDetached({ + name: opts.name, + cwd: opts.cwd, + }); - if (!opts.baseSession) { - return { ok: true, value: opts.baseName }; + if (createExitCode !== 0) { + return createExitCode; } - const n = getNextNumericSessionSuffix({ - sessions: opts.sessions, - base: opts.baseName, - }); - return { - ok: true, - value: buildSessionName({ base: opts.baseName, suffix: String(n) }), - }; + // Switch or attach depending on context (attachToSession handles this) + return await attachToSession(opts.name); } -function resolveProjectSessionBackend(opts: { - readonly mode: Awaited>["mode"]; - readonly backends: Awaited>["backends"]; - readonly baseSession: MuxSession | null; - readonly defaultBackend: MuxBackendName | null; -}): ParseResult { - const backend: MuxBackendName | null = - opts.baseSession?.backend ?? opts.defaultBackend; - if (backend) { - return { ok: true, value: backend }; - } +async function createSessionDetached(opts: { + readonly name: string; + readonly cwd: string; +}): Promise { + const createResult = await exec( + ["tmux", "new-session", "-d", "-s", opts.name, "-c", opts.cwd], + { stdin: "ignore" } + ); - const available = [ - opts.backends.get("tmux")?.available ? "tmux" : null, - opts.backends.get("zellij")?.available ? "zellij" : null, - ] - .filter((v): v is string => typeof v === "string") - .join(", "); - - if (available.length > 0) { - return { - ok: false, - error: `No session backend available for sessions.mux=${opts.mode}. Available: ${available}`, - }; + if (createResult.exitCode !== 0) { + logger.error({ message: `Failed to create session: ${opts.name}` }); + return 1; } - return { - ok: false, - error: - "No session backend available (install tmux or zellij, or set sessions.mux=none).", - }; -} - -function shouldAttachToExistingProjectSession(opts: { - readonly baseName: string; - readonly desiredName: string; - readonly forceNew: boolean; - readonly customSuffix: string | null; -}): boolean { - return ( - !(opts.forceNew || opts.customSuffix) && opts.desiredName === opts.baseName - ); + logger.info({ message: `Created session: ${opts.name}` }); + return 0; } /** diff --git a/src/commands/ssh.ts b/src/commands/ssh.ts index 2353a954..83dcb64d 100644 --- a/src/commands/ssh.ts +++ b/src/commands/ssh.ts @@ -52,7 +52,6 @@ const optDirect = defineOption({ name: "direct", type: "boolean", long: "--direct", - short: "-d", description: "Use direct SSH (requires --host)", } as const); @@ -412,12 +411,22 @@ async function connectToSession(opts: { readonly port?: number; readonly sessionName: string; }): Promise { - p.log.step(`Connecting to ${opts.sessionName}...`); + const sessionName = opts.sessionName.trim(); + if (!SESSION_NAME_PATTERN.test(sessionName)) { + p.log.error( + "Invalid session name (only letters, numbers, dashes, underscores, or dots)" + ); + return 1; + } + + p.log.step(`Connecting to ${sessionName}...`); console.log(""); // Use login shell to ensure PATH includes homebrew etc. // -d detaches other clients to avoid size conflicts from different terminals - const tmuxCmd = `$SHELL -l -c 'tmux attach -d -t ${opts.sessionName} 2>/dev/null || tmux new -s ${opts.sessionName}'`; + const quotedSessionName = shellQuote({ value: sessionName }); + const tmuxInnerCommand = `tmux attach -d -t ${quotedSessionName} 2>/dev/null || tmux new -s ${quotedSessionName}`; + const tmuxCmd = `$SHELL -l -c ${shellQuote({ value: tmuxInnerCommand })}`; const sshArgs = [ "ssh", @@ -431,30 +440,38 @@ async function connectToSession(opts: { return await run(sshArgs, { stdin: "inherit" }); } +function shellQuote(opts: { readonly value: string }): string { + return `'${opts.value.split("'").join(`'"'"'`)}'`; +} + /** * List all tmux sessions. */ async function listTmuxSessions(): Promise { - const result = await exec( - [ - "tmux", - "list-sessions", - "-F", - "#{session_name}:#{session_attached}:#{session_path}", - ], - { stdin: "ignore" } - ); + const separator = "|||HACK_SESSION_FIELD|||"; + const format = [ + "#{session_name}", + "#{session_attached}", + "#{session_path}", + ].join(separator); + const result = await exec(["tmux", "list-sessions", "-F", format], { + stdin: "ignore", + }); if (result.exitCode !== 0) { return []; } const sessions: TmuxSession[] = []; - for (const line of result.stdout.trim().split("\n")) { - if (!line) { + for (const line of result.stdout.split("\n")) { + if (!line.trim()) { + continue; + } + const fields = parseTmuxSessionFields(line, separator, 3); + if (!fields) { continue; } - const [name, attached, path] = line.split(":"); + const [name, attached, path] = fields; if (name) { sessions.push({ name, @@ -467,4 +484,20 @@ async function listTmuxSessions(): Promise { return sessions; } +function parseTmuxSessionFields( + line: string, + separator: string, + expectedCount: number +): readonly string[] | null { + const bySeparator = line.split(separator); + if (bySeparator.length === expectedCount) { + return bySeparator; + } + const byTab = line.split("\t"); + if (byTab.length === expectedCount) { + return byTab; + } + return null; +} + export const sshCommand = withHandler(sshSpec, handleSsh); diff --git a/src/commands/x.ts b/src/commands/x.ts index 14041293..4a3fa044 100644 --- a/src/commands/x.ts +++ b/src/commands/x.ts @@ -151,6 +151,17 @@ async function dispatchExtensionCommand(opts: { }); } + const disabledCommandResult = await dispatchDisabledExtensionCommandIfAllowed( + { + loaded: opts.loaded, + extension: opts.extension, + invocation: opts.invocation, + } + ); + if (disabledCommandResult !== null) { + return disabledCommandResult; + } + const didEnable = await promptEnableExtension({ loaded: opts.loaded, extension: opts.extension, @@ -178,6 +189,29 @@ async function dispatchExtensionCommand(opts: { }); } +async function dispatchDisabledExtensionCommandIfAllowed(opts: { + readonly loaded: Awaited>; + readonly extension: ResolvedExtension; + readonly invocation: ExtensionInvocation; +}): Promise { + if (!opts.invocation.command || opts.invocation.command === "help") { + return null; + } + + const command = opts.extension.commands.find( + (entry) => + entry.name === opts.invocation.command && entry.allowWhenDisabled === true + ); + if (!command) { + return null; + } + + return await command.handler({ + ctx: opts.loaded.context, + args: opts.invocation.args, + }); +} + async function promptEnableExtension(opts: { readonly loaded: Awaited>; readonly extension: ResolvedExtension; diff --git a/src/control-plane/extensions/gateway/commands.ts b/src/control-plane/extensions/gateway/commands.ts index b4b3a2e7..651d97c5 100644 --- a/src/control-plane/extensions/gateway/commands.ts +++ b/src/control-plane/extensions/gateway/commands.ts @@ -27,6 +27,13 @@ export const GATEWAY_COMMANDS: readonly ExtensionCommand[] = [ scope, }); + if (parsed.value.json) { + process.stdout.write( + `${JSON.stringify({ token: issued.token, record: issued.record }, null, 2)}\n` + ); + return 0; + } + await display.kv({ title: "Gateway token", entries: [ @@ -48,10 +55,20 @@ export const GATEWAY_COMMANDS: readonly ExtensionCommand[] = [ name: "token-list", summary: "List gateway tokens", scope: "global", - handler: async ({ args: _args }) => { + handler: async ({ ctx, args }) => { + const parsed = parseTokenListArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } const paths = resolveDaemonPaths({}); const tokens = await listGatewayTokens({ rootDir: paths.root }); + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify({ tokens }, null, 2)}\n`); + return 0; + } + if (tokens.length === 0) { await display.panel({ title: "Gateway tokens", @@ -80,27 +97,31 @@ export const GATEWAY_COMMANDS: readonly ExtensionCommand[] = [ summary: "Revoke a gateway token by id", scope: "global", handler: async ({ ctx, args }) => { - const tokenId = (args[0] ?? "").trim(); - if (!tokenId) { - ctx.logger.error({ - message: "Usage: hack x gateway token-revoke ", - }); + const parsed = parseTokenRevokeArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); return 1; } const paths = resolveDaemonPaths({}); const revoked = await revokeGatewayToken({ rootDir: paths.root, - tokenId, + tokenId: parsed.value.tokenId, }); + if (parsed.value.json) { + process.stdout.write( + `${JSON.stringify({ id: parsed.value.tokenId, revoked }, null, 2)}\n` + ); + return revoked ? 0 : 1; + } if (!revoked) { ctx.logger.warn({ - message: `Token not found or already revoked: ${tokenId}`, + message: `Token not found or already revoked: ${parsed.value.tokenId}`, }); return 1; } - ctx.logger.success({ message: `Revoked token ${tokenId}` }); + ctx.logger.success({ message: `Revoked token ${parsed.value.tokenId}` }); return 0; }, }, @@ -109,157 +130,177 @@ export const GATEWAY_COMMANDS: readonly ExtensionCommand[] = [ type TokenCreateArgs = { readonly label?: string; readonly scope: GatewayTokenScope; + readonly json: boolean; }; -type TokenCreateParseResult = - | { readonly ok: true; readonly value: TokenCreateArgs } +type TokenListArgs = { + readonly json: boolean; +}; + +type TokenListParseResult = + | { readonly ok: true; readonly value: TokenListArgs } + | { readonly ok: false; readonly error: string }; + +type TokenRevokeArgs = { + readonly tokenId: string; + readonly json: boolean; +}; + +type TokenRevokeParseResult = + | { readonly ok: true; readonly value: TokenRevokeArgs } | { readonly ok: false; readonly error: string }; -type ParseResult = - | { readonly ok: true; readonly value: T } +type TokenCreateParseResult = + | { readonly ok: true; readonly value: TokenCreateArgs } | { readonly ok: false; readonly error: string }; function parseTokenCreateArgs(opts: { readonly args: readonly string[]; }): TokenCreateParseResult { - const state: { label?: string; scope: GatewayTokenScope } = { scope: "read" }; + let label: string | undefined; + let scope: GatewayTokenScope = "read"; + let json = false; + + const takeValue = ( + _token: string, + value: string | undefined + ): string | null => { + if (!value || value.startsWith("-")) { + return null; + } + return value; + }; for (let i = 0; i < opts.args.length; i += 1) { const token = opts.args[i] ?? ""; - if (token === "--") { const rest = opts.args.slice(i + 1); - if (rest.length > 0 && !state.label) { - state.label = normalizeLabel(rest[0] ?? ""); + if (rest.length > 0 && !label) { + label = normalizeLabel(rest[0] ?? ""); } break; } - const parsed = parseTokenCreateToken({ - token, - next: opts.args[i + 1], - state, - }); - if (!parsed.ok) { - return { ok: false, error: parsed.error }; + if (token === "--write") { + scope = "write"; + continue; + } + + if (token === "--json") { + json = true; + continue; + } + + if (token.startsWith("--scope=")) { + const value = token.slice("--scope=".length).trim(); + const parsed = parseScope(value); + if (!parsed) { + return { ok: false, error: "Invalid --scope (use read|write)." }; + } + scope = parsed; + continue; + } + + if (token === "--scope") { + const value = takeValue(token, opts.args[i + 1]); + if (!value) { + return { ok: false, error: "--scope requires a value." }; + } + const parsed = parseScope(value); + if (!parsed) { + return { ok: false, error: "Invalid --scope (use read|write)." }; + } + scope = parsed; + i += 1; + continue; + } + + if (token.startsWith("--label=")) { + label = normalizeLabel(token.slice("--label=".length)); + continue; } - state.label = parsed.value.state.label; - state.scope = parsed.value.state.scope; - i += parsed.value.consume; + if (token === "--label") { + const value = takeValue(token, opts.args[i + 1]); + if (!value) { + return { ok: false, error: "--label requires a value." }; + } + label = normalizeLabel(value); + i += 1; + continue; + } + + if (token.startsWith("-")) { + return { ok: false, error: `Unknown option: ${token}` }; + } + + if (!label) { + label = normalizeLabel(token); + continue; + } + + return { ok: false, error: `Unexpected argument: ${token}` }; } return { ok: true, value: { - ...(state.label ? { label: state.label } : {}), - scope: state.scope, + ...(label ? { label } : {}), + scope, + json, }, }; } -type TokenCreateState = { - readonly label?: string; - readonly scope: GatewayTokenScope; -}; - -function parseTokenCreateToken(opts: { - readonly token: string; - readonly next: string | undefined; - readonly state: TokenCreateState; -}): ParseResult<{ - readonly state: TokenCreateState; - readonly consume: number; -}> { - if (opts.token === "--write") { - return { - ok: true, - value: { state: { ...opts.state, scope: "write" }, consume: 0 }, - }; - } - - const scopeInline = parseInlineFlag({ token: opts.token, name: "--scope" }); - if (scopeInline) { - const scope = parseScope(scopeInline); - if (!scope) { - return { ok: false, error: "Invalid --scope (use read|write)." }; +function parseTokenListArgs(opts: { + readonly args: readonly string[]; +}): TokenListParseResult { + let json = false; + for (const token of opts.args) { + if (token === "--json") { + json = true; + continue; } - return { ok: true, value: { state: { ...opts.state, scope }, consume: 0 } }; + return { ok: false, error: `Unknown option: ${token}` }; } + return { ok: true, value: { json } }; +} - if (opts.token === "--scope") { - const scopeValue = takeFlagValue({ token: opts.token, value: opts.next }); - if (!scopeValue) { - return { ok: false, error: "--scope requires a value." }; +function parseTokenRevokeArgs(opts: { + readonly args: readonly string[]; +}): TokenRevokeParseResult { + let tokenId: string | null = null; + let json = false; + + for (const token of opts.args) { + if (token === "--json") { + json = true; + continue; } - const scope = parseScope(scopeValue); - if (!scope) { - return { ok: false, error: "Invalid --scope (use read|write)." }; + if (token.startsWith("-")) { + return { ok: false, error: `Unknown option: ${token}` }; } - return { ok: true, value: { state: { ...opts.state, scope }, consume: 1 } }; - } - - const labelInline = parseInlineFlag({ token: opts.token, name: "--label" }); - if (labelInline !== null) { - return { - ok: true, - value: { - state: { ...opts.state, label: normalizeLabel(labelInline) }, - consume: 0, - }, - }; - } - - if (opts.token === "--label") { - const labelValue = takeFlagValue({ token: opts.token, value: opts.next }); - if (!labelValue) { - return { ok: false, error: "--label requires a value." }; + if (!tokenId) { + tokenId = token.trim(); + continue; } - return { - ok: true, - value: { - state: { ...opts.state, label: normalizeLabel(labelValue) }, - consume: 1, - }, - }; + return { ok: false, error: `Unexpected argument: ${token}` }; } - if (opts.token.startsWith("-")) { - return { ok: false, error: `Unknown option: ${opts.token}` }; - } - - if (!opts.state.label) { + if (!tokenId) { return { - ok: true, - value: { - state: { ...opts.state, label: normalizeLabel(opts.token) }, - consume: 0, - }, + ok: false, + error: "Usage: hack x gateway token-revoke [--json]", }; } - return { ok: false, error: `Unexpected argument: ${opts.token}` }; -} - -function parseInlineFlag(opts: { - readonly token: string; - readonly name: string; -}): string | null { - const prefix = `${opts.name}=`; - if (!opts.token.startsWith(prefix)) { - return null; - } - return opts.token.slice(prefix.length).trim(); -} - -function takeFlagValue(opts: { - readonly token: string; - readonly value: string | undefined; -}): string | null { - if (!opts.value || opts.value.startsWith("-")) { - return null; - } - return opts.value; + return { + ok: true, + value: { + tokenId, + json, + }, + }; } function parseScope(value: string): GatewayTokenScope | null { diff --git a/src/control-plane/extensions/tailscale/commands.ts b/src/control-plane/extensions/tailscale/commands.ts index 5a253f5f..a805b19e 100644 --- a/src/control-plane/extensions/tailscale/commands.ts +++ b/src/control-plane/extensions/tailscale/commands.ts @@ -1,3 +1,10 @@ +import { + getRecord, + getString, + getStringArray, + isRecord, +} from "../../../lib/guards.ts"; +import { exec, findExecutableInPath } from "../../../lib/shell.ts"; import { display } from "../../../ui/display.ts"; import type { ExtensionCommand } from "../types.ts"; @@ -43,6 +50,45 @@ export const TAILSCALE_COMMANDS: readonly ExtensionCommand[] = [ return await runTailscale({ args: ["status", ...args], inherit: true }); }, }, + { + name: "inspect", + summary: "Return parsed tailscale status for UI integrations", + scope: "global", + allowWhenDisabled: true, + handler: async ({ args }) => { + const parsed = parseInspectArgs({ args }); + if (!parsed.ok) { + process.stderr.write(`${parsed.error}\n`); + return 1; + } + const payload = await inspectTailscaleStatus(); + if (parsed.value.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return payload.error ? 1 : 0; + } + + const entries = [ + ["installed", payload.installed ? "yes" : "no"], + ["connected", payload.connected ? "yes" : "no"], + ["backend_state", payload.backendState ?? ""], + ["tailnet", payload.tailnetName ?? ""], + ["self", payload.self?.hostname ?? ""], + ["self_ip", payload.self?.tailscaleIp ?? ""], + ["peers", String(payload.peers.length)], + ["online_peers", String(payload.onlinePeerCount)], + ["exit_nodes", String(payload.exitNodes.length)], + ] as const; + await display.kv({ + title: "Tailscale inspect", + entries, + }); + if (payload.error) { + process.stderr.write(`${payload.error}\n`); + return 1; + } + return 0; + }, + }, { name: "ip", summary: "Show tailscale IP addresses", @@ -88,3 +134,244 @@ async function runTailscale(opts: { return await proc.exited; } + +type InspectArgs = { + readonly json: boolean; +}; + +type InspectArgsParseResult = + | { readonly ok: true; readonly value: InspectArgs } + | { readonly ok: false; readonly error: string }; + +type TailscaleInspectPeer = { + readonly id: string; + readonly hostname: string; + readonly dnsName?: string; + readonly tailscaleIp?: string; + readonly online: boolean; + readonly os?: string; + readonly tags: readonly string[]; + readonly isExitNode: boolean; + readonly isExitNodeOption: boolean; +}; + +type TailscaleInspectSelf = { + readonly id: string; + readonly hostname: string; + readonly dnsName?: string; + readonly tailscaleIp?: string; + readonly online: boolean; + readonly os?: string; + readonly tags: readonly string[]; + readonly isExitNode: boolean; +}; + +type TailscaleInspectPayload = { + readonly installed: boolean; + readonly binaryPath?: string; + readonly connected: boolean; + readonly backendState?: string; + readonly tailnetName?: string; + readonly magicDnsSuffix?: string; + readonly authUrl?: string; + readonly currentExitNodeId?: string; + readonly currentExitNodeName?: string; + readonly self?: TailscaleInspectSelf; + readonly peers: readonly TailscaleInspectPeer[]; + readonly onlinePeerCount: number; + readonly exitNodes: readonly TailscaleInspectPeer[]; + readonly health: readonly string[]; + readonly error?: string; +}; + +function parseInspectArgs(opts: { + readonly args: readonly string[]; +}): InspectArgsParseResult { + let json = false; + for (const token of opts.args) { + if (token === "--json") { + json = true; + continue; + } + return { + ok: false, + error: `Unknown option: ${token}. Usage: hack x tailscale inspect [--json]`, + }; + } + return { + ok: true, + value: { + json, + }, + }; +} + +async function inspectTailscaleStatus(): Promise { + const binaryPath = findExecutableInPath("tailscale") ?? undefined; + if (!binaryPath) { + return { + installed: false, + connected: false, + peers: [], + onlinePeerCount: 0, + exitNodes: [], + health: [], + error: "tailscale not found. Install with: brew install tailscale", + }; + } + + const result = await exec(["tailscale", "status", "--json"], { + stdin: "ignore", + }); + const fallbackBase: Omit = { + installed: true, + binaryPath, + connected: false, + peers: [], + onlinePeerCount: 0, + exitNodes: [], + health: [], + }; + if (result.exitCode !== 0) { + const stderr = result.stderr.trim(); + return { + ...fallbackBase, + error: stderr.length > 0 ? stderr : "tailscale status failed", + }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(result.stdout); + } catch { + return { + ...fallbackBase, + error: "tailscale status returned invalid JSON", + }; + } + if (!isRecord(parsed)) { + return { + ...fallbackBase, + error: "tailscale status returned invalid JSON", + }; + } + + const backendState = getString(parsed, "BackendState"); + const connected = backendState === "Running"; + const currentTailnet = getRecord(parsed, "CurrentTailnet"); + const tailnetName = currentTailnet + ? getString(currentTailnet, "Name") + : undefined; + const magicDnsSuffix = currentTailnet + ? getString(currentTailnet, "MagicDNSSuffix") + : undefined; + const authUrl = getString(parsed, "AuthURL"); + const currentExitNodeId = getString(parsed, "ExitNodeID"); + const health = getStringArray(parsed, "Health") ?? []; + + const selfRaw = getRecord(parsed, "Self"); + const self = selfRaw + ? parseSelfPeer({ + id: getString(selfRaw, "ID") ?? "self", + value: selfRaw, + }) + : undefined; + + const peerMap = getRecord(parsed, "Peer"); + const peers: TailscaleInspectPeer[] = []; + if (peerMap) { + for (const [id, value] of Object.entries(peerMap)) { + if (!isRecord(value)) { + continue; + } + peers.push(parsePeer({ id, value })); + } + } + + peers.sort((left, right) => { + if (left.online !== right.online) { + return left.online ? -1 : 1; + } + return left.hostname.localeCompare(right.hostname); + }); + + const exitNodes = peers.filter( + (peer) => peer.isExitNodeOption || peer.isExitNode + ); + const currentExitNodeName = currentExitNodeId + ? peers.find((peer) => peer.id === currentExitNodeId)?.hostname + : undefined; + const onlinePeerCount = peers.filter((peer) => peer.online).length; + + return { + ...fallbackBase, + connected, + ...(backendState ? { backendState } : {}), + ...(tailnetName ? { tailnetName } : {}), + ...(magicDnsSuffix ? { magicDnsSuffix } : {}), + ...(authUrl ? { authUrl } : {}), + ...(currentExitNodeId ? { currentExitNodeId } : {}), + ...(currentExitNodeName ? { currentExitNodeName } : {}), + ...(self ? { self } : {}), + peers, + onlinePeerCount, + exitNodes, + health, + }; +} + +function parsePeer(opts: { + readonly id: string; + readonly value: Record; +}): TailscaleInspectPeer { + return { + id: opts.id, + hostname: getString(opts.value, "HostName") ?? opts.id, + dnsName: normalizeDnsName(getString(opts.value, "DNSName")), + tailscaleIp: firstTailscaleIp(opts.value), + online: opts.value.Online === true, + os: getString(opts.value, "OS"), + tags: getStringArray(opts.value, "Tags") ?? [], + isExitNode: opts.value.ExitNode === true, + isExitNodeOption: opts.value.ExitNodeOption === true, + }; +} + +function parseSelfPeer(opts: { + readonly id: string; + readonly value: Record; +}): TailscaleInspectSelf { + return { + id: opts.id, + hostname: getString(opts.value, "HostName") ?? "this-device", + dnsName: normalizeDnsName(getString(opts.value, "DNSName")), + tailscaleIp: firstTailscaleIp(opts.value), + online: opts.value.Online === true, + os: getString(opts.value, "OS"), + tags: getStringArray(opts.value, "Tags") ?? [], + isExitNode: opts.value.ExitNode === true, + }; +} + +function firstTailscaleIp(record: Record): string | undefined { + const values = record.TailscaleIPs; + if (!Array.isArray(values)) { + return undefined; + } + for (const value of values) { + if (typeof value === "string" && value.length > 0) { + return value; + } + } + return undefined; +} + +function normalizeDnsName(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + if (!value.endsWith(".")) { + return value; + } + return value.slice(0, -1); +} diff --git a/src/control-plane/extensions/types.ts b/src/control-plane/extensions/types.ts index 6d67106f..1664faa8 100644 --- a/src/control-plane/extensions/types.ts +++ b/src/control-plane/extensions/types.ts @@ -26,6 +26,7 @@ export type ExtensionCommand = { readonly summary: string; readonly description?: string; readonly scope: ExtensionScope; + readonly allowWhenDisabled?: boolean; readonly handler: (input: { readonly ctx: ExtensionCommandContext; readonly args: readonly string[]; diff --git a/src/control-plane/sdk/config.ts b/src/control-plane/sdk/config.ts index 86d8a9a2..27b41853 100644 --- a/src/control-plane/sdk/config.ts +++ b/src/control-plane/sdk/config.ts @@ -119,6 +119,86 @@ const GatewayConfigSchema = z.object({ allowWrites: z.boolean().default(false), }); +const PreferencesAppearanceInputSchema = z.object({ + theme: z.string().optional(), +}); + +const PreferencesAppearanceSchema = z.object({ + theme: z.string().default("system"), +}); + +const PreferencesTerminalInputSchema = z.object({ + defaultApp: z.string().optional(), +}); + +const PreferencesTerminalSchema = z.object({ + defaultApp: z.string().default("terminal"), +}); + +const PreferencesEditorInputSchema = z.object({ + defaultApp: z.string().optional(), +}); + +const PreferencesEditorSchema = z.object({ + defaultApp: z.string().default("cursor"), +}); + +const PreferencesAgentsInputSchema = z.object({ + defaultApp: z.string().optional(), + binaryPath: z.string().optional(), +}); + +const PreferencesAgentsSchema = z.object({ + defaultApp: z.string().default("codex"), + binaryPath: z.string().default(""), +}); + +const PreferencesSessionInputSchema = z.object({ + provider: z.string().optional(), + binaryPath: z.string().optional(), +}); + +const PreferencesSessionSchema = z.object({ + provider: z.string().default("tmux"), + binaryPath: z.string().default(""), +}); + +const PreferencesContainerInputSchema = z.object({ + provider: z.string().optional(), + binaryPath: z.string().optional(), +}); + +const PreferencesContainerSchema = z.object({ + provider: z.string().default("docker"), + binaryPath: z.string().default(""), +}); + +const PreferencesConfigInputSchema = z.object({ + appearance: PreferencesAppearanceInputSchema.optional(), + terminal: PreferencesTerminalInputSchema.optional(), + editor: PreferencesEditorInputSchema.optional(), + agents: PreferencesAgentsInputSchema.optional(), + sessions: PreferencesSessionInputSchema.optional(), + containers: PreferencesContainerInputSchema.optional(), +}); + +const PreferencesConfigSchema = z.object({ + appearance: PreferencesAppearanceSchema.default( + PreferencesAppearanceSchema.parse({}) + ), + terminal: PreferencesTerminalSchema.default( + PreferencesTerminalSchema.parse({}) + ), + editor: PreferencesEditorSchema.default(PreferencesEditorSchema.parse({})), + agents: PreferencesAgentsSchema.default(PreferencesAgentsSchema.parse({})), + sessions: PreferencesSessionSchema.default( + PreferencesSessionSchema.parse({}) + ), + containers: PreferencesContainerSchema.default( + PreferencesContainerSchema.parse({}) + ), +}); + const ControlPlaneConfigInputSchema = z.object({ extensions: z.record(z.string(), ExtensionEnablementInputSchema).optional(), tickets: z @@ -131,6 +211,7 @@ const ControlPlaneConfigInputSchema = z.object({ usage: UsageConfigInputSchema.optional(), daemon: DaemonConfigInputSchema.optional(), gateway: GatewayConfigInputSchema.optional(), + preferences: PreferencesConfigInputSchema.optional(), }); const ControlPlaneConfigSchema = z.object({ @@ -145,6 +226,9 @@ const ControlPlaneConfigSchema = z.object({ usage: UsageConfigSchema.default(UsageConfigSchema.parse({})), daemon: DaemonConfigSchema.default(DaemonConfigSchema.parse({})), gateway: GatewayConfigSchema.default(GatewayConfigSchema.parse({})), + preferences: PreferencesConfigSchema.default( + PreferencesConfigSchema.parse({}) + ), }); export type ControlPlaneConfig = z.infer; diff --git a/src/daemon/routes/sessions.ts b/src/daemon/routes/sessions.ts index 7f61ce52..b556236c 100644 --- a/src/daemon/routes/sessions.ts +++ b/src/daemon/routes/sessions.ts @@ -3,19 +3,20 @@ import { buildTailscaleSshCommand, getTailscaleStatus, } from "../../lib/tailscale.ts"; -import type { MuxBackendName, MuxSession } from "../../mux/mux-backend.ts"; -import { - resolveDefaultBackendName, - resolveMux, -} from "../../mux/mux-resolver.ts"; -/** Valid session name pattern: alphanumeric, dash, underscore */ +/** Valid session name pattern: alphanumeric, dash, or underscore */ const SESSION_NAME_PATTERN = /^[\w-]+$/; /** - * Parsed mux session info. + * Parsed tmux session info. */ -export type DaemonSession = MuxSession; +export interface TmuxSession { + readonly name: string; + readonly attached: boolean; + readonly path: string | null; + readonly windows: number; + readonly createdAt: string | null; +} /** * Session create input. @@ -23,7 +24,6 @@ export type DaemonSession = MuxSession; export interface SessionCreateInput { readonly name: string; readonly cwd?: string; - readonly backend?: MuxBackendName; } /** @@ -62,7 +62,7 @@ type ParseResult = * Handles session API routes. * * Routes: - * - GET /v1/sessions - List all sessions + * - GET /v1/sessions - List all tmux sessions * - POST /v1/sessions - Create a new session * - GET /v1/sessions/:id - Get session details * - POST /v1/sessions/:id/stop - Stop (kill) session @@ -96,6 +96,15 @@ export async function handleSessionRoutes(opts: { if (!sessionId) { return jsonResponse({ error: "missing_session_id" }, 400); } + if (!SESSION_NAME_PATTERN.test(sessionId)) { + return jsonResponse( + { + error: + "invalid_name: must contain only alphanumeric, dash, or underscore", + }, + 400 + ); + } // GET /v1/sessions/:id - get session details if (segments.length === 3 && opts.req.method === "GET") { @@ -121,28 +130,22 @@ export async function handleSessionRoutes(opts: { } /** - * List all sessions. + * List all tmux sessions. */ async function handleListSessions(): Promise { - const mux = await resolveMux({ project: null }); const [sessions, connectionInfo] = await Promise.all([ - mux.mode === "none" ? Promise.resolve([] as const) : listSessions({ mux }), + listTmuxSessions(), getConnectionInfo(), ]); return jsonResponse({ sessions, connection: connectionInfo }); } /** - * Create a new session. + * Create a new tmux session. */ async function handleCreateSession(opts: { readonly req: Request; }): Promise { - const mux = await resolveMux({ project: null }); - if (mux.mode === "none") { - return jsonResponse({ error: "sessions_disabled" }, 503); - } - const body = await readJsonBody(opts.req); if (!body) { return jsonResponse({ error: "invalid_json" }, 400); @@ -153,33 +156,30 @@ async function handleCreateSession(opts: { return jsonResponse({ error: parsed.error }, 400); } - const { name, cwd, backend: backendRequested } = parsed.value; - const backendName = - backendRequested ?? - resolveDefaultBackendName({ mode: mux.mode, backends: mux.backends }); - if (!backendName) { - return jsonResponse({ error: "no_backend_available" }, 503); - } - const backend = mux.backends.get(backendName); - if (!backend?.available) { - return jsonResponse({ error: "backend_unavailable" }, 503); - } + const { name, cwd } = parsed.value; // Check if session already exists - const existing = await findSession({ mux, name }); + const existing = await findSession({ name }); if (existing) { return jsonResponse({ error: "session_exists", session: existing }, 409); } - const create = await backend.createSession({ name, cwd }); - if (!create.ok) { + // Create session - uses array form for safety (no shell interpolation) + const args = ["tmux", "new-session", "-d", "-s", name]; + if (cwd) { + args.push("-c", cwd); + } + + const result = await exec(args, { stdin: "ignore" }); + if (result.exitCode !== 0) { return jsonResponse( - { error: create.error, message: create.stderr ?? "" }, + { error: "create_failed", message: result.stderr.trim() }, 500 ); } - const session = create.session ?? (await findSession({ mux, name })); + // Return created session + const session = await findSession({ name }); return jsonResponse({ session }, 201); } @@ -189,9 +189,8 @@ async function handleCreateSession(opts: { async function handleGetSession(opts: { readonly sessionId: string; }): Promise { - const mux = await resolveMux({ project: null }); const [session, connectionInfo] = await Promise.all([ - findSession({ mux, name: opts.sessionId }), + findSession({ name: opts.sessionId }), getConnectionInfo({ sessionName: opts.sessionId }), ]); if (!session) { @@ -201,22 +200,19 @@ async function handleGetSession(opts: { } /** - * Stop (kill) a session. + * Stop (kill) a tmux session. */ async function handleStopSession(opts: { readonly sessionId: string; }): Promise { - const mux = await resolveMux({ project: null }); - const session = await findSession({ mux, name: opts.sessionId }); + const session = await findSession({ name: opts.sessionId }); if (!session) { return jsonResponse({ error: "session_not_found" }, 404); } - const backend = mux.backends.get(session.backend); - if (!backend?.available) { - return jsonResponse({ error: "backend_unavailable" }, 503); - } - const result = await backend.killSession({ name: opts.sessionId }); + const result = await exec(["tmux", "kill-session", "-t", opts.sessionId], { + stdin: "ignore", + }); if (result.exitCode !== 0) { return jsonResponse( @@ -229,15 +225,14 @@ async function handleStopSession(opts: { } /** - * Execute a command in a session. + * Execute a command in a tmux session. * Sends the command followed by Enter. */ async function handleExecSession(opts: { readonly req: Request; readonly sessionId: string; }): Promise { - const mux = await resolveMux({ project: null }); - const session = await findSession({ mux, name: opts.sessionId }); + const session = await findSession({ name: opts.sessionId }); if (!session) { return jsonResponse({ error: "session_not_found" }, 404); } @@ -252,14 +247,11 @@ async function handleExecSession(opts: { return jsonResponse({ error: parsed.error }, 400); } - const backend = mux.backends.get(session.backend); - if (!backend?.available) { - return jsonResponse({ error: "backend_unavailable" }, 503); - } - const result = await backend.execInSession({ - name: opts.sessionId, - command: parsed.value.command, - }); + // Uses array form - command is a separate argument, not interpolated + const result = await exec( + ["tmux", "send-keys", "-t", opts.sessionId, parsed.value.command, "Enter"], + { stdin: "ignore" } + ); if (result.exitCode !== 0) { return jsonResponse( @@ -272,7 +264,7 @@ async function handleExecSession(opts: { } /** - * Send raw input/keystrokes to a session. + * Send raw input/keystrokes to a tmux session. * Does NOT automatically append Enter - allows sending key sequences like: * - "C-c" (Ctrl+C) * - "C-d" (Ctrl+D) @@ -285,8 +277,7 @@ async function handleInputSession(opts: { readonly req: Request; readonly sessionId: string; }): Promise { - const mux = await resolveMux({ project: null }); - const session = await findSession({ mux, name: opts.sessionId }); + const session = await findSession({ name: opts.sessionId }); if (!session) { return jsonResponse({ error: "session_not_found" }, 404); } @@ -301,14 +292,11 @@ async function handleInputSession(opts: { return jsonResponse({ error: parsed.error }, 400); } - const backend = mux.backends.get(session.backend); - if (!backend?.available) { - return jsonResponse({ error: "backend_unavailable" }, 503); - } - const result = await backend.sendInput({ - name: opts.sessionId, - keys: parsed.value.keys, - }); + // send-keys without trailing Enter - uses array form for safety + const result = await exec( + ["tmux", "send-keys", "-t", opts.sessionId, parsed.value.keys], + { stdin: "ignore" } + ); if (result.exitCode !== 0) { return jsonResponse( @@ -321,29 +309,78 @@ async function handleInputSession(opts: { } /** - * Find a session by name. + * List all tmux sessions with detailed info. */ -async function findSession(opts: { - readonly mux: Awaited>; - readonly name: string; -}): Promise { - const sessions = await listSessions({ mux: opts.mux }); - return sessions.find((s) => s.name === opts.name) ?? null; -} +async function listTmuxSessions(): Promise { + const separator = "|||HACK_SESSION_FIELD|||"; + const format = [ + "#{session_name}", + "#{session_attached}", + "#{session_path}", + "#{session_windows}", + "#{session_created}", + ].join(separator); + + const result = await exec(["tmux", "list-sessions", "-F", format], { + stdin: "ignore", + }); + + if (result.exitCode !== 0) { + return []; + } -async function listSessions(opts: { - readonly mux: Awaited>; -}): Promise { - const sessions: DaemonSession[] = []; - for (const backend of opts.mux.backends.values()) { - if (!backend?.available) { + const sessions: TmuxSession[] = []; + for (const line of result.stdout.split("\n")) { + if (!line.trim()) { continue; } - sessions.push(...(await backend.listSessions())); + const fields = parseTmuxSessionFields(line, separator, 5); + if (!fields) { + continue; + } + const [name, attached, path, windows, created] = fields; + if (name) { + sessions.push({ + name, + attached: attached === "1", + path: path || null, + windows: Number.parseInt(windows ?? "1", 10), + createdAt: created + ? new Date(Number.parseInt(created, 10) * 1000).toISOString() + : null, + }); + } } + return sessions; } +function parseTmuxSessionFields( + line: string, + separator: string, + expectedCount: number +): readonly string[] | null { + const bySeparator = line.split(separator); + if (bySeparator.length === expectedCount) { + return bySeparator; + } + const byTab = line.split("\t"); + if (byTab.length === expectedCount) { + return byTab; + } + return null; +} + +/** + * Find a session by name. + */ +async function findSession(opts: { + readonly name: string; +}): Promise { + const sessions = await listTmuxSessions(); + return sessions.find((s) => s.name === opts.name) ?? null; +} + /** * Get connection info for SSH access. * @@ -389,7 +426,7 @@ function parseSessionCreateInput( return { ok: false, error: "missing_name" }; } - // Validate session name (alphanumeric, dash, underscore, dot) + // Validate session name (tmux restrictions) const trimmedName = name.trim(); if (!SESSION_NAME_PATTERN.test(trimmedName)) { return { @@ -400,17 +437,12 @@ function parseSessionCreateInput( } const cwd = typeof body.cwd === "string" ? body.cwd.trim() : undefined; - const backend = - body.backend === "tmux" || body.backend === "zellij" - ? (body.backend as MuxBackendName) - : undefined; return { ok: true, value: { name: trimmedName, ...(cwd && cwd.length > 0 ? { cwd } : {}), - ...(backend ? { backend } : {}), }, }; } diff --git a/src/lib/project-views.ts b/src/lib/project-views.ts index 76716c57..0a93a269 100644 --- a/src/lib/project-views.ts +++ b/src/lib/project-views.ts @@ -1,3 +1,4 @@ +import { realpathSync } from "node:fs"; import { resolve } from "node:path"; import { YAML } from "bun"; @@ -11,12 +12,32 @@ import { type RuntimeProject, serializeRuntimeProject, } from "./runtime-projects.ts"; +import { exec } from "./shell.ts"; export type BranchRuntime = { readonly branch: string; readonly runtime: RuntimeProject; }; +export type MuxSession = { + readonly name: string; + readonly backend: "tmux" | "zellij"; + readonly attached: boolean; + readonly path: string | null; + readonly windows: number | null; + readonly createdAt: number | null; +}; + +export type ProjectSession = { + readonly name: string; + readonly backend: "tmux" | "zellij"; + readonly source: "hack" | "external"; + readonly attached: boolean; + readonly path: string | null; + readonly windows: number | null; + readonly createdAt: number | null; +}; + export type ProjectView = { readonly projectId?: string; readonly name: string; @@ -31,6 +52,7 @@ export type ProjectView = { readonly runtimeStatus: ProjectRuntimeStatus; readonly runtime: RuntimeProject | null; readonly branchRuntime: readonly BranchRuntime[]; + readonly sessions: readonly ProjectSession[]; readonly kind: "registered" | "unregistered"; readonly status: | "running" @@ -47,24 +69,35 @@ export type ProjectRuntimeStatus = | "unknown" | "not_configured"; -export async function buildProjectViews(opts: { +type BuildProjectViewsOptions = { readonly registryProjects: readonly RegisteredProject[]; readonly runtime: readonly RuntimeProject[]; readonly runtimeOk: boolean; readonly filter: string | null; readonly includeUnregistered: boolean; -}): Promise { + readonly muxSessions?: readonly MuxSession[]; +}; + +export async function buildProjectViews( + opts: BuildProjectViewsOptions +): Promise { const byName = new Map( opts.registryProjects.map((p) => [p.name, p] as const) ); const runtimeByName = new Map( opts.runtime.map((p) => [p.project, p] as const) ); - const names = collectProjectNames({ - registryProjects: opts.registryProjects, - runtime: opts.runtime, - includeUnregistered: opts.includeUnregistered, - }); + + const names = new Set(); + for (const p of opts.registryProjects) { + names.add(p.name); + } + if (opts.includeUnregistered) { + for (const p of opts.runtime) { + names.add(p.project); + } + } + const muxSessions = opts.muxSessions ?? (await listMuxSessions()); const out: ProjectView[] = []; for (const name of [...names].sort((a, b) => a.localeCompare(b))) { @@ -79,10 +112,11 @@ export async function buildProjectViews(opts: { out.push( await buildRegisteredProjectView({ name, - reg, + registration: reg, runtime, + allRuntime: opts.runtime, runtimeOk: opts.runtimeOk, - runtimeProjects: opts.runtime, + muxSessions, }) ); continue; @@ -102,68 +136,69 @@ export async function buildProjectViews(opts: { return out; } -function collectProjectNames(opts: { - readonly registryProjects: readonly RegisteredProject[]; - readonly runtime: readonly RuntimeProject[]; - readonly includeUnregistered: boolean; -}): ReadonlySet { - const names = new Set(); - for (const p of opts.registryProjects) { - names.add(p.name); - } - if (!opts.includeUnregistered) { - return names; - } - for (const p of opts.runtime) { - names.add(p.project); - } - return names; -} - async function buildRegisteredProjectView(opts: { readonly name: string; - readonly reg: RegisteredProject; + readonly registration: RegisteredProject; readonly runtime: RuntimeProject | null; + readonly allRuntime: readonly RuntimeProject[]; readonly runtimeOk: boolean; - readonly runtimeProjects: readonly RuntimeProject[]; + readonly muxSessions: readonly MuxSession[]; }): Promise { - const composeMeta = await resolveComposeMeta({ - projectDir: opts.reg.projectDir, - }); + const projectDirOk = await pathExists(opts.registration.projectDir); + const composeFile = resolve( + opts.registration.projectDir, + PROJECT_COMPOSE_FILENAME + ); + const composeExists = projectDirOk && (await pathExists(composeFile)); + const definedServices = composeExists + ? await readComposeServices({ composeFile }) + : null; + const serviceHosts = composeExists + ? await readComposeServiceHosts({ composeFile }) + : null; const running = countRunningServices(opts.runtime); - const runtimeStatus = resolveRuntimeStatus({ - projectDirOk: composeMeta.projectDirOk, - composeExists: composeMeta.composeExists, + const runtimeConfigured = composeExists; + const runtimeStatus: ProjectRuntimeStatus = resolveRuntimeStatus({ + projectDirOk, + composeExists, runtimeOk: opts.runtimeOk, running, }); - const status = resolveProjectStatus({ - projectDirOk: composeMeta.projectDirOk, + const status: ProjectView["status"] = resolveProjectStatus({ + projectDirOk, runtimeOk: opts.runtimeOk, running, }); const branchRuntime = collectBranchRuntime({ baseName: opts.name, - runtimeProjects: opts.runtimeProjects, + runtimeProjects: opts.allRuntime, + }); + const sessions = collectProjectSessions({ + projectName: opts.name, + repoRoot: opts.registration.repoRoot, + muxSessions: opts.muxSessions, }); - const extensions = composeMeta.projectDirOk - ? await resolveProjectExtensions({ projectDir: opts.reg.projectDir }) + const extensions = projectDirOk + ? await resolveProjectExtensions({ + projectDir: opts.registration.projectDir, + }) : null; return { - projectId: opts.reg.id, + projectId: opts.registration.id, name: opts.name, - devHost: opts.reg.devHost ?? null, - repoRoot: opts.reg.repoRoot, - projectDir: opts.reg.projectDir, - definedServices: composeMeta.definedServices, + devHost: opts.registration.devHost ?? null, + repoRoot: opts.registration.repoRoot, + projectDir: opts.registration.projectDir, + definedServices, extensionsEnabled: extensions?.enabled ?? null, features: extensions?.features ?? null, - serviceHosts: composeMeta.serviceHosts, - runtimeConfigured: composeMeta.composeExists, + serviceHosts, + runtimeConfigured, runtimeStatus, runtime: opts.runtime, branchRuntime, + sessions, kind: "registered", status, }; @@ -175,11 +210,10 @@ function buildUnregisteredProjectView(opts: { readonly runtimeOk: boolean; }): ProjectView { const running = countRunningServices(opts.runtime); - const runtimeStatus = resolveUnregisteredRuntimeStatus({ + const runtimeStatus: ProjectRuntimeStatus = resolveUnregisteredRuntimeStatus({ runtimeOk: opts.runtimeOk, running, }); - return { name: opts.name, devHost: null, @@ -193,46 +227,12 @@ function buildUnregisteredProjectView(opts: { runtimeStatus, runtime: opts.runtime, branchRuntime: [], + sessions: [], kind: "unregistered", status: "unregistered", }; } -type ComposeMeta = { - readonly projectDirOk: boolean; - readonly composeExists: boolean; - readonly definedServices: readonly string[] | null; - readonly serviceHosts: Readonly> | null; -}; - -async function resolveComposeMeta(opts: { - readonly projectDir: string; -}): Promise { - const projectDirOk = await pathExists(opts.projectDir); - const composeFile = resolve(opts.projectDir, PROJECT_COMPOSE_FILENAME); - const composeExists = projectDirOk && (await pathExists(composeFile)); - if (!composeExists) { - return { - projectDirOk, - composeExists, - definedServices: null, - serviceHosts: null, - }; - } - - const [definedServices, serviceHosts] = await Promise.all([ - readComposeServices({ composeFile }), - readComposeServiceHosts({ composeFile }), - ]); - - return { - projectDirOk, - composeExists, - definedServices, - serviceHosts, - }; -} - export function serializeProjectView( view: ProjectView ): Record { @@ -253,6 +253,15 @@ export function serializeProjectView( branch: entry.branch, runtime: serializeRuntimeProject(entry.runtime), })), + sessions: view.sessions.map((entry) => ({ + name: entry.name, + backend: entry.backend, + source: entry.source, + attached: entry.attached, + path: entry.path, + windows: entry.windows, + created_at: entry.createdAt, + })), kind: view.kind, status: view.status, }; @@ -277,6 +286,229 @@ function collectBranchRuntime(opts: { return out; } +function collectProjectSessions(opts: { + readonly projectName: string; + readonly repoRoot: string; + readonly muxSessions: readonly MuxSession[]; +}): readonly ProjectSession[] { + const projectRoot = canonicalPath(opts.repoRoot); + const out: ProjectSession[] = []; + + for (const session of opts.muxSessions) { + if ( + !isSessionForProject({ + sessionName: session.name, + sessionPath: session.path, + projectName: opts.projectName, + projectRoot, + }) + ) { + continue; + } + + out.push({ + name: session.name, + backend: session.backend, + source: classifySessionSource({ + sessionName: session.name, + projectName: opts.projectName, + }), + attached: session.attached, + path: session.path, + windows: session.windows, + createdAt: session.createdAt, + }); + } + + return out.sort((a, b) => { + if (a.source !== b.source) { + return a.source === "hack" ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); +} + +function classifySessionSource(opts: { + readonly sessionName: string; + readonly projectName: string; +}): "hack" | "external" { + if (matchesHackSessionName(opts)) { + return "hack"; + } + return "external"; +} + +function isSessionForProject(opts: { + readonly sessionName: string; + readonly sessionPath: string | null; + readonly projectName: string; + readonly projectRoot: string; +}): boolean { + if ( + matchesHackSessionName({ + sessionName: opts.sessionName, + projectName: opts.projectName, + }) + ) { + return true; + } + + if (!opts.sessionPath) { + return false; + } + const sessionPath = canonicalPath(opts.sessionPath); + return ( + sessionPath === opts.projectRoot || + sessionPath.startsWith(`${opts.projectRoot}/`) + ); +} + +function matchesHackSessionName(opts: { + readonly sessionName: string; + readonly projectName: string; +}): boolean { + const [sessionBase] = opts.sessionName.split(":"); + const normalizedProject = normalizeSessionToken(opts.projectName); + const normalizedSessionBase = normalizeSessionToken(sessionBase ?? ""); + if (normalizedProject.length === 0 || normalizedSessionBase.length === 0) { + return false; + } + return ( + opts.sessionName === opts.projectName || + opts.sessionName.startsWith(`${opts.projectName}:`) || + normalizedSessionBase === normalizedProject + ); +} + +function normalizeSessionToken(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-"); +} + +function canonicalPath(path: string): string { + const resolved = resolve(path); + try { + return realpathSync.native(resolved); + } catch { + return resolved; + } +} + +async function listMuxSessions(): Promise { + const [tmux, zellij] = await Promise.all([ + listTmuxSessions(), + listZellijSessions(), + ]); + return [...tmux, ...zellij]; +} + +async function listTmuxSessions(): Promise { + const separator = "|||HACK_SESSION_FIELD|||"; + const format = [ + "#{session_name}", + "#{session_attached}", + "#{session_path}", + "#{session_windows}", + "#{session_created}", + ].join(separator); + const result = await exec(["tmux", "list-sessions", "-F", format], { + stdin: "ignore", + }); + if (result.exitCode !== 0) { + return []; + } + + const out: MuxSession[] = []; + const lines = result.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + for (const line of lines) { + const fields = parseTmuxSessionFields(line, separator, 5); + if (!fields) { + continue; + } + const [name, attachedRaw, pathRaw, windowsRaw, createdAtRaw] = fields; + if (!name) { + continue; + } + const windows = windowsRaw ? Number.parseInt(windowsRaw, 10) : Number.NaN; + const createdAt = createdAtRaw + ? Number.parseInt(createdAtRaw, 10) + : Number.NaN; + out.push({ + name, + backend: "tmux", + attached: attachedRaw === "1", + path: pathRaw && pathRaw.length > 0 ? pathRaw : null, + windows: Number.isFinite(windows) ? windows : null, + createdAt: Number.isFinite(createdAt) ? createdAt : null, + }); + } + return out; +} + +function parseTmuxSessionFields( + line: string, + separator: string, + expectedCount: number +): readonly string[] | null { + const bySeparator = line.split(separator); + if (bySeparator.length === expectedCount) { + return bySeparator; + } + const byTab = line.split("\t"); + if (byTab.length === expectedCount) { + return byTab; + } + return null; +} + +async function listZellijSessions(): Promise { + const result = await exec(["zellij", "list-sessions", "--no-formatting"], { + stdin: "ignore", + }); + if (result.exitCode !== 0) { + return []; + } + + const out: MuxSession[] = []; + const lines = result.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + for (const line of lines) { + if (line.includes("(EXITED")) { + continue; + } + const name = parseZellijSessionName(line); + if (!name) { + continue; + } + out.push({ + name, + backend: "zellij", + attached: false, + path: null, + windows: null, + createdAt: null, + }); + } + + return out; +} + +function parseZellijSessionName(line: string): string | null { + const boundaries = [line.indexOf(" ["), line.indexOf(" (")].filter( + (index) => index > 0 + ); + const end = boundaries.length > 0 ? Math.min(...boundaries) : line.length; + const name = line.slice(0, end).trim(); + return name.length > 0 ? name : null; +} + async function readComposeServices(opts: { readonly composeFile: string; }): Promise { diff --git a/src/lib/runtime-projects.ts b/src/lib/runtime-projects.ts index 8e1bd413..6102fc58 100644 --- a/src/lib/runtime-projects.ts +++ b/src/lib/runtime-projects.ts @@ -19,16 +19,26 @@ export type RuntimeContainer = { readonly status: string; readonly name: string; readonly ports: string; - readonly image: string | null; - readonly ip: string | null; - readonly mounts: readonly RuntimeMount[]; - readonly labels: Readonly>; readonly workingDir: string | null; + readonly image: string | null; + readonly labels: Readonly> | null; + readonly mounts: readonly RuntimeContainerMount[]; + readonly networks: readonly RuntimeContainerNetwork[]; }; -export type RuntimeMount = { - readonly source: string | null; - readonly destination: string | null; +export type RuntimeContainerMount = { + readonly type: string; + readonly source: string; + readonly destination: string; + readonly mode: string; + readonly rw: boolean | null; +}; + +export type RuntimeContainerNetwork = { + readonly name: string; + readonly ipAddress: string | null; + readonly gateway: string | null; + readonly aliases: readonly string[]; }; export type RuntimeService = { @@ -50,6 +60,13 @@ export type RuntimeProjectsResult = { readonly checkedAtMs: number; }; +type ContainerInspectData = { + readonly labels: Record; + readonly image: string | null; + readonly mounts: readonly RuntimeContainerMount[]; + readonly networks: readonly RuntimeContainerNetwork[]; +}; + export function countRunningServices(runtime: RuntimeProject | null): number { if (!runtime) { return 0; @@ -78,40 +95,6 @@ export async function readRuntimeProjects(opts: { readonly includeGlobal: boolean; }): Promise { const checkedAtMs = Date.now(); - const psResult = await readDockerComposePs(); - if (!psResult.ok) { - return { ok: false, runtime: [], error: psResult.error, checkedAtMs }; - } - - const globalRoot = resolveGlobalHackRoot(); - const containers = buildRuntimeContainers({ - rows: psResult.rows, - inspectById: psResult.inspectById, - globalRoot, - includeGlobal: opts.includeGlobal, - }); - const out = buildRuntimeProjects({ - containers, - globalRoot, - }); - - return { - ok: true, - runtime: out.sort((a, b) => a.project.localeCompare(b.project)), - error: null, - checkedAtMs, - }; -} - -type DockerComposePsResult = - | { - readonly ok: true; - readonly rows: readonly unknown[]; - readonly inspectById: Map; - } - | { readonly ok: false; readonly error: string }; - -async function readDockerComposePs(): Promise { const res = await exec( [ "docker", @@ -127,116 +110,70 @@ async function readDockerComposePs(): Promise { if (res.exitCode !== 0) { return { ok: false, + runtime: [], error: formatDockerError({ exitCode: res.exitCode, stdout: res.stdout, stderr: res.stderr, }), + checkedAtMs, }; } - const rows = parseJsonLines(res.stdout); - const ids = rows + const baseRows = parseJsonLines(res.stdout); + const ids = baseRows .map((row) => getString(row, "ID") ?? getString(row, "Id") ?? "") .filter((id) => id.length > 0); - const inspectById = await readContainerInspectMeta({ ids }); - return { ok: true, rows, inspectById }; -} + const inspectById = await readContainerInspectData({ ids }); -function resolveGlobalHackRoot(): string { const home = process.env.HOME ?? ""; - return home ? resolve(home, GLOBAL_HACK_DIR_NAME) : ""; -} + const globalRoot = home ? resolve(home, GLOBAL_HACK_DIR_NAME) : ""; -function buildRuntimeContainers(opts: { - readonly rows: readonly unknown[]; - readonly inspectById: Map; - readonly globalRoot: string; - readonly includeGlobal: boolean; -}): RuntimeContainer[] { const containers: RuntimeContainer[] = []; - - for (const row of opts.rows) { - const container = parseRuntimeContainerRow({ - row, - inspectById: opts.inspectById, - globalRoot: opts.globalRoot, - }); - if (!container) { + for (const row of baseRows) { + const id = getString(row, "ID") ?? getString(row, "Id") ?? ""; + const state = getString(row, "State") ?? ""; + const status = getString(row, "Status") ?? ""; + const name = getString(row, "Names") ?? ""; + const ports = getString(row, "Ports") ?? ""; + const inspect = id.length > 0 ? inspectById.get(id) : undefined; + const labelsRaw = getString(row, "Labels"); + const labels = + inspect?.labels ?? + (labelsRaw ? parseLabelString({ raw: labelsRaw }) : {}); + const project = labels["com.docker.compose.project"] ?? null; + const service = labels["com.docker.compose.service"] ?? null; + const oneoff = + (labels["com.docker.compose.oneoff"] ?? "").toLowerCase() === "true"; + if (!(project && service) || oneoff) { continue; } - if ( - !opts.includeGlobal && - isGlobalWorkingDir({ - globalRoot: opts.globalRoot, - workingDir: container.workingDir, - }) - ) { + const workingDir = labels["com.docker.compose.project.working_dir"] ?? null; + const isGlobal = + globalRoot.length > 0 && workingDir + ? workingDir.startsWith(globalRoot) + : false; + if (isGlobal && !opts.includeGlobal) { continue; } - containers.push(container); - } - - return containers; -} - -function parseRuntimeContainerRow(opts: { - readonly row: unknown; - readonly inspectById: Map; - readonly globalRoot: string; -}): RuntimeContainer | null { - if (!isRecord(opts.row)) { - return null; - } - const row = opts.row; - - const id = getString(row, "ID") ?? getString(row, "Id") ?? ""; - const imageFromPs = getString(row, "Image") ?? null; - const labelsRaw = getString(row, "Labels"); - const inspect = id.length > 0 ? opts.inspectById.get(id) : undefined; - const labels = - inspect?.labels ?? (labelsRaw ? parseLabelString({ raw: labelsRaw }) : {}); - const project = labels["com.docker.compose.project"] ?? null; - const service = labels["com.docker.compose.service"] ?? null; - const oneoff = - (labels["com.docker.compose.oneoff"] ?? "").toLowerCase() === "true"; - if (!(project && service) || oneoff) { - return null; + containers.push({ + id, + project, + service, + state, + status, + name, + ports, + workingDir, + image: inspect?.image ?? null, + labels: Object.keys(labels).length > 0 ? labels : null, + mounts: inspect?.mounts ?? [], + networks: inspect?.networks ?? [], + }); } - const workingDir = labels["com.docker.compose.project.working_dir"] ?? null; - - return { - id, - project, - service, - state: getString(row, "State") ?? "", - status: getString(row, "Status") ?? "", - name: getString(row, "Names") ?? "", - ports: getString(row, "Ports") ?? "", - image: inspect?.image ?? imageFromPs, - ip: inspect?.ip ?? null, - mounts: inspect?.mounts ?? [], - labels, - workingDir, - }; -} - -function isGlobalWorkingDir(opts: { - readonly globalRoot: string; - readonly workingDir: string | null; -}): boolean { - return opts.globalRoot.length > 0 && opts.workingDir !== null - ? opts.workingDir.startsWith(opts.globalRoot) - : false; -} - -function buildRuntimeProjects(opts: { - readonly containers: readonly RuntimeContainer[]; - readonly globalRoot: string; -}): RuntimeProject[] { const byProject = new Map< string, { @@ -245,29 +182,20 @@ function buildRuntimeProjects(opts: { isGlobal: boolean; } >(); - - for (const container of opts.containers) { - const existing = byProject.get(container.project); - if (!existing) { - const byService = new Map(); - byService.set(container.service, [container]); - byProject.set(container.project, { - workingDir: container.workingDir, - byService, - isGlobal: isGlobalWorkingDir({ - globalRoot: opts.globalRoot, - workingDir: container.workingDir, - }), - }); - continue; - } - - const list = existing.byService.get(container.service); - if (list) { - list.push(container); - } else { - existing.byService.set(container.service, [container]); - } + for (const c of containers) { + const workingDir = c.workingDir; + const isGlobal = + globalRoot.length > 0 && workingDir + ? workingDir.startsWith(globalRoot) + : false; + const p = byProject.get(c.project) ?? { + workingDir, + byService: new Map(), + isGlobal, + }; + const arr = p.byService.get(c.service) ?? []; + p.byService.set(c.service, [...arr, c]); + byProject.set(c.project, p); } const out: RuntimeProject[] = []; @@ -284,7 +212,12 @@ function buildRuntimeProjects(opts: { }); } - return out; + return { + ok: true, + runtime: out.sort((a, b) => a.project.localeCompare(b.project)), + error: null, + checkedAtMs, + }; } export async function autoRegisterRuntimeHackProjects(opts: { @@ -331,72 +264,88 @@ export function serializeRuntimeProject( status: container.status, name: container.name, ports: container.ports, + working_dir: container.workingDir ?? null, image: container.image, - ip: container.ip, + labels: container.labels, mounts: container.mounts.map((mount) => ({ + type: mount.type, source: mount.source, destination: mount.destination, + mode: mount.mode, + rw: mount.rw, + })), + networks: container.networks.map((network) => ({ + name: network.name, + ip_address: network.ipAddress, + gateway: network.gateway, + aliases: network.aliases, })), - labels: container.labels, - working_dir: container.workingDir ?? null, })), })), }; } -type ContainerInspectMeta = { - readonly labels: Readonly>; - readonly image: string | null; - readonly ip: string | null; - readonly mounts: readonly RuntimeMount[]; -}; +export async function readContainerLabels(opts: { + readonly ids: readonly string[]; +}): Promise>> { + const inspectById = await readContainerInspectData({ ids: opts.ids }); + const labelsById = new Map>(); + for (const [id, detail] of inspectById.entries()) { + labelsById.set(id, detail.labels); + } + return labelsById; +} -export async function readContainerInspectMeta(opts: { +async function readContainerInspectData(opts: { readonly ids: readonly string[]; -}): Promise> { +}): Promise> { if (opts.ids.length === 0) { return new Map(); } - const res = await exec( - [ - "docker", - "inspect", - "--format", - "{{.Id}}\t{{.Config.Image}}\t{{json .Config.Labels}}\t{{json .Mounts}}\t{{json .NetworkSettings.Networks}}", - ...opts.ids, - ], - { stdin: "ignore" } - ); + const res = await exec(["docker", "inspect", ...opts.ids], { + stdin: "ignore", + }); if (res.exitCode !== 0) { return new Map(); } - const out = new Map(); - for (const line of res.stdout.split("\n")) { - const trimmed = line.trim(); - if (trimmed.length === 0) { + let parsed: unknown; + try { + parsed = JSON.parse(res.stdout); + } catch { + return new Map(); + } + if (!Array.isArray(parsed)) { + return new Map(); + } + + const out = new Map(); + for (const item of parsed) { + if (!isRecord(item)) { continue; } - const [idRaw, imageRaw, labelsRaw, mountsRaw, networksRaw] = - trimmed.split("\t"); - const id = (idRaw ?? "").trim(); + const id = getString(item, "Id") ?? ""; if (id.length === 0) { continue; } - const image = (imageRaw ?? "").trim(); - const meta: ContainerInspectMeta = { - labels: filterLabelsForUi(parseLabelsJson({ raw: labelsRaw ?? "" })), - image: image.length > 0 ? image : null, - ip: parseIpFromNetworksJson({ raw: networksRaw ?? "" }), - mounts: parseMountsJson({ raw: mountsRaw ?? "" }), + const config = item.Config; + const labels = parseInspectLabels(config); + const image = parseInspectImage(config); + const mounts = parseInspectMounts(item.Mounts); + const networks = parseInspectNetworks(item.NetworkSettings); + + const detail: ContainerInspectData = { + labels, + image, + mounts, + networks, }; - - out.set(id, meta); + out.set(id, detail); if (id.length >= 12) { - out.set(id.slice(0, 12), meta); + out.set(id.slice(0, 12), detail); } } @@ -413,110 +362,84 @@ function resolveProjectDirName(workingDir: string): ".hack" | ".dev" | null { return null; } -function parseLabelsJson(opts: { - readonly raw: string; -}): Record { - if (!opts.raw || opts.raw === "null") { - return {}; - } - let parsed: unknown; - try { - parsed = JSON.parse(opts.raw); - } catch { +function parseInspectLabels(config: unknown): Record { + if (!isRecord(config)) { return {}; } - if (!isRecord(parsed)) { + const labels = config.Labels; + if (!isRecord(labels)) { return {}; } - const out: Record = {}; - for (const [k, v] of Object.entries(parsed)) { - if (typeof v === "string") { - out[k] = v; - } - } - return out; -} - -function filterLabelsForUi( - labels: Record -): Readonly> { const out: Record = {}; for (const [key, value] of Object.entries(labels)) { - if (key.startsWith("caddy")) { - out[key] = value; - continue; - } - if (key.startsWith("com.docker.compose.")) { + if (typeof value === "string") { out[key] = value; } } return out; } -function parseMountsJson(opts: { - readonly raw: string; -}): readonly RuntimeMount[] { - const raw = opts.raw.trim(); - if (raw.length === 0 || raw === "null") { - return []; +function parseInspectImage(config: unknown): string | null { + if (!isRecord(config)) { + return null; } + return getString(config, "Image") ?? null; +} - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return []; - } - if (!Array.isArray(parsed)) { +function parseInspectMounts(raw: unknown): RuntimeContainerMount[] { + if (!Array.isArray(raw)) { return []; } - const out: RuntimeMount[] = []; - for (const entry of parsed) { - if (!isRecord(entry)) { + const mounts: RuntimeContainerMount[] = []; + for (const value of raw) { + if (!isRecord(value)) { continue; } - const source = - getString(entry, "Source") ?? getString(entry, "Name") ?? null; - const destination = getString(entry, "Destination") ?? null; - if (!(source || destination)) { + const source = getString(value, "Source") ?? ""; + const destination = getString(value, "Destination") ?? ""; + if (!(source && destination)) { continue; } - out.push({ source, destination }); + const rwValue = value.RW; + mounts.push({ + type: getString(value, "Type") ?? "", + source, + destination, + mode: getString(value, "Mode") ?? "", + rw: typeof rwValue === "boolean" ? rwValue : null, + }); } - return out; + return mounts; } -function parseIpFromNetworksJson(opts: { - readonly raw: string; -}): string | null { - const raw = opts.raw.trim(); - if (raw.length === 0 || raw === "null") { - return null; - } - - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return null; +function parseInspectNetworks(raw: unknown): RuntimeContainerNetwork[] { + if (!isRecord(raw)) { + return []; } - if (!isRecord(parsed)) { - return null; + const networks = raw.Networks; + if (!isRecord(networks)) { + return []; } - for (const value of Object.values(parsed)) { + const out: RuntimeContainerNetwork[] = []; + for (const [name, value] of Object.entries(networks)) { if (!isRecord(value)) { continue; } - const ip = getString(value, "IPAddress"); - if (ip && ip.length > 0) { - return ip; - } + const aliasesRaw = value.Aliases; + const aliases = Array.isArray(aliasesRaw) + ? aliasesRaw.filter((alias): alias is string => typeof alias === "string") + : []; + out.push({ + name, + ipAddress: getString(value, "IPAddress") ?? null, + gateway: getString(value, "Gateway") ?? null, + aliases, + }); } - - return null; + return out; } function parseLabelString(opts: { diff --git a/tests/config-command.test.ts b/tests/config-command.test.ts new file mode 100644 index 00000000..65b1c555 --- /dev/null +++ b/tests/config-command.test.ts @@ -0,0 +1,173 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +let tempDir: string | null = null; +let originalHome: string | undefined; +let originalLogger: string | undefined; +let originalGlobalConfigPath: string | undefined; + +beforeEach(async () => { + originalHome = process.env.HOME; + originalLogger = process.env.HACK_LOGGER; + originalGlobalConfigPath = process.env.HACK_GLOBAL_CONFIG_PATH; + tempDir = await mkdtemp(join(tmpdir(), "hack-config-command-")); + process.env.HOME = tempDir; + process.env.HACK_LOGGER = "console"; + process.env.HACK_GLOBAL_CONFIG_PATH = join(tempDir, "hack.config.json"); +}); + +afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = null; + } + process.env.HOME = originalHome; + process.env.HACK_LOGGER = originalLogger; + process.env.HACK_GLOBAL_CONFIG_PATH = originalGlobalConfigPath; +}); + +test("config set --global updates extension enabled using bracket path", async () => { + const configPath = await writeBaseGlobalConfig(); + const { runCli } = await import("../src/cli/run.ts"); + const exitCode = await runCli([ + "config", + "set", + "--global", + 'controlPlane.extensions["dance.hack.cloudflare"].enabled', + "false", + ]); + expect(exitCode).toBe(0); + + const parsed = JSON.parse(await readFile(configPath, "utf8")); + expect(parsed.controlPlane.extensions["dance.hack.cloudflare"].enabled).toBe( + false + ); + expect(parsed.controlPlane["dance.hack.cloudflare"]).toBeUndefined(); +}); + +test("config set --global updates extension config hostname using bracket path", async () => { + const configPath = await writeBaseGlobalConfig(); + const { runCli } = await import("../src/cli/run.ts"); + const exitCode = await runCli([ + "config", + "set", + "--global", + 'controlPlane.extensions["dance.hack.cloudflare"].config.hostname', + "gateway.example.com", + ]); + expect(exitCode).toBe(0); + + const parsed = JSON.parse(await readFile(configPath, "utf8")); + expect( + parsed.controlPlane.extensions["dance.hack.cloudflare"].config.hostname + ).toBe("gateway.example.com"); + expect(parsed.controlPlane["dance.hack.cloudflare"]).toBeUndefined(); +}); + +test("config set --global migrates legacy controlPlane extension path to extensions map", async () => { + const configPath = await writeLegacyGlobalConfig(); + const { runCli } = await import("../src/cli/run.ts"); + const exitCode = await runCli([ + "config", + "set", + "--global", + 'controlPlane["dance.hack.cloudflare"].enabled', + "false", + ]); + expect(exitCode).toBe(0); + + const parsed = JSON.parse(await readFile(configPath, "utf8")); + expect(parsed.controlPlane.extensions["dance.hack.cloudflare"].enabled).toBe( + false + ); + expect(parsed.controlPlane["dance.hack.cloudflare"]).toBeUndefined(); +}); + +test("config set --global cleans stale legacy cloudflare mirror on canonical updates", async () => { + const configPath = await writeLegacyGlobalConfig(); + const { runCli } = await import("../src/cli/run.ts"); + const exitCode = await runCli([ + "config", + "set", + "--global", + 'controlPlane.extensions["dance.hack.cloudflare"].config.hostname', + "gateway.cleaned.test", + ]); + expect(exitCode).toBe(0); + + const parsed = JSON.parse(await readFile(configPath, "utf8")); + expect( + parsed.controlPlane.extensions["dance.hack.cloudflare"].config.hostname + ).toBe("gateway.cleaned.test"); + expect(parsed.controlPlane["dance.hack.cloudflare"]).toBeUndefined(); +}); + +async function writeBaseGlobalConfig(): Promise { + const configPath = globalConfigPathForTest(); + await mkdir(dirname(configPath), { recursive: true }); + await writeFile( + configPath, + `${JSON.stringify( + { + controlPlane: { + extensions: { + "dance.hack.cloudflare": { + enabled: true, + config: { + hostname: "gateway.initial.test", + sshHostname: "ssh.initial.test", + }, + }, + }, + }, + }, + null, + 2 + )}\n` + ); + return configPath; +} + +async function writeLegacyGlobalConfig(): Promise { + const configPath = globalConfigPathForTest(); + await mkdir(dirname(configPath), { recursive: true }); + await writeFile( + configPath, + `${JSON.stringify( + { + controlPlane: { + extensions: { + "dance.hack.cloudflare": { + enabled: true, + config: { + hostname: "gateway.initial.test", + sshHostname: "ssh.initial.test", + }, + }, + }, + "dance.hack.cloudflare": { + enabled: true, + config: { + hostname: "gateway.legacy.test", + }, + }, + }, + }, + null, + 2 + )}\n` + ); + return configPath; +} + +function globalConfigPathForTest(): string { + const configured = (process.env.HACK_GLOBAL_CONFIG_PATH ?? "").trim(); + if (configured.length === 0) { + throw new Error( + "HACK_GLOBAL_CONFIG_PATH must be set for config command tests" + ); + } + return configured; +} diff --git a/tests/daemon-sessions.test.ts b/tests/daemon-sessions.test.ts index cf4dac93..432458e2 100644 --- a/tests/daemon-sessions.test.ts +++ b/tests/daemon-sessions.test.ts @@ -112,6 +112,19 @@ describe.skipIf(!hasTmux)("handleSessionRoutes", () => { expect(result).not.toBeNull(); expect(result?.status).toBe(404); }); + + test("returns 400 for invalid session id in path", async () => { + const req = mockRequest({ + method: "GET", + path: "/v1/sessions/invalid%20name", + }); + const url = new URL(req.url); + const result = await handleSessionRoutes({ req, url }); + expect(result).not.toBeNull(); + expect(result?.status).toBe(400); + const body = await parseResponse(result!); + expect(body?.error).toContain("invalid_name"); + }); }); describe("session name validation", () => { diff --git a/tests/global-command.test.ts b/tests/global-command.test.ts index b2afe59a..c40a841a 100644 --- a/tests/global-command.test.ts +++ b/tests/global-command.test.ts @@ -4,6 +4,8 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { + DEFAULT_CADDY_IP, + DEFAULT_COREDNS_IP, DEFAULT_INGRESS_NETWORK, DEFAULT_LOGGING_NETWORK, GLOBAL_CADDY_COMPOSE_FILENAME, @@ -75,6 +77,25 @@ async function writeComposeFile(path: string): Promise { await writeFile(path, "services: {}\n"); } +async function writeStaticCaddyCompose(path: string): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile( + path, + [ + "services:", + " caddy:", + " networks:", + " default:", + ` ipv4_address: ${DEFAULT_CADDY_IP}`, + " coredns:", + " networks:", + " default:", + ` ipv4_address: ${DEFAULT_COREDNS_IP}`, + "", + ].join("\n") + ); +} + test("global up runs docker compose up for caddy and logging", async () => { const caddyCompose = join( tempDir!, @@ -107,6 +128,80 @@ test("global up runs docker compose up for caddy and logging", async () => { ).toBe(true); }); +test("global up reassigns containers when reserved ingress IPs are occupied", async () => { + const caddyCompose = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + GLOBAL_CADDY_COMPOSE_FILENAME + ); + const loggingCompose = join( + tempDir!, + GLOBAL_HACK_DIR_NAME, + GLOBAL_LOGGING_DIR_NAME, + GLOBAL_LOGGING_COMPOSE_FILENAME + ); + await writeStaticCaddyCompose(caddyCompose); + await writeComposeFile(loggingCompose); + + let inspectCallCount = 0; + execMockResponder = (cmd) => { + if ( + cmd[0] === "docker" && + cmd[1] === "network" && + cmd[2] === "inspect" && + cmd[3] === DEFAULT_INGRESS_NETWORK + ) { + inspectCallCount += 1; + if (inspectCallCount === 1) { + return { + exitCode: 0, + stdout: JSON.stringify([ + { + Containers: { + abc123: { + Name: "omega-temporal-server-1", + IPv4Address: `${DEFAULT_CADDY_IP}/16`, + }, + }, + }, + ]), + stderr: "", + }; + } + return { + exitCode: 0, + stdout: JSON.stringify([{ Containers: {} }]), + stderr: "", + }; + } + + return null; + }; + + const { runCli } = await import("../src/cli/run.ts"); + const code = await runCli(["global", "up"]); + expect(code).toBe(0); + + expect( + execCalls.some( + (call) => + call.join(" ") === + `docker network disconnect -f ${DEFAULT_INGRESS_NETWORK} omega-temporal-server-1` + ) + ).toBe(true); + expect( + execCalls.some( + (call) => + call.join(" ") === + `docker network connect ${DEFAULT_INGRESS_NETWORK} omega-temporal-server-1` + ) + ).toBe(true); + expect( + runCalls.some((call) => call.includes(caddyCompose) && call.includes("up")) + ).toBe(true); +}); + test("global down runs docker compose down when files exist", async () => { const caddyCompose = join( tempDir!, diff --git a/tests/project-views.test.ts b/tests/project-views.test.ts index 2c52704a..a406f0ea 100644 --- a/tests/project-views.test.ts +++ b/tests/project-views.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { PROJECT_COMPOSE_FILENAME } from "../src/constants.ts"; @@ -100,11 +100,14 @@ function makeContainer(opts: { status: opts.state === "running" ? "Up 5s" : "Exited (0)", name: opts.name, ports: "", - image: null, - ip: null, - mounts: [], - labels: {}, workingDir: `/tmp/${opts.project}/.hack`, + image: "imbios/bun-node:latest", + labels: { + "com.docker.compose.project": opts.project, + "com.docker.compose.service": opts.service, + }, + mounts: [], + networks: [], }; } @@ -151,6 +154,7 @@ test("buildProjectViews includes defined services and runtime status", async () runtimeOk: true, filter: null, includeUnregistered: true, + muxSessions: [], }); const alphaView = views.find((view) => view.name === "alpha"); @@ -181,8 +185,138 @@ test("buildProjectViews marks runtime status unknown when runtime is unavailable runtimeOk: false, filter: null, includeUnregistered: false, + muxSessions: [], }); const alphaView = views.find((view) => view.name === "alpha"); expect(alphaView?.status).toBe("unknown"); }); + +test("buildProjectViews includes matching project sessions from tmux", async () => { + const alpha = await createProject({ name: "alpha", services: ["api"] }); + const views = await buildProjectViews({ + registryProjects: [alpha], + runtime: [], + runtimeOk: true, + filter: null, + includeUnregistered: false, + muxSessions: [ + { + name: "alpha", + backend: "tmux", + attached: true, + path: alpha.repoRoot, + windows: 2, + createdAt: 1_735_000_000, + }, + { + name: "alpha:agent-1", + backend: "tmux", + attached: false, + path: join(alpha.repoRoot, "apps"), + windows: 1, + createdAt: 1_735_000_123, + }, + { + name: "manual-scratch", + backend: "tmux", + attached: false, + path: alpha.repoRoot, + windows: 1, + createdAt: 1_735_000_456, + }, + { + name: "other", + backend: "tmux", + attached: false, + path: "/tmp/other", + windows: 1, + createdAt: 1_735_000_789, + }, + ], + }); + + const alphaView = views.find((view) => view.name === "alpha"); + expect(alphaView?.sessions.map((session) => session.name)).toEqual([ + "alpha", + "alpha:agent-1", + "manual-scratch", + ]); + expect(alphaView?.sessions.map((session) => session.source)).toEqual([ + "hack", + "hack", + "external", + ]); + + const serialized = alphaView ? serializeProjectView(alphaView) : null; + const serializedSessions = serialized?.sessions as + | Record[] + | undefined; + expect(serializedSessions?.length).toBe(3); + expect(serializedSessions?.[0]?.name).toBe("alpha"); + expect(serializedSessions?.[0]?.backend).toBe("tmux"); + expect(serializedSessions?.[0]?.source).toBe("hack"); +}); + +test("buildProjectViews matches tmux sessions when path is a symlink to repo root", async () => { + const alpha = await createProject({ name: "alpha", services: ["api"] }); + if (!tempDir) { + throw new Error("tempDir not set"); + } + + const aliasPath = join(tempDir, "alpha-alias"); + await symlink(alpha.repoRoot, aliasPath); + + const views = await buildProjectViews({ + registryProjects: [alpha], + runtime: [], + runtimeOk: true, + filter: null, + includeUnregistered: false, + muxSessions: [ + { + name: "manual-alpha-shell", + backend: "tmux", + attached: false, + path: aliasPath, + windows: 1, + createdAt: 1_735_111_000, + }, + ], + }); + + const alphaView = views.find((view) => view.name === "alpha"); + expect(alphaView?.sessions.map((session) => session.name)).toEqual([ + "manual-alpha-shell", + ]); +}); + +test("buildProjectViews includes zellij sessions when session name matches project name", async () => { + const alpha = await createProject({ name: "alpha", services: ["api"] }); + + const views = await buildProjectViews({ + registryProjects: [alpha], + runtime: [], + runtimeOk: true, + filter: null, + includeUnregistered: false, + muxSessions: [ + { + name: "alpha:research", + backend: "zellij", + attached: false, + path: null, + windows: null, + createdAt: null, + }, + ], + }); + + const alphaView = views.find((view) => view.name === "alpha"); + expect(alphaView?.sessions.map((session) => session.name)).toEqual([ + "alpha:research", + ]); + expect(alphaView?.sessions.map((session) => session.backend)).toEqual([ + "zellij", + ]); +}); diff --git a/tests/runtime-cache.test.ts b/tests/runtime-cache.test.ts index 40f196fd..e873c979 100644 --- a/tests/runtime-cache.test.ts +++ b/tests/runtime-cache.test.ts @@ -148,6 +148,7 @@ test("getProjectsPayload keeps working when resolveProjectMeta fails for one pro runtimeStatus: "unknown", runtime: null, branchRuntime: [], + sessions: [], kind: "registered", status: "unknown", }); diff --git a/tests/runtime-projects.test.ts b/tests/runtime-projects.test.ts index 3af00765..6b063e19 100644 --- a/tests/runtime-projects.test.ts +++ b/tests/runtime-projects.test.ts @@ -26,11 +26,29 @@ function makeContainer(opts: { status: opts.status, name: opts.name, ports: opts.ports ?? "", - image: null, - ip: null, - mounts: [], - labels: {}, workingDir: `/tmp/${opts.project}/.hack`, + image: "imbios/bun-node:latest", + labels: { + "com.docker.compose.project": opts.project, + "com.docker.compose.service": opts.service, + }, + mounts: [ + { + type: "bind", + source: `/tmp/${opts.project}`, + destination: "/app", + mode: "", + rw: true, + }, + ], + networks: [ + { + name: "default", + ipAddress: "172.30.0.10", + gateway: "172.30.0.1", + aliases: [opts.service], + }, + ], }; } @@ -123,4 +141,26 @@ test("serializeRuntimeProject includes container ports", () => { expect(services[0]?.service).toBe("api"); const containers = services[0]?.containers as Record[]; expect(containers[0]?.ports).toBe("8080/tcp"); + expect(containers[0]?.image).toBe("imbios/bun-node:latest"); + expect(containers[0]?.labels).toEqual({ + "com.docker.compose.project": "alpha", + "com.docker.compose.service": "api", + }); + expect(containers[0]?.mounts).toEqual([ + { + type: "bind", + source: "/tmp/alpha", + destination: "/app", + mode: "", + rw: true, + }, + ]); + expect(containers[0]?.networks).toEqual([ + { + name: "default", + ip_address: "172.30.0.10", + gateway: "172.30.0.1", + aliases: ["api"], + }, + ]); });