diff --git a/.hack/hack.config.json b/.hack/hack.config.json index f08517b4..070c4d32 100644 --- a/.hack/hack.config.json +++ b/.hack/hack.config.json @@ -4,7 +4,9 @@ "dev_host": "hack-cli.hack", "controlPlane": { "extensions": { - "dance.hack.tickets": { "enabled": true } + "dance.hack.tickets": { + "enabled": true + } } } } diff --git a/README.md b/README.md index 82a021a0..249162e2 100644 --- a/README.md +++ b/README.md @@ -256,11 +256,11 @@ hack daemon logs On macOS, you can install `hackd` as a launchd service for automatic management: ```bash -# Install with auto-start on login -hack daemon install --run-at-load +# Install (auto-start on login by default) +hack daemon install # Install without auto-start (manual start/stop via launchd) -hack daemon install +hack daemon install --no-run-at-load # Uninstall the launchd service hack daemon uninstall diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift index bf31dddc..82781fc4 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift @@ -24,7 +24,7 @@ public struct DashboardView: View { @Bindable var model = model GeometryReader { proxy in - ZStack { + ZStack(alignment: .top) { VSplitView { ZStack { mainSplitView @@ -67,6 +67,11 @@ public struct DashboardView: View { .background(alignment: .top) { topHeaderChrome } + GlobalStatusStrip(placement: .titlebar) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 8) + .offset(y: -48) + .zIndex(16) } // 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). @@ -99,10 +104,6 @@ public struct DashboardView: View { } } } - ToolbarItem(placement: .principal) { - GlobalStatusStrip(placement: .titlebar) - .frame(maxWidth: .infinity, alignment: .center) - } ToolbarItemGroup(placement: .primaryAction) { ToolbarIconButton( systemImage: "gearshape", @@ -312,21 +313,11 @@ public struct DashboardView: View { } private var titlebarNeutralIconTint: NSColor { - titlebarIconTint(lightOpacity: 0.72, darkOpacity: 0.90) + NSColor.labelColor.withAlphaComponent(0.84) } 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) - } + NSColor.labelColor } private var shouldShowGlobalRecoveryOverlay: Bool { diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GlobalStatusStrip.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GlobalStatusStrip.swift index 412f4b6e..9afb9952 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GlobalStatusStrip.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GlobalStatusStrip.swift @@ -12,6 +12,17 @@ struct GlobalStatusStrip: View { @Environment(DashboardModel.self) private var model @Environment(\.colorScheme) private var colorScheme let placement: GlobalStatusPlacement + @State private var isSelectorHeaderHovered = false + @State private var isSelectorPanelHovered = false + @State private var isSelectorExpanded = false + @State private var isSelectorListVisible = false + @State private var selectorUsesExpandedCorners = false + @State private var hoveredSelectorProjectId: String? = nil + @State private var selectorExpandTask: Task? = nil + @State private var selectorCollapseTask: Task? = nil + @State private var selectorListRevealTask: Task? = nil + @State private var selectorCornerResetTask: Task? = nil + @State private var stripContentWidth: CGFloat = 0 init(placement: GlobalStatusPlacement = .content) { self.placement = placement @@ -113,15 +124,69 @@ struct GlobalStatusStrip: View { .menuIndicator(.hidden) .buttonStyle(.plain) } - .frame(minHeight: placement == .titlebar ? 30 : 0) + .frame( + minHeight: placement == .titlebar ? selectorContainerHeight : 0, + maxHeight: placement == .titlebar ? selectorContainerHeight : nil, + alignment: selectorContainerAlignment + ) .padding(.horizontal, placement == .titlebar ? 10 : 0) - .padding(.vertical, placement == .titlebar ? 4 : 6) - .background(titlebarPillBackground) + .padding(.vertical, placement == .titlebar ? 0 : 6) + .contentShape(Rectangle()) + .background(alignment: .topLeading) { + titlebarPillBackground + } + .overlay(alignment: .topLeading) { + if placement == .titlebar, isSelectorExpanded { + selectorExpandedPanel + .offset(y: selectorHeaderHeight) + .transition( + .asymmetric( + insertion: .move(edge: .top).combined(with: .opacity), + removal: .opacity + ) + ) + } + } + .background { + GeometryReader { proxy in + Color.clear + .onAppear { + updateStripContentWidth(proxy.size.width) + } + .onChange(of: proxy.size.width) { _, width in + updateStripContentWidth(width) + } + } + } .fixedSize(horizontal: placement == .titlebar, vertical: false) .animation(.easeInOut(duration: 0.18), value: stripLayoutSignature) + .onHover { hovering in + guard placement == .titlebar else { return } + isSelectorHeaderHovered = hovering + updateSelectorExpansionFromHover() + } + .onDisappear { + selectorExpandTask?.cancel() + selectorCollapseTask?.cancel() + selectorListRevealTask?.cancel() + selectorCornerResetTask?.cancel() + selectorExpandTask = nil + selectorCollapseTask = nil + selectorListRevealTask = nil + selectorCornerResetTask = nil + } } + @ViewBuilder private var selectorPill: some View { + if placement == .titlebar { + titlebarSelectorPill + } else { + selectorMenu + } + } + + private var selectorMenu: some View { Menu { Button("Dashboard") { model.selectedItem = .home @@ -163,38 +228,167 @@ struct GlobalStatusStrip: View { } .padding(.horizontal, placement == .titlebar ? 4 : 12) .padding(.vertical, placement == .titlebar ? 2 : 6) - .background(selectorBackground) + .background(selectorPillBackground) } .menuStyle(.borderlessButton) .menuIndicator(.hidden) } + private var titlebarSelectorPill: some View { + selectorHeader + .onTapGesture { + toggleSelectorExpanded() + } + } + + private var selectorHeader: some View { + HStack(spacing: 8) { + Image(systemName: selectorIcon) + .font(.mono(.caption, weight: .semibold)) + 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) + .rotationEffect(.degrees(isSelectorExpanded ? 180 : 0)) + .offset(y: 0.5) + } + .padding(.horizontal, placement == .titlebar ? 8 : 12) + .padding(.vertical, placement == .titlebar ? 4 : 6) + .background(selectorHeaderBackground) + .contentShape(RoundedRectangle(cornerRadius: selectorCornerRadius, style: .continuous)) + .animation(.easeInOut(duration: 0.2), value: isSelectorExpanded) + } + @ViewBuilder - private var selectorBackground: some View { + private var selectorHeaderBackground: some View { if placement == .titlebar { - EmptyView() + Color.clear } else { selectorPillBackground } } + private var selectorExpandedPanel: some View { + VStack(spacing: 0) { + Divider() + .opacity(0.22) + .padding(.horizontal, 8) + + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(selectorProjects.enumerated()), id: \.element.id) { index, project in + titlebarSelectorProjectRow(project: project) + .opacity(isSelectorListVisible ? 1 : 0) + .offset(y: isSelectorListVisible ? 0 : 6) + .animation( + .spring(response: 0.34, dampingFraction: 0.85) + .delay(Double(index) * 0.025), + value: isSelectorListVisible + ) + + if index != selectorProjects.count - 1 { + Divider() + .opacity(0.20) + .padding(.horizontal, 8) + } + } + } + .padding(.vertical, 8) + .padding(.horizontal, 8) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .frame(width: selectorExpandedWidth, height: selectorPanelHeight, alignment: .topLeading) + .onHover { hovering in + isSelectorPanelHovered = hovering + updateSelectorExpansionFromHover() + } + } + + private func titlebarSelectorProjectRow(project: ProjectSummary) -> some View { + Button { + withAnimation(.easeInOut(duration: 0.18)) { + model.selectedItem = .project(project.id) + isSelectorExpanded = false + } + selectorExpandTask?.cancel() + selectorExpandTask = nil + } label: { + HStack(spacing: 10) { + Circle() + .fill(selectorStatusColor(for: project)) + .frame(width: 7, height: 7) + + VStack(alignment: .leading, spacing: 3) { + Text(project.name) + .font(.mono(.caption, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.tail) + if let host = project.devHost, !host.isEmpty { + Text(host) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + } + + Spacer(minLength: 8) + + Text(selectorStatusLabel(for: project)) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 8) + .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(selectorRowBackground(for: project)) + ) + } + .buttonStyle(.plain) + .onHover { hovering in + if hovering { + hoveredSelectorProjectId = project.id + } else if hoveredSelectorProjectId == project.id { + hoveredSelectorProjectId = nil + } + } + } + @ViewBuilder private var titlebarPillBackground: some View { if placement == .titlebar { - RoundedRectangle(cornerRadius: 999, style: .continuous) + RoundedRectangle(cornerRadius: selectorCornerRadius, style: .continuous) .fill(.regularMaterial) .overlay( - RoundedRectangle(cornerRadius: 999, style: .continuous) + RoundedRectangle(cornerRadius: selectorCornerRadius, style: .continuous) .fill(titlebarPillTint) ) .overlay( - RoundedRectangle(cornerRadius: 999, style: .continuous) + RoundedRectangle(cornerRadius: selectorCornerRadius, style: .continuous) .strokeBorder( titlebarPillStroke, lineWidth: 1 ) ) - .shadow(color: titlebarPillShadow, radius: 18, x: 0, y: 10) + .shadow( + color: titlebarPillShadow.opacity(isSelectorExpanded ? 1 : 0), + radius: isSelectorExpanded ? 20 : 0, + x: 0, + y: isSelectorExpanded ? 12 : 0 + ) + .frame( + width: isSelectorExpanded ? selectorExpandedWidth : nil, + height: selectorContainerHeight, + alignment: .topLeading + ) } else { EmptyView() } @@ -511,6 +705,232 @@ struct GlobalStatusStrip: View { } } + private var selectorProjects: [ProjectSummary] { + model.projects.sorted { lhs, rhs in + let lhsActive = isProjectActive(lhs) + let rhsActive = isProjectActive(rhs) + if lhsActive != rhsActive { + return lhsActive && !rhsActive + } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + } + + private var selectorExpandedWidth: CGFloat { + max(stripContentWidth, 420) + } + + private var selectorProjectListMaxHeight: CGFloat { + 320 + } + + private var selectorHeaderHeight: CGFloat { + 34 + } + + private var selectorProjectRowHeight: CGFloat { + 56 + } + + private var selectorPanelHeight: CGFloat { + guard isSelectorExpanded else { return 0 } + let rowsHeight = CGFloat(selectorProjects.count) * selectorProjectRowHeight + let listPadding: CGFloat = 16 + let dividersHeight = CGFloat(max(0, selectorProjects.count - 1)) + let desired = rowsHeight + listPadding + dividersHeight + 1 + return min(selectorProjectListMaxHeight + 1, max(72, desired)) + } + + private var selectorContainerHeight: CGFloat { + selectorHeaderHeight + selectorPanelHeight + } + + private var selectorContainerAlignment: Alignment { + if placement == .titlebar, isSelectorExpanded { + return .topLeading + } + return .center + } + + private var selectorExpandedCornerRadius: CGFloat { + 16 + } + + private var selectorCornerRadius: CGFloat { + selectorUsesExpandedCorners ? selectorExpandedCornerRadius : 999 + } + + private var selectorOpenDelayNanoseconds: UInt64 { + 500_000_000 + } + + private var selectorCloseDelayNanoseconds: UInt64 { + 180_000_000 + } + + private var selectorListRevealDelayNanoseconds: UInt64 { + 170_000_000 + } + + private func toggleSelectorExpanded() { + if isSelectorExpanded { + collapseSelectorPanel() + return + } + expandSelectorPanel() + } + + private func updateSelectorExpansionFromHover() { + guard placement == .titlebar else { return } + let hoveringSelector = isSelectorHeaderHovered || isSelectorPanelHovered + if hoveringSelector { + selectorCollapseTask?.cancel() + selectorCollapseTask = nil + scheduleSelectorExpansion() + } else { + selectorExpandTask?.cancel() + selectorExpandTask = nil + scheduleSelectorCollapse() + } + } + + private func scheduleSelectorExpansion() { + if isSelectorExpanded || selectorExpandTask != nil { + return + } + selectorExpandTask = Task { + try? await Task.sleep(nanoseconds: selectorOpenDelayNanoseconds) + guard !Task.isCancelled else { return } + await MainActor.run { + selectorExpandTask = nil + guard isSelectorHeaderHovered || isSelectorPanelHovered else { return } + expandSelectorPanel() + } + } + } + + private func expandSelectorPanel() { + selectorExpandTask?.cancel() + selectorExpandTask = nil + selectorCollapseTask?.cancel() + selectorCollapseTask = nil + selectorListRevealTask?.cancel() + selectorListRevealTask = nil + selectorCornerResetTask?.cancel() + selectorCornerResetTask = nil + isSelectorListVisible = false + + withAnimation(.easeOut(duration: 0.08)) { + selectorUsesExpandedCorners = true + } + withAnimation(.spring(response: 0.34, dampingFraction: 0.90)) { + isSelectorExpanded = true + } + + selectorListRevealTask = Task { + try? await Task.sleep(nanoseconds: selectorListRevealDelayNanoseconds) + guard !Task.isCancelled else { return } + await MainActor.run { + guard isSelectorExpanded else { return } + withAnimation(.easeInOut(duration: 0.12)) { + isSelectorListVisible = true + } + selectorListRevealTask = nil + } + } + } + + private func scheduleSelectorCollapse() { + if selectorCollapseTask != nil { + return + } + selectorCollapseTask = Task { + try? await Task.sleep(nanoseconds: selectorCloseDelayNanoseconds) + guard !Task.isCancelled else { return } + await MainActor.run { + selectorCollapseTask = nil + guard !(isSelectorHeaderHovered || isSelectorPanelHovered) else { return } + collapseSelectorPanel() + } + } + } + + private func collapseSelectorPanel() { + selectorExpandTask?.cancel() + selectorExpandTask = nil + selectorCollapseTask?.cancel() + selectorCollapseTask = nil + selectorListRevealTask?.cancel() + selectorListRevealTask = nil + guard isSelectorExpanded else { return } + hoveredSelectorProjectId = nil + isSelectorListVisible = false + withAnimation(.easeInOut(duration: 0.16)) { + isSelectorExpanded = false + } + selectorCornerResetTask?.cancel() + selectorCornerResetTask = Task { + try? await Task.sleep(nanoseconds: 140_000_000) + guard !Task.isCancelled else { return } + await MainActor.run { + guard !isSelectorExpanded else { return } + withAnimation(.easeOut(duration: 0.08)) { + selectorUsesExpandedCorners = false + } + selectorCornerResetTask = nil + } + } + } + + private func updateStripContentWidth(_ width: CGFloat) { + guard width.isFinite, width > 0 else { return } + if abs(stripContentWidth - width) > 0.5 { + stripContentWidth = width + } + } + + private func isProjectActive(_ project: ProjectSummary) -> Bool { + project.status == .running || project.runtimeStatus == .running + } + + private func selectorStatusLabel(for project: ProjectSummary) -> String { + if isProjectActive(project) { + return "Running" + } + if project.status == .missing { + return "Missing" + } + if project.isRuntimeConfigured { + return "Stopped" + } + return "Unknown" + } + + private func selectorStatusColor(for project: ProjectSummary) -> Color { + if isProjectActive(project) { + return .green + } + if project.status == .missing { + return .orange + } + return .secondary + } + + private func selectorRowBackground(for project: ProjectSummary) -> Color { + let isSelected: Bool = { + guard case let .project(id) = model.selectedItem else { return false } + return id == project.id + }() + let isHovered = hoveredSelectorProjectId == project.id + if isSelected { + return Color.primary.opacity(colorScheme == .dark ? 0.15 : 0.08) + } + if isHovered { + return Color.primary.opacity(colorScheme == .dark ? 0.10 : 0.06) + } + return .clear + } + private var titlebarIconForeground: Color { dynamicColor( light: NSColor.black.withAlphaComponent(0.74), diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/HomeDashboardView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/HomeDashboardView.swift index 7dd50749..db00155a 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/HomeDashboardView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/HomeDashboardView.swift @@ -4,11 +4,17 @@ import HackDesktopModels struct HomeDashboardView: View { @Environment(DashboardModel.self) private var model + @AppStorage("hackDesktop.preferences.defaultTerminal") private var preferredExternalTerminalRaw = + TerminalIntegration.ExternalTerminalApp.terminal.rawValue + @AppStorage("hackDesktop.sessions.preferredExternalTerminal") private var legacyPreferredExternalTerminalRaw = + TerminalIntegration.ExternalTerminalApp.terminal.rawValue + @State private var showDetachedSessions = false var body: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { healthCard + sessionsCard projectsCard } .padding(16) @@ -39,27 +45,96 @@ struct HomeDashboardView: View { title: "Running", count: runningProjects.count, projects: runningProjects, - emptyMessage: "No running projects." + emptyMessage: "No running projects.", + topSpacing: 0 ) - Divider() - .opacity(0.24) - .padding(.vertical, 8) projectGroupSection( title: "Not running", count: stoppedProjects.count, projects: stoppedProjects, - emptyMessage: "No stopped projects." + emptyMessage: "No stopped projects.", + topSpacing: runningProjects.isEmpty ? 0 : 12 ) } } } + private var sessionsCard: some View { + GlassCard(title: "Sessions", systemImage: "rectangle.3.group.bubble.left") { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text("\(activeSessions.count) active") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + Text("(\(attachedSessions.count) attached, \(detachedSessions.count) detached)") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + } + + if activeSessions.isEmpty { + Text("No active hack sessions.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + .padding(.leading, 8) + } else { + VStack(alignment: .leading, spacing: 0) { + if !attachedSessions.isEmpty { + sessionGroupSection( + title: "Attached", + count: attachedSessions.count, + entries: attachedSessions, + topSpacing: 0 + ) + } + if attachedSessions.isEmpty && !detachedSessions.isEmpty && !showDetachedSessions { + Text("No attached sessions.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + } + if !detachedSessions.isEmpty { + HStack(spacing: 8) { + Button { + withAnimation(.easeInOut(duration: 0.16)) { + showDetachedSessions.toggle() + } + } label: { + Label( + showDetachedSessions ? "Hide detached sessions" : "Show detached sessions", + systemImage: showDetachedSessions ? "eye.slash" : "eye" + ) + .labelStyle(.titleAndIcon) + } + .buttonStyle(PressableIconButtonStyle()) + + BadgePill(label: "\(detachedSessions.count) detached", tint: .secondary) + Spacer() + } + .padding(.horizontal, 8) + + if showDetachedSessions { + sessionGroupSection( + title: "Detached", + count: detachedSessions.count, + entries: detachedSessions, + topSpacing: attachedSessions.isEmpty ? 0 : 12 + ) + } + } + } + } + } + } + } + @ViewBuilder private func projectGroupSection( title: String, count: Int, projects: [ProjectSummary], - emptyMessage: String + emptyMessage: String, + topSpacing: CGFloat ) -> some View { HStack(alignment: .center, spacing: 8) { Text(title) @@ -70,25 +145,181 @@ struct HomeDashboardView: View { .foregroundStyle(.secondary) Spacer() } - .padding(.horizontal, 2) - .padding(.bottom, 8) + .padding(.top, topSpacing) + .padding(.horizontal, 8) + .padding(.bottom, 3) if projects.isEmpty { Text(emptyMessage) .font(.mono(.caption)) .foregroundStyle(.secondary) - .padding(.bottom, 8) + .padding(.leading, 14) + .padding(.bottom, 2) } else { - ForEach(Array(projects.enumerated()), id: \.element.id) { index, project in - ProjectListRow(project: project) { - model.selectedItem = .project(project.id) + VStack(spacing: 0) { + 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) + } } - if index != projects.count - 1 { + } + .padding(.leading, 14) + } + } + + @ViewBuilder + private func sessionGroupSection( + title: String, + count: Int, + entries: [DashboardSessionEntry], + topSpacing: CGFloat + ) -> 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(.top, topSpacing) + .padding(.horizontal, 8) + .padding(.bottom, 2) + + VStack(spacing: 0) { + ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in + sessionRow(entry) + if index != entries.count - 1 { Divider() .opacity(0.2) } } } + .padding(.leading, 14) + } + + @ViewBuilder + private func sessionRow(_ entry: DashboardSessionEntry) -> some View { + HStack(alignment: .center, spacing: 10) { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + Text(entry.session.name) + .font(.mono(.subheadline, weight: .semibold)) + BadgePill(label: entry.session.backend.rawValue, tint: .secondary) + BadgePill(label: entry.session.source == .hack ? "hack" : "external", tint: .secondary) + BadgePill(label: entry.session.attached ? "attached" : "detached", tint: entry.session.attached ? .green : .orange) + } + + HStack(spacing: 6) { + if let project = entry.project { + Text(project.name) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + if let host = project.devHost, !host.isEmpty { + Text(host) + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + } else { + Text("No linked project") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + } + } + + Spacer(minLength: 8) + + HStack(spacing: 6) { + if let project = entry.project { + Button { + model.selectedItem = .project(project.id) + model.selectedProjectTab = .sessions + } label: { + Label("Project", systemImage: "shippingbox") + .labelStyle(.titleAndIcon) + } + .adaptiveToolbarButton() + } + + Menu { + sessionAttachMenuItems(for: entry) + } label: { + Label("Attach", systemImage: "terminal") + .labelStyle(.titleAndIcon) + } + .adaptiveToolbarButtonProminent() + + Button("Stop") { + Task { await model.stopSession(name: entry.session.name) } + } + .adaptiveToolbarButton() + } + .controlSize(.small) + } + .padding(.leading, 8) + .padding(.vertical, 5) + } + + @ViewBuilder + private func sessionAttachMenuItems(for entry: DashboardSessionEntry) -> some View { + ForEach(availableSessionOpenTargets, id: \.self) { terminalApp in + Button { + attachSession(entry, terminalApp: terminalApp) + } label: { + Label( + "Attach in \(terminalApp.displayName)", + systemImage: terminalApp == preferredExternalTerminal ? "checkmark.circle.fill" : "circle" + ) + } + } + } + + private func attachSession( + _ entry: DashboardSessionEntry, + terminalApp: TerminalIntegration.ExternalTerminalApp + ) { + preferredExternalTerminalRaw = terminalApp.rawValue + legacyPreferredExternalTerminalRaw = terminalApp.rawValue + + let command = attachCommand(for: entry.session) + if terminalApp == .hackDesktop { + let projectId = entry.project?.id ?? "global-shell" + NotificationCenter.default.post( + name: .hackTerminalOpenRequested, + object: nil, + userInfo: [ + TerminalOpenRequest.projectIdKey: projectId, + TerminalOpenRequest.kindKey: TerminalDrawerModel.Kind.shell.rawValue, + TerminalOpenRequest.commandKey: command, + TerminalOpenRequest.titleKey: "\(entry.session.name) (attached)" + ] + ) + return + } + + TerminalIntegration.openExternalTerminalWithCommand(command, app: terminalApp) + } + + 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 shellQuote(_ value: String) -> String { + if value.isEmpty { + return "''" + } + return "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" } private var runningProjects: [ProjectSummary] { @@ -103,6 +334,65 @@ struct HomeDashboardView: View { project.status == .running || project.runtimeStatus == .running } + private var activeSessions: [DashboardSessionEntry] { + var seen = Set() + var sessions: [DashboardSessionEntry] = [] + + for project in model.projects { + for session in project.sessions ?? [] { + let key = "\(session.backend.rawValue):\(session.name)" + guard seen.insert(key).inserted else { continue } + sessions.append(DashboardSessionEntry(session: session, project: project)) + } + } + + return sessions.sorted { lhs, rhs in + if lhs.session.attached != rhs.session.attached { + return lhs.session.attached && !rhs.session.attached + } + let lhsCreated = lhs.session.createdAt ?? 0 + let rhsCreated = rhs.session.createdAt ?? 0 + if lhsCreated != rhsCreated { + return lhsCreated > rhsCreated + } + return lhs.session.name.localizedCaseInsensitiveCompare(rhs.session.name) == .orderedAscending + } + } + + private var attachedSessions: [DashboardSessionEntry] { + activeSessions.filter { $0.session.attached } + } + + private var detachedSessions: [DashboardSessionEntry] { + activeSessions.filter { !$0.session.attached } + } + + private var preferredExternalTerminal: TerminalIntegration.ExternalTerminalApp { + if let explicit = TerminalIntegration.ExternalTerminalApp(rawValue: preferredExternalTerminalRaw) { + return explicit + } + if let legacy = TerminalIntegration.ExternalTerminalApp(rawValue: legacyPreferredExternalTerminalRaw) { + return legacy + } + return .hackDesktop + } + + private var availableSessionOpenTargets: [TerminalIntegration.ExternalTerminalApp] { + let installed = TerminalIntegration.installedExternalTerminalApps() + var ordered: [TerminalIntegration.ExternalTerminalApp] = [.hackDesktop] + if installed.isEmpty { + ordered.append(.terminal) + } else if installed.contains(preferredExternalTerminal) { + ordered.append(contentsOf: installed) + } else { + ordered.append(preferredExternalTerminal) + ordered.append(contentsOf: installed) + } + + var seen = Set() + return ordered.filter { seen.insert($0).inserted } + } + private var runtimeState: (label: String, tone: HealthMetricChip.Tone) { switch model.runtimeHealthState { case .healthy: @@ -156,6 +446,15 @@ struct HomeDashboardView: View { } } +private struct DashboardSessionEntry: Identifiable, Hashable { + let session: ProjectSessionSummary + let project: ProjectSummary? + + var id: String { + "\(session.id)::\(project?.id ?? "unlinked")" + } +} + private struct ProjectListRow: View { let project: ProjectSummary let action: () -> Void @@ -183,7 +482,7 @@ private struct ProjectListRow: View { .foregroundStyle(.secondary) } .padding(.horizontal, 2) - .padding(.vertical, 9) + .padding(.vertical, 7) .contentShape(Rectangle()) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift index 0f03ab23..fbae1ca6 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift @@ -754,6 +754,11 @@ struct ProjectDetailView: View { let startupHookCount: Int let shutdownHookCount: Int let processCount: Int + let persistentHookCount: Int + + var persistentCount: Int { + persistentHookCount + processCount + } var hasEntries: Bool { startupHookCount > 0 || shutdownHookCount > 0 || processCount > 0 @@ -762,10 +767,12 @@ struct ProjectDetailView: View { private var lifecycleSummary: LifecycleSummaryCounts { let lifecycle = project.lifecycle + let persistentHooks = lifecycleHooks.filter { $0.command.persistent == true } return LifecycleSummaryCounts( startupHookCount: (lifecycle?.upBefore.count ?? 0) + (lifecycle?.upAfter.count ?? 0), shutdownHookCount: (lifecycle?.downBefore.count ?? 0) + (lifecycle?.downAfter.count ?? 0), - processCount: lifecycle?.processes.count ?? 0 + processCount: lifecycle?.processes.count ?? 0, + persistentHookCount: persistentHooks.count ) } @@ -802,7 +809,7 @@ struct ProjectDetailView: View { HStack(spacing: 8) { BadgePill(label: "\(lifecycleSummary.startupHookCount) startup hooks", tint: .secondary) BadgePill(label: "\(lifecycleSummary.shutdownHookCount) shutdown hooks", tint: .secondary) - BadgePill(label: "\(lifecycleSummary.processCount) persistent", tint: .secondary) + BadgePill(label: "\(lifecycleSummary.persistentCount) persistent", tint: .secondary) } if let lifecycle = project.lifecycle, !lifecycle.processes.isEmpty { @@ -834,6 +841,7 @@ struct ProjectDetailView: View { HStack(spacing: 8) { Text(process.name) .font(.mono(.caption, weight: .semibold)) + BadgePill(label: "persistent", tint: .green) BadgePill(label: process.service, tint: .secondary) Spacer() Button("Tail logs") { @@ -864,6 +872,9 @@ struct ProjectDetailView: View { Text(command.name ?? command.service) .font(.mono(.caption, weight: .semibold)) BadgePill(label: phase, tint: .secondary) + if command.persistent == true { + BadgePill(label: "persistent", tint: .green) + } Spacer() Button("Show output") { openLifecycleLogs(service: command.service, title: "\(command.service) output") diff --git a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift index 2cb2380b..9b056b1a 100644 --- a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift +++ b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift @@ -118,16 +118,24 @@ public struct ProjectLifecycleCommandSummary: Decodable, Hashable, Identifiable public let command: String public let cwd: String? public let service: String + public let persistent: Bool? public var id: String { "\(service)::\(command)::\(cwd ?? "")" } - public init(name: String?, command: String, cwd: String?, service: String) { + public init( + name: String?, + command: String, + cwd: String?, + service: String, + persistent: Bool? = nil + ) { self.name = name self.command = command self.cwd = cwd self.service = service + self.persistent = persistent } } diff --git a/docs/cli.md b/docs/cli.md index 88ba33b9..bfa56c63 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1010,7 +1010,7 @@ Options: | Flag | Type | Default | Description | | --- | --- | --- | --- | -| `--run-at-load` | boolean | false | Start hackd automatically on login | +| `--run-at-load` | boolean | true | Start hackd automatically on login | | `--no-run-at-load` | boolean | - | Do not start hackd automatically on login | | `--gui-only` | boolean | true | Only run in GUI sessions (Aqua) | | `--no-gui-only` | boolean | - | Run in all session types (including SSH) | diff --git a/examples/templates/global/caddy.compose.yml b/examples/templates/global/caddy.compose.yml index 8891da76..7deaa7e5 100644 --- a/examples/templates/global/caddy.compose.yml +++ b/examples/templates/global/caddy.compose.yml @@ -2,6 +2,10 @@ name: hack-dev-proxy services: caddy: image: lucaslorentz/caddy-docker-proxy:2.10 + command: + - docker-proxy + - --polling-interval + - 5s ports: - "80:80" - "443:443" diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 15e5bf4b..e48d0bcd 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -469,6 +469,9 @@ async function renderProjectLifecycle(opts: { ...opts.lifecycle.downBefore, ...opts.lifecycle.downAfter, ]; + const persistentHookCount = hooks.filter( + (entry) => entry.persistent === true + ).length; const summary: Array = [ [ "Startup hooks", @@ -480,6 +483,7 @@ async function renderProjectLifecycle(opts: { opts.lifecycle.downBefore.length + opts.lifecycle.downAfter.length ), ], + ["Persistent hooks", String(persistentHookCount)], ["Persistent processes", String(opts.lifecycle.processes.length)], ]; @@ -507,9 +511,10 @@ async function renderProjectLifecycle(opts: { ]; await display.table({ - columns: ["Phase", "Service", "Name", "Cwd", "Command"], + columns: ["Phase", "Persistent", "Service", "Name", "Cwd", "Command"], rows: hookRows.map((entry) => [ entry.phase, + entry.persistent === true ? "yes" : "", entry.service, entry.name ?? "", entry.cwd ?? "", diff --git a/src/control-plane/sdk/config.ts b/src/control-plane/sdk/config.ts index 27b41853..f42884e5 100644 --- a/src/control-plane/sdk/config.ts +++ b/src/control-plane/sdk/config.ts @@ -89,7 +89,7 @@ const DaemonLaunchdConfigInputSchema = z.object({ const DaemonLaunchdConfigSchema = z.object({ installed: z.boolean().default(false), - runAtLoad: z.boolean().default(false), + runAtLoad: z.boolean().default(true), guiSessionOnly: z.boolean().default(true), }); diff --git a/src/lib/project-views.ts b/src/lib/project-views.ts index f78e120f..ae22558c 100644 --- a/src/lib/project-views.ts +++ b/src/lib/project-views.ts @@ -52,6 +52,7 @@ export type ProjectLifecycleCommandView = { readonly command: string; readonly cwd: string | null; readonly service: string; + readonly persistent?: boolean; }; export type ProjectLifecycleProcessView = { @@ -308,24 +309,28 @@ export function serializeProjectView( command: entry.command, cwd: entry.cwd, service: entry.service, + ...(entry.persistent === true ? { persistent: true } : {}), })), up_after: view.lifecycle.upAfter.map((entry) => ({ name: entry.name, command: entry.command, cwd: entry.cwd, service: entry.service, + ...(entry.persistent === true ? { persistent: true } : {}), })), down_before: view.lifecycle.downBefore.map((entry) => ({ name: entry.name, command: entry.command, cwd: entry.cwd, service: entry.service, + ...(entry.persistent === true ? { persistent: true } : {}), })), down_after: view.lifecycle.downAfter.map((entry) => ({ name: entry.name, command: entry.command, cwd: entry.cwd, service: entry.service, + ...(entry.persistent === true ? { persistent: true } : {}), })), processes: view.lifecycle.processes.map((entry) => ({ name: entry.name, @@ -406,6 +411,7 @@ function mapLifecycleCommandView(opts: { command, index, }), + ...(command.persistent === true ? { persistent: true } : {}), })); } diff --git a/src/templates.ts b/src/templates.ts index d9e5bb44..02266585 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -29,6 +29,10 @@ export function renderGlobalCaddyCompose(opts?: { "services:", " caddy:", " image: lucaslorentz/caddy-docker-proxy:2.10", + " command:", + " - docker-proxy", + " - --polling-interval", + " - 5s", " ports:", ' - "80:80"', ' - "443:443"', diff --git a/tests/control-plane-config.test.ts b/tests/control-plane-config.test.ts index a3bdab87..2f52b035 100644 --- a/tests/control-plane-config.test.ts +++ b/tests/control-plane-config.test.ts @@ -44,6 +44,7 @@ test("readControlPlaneConfig returns defaults when config is missing", async () expect(result.config.tickets.git.branch).toBe("hack/tickets"); expect(result.config.supervisor.enabled).toBe(true); expect(result.config.daemon.autoStart).toBe(true); + expect(result.config.daemon.launchd.runAtLoad).toBe(true); }); test("readControlPlaneConfig reads controlPlane overrides", async () => { diff --git a/tests/coredns-config.test.ts b/tests/coredns-config.test.ts index 1a298301..2132b875 100644 --- a/tests/coredns-config.test.ts +++ b/tests/coredns-config.test.ts @@ -29,6 +29,10 @@ test("renderGlobalCaddyCompose pins caddy and coredns when requested", () => { useStaticCaddyIp: true, useStaticCoreDnsIp: true, }); + expect(text).toContain("command:"); + expect(text).toContain("- docker-proxy"); + expect(text).toContain("- --polling-interval"); + expect(text).toContain("- 5s"); expect(text).toContain(`name: ${DEFAULT_INGRESS_NETWORK}`); expect(text).toContain(`ipv4_address: ${DEFAULT_CADDY_IP}`); expect(text).toContain(`ipv4_address: ${DEFAULT_COREDNS_IP}`); diff --git a/tests/global-command.test.ts b/tests/global-command.test.ts index c40a841a..8f4e8cf9 100644 --- a/tests/global-command.test.ts +++ b/tests/global-command.test.ts @@ -43,8 +43,9 @@ mock.module("../src/lib/shell.ts", () => ({ runCalls.push([...cmd]); return 0; }, - findExecutableInPath: async (name?: string) => + findExecutableInPath: (name?: string) => name === "hack" ? "/usr/local/bin/hack" : "/usr/bin/mkcert", + CommandError: class CommandError extends Error {}, })); mock.module("../src/lib/os.ts", () => ({ diff --git a/tests/log-backend.test.ts b/tests/log-backend.test.ts index 052a3ea1..19a156b2 100644 --- a/tests/log-backend.test.ts +++ b/tests/log-backend.test.ts @@ -1,22 +1,19 @@ import { beforeEach, expect, mock, test } from "bun:test"; -const runCalls: string[][] = []; const dockerJsonCalls: Record[] = []; +const dockerPlainCalls: Record[] = []; const dockerPrettyCalls: Record[] = []; const lokiCalls: Record[] = []; -mock.module("../src/lib/shell.ts", () => ({ - run: async (cmd: readonly string[]) => { - runCalls.push([...cmd]); - return 0; - }, -})); - mock.module("../src/ui/docker-logs.ts", () => ({ dockerComposeLogsJson: async (opts: Record) => { dockerJsonCalls.push(opts); return 0; }, + dockerComposeLogsPlain: async (opts: Record) => { + dockerPlainCalls.push(opts); + return 0; + }, dockerComposeLogsPretty: async (opts: Record) => { dockerPrettyCalls.push(opts); return 0; @@ -37,8 +34,8 @@ import { } from "../src/backends/log-backend.ts"; beforeEach(() => { - runCalls.length = 0; dockerJsonCalls.length = 0; + dockerPlainCalls.length = 0; dockerPrettyCalls.length = 0; lokiCalls.length = 0; }); @@ -81,21 +78,15 @@ test("composeLogBackend routes plain output to docker compose logs", async () => profiles: ["ops"], }); - expect(runCalls[0]).toEqual([ - "docker", - "compose", - "-p", - "proj", - "-f", - "docker-compose.yml", - "--profile", - "ops", - "logs", - "-f", - "--tail", - "10", - "api", - ]); + expect(dockerPlainCalls.length).toBe(1); + expect(dockerPlainCalls[0]).toMatchObject({ + composeFile: "docker-compose.yml", + follow: true, + tail: 10, + service: "api", + composeProject: "proj", + profiles: ["ops"], + }); }); test("lokiLogBackend.isAvailable proxies canReachLoki", async () => { diff --git a/tests/project-views.test.ts b/tests/project-views.test.ts index 777884fb..6caec4cb 100644 --- a/tests/project-views.test.ts +++ b/tests/project-views.test.ts @@ -234,6 +234,46 @@ test("buildProjectViews includes lifecycle and startup summaries", async () => { expect(Array.isArray(lifecycle?.processes)).toBe(true); }); +test("buildProjectViews preserves persistent lifecycle hook flags", async () => { + const lifecycleConfig = JSON.stringify( + { + lifecycle: { + up: { + before: [ + { name: "proxy", command: "bun run proxy", persistent: true }, + ], + }, + }, + }, + null, + 2 + ); + + const alpha = await createProject({ + name: "alpha", + services: ["api"], + configJson: lifecycleConfig, + }); + const views = await buildProjectViews({ + registryProjects: [alpha], + runtime: [], + runtimeOk: true, + filter: null, + includeUnregistered: false, + muxSessions: [], + }); + + const alphaView = views.find((view) => view.name === "alpha"); + expect(alphaView?.lifecycle?.upBefore[0]?.persistent).toBe(true); + + const serialized = alphaView ? serializeProjectView(alphaView) : null; + const lifecycle = serialized?.lifecycle as + | Record + | undefined; + const upBefore = (lifecycle?.up_before ?? []) as Record[]; + expect(upBefore[0]?.persistent).toBe(true); +}); + test("buildProjectViews marks runtime status unknown when runtime is unavailable", async () => { const alpha = await createProject({ name: "alpha", services: ["api"] }); const views = await buildProjectViews({ diff --git a/tests/runtime-backend.test.ts b/tests/runtime-backend.test.ts index 8c99b84a..170c31f1 100644 --- a/tests/runtime-backend.test.ts +++ b/tests/runtime-backend.test.ts @@ -8,10 +8,16 @@ mock.module("../src/lib/shell.ts", () => ({ execCalls.push([...cmd]); return { exitCode: 0, stdout: "", stderr: "" }; }, + execOrThrow: async (cmd: readonly string[]) => { + execCalls.push([...cmd]); + return { exitCode: 0, stdout: "", stderr: "" }; + }, run: async (cmd: readonly string[]) => { runCalls.push([...cmd]); return 0; }, + findExecutableInPath: () => "/usr/bin/docker", + CommandError: class CommandError extends Error {}, })); import { composeRuntimeBackend } from "../src/backends/runtime-backend.ts";