mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-26 15:07:39 +00:00
Modernize SwiftUI on iOS
This commit is contained in:
parent
596c3393ea
commit
856a581608
13 changed files with 139 additions and 119 deletions
|
|
@ -2,7 +2,7 @@ import SwiftUI
|
|||
import UniformTypeIdentifiers
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var connectionManager: ConnectionManager
|
||||
@Environment(ConnectionManager.self) var connectionManager
|
||||
@State private var showingFilePicker = false
|
||||
@State private var showingCastPlayer = false
|
||||
@State private var selectedCastFile: URL?
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import SwiftUI
|
||||
import Observation
|
||||
|
||||
@main
|
||||
struct VibeTunnelApp: App {
|
||||
@StateObject private var connectionManager = ConnectionManager()
|
||||
@StateObject private var navigationManager = NavigationManager()
|
||||
@State private var connectionManager = ConnectionManager()
|
||||
@State private var navigationManager = NavigationManager()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(connectionManager)
|
||||
.environmentObject(navigationManager)
|
||||
.environment(connectionManager)
|
||||
.environment(navigationManager)
|
||||
.onOpenURL { url in
|
||||
handleURL(url)
|
||||
}
|
||||
|
|
@ -28,9 +29,10 @@ struct VibeTunnelApp: App {
|
|||
}
|
||||
}
|
||||
|
||||
class ConnectionManager: ObservableObject {
|
||||
@Published var isConnected: Bool = false
|
||||
@Published var serverConfig: ServerConfig?
|
||||
@Observable
|
||||
class ConnectionManager {
|
||||
var isConnected: Bool = false
|
||||
var serverConfig: ServerConfig?
|
||||
|
||||
init() {
|
||||
loadSavedConnection()
|
||||
|
|
@ -55,9 +57,10 @@ class ConnectionManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
class NavigationManager: ObservableObject {
|
||||
@Published var selectedSessionId: String?
|
||||
@Published var shouldNavigateToSession: Bool = false
|
||||
@Observable
|
||||
class NavigationManager {
|
||||
var selectedSessionId: String?
|
||||
var shouldNavigateToSession: Bool = false
|
||||
|
||||
func navigateToSession(_ sessionId: String) {
|
||||
selectedSessionId = sessionId
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import Foundation
|
||||
import Observation
|
||||
|
||||
// Asciinema cast v2 format support
|
||||
struct CastFile: Codable {
|
||||
|
|
@ -25,10 +26,11 @@ struct CastEvent: Codable {
|
|||
|
||||
// Cast file recorder for terminal sessions
|
||||
@MainActor
|
||||
class CastRecorder: ObservableObject {
|
||||
@Published var isRecording = false
|
||||
@Published var recordingStartTime: Date?
|
||||
@Published var events: [CastEvent] = []
|
||||
@Observable
|
||||
class CastRecorder {
|
||||
var isRecording = false
|
||||
var recordingStartTime: Date?
|
||||
var events: [CastEvent] = []
|
||||
|
||||
private let sessionId: String
|
||||
private let width: Int
|
||||
|
|
|
|||
|
|
@ -327,14 +327,8 @@ class BufferWebSocketClient: NSObject {
|
|||
}
|
||||
|
||||
deinit {
|
||||
reconnectTimer?.invalidate()
|
||||
reconnectTimer = nil
|
||||
pingTimer?.invalidate()
|
||||
pingTimer = nil
|
||||
|
||||
// Cancel the WebSocket task
|
||||
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
||||
webSocketTask = nil
|
||||
|
||||
subscriptions.removeAll()
|
||||
// Timers will be cleaned up automatically when the object is deallocated
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
import SwiftUI
|
||||
import Observation
|
||||
|
||||
struct ConnectionView: View {
|
||||
@EnvironmentObject var connectionManager: ConnectionManager
|
||||
@StateObject private var viewModel = ConnectionViewModel()
|
||||
@Environment(ConnectionManager.self) var connectionManager
|
||||
@State private var viewModel = ConnectionViewModel()
|
||||
@State private var logoScale: CGFloat = 0.8
|
||||
@State private var contentOpacity: Double = 0
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
// Background
|
||||
Theme.Colors.terminalBackground
|
||||
|
|
@ -72,7 +73,7 @@ struct ConnectionView: View {
|
|||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
.preferredColorScheme(.dark)
|
||||
|
|
@ -91,13 +92,14 @@ struct ConnectionView: View {
|
|||
}
|
||||
}
|
||||
|
||||
class ConnectionViewModel: ObservableObject {
|
||||
@Published var host: String = "127.0.0.1"
|
||||
@Published var port: String = "4020"
|
||||
@Published var name: String = ""
|
||||
@Published var password: String = ""
|
||||
@Published var isConnecting: Bool = false
|
||||
@Published var errorMessage: String?
|
||||
@Observable
|
||||
class ConnectionViewModel {
|
||||
var host: String = "127.0.0.1"
|
||||
var port: String = "4020"
|
||||
var name: String = ""
|
||||
var password: String = ""
|
||||
var isConnecting: Bool = false
|
||||
var errorMessage: String?
|
||||
|
||||
func loadLastConnection() {
|
||||
if let config = UserDefaults.standard.data(forKey: "savedServerConfig"),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import SwiftUI
|
||||
import Observation
|
||||
|
||||
struct FileBrowserView: View {
|
||||
@StateObject private var viewModel = FileBrowserViewModel()
|
||||
@State private var viewModel = FileBrowserViewModel()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let onSelect: (String) -> Void
|
||||
|
|
@ -148,7 +149,7 @@ struct FileBrowserView: View {
|
|||
.background(Theme.Colors.terminalDarkGray)
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.alert("Create Folder", isPresented: $viewModel.showCreateFolder) {
|
||||
TextField("Folder name", text: $viewModel.newFolderName)
|
||||
.textInputAutocapitalization(.never)
|
||||
|
|
@ -259,14 +260,15 @@ struct TerminalButtonStyle: ButtonStyle {
|
|||
}
|
||||
|
||||
@MainActor
|
||||
class FileBrowserViewModel: ObservableObject {
|
||||
@Published var currentPath = "~"
|
||||
@Published var entries: [FileEntry] = []
|
||||
@Published var isLoading = false
|
||||
@Published var showCreateFolder = false
|
||||
@Published var newFolderName = ""
|
||||
@Published var showError = false
|
||||
@Published var errorMessage: String?
|
||||
@Observable
|
||||
class FileBrowserViewModel {
|
||||
var currentPath = "~"
|
||||
var entries: [FileEntry] = []
|
||||
var isLoading = false
|
||||
var showCreateFolder = false
|
||||
var newFolderName = ""
|
||||
var showError = false
|
||||
var errorMessage: String?
|
||||
|
||||
private let apiClient = APIClient.shared
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ struct SessionCreateView: View {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Theme.Colors.terminalBackground
|
||||
.ignoresSafeArea()
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import SwiftUI
|
||||
import Observation
|
||||
|
||||
struct SessionListView: View {
|
||||
@EnvironmentObject var connectionManager: ConnectionManager
|
||||
@EnvironmentObject var navigationManager: NavigationManager
|
||||
@StateObject private var viewModel = SessionListViewModel()
|
||||
@Environment(ConnectionManager.self) var connectionManager
|
||||
@Environment(NavigationManager.self) var navigationManager
|
||||
@State private var viewModel = SessionListViewModel()
|
||||
@State private var showingCreateSession = false
|
||||
@State private var selectedSession: Session?
|
||||
@State private var showExitedSessions = true
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
// Background
|
||||
Theme.Colors.terminalBackground
|
||||
|
|
@ -78,9 +79,7 @@ struct SessionListView: View {
|
|||
viewModel.stopAutoRefresh()
|
||||
}
|
||||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
.preferredColorScheme(.dark)
|
||||
.environmentObject(connectionManager)
|
||||
.onChange(of: navigationManager.shouldNavigateToSession) { shouldNavigate in
|
||||
if shouldNavigate,
|
||||
let sessionId = navigationManager.selectedSessionId,
|
||||
|
|
@ -294,30 +293,33 @@ struct SessionListView: View {
|
|||
}
|
||||
|
||||
@MainActor
|
||||
class SessionListViewModel: ObservableObject {
|
||||
@Published var sessions: [Session] = []
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
@Observable
|
||||
class SessionListViewModel {
|
||||
var sessions: [Session] = []
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
|
||||
private var refreshTimer: Timer?
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
private let sessionService = SessionService.shared
|
||||
|
||||
func startAutoRefresh() {
|
||||
Task {
|
||||
refreshTask?.cancel()
|
||||
refreshTask = Task {
|
||||
await loadSessions()
|
||||
}
|
||||
|
||||
// Refresh every 3 seconds
|
||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { _ in
|
||||
Task { @MainActor in
|
||||
await self.loadSessions()
|
||||
|
||||
// Refresh every 3 seconds using modern async approach
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
|
||||
if !Task.isCancelled {
|
||||
await loadSessions()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopAutoRefresh() {
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = nil
|
||||
refreshTask?.cancel()
|
||||
refreshTask = nil
|
||||
}
|
||||
|
||||
func loadSessions() async {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
import SwiftUI
|
||||
import Observation
|
||||
import SwiftTerm
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct CastPlayerView: View {
|
||||
let castFileURL: URL
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@StateObject private var viewModel = CastPlayerViewModel()
|
||||
@State private var viewModel = CastPlayerViewModel()
|
||||
@State private var fontSize: CGFloat = 14
|
||||
@State private var isPlaying = false
|
||||
@State private var currentTime: TimeInterval = 0
|
||||
@State private var playbackSpeed: Double = 1.0
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Theme.Colors.terminalBackground
|
||||
.ignoresSafeArea()
|
||||
|
|
@ -143,9 +144,9 @@ struct CastPlayerView: View {
|
|||
.padding()
|
||||
.background(Theme.Colors.cardBackground)
|
||||
}
|
||||
.onReceive(viewModel.$currentTime) { time in
|
||||
.onChange(of: viewModel.currentTime) { _, newTime in
|
||||
if !viewModel.isSeeking {
|
||||
currentTime = time
|
||||
currentTime = newTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -177,7 +178,7 @@ struct CastPlayerView: View {
|
|||
// Simple terminal view for cast playback
|
||||
struct CastTerminalView: UIViewRepresentable {
|
||||
@Binding var fontSize: CGFloat
|
||||
@ObservedObject var viewModel: CastPlayerViewModel
|
||||
let viewModel: CastPlayerViewModel
|
||||
|
||||
func makeUIView(context: Context) -> SwiftTerm.TerminalView {
|
||||
let terminal = SwiftTerm.TerminalView()
|
||||
|
|
@ -251,11 +252,12 @@ struct CastTerminalView: UIViewRepresentable {
|
|||
}
|
||||
|
||||
@MainActor
|
||||
class CastPlayerViewModel: ObservableObject {
|
||||
@Published var isLoading = true
|
||||
@Published var errorMessage: String?
|
||||
@Published var currentTime: TimeInterval = 0
|
||||
@Published var isSeeking = false
|
||||
@Observable
|
||||
class CastPlayerViewModel {
|
||||
var isLoading = true
|
||||
var errorMessage: String?
|
||||
var currentTime: TimeInterval = 0
|
||||
var isSeeking = false
|
||||
|
||||
var player: CastPlayer?
|
||||
var header: CastFile? { player?.header }
|
||||
|
|
@ -293,19 +295,21 @@ class CastPlayerViewModel: ObservableObject {
|
|||
guard let player = player else { return }
|
||||
|
||||
player.play(from: currentTime, speed: speed) { [weak self] event in
|
||||
guard let self = self else { return }
|
||||
|
||||
switch event.type {
|
||||
case "o":
|
||||
self.onTerminalOutput?(event.data)
|
||||
case "r":
|
||||
// Handle resize if needed
|
||||
break
|
||||
default:
|
||||
break
|
||||
Task { @MainActor in
|
||||
guard let self = self else { return }
|
||||
|
||||
switch event.type {
|
||||
case "o":
|
||||
self.onTerminalOutput?(event.data)
|
||||
case "r":
|
||||
// Handle resize if needed
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
self.currentTime = event.time
|
||||
}
|
||||
|
||||
self.currentTime = event.time
|
||||
} completion: {
|
||||
// Playback completed
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ struct FontSizeSheet: View {
|
|||
let fontSizes: [CGFloat] = [10, 12, 14, 16, 18, 20, 22, 24, 28, 32]
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// Font size preview
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import SwiftUI
|
|||
import UniformTypeIdentifiers
|
||||
|
||||
struct RecordingExportSheet: View {
|
||||
@ObservedObject var recorder: CastRecorder
|
||||
var recorder: CastRecorder
|
||||
let sessionName: String
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var isExporting = false
|
||||
|
|
@ -10,7 +10,7 @@ struct RecordingExportSheet: View {
|
|||
@State private var exportedFileURL: URL?
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
NavigationStack {
|
||||
VStack(spacing: Theme.Spacing.xl) {
|
||||
// Icon
|
||||
ZStack {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ struct TerminalHostingView: UIViewRepresentable {
|
|||
@Binding var fontSize: CGFloat
|
||||
let onInput: (String) -> Void
|
||||
let onResize: (Int, Int) -> Void
|
||||
@ObservedObject var viewModel: TerminalViewModel
|
||||
var viewModel: TerminalViewModel
|
||||
@State private var isAutoScrollEnabled = true
|
||||
|
||||
func makeUIView(context: Context) -> SwiftTerm.TerminalView {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import SwiftUI
|
||||
import Combine
|
||||
import Observation
|
||||
import SwiftTerm
|
||||
|
||||
struct TerminalView: View {
|
||||
let session: Session
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@StateObject private var viewModel: TerminalViewModel
|
||||
@State private var viewModel: TerminalViewModel
|
||||
@State private var fontSize: CGFloat = 14
|
||||
@State private var showingFontSizeSheet = false
|
||||
@State private var showingRecordingSheet = false
|
||||
|
|
@ -14,11 +14,11 @@ struct TerminalView: View {
|
|||
|
||||
init(session: Session) {
|
||||
self.session = session
|
||||
self._viewModel = StateObject(wrappedValue: TerminalViewModel(session: session))
|
||||
self._viewModel = State(initialValue: TerminalViewModel(session: session))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
// Background
|
||||
Theme.Colors.terminalBackground
|
||||
|
|
@ -157,7 +157,6 @@ struct TerminalView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
.preferredColorScheme(.dark)
|
||||
.onAppear {
|
||||
viewModel.connect()
|
||||
|
|
@ -257,20 +256,22 @@ struct TerminalView: View {
|
|||
}
|
||||
|
||||
@MainActor
|
||||
class TerminalViewModel: ObservableObject {
|
||||
@Published var isConnecting = true
|
||||
@Published var isConnected = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var terminalViewId = UUID()
|
||||
@Published var terminalCols: Int = 0
|
||||
@Published var terminalRows: Int = 0
|
||||
@Published var isAutoScrollEnabled = true
|
||||
@Published var recordingPulse = false
|
||||
@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
|
||||
|
||||
let session: Session
|
||||
let castRecorder: CastRecorder
|
||||
private var bufferWebSocketClient: BufferWebSocketClient?
|
||||
var cancellables = Set<AnyCancellable>()
|
||||
private var connectionStatusTask: Task<Void, Never>?
|
||||
private var connectionErrorTask: Task<Void, Never>?
|
||||
weak var terminalCoordinator: TerminalHostingView.Coordinator?
|
||||
|
||||
init(session: Session) {
|
||||
|
|
@ -311,9 +312,12 @@ class TerminalViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
// Monitor connection status
|
||||
bufferWebSocketClient?.$isConnected
|
||||
.sink { [weak self] connected in
|
||||
Task { @MainActor in
|
||||
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 {
|
||||
|
|
@ -322,19 +326,24 @@ class TerminalViewModel: ObservableObject {
|
|||
self?.errorMessage = nil
|
||||
}
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 500_000_000) // Check every 0.5 seconds
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// Monitor connection errors
|
||||
bufferWebSocketClient?.$connectionError
|
||||
.compactMap { $0 }
|
||||
.sink { [weak self] error in
|
||||
Task { @MainActor in
|
||||
self?.errorMessage = error.localizedDescription
|
||||
self?.isConnecting = false
|
||||
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
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
@ -353,6 +362,8 @@ class TerminalViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
func disconnect() {
|
||||
connectionStatusTask?.cancel()
|
||||
connectionErrorTask?.cancel()
|
||||
bufferWebSocketClient?.unsubscribe(from: session.id)
|
||||
bufferWebSocketClient?.disconnect()
|
||||
bufferWebSocketClient = nil
|
||||
|
|
@ -369,13 +380,13 @@ class TerminalViewModel: ObservableObject {
|
|||
terminalRows = height
|
||||
// The terminal will be resized when created
|
||||
|
||||
case .output(let timestamp, let data):
|
||||
case .output(_, let data):
|
||||
// Feed output data directly to the terminal
|
||||
terminalCoordinator?.feedData(data)
|
||||
// Record output if recording
|
||||
castRecorder.recordOutput(data)
|
||||
|
||||
case .resize(let timestamp, let dimensions):
|
||||
case .resize(_, let dimensions):
|
||||
// Parse dimensions like "120x30"
|
||||
let parts = dimensions.split(separator: "x")
|
||||
if parts.count == 2,
|
||||
|
|
|
|||
Loading…
Reference in a new issue