diff --git a/Sources/Core/Sessions/SessionRegistry.swift b/Sources/Core/Sessions/SessionRegistry.swift new file mode 100644 index 0000000..dba9c1b --- /dev/null +++ b/Sources/Core/Sessions/SessionRegistry.swift @@ -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/ + + /// 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." + } + } +} diff --git a/Sources/UI/Sessions/SessionRow.swift b/Sources/UI/Sessions/SessionRow.swift new file mode 100644 index 0000000..af74613 --- /dev/null +++ b/Sources/UI/Sessions/SessionRow.swift @@ -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 + } + } +} diff --git a/Sources/UI/Sessions/SessionSwitcher.swift b/Sources/UI/Sessions/SessionSwitcher.swift new file mode 100644 index 0000000..f71d5db --- /dev/null +++ b/Sources/UI/Sessions/SessionSwitcher.swift @@ -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) } + } +} diff --git a/Sources/UI/Terminal/MainTerminalView.swift b/Sources/UI/Terminal/MainTerminalView.swift index 235418c..4f49f24 100644 --- a/Sources/UI/Terminal/MainTerminalView.swift +++ b/Sources/UI/Terminal/MainTerminalView.swift @@ -14,6 +14,9 @@ struct MainTerminalView: View { @State private var statusText = "Connecting…" @State private var cancellables = Set() @EnvironmentObject var appState: AppState + @StateObject private var registry = SessionRegistry() + @State private var showSwitcher = false + @State private var activeSessionId: String? = nil var body: some View { VStack(spacing: 0) { @@ -23,6 +26,10 @@ struct MainTerminalView: View { .font(.caption.monospaced()) .foregroundStyle(.secondary) Spacer() + Button { showSwitcher = true } label: { + Image(systemName: "list.bullet") + } + .font(.caption) Button("Unpair") { appState.unpair() } @@ -51,6 +58,17 @@ struct MainTerminalView: View { .background(Color(uiColor: .secondarySystemBackground)) } .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