Files
remmina-mac/Sources/RemminaMac/Views/ContentView.swift
T

237 lines
9.1 KiB
Swift

import SwiftUI
import AppKit
struct ContentView: View {
@EnvironmentObject var store: ProfileStore
@StateObject private var terminals = TerminalManager()
@State private var selection: ConnectionProfile.ID?
@State private var openSessions: [ConnectionProfile] = []
@State private var activeSessionID: ConnectionProfile.ID?
@State private var showingEditor = false
@State private var editingProfile: ConnectionProfile?
@State private var showingKeys = false
@State private var showingAbout = false
@State private var statusMessage = "Bereit."
var body: some View {
NavigationSplitView {
// Sidebar wie Remmina: Gruppen + Profile
VStack(spacing: 0) {
Picker("Gruppe", selection: $store.selectedGroup) {
ForEach(store.groups, id: \.self) { g in Text(g).tag(g) }
}
.pickerStyle(.menu)
.padding(8)
SearchField(text: $store.searchText)
.padding(.horizontal, 8)
List(store.filteredProfiles, selection: $selection) { p in
HStack {
Image(systemName: p.proto.systemImage)
.foregroundStyle(color(for: p.colorTag))
.frame(width: 22)
VStack(alignment: .leading) {
Text(p.name).font(.headline)
Text("\(p.username)@\(p.host):\(p.port) · \(p.proto.rawValue)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.tag(p.id)
.contextMenu {
Button("Verbinden") { connect(p) }
Button("Duplizieren") { store.duplicate(p) }
Button("Bearbeiten") { edit(p) }
Button("Löschen", role: .destructive) { store.delete(p) }
}
}
.onDoubleClick { if let p = selectedProfile { connect(p) } }
}
.frame(minWidth: 280)
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button(action: addProfile) { Image(systemName: "plus") }
.help("Neues Profil")
Button(action: { if let p = selectedProfile { edit(p) } }) { Image(systemName: "pencil") }
.help("Bearbeiten").disabled(selection == nil)
Button(action: { if let p = selectedProfile { store.duplicate(p) } }) { Image(systemName: "plus.square.on.square") }
.help("Duplizieren").disabled(selection == nil)
Button(action: { showingKeys = true }) { Image(systemName: "key") }
.help("SSH-Keys verwalten")
Button(action: { showingAbout = true }) { Image(systemName: "info.circle") }
.help("Info")
}
}
} detail: {
VStack(spacing: 0) {
// Verbindungs-Tabs
if openSessions.isEmpty {
emptyState
} else {
TabView(selection: $activeSessionID) {
ForEach(openSessions) { session in
SessionView(
profile: session,
statusMessage: $statusMessage,
term: terminals.session(for: session, key: store.key(for: session)),
onClose: { closeSession(session) }
)
.tag(Optional(session.id))
.tabItem { Text(session.name) }
}
}
}
Divider()
// Statusbar wie Remmina
HStack {
Image(systemName: "circle.fill").foregroundStyle(.green).font(.caption2)
Text(statusMessage).font(.caption).foregroundStyle(.secondary)
Spacer()
Text("\(store.profiles.count) Profile · \(openSessions.count) Sitzungen")
.font(.caption).foregroundStyle(.secondary)
}
.padding(6)
.background(.bar)
}
.toolbar {
ToolbarItem(placement: .navigation) {
Button(action: { if let p = selectedProfile { connect(p) } }) {
Label("Verbinden", systemImage: "play.fill")
}.disabled(selection == nil)
}
ToolbarItem {
Button(action: quickConnect) { Label("Quick-Connect", systemImage: "bolt") }
}
}
}
.sheet(item: $editingProfile) { profile in
ProfileEditorView(profile: profile, isNew: !store.profiles.contains(where: { $0.id == profile.id })) { saved in
if store.profiles.contains(where: { $0.id == saved.id }) {
store.update(saved)
} else {
store.add(saved)
}
statusMessage = "Profil '\(saved.name)' gespeichert."
}
}
.sheet(isPresented: $showingKeys) {
SSHKeysView().environmentObject(store)
}
.sheet(isPresented: $showingAbout) {
AboutView()
}
}
var selectedProfile: ConnectionProfile? {
store.profiles.first { $0.id == selection }
?? store.filteredProfiles.first { $0.id == selection }
}
var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "rectangle.connected.to.line.below").font(.system(size: 48)).foregroundStyle(.secondary)
Text("Kein Profil geöffnet").font(.title2)
Text("Wähle links ein Profil und klicke Verbinden (Doppelklick geht auch).").foregroundStyle(.secondary)
Button("Neues Profil anlegen", action: addProfile)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
func addProfile() {
editingProfile = ConnectionProfile()
}
func edit(_ p: ConnectionProfile) {
editingProfile = p
}
func connect(_ p: ConnectionProfile) {
var updated = p
updated.lastConnected = Date()
store.update(updated)
if !openSessions.contains(where: { $0.id == p.id }) {
openSessions.append(updated)
}
activeSessionID = p.id
switch p.proto {
case .ssh, .sftp:
// In-App-Terminal verbindet automatisch (onAppear) — kein externes Fenster nötig
statusMessage = "SSH-Terminal zu \(p.username)@\(p.host) …"
case .rdp:
RDPService.connect(profile: p)
statusMessage = "RDP zu \(p.host):\(p.port) gestartet."
case .vnc:
RDPService.connectVNC(profile: p)
statusMessage = "VNC zu \(p.host):\(p.port) geöffnet."
}
}
func closeSession(_ p: ConnectionProfile) {
terminals.close(p)
openSessions.removeAll { $0.id == p.id }
if activeSessionID == p.id { activeSessionID = openSessions.last?.id }
statusMessage = "Sitzung '\(p.name)' geschlossen."
}
func quickConnect() {
// Simple Quick-Connect: nimmt Auswahl oder leeres SSH-Profil
if let p = selectedProfile { connect(p) }
else { editingProfile = ConnectionProfile(name: "Quick-Connect") }
}
func color(for tag: String) -> Color {
switch tag {
case "red": return .red
case "green": return .green
case "orange": return .orange
case "purple": return .purple
default: return .blue
}
}
}
struct SearchField: View {
@Binding var text: String
var body: some View {
HStack {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
TextField("Suchen …", text: $text)
.textFieldStyle(.plain)
if !text.isEmpty {
Button(action: { text = "" }) { Image(systemName: "xmark.circle.fill") }
.buttonStyle(.plain).foregroundStyle(.secondary)
}
}
.padding(6)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
}
}
extension View {
func onDoubleClick(perform action: @escaping () -> Void) -> some View {
background(DoubleClickHandler(action: action))
}
}
struct DoubleClickHandler: NSViewRepresentable {
var action: () -> Void
func makeNSView(context: Context) -> NSClickView {
let v = NSClickView()
v.onDoubleClick = action
return v
}
func updateNSView(_ nsView: NSClickView, context: Context) {
nsView.onDoubleClick = action
}
}
final class NSClickView: NSView {
var onDoubleClick: (() -> Void)?
override func mouseDown(with event: NSEvent) {
if event.clickCount == 2 { onDoubleClick?() }
super.mouseDown(with: event)
}
}