219 lines
8.2 KiB
Swift
219 lines
8.2 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
|
|
struct ContentView: View {
|
|
@EnvironmentObject var store: ProfileStore
|
|
@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 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")
|
|
}
|
|
}
|
|
} detail: {
|
|
VStack(spacing: 0) {
|
|
// Verbindungs-Tabs
|
|
if openSessions.isEmpty {
|
|
emptyState
|
|
} else {
|
|
TabView(selection: $activeSessionID) {
|
|
ForEach(openSessions) { session in
|
|
SessionView(profile: session, statusMessage: $statusMessage)
|
|
.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) { 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)
|
|
}
|
|
}
|
|
|
|
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
|
|
let key = store.key(for: p)
|
|
switch p.proto {
|
|
case .ssh, .sftp:
|
|
SSHService.openInTerminal(profile: p, key: key)
|
|
statusMessage = "SSH: \(SSHService.sshCommandString(for: p, key: key))"
|
|
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 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)
|
|
}
|
|
}
|