mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-12 12:25:53 +00:00
- 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
907 lines
32 KiB
Swift
907 lines
32 KiB
Swift
import Observation
|
||
import SwiftTerm
|
||
import SwiftUI
|
||
|
||
private let logger = Logger(category: "TerminalView")
|
||
|
||
/// Interactive terminal view for a session.
|
||
///
|
||
/// Displays a full terminal emulator using SwiftTerm with support for
|
||
/// input, output, recording, and font size adjustment.
|
||
struct TerminalView: View {
|
||
let session: Session
|
||
@Environment(\.dismiss)
|
||
var dismiss
|
||
@State private var viewModel: TerminalViewModel
|
||
@State private var fontSize: CGFloat = 14
|
||
@State private var showingFontSizeSheet = false
|
||
@State private var showingRecordingSheet = false
|
||
@State private var showingTerminalWidthSheet = false
|
||
@State private var showingTerminalThemeSheet = false
|
||
@State private var selectedTerminalWidth: Int?
|
||
@State private var selectedTheme = TerminalTheme.selected
|
||
@State private var keyboardHeight: CGFloat = 0
|
||
@State private var showScrollToBottom = false
|
||
@State private var showingFileBrowser = false
|
||
@State private var selectedRenderer = TerminalRenderer.selected
|
||
@State private var showingDebugMenu = false
|
||
@State private var showingExportSheet = false
|
||
@State private var exportedFileURL: URL?
|
||
@State private var showingWidthSelector = false
|
||
@State private var currentTerminalWidth: TerminalWidth = .unlimited
|
||
@State private var showingFullscreenInput = false
|
||
@State private var showingCtrlKeyGrid = false
|
||
@FocusState private var isInputFocused: Bool
|
||
|
||
init(session: Session) {
|
||
self.session = session
|
||
self._viewModel = State(initialValue: TerminalViewModel(session: session))
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
mainContent
|
||
.navigationTitle(session.displayName)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar(.visible, for: .bottomBar)
|
||
.toolbarBackground(.automatic, for: .bottomBar)
|
||
.toolbar {
|
||
navigationToolbarItems
|
||
bottomToolbarItems
|
||
recordingIndicator
|
||
}
|
||
}
|
||
.preferredColorScheme(.dark)
|
||
.focusable()
|
||
.onAppear {
|
||
viewModel.connect()
|
||
isInputFocused = true
|
||
}
|
||
.onDisappear {
|
||
viewModel.disconnect()
|
||
}
|
||
.sheet(isPresented: $showingFontSizeSheet) {
|
||
FontSizeSheet(fontSize: $fontSize)
|
||
}
|
||
.sheet(isPresented: $showingRecordingSheet) {
|
||
RecordingExportSheet(recorder: viewModel.castRecorder, sessionName: session.displayName)
|
||
}
|
||
.sheet(isPresented: $showingTerminalWidthSheet) {
|
||
TerminalWidthSheet(
|
||
selectedWidth: $selectedTerminalWidth,
|
||
isResizeBlockedByServer: viewModel.isResizeBlockedByServer
|
||
)
|
||
.onAppear {
|
||
selectedTerminalWidth = viewModel.terminalCols
|
||
}
|
||
}
|
||
.sheet(isPresented: $showingTerminalThemeSheet) {
|
||
TerminalThemeSheet(selectedTheme: $selectedTheme)
|
||
}
|
||
.sheet(isPresented: $showingFileBrowser) {
|
||
FileBrowserView(
|
||
initialPath: session.workingDir,
|
||
mode: .insertPath,
|
||
onSelect: { _ in
|
||
showingFileBrowser = false
|
||
},
|
||
onInsertPath: { [weak viewModel] path, _ in
|
||
// Insert the path into the terminal
|
||
viewModel?.sendInput(path)
|
||
showingFileBrowser = false
|
||
}
|
||
)
|
||
}
|
||
.sheet(isPresented: $showingFullscreenInput) {
|
||
FullscreenTextInput(isPresented: $showingFullscreenInput) { [weak viewModel] text in
|
||
viewModel?.sendInput(text)
|
||
}
|
||
}
|
||
.sheet(isPresented: $showingCtrlKeyGrid) {
|
||
CtrlKeyGrid(isPresented: $showingCtrlKeyGrid) { [weak viewModel] controlChar in
|
||
viewModel?.sendInput(controlChar)
|
||
}
|
||
}
|
||
.gesture(
|
||
DragGesture()
|
||
.onEnded { value in
|
||
if value.startLocation.x < 20 && value.translation.width > 50 {
|
||
dismiss()
|
||
HapticFeedback.impact(.light)
|
||
}
|
||
}
|
||
)
|
||
.task {
|
||
for await notification in NotificationCenter.default
|
||
.notifications(named: UIResponder.keyboardWillShowNotification)
|
||
{
|
||
if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
|
||
withAnimation(Theme.Animation.standard) {
|
||
keyboardHeight = keyboardFrame.height
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.task {
|
||
for await _ in NotificationCenter.default.notifications(named: UIResponder.keyboardWillHideNotification) {
|
||
withAnimation(Theme.Animation.standard) {
|
||
keyboardHeight = 0
|
||
}
|
||
}
|
||
}
|
||
.onChange(of: selectedTerminalWidth) { _, newValue in
|
||
if let width = newValue, width != viewModel.terminalCols {
|
||
let aspectRatio = Double(viewModel.terminalRows) / Double(viewModel.terminalCols)
|
||
let newHeight = Int(Double(width) * aspectRatio)
|
||
viewModel.resize(cols: width, rows: newHeight)
|
||
}
|
||
}
|
||
.onChange(of: currentTerminalWidth) { _, newWidth in
|
||
let targetWidth = newWidth.value == 0 ? nil : newWidth.value
|
||
if targetWidth != selectedTerminalWidth {
|
||
selectedTerminalWidth = targetWidth
|
||
viewModel.setMaxWidth(targetWidth ?? 0)
|
||
TerminalWidthManager.shared.defaultWidth = newWidth.value
|
||
}
|
||
}
|
||
.onChange(of: viewModel.isAtBottom) { _, newValue in
|
||
withAnimation(Theme.Animation.smooth) {
|
||
showScrollToBottom = !newValue
|
||
}
|
||
}
|
||
.onKeyPress(keys: ["o"]) { press in
|
||
if press.modifiers.contains(.command) && session.isRunning {
|
||
showingFileBrowser = true
|
||
return .handled
|
||
}
|
||
return .ignored
|
||
}
|
||
.sheet(isPresented: $showingExportSheet) {
|
||
if let url = exportedFileURL {
|
||
ShareSheet(items: [url])
|
||
.onDisappear {
|
||
// Clean up temporary file
|
||
try? FileManager.default.removeItem(at: url)
|
||
exportedFileURL = nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Export Functions
|
||
|
||
private func exportTerminalBuffer() {
|
||
guard let bufferContent = viewModel.getBufferContent() else { return }
|
||
|
||
let fileName = "\(session.displayName)_\(Date().timeIntervalSince1970).txt"
|
||
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)
|
||
|
||
do {
|
||
try bufferContent.write(to: tempURL, atomically: true, encoding: .utf8)
|
||
exportedFileURL = tempURL
|
||
showingExportSheet = true
|
||
} catch {
|
||
logger.error("Failed to export terminal buffer: \(error)")
|
||
}
|
||
}
|
||
|
||
// MARK: - View Components
|
||
|
||
private var mainContent: some View {
|
||
ZStack {
|
||
selectedTheme.background
|
||
.ignoresSafeArea()
|
||
|
||
VStack(spacing: 0) {
|
||
if viewModel.isConnecting {
|
||
loadingView
|
||
} else if let error = viewModel.errorMessage {
|
||
errorView(error)
|
||
} else {
|
||
terminalContent
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var navigationToolbarItems: some ToolbarContent {
|
||
Group {
|
||
ToolbarItem(placement: .navigationBarLeading) {
|
||
Button("Close") {
|
||
dismiss()
|
||
}
|
||
.foregroundColor(Theme.Colors.primaryAccent)
|
||
}
|
||
|
||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||
QuickFontSizeButtons(fontSize: $fontSize)
|
||
.fixedSize()
|
||
fileBrowserButton
|
||
widthSelectorButton
|
||
menuButton
|
||
}
|
||
}
|
||
}
|
||
|
||
private var bottomToolbarItems: some ToolbarContent {
|
||
ToolbarItemGroup(placement: .bottomBar) {
|
||
terminalSizeIndicator
|
||
Spacer()
|
||
}
|
||
}
|
||
|
||
private var recordingIndicator: some ToolbarContent {
|
||
ToolbarItem(placement: .navigationBarTrailing) {
|
||
if viewModel.castRecorder.isRecording {
|
||
recordingView
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Toolbar Components
|
||
|
||
private var fileBrowserButton: some View {
|
||
Button(action: {
|
||
HapticFeedback.impact(.light)
|
||
showingFileBrowser = true
|
||
}, label: {
|
||
Image(systemName: "folder")
|
||
.font(.system(size: 16))
|
||
.foregroundColor(Theme.Colors.primaryAccent)
|
||
})
|
||
}
|
||
|
||
private var widthSelectorButton: some View {
|
||
Button(action: { showingWidthSelector = true }, label: {
|
||
HStack(spacing: 2) {
|
||
Image(systemName: "arrow.left.and.right")
|
||
.font(.system(size: 12))
|
||
Text(currentTerminalWidth.label)
|
||
.font(Theme.Typography.terminalSystem(size: 14))
|
||
.fontWeight(.medium)
|
||
}
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 4)
|
||
.background(Theme.Colors.cardBackground)
|
||
.cornerRadius(6)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.stroke(Theme.Colors.primaryAccent.opacity(0.3), lineWidth: 1)
|
||
)
|
||
})
|
||
.foregroundColor(Theme.Colors.primaryAccent)
|
||
.popover(isPresented: $showingWidthSelector, arrowEdge: .top) {
|
||
WidthSelectorPopover(
|
||
currentWidth: $currentTerminalWidth,
|
||
isPresented: $showingWidthSelector
|
||
)
|
||
}
|
||
}
|
||
|
||
private var menuButton: some View {
|
||
Menu {
|
||
terminalMenuItems
|
||
} label: {
|
||
Image(systemName: "ellipsis.circle")
|
||
.foregroundColor(Theme.Colors.primaryAccent)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var terminalMenuItems: some View {
|
||
Button(action: { viewModel.clearTerminal() }, label: {
|
||
Label("Clear", systemImage: "clear")
|
||
})
|
||
|
||
Button(action: { showingFullscreenInput = true }, label: {
|
||
Label("Compose Command", systemImage: "text.viewfinder")
|
||
})
|
||
|
||
Button(action: { showingCtrlKeyGrid = true }, label: {
|
||
Label("Ctrl Shortcuts", systemImage: "command.square")
|
||
})
|
||
|
||
Divider()
|
||
|
||
Menu {
|
||
Button(action: {
|
||
fontSize = max(8, fontSize - 1)
|
||
HapticFeedback.impact(.light)
|
||
}, label: {
|
||
Label("Decrease", systemImage: "minus")
|
||
})
|
||
.disabled(fontSize <= 8)
|
||
|
||
Button(action: {
|
||
fontSize = min(32, fontSize + 1)
|
||
HapticFeedback.impact(.light)
|
||
}, label: {
|
||
Label("Increase", systemImage: "plus")
|
||
})
|
||
.disabled(fontSize >= 32)
|
||
|
||
Button(action: {
|
||
fontSize = 14
|
||
HapticFeedback.impact(.light)
|
||
}, label: {
|
||
Label("Reset to Default", systemImage: "arrow.counterclockwise")
|
||
})
|
||
.disabled(fontSize == 14)
|
||
|
||
Divider()
|
||
|
||
Button(action: { showingFontSizeSheet = true }, label: {
|
||
Label("More Options...", systemImage: "slider.horizontal.3")
|
||
})
|
||
} label: {
|
||
Label("Font Size (\(Int(fontSize))pt)", systemImage: "textformat.size")
|
||
}
|
||
|
||
Button(action: { showingTerminalWidthSheet = true }, label: {
|
||
Label("Terminal Width", systemImage: "arrow.left.and.right")
|
||
})
|
||
|
||
Button(action: { viewModel.toggleFitToWidth() }, label: {
|
||
Label(
|
||
viewModel.fitToWidth ? "Fixed Width" : "Fit to Width",
|
||
systemImage: viewModel.fitToWidth ? "arrow.left.and.right.square" : "arrow.left.and.right.square.fill"
|
||
)
|
||
})
|
||
|
||
Button(action: { showingTerminalThemeSheet = true }, label: {
|
||
Label("Theme", systemImage: "paintbrush")
|
||
})
|
||
|
||
Button(action: { viewModel.copyBuffer() }, label: {
|
||
Label("Copy All", systemImage: "square.on.square")
|
||
})
|
||
|
||
Button(action: { exportTerminalBuffer() }, label: {
|
||
Label("Export as Text", systemImage: "square.and.arrow.up")
|
||
})
|
||
|
||
Divider()
|
||
|
||
recordingMenuItems
|
||
|
||
Divider()
|
||
|
||
debugMenuItems
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var recordingMenuItems: some View {
|
||
if viewModel.castRecorder.isRecording {
|
||
Button(action: {
|
||
viewModel.stopRecording()
|
||
showingRecordingSheet = true
|
||
}, label: {
|
||
Label("Stop Recording", systemImage: "stop.circle.fill")
|
||
.foregroundColor(.red)
|
||
})
|
||
} else {
|
||
Button(action: { viewModel.startRecording() }, label: {
|
||
Label("Start Recording", systemImage: "record.circle")
|
||
})
|
||
}
|
||
|
||
Button(action: { showingRecordingSheet = true }, label: {
|
||
Label("Export Recording", systemImage: "square.and.arrow.up")
|
||
})
|
||
.disabled(viewModel.castRecorder.events.isEmpty)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var debugMenuItems: some View {
|
||
Menu {
|
||
ForEach(TerminalRenderer.allCases, id: \.self) { renderer in
|
||
Button(action: {
|
||
selectedRenderer = renderer
|
||
TerminalRenderer.selected = renderer
|
||
viewModel.terminalViewId = UUID() // Force recreate terminal view
|
||
}, label: {
|
||
HStack {
|
||
Text(renderer.displayName)
|
||
if renderer == selectedRenderer {
|
||
Image(systemName: "checkmark")
|
||
}
|
||
}
|
||
})
|
||
}
|
||
} label: {
|
||
Label("Terminal Renderer", systemImage: "gearshape.2")
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var terminalSizeIndicator: some View {
|
||
if viewModel.terminalCols > 0 && viewModel.terminalRows > 0 {
|
||
Text("\(viewModel.terminalCols)×\(viewModel.terminalRows)")
|
||
.font(Theme.Typography.terminalSystem(size: 11))
|
||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.5))
|
||
}
|
||
}
|
||
|
||
private var recordingView: some View {
|
||
HStack(spacing: 4) {
|
||
Circle()
|
||
.fill(Theme.Colors.errorAccent)
|
||
.frame(width: 8, height: 8)
|
||
.overlay(
|
||
Circle()
|
||
.fill(Theme.Colors.errorAccent.opacity(0.3))
|
||
.frame(width: 16, height: 16)
|
||
.scaleEffect(viewModel.recordingPulse ? 1.5 : 1.0)
|
||
.animation(
|
||
.easeInOut(duration: 1.0).repeatForever(autoreverses: true),
|
||
value: viewModel.recordingPulse
|
||
)
|
||
)
|
||
Text("REC")
|
||
.font(.system(size: 12, weight: .bold))
|
||
.foregroundColor(Theme.Colors.errorAccent)
|
||
}
|
||
.onAppear {
|
||
viewModel.recordingPulse = true
|
||
}
|
||
}
|
||
|
||
private var loadingView: some View {
|
||
VStack(spacing: Theme.Spacing.large) {
|
||
ProgressView()
|
||
.progressViewStyle(CircularProgressViewStyle(tint: Theme.Colors.primaryAccent))
|
||
.scaleEffect(1.5)
|
||
|
||
Text("Connecting to session...")
|
||
.font(Theme.Typography.terminalSystem(size: 14))
|
||
.foregroundColor(Theme.Colors.terminalForeground)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
|
||
private func errorView(_ error: String) -> some View {
|
||
VStack(spacing: Theme.Spacing.large) {
|
||
Image(systemName: "exclamationmark.triangle")
|
||
.font(.system(size: 48))
|
||
.foregroundColor(Theme.Colors.errorAccent)
|
||
|
||
Text("Connection Error")
|
||
.font(.headline)
|
||
.foregroundColor(Theme.Colors.terminalForeground)
|
||
|
||
Text(error)
|
||
.font(Theme.Typography.terminalSystem(size: 12))
|
||
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
||
.multilineTextAlignment(.center)
|
||
.padding(.horizontal)
|
||
|
||
Button("Retry") {
|
||
viewModel.connect()
|
||
}
|
||
.terminalButton()
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
|
||
private var terminalContent: some View {
|
||
VStack(spacing: 0) {
|
||
// Terminal view based on selected renderer
|
||
Group {
|
||
switch selectedRenderer {
|
||
case .swiftTerm:
|
||
TerminalHostingView(
|
||
session: session,
|
||
fontSize: $fontSize,
|
||
theme: selectedTheme,
|
||
onInput: { text in
|
||
viewModel.sendInput(text)
|
||
},
|
||
onResize: { cols, rows in
|
||
viewModel.terminalCols = cols
|
||
viewModel.terminalRows = rows
|
||
viewModel.resize(cols: cols, rows: rows)
|
||
},
|
||
viewModel: viewModel
|
||
)
|
||
case .xterm:
|
||
XtermWebView(
|
||
session: session,
|
||
fontSize: $fontSize,
|
||
theme: selectedTheme,
|
||
onInput: { text in
|
||
viewModel.sendInput(text)
|
||
},
|
||
onResize: { cols, rows in
|
||
viewModel.terminalCols = cols
|
||
viewModel.terminalRows = rows
|
||
viewModel.resize(cols: cols, rows: rows)
|
||
},
|
||
viewModel: viewModel
|
||
)
|
||
}
|
||
}
|
||
.id(viewModel.terminalViewId)
|
||
.background(selectedTheme.background)
|
||
.focused($isInputFocused)
|
||
.overlay(
|
||
ScrollToBottomButton(
|
||
isVisible: showScrollToBottom
|
||
) {
|
||
viewModel.scrollToBottom()
|
||
showScrollToBottom = false
|
||
}
|
||
.padding(.bottom, Theme.Spacing.large)
|
||
.padding(.leading, Theme.Spacing.large),
|
||
alignment: .bottomLeading
|
||
)
|
||
|
||
// Keyboard toolbar
|
||
if keyboardHeight > 0 {
|
||
TerminalToolbar(
|
||
onSpecialKey: { key in
|
||
viewModel.sendInput(key.rawValue)
|
||
},
|
||
onDismissKeyboard: {
|
||
isInputFocused = false
|
||
},
|
||
onRawInput: { input in
|
||
viewModel.sendInput(input)
|
||
}
|
||
)
|
||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// View model for terminal session management.
|
||
@MainActor
|
||
@Observable
|
||
class TerminalViewModel {
|
||
var isConnecting = true
|
||
var isConnected = false
|
||
var errorMessage: String?
|
||
var terminalViewId = UUID()
|
||
var terminalCols: Int = 0
|
||
var terminalRows: Int = 0
|
||
var isAutoScrollEnabled = true
|
||
var recordingPulse = false
|
||
var isResizeBlockedByServer = false
|
||
var isAtBottom = true
|
||
var fitToWidth = false
|
||
|
||
let session: Session
|
||
let castRecorder: CastRecorder
|
||
var bufferWebSocketClient: BufferWebSocketClient?
|
||
private var connectionStatusTask: Task<Void, Never>?
|
||
private var connectionErrorTask: Task<Void, Never>?
|
||
weak var terminalCoordinator: AnyObject? // Can be TerminalHostingView.Coordinator
|
||
|
||
init(session: Session) {
|
||
self.session = session
|
||
self.castRecorder = CastRecorder(sessionId: session.id, width: 80, height: 24)
|
||
setupTerminal()
|
||
}
|
||
|
||
private func setupTerminal() {
|
||
// Terminal setup now handled by SimpleTerminalView
|
||
}
|
||
|
||
func startRecording() {
|
||
castRecorder.startRecording()
|
||
}
|
||
|
||
func stopRecording() {
|
||
castRecorder.stopRecording()
|
||
}
|
||
|
||
func connect() {
|
||
isConnecting = true
|
||
errorMessage = nil
|
||
|
||
// Create WebSocket client if needed
|
||
if bufferWebSocketClient == nil {
|
||
bufferWebSocketClient = BufferWebSocketClient()
|
||
}
|
||
|
||
// Connect to WebSocket
|
||
bufferWebSocketClient?.connect()
|
||
|
||
// Load initial snapshot after a brief delay to ensure terminal is ready
|
||
Task { @MainActor in
|
||
// Wait for terminal view to be initialized
|
||
try? await Task.sleep(nanoseconds: 200_000_000) // 0.2s
|
||
await loadSnapshot()
|
||
}
|
||
|
||
// Subscribe to terminal events
|
||
bufferWebSocketClient?.subscribe(to: session.id) { [weak self] event in
|
||
Task { @MainActor in
|
||
self?.handleWebSocketEvent(event)
|
||
}
|
||
}
|
||
|
||
// Monitor connection status
|
||
connectionStatusTask?.cancel()
|
||
connectionStatusTask = Task { [weak self] in
|
||
guard let client = self?.bufferWebSocketClient else { return }
|
||
while !Task.isCancelled {
|
||
let connected = client.isConnected
|
||
await MainActor.run {
|
||
self?.isConnecting = false
|
||
self?.isConnected = connected
|
||
if !connected {
|
||
self?.errorMessage = "WebSocket disconnected"
|
||
} else {
|
||
self?.errorMessage = nil
|
||
}
|
||
}
|
||
try? await Task.sleep(nanoseconds: 500_000_000) // Check every 0.5 seconds
|
||
}
|
||
}
|
||
|
||
// Monitor connection errors
|
||
connectionErrorTask?.cancel()
|
||
connectionErrorTask = Task { [weak self] in
|
||
guard let client = self?.bufferWebSocketClient else { return }
|
||
while !Task.isCancelled {
|
||
if let error = client.connectionError {
|
||
await MainActor.run {
|
||
self?.errorMessage = error.localizedDescription
|
||
self?.isConnecting = false
|
||
}
|
||
}
|
||
try? await Task.sleep(nanoseconds: 500_000_000) // Check every 0.5 seconds
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func loadSnapshot() async {
|
||
do {
|
||
let snapshot = try await APIClient.shared.getSessionSnapshot(sessionId: session.id)
|
||
|
||
// Process the snapshot events
|
||
if let header = snapshot.header {
|
||
// Initialize terminal with dimensions from header
|
||
terminalCols = header.width
|
||
terminalRows = header.height
|
||
logger.debug("Snapshot header: \(header.width)x\(header.height)")
|
||
}
|
||
|
||
// Feed all output events to the terminal
|
||
for event in snapshot.events {
|
||
if event.type == .output {
|
||
// Feed the actual terminal output data
|
||
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
|
||
coordinator.feedData(event.data)
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
logger.error("Failed to load terminal snapshot: \(error)")
|
||
}
|
||
}
|
||
|
||
func disconnect() {
|
||
connectionStatusTask?.cancel()
|
||
connectionErrorTask?.cancel()
|
||
bufferWebSocketClient?.unsubscribe(from: session.id)
|
||
bufferWebSocketClient?.disconnect()
|
||
bufferWebSocketClient = nil
|
||
isConnected = false
|
||
}
|
||
|
||
@MainActor
|
||
private func handleWebSocketEvent(_ event: TerminalWebSocketEvent) {
|
||
switch event {
|
||
case .header(let width, let height):
|
||
// Initial terminal setup
|
||
logger.info("Terminal initialized: \(width)x\(height)")
|
||
terminalCols = width
|
||
terminalRows = height
|
||
// The terminal will be resized when created
|
||
|
||
case .output(_, let data):
|
||
// Feed output data directly to the terminal
|
||
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
|
||
coordinator.feedData(data)
|
||
} else {
|
||
// Queue the data to be fed once coordinator is ready
|
||
logger.warning("Terminal coordinator not ready, queueing data")
|
||
Task {
|
||
// Wait a bit for coordinator to be initialized
|
||
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s
|
||
if let coordinator = self.terminalCoordinator as? TerminalHostingView.Coordinator {
|
||
coordinator.feedData(data)
|
||
}
|
||
}
|
||
}
|
||
// Record output if recording
|
||
castRecorder.recordOutput(data)
|
||
|
||
case .resize(_, let dimensions):
|
||
// Parse dimensions like "120x30"
|
||
let parts = dimensions.split(separator: "x")
|
||
if parts.count == 2,
|
||
let cols = Int(parts[0]),
|
||
let rows = Int(parts[1])
|
||
{
|
||
// Update terminal dimensions
|
||
terminalCols = cols
|
||
terminalRows = rows
|
||
logger.info("Terminal resize: \(cols)x\(rows)")
|
||
// Record resize event
|
||
castRecorder.recordResize(cols: cols, rows: rows)
|
||
}
|
||
|
||
case .exit(let code):
|
||
// Session has exited
|
||
isConnected = false
|
||
if code != 0 {
|
||
errorMessage = "Session exited with code \(code)"
|
||
}
|
||
// Stop recording if active
|
||
if castRecorder.isRecording {
|
||
stopRecording()
|
||
}
|
||
|
||
// Load final snapshot for exited session
|
||
Task { @MainActor in
|
||
// Give the server a moment to finalize the snapshot
|
||
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s
|
||
await loadSnapshot()
|
||
}
|
||
|
||
case .bufferUpdate(let snapshot):
|
||
// Update terminal buffer directly
|
||
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
|
||
coordinator.updateBuffer(from: TerminalHostingView.BufferSnapshot(
|
||
cols: snapshot.cols,
|
||
rows: snapshot.rows,
|
||
viewportY: snapshot.viewportY,
|
||
cursorX: snapshot.cursorX,
|
||
cursorY: snapshot.cursorY,
|
||
cells: snapshot.cells.map { row in
|
||
row.map { cell in
|
||
TerminalHostingView.BufferCell(
|
||
char: cell.char,
|
||
width: cell.width,
|
||
fg: cell.fg,
|
||
bg: cell.bg,
|
||
attributes: cell.attributes
|
||
)
|
||
}
|
||
}
|
||
))
|
||
} else {
|
||
// Fallback: buffer updates not available yet
|
||
logger.warning("Direct buffer update not available")
|
||
}
|
||
|
||
case .bell:
|
||
// Terminal bell - play sound and/or haptic feedback
|
||
handleTerminalBell()
|
||
|
||
case .alert(let title, let message):
|
||
// Terminal alert - show notification
|
||
handleTerminalAlert(title: title, message: message)
|
||
}
|
||
}
|
||
|
||
func sendInput(_ text: String) {
|
||
Task {
|
||
do {
|
||
try await SessionService.shared.sendInput(to: session.id, text: text)
|
||
} catch {
|
||
logger.error("Failed to send input: \(error)")
|
||
}
|
||
}
|
||
}
|
||
|
||
func resize(cols: Int, rows: Int) {
|
||
Task {
|
||
do {
|
||
try await SessionService.shared.resizeTerminal(sessionId: session.id, cols: cols, rows: rows)
|
||
// If resize succeeded, ensure the flag is cleared
|
||
isResizeBlockedByServer = false
|
||
} catch {
|
||
logger.error("Failed to resize terminal: \(error)")
|
||
// Check if the error is specifically about resize being disabled
|
||
if case APIError.resizeDisabledByServer = error {
|
||
isResizeBlockedByServer = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func clearTerminal() {
|
||
// Reset the terminal by recreating it
|
||
terminalViewId = UUID()
|
||
HapticFeedback.impact(.medium)
|
||
}
|
||
|
||
func copyBuffer() {
|
||
// Terminal copy is handled by SwiftTerm's built-in functionality
|
||
HapticFeedback.notification(.success)
|
||
}
|
||
|
||
func getBufferContent() -> String? {
|
||
// Get the current terminal buffer content
|
||
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
|
||
return coordinator.getBufferContent()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
@MainActor
|
||
private func handleTerminalBell() {
|
||
// Haptic feedback for bell
|
||
HapticFeedback.notification(.warning)
|
||
|
||
// Visual bell - flash the terminal briefly
|
||
withAnimation(.easeInOut(duration: 0.1)) {
|
||
// SwiftTerm handles visual bell internally
|
||
// but we can add additional feedback if needed
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func handleTerminalAlert(title: String?, message: String) {
|
||
// Log the alert
|
||
logger.info("Terminal Alert - \(title ?? "Alert"): \(message)")
|
||
|
||
// Show as a system notification if app is in background
|
||
// For now, just provide haptic feedback
|
||
HapticFeedback.notification(.error)
|
||
}
|
||
|
||
func scrollToBottom() {
|
||
// Signal the terminal to scroll to bottom
|
||
isAutoScrollEnabled = true
|
||
isAtBottom = true
|
||
// The actual scrolling is handled by the terminal coordinator
|
||
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
|
||
coordinator.scrollToBottom()
|
||
}
|
||
}
|
||
|
||
func updateScrollState(isAtBottom: Bool) {
|
||
self.isAtBottom = isAtBottom
|
||
self.isAutoScrollEnabled = isAtBottom
|
||
}
|
||
|
||
func toggleFitToWidth() {
|
||
fitToWidth.toggle()
|
||
HapticFeedback.impact(.light)
|
||
|
||
if fitToWidth {
|
||
// Calculate optimal width to fit the screen
|
||
let screenWidth = UIScreen.main.bounds.width
|
||
let padding: CGFloat = 32 // Account for UI padding
|
||
let charWidth: CGFloat = 9 // Approximate character width
|
||
let optimalCols = Int((screenWidth - padding) / charWidth)
|
||
|
||
// Resize to fit
|
||
resize(cols: optimalCols, rows: terminalRows)
|
||
}
|
||
}
|
||
|
||
func setMaxWidth(_ maxWidth: Int) {
|
||
// Store the max width preference
|
||
// When maxWidth is 0, it means unlimited
|
||
let targetWidth = maxWidth == 0 ? nil : maxWidth
|
||
|
||
if let width = targetWidth, width != terminalCols {
|
||
// Maintain aspect ratio when changing width
|
||
let aspectRatio = Double(terminalRows) / Double(terminalCols)
|
||
let newHeight = Int(Double(width) * aspectRatio)
|
||
resize(cols: width, rows: newHeight)
|
||
}
|
||
|
||
// Update the terminal coordinator if using constrained width
|
||
if let coordinator = terminalCoordinator as? TerminalHostingView.Coordinator {
|
||
coordinator.setMaxWidth(maxWidth)
|
||
}
|
||
}
|
||
}
|