mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Add session details to menui
This commit is contained in:
parent
d6d3a8f570
commit
c6a9e84bec
3 changed files with 249 additions and 11 deletions
|
|
@ -38,6 +38,12 @@ struct MenuBarView: View {
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
// Session list with clickable items
|
||||||
|
if !sessionMonitor.sessions.isEmpty {
|
||||||
|
SessionListView(sessions: sessionMonitor.sessions)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
|
|
||||||
|
|
@ -222,11 +228,12 @@ struct SessionCountView: View {
|
||||||
/// Lists active SSH sessions with truncation for large lists
|
/// Lists active SSH sessions with truncation for large lists
|
||||||
struct SessionListView: View {
|
struct SessionListView: View {
|
||||||
let sessions: [String: SessionMonitor.SessionInfo]
|
let sessions: [String: SessionMonitor.SessionInfo]
|
||||||
|
@Environment(\.openWindow) private var openWindow
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
ForEach(Array(activeSessions.prefix(5)), id: \.key) { session in
|
ForEach(Array(activeSessions.prefix(5)), id: \.key) { session in
|
||||||
SessionRowView(session: session)
|
SessionRowView(session: session, openWindow: openWindow)
|
||||||
}
|
}
|
||||||
|
|
||||||
if activeSessions.count > 5 {
|
if activeSessions.count > 5 {
|
||||||
|
|
@ -251,17 +258,39 @@ struct SessionListView: View {
|
||||||
/// Individual row displaying session information
|
/// Individual row displaying session information
|
||||||
struct SessionRowView: View {
|
struct SessionRowView: View {
|
||||||
let session: (key: String, value: SessionMonitor.SessionInfo)
|
let session: (key: String, value: SessionMonitor.SessionInfo)
|
||||||
|
let openWindow: OpenWindowAction
|
||||||
|
@State private var isHovered = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
Button(action: {
|
||||||
|
// Open the session detail window
|
||||||
|
openWindow(id: "session-detail", value: session.key)
|
||||||
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(" • \(sessionName)")
|
Text(" • \(sessionName)")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.primary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.middle)
|
.truncationMode(.middle)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
Text("PID: \(session.value.pid)")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(isHovered ? Color.accentColor.opacity(0.1) : Color.clear)
|
||||||
|
)
|
||||||
|
.onHover { hovering in
|
||||||
|
isHovered = hovering
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var sessionName: String {
|
private var sessionName: String {
|
||||||
|
|
@ -270,8 +299,8 @@ struct SessionRowView: View {
|
||||||
let name = (workingDir as NSString).lastPathComponent
|
let name = (workingDir as NSString).lastPathComponent
|
||||||
|
|
||||||
// Truncate long session names
|
// Truncate long session names
|
||||||
if name.count > 35 {
|
if name.count > 30 {
|
||||||
let prefix = String(name.prefix(20))
|
let prefix = String(name.prefix(15))
|
||||||
let suffix = String(name.suffix(10))
|
let suffix = String(name.suffix(10))
|
||||||
return "\(prefix)...\(suffix)"
|
return "\(prefix)...\(suffix)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
196
mac/VibeTunnel/Presentation/Views/SessionDetailView.swift
Normal file
196
mac/VibeTunnel/Presentation/Views/SessionDetailView.swift
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// View displaying detailed information about a specific terminal session
|
||||||
|
struct SessionDetailView: View {
|
||||||
|
let session: SessionMonitor.SessionInfo
|
||||||
|
@State private var windowTitle = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
// Session Header
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Session Details")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Label("PID: \(session.pid)", systemImage: "number.circle.fill")
|
||||||
|
.font(.title3)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
StatusBadge(isRunning: session.isRunning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
|
||||||
|
// Session Information
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
DetailRow(label: "Session ID", value: session.id)
|
||||||
|
DetailRow(label: "Command", value: session.command)
|
||||||
|
DetailRow(label: "Working Directory", value: session.workingDir)
|
||||||
|
DetailRow(label: "Status", value: session.status.capitalized)
|
||||||
|
DetailRow(label: "Started At", value: formatDate(session.startedAt))
|
||||||
|
DetailRow(label: "Last Modified", value: formatDate(session.lastModified))
|
||||||
|
|
||||||
|
if let exitCode = session.exitCode {
|
||||||
|
DetailRow(label: "Exit Code", value: "\(exitCode)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Action Buttons
|
||||||
|
HStack {
|
||||||
|
Button("Open in Terminal") {
|
||||||
|
openInTerminal()
|
||||||
|
}
|
||||||
|
.controlSize(.large)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if session.isRunning {
|
||||||
|
Button("Terminate Session") {
|
||||||
|
terminateSession()
|
||||||
|
}
|
||||||
|
.controlSize(.large)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(30)
|
||||||
|
.frame(minWidth: 600, minHeight: 450)
|
||||||
|
.onAppear {
|
||||||
|
updateWindowTitle()
|
||||||
|
}
|
||||||
|
.background(WindowAccessor(title: $windowTitle))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateWindowTitle() {
|
||||||
|
let dir = URL(fileURLWithPath: session.workingDir).lastPathComponent
|
||||||
|
windowTitle = "\(dir) — VibeTunnel (PID: \(session.pid))"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatDate(_ dateString: String) -> String {
|
||||||
|
// Parse the date string and format it nicely
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
||||||
|
|
||||||
|
if let date = formatter.date(from: String(dateString.prefix(19))) {
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
formatter.timeStyle = .medium
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dateString
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openInTerminal() {
|
||||||
|
// TODO: Implement opening session in terminal
|
||||||
|
print("Open session \(session.id) in terminal")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func terminateSession() {
|
||||||
|
// TODO: Implement session termination
|
||||||
|
print("Terminate session \(session.id)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Supporting Views
|
||||||
|
|
||||||
|
struct DetailRow: View {
|
||||||
|
let label: String
|
||||||
|
let value: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
Text(label + ":")
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(width: 140, alignment: .trailing)
|
||||||
|
|
||||||
|
Text(value)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StatusBadge: View {
|
||||||
|
let isRunning: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle()
|
||||||
|
.fill(isRunning ? Color.green : Color.red)
|
||||||
|
.frame(width: 10, height: 10)
|
||||||
|
|
||||||
|
Text(isRunning ? "Running" : "Stopped")
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(isRunning ? .green : .red)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(isRunning ? Color.green.opacity(0.1) : Color.red.opacity(0.1))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Window Title Accessor
|
||||||
|
|
||||||
|
struct WindowAccessor: NSViewRepresentable {
|
||||||
|
@Binding var title: String
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> NSView {
|
||||||
|
let view = NSView()
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let window = view.window {
|
||||||
|
window.title = self.title
|
||||||
|
|
||||||
|
// Watch for title changes
|
||||||
|
Task { @MainActor in
|
||||||
|
context.coordinator.startObserving(window: window, binding: self.$title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: NSView, context: Context) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let window = nsView.window {
|
||||||
|
window.title = self.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator()
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject {
|
||||||
|
private var observation: NSKeyValueObservation?
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func startObserving(window: NSWindow, binding: Binding<String>) {
|
||||||
|
// Update the binding when window title changes
|
||||||
|
observation = window.observe(\.title, options: [.new]) { window, change in
|
||||||
|
if let newTitle = change.newValue {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
binding.wrappedValue = newTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set initial title
|
||||||
|
window.title = binding.wrappedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
observation?.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,19 @@ struct VibeTunnelApp: App {
|
||||||
.defaultSize(width: 580, height: 480)
|
.defaultSize(width: 580, height: 480)
|
||||||
.windowStyle(.hiddenTitleBar)
|
.windowStyle(.hiddenTitleBar)
|
||||||
|
|
||||||
|
// Session Detail Window
|
||||||
|
WindowGroup("Session Details", id: "session-detail", for: String.self) { $sessionId in
|
||||||
|
if let sessionId = sessionId,
|
||||||
|
let session = SessionMonitor.shared.sessions[sessionId] {
|
||||||
|
SessionDetailView(session: session)
|
||||||
|
.withVibeTunnelServices()
|
||||||
|
} else {
|
||||||
|
Text("Session not found")
|
||||||
|
.frame(width: 400, height: 300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.windowResizability(.contentSize)
|
||||||
|
|
||||||
Settings {
|
Settings {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
.withVibeTunnelServices()
|
.withVibeTunnelServices()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue