Terminal-Bugfix: In-App-PTY-Terminal im Dracula-Theme, .command-Fallback, ANSI-Parser-Fix, Doku
This commit is contained in:
@@ -3,6 +3,7 @@ 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?
|
||||
@@ -68,7 +69,12 @@ struct ContentView: View {
|
||||
} else {
|
||||
TabView(selection: $activeSessionID) {
|
||||
ForEach(openSessions) { session in
|
||||
SessionView(profile: session, statusMessage: $statusMessage)
|
||||
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) }
|
||||
}
|
||||
@@ -143,11 +149,10 @@ struct ContentView: View {
|
||||
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))"
|
||||
// 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."
|
||||
@@ -157,6 +162,13 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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) }
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// In-App-Terminal im Dracula-Look: Ausgabe (NSTextView, read-only) + Eingabezeile.
|
||||
/// Zeilenumbruch ist an, Autoscroll ans Ende, Passwort-Modus via Auge-Icon.
|
||||
struct EmbeddedTerminalView: View {
|
||||
@ObservedObject var term: PTYSession
|
||||
var canConnect: Bool
|
||||
@State private var input = ""
|
||||
@State private var secureInput = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Statuszeile
|
||||
HStack(spacing: 8) {
|
||||
statusDot
|
||||
Text(statusText).font(.caption).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
if term.isRunning {
|
||||
Button("Trennen") { term.stop() }.buttonStyle(.link).font(.caption)
|
||||
} else if canConnect {
|
||||
Button("Verbinden") { term.start() }.buttonStyle(.link).font(.caption)
|
||||
}
|
||||
Button("Leeren") { term.clear() }.buttonStyle(.link).font(.caption)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Dracula.currentLine.opacity(0.35))
|
||||
|
||||
// Ausgabe im Dracula-Theme
|
||||
DraculaTextView(lines: term.lines)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
// Eingabezeile
|
||||
HStack(spacing: 6) {
|
||||
Text("❯").font(.system(.body, design: .monospaced)).foregroundStyle(Dracula.green)
|
||||
if secureInput {
|
||||
SecureField("Eingabe (versteckt, z. B. Passwort) …", text: $input, onCommit: submit)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundStyle(Dracula.foreground)
|
||||
} else {
|
||||
TextField("Eingabe …", text: $input, onCommit: submit)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundStyle(Dracula.foreground)
|
||||
}
|
||||
Button(action: { secureInput.toggle() }) {
|
||||
Image(systemName: secureInput ? "eye.slash" : "eye")
|
||||
}
|
||||
.buttonStyle(.plain).foregroundStyle(Dracula.comment)
|
||||
.help("Passwort-Modus (versteckte Eingabe)")
|
||||
Button("Senden") { submit() }
|
||||
.disabled(input.isEmpty || !term.isRunning)
|
||||
ControlButton(label: "^C", tip: "SIGINT (Strg+C)") { term.send("\u{03}") }
|
||||
ControlButton(label: "^D", tip: "EOF (Strg+D)") { term.send("\u{04}") }
|
||||
}
|
||||
.padding(8)
|
||||
.background(Dracula.background)
|
||||
}
|
||||
.background(Dracula.background)
|
||||
.cornerRadius(10)
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Dracula.currentLine, lineWidth: 1))
|
||||
.onAppear {
|
||||
if case .never = term.state, canConnect {
|
||||
term.start() // Auto-Connect beim Öffnen des Tabs (Bugfix: kein externes Terminal mehr nötig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func submit() {
|
||||
guard term.isRunning, !input.isEmpty else { return }
|
||||
term.send(input + "\n")
|
||||
input = ""
|
||||
}
|
||||
|
||||
var statusDot: some View {
|
||||
Group {
|
||||
if term.isRunning { Image(systemName: "circle.fill").foregroundStyle(Dracula.green) }
|
||||
else if case .done = term.state { Image(systemName: "circle.fill").foregroundStyle(Dracula.red) }
|
||||
else { Image(systemName: "circle.fill").foregroundStyle(Dracula.comment) }
|
||||
}.font(.caption2)
|
||||
}
|
||||
|
||||
var statusText: String {
|
||||
switch term.state {
|
||||
case .never: return canConnect ? "Bereit — verbindet beim Öffnen." : "Host/Benutzer fehlt."
|
||||
case .running: return "Verbunden (PTY, TERM=xterm-256color)."
|
||||
case .done(_, let msg): return msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ControlButton: View {
|
||||
var label: String
|
||||
var tip: String
|
||||
var action: () -> Void
|
||||
var body: some View {
|
||||
Button(label, action: action)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(Dracula.orange)
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Dracula.currentLine.opacity(0.6), in: RoundedRectangle(cornerRadius: 5))
|
||||
.help(tip)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only NSTextView mit Dracula-Farben, baut NSAttributedString aus StyledText-Zeilen.
|
||||
struct DraculaTextView: NSViewRepresentable {
|
||||
var lines: [[StyledText]]
|
||||
|
||||
func makeNSView(context: Context) -> NSScrollView {
|
||||
let scroll = NSScrollView()
|
||||
scroll.hasVerticalScroller = true
|
||||
scroll.drawsBackground = true
|
||||
scroll.backgroundColor = Dracula.nsBackground
|
||||
let tv = NSTextView()
|
||||
tv.isEditable = false
|
||||
tv.isSelectable = true
|
||||
tv.backgroundColor = Dracula.nsBackground
|
||||
tv.textColor = Dracula.nsColor(for: nil)
|
||||
tv.font = .monospacedSystemFont(ofSize: 12, weight: .regular)
|
||||
tv.textContainerInset = NSSize(width: 8, height: 8)
|
||||
tv.isAutomaticQuoteSubstitutionEnabled = false
|
||||
scroll.documentView = tv
|
||||
return scroll
|
||||
}
|
||||
|
||||
func updateNSView(_ scroll: NSScrollView, context: Context) {
|
||||
guard let tv = scroll.documentView as? NSTextView else { return }
|
||||
let attr = NSMutableAttributedString()
|
||||
let font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
|
||||
let boldFont = NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask)
|
||||
for (idx, row) in lines.enumerated() {
|
||||
if row.isEmpty {
|
||||
attr.append(NSAttributedString(string: idx < lines.count - 1 ? "\n" : ""))
|
||||
continue
|
||||
}
|
||||
for seg in row {
|
||||
let attrs: [NSAttributedString.Key: Any] = [
|
||||
.font: seg.style.bold ? boldFont : font,
|
||||
.foregroundColor: Dracula.nsColor(for: seg.style.fg),
|
||||
.backgroundColor: seg.style.bg.map { Dracula.nsColor(for: $0) } ?? Dracula.nsBackground
|
||||
]
|
||||
attr.append(NSAttributedString(string: seg.text, attributes: attrs))
|
||||
}
|
||||
if idx < lines.count - 1 { attr.append(NSAttributedString(string: "\n")) }
|
||||
}
|
||||
// Nur neu setzen wenn geändert (Performance bei viel Output)
|
||||
if tv.textStorage?.string != attr.string {
|
||||
tv.textStorage?.setAttributedString(attr)
|
||||
}
|
||||
tv.scrollToEndOfDocument(nil)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,11 @@ struct SessionView: View {
|
||||
let profile: ConnectionProfile
|
||||
@Binding var statusMessage: String
|
||||
@EnvironmentObject var store: ProfileStore
|
||||
@State private var log: String = ""
|
||||
@ObservedObject var term: PTYSession
|
||||
var onClose: () -> Void
|
||||
|
||||
var isSSH: Bool { profile.proto == .ssh || profile.proto == .sftp }
|
||||
var canConnect: Bool { !profile.host.isEmpty && !profile.username.isEmpty }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -16,8 +20,8 @@ struct SessionView: View {
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if profile.proto == .ssh || profile.proto == .sftp {
|
||||
Button("In Terminal öffnen") {
|
||||
if isSSH {
|
||||
Button("In Terminal.app öffnen") {
|
||||
SSHService.openInTerminal(profile: profile, key: store.key(for: profile))
|
||||
}
|
||||
Button("Befehl kopieren") {
|
||||
@@ -27,12 +31,20 @@ struct SessionView: View {
|
||||
} else if profile.proto == .rdp {
|
||||
Button("RDP erneut verbinden") { RDPService.connect(profile: profile) }
|
||||
}
|
||||
Button("Tab schließen") { onClose() }
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
if isSSH {
|
||||
// In-App-Terminal (Dracula) — kein externes Fenster mehr nötig
|
||||
EmbeddedTerminalView(term: term, canConnect: canConnect)
|
||||
.frame(minHeight: 380)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding()
|
||||
|
||||
GroupBox("Verbindungsdetails") {
|
||||
let key = store.key(for: profile)
|
||||
let cmd = profile.proto == .ssh || profile.proto == .sftp
|
||||
let cmd = isSSH
|
||||
? SSHService.sshCommandString(for: profile, key: key)
|
||||
: "\(profile.proto.rawValue) \(profile.host):\(profile.port)"
|
||||
Text(cmd)
|
||||
@@ -54,14 +66,15 @@ struct SessionView: View {
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
var hintText: String {
|
||||
switch profile.proto {
|
||||
case .ssh:
|
||||
return "SSH wird in Terminal.app geöffnet (System-ssh). Für ein eingebettetes Terminal als nächsten Schritt SwiftTerm + swift-nio-ssh einbinden."
|
||||
return "In-App-Terminal (Dracula-Theme) — läuft direkt in diesem Tab über ein PTY. Alternativ: In Terminal.app öffnen."
|
||||
case .sftp:
|
||||
return "SFTP nutzt denselben SSH-Zugang. Befehl kopieren und z. B. mit Cyberduck / Transmit / sftp im Terminal nutzen: sftp -P \(profile.port) \(profile.username)@\(profile.host)."
|
||||
return "SFTP-Sitzung läuft im Terminal oben. Alternativ extern nutzen: sftp -P \(profile.port) \(profile.username)@\(profile.host)."
|
||||
case .rdp:
|
||||
return RDPService.freeRDPPath != nil
|
||||
? "Verbunden über xfreerdp (\(RDPService.freeRDPPath!))."
|
||||
|
||||
Reference in New Issue
Block a user