From 45b2093505332dc3afbd98e6fb8f8e5e03a07524 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:50:27 +0000 Subject: [PATCH 1/3] Initial plan From fc7d95ab665ef15c92b12ebfb46b3b2e6c8ea9a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:14:39 +0000 Subject: [PATCH 2/3] feat: add editor, templates, visual env vars, log search/filter/download, multi-session terminal Co-authored-by: djpfs <43576725+djpfs@users.noreply.github.com> --- Sources/AppleDockerApp/App.swift | 4 + Sources/AppleDockerApp/AppState.swift | 36 +- .../Views/ComposeEditorView.swift | 431 ++++++++++++++++++ .../AppleDockerApp/Views/ComposeView.swift | 51 ++- .../Views/ContainerDetailView.swift | 103 ++++- .../Views/ContainerSettingsView.swift | 82 +++- .../Views/ContainerTerminalSession.swift | 6 +- .../Views/ContainerTerminalView.swift | 142 ++++-- 8 files changed, 783 insertions(+), 72 deletions(-) create mode 100644 Sources/AppleDockerApp/Views/ComposeEditorView.swift diff --git a/Sources/AppleDockerApp/App.swift b/Sources/AppleDockerApp/App.swift index 6b92d50..9ebf668 100644 --- a/Sources/AppleDockerApp/App.swift +++ b/Sources/AppleDockerApp/App.swift @@ -16,6 +16,7 @@ enum SidebarItem: String, CaseIterable, Identifiable { case volumes = "Volumes" case networks = "Networks" case compose = "Compose" + case editor = "Editor" case activity = "Activity Monitor" case settings = "Settings" @@ -30,6 +31,7 @@ enum SidebarItem: String, CaseIterable, Identifiable { case .volumes: "externaldrive" case .networks: "network" case .compose: "square.stack.3d.up" + case .editor: "doc.plaintext" case .activity: "waveform.path.ecg" case .settings: "gearshape" } @@ -121,6 +123,8 @@ private struct RootView: View { NetworkListView() case .compose: ComposeView() + case .editor: + ComposeEditorView() case .activity: ActivityMonitorView() case .settings: diff --git a/Sources/AppleDockerApp/AppState.swift b/Sources/AppleDockerApp/AppState.swift index fccc095..312bf8f 100644 --- a/Sources/AppleDockerApp/AppState.swift +++ b/Sources/AppleDockerApp/AppState.swift @@ -84,8 +84,8 @@ final class AppState { /// History of image builds, shown in the Builds tab (most recent first). var buildHistory: [BuildRecord] = [] /// Persistent terminal sessions, keyed by container ID. Kept here so the - /// terminal survives tab switches. - var terminalSessions: [String: ContainerTerminalSession] = [:] + /// terminal survives tab switches. Each container may have multiple sessions. + var terminalSessions: [String: [ContainerTerminalSession]] = [:] // MARK: - Build tracking @@ -357,17 +357,35 @@ final class AppState { // MARK: - Terminal sessions - /// Return the persistent terminal session for a container, creating it on - /// first use. - func terminalSession(for containerID: String) -> ContainerTerminalSession { - if let session = terminalSessions[containerID] { - return session + /// Return the list of terminal sessions for a container, creating a + /// default first session if none exist yet. + func terminalSessions(for containerID: String) -> [ContainerTerminalSession] { + if let sessions = terminalSessions[containerID], !sessions.isEmpty { + return sessions } - let session = ContainerTerminalSession(containerID: containerID) - terminalSessions[containerID] = session + let session = ContainerTerminalSession(containerID: containerID, sessionLabel: "Session 1") + terminalSessions[containerID] = [session] + return [session] + } + + /// Add a new terminal session for a container and return it. + @discardableResult + func addTerminalSession(for containerID: String) -> ContainerTerminalSession { + let existing = terminalSessions[containerID] ?? [] + let label = "Session \(existing.count + 1)" + let session = ContainerTerminalSession(containerID: containerID, sessionLabel: label) + terminalSessions[containerID] = existing + [session] return session } + /// Remove a terminal session for a container. + func removeTerminalSession(_ session: ContainerTerminalSession) { + guard var sessions = terminalSessions[session.containerID] else { return } + sessions.removeAll { $0 === session } + session.disconnect() + terminalSessions[session.containerID] = sessions + } + // MARK: - Container actions func startContainer(_ id: String) async { diff --git a/Sources/AppleDockerApp/Views/ComposeEditorView.swift b/Sources/AppleDockerApp/Views/ComposeEditorView.swift new file mode 100644 index 0000000..c7a54cd --- /dev/null +++ b/Sources/AppleDockerApp/Views/ComposeEditorView.swift @@ -0,0 +1,431 @@ +//===----------------------------------------------------------------------===// +// ComposeEditorView — integrated text editor for docker-compose.yml and +// Dockerfile files, with project templates for quick scaffolding. +//===----------------------------------------------------------------------===// + +import SwiftUI +import UniformTypeIdentifiers + +// MARK: - Project templates + +/// A pre-built project template the user can load as a starting point. +struct ProjectTemplate: Identifiable { + let id = UUID() + let name: String + let description: String + let systemImage: String + let compose: String + let dockerfile: String? +} + +extension ProjectTemplate { + static let all: [ProjectTemplate] = [ + .nodeApp, + .pythonFlask, + .goApp, + .postgresOnly, + .webNginx, + ] + + // MARK: Node.js + Postgres + + static let nodeApp = ProjectTemplate( + name: "Node.js + PostgreSQL", + description: "Express API backed by a PostgreSQL database.", + systemImage: "server.rack", + compose: """ +version: "3.9" +services: + app: + build: . + ports: + - "3000:3000" + environment: + - DATABASE_URL=postgres://user:password@db:5432/appdb + depends_on: + - db + db: + image: postgres:16 + environment: + - POSTGRES_USER=user + - POSTGRES_PASSWORD=password + - POSTGRES_DB=appdb + volumes: + - pgdata:/var/lib/postgresql/data +volumes: + pgdata: +""", + dockerfile: """ +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev +COPY . . +EXPOSE 3000 +CMD ["node", "index.js"] +""" + ) + + // MARK: Python / Flask + Redis + + static let pythonFlask = ProjectTemplate( + name: "Python Flask + Redis", + description: "Flask web app with a Redis cache.", + systemImage: "flame", + compose: """ +version: "3.9" +services: + web: + build: . + ports: + - "5000:5000" + environment: + - FLASK_ENV=development + - REDIS_URL=redis://cache:6379 + depends_on: + - cache + cache: + image: redis:7-alpine +volumes: {} +""", + dockerfile: """ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 5000 +CMD ["flask", "run", "--host=0.0.0.0"] +""" + ) + + // MARK: Go service + + static let goApp = ProjectTemplate( + name: "Go Service", + description: "Minimal Go HTTP service with multi-stage build.", + systemImage: "bolt", + compose: """ +version: "3.9" +services: + api: + build: . + ports: + - "8080:8080" + environment: + - PORT=8080 +""", + dockerfile: """ +# Build stage +FROM golang:1.22-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /app ./cmd/server + +# Runtime stage +FROM alpine:3.20 +COPY --from=builder /app /app +EXPOSE 8080 +ENTRYPOINT ["/app"] +""" + ) + + // MARK: PostgreSQL only + + static let postgresOnly = ProjectTemplate( + name: "PostgreSQL", + description: "Stand-alone PostgreSQL instance for local development.", + systemImage: "cylinder", + compose: """ +version: "3.9" +services: + db: + image: postgres:16 + ports: + - "5432:5432" + environment: + - POSTGRES_USER=dev + - POSTGRES_PASSWORD=devpassword + - POSTGRES_DB=devdb + volumes: + - pgdata:/var/lib/postgresql/data +volumes: + pgdata: +""", + dockerfile: nil + ) + + // MARK: Nginx static site + + static let webNginx = ProjectTemplate( + name: "Nginx Static Site", + description: "Nginx serving a static site from ./html.", + systemImage: "globe", + compose: """ +version: "3.9" +services: + web: + image: nginx:alpine + ports: + - "80:80" + volumes: + - ./html:/usr/share/nginx/html:ro +""", + dockerfile: nil + ) +} + +// MARK: - Editor view + +/// Full-screen editor for a docker-compose.yml or Dockerfile, with project +/// template picker and file save/open capabilities. +struct ComposeEditorView: View { + /// The file currently being edited ("compose" or "dockerfile"). + enum FileKind: String, CaseIterable, Identifiable { + case compose = "docker-compose.yml" + case dockerfile = "Dockerfile" + var id: String { rawValue } + } + + @State private var selectedKind: FileKind = .compose + @State private var composeText: String = "" + @State private var dockerfileText: String = "" + @State private var showTemplatePicker = false + @State private var showSavePanel = false + @State private var showOpenPanel = false + @State private var saveMessage: String? + + var body: some View { + VStack(spacing: 0) { + toolbar + Divider() + editorArea + } + .navigationTitle("Editor") + .sheet(isPresented: $showTemplatePicker) { + TemplatePicker(composeText: $composeText, dockerfileText: $dockerfileText) + } + } + + // MARK: Toolbar + + private var toolbar: some View { + HStack(spacing: 8) { + Picker("File", selection: $selectedKind) { + ForEach(FileKind.allCases) { kind in + Text(kind.rawValue).tag(kind) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 280) + + Spacer() + + if let msg = saveMessage { + Text(msg) + .font(.caption) + .foregroundStyle(.secondary) + .transition(.opacity) + } + + Button { + showTemplatePicker = true + } label: { + Label("Templates", systemImage: "square.on.square") + } + .buttonStyle(.bordered) + .help("Choose a project template") + + Button { + openFile() + } label: { + Label("Open…", systemImage: "folder") + } + .buttonStyle(.bordered) + .help("Open a file to edit") + + Button { + saveFile() + } label: { + Label("Save…", systemImage: "square.and.arrow.down") + } + .buttonStyle(.borderedProminent) + .help("Save the current file") + } + .padding(12) + } + + // MARK: Editor area + + @ViewBuilder + private var editorArea: some View { + let text = selectedKind == .compose ? $composeText : $dockerfileText + TextEditor(text: text) + .font(.system(.body, design: .monospaced)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .textBackgroundColor)) + .overlay(alignment: .topLeading) { + if currentText.isEmpty { + Text(selectedKind == .compose + ? "Paste or type your docker-compose.yml here…" + : "Paste or type your Dockerfile here…") + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + .padding(8) + .allowsHitTesting(false) + } + } + } + + private var currentText: String { + selectedKind == .compose ? composeText : dockerfileText + } + + // MARK: File I/O + + private func openFile() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.allowedContentTypes = [.yaml, .plainText, .item] + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + let content = try String(contentsOf: url, encoding: .utf8) + let name = url.lastPathComponent.lowercased() + if name.contains("dockerfile") { + dockerfileText = content + selectedKind = .dockerfile + } else { + composeText = content + selectedKind = .compose + } + } catch { + saveMessage = "Error: \(error.localizedDescription)" + } + } + + private func saveFile() { + let panel = NSSavePanel() + panel.nameFieldStringValue = selectedKind.rawValue + panel.allowedContentTypes = [.yaml, .plainText, .item] + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + let text = selectedKind == .compose ? composeText : dockerfileText + try text.write(to: url, atomically: true, encoding: .utf8) + saveMessage = "Saved to \(url.lastPathComponent)" + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + saveMessage = nil + } + } catch { + saveMessage = "Error: \(error.localizedDescription)" + } + } +} + +// MARK: - Template picker sheet + +private struct TemplatePicker: View { + @Environment(\.dismiss) private var dismiss + @Binding var composeText: String + @Binding var dockerfileText: String + @State private var selected: ProjectTemplate? + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Project Templates") + .font(.title2.bold()) + Spacer() + Button("Cancel") { dismiss() } + .buttonStyle(.bordered) + } + .padding(16) + Divider() + + HStack(spacing: 0) { + // Template list + List(ProjectTemplate.all, selection: $selected) { template in + templateRow(template) + .tag(template as ProjectTemplate?) + } + .listStyle(.sidebar) + .frame(minWidth: 200, maxWidth: 240) + + Divider() + + // Preview + if let tpl = selected { + templatePreview(tpl) + } else { + Text("Select a template to preview") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + Divider() + HStack { + Spacer() + Button("Use Template") { + if let tpl = selected { + composeText = tpl.compose + dockerfileText = tpl.dockerfile ?? "" + } + dismiss() + } + .buttonStyle(.borderedProminent) + .disabled(selected == nil) + } + .padding(12) + } + .frame(minWidth: 620, minHeight: 440) + } + + private func templateRow(_ template: ProjectTemplate) -> some View { + HStack(spacing: 10) { + Image(systemName: template.systemImage) + .frame(width: 20) + .foregroundStyle(.blue) + VStack(alignment: .leading, spacing: 2) { + Text(template.name).font(.body.weight(.medium)) + Text(template.description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .padding(.vertical, 4) + } + + private func templatePreview(_ template: ProjectTemplate) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("docker-compose.yml") + .font(.caption.bold()) + .foregroundStyle(.secondary) + Text(template.compose) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .padding(10) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + + if let df = template.dockerfile { + Text("Dockerfile") + .font(.caption.bold()) + .foregroundStyle(.secondary) + Text(df) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .padding(10) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + .padding(16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Sources/AppleDockerApp/Views/ComposeView.swift b/Sources/AppleDockerApp/Views/ComposeView.swift index 579e943..04a8198 100644 --- a/Sources/AppleDockerApp/Views/ComposeView.swift +++ b/Sources/AppleDockerApp/Views/ComposeView.swift @@ -18,6 +18,7 @@ struct ComposeView: View { @AppStorage("logTail") private var logTail = 100 @State private var isBusy = false @State private var showFilePicker = false + @State private var logSearchText = "" private let orchestrator = ComposeOrchestrator() @@ -196,6 +197,14 @@ struct ComposeView: View { .buttonStyle(.borderless) .disabled(state.composeLogLines.isEmpty) .help("Copy logs to the clipboard") + Button { + downloadLogs() + } label: { + Label("Download", systemImage: "square.and.arrow.down") + } + .buttonStyle(.borderless) + .disabled(state.composeLogLines.isEmpty) + .help("Save logs to a file") Button { state.clearComposeLog() } label: { @@ -215,8 +224,31 @@ struct ComposeView: View { .padding(.horizontal, 12) .padding(.vertical, 6) + // Search bar + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Filter logs…", text: $logSearchText) + .textFieldStyle(.roundedBorder) + .controlSize(.small) + if !logSearchText.isEmpty { + Button { + logSearchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + } + Text("\(filteredComposeLogLines.count) / \(state.composeLogLines.count)") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.bottom, 4) + ScrollView { - Text(state.composeLogLines.joined(separator: "\n")) + Text(filteredComposeLogLines.joined(separator: "\n")) .font(.system(.caption, design: .monospaced)) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) @@ -227,6 +259,13 @@ struct ComposeView: View { } } + private var filteredComposeLogLines: [String] { + guard !logSearchText.isEmpty else { return state.composeLogLines } + return state.composeLogLines.filter { + $0.localizedCaseInsensitiveContains(logSearchText) + } + } + private func serviceRow(project: ComposeProject, name: String) -> some View { let service = project.services[name]! return HStack(spacing: 10) { @@ -434,6 +473,16 @@ struct ComposeView: View { pasteboard.setString(text, forType: .string) } + /// Save the current compose log lines to a file chosen by the user. + private func downloadLogs() { + let panel = NSSavePanel() + panel.nameFieldStringValue = "compose-logs.txt" + panel.allowedContentTypes = [.plainText] + guard panel.runModal() == .OK, let url = panel.url else { return } + let content = state.composeLogLines.joined(separator: "\n") + try? content.write(to: url, atomically: true, encoding: .utf8) + } + /// Append the last 50 lines of each service's container output to the log /// panel, so `up` shows the actual boot logs. private func appendContainerLogs(_ project: ComposeProject) async { diff --git a/Sources/AppleDockerApp/Views/ContainerDetailView.swift b/Sources/AppleDockerApp/Views/ContainerDetailView.swift index fc2f944..aa3c2c2 100644 --- a/Sources/AppleDockerApp/Views/ContainerDetailView.swift +++ b/Sources/AppleDockerApp/Views/ContainerDetailView.swift @@ -216,31 +216,69 @@ private struct StatsView: View { } } -/// The Logs tab: streaming container logs. +/// The Logs tab: streaming container logs with search, filter and download. private struct LogsView: View { let containerID: String @AppStorage("logTail") private var tail = 100 @State private var lines: [String] = [] @State private var follow = true @State private var task: Task? + @State private var searchText = "" + @State private var showOnlyErrors = false + + /// Lines that match the active search / error filter. + private var filteredLines: [String] { + lines.filter { line in + if showOnlyErrors && !line.localizedCaseInsensitiveContains("error") { return false } + if searchText.isEmpty { return true } + return line.localizedCaseInsensitiveContains(searchText) + } + } var body: some View { - ScrollViewReader { proxy in - ScrollView { - VStack(alignment: .leading, spacing: 2) { - ForEach(Array(lines.enumerated()), id: \.offset) { _, line in - Text(line) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) + VStack(spacing: 0) { + // Search / filter bar + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Search logs…", text: $searchText) + .textFieldStyle(.roundedBorder) + if !searchText.isEmpty { + Button { + searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) } + .buttonStyle(.borderless) } - .padding(8) + Toggle("Errors only", isOn: $showOnlyErrors) + .toggleStyle(.checkbox) + .controlSize(.small) } - .background(Color(nsColor: .textBackgroundColor)) - .onChange(of: lines.count) { - if follow, let last = lines.indices.last { - proxy.scrollTo(last, anchor: .bottom) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(.bar) + + Divider() + + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 2) { + ForEach(Array(filteredLines.enumerated()), id: \.offset) { _, line in + Text(attributedLine(line)) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(8) + } + .background(Color(nsColor: .textBackgroundColor)) + .onChange(of: lines.count) { + if follow, let last = filteredLines.indices.last { + proxy.scrollTo(last, anchor: .bottom) + } } } } @@ -250,11 +288,23 @@ private struct LogsView: View { .toggleStyle(.switch) .controlSize(.small) Spacer() + Text("\(filteredLines.count) / \(lines.count) lines") + .font(.caption) + .foregroundStyle(.secondary) Stepper("Tail: \(tail)", value: $tail, in: 10...5000, step: 10) .controlSize(.small) .onChange(of: tail) { startStreaming() } Button("Clear") { lines.removeAll() } .controlSize(.small) + Button { + downloadLogs() + } label: { + Label("Download", systemImage: "square.and.arrow.down") + } + .controlSize(.small) + .buttonStyle(.bordered) + .disabled(lines.isEmpty) + .help("Save logs to a file") } .padding(8) .background(.bar) @@ -263,6 +313,22 @@ private struct LogsView: View { .onDisappear { task?.cancel() } } + /// Highlight the search term in a log line using an AttributedString. + private func attributedLine(_ line: String) -> AttributedString { + var attr = AttributedString(line) + guard !searchText.isEmpty else { return attr } + var searchFrom = attr.startIndex + while searchFrom < attr.endIndex { + guard let range = attr[searchFrom...].range( + of: searchText, + options: .caseInsensitive + ) else { break } + attr[range].backgroundColor = .init(nsColor: .systemYellow.withAlphaComponent(0.4)) + searchFrom = range.upperBound + } + return attr + } + private func startStreaming() { task?.cancel() task = Task { @@ -280,4 +346,13 @@ private struct LogsView: View { } } } + + private func downloadLogs() { + let panel = NSSavePanel() + panel.nameFieldStringValue = "\(containerID)-logs.txt" + panel.allowedContentTypes = [.plainText] + guard panel.runModal() == .OK, let url = panel.url else { return } + let content = lines.joined(separator: "\n") + try? content.write(to: url, atomically: true, encoding: .utf8) + } } diff --git a/Sources/AppleDockerApp/Views/ContainerSettingsView.swift b/Sources/AppleDockerApp/Views/ContainerSettingsView.swift index 3314957..f7ff92f 100644 --- a/Sources/AppleDockerApp/Views/ContainerSettingsView.swift +++ b/Sources/AppleDockerApp/Views/ContainerSettingsView.swift @@ -16,7 +16,7 @@ private struct SettingsModel { var ports: [PortEntry] var memoryMB: String var cpus: String - var envVars: [String] + var envVars: [EnvEntry] var labels: [String: String] struct PortEntry: Identifiable { @@ -26,6 +26,35 @@ private struct SettingsModel { var proto: PublishProtocol } + /// A key/value pair for an environment variable. The raw "KEY=VALUE" wire + /// format is split on first `=` so that values containing `=` are handled + /// correctly. + struct EnvEntry: Identifiable { + let id = UUID() + var key: String + var value: String + + /// Reconstruct the "KEY=VALUE" string for the container configuration. + var rawString: String { "\(key)=\(value)" } + + /// Parse a "KEY=VALUE" string, treating everything after the first `=` + /// as the value (so values may themselves contain `=`). + init(raw: String) { + if let range = raw.range(of: "=") { + key = String(raw[..? - init(containerID: String) { + init(containerID: String, sessionLabel: String = "Session 1") { self.containerID = containerID + self.sessionLabel = sessionLabel } /// Create and start the interactive shell, then stream its output. diff --git a/Sources/AppleDockerApp/Views/ContainerTerminalView.swift b/Sources/AppleDockerApp/Views/ContainerTerminalView.swift index 1ffa5e9..f69e5e7 100644 --- a/Sources/AppleDockerApp/Views/ContainerTerminalView.swift +++ b/Sources/AppleDockerApp/Views/ContainerTerminalView.swift @@ -1,56 +1,127 @@ //===----------------------------------------------------------------------===// -// ContainerTerminalView — an integrated terminal for a running container. +// ContainerTerminalView — multi-session integrated terminal for a container. // -// Uses a persistent ContainerTerminalSession from AppState so the shell and -// its output survive tab switches. The Send button enables once the session is -// connected; commands are written to the shell's stdin and echoed locally. +// Each container can have multiple independent shell sessions. Sessions are +// stored in AppState and survive tab switches. New sessions can be added with +// the "+" button and closed with the "×" button on the tab. //===----------------------------------------------------------------------===// import SwiftUI import ContainerBackend -/// A simple integrated terminal for a container. +/// A multi-session integrated terminal for a container. struct ContainerTerminalView: View { @Environment(AppState.self) private var state let containerID: String - @State private var command = "" - @State private var session: ContainerTerminalSession? + + /// The currently selected session's stable UUID. + @State private var selectedSessionID: UUID? + + private var sessions: [ContainerTerminalSession] { + state.terminalSessions(for: containerID) + } + + private var selectedSession: ContainerTerminalSession? { + sessions.first { $0.objectID == selectedSessionID } ?? sessions.first + } var body: some View { - Group { - if let session { - terminalContent(session) + VStack(spacing: 0) { + sessionTabBar + Divider() + if let session = selectedSession { + SingleTerminalView(session: session) + .id(session.objectID) } else { ProgressView("Connecting…") .frame(maxWidth: .infinity, maxHeight: .infinity) } } .onAppear { - let s = state.terminalSession(for: containerID) - session = s - s.connect() + // Ensure at least one session exists and select it. + let existing = state.terminalSessions(for: containerID) + selectedSessionID = existing[0].objectID + existing[0].connect() } } - private func terminalContent(_ session: ContainerTerminalSession) -> some View { - VStack(spacing: 0) { - HStack { - Text("Terminal") - .font(.headline) - Spacer() - if session.isConnected { - Circle() - .fill(Color.green) - .frame(width: 8, height: 8) - Text("Connected") - .font(.caption) - .foregroundStyle(.secondary) + // MARK: Session tab bar + + private var sessionTabBar: some View { + HStack(spacing: 0) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 2) { + ForEach(sessions, id: \.objectID) { session in + sessionTab(session) + } } + .padding(.horizontal, 8) + .padding(.vertical, 6) } - .padding(8) - Divider() + Divider().frame(height: 24) + // Add new session + Button { + let newSession = state.addTerminalSession(for: containerID) + selectedSessionID = newSession.objectID + newSession.connect() + } label: { + Image(systemName: "plus") + .frame(width: 28, height: 28) + } + .buttonStyle(.borderless) + .help("New terminal session") + .padding(.trailing, 6) + } + .background(.bar) + } + + private func sessionTab(_ session: ContainerTerminalSession) -> some View { + let isSelected = session.objectID == (selectedSessionID ?? sessions.first?.objectID) + return HStack(spacing: 4) { + if session.isConnected { + Circle() + .fill(Color.green) + .frame(width: 6, height: 6) + } + Text(session.sessionLabel) + .font(.caption) + if sessions.count > 1 { + Button { + state.removeTerminalSession(session) + // Select the last remaining session if the removed one was active. + if isSelected { + selectedSessionID = state.terminalSessions(for: containerID).last?.objectID + } + } label: { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .bold)) + } + .buttonStyle(.borderless) + .help("Close session") + } + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(isSelected ? Color.accentColor.opacity(0.15) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .contentShape(Rectangle()) + .onTapGesture { + selectedSessionID = session.objectID + } + } +} + +// MARK: - Single terminal panel + +/// The terminal output + input UI for one session. +private struct SingleTerminalView: View { + @Bindable var session: ContainerTerminalSession + @State private var command = "" + + var body: some View { + VStack(spacing: 0) { ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 2) { @@ -76,18 +147,27 @@ struct ContainerTerminalView: View { HStack(spacing: 8) { TextField("Enter command…", text: $command) .textFieldStyle(.roundedBorder) - .onSubmit { sendCommand(session) } - Button("Send") { sendCommand(session) } + .onSubmit { sendCommand() } + Button("Send") { sendCommand() } .buttonStyle(.borderedProminent) .disabled(!session.isConnected) + Button("Clear") { session.output.removeAll() } + .buttonStyle(.bordered) + .help("Clear terminal output") } .padding(8) } + .onAppear { + if !session.isConnected { + session.connect() + } + } } - private func sendCommand(_ session: ContainerTerminalSession) { + private func sendCommand() { let cmd = command command = "" session.sendCommand(cmd) } } + From 946db53132e8c316401bde2d6a6961c81e88a1cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 06:05:35 +0000 Subject: [PATCH 3/3] fix review comment followups Co-authored-by: djpfs <43576725+djpfs@users.noreply.github.com> --- Sources/AppleDockerApp/Views/ComposeEditorView.swift | 4 +--- Sources/AppleDockerApp/Views/ComposeView.swift | 6 +++++- Sources/AppleDockerApp/Views/ContainerDetailView.swift | 6 +++++- Sources/AppleDockerApp/Views/ContainerSettingsView.swift | 2 +- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/Sources/AppleDockerApp/Views/ComposeEditorView.swift b/Sources/AppleDockerApp/Views/ComposeEditorView.swift index c7a54cd..7531b53 100644 --- a/Sources/AppleDockerApp/Views/ComposeEditorView.swift +++ b/Sources/AppleDockerApp/Views/ComposeEditorView.swift @@ -9,7 +9,7 @@ import UniformTypeIdentifiers // MARK: - Project templates /// A pre-built project template the user can load as a starting point. -struct ProjectTemplate: Identifiable { +struct ProjectTemplate: Identifiable, Hashable { let id = UUID() let name: String let description: String @@ -193,8 +193,6 @@ struct ComposeEditorView: View { @State private var composeText: String = "" @State private var dockerfileText: String = "" @State private var showTemplatePicker = false - @State private var showSavePanel = false - @State private var showOpenPanel = false @State private var saveMessage: String? var body: some View { diff --git a/Sources/AppleDockerApp/Views/ComposeView.swift b/Sources/AppleDockerApp/Views/ComposeView.swift index 04a8198..301fbe7 100644 --- a/Sources/AppleDockerApp/Views/ComposeView.swift +++ b/Sources/AppleDockerApp/Views/ComposeView.swift @@ -480,7 +480,11 @@ struct ComposeView: View { panel.allowedContentTypes = [.plainText] guard panel.runModal() == .OK, let url = panel.url else { return } let content = state.composeLogLines.joined(separator: "\n") - try? content.write(to: url, atomically: true, encoding: .utf8) + do { + try content.write(to: url, atomically: true, encoding: .utf8) + } catch { + state.composeStatusMessage = StatusMessage(kind: .error, text: "Failed to save logs: \(error.localizedDescription)") + } } /// Append the last 50 lines of each service's container output to the log diff --git a/Sources/AppleDockerApp/Views/ContainerDetailView.swift b/Sources/AppleDockerApp/Views/ContainerDetailView.swift index aa3c2c2..12b9687 100644 --- a/Sources/AppleDockerApp/Views/ContainerDetailView.swift +++ b/Sources/AppleDockerApp/Views/ContainerDetailView.swift @@ -353,6 +353,10 @@ private struct LogsView: View { panel.allowedContentTypes = [.plainText] guard panel.runModal() == .OK, let url = panel.url else { return } let content = lines.joined(separator: "\n") - try? content.write(to: url, atomically: true, encoding: .utf8) + do { + try content.write(to: url, atomically: true, encoding: .utf8) + } catch { + lines.append("[ERROR] Failed to save logs: \(error.localizedDescription)") + } } } diff --git a/Sources/AppleDockerApp/Views/ContainerSettingsView.swift b/Sources/AppleDockerApp/Views/ContainerSettingsView.swift index f7ff92f..3912803 100644 --- a/Sources/AppleDockerApp/Views/ContainerSettingsView.swift +++ b/Sources/AppleDockerApp/Views/ContainerSettingsView.swift @@ -271,7 +271,7 @@ struct ContainerSettingsView: View { .textFieldStyle(.roundedBorder) .frame(maxWidth: .infinity) Button { - model.envVars.removeAll { $0.id == entry.id } + model.envVars.removeAll { $0.id == $entry.wrappedValue.id } } label: { Image(systemName: "minus.circle") }