mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
442 lines
16 KiB
Swift
442 lines
16 KiB
Swift
import SwiftUI
|
|
|
|
/// Enhanced connection view with server profiles support
|
|
struct EnhancedConnectionView: View {
|
|
@Environment(ConnectionManager.self)
|
|
var connectionManager
|
|
@State private var networkMonitor = NetworkMonitor.shared
|
|
@State private var viewModel = ConnectionViewModel()
|
|
@State private var profilesViewModel = ServerProfilesViewModel()
|
|
@State private var logoScale: CGFloat = 0.8
|
|
@State private var contentOpacity: Double = 0
|
|
@State private var showingNewServerForm = false
|
|
@State private var selectedProfile: ServerProfile?
|
|
@State private var showingProfileEditor = false
|
|
|
|
#if targetEnvironment(macCatalyst)
|
|
@StateObject private var windowManager = MacCatalystWindowManager.shared
|
|
#endif
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
ScrollView {
|
|
VStack(spacing: Theme.Spacing.extraLarge) {
|
|
// Logo and Title
|
|
headerView
|
|
.padding(.top, {
|
|
#if targetEnvironment(macCatalyst)
|
|
return windowManager.windowStyle == .inline ? 60 : 40
|
|
#else
|
|
return 40
|
|
#endif
|
|
}())
|
|
|
|
// Quick Connect Section
|
|
if !profilesViewModel.profiles.isEmpty && !showingNewServerForm {
|
|
quickConnectSection
|
|
.opacity(contentOpacity)
|
|
.onAppear {
|
|
withAnimation(Theme.Animation.smooth.delay(0.3)) {
|
|
contentOpacity = 1.0
|
|
}
|
|
}
|
|
}
|
|
|
|
// New Connection Form
|
|
if showingNewServerForm || profilesViewModel.profiles.isEmpty {
|
|
newConnectionSection
|
|
.opacity(contentOpacity)
|
|
.onAppear {
|
|
withAnimation(Theme.Animation.smooth.delay(0.3)) {
|
|
contentOpacity = 1.0
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 50)
|
|
}
|
|
.padding()
|
|
}
|
|
.scrollBounceBehavior(.basedOnSize)
|
|
}
|
|
.toolbar(.hidden, for: .navigationBar)
|
|
.background(Theme.Colors.terminalBackground.ignoresSafeArea())
|
|
.sheet(item: $selectedProfile) { profile in
|
|
ServerProfileEditView(
|
|
profile: profile,
|
|
onSave: { updatedProfile, password in
|
|
Task {
|
|
try await profilesViewModel.updateProfile(updatedProfile, password: password)
|
|
selectedProfile = nil
|
|
}
|
|
},
|
|
onDelete: {
|
|
Task {
|
|
try await profilesViewModel.deleteProfile(profile)
|
|
selectedProfile = nil
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
.navigationViewStyle(StackNavigationViewStyle())
|
|
.preferredColorScheme(.dark)
|
|
.onAppear {
|
|
profilesViewModel.loadProfiles()
|
|
}
|
|
}
|
|
|
|
// MARK: - Header View
|
|
|
|
private var headerView: some View {
|
|
VStack(spacing: Theme.Spacing.large) {
|
|
ZStack {
|
|
// Glow effect
|
|
Image(systemName: "terminal.fill")
|
|
.font(.system(size: 80))
|
|
.foregroundColor(Theme.Colors.primaryAccent)
|
|
.blur(radius: 20)
|
|
.opacity(0.5)
|
|
|
|
// Main icon
|
|
Image(systemName: "terminal.fill")
|
|
.font(.system(size: 80))
|
|
.foregroundColor(Theme.Colors.primaryAccent)
|
|
.glowEffect()
|
|
}
|
|
.scaleEffect(logoScale)
|
|
.onAppear {
|
|
withAnimation(Theme.Animation.smooth.delay(0.1)) {
|
|
logoScale = 1.0
|
|
}
|
|
}
|
|
|
|
VStack(spacing: Theme.Spacing.small) {
|
|
Text("VibeTunnel")
|
|
.font(.system(size: 42, weight: .bold, design: .rounded))
|
|
.foregroundColor(Theme.Colors.terminalForeground)
|
|
|
|
Text("Terminal Multiplexer")
|
|
.font(Theme.Typography.terminalSystem(size: 16))
|
|
.foregroundColor(Theme.Colors.terminalForeground.opacity(0.7))
|
|
.tracking(2)
|
|
|
|
// Network status
|
|
ConnectionStatusView()
|
|
.padding(.top, Theme.Spacing.small)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Quick Connect Section
|
|
|
|
private var quickConnectSection: some View {
|
|
VStack(alignment: .leading, spacing: Theme.Spacing.medium) {
|
|
HStack {
|
|
Text("Saved Servers")
|
|
.font(Theme.Typography.terminalSystem(size: 18, weight: .semibold))
|
|
.foregroundColor(Theme.Colors.terminalForeground)
|
|
|
|
Spacer()
|
|
|
|
Button(action: {
|
|
withAnimation {
|
|
showingNewServerForm.toggle()
|
|
}
|
|
}) {
|
|
Image(systemName: showingNewServerForm ? "minus.circle" : "plus.circle")
|
|
.font(.system(size: 20))
|
|
.foregroundColor(Theme.Colors.primaryAccent)
|
|
}
|
|
}
|
|
|
|
VStack(spacing: Theme.Spacing.small) {
|
|
ForEach(profilesViewModel.profiles) { profile in
|
|
ServerProfileCard(
|
|
profile: profile,
|
|
isLoading: profilesViewModel.isLoading,
|
|
onConnect: {
|
|
connectToProfile(profile)
|
|
},
|
|
onEdit: {
|
|
selectedProfile = profile
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - New Connection Section
|
|
|
|
private var newConnectionSection: some View {
|
|
VStack(spacing: Theme.Spacing.large) {
|
|
if !profilesViewModel.profiles.isEmpty {
|
|
HStack {
|
|
Text("New Server Connection")
|
|
.font(Theme.Typography.terminalSystem(size: 18, weight: .semibold))
|
|
.foregroundColor(Theme.Colors.terminalForeground)
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
ServerConfigForm(
|
|
host: $viewModel.host,
|
|
port: $viewModel.port,
|
|
name: $viewModel.name,
|
|
password: $viewModel.password,
|
|
isConnecting: viewModel.isConnecting,
|
|
errorMessage: viewModel.errorMessage,
|
|
onConnect: saveAndConnect
|
|
)
|
|
|
|
if !profilesViewModel.profiles.isEmpty {
|
|
Button(action: {
|
|
withAnimation {
|
|
showingNewServerForm = false
|
|
}
|
|
}) {
|
|
Text("Cancel")
|
|
.font(Theme.Typography.terminalSystem(size: 16))
|
|
.foregroundColor(Theme.Colors.secondaryText)
|
|
}
|
|
.padding(.top, Theme.Spacing.small)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func connectToProfile(_ profile: ServerProfile) {
|
|
guard networkMonitor.isConnected else {
|
|
viewModel.errorMessage = "No internet connection available"
|
|
return
|
|
}
|
|
|
|
Task {
|
|
do {
|
|
try await profilesViewModel.connectToProfile(profile, connectionManager: connectionManager)
|
|
} catch {
|
|
viewModel.errorMessage = "Failed to connect: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
}
|
|
|
|
private func saveAndConnect() {
|
|
guard networkMonitor.isConnected else {
|
|
viewModel.errorMessage = "No internet connection available"
|
|
return
|
|
}
|
|
|
|
// Create profile from form data
|
|
let urlString = viewModel.port.isEmpty ? viewModel.host : "\(viewModel.host):\(viewModel.port)"
|
|
guard let profile = profilesViewModel.createProfileFromURL(urlString) else {
|
|
viewModel.errorMessage = "Invalid server URL"
|
|
return
|
|
}
|
|
|
|
var updatedProfile = profile
|
|
updatedProfile.name = viewModel.name.isEmpty ? profile.name : viewModel.name
|
|
updatedProfile.requiresAuth = !viewModel.password.isEmpty
|
|
updatedProfile.username = updatedProfile.requiresAuth ? "admin" : nil
|
|
|
|
// Save profile and password
|
|
Task {
|
|
try await profilesViewModel.addProfile(updatedProfile, password: viewModel.password)
|
|
|
|
// Connect
|
|
connectToProfile(updatedProfile)
|
|
}
|
|
|
|
// Reset form
|
|
viewModel = ConnectionViewModel()
|
|
showingNewServerForm = false
|
|
}
|
|
}
|
|
|
|
// MARK: - Server Profile Card
|
|
|
|
struct ServerProfileCard: View {
|
|
let profile: ServerProfile
|
|
let isLoading: Bool
|
|
let onConnect: () -> Void
|
|
let onEdit: () -> Void
|
|
|
|
@State private var isPressed = false
|
|
|
|
var body: some View {
|
|
HStack(spacing: Theme.Spacing.medium) {
|
|
// Icon
|
|
Image(systemName: profile.iconSymbol)
|
|
.font(.system(size: 24))
|
|
.foregroundColor(Theme.Colors.primaryAccent)
|
|
.frame(width: 40, height: 40)
|
|
.background(Theme.Colors.primaryAccent.opacity(0.1))
|
|
.cornerRadius(Theme.CornerRadius.small)
|
|
|
|
// Server Info
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(profile.name)
|
|
.font(Theme.Typography.terminalSystem(size: 16, weight: .medium))
|
|
.foregroundColor(Theme.Colors.terminalForeground)
|
|
|
|
HStack(spacing: 4) {
|
|
Text(profile.url)
|
|
.font(Theme.Typography.terminalSystem(size: 12))
|
|
.foregroundColor(Theme.Colors.secondaryText)
|
|
|
|
if profile.requiresAuth {
|
|
Image(systemName: "lock.fill")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(Theme.Colors.warningAccent)
|
|
}
|
|
}
|
|
|
|
if let lastConnected = profile.lastConnected {
|
|
Text(RelativeDateTimeFormatter().localizedString(for: lastConnected, relativeTo: Date()))
|
|
.font(Theme.Typography.terminalSystem(size: 11))
|
|
.foregroundColor(Theme.Colors.secondaryText.opacity(0.7))
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Action Buttons
|
|
HStack(spacing: Theme.Spacing.small) {
|
|
Button(action: onEdit) {
|
|
Image(systemName: "ellipsis.circle")
|
|
.font(.system(size: 20))
|
|
.foregroundColor(Theme.Colors.secondaryText)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Button(action: onConnect) {
|
|
HStack(spacing: 4) {
|
|
if isLoading {
|
|
ProgressView()
|
|
.scaleEffect(0.8)
|
|
} else {
|
|
Image(systemName: "arrow.right.circle.fill")
|
|
.font(.system(size: 24))
|
|
}
|
|
}
|
|
.foregroundColor(Theme.Colors.primaryAccent)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(isLoading)
|
|
}
|
|
}
|
|
.padding(Theme.Spacing.medium)
|
|
.background(Theme.Colors.cardBackground)
|
|
.cornerRadius(Theme.CornerRadius.card)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.card)
|
|
.stroke(Theme.Colors.cardBorder, lineWidth: 1)
|
|
)
|
|
.scaleEffect(isPressed ? 0.98 : 1.0)
|
|
.animation(.easeInOut(duration: 0.1), value: isPressed)
|
|
.onTapGesture {
|
|
onConnect()
|
|
}
|
|
.simultaneousGesture(
|
|
DragGesture(minimumDistance: 0)
|
|
.onChanged { _ in isPressed = true }
|
|
.onEnded { _ in isPressed = false }
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Server Profile Edit View
|
|
|
|
struct ServerProfileEditView: View {
|
|
@State var profile: ServerProfile
|
|
let onSave: (ServerProfile, String?) -> Void
|
|
let onDelete: () -> Void
|
|
|
|
@State private var password: String = ""
|
|
@State private var showingDeleteConfirmation = false
|
|
@Environment(\.dismiss)
|
|
private var dismiss
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
Section("Server Details") {
|
|
HStack {
|
|
Text("Icon")
|
|
Spacer()
|
|
Image(systemName: profile.iconSymbol)
|
|
.font(.system(size: 24))
|
|
.foregroundColor(Theme.Colors.primaryAccent)
|
|
}
|
|
|
|
TextField("Name", text: $profile.name)
|
|
TextField("URL", text: $profile.url)
|
|
|
|
Toggle("Requires Authentication", isOn: $profile.requiresAuth)
|
|
|
|
if profile.requiresAuth {
|
|
TextField("Username", text: Binding(
|
|
get: { profile.username ?? "admin" },
|
|
set: { profile.username = $0 }
|
|
))
|
|
SecureField("Password", text: $password)
|
|
.textContentType(.password)
|
|
}
|
|
}
|
|
|
|
Section {
|
|
Button(role: .destructive, action: {
|
|
showingDeleteConfirmation = true
|
|
}) {
|
|
Label("Delete Server", systemImage: "trash")
|
|
.foregroundColor(.red)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Edit Server")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
}
|
|
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Save") {
|
|
onSave(profile, profile.requiresAuth ? password : nil)
|
|
dismiss()
|
|
}
|
|
.fontWeight(.semibold)
|
|
}
|
|
}
|
|
.alert("Delete Server?", isPresented: $showingDeleteConfirmation) {
|
|
Button("Delete", role: .destructive) {
|
|
onDelete()
|
|
dismiss()
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("Are you sure you want to delete \"\(profile.name)\"? This action cannot be undone.")
|
|
}
|
|
}
|
|
.task {
|
|
// Load existing password from keychain
|
|
if profile.requiresAuth,
|
|
let existingPassword = try? KeychainService.getPassword(for: profile.id)
|
|
{
|
|
password = existingPassword
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
EnhancedConnectionView()
|
|
.environment(ConnectionManager())
|
|
}
|