vibetunnel/ios/VibeTunnel/Views/Sessions/SessionCardView.swift
Peter Steinberger baaaa5a033 fix: CI and linting issues across all platforms
- Fix code signing in Mac and iOS test workflows
- Fix all SwiftFormat and SwiftLint issues
- Fix ESLint issues in web code
- Remove force casts and unwrapping in Swift code
- Update build scripts to use correct file paths
2025-06-23 19:40:53 +02:00

349 lines
14 KiB
Swift

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)
}
}