feat: T-2.6 SessionRegistry + SessionSwitcher UI
This commit is contained in:
parent
fb56c11a29
commit
e29461b675
|
|
@ -0,0 +1,120 @@
|
||||||
|
// Sources/Core/Sessions/SessionRegistry.swift
|
||||||
|
// T-2.6: Session listing, creation, and deletion via the sidecar REST API.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - SessionInfo
|
||||||
|
|
||||||
|
public struct SessionInfo: Identifiable, Hashable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let name: String
|
||||||
|
public let state: String // "running", "idle", etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SessionRegistry
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class SessionRegistry: ObservableObject {
|
||||||
|
@Published public var sessions: [SessionInfo] = []
|
||||||
|
@Published public var isLoading = false
|
||||||
|
@Published public var error: String? = nil
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
// MARK: - Private helpers
|
||||||
|
|
||||||
|
private func baseURL(credential: SidecarCredential) -> URL {
|
||||||
|
URL(string: "http://\(credential.host):\(credential.port)")!
|
||||||
|
}
|
||||||
|
|
||||||
|
private func authorizedRequest(url: URL, credential: SidecarCredential) -> URLRequest {
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.setValue("Bearer \(credential.bearerToken)", forHTTPHeaderField: "Authorization")
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - GET /sessions
|
||||||
|
|
||||||
|
/// Fetches the current session list from the sidecar and updates `sessions`.
|
||||||
|
public func refresh(credential: SidecarCredential) async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
let url = baseURL(credential: credential).appendingPathComponent("sessions")
|
||||||
|
let req = authorizedRequest(url: url, credential: credential)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (data, _) = try await URLSession.shared.data(for: req)
|
||||||
|
let items = try JSONDecoder().decode([SessionItem].self, from: data)
|
||||||
|
sessions = items.map { SessionInfo(id: $0.id, name: $0.name, state: $0.state) }
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - POST /sessions
|
||||||
|
|
||||||
|
/// Creates a new session with the given name and returns the created `SessionInfo`.
|
||||||
|
@discardableResult
|
||||||
|
public func spawnSession(name: String, credential: SidecarCredential) async throws -> SessionInfo {
|
||||||
|
let url = baseURL(credential: credential).appendingPathComponent("sessions")
|
||||||
|
var req = authorizedRequest(url: url, credential: credential)
|
||||||
|
req.httpMethod = "POST"
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
req.httpBody = try JSONEncoder().encode(["name": name])
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: req)
|
||||||
|
|
||||||
|
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
|
||||||
|
throw SessionRegistryError.unexpectedStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
let item = try JSONDecoder().decode(SessionItem.self, from: data)
|
||||||
|
let info = SessionInfo(id: item.id, name: item.name, state: item.state)
|
||||||
|
|
||||||
|
// Append optimistically; a follow-up refresh will normalise order.
|
||||||
|
sessions.append(info)
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - DELETE /sessions/<id>
|
||||||
|
|
||||||
|
/// Deletes the session with the given id.
|
||||||
|
public func deleteSession(id: String, credential: SidecarCredential) async throws {
|
||||||
|
let url = baseURL(credential: credential)
|
||||||
|
.appendingPathComponent("sessions")
|
||||||
|
.appendingPathComponent(id)
|
||||||
|
var req = authorizedRequest(url: url, credential: credential)
|
||||||
|
req.httpMethod = "DELETE"
|
||||||
|
|
||||||
|
let (_, response) = try await URLSession.shared.data(for: req)
|
||||||
|
|
||||||
|
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
|
||||||
|
throw SessionRegistryError.unexpectedStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions.removeAll { $0.id == id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Codable helper
|
||||||
|
|
||||||
|
private struct SessionItem: Decodable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let state: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
public enum SessionRegistryError: LocalizedError {
|
||||||
|
case unexpectedStatus
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unexpectedStatus:
|
||||||
|
return "Unexpected HTTP status from sidecar."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
// Sources/UI/Sessions/SessionRow.swift
|
||||||
|
// T-2.6: Row view for a single session in the SessionSwitcher list.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SessionRow: View {
|
||||||
|
let session: SessionInfo
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(session.name)
|
||||||
|
.font(.body)
|
||||||
|
Spacer()
|
||||||
|
stateBadge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var stateBadge: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Circle()
|
||||||
|
.fill(badgeColor)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
Text(session.state)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(badgeColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var badgeColor: Color {
|
||||||
|
switch session.state.lowercased() {
|
||||||
|
case "running", "active":
|
||||||
|
return .green
|
||||||
|
case "idle":
|
||||||
|
return .yellow
|
||||||
|
default:
|
||||||
|
return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
// Sources/UI/Sessions/SessionSwitcher.swift
|
||||||
|
// T-2.6: Sheet UI for listing, selecting, and creating sessions.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SessionSwitcher: View {
|
||||||
|
@ObservedObject var registry: SessionRegistry
|
||||||
|
let credential: SidecarCredential
|
||||||
|
let onSelect: (SessionInfo) -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var showNewSessionAlert = false
|
||||||
|
@State private var newSessionName = ""
|
||||||
|
@State private var spawnError: String? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if registry.isLoading {
|
||||||
|
ProgressView("Loading sessions…")
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
List(registry.sessions) { session in
|
||||||
|
Button {
|
||||||
|
onSelect(session)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
SessionRow(session: session)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if registry.sessions.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Sessions",
|
||||||
|
systemImage: "terminal",
|
||||||
|
description: Text("Tap + to create a session.")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Sessions")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button {
|
||||||
|
newSessionName = ""
|
||||||
|
spawnError = nil
|
||||||
|
showNewSessionAlert = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("New Session", isPresented: $showNewSessionAlert) {
|
||||||
|
TextField("Session name", text: $newSessionName)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
Button("Create") {
|
||||||
|
let name = newSessionName.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !name.isEmpty else { return }
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await registry.spawnSession(name: name, credential: credential)
|
||||||
|
await registry.refresh(credential: credential)
|
||||||
|
} catch {
|
||||||
|
spawnError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Enter a name for the new session.")
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .bottom) {
|
||||||
|
if let err = registry.error ?? spawnError {
|
||||||
|
Text(err)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.padding(8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color(uiColor: .secondarySystemBackground))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await registry.refresh(credential: credential) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,9 @@ struct MainTerminalView: View {
|
||||||
@State private var statusText = "Connecting…"
|
@State private var statusText = "Connecting…"
|
||||||
@State private var cancellables = Set<AnyCancellable>()
|
@State private var cancellables = Set<AnyCancellable>()
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
|
@StateObject private var registry = SessionRegistry()
|
||||||
|
@State private var showSwitcher = false
|
||||||
|
@State private var activeSessionId: String? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|
@ -23,6 +26,10 @@ struct MainTerminalView: View {
|
||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Button { showSwitcher = true } label: {
|
||||||
|
Image(systemName: "list.bullet")
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
Button("Unpair") {
|
Button("Unpair") {
|
||||||
appState.unpair()
|
appState.unpair()
|
||||||
}
|
}
|
||||||
|
|
@ -51,6 +58,17 @@ struct MainTerminalView: View {
|
||||||
.background(Color(uiColor: .secondarySystemBackground))
|
.background(Color(uiColor: .secondarySystemBackground))
|
||||||
}
|
}
|
||||||
.task { await bootstrap() }
|
.task { await bootstrap() }
|
||||||
|
.task { await registry.refresh(credential: credential) }
|
||||||
|
.sheet(isPresented: $showSwitcher) {
|
||||||
|
SessionSwitcher(
|
||||||
|
registry: registry,
|
||||||
|
credential: credential,
|
||||||
|
onSelect: { session in
|
||||||
|
activeSessionId = session.id
|
||||||
|
showSwitcher = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Bootstrap
|
// MARK: - Bootstrap
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue