import SwiftUI /// Card component displaying session information in the list. /// /// Shows session details including status, command, working directory, /// and provides quick actions for managing the session. struct SessionCardView: View { let session: Session let onTap: () -> Void let onKill: () -> Void let onCleanup: () -> Void @State private var isPressed = false @State private var terminalSnapshot: TerminalSnapshot? @State private var isLoadingSnapshot = false @State private var isKilling = false @State private var opacity: Double = 1.0 @State private var scale: CGFloat = 1.0 @State private var rotation: Double = 0 @State private var brightness: Double = 1.0 @Environment(\.livePreviewSubscription) private var livePreview private var displayWorkingDir: String { // Convert absolute paths back to ~ notation for display let homePrefix = "/Users/" if session.workingDir.hasPrefix(homePrefix), let userEndIndex = session.workingDir[homePrefix.endIndex...].firstIndex(of: "/") { let restOfPath = String(session.workingDir[userEndIndex...]) return "~\(restOfPath)" } return session.workingDir } var body: some View { Button(action: onTap) { VStack(alignment: .leading, spacing: Theme.Spacing.medium) { // Header with session ID/name and kill button HStack { Text(session.displayName) .font(Theme.Typography.terminalSystem(size: 14)) .fontWeight(.medium) .foregroundColor(Theme.Colors.primaryAccent) .lineLimit(1) Spacer() Button(action: { HapticFeedback.impact(.medium) if session.isRunning { animateKill() } else { animateCleanup() } }, label: { if isKilling { LoadingView(message: "", useUnicodeSpinner: true) .scaleEffect(0.7) .frame(width: 18, height: 18) } else { Image(systemName: session.isRunning ? "xmark.circle" : "trash.circle") .font(.system(size: 18)) .foregroundColor(session.isRunning ? Theme.Colors.errorAccent : Theme.Colors .terminalForeground.opacity(0.6) ) } }) .buttonStyle(.plain) } // Terminal content area showing command and terminal output preview RoundedRectangle(cornerRadius: Theme.CornerRadius.small) .fill(Theme.Colors.terminalBackground) .frame(height: 120) .overlay( Group { if session.isRunning { // Show live preview if available if let bufferSnapshot = livePreview?.latestSnapshot { CompactTerminalPreview(snapshot: bufferSnapshot) .animation(.easeInOut(duration: 0.2), value: bufferSnapshot.cursorY) } else if let snapshot = terminalSnapshot, !snapshot.cleanOutputPreview.isEmpty { // Show static snapshot as fallback staticSnapshotView(snapshot) } else { // Show command and working directory info as fallback commandInfoView } } else { // For exited sessions, show last output if available if let snapshot = terminalSnapshot, !snapshot.cleanOutputPreview.isEmpty { exitedSessionView(snapshot) } else { Text("Session exited") .font(Theme.Typography.terminalSystem(size: 12)) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.5)) .frame(maxWidth: .infinity, maxHeight: .infinity) } } } ) // Status bar at bottom HStack(spacing: Theme.Spacing.small) { // Status indicator HStack(spacing: 4) { Circle() .fill(session.isRunning ? Theme.Colors.successAccent : Theme.Colors.terminalForeground .opacity(0.3) ) .frame(width: 6, height: 6) Text(session.isRunning ? "running" : "exited") .font(Theme.Typography.terminalSystem(size: 10)) .foregroundColor(session.isRunning ? Theme.Colors.successAccent : Theme.Colors .terminalForeground.opacity(0.5) ) // Live preview indicator if session.isRunning && livePreview?.latestSnapshot != nil { HStack(spacing: 2) { Image(systemName: "dot.radiowaves.left.and.right") .font(.system(size: 8)) .foregroundColor(Theme.Colors.primaryAccent) .symbolEffect(.pulse) Text("live") .font(Theme.Typography.terminalSystem(size: 9)) .foregroundColor(Theme.Colors.primaryAccent) } .padding(.horizontal, 6) .padding(.vertical, 2) .background( Capsule() .fill(Theme.Colors.primaryAccent.opacity(0.1)) ) } } Spacer() // PID info if session.isRunning, let pid = session.pid { Text("PID: \(pid)") .font(Theme.Typography.terminalSystem(size: 10)) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.5)) .onTapGesture { UIPasteboard.general.string = String(pid) HapticFeedback.notification(.success) } } } } .padding(Theme.Spacing.medium) .background( RoundedRectangle(cornerRadius: Theme.CornerRadius.card) .fill(Theme.Colors.cardBackground) ) .overlay( RoundedRectangle(cornerRadius: Theme.CornerRadius.card) .stroke(Theme.Colors.cardBorder, lineWidth: 1) ) .scaleEffect(isPressed ? 0.98 : scale) .opacity(opacity) .rotationEffect(.degrees(rotation)) .brightness(brightness) } .buttonStyle(.plain) .onLongPressGesture( minimumDuration: 0.1, maximumDistance: .infinity, pressing: { pressing in withAnimation(Theme.Animation.quick) { isPressed = pressing } }, perform: {} ) .contextMenu { if session.isRunning { Button(action: animateKill) { Label("Kill Session", systemImage: "stop.circle") } } else { Button(action: animateCleanup) { Label("Clean Up", systemImage: "trash") } } } .onAppear { loadSnapshot() } } private func loadSnapshot() { guard terminalSnapshot == nil else { return } isLoadingSnapshot = true Task { do { let snapshot = try await APIClient.shared.getSessionSnapshot(sessionId: session.id) await MainActor.run { self.terminalSnapshot = snapshot self.isLoadingSnapshot = false } } catch { // Silently fail - we'll just show the command/cwd fallback await MainActor.run { self.isLoadingSnapshot = false } } } } private func animateKill() { guard !isKilling else { return } isKilling = true // Shake animation withAnimation(.linear(duration: 0.05).repeatCount(4, autoreverses: true)) { scale = 0.97 } // Fade out after shake DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { withAnimation(.easeOut(duration: 0.3)) { opacity = 0.5 scale = 0.95 } onKill() // Reset after a delay DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { isKilling = false withAnimation(.easeIn(duration: 0.2)) { opacity = 1.0 scale = 1.0 } } } } private func animateCleanup() { // Black hole collapse animation matching web withAnimation(.easeInOut(duration: 0.3)) { scale = 0 rotation = 360 brightness = 0.3 opacity = 0 } DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { onCleanup() // Reset values for potential reuse scale = 1.0 rotation = 0 brightness = 1.0 opacity = 1.0 } } // MARK: - View Components @ViewBuilder private var commandInfoView: some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 4) { Text("$") .font(Theme.Typography.terminalSystem(size: 12)) .foregroundColor(Theme.Colors.primaryAccent) Text(session.command.joined(separator: " ")) .font(Theme.Typography.terminalSystem(size: 12)) .foregroundColor(Theme.Colors.terminalForeground) } Text(displayWorkingDir) .font(Theme.Typography.terminalSystem(size: 10)) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.6)) .lineLimit(1) .onTapGesture { UIPasteboard.general.string = session.workingDir HapticFeedback.notification(.success) } if isLoadingSnapshot { HStack { ProgressView() .progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent)) .scaleEffect(0.8) Text("Connecting...") .font(Theme.Typography.terminalSystem(size: 10)) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.5)) } .padding(.top, Theme.Spacing.extraSmall) } } .padding(Theme.Spacing.small) } @ViewBuilder private func staticSnapshotView(_ snapshot: TerminalSnapshot) -> some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 4) { // ESC indicator if present if snapshot.cleanOutputPreview.lowercased().contains("esc to interrupt") { HStack(spacing: 4) { Image(systemName: "escape") .font(.system(size: 10, weight: .bold)) .foregroundColor(Theme.Colors.warningAccent) Text("Press ESC to interrupt") .font(Theme.Typography.terminalSystem(size: 10)) .foregroundColor(Theme.Colors.warningAccent) } .padding(.horizontal, 8) .padding(.vertical, 4) .background( RoundedRectangle(cornerRadius: 4) .fill(Theme.Colors.warningAccent.opacity(0.2)) ) } Text(snapshot.cleanOutputPreview) .font(Theme.Typography.terminalSystem(size: 10)) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.8)) .frame(maxWidth: .infinity, alignment: .leading) .lineLimit(nil) .multilineTextAlignment(.leading) } } .padding(Theme.Spacing.small) } @ViewBuilder private func exitedSessionView(_ snapshot: TerminalSnapshot) -> some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 4) { Text("Session exited") .font(Theme.Typography.terminalSystem(size: 10)) .foregroundColor(Theme.Colors.errorAccent) Text(snapshot.cleanOutputPreview) .font(Theme.Typography.terminalSystem(size: 10)) .foregroundColor(Theme.Colors.terminalForeground.opacity(0.6)) .frame(maxWidth: .infinity, alignment: .leading) .lineLimit(nil) .multilineTextAlignment(.leading) } } .padding(Theme.Spacing.small) } }