138 lines
4.6 KiB
Swift
138 lines
4.6 KiB
Swift
import SwiftUI
|
|
import Combine
|
|
|
|
/// Top-level view: NavigationSplitView with a CxLLM sidebar.
|
|
/// The original WebView surface lives in `WebContentView` and is the
|
|
/// default selection so existing behavior is preserved.
|
|
struct ContentView: View {
|
|
let webAction: PassthroughSubject<WebView.WebAction, Never>
|
|
|
|
@State private var selection: SidebarItem? = .web
|
|
|
|
var body: some View {
|
|
NavigationSplitView {
|
|
List(SidebarItem.allCases, selection: $selection) { item in
|
|
Label(item.rawValue, systemImage: item.systemImage)
|
|
.tag(item)
|
|
}
|
|
.navigationTitle("CxLLM")
|
|
.frame(minWidth: 180)
|
|
} detail: {
|
|
switch selection ?? .web {
|
|
case .web: WebContentView(webAction: webAction)
|
|
case let other: CxLLMSectionView(item: other)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct WebContentView: View {
|
|
@EnvironmentObject var settings: AppSettings
|
|
@StateObject private var health = HealthMonitor()
|
|
|
|
@State private var canGoBack = false
|
|
@State private var canGoForward = false
|
|
@State private var isLoading = false
|
|
@State private var pageTitle = ""
|
|
|
|
let webAction: PassthroughSubject<WebView.WebAction, Never>
|
|
|
|
private var url: URL { URL(string: settings.backendURL) ?? URL(string: "about:blank")! }
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
toolbar
|
|
Divider()
|
|
WebView(
|
|
url: url,
|
|
canGoBack: $canGoBack,
|
|
canGoForward: $canGoForward,
|
|
isLoading: $isLoading,
|
|
pageTitle: $pageTitle,
|
|
developerExtras: settings.developerExtras,
|
|
action: webAction
|
|
)
|
|
Divider()
|
|
statusBar
|
|
}
|
|
.frame(minWidth: 800, minHeight: 600)
|
|
.onAppear { health.start(url: settings.backendURL) }
|
|
.onChange(of: settings.backendURL) { _ in
|
|
health.start(url: settings.backendURL)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private var toolbar: some View {
|
|
HStack(spacing: 8) {
|
|
Button { webAction.send(.back) } label: { Image(systemName: "chevron.left") }
|
|
.disabled(!canGoBack)
|
|
Button { webAction.send(.forward) } label: { Image(systemName: "chevron.right") }
|
|
.disabled(!canGoForward)
|
|
Button { webAction.send(.reload) } label: { Image(systemName: "arrow.clockwise") }
|
|
Button { webAction.send(.home) } label: { Image(systemName: "house") }
|
|
|
|
Divider().frame(height: 16)
|
|
|
|
statusPill
|
|
|
|
Text(settings.backendURL)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
.foregroundStyle(.secondary)
|
|
.font(.system(.caption, design: .monospaced))
|
|
|
|
Spacer()
|
|
|
|
if isLoading { ProgressView().scaleEffect(0.5).frame(width: 16, height: 16) }
|
|
|
|
Button { webAction.send(.copyURL) } label: { Image(systemName: "doc.on.doc") }
|
|
.help("Copy URL")
|
|
Button { webAction.send(.openInBrowser) } label: { Image(systemName: "safari") }
|
|
.help("Open in default browser")
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.buttonStyle(.borderless)
|
|
}
|
|
|
|
private var statusPill: some View {
|
|
let (label, color): (String, Color) = {
|
|
switch health.status {
|
|
case .unknown: return ("…", .gray)
|
|
case .up: return ("healthy", .green)
|
|
case .down: return ("down", .red)
|
|
}
|
|
}()
|
|
return HStack(spacing: 4) {
|
|
Circle().fill(color).frame(width: 8, height: 8)
|
|
Text(label).font(.caption)
|
|
}
|
|
.padding(.horizontal, 8).padding(.vertical, 2)
|
|
.background(.quaternary.opacity(0.6), in: Capsule())
|
|
.help(health.lastError ?? "Backend reachable")
|
|
}
|
|
|
|
private var statusBar: some View {
|
|
HStack(spacing: 12) {
|
|
Text(pageTitle.isEmpty ? "CxWebApp" : pageTitle)
|
|
.font(.caption)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
if let t = health.lastChecked {
|
|
Text("checked " + Self.relative(t))
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
private static let relativeFmt: RelativeDateTimeFormatter = {
|
|
let f = RelativeDateTimeFormatter(); f.unitsStyle = .short; return f
|
|
}()
|
|
private static func relative(_ d: Date) -> String { relativeFmt.localizedString(for: d, relativeTo: Date()) }
|
|
}
|