From 1ba1a888a6ebe77ddeda7490b439d1c154dc1746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barr=C4=B1=C5=9Fcan=20Aslan?= Date: Mon, 29 Jun 2026 00:48:11 +0300 Subject: [PATCH 01/13] docs: Milestone 7 run/build/exec/logs/copy + activity-pane design Grounded against container v1.0.0 --help (run/build/exec/copy/logs/machine run). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QRY8sjZCmM87FEsjo8A4he --- ...estone7-run-build-exec-logs-copy-design.md | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-29-milestone7-run-build-exec-logs-copy-design.md diff --git a/docs/superpowers/specs/2026-06-29-milestone7-run-build-exec-logs-copy-design.md b/docs/superpowers/specs/2026-06-29-milestone7-run-build-exec-logs-copy-design.md new file mode 100644 index 0000000..c64b9fc --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-milestone7-run-build-exec-logs-copy-design.md @@ -0,0 +1,286 @@ +# Milestone 7 · Run, Build, Exec, Logs, Copy + terminal + Activity pane — Design + +Status: approved-to-build (autonomous; `/goal` directive). Date: 2026-06-29. +Branch: `milestone-7-run-build-exec-logs-copy` (cut from `main` after M6 merges; if M6 is +still unmerged, branch from `milestone-6-images-registries`). + +## 1. Goal & scope + +Finish the core daily workflows so most tasks never leave the GUI, and complete the +long-running-task infrastructure. Concretely: + +- **Activity task model** (foundation): the `TaskCenter`/`OperationTask` backing built in + M6 gains **cancel**, **determinate-or-indeterminate progress**, and new task kinds, so + every long job (build/pull/push/save/load/**export**/**system start**/**run-detached**/ + **copy**) registers a queue entry with a streamed transcript, progress, cancel, and retry. +- **Run**: a Quick Run sheet with a live `container run …` command preview ("Run Inspector"), + a contextual **Run Image…** from the Images list. Attached/TTY runs open the integrated + terminal; detached runs register a task. On failure, a triage panel offers + **resolve image / inspect logs / retry in terminal**. +- **Build**: a Build sheet with **folder drag/drop** for the context dir, presets, a tag, + optional Dockerfile path and build-args, and a **live streaming pane**. Raw stdout/stderr + is **never hidden**: the full transcript stays, is exportable, and a **"plain progress" + retry** re-runs with `--progress plain`. +- **Exec / interactive**: an Exec sheet (`exec -it `), a **machine shell**, the + AppKit-hosted terminal (already built in M5.5), a detachable **"Open in Terminal.app"** + fallback, and rerun-with-captured-command. +- **Logs**: a logs pane + a **detachable log window** backed by `container logs` with + follow, **tail (-n)**, **search**, and **save transcript**. +- **Copy**: a copy sheet + drag/drop between a Finder-like host panel and a best-effort + container file browser, validating `container:path` semantics early with path examples. + +Out of scope (YAGNI): `create` (run covers the common path); env-file/secret-file pickers +for build (`--build-arg`/typed env rows are enough); a full recursive container file tree +(one-level `ls` listing on demand is enough); progress bars parsed from every CLI line +(determinate only where the CLI emits a clean `NN%`, indeterminate otherwise — the +transcript stays the source of truth, per M6 decision #4). + +## 2. CLI facts (verified against `container` v1.0.0 `--help` on this machine) + +``` +run [opts] [args...] + -e/--env key=value -i/--interactive -t/--tty -w/--workdir + -d/--detach --name --rm/--remove + -p/--publish [host-ip:]host-port:container-port[/proto] + -v/--volume --mount type=,source=,target=,readonly + -u/--user --entrypoint --platform + --progress auto|none|ansi|plain|color +build [opts] [] (context default: ".") + -t/--tag -f/--file --build-arg key=val --no-cache + --progress auto|plain|tty --target -l/--label --platform + (NB: default --tag is a random UUID, so we require a tag in the sheet) +exec [opts] + -i/--interactive -t/--tty -w/--workdir -e/--env -u/--user -d/--detach +copy (alias: cp; / are container:path or local path) +logs [--boot] [-f/--follow] [-n ] +machine run [-i -t] [-n ] [] [args...] (default executable: login shell) +``` + +No native "list files inside a container" verb exists, so the container file browser issues +`container exec ls -la ` and parses the output leniently (best-effort; requires a +running container with `ls`). The `cp` alias resolves to the canonical `copy` subcommand — +the adapter emits `copy`. + +## 3. Architecture & layering + +Obeys the arch-guard rules (UI imports no Backend; Domain imports no UI and no `Process`). +Reuses every M5.5/M6 seam: the `TaskCenter` for long jobs, the `TerminalRequest` + +`TerminalSurfaceProviding` port for interactive sessions, and the per-view `Sheet` enum + +`.sheet(item:)` presentation pattern. + +### 3.1 Backend port additions (`CapsuleBackend.ContainerBackend`) + +New value types (pure data, in `CapsuleBackend`, each with a computed `arguments: [String]` +— the argv **after** `container` — so the CLI adapter and the Domain's terminal-request +builder share one source of truth and both are unit-tested): + +```swift +struct RunConfiguration: Sendable, Equatable { + var image: String + var name: String? + var command: [String] // init-process args after the image + var env: [String] // ["KEY=value", …] + var publishPorts: [String] // ["8080:80", "127.0.0.1:5432:5432/tcp", …] + var volumes: [String] // ["/host:/container", "vol:/data:ro", …] + var workdir: String? + var user: String? + var interactive: Bool // -i + var tty: Bool // -t + var detach: Bool // -d + var remove: Bool // --rm + var arguments: [String] { … } // ["run", "-d", "--rm", "--name", …, image, cmd…] +} +struct BuildConfiguration: Sendable, Equatable { + var contextDirectory: URL + var tag: String + var dockerfile: String? // -f + var buildArgs: [String] // ["KEY=value", …] (--build-arg) + var noCache: Bool + var plainProgress: Bool // --progress plain (the "plain retry") + var arguments: [String] { … } // ["build", "--tag", …, "--progress", "plain"?, ctx] +} +struct ContainerFileEntry: Sendable, Equatable, Identifiable { + var name: String; var isDirectory: Bool; var size: Int64?; var mode: String? + var id: String { name } +} +``` + +New protocol methods: + +```swift +// Run (detached only goes through the port; interactive run is a TerminalRequest) +func runContainer(_ config: RunConfiguration) async throws -> String // returns the new id +// Build (streaming; transcript is the source of truth) +func buildImage(_ config: BuildConfiguration) -> AsyncThrowingStream +// Copy (one-shot; either source or destination is container:path) +func copyToContainer(source: URL, containerID: String, containerPath: String) async throws +func copyFromContainer(containerID: String, containerPath: String, destination: URL) async throws +// Logs (non-follow fetch; followLogs already exists for the follow case) +func fetchLogs(container id: String, tail: Int?, boot: Bool) async throws -> [OutputLine] +// Container FS listing for the copy browser (best-effort via `exec ls`) +func listContainerDirectory(id: String, path: String) async throws -> [ContainerFileEntry] +``` + +`CLICommand` gains a static factory per command (delegating to `config.arguments` for +run/build); `CLIContainerBackend` implements one-shot ops via the existing `runChecked` and +streaming ops via `runner.stream`. `runContainer` captures stdout and returns the trimmed +last non-empty line (apple/container prints the new id on `run -d`). `listContainerDirectory` +runs `exec ls -la ` through the non-throwing `runRaw` escape hatch and parses +rows leniently (graceful empty/`unavailable` on a non-running container). `MockBackend` +gains in-memory implementations that record last-call params and honor `failure` injection; +build/copy reuse the seeded-stream / state-mutation patterns. + +### 3.2 Activity task model (`CapsuleDomain`) + +- `TaskState` gains **`case cancelled`** (`isActive == false`; rendered neutral/gray, not + red — a cancel is not a failure). +- `OperationKind` gains **`build`, `run`, `export`, `systemStart`, `copy`** (existing: + pull/push/save/load) with titles + SF symbols. +- `OperationTask` gains an optional **`progress: Double?`** surfaced from + `state == .running(progress:)` (already modelled) and a **`isCancellable`** flag. +- `TaskCenter` gains: + - `cancel(_ task:)` — cancels the driver `Task`; the streaming backend's + `AsyncThrowingStream.onTermination`/`CLIProcessRunner` cancellation terminates the child + process. A cancelled driver sets `state = .cancelled` (catching `CancellationError`). + - A small `ProgressParser` applied to each streamed line: when a line matches a clean + `NN%` it updates `state = .running(progress: NN/100)`; otherwise progress stays `nil` + (indeterminate). Pure + unit-tested; default behavior unchanged for pull/push. +- **Wire export + system start as tasks**: `ContainerLifecycleModel.export` registers a + `.export` task via the task center (with cancel); `SystemStatusModel.startServices` + registers a `.systemStart` task. Both keep their existing notices for hard failures. + +### 3.3 Domain models (`CapsuleDomain`) + +All `@Observable`, constructed in `AppEnvironment.live()` with `backend`, `normalize`, +`onActivity`, and (where relevant) `taskCenter`, `reloadList`, `launchTerminal`/`copyCommand` +seams — mirroring `ImageActionsModel`/`ContainerLifecycleModel`. + +- **`RunModel`** — holds a `RunDraft` (UI-friendly raw strings: image, name, port rows, env + rows, volume rows, workdir, command, toggles). `validatedConfiguration()` parses the draft + into a `RunConfiguration` or returns field-level `invalidInput` errors. `commandPreview` + exposes `"container " + config.arguments.joined(" ")` live (the Run Inspector). + `runDetached()` → `taskCenter.runAsync(.run)` (onSuccess: reload containers) ; + `runInTerminal()` → builds the interactive `RunConfiguration` (i/t on, detach off) and + emits a `TerminalRequest(kind: .runInteractive)` through `launchOrCopy`. A failed detached + run records the failing task and exposes triage actions + (`resolveImage` → open Pull sheet for the image; `inspectLogs`; `retryInTerminal`). +- **`BuildModel`** — holds a `BuildDraft` (contextDirectory, tag, dockerfile, build-arg rows, + noCache, preset). `presets` = Default / No-cache / Plain-progress. `build()` → + `taskCenter.runStreaming(.build)` (onSuccess: reload images). `retryPlain(task:)` re-runs + the build with `plainProgress = true`. Transcript export = the task's `transcriptText` + (Save via `NSSavePanel` in the UI). Validates the context dir exists and a tag is present. +- **`LogsModel`** — per-container log viewing: `lines: [LogLine]`, `follow`, `tail: Int?`, + `boot`, `searchText`, derived `filteredLines`. `start(id:)` runs `followLogs` (follow) or + `fetchLogs` (snapshot) into the buffer; `stop()` cancels. `transcriptText` for save. +- **`CopyModel`** — `direction` (toContainer/fromContainer), `hostURL: URL?`, + `containerID`, `containerPath`, derived `validation` (container path must be absolute; + shows `id:/abs/path` examples; both endpoints required). `copy()` → + `taskCenter.runAsync(.copy)` calling the matching backend method (onSuccess: notice). + `browse(path:)` → `backend.listContainerDirectory` for the container panel (best-effort). + +### 3.4 UI (`CapsuleUI`) + +- **Run**: `QuickRunSheet` (image; name; +/- port/env/volume rows; workdir; command; toggles + for interactive+tty / detach / remove; a live monospaced **command preview**; Run button + that routes to terminal or task by the interactive toggle). A **Run Image…** context item + + toolbar button in `ImageListView` (prefills the image). The failure **triage panel** + (`RunFailureTriageView`) reads the failed `.run` task and offers Resolve Image / Inspect + Logs / Retry in Terminal. +- **Build**: `BuildSheet` — a **drop zone** (`onDrop` of a folder `NSItemProvider`, plus a + Choose… button) for the context dir, a preset `Picker`, tag/Dockerfile fields, +/- build-arg + rows, a no-cache toggle, a live `TaskTranscriptView` pane while building, an **Export + Transcript** button, and a **Retry with plain progress** button shown on failure. Reached + from the Images toolbar (**Build…**). +- **Exec / terminal**: `ExecSheet` (container id read-only; command field defaulting to `sh`; + interactive+tty toggle; Run → terminal). A **machine shell** action opens an interactive + login shell in a machine via `container machine run -it [-n ]` (a `TerminalRequest`, + surfaced from the Machines list when that section lands; harmless to wire now). The + **"Open in Terminal.app"** fallback writes the argv to a temp script and launches + Terminal via `NSWorkspace`/`open -a Terminal`; offered alongside the existing + copy-to-clipboard fallback. The terminal tab already supports rerun via its Restart banner; + rerun-with-captured-command reuses `session.request`. +- **Logs**: `LogsPaneView` (follow toggle, tail field, search field, save button, monospaced + scrollback) shown in a new **Logs** Activity sub-surface for the selected container, and a + detachable **`Window`** scene (`LogWindow`) opened via a toolbar/menu **Open Log Window**; + both bind a `LogsModel`. Save uses `NSSavePanel` (`.txt`/`.log`). +- **Copy**: `CopySheet` — a direction segmented control; a host panel (a drop target + Choose… + showing the chosen path) and a container panel (id field + path field with inline + `id:/path` examples and a **Browse** disclosure that lists `ContainerFileEntry` rows from + `listContainerDirectory`); drag a host file onto the container panel (or vice-versa) to set + the pair; a Copy button gated on `validation`. Reached from a container's context menu + (**Copy Files…**). + +### 3.5 Composition (`CapsuleApp`) + +`AppEnvironment` gains `runModel`, `buildModel`, `logsModel`, `copyModel`; `live()` wires +them with the shared `taskCenter`, `ErrorNormalizer.normalize`, `shell.appendActivity`, the +container/image reloads, and the terminal launch/copy seams (the same closures +`ContainerLifecycleModel` already receives). `runModel`'s interactive path and the new +Open-in-Terminal fallback are injected here (the only place that may touch `NSWorkspace`). +`CapsuleScene` adds a `Window("Logs", id: LogWindow.id)` scene bound to `logsModel` for the +detachable window. `CapsuleCommands` adds a **View ▸ Open Log Window** item. + +## 4. Error handling + +Every backend failure normalizes through `ErrorNormalizer.normalize` → `CapsuleError` → +`ErrorDetail`, as elsewhere. Raw transcripts stay visible: build/run-detached/copy/export +keep their full task transcript; the run triage panel and build sheet never hide stdout/ +stderr. Cancel is a first-class neutral outcome (`.cancelled`), distinct from failure. +Benign cases: copying onto a stopped/missing container surfaces the CLI's verbatim stderr; +`listContainerDirectory` degrades to an "unavailable" notice (running container required) +rather than throwing into the UI. Path validation rejects a missing `:` or relative +container path **before** spawning, with an example. + +## 5. Testing (all against `MockBackend`) + +- `RunConfigurationTests` / `BuildConfigurationTests` — `arguments` argv for every toggle + combination matches the verified `--help` flags (detach vs interactive, ports/env/volumes, + no-cache, plain progress, custom Dockerfile, build-args, context default). +- `CLICommandTests` / `CLIContainerBackendTests` — argv for run/build/copy(to+from)/ + fetchLogs/listContainerDirectory; `runContainer` returns the parsed id; `copy` (not `cp`) + is emitted; `listContainerDirectory` parses `ls -la` leniently and degrades on non-zero. +- `TaskCenterTests` — `cancel` flips a running task to `.cancelled` and stops the driver; + `retry` of a cancelled task re-runs; `ProgressParser` updates determinate progress and + leaves unmatched lines indeterminate; new kinds carry titles/symbols. +- `RunModelTests` — draft→config validation (good + each field error), `commandPreview`, + detached run registers a `.run` task + reloads, interactive run emits a `TerminalRequest` + (argv `container run -it …`), triage actions. +- `BuildModelTests` — draft→config, presets, `build()` registers a streaming `.build` task + + reloads images on success, `retryPlain` sets `--progress plain`, context/tag validation. +- `LogsModelTests` — follow vs snapshot population, search filter, tail/boot flags, + `transcriptText`, stop cancels. +- `CopyModelTests` — direction argv routing, validation (missing endpoint, relative + container path, missing `:` handled by the model), `copy()` registers a `.copy` task, + `browse` lists entries / degrades. +- `MockBackendTests` — new methods mutate/record state and honor `failure`. +- `ArchitectureGuardTests` — unchanged rules keep passing (no UI→Backend, no Domain→Process). +- `CompositionTests` / `AppEnvironmentActionsTests` — env exposes the new models; export and + system-start now register tasks; a run-detached action registers a task and reloads. + +Acceptance maps 1:1 to the goal: build streams live logs with export + plain-progress retry ✓; +run attaches into the integrated terminal with a failure triage panel ✓; exec opens the +AppKit-hosted terminal with detach fallback + rerun ✓; logs follow/search/save ✓; copy +validates paths and supports drag/drop ✓; the Activity pane shows progress, transcript, +cancel, and retry for every long job ✓. + +## 6. Decisions log (autonomous; documented for async review) + +1. **Interactive run/exec go through the terminal (PTY), not the runner.** Only detached run, + build, copy, logs-fetch, and ls-listing go through the `ContainerBackend` port; `run -it` + and `exec -it` are `TerminalRequest`s spawned by `SwiftTermSurfaceProvider`. This avoids + bolting bidirectional stdin onto `CLIProcessRunner`. +2. **One source of truth for argv.** `RunConfiguration`/`BuildConfiguration` own a computed + `arguments`; both the CLI adapter and the Domain terminal-request builder consume it. +3. **Cancel is a neutral state (`.cancelled`)**, not a failure — different glyph/color, retry + still offered. +4. **`run -d` returns the id by parsing stdout** (no machine-readable verb); best-effort. +5. **Container file browser is one-level `exec ls`**, best-effort, requires a running + container; it degrades gracefully rather than pretending to be a full VFS. +6. **`copy` emitted, not `cp`** (canonical subcommand); both source/dest validated for + `container:path` before spawning. +7. **Determinate progress only where the CLI emits a clean `NN%`** (pull/build), else + indeterminate; the transcript stays the source of truth (carries M6 decision #4 forward). +8. **One milestone, one branch**, phased commits (Activity-model → backend → run → build → + exec → logs → copy → composition/smoke), mirroring M6. + + From 7c4f96c7df9d1649b4b45cad222c3e3ef4c9da8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barr=C4=B1=C5=9Fcan=20Aslan?= Date: Mon, 29 Jun 2026 00:52:07 +0300 Subject: [PATCH 02/13] docs: Milestone 7 implementation plan (8 phases, TDD) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QRY8sjZCmM87FEsjo8A4he --- ...-29-milestone7-run-build-exec-logs-copy.md | 684 ++++++++++++++++++ 1 file changed, 684 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-29-milestone7-run-build-exec-logs-copy.md diff --git a/docs/superpowers/plans/2026-06-29-milestone7-run-build-exec-logs-copy.md b/docs/superpowers/plans/2026-06-29-milestone7-run-build-exec-logs-copy.md new file mode 100644 index 0000000..2e35af9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-milestone7-run-build-exec-logs-copy.md @@ -0,0 +1,684 @@ +# Milestone 7 · Run / Build / Exec / Logs / Copy + terminal + Activity pane — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Complete the core GUI workflows (run, build, exec, logs, copy) on top of the integrated terminal and finish the Activity-pane task model (queue, progress, transcript, cancel, retry for every long job). + +**Architecture:** Ports & Adapters. New value types + port methods in `CapsuleBackend`; `@Observable` models in `CapsuleDomain`; SwiftUI sheets/views in `CapsuleUI`; CLI argv in `CapsuleCLIBackend`; wiring in `CapsuleApp`. Interactive run/exec/machine-shell go through the existing `TerminalRequest` + `TerminalSurfaceProviding` (PTY) seam; detached run, build, copy, logs-fetch, and `ls`-listing go through the `ContainerBackend` port. `RunConfiguration`/`BuildConfiguration` own a computed `arguments` argv consumed by BOTH the CLI adapter and the Domain terminal-request builder. + +**Tech Stack:** Swift 6, SwiftUI, Observation, XCTest, SwiftTerm (already integrated), XcodeGen. + +## Global Constraints + +- Build CLI tests with **`DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test`** (XCTest needs Xcode; CLT-only `swift build` compiles but cannot test). +- Arch-guard invariants (enforced by `ArchitectureGuardTests` + `Scripts/check-architecture.sh`): **UI imports no Backend module**; **Domain imports no UI and no `Foundation.Process`**. No backend type may appear in any UI signature. +- License header on every new file (pre-commit hook enforces it — copy the header block from any existing file in the same module). +- Secrets never on argv (carried from M6); not directly relevant to M7 but keep the invariant. +- `swift-format` clean (`make format` then `make lint`); commit small and often (one task = one commit). +- Verify before claiming done: run the full suite + arch + lint, then a live GUI smoke, exactly as M5/M6. +- The CLI subcommand for copy is **`copy`** (not the `cp` alias). Build's default `--tag` is a random UUID, so a tag is **required** in the UI. + +--- + +## Phase 1 — Activity task model (foundation: cancel, progress, new kinds) + +### Task 1.1: `TaskState.cancelled` + +**Files:** +- Modify: `Sources/CapsuleDomain/TaskState.swift` +- Test: `Tests/CapsuleUnitTests/OperationStatusTests.swift` (or `TaskCenterTests.swift` — add a `TaskState` case test there) + +**Interfaces:** +- Produces: `TaskState.cancelled` with `isActive == false`. + +- [ ] **Step 1: Failing test** — in `TaskCenterTests.swift` add: +```swift +func testCancelledStateIsNotActive() { + XCTAssertFalse(TaskState.cancelled.isActive) + XCTAssertTrue(TaskState.running(progress: nil).isActive) +} +``` +- [ ] **Step 2: Run** `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test --filter TaskCenterTests/testCancelledStateIsNotActive` → FAIL (no `.cancelled`). +- [ ] **Step 3: Implement** — add `case cancelled` to `TaskState`; in `isActive`, group `cancelled` with the non-active cases (`case .idle, .succeeded, .failed, .cancelled: return false`). +- [ ] **Step 4: Run** the filter → PASS. Then full `swift test` to catch any non-exhaustive `switch` on `TaskState` (fix each: `TaskTranscriptView.stateIcon`, anywhere matching `.failed`). +- [ ] **Step 5: Commit** `feat(tasks): add neutral .cancelled task state`. + +### Task 1.2: Expand `OperationKind` (build/run/export/systemStart/copy) + +**Files:** +- Modify: `Sources/CapsuleDomain/TaskCenter.swift` +- Test: `Tests/CapsuleUnitTests/TaskCenterTests.swift` + +**Interfaces:** +- Produces: `OperationKind` cases `.build, .run, .export, .systemStart, .copy` each with `title` + `symbolName`. + +- [ ] **Step 1: Failing test**: +```swift +func testNewOperationKindsHaveTitlesAndSymbols() { + for kind in [OperationKind.build, .run, .export, .systemStart, .copy] { + XCTAssertFalse(kind.title.isEmpty) + XCTAssertFalse(kind.symbolName.isEmpty) + } + XCTAssertEqual(OperationKind.build.title, "Build") + XCTAssertEqual(OperationKind.run.title, "Run") +} +``` +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement** — add the cases + `title`/`symbolName` arms: build→"Build"/"hammer", run→"Run"/"play.rectangle", export→"Export"/"square.and.arrow.up.on.square", systemStart→"Start Services"/"power", copy→"Copy"/"doc.on.doc". +- [ ] **Step 4: Run** → PASS. +- [ ] **Step 5: Commit** `feat(tasks): add build/run/export/systemStart/copy operation kinds`. + +### Task 1.3: `ProgressParser` (determinate progress from a clean `NN%`) + +**Files:** +- Create: `Sources/CapsuleDomain/ProgressParser.swift` +- Test: `Tests/CapsuleUnitTests/ProgressParserTests.swift` + +**Interfaces:** +- Produces: `enum ProgressParser { static func fraction(in line: String) -> Double? }` returning `0.0...1.0` or `nil`. + +- [ ] **Step 1: Failing test**: +```swift +func testParsesCleanPercent() { + XCTAssertEqual(ProgressParser.fraction(in: "Downloading 42%"), 0.42, accuracy: 0.001) + XCTAssertEqual(ProgressParser.fraction(in: "[100%] done"), 1.0, accuracy: 0.001) +} +func testIgnoresLinesWithoutPercent() { + XCTAssertNil(ProgressParser.fraction(in: "Step 2/5 : RUN echo hi")) + XCTAssertNil(ProgressParser.fraction(in: "pulling layer sha256:abc")) +} +func testClampsAndTakesLastPercent() { + XCTAssertEqual(ProgressParser.fraction(in: "a 10% b 80%"), 0.80, accuracy: 0.001) +} +``` +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement** — regex `([0-9]{1,3})%`, take the last match, clamp to `0...100`, divide by 100. No match → nil. Pure, no Foundation.Process. +- [ ] **Step 4: Run** → PASS. +- [ ] **Step 5: Commit** `feat(tasks): ProgressParser for determinate progress from CLI percent lines`. + +### Task 1.4: `TaskCenter.cancel` + progress wiring + cancellable flag + +**Files:** +- Modify: `Sources/CapsuleDomain/TaskCenter.swift` +- Test: `Tests/CapsuleUnitTests/TaskCenterTests.swift` + +**Interfaces:** +- Consumes: `ProgressParser.fraction(in:)`. +- Produces: `TaskCenter.cancel(_ task: OperationTask)`; `OperationTask.isCancellable: Bool`; streaming driver updates `state = .running(progress:)` from parsed lines; `CancellationError` → `.cancelled`. + +- [ ] **Step 1: Failing tests**: +```swift +func testCancelStopsRunningTaskAndMarksCancelled() async { + let center = TaskCenter() + let gate = AsyncStream.makeStream() // never finishes until we cancel + let task = center.runStreaming(kind: .build, title: "build") { + AsyncThrowingStream { cont in + let t = Task { for await l in gate.stream { cont.yield(l) }; cont.finish() } + cont.onTermination = { _ in t.cancel() } + } + } + await Task.yield() + center.cancel(task) + await task.wait() + XCTAssertEqual(task.state, .cancelled) + XCTAssertFalse(task.state.isActive) +} +func testStreamingUpdatesDeterminateProgress() async { + let center = TaskCenter() + let task = center.runStreaming(kind: .pull, title: "pull") { + AsyncThrowingStream { cont in + cont.yield(OutputLine(source: .stdout, text: "Downloading 50%")) + cont.finish() + } + } + await task.wait() + // last running progress observed was 0.5 before success; assert transcript captured the line + XCTAssertTrue(task.transcriptText.contains("50%")) +} +func testRetryOfCancelledReRuns() async { + // cancel then retry drives again and can succeed +} +``` +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement**: + - Add `public internal(set) var isCancellable: Bool = true` to `OperationTask`. + - In `drive(_:)` streaming + async branches, wrap the body so a thrown `CancellationError` (or `task.driver?.isCancelled`) sets `task.state = .cancelled` instead of `recordFailure`. + - In the streaming loop, after `task.append(...)`, call `if let f = ProgressParser.fraction(in: line.text) { task.state = .running(progress: f) }`. + - Add `public func cancel(_ task: OperationTask) { guard task.state.isActive else { return }; task.driver?.cancel() }`. (Driver's `catch is CancellationError` sets `.cancelled`; for the non-streaming `runAsync` branch, `CLIProcessRunner` cancellation terminates the child and surfaces as an error/cancellation — catch `CancellationError` first.) +- [ ] **Step 4: Run** the three tests + full `TaskCenterTests` → PASS. +- [ ] **Step 5: Commit** `feat(tasks): cancel + determinate-progress wiring in TaskCenter`. + +### Task 1.5: UI — cancel button + progress bar in tasks/progress tabs + +**Files:** +- Modify: `Sources/CapsuleUI/TaskTranscriptView.swift`, `Sources/CapsuleUI/ActivityPaneView.swift` +- Test: none new (SwiftUI views); covered by build/run later + manual smoke. (Keep `BannerPresentationTests` style if a pure helper is extracted.) + +**Interfaces:** +- Consumes: `OperationTask.state`, `OperationTask.isCancellable`, `TaskCenter.cancel`. + +- [ ] **Step 1:** Add `var onCancel: (() -> Void)?` to `TaskTranscriptView`; show a Stop button while `task.state.isActive && task.isCancellable`; add a `.cancelled` arm to `stateIcon` (gray `minus.circle.fill` or `stop.circle`); render a determinate `ProgressView(value:)` when `state == .running(progress:)` with non-nil progress, else the existing indeterminate spinner. +- [ ] **Step 2:** In `ActivityPaneView.tasksList`, pass `onCancel: { taskCenter?.cancel(task) }`; in `progressList`, render `ProgressView(value:)` when determinate and a Stop button. +- [ ] **Step 3:** `swift build` (CLT) compiles; `make format`/`make lint` clean. +- [ ] **Step 4: Commit** `feat(ui): cancel button + determinate progress in Activity pane`. + +### Task 1.6: Route export + system-start through the TaskCenter + +**Files:** +- Modify: `Sources/CapsuleDomain/ContainerLifecycleModel.swift` (export), `Sources/CapsuleDomain/SystemStatusModel.swift` (startServices) +- Test: `Tests/CapsuleUnitTests/ContainerLifecycleModelTests.swift`, `Tests/CapsuleUnitTests/SystemStatusModelTests.swift` + +**Interfaces:** +- Consumes: a `taskCenter` injected into both models (new optional init param, default nil to keep existing tests compiling). +- Produces: `export(id:to:)` registers a `.export` `runAsync` task; `startServices()` registers a `.systemStart` task. + +- [ ] **Step 1: Failing test** (lifecycle): inject a `TaskCenter`, call `export`, assert `taskCenter.tasks` gains one `.export` task that succeeds (Mock export succeeds). +```swift +func testExportRegistersTask() async { + let center = TaskCenter() + let model = ContainerLifecycleModel(backend: MockBackend(), taskCenter: center) + await model.export(id: "c1", to: URL(fileURLWithPath: "/tmp/x.tar")) + XCTAssertEqual(center.tasks.count, 1) + XCTAssertEqual(center.tasks.first?.kind, .export) +} +``` +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement** — add `taskCenter: TaskCenter? = nil` param; in `export`, if a center is present, `center.runAsync(kind: .export, title: "Export \(id)") { try await backend.exportContainer(id: id, to: url) }` and return; keep the notice path only as a fallback when no center. Same shape for `startServices` (`.systemStart`, title "Start services"). Keep the settle/refresh behavior after success via `onSuccess`. +- [ ] **Step 4: Run** both suites → PASS. +- [ ] **Step 5: Commit** `feat(tasks): export + system-start register Activity tasks`. + +--- + +## Phase 2 — Backend: value types, port, CLI adapter, mock + +### Task 2.1: `RunConfiguration` + `arguments` + +**Files:** +- Create: `Sources/CapsuleBackend/RunConfiguration.swift` +- Test: `Tests/CapsuleUnitTests/RunConfigurationTests.swift` + +**Interfaces:** +- Produces: `struct RunConfiguration` (fields per design §3.1) with `var arguments: [String]`. + +- [ ] **Step 1: Failing tests** (exact argv): +```swift +func testDetachedRunArgv() { + let c = RunConfiguration(image: "nginx:latest", name: "web", command: [], + env: ["FOO=bar"], publishPorts: ["8080:80"], volumes: ["/h:/c"], + workdir: "/app", user: nil, interactive: false, tty: false, detach: true, remove: true) + XCTAssertEqual(c.arguments, + ["run", "-d", "--rm", "--name", "web", "-e", "FOO=bar", + "-p", "8080:80", "-v", "/h:/c", "-w", "/app", "nginx:latest"]) +} +func testInteractiveRunArgvWithCommand() { + let c = RunConfiguration(image: "alpine", name: nil, command: ["sh", "-c", "echo hi"], + env: [], publishPorts: [], volumes: [], workdir: nil, user: nil, + interactive: true, tty: true, detach: false, remove: false) + XCTAssertEqual(c.arguments, ["run", "-i", "-t", "alpine", "sh", "-c", "echo hi"]) +} +``` +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement** `arguments`: start `["run"]`; append `-d` if detach; `-i` if interactive; `-t` if tty; `--rm` if remove; `--name name`; each env as `-e v`; each port as `-p v`; each volume as `-v v`; `-w workdir`; `-u user`; then `image`; then `command…`. (Order: flags, then image, then command — image must precede init args.) +- [ ] **Step 4: Run** → PASS. +- [ ] **Step 5: Commit** `feat(backend): RunConfiguration with argv builder`. + +### Task 2.2: `BuildConfiguration` + `arguments` + +**Files:** +- Create: `Sources/CapsuleBackend/BuildConfiguration.swift` +- Test: `Tests/CapsuleUnitTests/BuildConfigurationTests.swift` + +- [ ] **Step 1: Failing tests**: +```swift +func testBuildArgvDefaults() { + let c = BuildConfiguration(contextDirectory: URL(fileURLWithPath: "/proj"), + tag: "app:dev", dockerfile: nil, buildArgs: [], noCache: false, plainProgress: false) + XCTAssertEqual(c.arguments, ["build", "--tag", "app:dev", "/proj"]) +} +func testBuildArgvFull() { + let c = BuildConfiguration(contextDirectory: URL(fileURLWithPath: "/proj"), + tag: "app:dev", dockerfile: "docker/Dockerfile", buildArgs: ["VER=1"], + noCache: true, plainProgress: true) + XCTAssertEqual(c.arguments, + ["build", "--tag", "app:dev", "--file", "docker/Dockerfile", + "--build-arg", "VER=1", "--no-cache", "--progress", "plain", "/proj"]) +} +``` +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement** `arguments`: `["build", "--tag", tag]`; `--file dockerfile`; each build-arg as `--build-arg v`; `--no-cache` if set; `--progress plain` if plainProgress; then `contextDirectory.path`. +- [ ] **Step 4: Run** → PASS. **Step 5: Commit** `feat(backend): BuildConfiguration with argv builder`. + +### Task 2.3: `ContainerFileEntry` value type + +**Files:** +- Create: `Sources/CapsuleBackend/ContainerFileEntry.swift` +- Test: folded into Task 2.7 (parsing). + +- [ ] **Step 1:** Implement `struct ContainerFileEntry: Sendable, Equatable, Identifiable { let name; let isDirectory; let size: Int64?; let mode: String?; var id: String { name } }`. **Commit** with Task 2.7. + +### Task 2.4: Port additions on `ContainerBackend` + +**Files:** +- Modify: `Sources/CapsuleBackend/ContainerBackend.swift` +- Test: compile-driven (MockBackend in 2.6 must satisfy them). + +**Interfaces (Produces):** +```swift +func runContainer(_ config: RunConfiguration) async throws -> String +func buildImage(_ config: BuildConfiguration) -> AsyncThrowingStream +func copyToContainer(source: URL, containerID: String, containerPath: String) async throws +func copyFromContainer(containerID: String, containerPath: String, destination: URL) async throws +func fetchLogs(container id: String, tail: Int?, boot: Bool) async throws -> [OutputLine] +func listContainerDirectory(id: String, path: String) async throws -> [ContainerFileEntry] +``` +- [ ] **Step 1:** Add the six methods to the protocol (under the Containers / Images sections). **Step 2:** `swift build` fails until adapters conform (expected — implemented next). **Step 3: Commit** with Task 2.6 (don't leave the tree uncompilable in its own commit; bundle 2.4+2.5+2.6). + +### Task 2.5: `CLICommand` factories + `CLIContainerBackend` impls + +**Files:** +- Modify: `Sources/CapsuleCLIBackend/CLICommand.swift`, `Sources/CapsuleCLIBackend/CLIContainerBackend.swift` +- Test: `Tests/CapsuleUnitTests/CLICommandTests.swift`, `Tests/CapsuleUnitTests/CLIContainerBackendTests.swift` + +**Interfaces:** +- Consumes: `RunConfiguration.arguments`, `BuildConfiguration.arguments`, `runChecked`, `runner.run`, `runner.stream`, `runRaw`. + +- [ ] **Step 1: Failing tests** (CLICommand + backend via `StubProcessRunner`): +```swift +// CLICommandTests +func testCopyToArgvUsesCopySubcommand() { + XCTAssertEqual(CLICommand.copy(source: "/h/f.txt", destination: "c1:/app/f.txt"), + ["copy", "/h/f.txt", "c1:/app/f.txt"]) +} +func testFetchLogsArgvWithTail() { + XCTAssertEqual(CLICommand.fetchLogs(container: "c1", tail: 100, boot: false), + ["logs", "-n", "100", "c1"]) +} +func testListDirectoryArgvUsesExecLs() { + XCTAssertEqual(CLICommand.listDirectory(id: "c1", path: "/etc"), + ["exec", "c1", "ls", "-la", "/etc"]) +} +// CLIContainerBackendTests +func testRunContainerReturnsParsedID() async throws { + let stub = StubProcessRunner() + stub.result = CommandResult(exitCode: 0, stdout: "abc123def\n", stderr: "") + let id = try await makeBackend(stub).runContainer( + RunConfiguration(image: "nginx", name: nil, command: [], env: [], publishPorts: [], + volumes: [], workdir: nil, user: nil, interactive: false, tty: false, + detach: true, remove: false)) + XCTAssertEqual(id, "abc123def") + XCTAssertEqual(stub.lastCall, ["run", "-d", "nginx"]) +} +func testBuildImageStreamsAndBuildsArgv() async throws { + let stub = StubProcessRunner() + stub.streamLines = [OutputLine(source: .stdout, text: "Step 1/2")] + var got: [String] = [] + for try await l in makeBackend(stub).buildImage(BuildConfiguration( + contextDirectory: URL(fileURLWithPath: "/p"), tag: "t:1", dockerfile: nil, + buildArgs: [], noCache: false, plainProgress: false)) { got.append(l.text) } + XCTAssertEqual(got, ["Step 1/2"]) + XCTAssertEqual(stub.lastCall, ["build", "--tag", "t:1", "/p"]) +} +func testListDirectoryParsesLsLeniently() async throws { + let stub = StubProcessRunner() + stub.result = CommandResult(exitCode: 0, + stdout: "total 8\ndrwxr-xr-x 2 root root 4096 Jun 1 00:00 bin\n-rw-r--r-- 1 root root 220 Jun 1 00:00 .bashrc\n", + stderr: "") + let rows = try await makeBackend(stub).listContainerDirectory(id: "c1", path: "/") + XCTAssertTrue(rows.contains { $0.name == "bin" && $0.isDirectory }) + XCTAssertTrue(rows.contains { $0.name == ".bashrc" && !$0.isDirectory }) +} +``` +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement**: + - `CLICommand.run(_ c: RunConfiguration) -> [String] { c.arguments }`, `build(_:)` likewise; `copy(source:destination:)`→`["copy", source, destination]`; `fetchLogs(container:tail:boot:)`→`ArgumentBuilder("logs").option("--boot", enabled: boot).flag("-n", tail.map(String.init)).adding(id)`; `listDirectory(id:path:)`→`["exec", id, "ls", "-la", path]`. + - `CLIContainerBackend.runContainer`: `let r = try await runChecked(CLICommand.run(config)); return r.stdout.split(separator: "\n").last.map(String.init)?.trimmingCharacters(in: .whitespaces) ?? ""`. + - `buildImage`: `runner.stream(CLICommand.build(config), environment: [:])`. + - `copyToContainer`: `_ = try await runChecked(CLICommand.copy(source: source.path, destination: "\(containerID):\(containerPath)"))`; `copyFromContainer` mirror (`"\(containerID):\(containerPath)"` source, `destination.path` dest). + - `fetchLogs`: run `CLICommand.fetchLogs`, split stdout into `OutputLine(.stdout)` lines. + - `listContainerDirectory`: `let out = try await runRaw(CLICommand.listDirectory(...))`; if `out.exitCode != 0` throw `BackendError.nonZeroExit`; parse each line: skip `total …`; split on whitespace; first char `d` ⇒ directory; size = field index 4 if numeric; name = the remainder after field 8 (join to preserve spaces). Lenient: ignore malformed lines. +- [ ] **Step 4: Run** both suites → PASS. +- [ ] **Step 5: Commit** `feat(backend): run/build/copy/fetchLogs/listDirectory CLI adapter + argv`. + +### Task 2.6: `MockBackend` implementations + +**Files:** +- Modify: `Sources/CapsuleBackend/MockBackend.swift` +- Test: `Tests/CapsuleUnitTests/MockBackendTests.swift` + +- [ ] **Step 1: Failing tests** — assert each new method records its inputs / returns seeded data and honors `failure`: +```swift +func testMockRunContainerRecordsConfigAndReturnsID() async throws { + let mock = MockBackend() + let id = try await mock.runContainer(RunConfiguration(image: "nginx", name: "web", + command: [], env: [], publishPorts: [], volumes: [], workdir: nil, user: nil, + interactive: false, tty: false, detach: true, remove: false)) + XCTAssertEqual(mock.lastRunConfig?.image, "nginx") + XCTAssertFalse(id.isEmpty) +} +func testMockCopyRecordsEndpoints() async throws { /* lastCopy == (.toContainer, url, "c1", "/app") */ } +func testMockListDirectoryReturnsSeeded() async throws { /* seeded entries */ } +func testMockBuildStreamsSeededLines() async throws { /* seededStream-style */ } +``` +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement** — add stored `lastRunConfig`, `lastBuildConfig`, `lastCopy: (direction, URL, String, String)?`, `lastListedPath`, seeded `containerFiles: [ContainerFileEntry]`. `runContainer` records + returns a synthetic id (e.g. `"mock-\(config.image)"`), honoring `failure`. `buildImage` → seeded stream. `copy*` record. `fetchLogs` → seeded `logLines`. `listContainerDirectory` → seeded `containerFiles` (or throw `failure`). +- [ ] **Step 4: Run** → PASS. **Step 5: Commit** `feat(backend): MockBackend run/build/copy/logs/listDirectory`. + +--- + +## Phase 3 — Run (model + sheet + triage) + +### Task 3.1: `RunDraft` + `RunModel.validatedConfiguration` + `commandPreview` + +**Files:** +- Create: `Sources/CapsuleDomain/RunModel.swift` +- Test: `Tests/CapsuleUnitTests/RunModelTests.swift` + +**Interfaces:** +- Produces: `struct RunDraft` (image, name, `portRows: [String]`, `envRows: [String]`, `volumeRows: [String]`, workdir, command string, interactive, tty, detach, remove); `RunModel.draft`; `func validatedConfiguration() -> Result`; `var commandPreview: String`. + +- [ ] **Step 1: Failing tests**: +```swift +func testValidationRejectsEmptyImage() { + let m = RunModel(backend: MockBackend(), taskCenter: TaskCenter()) + m.draft.image = " " + if case .failure(let e) = m.validatedConfiguration(), + case .invalidInput(let field, _) = e { XCTAssertEqual(field, "image") } + else { XCTFail() } +} +func testCommandPreviewReflectsToggles() { + let m = RunModel(backend: MockBackend(), taskCenter: TaskCenter()) + m.draft.image = "alpine"; m.draft.interactive = true; m.draft.tty = true + XCTAssertEqual(m.commandPreview, "container run -i -t alpine") +} +func testCommandSplittingHandlesQuotedArgs() { + let m = RunModel(backend: MockBackend(), taskCenter: TaskCenter()) + m.draft.image = "alpine"; m.draft.command = "sh -c \"echo hi\"" + if case .success(let c) = m.validatedConfiguration() { + XCTAssertEqual(c.command, ["sh", "-c", "echo hi"]) + } else { XCTFail() } +} +``` +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement** — `validatedConfiguration` trims image (empty ⇒ `.invalidInput(field:"image")`), drops blank rows, splits `command` with a small shell-like tokenizer (respect double quotes), builds the `RunConfiguration`. `commandPreview` = `"container " + (try? config).arguments.joined(" ")` or `"container run"` while image is empty. +- [ ] **Step 4: Run** → PASS. **Step 5: Commit** `feat(run): RunModel draft validation + command preview`. + +### Task 3.2: `RunModel.runDetached` / `runInTerminal` + triage state + +**Files:** +- Modify: `Sources/CapsuleDomain/RunModel.swift` +- Test: `Tests/CapsuleUnitTests/RunModelTests.swift` + +**Interfaces:** +- Consumes: `taskCenter`, `launchOrCopy` (injected `terminalAvailable`/`launchTerminal`/`copyCommand` like `ContainerLifecycleModel`), `reloadList`. +- Produces: `runDetached()` (registers `.run` task, onSuccess reload), `runInTerminal()` (emits `TerminalRequest(kind:.runInteractive)`), `lastFailedConfig`, triage helpers `resolveImageReference`, `retryInTerminal()`. + +- [ ] **Step 1: Failing tests**: +```swift +func testRunDetachedRegistersTask() async { + let center = TaskCenter() + let m = RunModel(backend: MockBackend(), taskCenter: center) + m.draft.image = "nginx"; m.draft.detach = true + m.runDetached(); await center.tasks.first?.wait() + XCTAssertEqual(center.tasks.first?.kind, .run) +} +func testRunInTerminalEmitsInteractiveRequest() { + var launched: TerminalRequest? + let m = RunModel(backend: MockBackend(), taskCenter: TaskCenter(), + terminalAvailable: { true }, launchTerminal: { launched = $0 }) + m.draft.image = "alpine" + m.runInTerminal() + XCTAssertEqual(launched?.argv, ["container", "run", "-i", "-t", "alpine"]) + XCTAssertEqual(launched?.kind, .runInteractive) +} +``` +- [ ] **Step 2: Run** → FAIL (also add `.runInteractive` to `TerminalRequest.Kind`). +- [ ] **Step 3: Implement** — add `.runInteractive` case to `TerminalRequest.Kind`; `runInTerminal` forces `interactive=true,tty=true,detach=false`, builds config, `launchOrCopy(TerminalRequest(containerID:nil, title:"Run · \(image)", argv:["container"]+config.arguments, kind:.runInteractive))`. `runDetached` forces `detach=true`, `taskCenter.runAsync(kind:.run, title:"Run \(image)", onSuccess: reloadList) { _ = try await backend.runContainer(config) }`, and on failure stores `lastFailedConfig` for triage. +- [ ] **Step 4: Run** → PASS. **Step 5: Commit** `feat(run): detached-run task + interactive terminal launch + triage state`. + +### Task 3.3: `QuickRunSheet` + Run-Image context + triage view (UI) + +**Files:** +- Create: `Sources/CapsuleUI/QuickRunSheet.swift`, `Sources/CapsuleUI/RunFailureTriageView.swift` +- Modify: `Sources/CapsuleUI/ImageListView.swift` (Run Image… toolbar/context, `ImageSheet.run(image:)` case) + +- [ ] **Step 1:** `QuickRunSheet` — bound to `RunModel.draft`: image field, name, dynamic +/- rows for ports/env/volumes (each a `TextField` list with add/remove), workdir, command, toggles (Interactive (i+t) / Detach / Remove), a monospaced **Command preview** line (`model.commandPreview`), inline validation error, and a Run button → `runInTerminal()` when interactive else `runDetached()` then dismiss. Detach + interactive are mutually exclusive (interactive disables detach). +- [ ] **Step 2:** `ImageListView` — add `.run(image:)` to `ImageSheet`, a **Run Image…** context item + toolbar button passing the row's reference; present `QuickRunSheet` with the image prefilled. +- [ ] **Step 3:** `RunFailureTriageView` — given a failed `.run` task + the `RunModel`, show three buttons: **Resolve Image** (opens Pull sheet for `lastFailedConfig.image`), **Inspect Logs** (reveals the task transcript / logs), **Retry in Terminal** (`model.retryInTerminal()`), plus the raw transcript. +- [ ] **Step 4:** `swift build` + `make format`/`lint` clean. **Step 5: Commit** `feat(run): Quick Run sheet, Run Image action, failure triage panel`. + +--- + +## Phase 4 — Build (model + sheet) + +### Task 4.1: `BuildDraft` + `BuildModel` (presets, build, retryPlain, validation) + +**Files:** +- Create: `Sources/CapsuleDomain/BuildModel.swift` +- Test: `Tests/CapsuleUnitTests/BuildModelTests.swift` + +**Interfaces:** +- Produces: `struct BuildDraft` (contextDirectory: URL?, tag, dockerfile, `buildArgRows: [String]`, noCache, preset: `BuildPreset`); `enum BuildPreset { case standard, noCache, plainProgress }`; `validatedConfiguration() -> Result`; `build()` (streaming `.build` task, onSuccess reload images); `retryPlain(_ task:)`. + +- [ ] **Step 1: Failing tests**: +```swift +func testValidationRequiresContextAndTag() { + let m = BuildModel(backend: MockBackend(), taskCenter: TaskCenter()) + if case .failure(let e) = m.validatedConfiguration(), + case .invalidInput(let field, _) = e { XCTAssertEqual(field, "context") } else { XCTFail() } +} +func testPresetMapsToConfig() { + let m = BuildModel(backend: MockBackend(), taskCenter: TaskCenter()) + m.draft.contextDirectory = URL(fileURLWithPath: "/p"); m.draft.tag = "t:1" + m.draft.preset = .plainProgress + if case .success(let c) = m.validatedConfiguration() { XCTAssertTrue(c.plainProgress) } else { XCTFail() } +} +func testBuildRegistersStreamingTask() async { + let center = TaskCenter() + let m = BuildModel(backend: MockBackend(), taskCenter: center) + m.draft.contextDirectory = URL(fileURLWithPath: "/p"); m.draft.tag = "t:1" + m.build(); await center.tasks.first?.wait() + XCTAssertEqual(center.tasks.first?.kind, .build) +} +``` +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement** — validation: nil context ⇒ `.invalidInput(field:"context")`; empty tag ⇒ `.invalidInput(field:"tag")`. Preset sets `noCache`/`plainProgress` (noCache preset ⇒ noCache true; plainProgress ⇒ plainProgress true; explicit toggles OR with preset). `build()` → `taskCenter.runStreaming(kind:.build, title:"Build \(tag)", onSuccess: reloadList) { backend.buildImage(config) }`, storing the task. `retryPlain(task)` rebuilds config with `plainProgress=true` and re-runs (new task or reuse). +- [ ] **Step 4: Run** → PASS. **Step 5: Commit** `feat(build): BuildModel with presets, streaming build, plain-progress retry`. + +### Task 4.2: `BuildSheet` (folder drop, presets, live pane, export, plain retry) + +**Files:** +- Create: `Sources/CapsuleUI/BuildSheet.swift` +- Modify: `Sources/CapsuleUI/ImageListView.swift` (Build… toolbar + `ImageSheet.build`) + +- [ ] **Step 1:** `BuildSheet` — a **drop zone** (`.onDrop(of: [.fileURL])` resolving a folder URL into `draft.contextDirectory`, plus a **Choose…** `NSOpenPanel` with `canChooseDirectories`), the chosen path shown; tag field (required marker); Dockerfile field; +/- build-arg rows; preset `Picker`; no-cache toggle; a **Build** button → `model.build()`. While the build task runs/finishes, embed `TaskTranscriptView(task:)` (full streaming output, never hidden). Footer: **Export Transcript…** (`NSSavePanel` writing `task.transcriptText`) and, on failure, **Retry with plain progress** (`model.retryPlain(task)`). +- [ ] **Step 2:** `ImageListView` — `Build…` toolbar button → `activeSheet = .build`. +- [ ] **Step 3:** `swift build` + format/lint clean. **Step 4: Commit** `feat(build): Build sheet with folder drop, live transcript, export + plain retry`. + +--- + +## Phase 5 — Exec / interactive + Open-in-Terminal fallback + +### Task 5.1: Exec + machine-shell entry points on `ContainerLifecycleModel` + +**Files:** +- Modify: `Sources/CapsuleDomain/ContainerLifecycleModel.swift` +- Test: `Tests/CapsuleUnitTests/ContainerLifecycleModelTests.swift` (+ `TerminalRequestTests.swift`) + +**Interfaces:** +- Produces: `execShell(id:command:)` (custom command, default `["sh"]`) and `openMachineShell(name:)`. + +- [ ] **Step 1: Failing tests**: +```swift +func testExecShellWithCustomCommand() { + var req: TerminalRequest? + let m = ContainerLifecycleModel(backend: MockBackend(), + terminalAvailable: { true }, launchTerminal: { req = $0 }) + m.execShell(id: "c1", command: ["bash", "-l"]) + XCTAssertEqual(req?.argv, ["container", "exec", "-it", "c1", "bash", "-l"]) +} +func testMachineShellArgv() { + var req: TerminalRequest? + let m = ContainerLifecycleModel(backend: MockBackend(), + terminalAvailable: { true }, launchTerminal: { req = $0 }) + m.openMachineShell(name: "default") + XCTAssertEqual(req?.argv, ["container", "machine", "run", "-it", "-n", "default"]) +} +``` +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement** — `execShell(id:command:)` builds `["container","exec","-it",id] + command` via `launchOrCopy` (kind `.execShell`); keep existing `openShell` as `execShell(id:command:["sh"])`. `openMachineShell(name:)` → `["container","machine","run","-it","-n",name]` (kind `.execShell`). +- [ ] **Step 4: Run** → PASS. **Step 5: Commit** `feat(exec): custom exec command + machine shell terminal entry points`. + +### Task 5.2: `ExecSheet` + Open-in-Terminal.app fallback + +**Files:** +- Create: `Sources/CapsuleUI/ExecSheet.swift` +- Modify: `Sources/CapsuleUI/ContainerListView.swift` (Exec… context/toolbar, `ContainerSheet.exec(id:)`), `Sources/CapsuleApp/AppEnvironment.swift` (inject an `openInTerminalApp` closure into the copy fallback path) + +**Interfaces:** +- Consumes: `lifecycle.execShell(id:command:)`. + +- [ ] **Step 1:** `ExecSheet` — container id (read-only), command field (default `sh`), interactive+tty toggle (on by default), a command preview, Run → `lifecycle.execShell(id:command: tokenized)` then dismiss. +- [ ] **Step 2:** `ContainerListView` — Exec… context item + `ContainerSheet.exec(id:)`. +- [ ] **Step 3:** In `AppEnvironment.live()`, extend the terminal fallback: keep clipboard copy, and add an **Open in Terminal.app** path — write argv to a temp `.command` script (chmod +x) and `NSWorkspace.shared.open` it (or `open -a Terminal`). Expose this as a `ShellActions`/lifecycle seam so the AttachConsole/StartAttach "terminal unavailable" affordance and the exec sheet can offer it. (Only `CapsuleApp` touches `NSWorkspace`.) +- [ ] **Step 4:** `swift build` + format/lint. **Step 5: Commit** `feat(exec): Exec sheet + Open-in-Terminal.app detach fallback`. + +--- + +## Phase 6 — Logs (model + pane + detachable window) + +### Task 6.1: `LogsModel` + +**Files:** +- Create: `Sources/CapsuleDomain/LogsModel.swift` +- Test: `Tests/CapsuleUnitTests/LogsModelTests.swift` + +**Interfaces:** +- Produces: `LogsModel` with `lines: [LogLine]`, `follow`, `tail: Int?`, `boot`, `searchText`, `filteredLines`, `start(id:)`, `stop()`, `transcriptText`, `containerID: String?`. + +- [ ] **Step 1: Failing tests**: +```swift +func testSnapshotPopulatesLines() async { + let mock = MockBackend(); mock.logLines = [OutputLine(source: .stdout, text: "hello")] + let m = LogsModel(backend: mock); m.follow = false + await m.startAndWait(id: "c1") // test helper that awaits the load task + XCTAssertTrue(m.lines.contains { $0.text == "hello" }) +} +func testSearchFilters() { + let m = LogsModel(backend: MockBackend()) + m.appendForTest(["alpha", "beta", "alpha-2"]); m.searchText = "alpha" + XCTAssertEqual(m.filteredLines.map(\.text), ["alpha", "alpha-2"]) +} +func testStopCancelsFollow() async { /* neverEndingLogStream → start(follow) → stop() ends */ } +``` +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement** — `start(id:)`: cancel prior task; if `follow` use `backend.followLogs` else `backend.fetchLogs(container:id, tail:tail, boot:boot)`; append `LogLine`s. `filteredLines` = case-insensitive contains on `searchText` (empty ⇒ all). `stop()` cancels. `transcriptText` joins lines. Provide test helpers `startAndWait`/`appendForTest`. +- [ ] **Step 4: Run** → PASS. **Step 5: Commit** `feat(logs): LogsModel with follow/snapshot/tail/boot/search/transcript`. + +### Task 6.2: `LogsPaneView` + detachable `LogWindow` scene + menu + +**Files:** +- Create: `Sources/CapsuleUI/LogsPaneView.swift`, `Sources/CapsuleUI/LogWindow.swift` +- Modify: `Sources/CapsuleApp/CapsuleScene.swift` (add `Window`), `Sources/CapsuleApp/CapsuleCommands.swift` (View ▸ Open Log Window), `Sources/CapsuleApp/AppEnvironment.swift` (add `logsModel`) + +- [ ] **Step 1:** `LogsPaneView(model:)` — toolbar row: Follow toggle, Tail field (number), Boot toggle, `.searchable`/search field, **Save…** (`NSSavePanel` `.log`/`.txt` writing `transcriptText`), and an **Open in Window** button; a monospaced auto-scrolling scrollback of `filteredLines`. Reused by both the pane and the window. +- [ ] **Step 2:** `LogWindow` + `Window("Logs", id: LogWindow.id) { LogsPaneView(model: logsModel) }` in `CapsuleScene`; a `View ▸ Open Log Window` command that opens it (`@Environment(\.openWindow)`), and a per-container **Logs** action that sets `logsModel.start(id:)` then opens the window. +- [ ] **Step 3:** `swift build` + format/lint. **Step 4: Commit** `feat(logs): logs pane + detachable log window with follow/search/save`. + +--- + +## Phase 7 — Copy (model + sheet + file browser) + +### Task 7.1: `CopyModel` (direction, validation, copy task, browse) + +**Files:** +- Create: `Sources/CapsuleDomain/CopyModel.swift` +- Test: `Tests/CapsuleUnitTests/CopyModelTests.swift` + +**Interfaces:** +- Produces: `enum CopyDirection { case toContainer, fromContainer }`; `CopyModel` with `direction`, `hostURL: URL?`, `containerID`, `containerPath`, `validationMessage: String?`, `canCopy: Bool`, `copy()` (registers `.copy` task), `browse(path:) async -> [ContainerFileEntry]`. + +- [ ] **Step 1: Failing tests**: +```swift +func testValidationRequiresAbsoluteContainerPathAndEndpoints() { + let m = CopyModel(backend: MockBackend(), taskCenter: TaskCenter()) + m.containerID = "c1"; m.containerPath = "relative"; m.hostURL = URL(fileURLWithPath: "/h") + XCTAssertFalse(m.canCopy) + XCTAssertNotNil(m.validationMessage) // explains id:/abs/path requirement + m.containerPath = "/app" + XCTAssertTrue(m.canCopy) +} +func testCopyToContainerRegistersTaskAndCallsBackend() async { + let mock = MockBackend(); let center = TaskCenter() + let m = CopyModel(backend: mock, taskCenter: center) + m.direction = .toContainer; m.hostURL = URL(fileURLWithPath: "/h/f"); m.containerID = "c1"; m.containerPath = "/app/f" + m.copy(); await center.tasks.first?.wait() + XCTAssertEqual(center.tasks.first?.kind, .copy) + XCTAssertEqual(mock.lastCopy?.containerID, "c1") +} +``` +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement** — `canCopy`/`validationMessage`: require `hostURL != nil`, non-empty `containerID`, and `containerPath` starting with `/` (else message `Container path must be absolute, e.g. c1:/app/file`). `copy()` → `taskCenter.runAsync(kind:.copy, title:"Copy")` calling `copyToContainer`/`copyFromContainer` by direction. `browse(path:)` → `try? await backend.listContainerDirectory(id:containerID, path:path) ?? []`. +- [ ] **Step 4: Run** → PASS. **Step 5: Commit** `feat(copy): CopyModel with path validation, copy task, container browse`. + +### Task 7.2: `CopySheet` + host/container panels + drag/drop + +**Files:** +- Create: `Sources/CapsuleUI/CopySheet.swift` +- Modify: `Sources/CapsuleUI/ContainerListView.swift` (Copy Files… context/toolbar, `ContainerSheet.copy(id:)`) + +- [ ] **Step 1:** `CopySheet` — a direction segmented control; a **host panel** (drop target + Choose… via `NSOpenPanel`, showing the chosen path) and a **container panel** (id field prefilled, path field with inline example text `c1:/app/file`, and a **Browse** disclosure listing `ContainerFileEntry` rows from `model.browse(path:)` — tapping a row sets `containerPath`); drag a host file onto the container panel sets `hostURL`. A validation line (`model.validationMessage`) and a **Copy** button gated on `model.canCopy`. +- [ ] **Step 2:** `ContainerListView` — Copy Files… context item + `ContainerSheet.copy(id:)`. +- [ ] **Step 3:** `swift build` + format/lint. **Step 4: Commit** `feat(copy): copy sheet with host/container panels, drag-drop, path examples`. + +--- + +## Phase 8 — Composition, arch-guard, full verification + +### Task 8.1: Wire new models into the composition root + +**Files:** +- Modify: `Sources/CapsuleApp/AppEnvironment.swift`, `Sources/CapsuleApp/CapsuleScene.swift`, `Sources/CapsuleUI/AppShellView.swift`, `Sources/CapsuleUI/RootView.swift`, `Sources/CapsuleUI/ContentColumnView.swift` +- Test: `Tests/CapsuleUnitTests/CompositionTests.swift`, `Tests/CapsuleUnitTests/AppEnvironmentActionsTests.swift` + +**Interfaces:** +- Produces: `AppEnvironment.runModel/buildModel/logsModel/copyModel`; threaded through `CapsuleScene` → `RootView` → `AppShellView`. + +- [ ] **Step 1: Failing test** (composition exposes models + export now registers a task): +```swift +func testEnvironmentExposesM7Models() { + let env = AppEnvironment.live() + XCTAssertNotNil(env.runModel); XCTAssertNotNil(env.buildModel) + XCTAssertNotNil(env.logsModel); XCTAssertNotNil(env.copyModel) +} +``` +- [ ] **Step 2: Run** → FAIL. +- [ ] **Step 3: Implement** — construct the four models in `live()` sharing `taskCenter`, normalize, `shell.appendActivity`, reloads, and the terminal seams (reuse the lifecycle model's `terminalAvailable`/`launchTerminal`/`copyCommand`); inject `taskCenter` into `ContainerLifecycleModel` (export) and `SystemStatusModel` (start). Thread the models through `CapsuleScene`/`RootView`/`AppShellView`; surface the sheets from `ContainerListView`/`ImageListView` and the logs window from `CapsuleScene`. +- [ ] **Step 4: Run** the suite → PASS. +- [ ] **Step 5: Commit** `feat(app): wire Run/Build/Logs/Copy models + log window into composition`. + +### Task 8.2: Arch-guard, format, full suite + +- [ ] **Step 1:** `make arch` (or `swift test --filter ArchitectureGuardTests`) → PASS (no UI→Backend, no Domain→Process; confirm the new files respect it — e.g. `AppEnvironment` is the only `NSWorkspace` site). +- [ ] **Step 2:** `make format` then `make lint` → clean. +- [ ] **Step 3:** `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test` → all green (integration tests may skip). +- [ ] **Step 4:** `make app` builds the bundle. +- [ ] **Step 5: Commit** any formatting `chore: swift-format` if needed. + +### Task 8.3: Live GUI smoke + adversarial review + +- [ ] **Step 1:** Launch the app (`make run` or open the built bundle). With the container service running, smoke each surface: a **detached run** of `alpine echo hi` (task appears, succeeds, list reloads); an **interactive run** of `alpine sh` (terminal tab); a **build** of a tiny context (live transcript, export, plain-progress retry on a forced failure); **exec -it** into a running container; **logs** follow + search + save + detach window; **copy** a host file into a container with path validation + browse; **cancel** a long task; verify export + system-start now show as tasks. +- [ ] **Step 2:** Dispatch an adversarial subagent code review (as M6 did) over the diff; fix any Critical/High findings (e.g. argv edge cases, cancel races, drag/drop URL resolution, terminal teardown). +- [ ] **Step 3:** Update the milestone memory; push the branch; prepare the PR body (gh may be unavailable → save body to scratch + provide the compare URL). + +## Self-Review (plan vs spec) + +- **Activity model** (cancel/progress/transcript/retry/queue, export+system-start tasks): Tasks 1.1–1.6 ✓. +- **Run** (quick sheet, inspector=preview, Run Image…, terminal attach, triage): Tasks 3.1–3.3 ✓. +- **Build** (folder drop, presets, live stream, export, plain retry): Tasks 2.2, 4.1–4.2 ✓. +- **Exec/interactive** (terminal, custom command, machine shell, Open-in-Terminal fallback, rerun): Tasks 5.1–5.2 (rerun via existing Restart banner) ✓. +- **Logs** (follow/search/save, detach window): Tasks 6.1–6.2 ✓. +- **Copy** (path validation, drag/drop, container browser, examples): Tasks 2.3/2.5, 7.1–7.2 ✓. +- **Backend** additions + Mock + argv tests: Tasks 2.1–2.6 ✓. +- **Composition + arch + verify**: Tasks 8.1–8.3 ✓. +- Type consistency: `RunConfiguration.arguments`, `BuildConfiguration.arguments`, `OperationKind.{build,run,export,systemStart,copy}`, `TaskState.cancelled`, `TerminalRequest.Kind.runInteractive`, `ContainerFileEntry`, model names (`RunModel/BuildModel/LogsModel/CopyModel`) used consistently across tasks. No placeholder steps. + From 7484cc7b10ada1e2cc2152e3761c0e220b695a10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barr=C4=B1=C5=9Fcan=20Aslan?= Date: Mon, 29 Jun 2026 00:55:42 +0300 Subject: [PATCH 03/13] feat(tasks): cancel, .cancelled state, new kinds, determinate progress - TaskState gains a neutral .cancelled (not a failure; grey, still retryable) - OperationKind gains build/run/export/systemStart/copy - ProgressParser promotes a task to determinate progress on a clean NN% line - TaskCenter.cancel() cancels the driver (terminates the child via stream teardown) - Activity pane + TaskTranscriptView: Stop button + linear progress Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QRY8sjZCmM87FEsjo8A4he --- Sources/CapsuleDomain/ProgressParser.swift | 45 +++++++++++ Sources/CapsuleDomain/TaskCenter.swift | 50 +++++++++++- Sources/CapsuleDomain/TaskState.swift | 5 +- Sources/CapsuleUI/ActivityPaneView.swift | 17 +++- Sources/CapsuleUI/TaskTranscriptView.swift | 37 ++++++++- .../ProgressParserTests.swift | 37 +++++++++ Tests/CapsuleUnitTests/TaskCenterTests.swift | 77 +++++++++++++++++++ 7 files changed, 259 insertions(+), 9 deletions(-) create mode 100644 Sources/CapsuleDomain/ProgressParser.swift create mode 100644 Tests/CapsuleUnitTests/ProgressParserTests.swift diff --git a/Sources/CapsuleDomain/ProgressParser.swift b/Sources/CapsuleDomain/ProgressParser.swift new file mode 100644 index 0000000..c44dca1 --- /dev/null +++ b/Sources/CapsuleDomain/ProgressParser.swift @@ -0,0 +1,45 @@ +// +// ProgressParser.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// Best-effort determinate progress: when a streamed CLI line carries a clean `NN%`, the +// task center promotes the task to determinate progress; otherwise progress stays nil +// (indeterminate) and the transcript remains the source of truth. Pure — no Process, no UI. + +import Foundation + +public enum ProgressParser { + /// Extracts a `0.0...1.0` fraction from the last `NN%` token in `line`, or nil when the + /// line carries no percentage. The last match wins so a line like `"a 10% b 80%"` reports + /// the most recent figure; values are clamped to `0...100`. + public static func fraction(in line: String) -> Double? { + let scalars = Array(line.unicodeScalars) + var lastPercent: Int? + var index = 0 + while index < scalars.count { + guard scalars[index] == "%" else { + index += 1 + continue + } + // Walk backwards over up to three digits immediately preceding the '%'. + var digitsEnd = index + var digitsStart = index + while digitsStart > 0, scalars[digitsStart - 1].properties.numericType == .decimal, + index - digitsStart < 3 + { + digitsStart -= 1 + } + if digitsStart < digitsEnd { + let digits = String(String.UnicodeScalarView(scalars[digitsStart.. Void)? + var onCancel: (() -> Void)? var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -30,11 +31,20 @@ struct TaskTranscriptView: View { .buttonStyle(.borderless) .help("Copy transcript") } - if isFailed, let onRetry { + if task.state.isActive, task.isCancellable, let onCancel { + Button("Stop", role: .destructive, action: onCancel) + .controlSize(.small) + } + if isRetryable, let onRetry { Button("Retry", action: onRetry) } } + if let progress = determinateProgress { + ProgressView(value: progress) + .progressViewStyle(.linear) + } + if !task.transcript.isEmpty { ScrollView { VStack(alignment: .leading, spacing: 1) { @@ -59,6 +69,21 @@ struct TaskTranscriptView: View { return false } + /// Retry is offered on both failure and a user cancel (a cancelled task is resumable). + private var isRetryable: Bool { + switch task.state { + case .failed, .cancelled: return true + default: return false + } + } + + /// The determinate fraction when the task reported a parseable percentage, else nil + /// (the indeterminate spinner stands in for unknown progress). + private var determinateProgress: Double? { + if case let .running(progress) = task.state { return progress } + return nil + } + /// stdout is primary; stderr is dimmed while running/succeeding (many CLIs emit progress /// there) and only turns red once the task has actually failed, so a successful pull /// never looks alarming. @@ -72,12 +97,18 @@ struct TaskTranscriptView: View { switch task.state { case .idle, .queued: Image(systemName: "clock").foregroundStyle(.secondary) - case .running: - ProgressView().controlSize(.small) + case let .running(progress): + if progress == nil { + ProgressView().controlSize(.small) + } else { + Image(systemName: "arrow.down.circle").foregroundStyle(.tint) + } case .succeeded: Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) case .failed: Image(systemName: "xmark.circle.fill").foregroundStyle(.red) + case .cancelled: + Image(systemName: "stop.circle.fill").foregroundStyle(.secondary) } } } diff --git a/Tests/CapsuleUnitTests/ProgressParserTests.swift b/Tests/CapsuleUnitTests/ProgressParserTests.swift new file mode 100644 index 0000000..38e5fa5 --- /dev/null +++ b/Tests/CapsuleUnitTests/ProgressParserTests.swift @@ -0,0 +1,37 @@ +// +// ProgressParserTests.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// + +import XCTest + +@testable import CapsuleDomain + +final class ProgressParserTests: XCTestCase { + func testParsesCleanPercent() throws { + try XCTAssertEqual( + XCTUnwrap(ProgressParser.fraction(in: "Downloading 42%")), 0.42, accuracy: 0.001) + try XCTAssertEqual( + XCTUnwrap(ProgressParser.fraction(in: "[100%] done")), 1.0, accuracy: 0.001) + try XCTAssertEqual( + XCTUnwrap(ProgressParser.fraction(in: "0% complete")), 0.0, accuracy: 0.001) + } + + func testIgnoresLinesWithoutPercent() { + XCTAssertNil(ProgressParser.fraction(in: "Step 2/5 : RUN echo hi")) + XCTAssertNil(ProgressParser.fraction(in: "pulling layer sha256:abc")) + XCTAssertNil(ProgressParser.fraction(in: "100 percent but no symbol")) + } + + func testTakesLastPercentAndClamps() throws { + try XCTAssertEqual( + XCTUnwrap(ProgressParser.fraction(in: "a 10% b 80%")), 0.80, accuracy: 0.001) + try XCTAssertEqual(XCTUnwrap(ProgressParser.fraction(in: "999%")), 1.0, accuracy: 0.001) + } + + func testLoneSymbolIsNotProgress() { + XCTAssertNil(ProgressParser.fraction(in: "discount of % applied")) + } +} diff --git a/Tests/CapsuleUnitTests/TaskCenterTests.swift b/Tests/CapsuleUnitTests/TaskCenterTests.swift index ec56e7e..6366196 100644 --- a/Tests/CapsuleUnitTests/TaskCenterTests.swift +++ b/Tests/CapsuleUnitTests/TaskCenterTests.swift @@ -105,6 +105,83 @@ final class TaskCenterTests: XCTestCase { XCTAssertTrue(task.transcript.contains { $0.text.contains("invalid archive") }) } + func testCancelledStateIsNotActive() { + XCTAssertFalse(TaskState.cancelled.isActive) + XCTAssertTrue(TaskState.running(progress: nil).isActive) + } + + func testNewOperationKindsHaveTitlesAndSymbols() { + for kind in [OperationKind.build, .run, .export, .systemStart, .copy] { + XCTAssertFalse(kind.title.isEmpty) + XCTAssertFalse(kind.symbolName.isEmpty) + } + XCTAssertEqual(OperationKind.build.title, "Build") + XCTAssertEqual(OperationKind.run.title, "Run") + } + + func testCancelStopsRunningTaskAndMarksCancelled() async { + let center = TaskCenter() + let gate = AsyncStream.makeStream() + let task = center.runStreaming(kind: .build, title: "build") { + AsyncThrowingStream { continuation in + let pump = Task { + for await line in gate.stream { continuation.yield(line) } + continuation.finish() + } + continuation.onTermination = { _ in pump.cancel() } + } + } + await Task.yield() + center.cancel(task) + await task.wait() + + XCTAssertEqual(task.state, .cancelled) + XCTAssertFalse(task.state.isActive) + } + + func testStreamingUpdatesDeterminateProgress() async { + let center = TaskCenter() + let task = center.runStreaming(kind: .pull, title: "pull") { + AsyncThrowingStream { continuation in + continuation.yield(OutputLine(source: .stdout, text: "Downloading 50%")) + continuation.finish() + } + } + await task.wait() + XCTAssertTrue(task.transcriptText.contains("50%")) + XCTAssertEqual(task.state, .succeeded) + } + + func testRetryOfCancelledTaskReRunsToSuccess() async { + let center = TaskCenter() + let flip = Flip(true) + let gate = AsyncStream.makeStream() + let task = center.runStreaming(kind: .build, title: "build") { + AsyncThrowingStream { continuation in + if flip.shouldFail { + let pump = Task { + for await line in gate.stream { continuation.yield(line) } + continuation.finish() + } + continuation.onTermination = { _ in pump.cancel() } + } else { + continuation.yield(OutputLine(source: .stdout, text: "built")) + continuation.finish() + } + } + } + await Task.yield() + center.cancel(task) + await task.wait() + XCTAssertEqual(task.state, .cancelled) + + flip.shouldFail = false + center.retry(task) + await task.wait() + XCTAssertEqual(task.state, .succeeded) + XCTAssertEqual(task.transcript.map(\.text), ["built"]) + } + func testClearFinishedRemovesOnlyCompletedTasks() async { let center = TaskCenter() let done = center.runAsync(kind: .save, title: "Save") {} From 5c7b90ad7c3b02d82da18c744cd95ea618f38d5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barr=C4=B1=C5=9Fcan=20Aslan?= Date: Mon, 29 Jun 2026 00:57:45 +0300 Subject: [PATCH 04/13] feat(tasks): export + system-start register Activity tasks Both route through the TaskCenter when wired (visible, cancellable, raw transcript on failure); the no-center path is kept for previews/tests. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QRY8sjZCmM87FEsjo8A4he --- .../ContainerLifecycleModel.swift | 28 +++++++++++++--- Sources/CapsuleDomain/SystemStatusModel.swift | 32 ++++++++++++++----- .../ContainerLifecycleModelTests.swift | 9 ++++++ .../SystemStatusModelTests.swift | 10 ++++++ 4 files changed, 66 insertions(+), 13 deletions(-) diff --git a/Sources/CapsuleDomain/ContainerLifecycleModel.swift b/Sources/CapsuleDomain/ContainerLifecycleModel.swift index 24d4af5..42c514e 100644 --- a/Sources/CapsuleDomain/ContainerLifecycleModel.swift +++ b/Sources/CapsuleDomain/ContainerLifecycleModel.swift @@ -29,6 +29,7 @@ public final class ContainerLifecycleModel { private let terminalAvailable: @MainActor () -> Bool private let copyCommand: @MainActor ([String]) -> Void private let launchTerminal: @MainActor (TerminalRequest) -> Void + private let taskCenter: TaskCenter? private let settleAttempts: Int private let settleDelay: Duration private let hangTimeout: Duration @@ -46,6 +47,7 @@ public final class ContainerLifecycleModel { terminalAvailable: @escaping @MainActor () -> Bool = { false }, copyCommand: @escaping @MainActor ([String]) -> Void = { _ in }, launchTerminal: @escaping @MainActor (TerminalRequest) -> Void = { _ in }, + taskCenter: TaskCenter? = nil, settleAttempts: Int = 4, settleDelay: Duration = .milliseconds(400), hangTimeout: Duration = .seconds(8) @@ -58,6 +60,7 @@ public final class ContainerLifecycleModel { self.terminalAvailable = terminalAvailable self.copyCommand = copyCommand self.launchTerminal = launchTerminal + self.taskCenter = taskCenter self.settleAttempts = settleAttempts self.settleDelay = settleDelay self.hangTimeout = hangTimeout @@ -353,13 +356,28 @@ public final class ContainerLifecycleModel { } public func export(id: String, to url: URL) async { + // With a task center wired, the export registers an Activity task: the long archive + // write is visible, cancellable, and keeps its raw transcript on failure. Without + // one (previews/tests), fall back to the inline notice path. + guard let taskCenter else { + busy.insert(id) + defer { busy.remove(id) } + do { + try await backend.exportContainer(id: id, to: url) + onActivity("Exported “\(id)” to \(url.lastPathComponent).") + } catch { + notice = LifecycleNotice(detail: normalize(error).detail) + } + return + } busy.insert(id) - defer { busy.remove(id) } - do { - try await backend.exportContainer(id: id, to: url) + let task = taskCenter.runAsync(kind: .export, title: "Export \(id)") { + [backend] in try await backend.exportContainer(id: id, to: url) + } + await task.wait() + busy.remove(id) + if case .succeeded = task.state { onActivity("Exported “\(id)” to \(url.lastPathComponent).") - } catch { - notice = LifecycleNotice(detail: normalize(error).detail) } } diff --git a/Sources/CapsuleDomain/SystemStatusModel.swift b/Sources/CapsuleDomain/SystemStatusModel.swift index 8ae3691..f432faf 100644 --- a/Sources/CapsuleDomain/SystemStatusModel.swift +++ b/Sources/CapsuleDomain/SystemStatusModel.swift @@ -27,16 +27,19 @@ public final class SystemStatusModel { private let backend: any ContainerBackend private let normalize: @Sendable (any Error) -> CapsuleError private let onActivity: @MainActor (String) -> Void + private let taskCenter: TaskCenter? public init( backend: any ContainerBackend, normalize: @escaping @Sendable (any Error) -> CapsuleError = SystemStatusModel .defaultNormalize, - onActivity: @escaping @MainActor (String) -> Void = { _ in } + onActivity: @escaping @MainActor (String) -> Void = { _ in }, + taskCenter: TaskCenter? = nil ) { self.backend = backend self.normalize = normalize self.onActivity = onActivity + self.taskCenter = taskCenter } /// The fallback normalizer used when the composition root injects none: pass through @@ -78,16 +81,29 @@ public final class SystemStatusModel { } } - /// Starts the service, then re-probes so the banner reflects the new state. + /// Starts the service, then re-probes so the banner reflects the new state. When a task + /// center is wired, the start registers an Activity task (so the long boot is visible, + /// cancellable, and its raw transcript is kept); either way the banner is driven by a + /// fresh re-probe, so a failed start surfaces as `.unavailable`/`.stopped` from reality + /// rather than a reconstructed error. public func startServices() async { health = .checking - do { - try await backend.startSystem() - onActivity("Started container services.") - await refreshStatus() - } catch { - health = .unavailable(normalize(error).detail) + guard let taskCenter else { + do { + try await backend.startSystem() + onActivity("Started container services.") + await refreshStatus() + } catch { + health = .unavailable(normalize(error).detail) + } + return + } + let task = taskCenter.runAsync(kind: .systemStart, title: "Start container services") { + [backend] in try await backend.startSystem() } + await task.wait() + if case .succeeded = task.state { onActivity("Started container services.") } + await refreshStatus() } /// Stops the service, then re-probes. diff --git a/Tests/CapsuleUnitTests/ContainerLifecycleModelTests.swift b/Tests/CapsuleUnitTests/ContainerLifecycleModelTests.swift index ecb4344..84cbe54 100644 --- a/Tests/CapsuleUnitTests/ContainerLifecycleModelTests.swift +++ b/Tests/CapsuleUnitTests/ContainerLifecycleModelTests.swift @@ -191,6 +191,15 @@ final class ContainerLifecycleModelTests: XCTestCase { XCTAssertEqual(backend.lastExportURL, URL(fileURLWithPath: "/tmp/c.tar")) } + func testExportRegistersActivityTask() async { + let center = TaskCenter() + let m = ContainerLifecycleModel(backend: MockBackend(), taskCenter: center) + await m.export(id: "a1b2c3d4", to: URL(fileURLWithPath: "/tmp/c.tar")) + XCTAssertEqual(center.tasks.count, 1) + XCTAssertEqual(center.tasks.first?.kind, .export) + XCTAssertEqual(center.tasks.first?.state, .succeeded) + } + func testStopMarksStopped() async { let backend = MockBackend() let m = model(backend: backend, state: { _ in .stopped }) diff --git a/Tests/CapsuleUnitTests/SystemStatusModelTests.swift b/Tests/CapsuleUnitTests/SystemStatusModelTests.swift index 71aaab8..546d798 100644 --- a/Tests/CapsuleUnitTests/SystemStatusModelTests.swift +++ b/Tests/CapsuleUnitTests/SystemStatusModelTests.swift @@ -54,6 +54,16 @@ final class SystemStatusModelTests: XCTestCase { XCTAssertTrue(model.health.isRunning) } + func testStartServicesRegistersActivityTask() async { + let center = TaskCenter() + let model = SystemStatusModel( + backend: MockBackend(systemRunState: .stopped), taskCenter: center) + await model.startServices() + XCTAssertEqual(center.tasks.count, 1) + XCTAssertEqual(center.tasks.first?.kind, .systemStart) + XCTAssertTrue(model.health.isRunning) + } + func testStopServicesFlipsRunningToStopped() async { let model = SystemStatusModel(backend: MockBackend()) await model.refreshStatus() From baaeae74a1390e906e5edf5c4c3ecb7f000e0f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barr=C4=B1=C5=9Fcan=20Aslan?= Date: Mon, 29 Jun 2026 01:02:43 +0300 Subject: [PATCH 05/13] feat(backend): run/build/exec-ls/copy/logs port + CLI adapter + mock - RunConfiguration/BuildConfiguration own a computed argv (single source of truth shared by the adapter and the domain terminal-request builder) - ContainerFileEntry + lenient ls -la parser for the copy file browser - ContainerBackend: runContainer (detached, returns id), buildImage (stream), copyTo/FromContainer, fetchLogs (snapshot/tail/boot), listContainerDirectory - CLICommand emits canonical 'copy' (not 'cp'); MockBackend records all inputs Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QRY8sjZCmM87FEsjo8A4he --- .../CapsuleBackend/BuildConfiguration.swift | 51 +++++++++++ Sources/CapsuleBackend/ContainerBackend.swift | 23 +++++ .../CapsuleBackend/ContainerFileEntry.swift | 26 ++++++ Sources/CapsuleBackend/MockBackend.swift | 73 ++++++++++++++++ Sources/CapsuleBackend/RunConfiguration.swift | 78 +++++++++++++++++ Sources/CapsuleCLIBackend/CLICommand.swift | 30 +++++++ .../CLIContainerBackend.swift | 55 ++++++++++++ Sources/CapsuleCLIBackend/OutputParser.swift | 29 +++++++ .../BuildConfigurationTests.swift | 36 ++++++++ Tests/CapsuleUnitTests/CLICommandTests.swift | 28 ++++++ .../CLIContainerBackendTests.swift | 87 +++++++++++++++++++ Tests/CapsuleUnitTests/MockBackendTests.swift | 65 ++++++++++++++ .../RunConfigurationTests.swift | 48 ++++++++++ 13 files changed, 629 insertions(+) create mode 100644 Sources/CapsuleBackend/BuildConfiguration.swift create mode 100644 Sources/CapsuleBackend/ContainerFileEntry.swift create mode 100644 Sources/CapsuleBackend/RunConfiguration.swift create mode 100644 Tests/CapsuleUnitTests/BuildConfigurationTests.swift create mode 100644 Tests/CapsuleUnitTests/RunConfigurationTests.swift diff --git a/Sources/CapsuleBackend/BuildConfiguration.swift b/Sources/CapsuleBackend/BuildConfiguration.swift new file mode 100644 index 0000000..e9a9893 --- /dev/null +++ b/Sources/CapsuleBackend/BuildConfiguration.swift @@ -0,0 +1,51 @@ +// +// BuildConfiguration.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// A typed description of a `container build` invocation; `arguments` is the single source of +// truth for its argv. A tag is required at the model layer (the CLI's default `--tag` is a +// random UUID, which would orphan the image). Flags mirror `container build` v1.0.0. + +import Foundation + +public struct BuildConfiguration: Sendable, Equatable { + public var contextDirectory: URL + public var tag: String + /// Path to a Dockerfile/Containerfile (`-f`); nil uses the context default. + public var dockerfile: String? + /// Build-time variables as `KEY=value` tokens (`--build-arg`). + public var buildArgs: [String] + public var noCache: Bool + /// When true, requests `--progress plain` — the "plain progress" retry that keeps raw, + /// uncollapsed build output for diagnosis. + public var plainProgress: Bool + + public init( + contextDirectory: URL, + tag: String, + dockerfile: String? = nil, + buildArgs: [String] = [], + noCache: Bool = false, + plainProgress: Bool = false + ) { + self.contextDirectory = contextDirectory + self.tag = tag + self.dockerfile = dockerfile + self.buildArgs = buildArgs + self.noCache = noCache + self.plainProgress = plainProgress + } + + /// The argv after `container`: `build` flags then the context directory (positional last). + public var arguments: [String] { + var argv = ["build", "--tag", tag] + if let dockerfile { argv += ["--file", dockerfile] } + for value in buildArgs { argv += ["--build-arg", value] } + if noCache { argv.append("--no-cache") } + if plainProgress { argv += ["--progress", "plain"] } + argv.append(contextDirectory.path) + return argv + } +} diff --git a/Sources/CapsuleBackend/ContainerBackend.swift b/Sources/CapsuleBackend/ContainerBackend.swift index d56d22a..34bdf42 100644 --- a/Sources/CapsuleBackend/ContainerBackend.swift +++ b/Sources/CapsuleBackend/ContainerBackend.swift @@ -75,6 +75,25 @@ public protocol ContainerBackend: Sendable { /// Streams a container's logs line-by-line, following until cancelled. func followLogs(container id: String) -> AsyncThrowingStream + /// Fetches a snapshot of a container's logs (no follow). `tail` limits to the last N + /// lines; `boot` requests the boot log instead of stdio. + func fetchLogs(container id: String, tail: Int?, boot: Bool) async throws -> [OutputLine] + + /// Runs a container. Only the **detached** path goes through the port (interactive + /// `run -it` is a terminal session); returns the new container id (parsed from stdout). + func runContainer(_ config: RunConfiguration) async throws -> String + + /// Copies a host file/folder into a running container (`copy :`). + func copyToContainer(source: URL, containerID: String, containerPath: String) async throws + + /// Copies a path out of a running container to the host (`copy : `). + func copyFromContainer( + containerID: String, containerPath: String, destination: URL + ) async throws + + /// Lists a directory inside a running container (best-effort `exec ls -la `). + func listContainerDirectory(id: String, path: String) async throws -> [ContainerFileEntry] + // MARK: Images func listImages() async throws -> [ImageSummary] @@ -100,6 +119,10 @@ public protocol ContainerBackend: Sendable { /// best-effort reclaimed summary. func pruneImages(all: Bool) async throws -> PruneResult + /// Builds an image from a Dockerfile/Containerfile, streaming progress line-by-line. The + /// raw transcript is the source of truth — build logs are never collapsed or hidden. + func buildImage(_ config: BuildConfiguration) -> AsyncThrowingStream + // MARK: Volumes / networks / registries / machines / builder func listVolumes() async throws -> [VolumeSummary] diff --git a/Sources/CapsuleBackend/ContainerFileEntry.swift b/Sources/CapsuleBackend/ContainerFileEntry.swift new file mode 100644 index 0000000..093a795 --- /dev/null +++ b/Sources/CapsuleBackend/ContainerFileEntry.swift @@ -0,0 +1,26 @@ +// +// ContainerFileEntry.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// One entry in a container's directory listing. apple/container has no file-listing verb, +// so the adapter derives these from `exec ls -la ` (best-effort, lenient). + +import Foundation + +public struct ContainerFileEntry: Sendable, Equatable, Identifiable { + public var name: String + public var isDirectory: Bool + public var size: Int64? + public var mode: String? + + public var id: String { name } + + public init(name: String, isDirectory: Bool, size: Int64? = nil, mode: String? = nil) { + self.name = name + self.isDirectory = isDirectory + self.size = size + self.mode = mode + } +} diff --git a/Sources/CapsuleBackend/MockBackend.swift b/Sources/CapsuleBackend/MockBackend.swift index eb53cc3..e0fca26 100644 --- a/Sources/CapsuleBackend/MockBackend.swift +++ b/Sources/CapsuleBackend/MockBackend.swift @@ -59,6 +59,20 @@ public final class MockBackend: ContainerBackend, @unchecked Sendable { public private(set) var statsCallCount = 0 /// How many `followLogs` streams have terminated (incl. via cancellation). public private(set) var logStreamTerminations = 0 + /// The configuration of the most recent `runContainer` call. + public private(set) var lastRunConfig: RunConfiguration? + /// The configuration of the most recent `buildImage` call. + public private(set) var lastBuildConfig: BuildConfiguration? + /// The direction/endpoints of the most recent copy call. + public private(set) var lastCopy: + (direction: CopyDirectionTag, hostURL: URL, containerID: String, containerPath: String)? + /// The id/path of the most recent `listContainerDirectory` call. + public private(set) var lastListedDirectory: (id: String, path: String)? + /// Seeded directory entries returned by `listContainerDirectory`. + public var containerFiles: [ContainerFileEntry] = MockBackend.sampleContainerFiles + + /// Which way a recorded copy went (the mock has no real filesystem). + public enum CopyDirectionTag: Sendable, Equatable { case toContainer, fromContainer } public init( containers: [ContainerSummary] = MockBackend.sampleContainers, @@ -220,6 +234,52 @@ public final class MockBackend: ContainerBackend, @unchecked Sendable { logStreamTerminations += 1 } + public func fetchLogs(container id: String, tail: Int?, boot: Bool) async throws -> [OutputLine] + { + try withState { state in + let lines = state.logLines + if let tail, tail < lines.count { return Array(lines.suffix(tail)) } + return lines + } + } + + public func runContainer(_ config: RunConfiguration) async throws -> String { + try withState { state in + state.lastRunConfig = config + let id = "mock-\(state.containers.count + 1)" + state.containers.append( + ContainerSummary( + id: id, name: config.name ?? id, image: config.image, state: "running", + ip: "192.168.64.99", createdAt: "2026-06-29T00:00:00Z")) + return id + } + } + + public func copyToContainer( + source: URL, containerID: String, containerPath: String + ) async throws { + try withState { state in + state.lastCopy = (.toContainer, source, containerID, containerPath) + } + } + + public func copyFromContainer( + containerID: String, containerPath: String, destination: URL + ) async throws { + try withState { state in + state.lastCopy = (.fromContainer, destination, containerID, containerPath) + } + } + + public func listContainerDirectory( + id: String, path: String + ) async throws -> [ContainerFileEntry] { + try withState { state in + state.lastListedDirectory = (id, path) + return state.containerFiles + } + } + // MARK: - Images public func listImages() async throws -> [ImageSummary] { @@ -252,6 +312,13 @@ public final class MockBackend: ContainerBackend, @unchecked Sendable { seededStream() } + public func buildImage(_ config: BuildConfiguration) -> AsyncThrowingStream { + lock.lock() + lastBuildConfig = config + lock.unlock() + return seededStream() + } + public func saveImage(references: [String], to url: URL, platform: String?) async throws { try withState { state in state.lastSavedURL = url } } @@ -410,6 +477,12 @@ extension MockBackend { OutputLine(source: .stdout, text: "ready"), ] + public static let sampleContainerFiles: [ContainerFileEntry] = [ + ContainerFileEntry(name: "bin", isDirectory: true, size: 4096, mode: "drwxr-xr-x"), + ContainerFileEntry(name: "etc", isDirectory: true, size: 4096, mode: "drwxr-xr-x"), + ContainerFileEntry(name: ".bashrc", isDirectory: false, size: 220, mode: "-rw-r--r--"), + ] + public static let sampleStatsDefault: [ContainerStatsSample] = [ ContainerStatsSample( id: "a1b2c3d4", cpuUsageUsec: 1_000_000, memoryUsageBytes: 64_000_000, diff --git a/Sources/CapsuleBackend/RunConfiguration.swift b/Sources/CapsuleBackend/RunConfiguration.swift new file mode 100644 index 0000000..c718d5c --- /dev/null +++ b/Sources/CapsuleBackend/RunConfiguration.swift @@ -0,0 +1,78 @@ +// +// RunConfiguration.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// A typed description of a `container run` invocation. Its `arguments` is the single source +// of truth for the argv (after the `container` executable): the CLI adapter uses it for a +// detached run, and the domain reuses it verbatim to build the interactive `TerminalRequest` +// for `run -it`. Flags mirror `container run` v1.0.0 (verified against `--help`). + +import Foundation + +public struct RunConfiguration: Sendable, Equatable { + public var image: String + /// Init-process arguments placed after the image. + public var command: [String] + /// Environment assignments as `KEY=value` tokens. + public var env: [String] + /// Port mappings, e.g. `8080:80` or `127.0.0.1:5432:5432/tcp`. + public var publishPorts: [String] + /// Volume/bind mounts, e.g. `/host:/container` or `vol:/data:ro`. + public var volumes: [String] + public var name: String? + public var workdir: String? + public var user: String? + public var interactive: Bool + public var tty: Bool + public var detach: Bool + public var remove: Bool + + public init( + image: String, + name: String? = nil, + command: [String] = [], + env: [String] = [], + publishPorts: [String] = [], + volumes: [String] = [], + workdir: String? = nil, + user: String? = nil, + interactive: Bool = false, + tty: Bool = false, + detach: Bool = false, + remove: Bool = false + ) { + self.image = image + self.name = name + self.command = command + self.env = env + self.publishPorts = publishPorts + self.volumes = volumes + self.workdir = workdir + self.user = user + self.interactive = interactive + self.tty = tty + self.detach = detach + self.remove = remove + } + + /// The argv after `container`: flags, then the image, then the init-process command. + /// The image must precede its arguments, so flags are emitted first. + public var arguments: [String] { + var argv = ["run"] + if detach { argv.append("-d") } + if interactive { argv.append("-i") } + if tty { argv.append("-t") } + if remove { argv.append("--rm") } + if let name { argv += ["--name", name] } + for value in env { argv += ["-e", value] } + for port in publishPorts { argv += ["-p", port] } + for volume in volumes { argv += ["-v", volume] } + if let workdir { argv += ["-w", workdir] } + if let user { argv += ["-u", user] } + argv.append(image) + argv += command + return argv + } +} diff --git a/Sources/CapsuleCLIBackend/CLICommand.swift b/Sources/CapsuleCLIBackend/CLICommand.swift index 9a0350a..e280bb7 100644 --- a/Sources/CapsuleCLIBackend/CLICommand.swift +++ b/Sources/CapsuleCLIBackend/CLICommand.swift @@ -84,8 +84,38 @@ public enum CLICommand { ArgumentBuilder("logs").option("--follow", enabled: true).adding(id).arguments } + public static func fetchLogs(container id: String, tail: Int?, boot: Bool) -> [String] { + ArgumentBuilder("logs") + .option("--boot", enabled: boot) + .flag("-n", tail.map(String.init)) + .adding(id) + .arguments + } + + /// Detached/interactive run argv comes straight from the typed configuration, the single + /// source of truth shared with the domain's terminal-request builder. + public static func run(_ config: RunConfiguration) -> [String] { + config.arguments + } + + /// Canonical `copy` subcommand (not the `cp` alias). Endpoints are `container:path` or a + /// local path, already composed by the caller. + public static func copy(source: String, destination: String) -> [String] { + ArgumentBuilder("copy").adding(source, destination).arguments + } + + /// Lists a directory inside a running container; apple/container has no file-listing verb, + /// so we exec `ls -la` and parse it leniently. + public static func listDirectory(id: String, path: String) -> [String] { + ArgumentBuilder("exec").adding(id, "ls", "-la", path).arguments + } + // MARK: - Images + public static func build(_ config: BuildConfiguration) -> [String] { + config.arguments + } + public static func listImages() -> [String] { ArgumentBuilder("image", "list").flag("--format", "json").arguments } diff --git a/Sources/CapsuleCLIBackend/CLIContainerBackend.swift b/Sources/CapsuleCLIBackend/CLIContainerBackend.swift index b0789e7..035fe2c 100644 --- a/Sources/CapsuleCLIBackend/CLIContainerBackend.swift +++ b/Sources/CapsuleCLIBackend/CLIContainerBackend.swift @@ -144,6 +144,57 @@ public struct CLIContainerBackend: ContainerBackend { runner.stream(CLICommand.followLogs(container: id), environment: [:]) } + public func fetchLogs(container id: String, tail: Int?, boot: Bool) async throws -> [OutputLine] + { + let output = try await runChecked( + CLICommand.fetchLogs(container: id, tail: tail, boot: boot)) + return output.stdout + .split(separator: "\n", omittingEmptySubsequences: false) + .map { OutputLine(source: .stdout, text: String($0)) } + } + + public func runContainer(_ config: RunConfiguration) async throws -> String { + // apple/container prints the new container id on stdout for a detached run; take the + // last non-empty line as the id (any progress lines precede it). + let output = try await runChecked(CLICommand.run(config)) + let id = + output.stdout + .split(separator: "\n", omittingEmptySubsequences: true) + .last + .map { $0.trimmingCharacters(in: .whitespaces) } ?? "" + return id + } + + public func copyToContainer( + source: URL, containerID: String, containerPath: String + ) async throws { + _ = try await runChecked( + CLICommand.copy(source: source.path, destination: "\(containerID):\(containerPath)")) + } + + public func copyFromContainer( + containerID: String, containerPath: String, destination: URL + ) async throws { + _ = try await runChecked( + CLICommand.copy( + source: "\(containerID):\(containerPath)", destination: destination.path)) + } + + public func listContainerDirectory( + id: String, path: String + ) async throws -> [ContainerFileEntry] { + // Use the non-throwing escape hatch so a non-zero exit (no such path / no `ls` / not + // running) surfaces a typed error the domain can degrade gracefully on, with the raw + // stderr preserved for diagnosis. + let output = try await runRaw(CLICommand.listDirectory(id: id, path: path)) + guard output.exitCode == 0 else { + throw BackendError.nonZeroExit( + command: commandDescription(CLICommand.listDirectory(id: id, path: path)), + code: output.exitCode, stderr: output.stderr) + } + return OutputParser.parseDirectoryListing(output.stdout) + } + // MARK: - Images public func listImages() async throws -> [ImageSummary] { @@ -175,6 +226,10 @@ public struct CLIContainerBackend: ContainerBackend { CLICommand.pushImage(reference: reference, platform: platform), environment: [:]) } + public func buildImage(_ config: BuildConfiguration) -> AsyncThrowingStream { + runner.stream(CLICommand.build(config), environment: [:]) + } + public func saveImage(references: [String], to url: URL, platform: String?) async throws { _ = try await runChecked( CLICommand.saveImage(references: references, to: url, platform: platform)) diff --git a/Sources/CapsuleCLIBackend/OutputParser.swift b/Sources/CapsuleCLIBackend/OutputParser.swift index 5827cfc..cc29369 100644 --- a/Sources/CapsuleCLIBackend/OutputParser.swift +++ b/Sources/CapsuleCLIBackend/OutputParser.swift @@ -35,6 +35,35 @@ public enum OutputParser { } } + // MARK: - Container files + + /// Parses `ls -la` output into directory entries. Lenient: skips the `total N` header + /// and any line that is not an ls long-format row. The mode column's leading character + /// distinguishes directories (`d`) and symlinks (`l`); size is column 5; the name is + /// everything after the date columns (so names with spaces survive). `.`/`..` are + /// dropped; a symlink's `name -> target` keeps just the link name. + public static func parseDirectoryListing(_ text: String) -> [ContainerFileEntry] { + var entries: [ContainerFileEntry] = [] + for rawLine in text.split(separator: "\n", omittingEmptySubsequences: true) { + let line = String(rawLine) + if line.hasPrefix("total ") { continue } + let fields = line.split(separator: " ", omittingEmptySubsequences: true).map( + String.init) + guard fields.count >= 9, let first = fields[0].first, "dlbcps-".contains(first) else { + continue + } + var name = fields[8...].joined(separator: " ") + if first == "l", let arrow = name.range(of: " -> ") { + name = String(name[.. busybox + """, stderr: "") + let rows = try await makeBackend(stub).listContainerDirectory(id: "c1", path: "/") + XCTAssertEqual(stub.lastCall, ["exec", "c1", "ls", "-la", "/"]) + XCTAssertTrue(rows.contains { $0.name == "bin" && $0.isDirectory && $0.size == 4096 }) + XCTAssertTrue(rows.contains { $0.name == ".bashrc" && !$0.isDirectory }) + XCTAssertTrue(rows.contains { $0.name == "sh" && !$0.isDirectory }) + } + + func testListDirectoryThrowsOnNonZeroExit() async { + let stub = StubProcessRunner() + stub.result = CommandResult(exitCode: 1, stdout: "", stderr: "no such file or directory") + do { + _ = try await makeBackend(stub).listContainerDirectory(id: "c1", path: "/nope") + XCTFail("expected a thrown error on non-zero exit") + } catch let BackendError.nonZeroExit(_, code, stderr) { + XCTAssertEqual(code, 1) + XCTAssertTrue(stderr.contains("no such file")) + } catch { + XCTFail("unexpected error: \(error)") + } + } } diff --git a/Tests/CapsuleUnitTests/MockBackendTests.swift b/Tests/CapsuleUnitTests/MockBackendTests.swift index 49eb16c..9338e2a 100644 --- a/Tests/CapsuleUnitTests/MockBackendTests.swift +++ b/Tests/CapsuleUnitTests/MockBackendTests.swift @@ -331,4 +331,69 @@ final class MockBackendTests: XCTestCase { XCTAssertEqual(stderr, "boom") } } + + // MARK: - M7 methods + + func testRunContainerRecordsConfigAndCreatesRunningContainer() async throws { + let backend = MockBackend() + let before = try await backend.listContainers(all: true).count + let id = try await backend.runContainer(RunConfiguration(image: "nginx", name: "web")) + XCTAssertEqual(backend.lastRunConfig?.image, "nginx") + XCTAssertFalse(id.isEmpty) + let after = try await backend.listContainers(all: true) + XCTAssertEqual(after.count, before + 1) + XCTAssertTrue(after.contains { $0.id == id && $0.state == "running" }) + } + + func testBuildImageRecordsConfigAndStreams() async throws { + let backend = MockBackend() + var lines = 0 + for try await _ in backend.buildImage( + BuildConfiguration(contextDirectory: URL(fileURLWithPath: "/p"), tag: "t:1")) + { + lines += 1 + } + XCTAssertEqual(backend.lastBuildConfig?.tag, "t:1") + XCTAssertGreaterThan(lines, 0) + } + + func testCopyRecordsEndpoints() async throws { + let backend = MockBackend() + try await backend.copyToContainer( + source: URL(fileURLWithPath: "/h/f"), containerID: "c1", containerPath: "/app/f") + XCTAssertEqual(backend.lastCopy?.direction, .toContainer) + XCTAssertEqual(backend.lastCopy?.containerID, "c1") + XCTAssertEqual(backend.lastCopy?.containerPath, "/app/f") + + try await backend.copyFromContainer( + containerID: "c2", containerPath: "/var/log", destination: URL(fileURLWithPath: "/h/l")) + XCTAssertEqual(backend.lastCopy?.direction, .fromContainer) + XCTAssertEqual(backend.lastCopy?.containerID, "c2") + } + + func testListContainerDirectoryReturnsSeededAndRecordsPath() async throws { + let backend = MockBackend() + let rows = try await backend.listContainerDirectory(id: "c1", path: "/etc") + XCTAssertEqual(backend.lastListedDirectory?.path, "/etc") + XCTAssertFalse(rows.isEmpty) + } + + func testFetchLogsTails() async throws { + let backend = MockBackend( + logLines: [ + OutputLine(source: .stdout, text: "a"), OutputLine(source: .stdout, text: "b"), + OutputLine(source: .stdout, text: "c"), + ]) + let tailed = try await backend.fetchLogs(container: "c1", tail: 2, boot: false) + XCTAssertEqual(tailed.map(\.text), ["b", "c"]) + } + + func testM7MethodsHonorInjectedFailure() async { + let backend = MockBackend() + backend.failure = BackendError.nonZeroExit(command: "x", code: 1, stderr: "boom") + do { + _ = try await backend.runContainer(RunConfiguration(image: "nginx")) + XCTFail("expected failure") + } catch {} + } } diff --git a/Tests/CapsuleUnitTests/RunConfigurationTests.swift b/Tests/CapsuleUnitTests/RunConfigurationTests.swift new file mode 100644 index 0000000..309a5d4 --- /dev/null +++ b/Tests/CapsuleUnitTests/RunConfigurationTests.swift @@ -0,0 +1,48 @@ +// +// RunConfigurationTests.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// RunConfiguration.arguments is the single source of truth for the `container run` argv — +// shared by the CLI adapter (detached) and the domain's interactive terminal request. + +import CapsuleBackend +import XCTest + +final class RunConfigurationTests: XCTestCase { + func testDetachedRunArgv() { + let config = RunConfiguration( + image: "nginx:latest", name: "web", command: [], env: ["FOO=bar"], + publishPorts: ["8080:80"], volumes: ["/h:/c"], workdir: "/app", user: nil, + interactive: false, tty: false, detach: true, remove: true) + XCTAssertEqual( + config.arguments, + [ + "run", "-d", "--rm", "--name", "web", "-e", "FOO=bar", + "-p", "8080:80", "-v", "/h:/c", "-w", "/app", "nginx:latest", + ]) + } + + func testInteractiveRunArgvWithCommand() { + let config = RunConfiguration( + image: "alpine", command: ["sh", "-c", "echo hi"], interactive: true, tty: true) + XCTAssertEqual(config.arguments, ["run", "-i", "-t", "alpine", "sh", "-c", "echo hi"]) + } + + func testMinimalRunArgv() { + XCTAssertEqual(RunConfiguration(image: "alpine").arguments, ["run", "alpine"]) + } + + func testMultipleEnvPortVolume() { + let config = RunConfiguration( + image: "img", env: ["A=1", "B=2"], publishPorts: ["80:80", "443:443"], + volumes: ["/a:/a", "/b:/b"]) + XCTAssertEqual( + config.arguments, + [ + "run", "-e", "A=1", "-e", "B=2", "-p", "80:80", "-p", "443:443", + "-v", "/a:/a", "-v", "/b:/b", "img", + ]) + } +} From c3f76f8bf4dba7972f2b84f597e1dee4dd5f7a46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barr=C4=B1=C5=9Fcan=20Aslan?= Date: Mon, 29 Jun 2026 01:04:46 +0300 Subject: [PATCH 06/13] feat(run): RunModel (draft validation, command preview, detached/terminal, triage) - CommandTokenizer splits quoted command strings into argv - validatedConfiguration() maps the draft to a RunConfiguration (field errors) - commandPreview is the live 'Run Inspector'; runDetached registers a .run task + reloads; runInTerminal emits a .runInteractive TerminalRequest (or copies) - failed detached runs keep triage state (resolve image / inspect logs / retry) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QRY8sjZCmM87FEsjo8A4he --- Sources/CapsuleDomain/CommandTokenizer.swift | 43 +++++ Sources/CapsuleDomain/RunModel.swift | 177 +++++++++++++++++++ Sources/CapsuleDomain/TerminalRequest.swift | 2 + Tests/CapsuleUnitTests/RunModelTests.swift | 101 +++++++++++ 4 files changed, 323 insertions(+) create mode 100644 Sources/CapsuleDomain/CommandTokenizer.swift create mode 100644 Sources/CapsuleDomain/RunModel.swift create mode 100644 Tests/CapsuleUnitTests/RunModelTests.swift diff --git a/Sources/CapsuleDomain/CommandTokenizer.swift b/Sources/CapsuleDomain/CommandTokenizer.swift new file mode 100644 index 0000000..2ded466 --- /dev/null +++ b/Sources/CapsuleDomain/CommandTokenizer.swift @@ -0,0 +1,43 @@ +// +// CommandTokenizer.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// Splits a user-typed command string into argv tokens, honoring single/double quotes so +// `sh -c "echo hi"` becomes ["sh", "-c", "echo hi"]. Pure; shared by Run and Exec. + +import Foundation + +public enum CommandTokenizer { + public static func tokenize(_ input: String) -> [String] { + var tokens: [String] = [] + var current = "" + var quote: Character? + var hasToken = false + + for character in input { + if let active = quote { + if character == active { + quote = nil + } else { + current.append(character) + } + } else if character == "\"" || character == "'" { + quote = character + hasToken = true + } else if character == " " || character == "\t" { + if hasToken { + tokens.append(current) + current = "" + hasToken = false + } + } else { + current.append(character) + hasToken = true + } + } + if hasToken { tokens.append(current) } + return tokens + } +} diff --git a/Sources/CapsuleDomain/RunModel.swift b/Sources/CapsuleDomain/RunModel.swift new file mode 100644 index 0000000..177da93 --- /dev/null +++ b/Sources/CapsuleDomain/RunModel.swift @@ -0,0 +1,177 @@ +// +// RunModel.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// NOTE: This module must remain free of UI and of `Foundation.Process`. Owns the Quick Run +// flow: a UI-friendly draft, validation into a `RunConfiguration`, a live command preview +// (the "Run Inspector"), and the two run paths — detached (an Activity task) and +// attached/TTY (an embedded-terminal session). A failed detached run keeps triage state. + +import CapsuleBackend +import Foundation +import Observation + +/// A UI-friendly draft of a run: raw text rows the model trims/validates into a config. +public struct RunDraft: Sendable, Equatable { + public var image: String = "" + public var name: String = "" + public var command: String = "" + public var workdir: String = "" + /// `KEY=value` rows; blank rows are ignored. + public var envRows: [String] = [] + /// `host:container[/proto]` rows; blank rows are ignored. + public var portRows: [String] = [] + /// `host:container[:ro]` rows; blank rows are ignored. + public var volumeRows: [String] = [] + public var interactive: Bool = false + public var remove: Bool = false + public var detach: Bool = false + + public init() {} + + public init(image: String) { + self.image = image + } +} + +@MainActor +@Observable +public final class RunModel { + public var draft = RunDraft() + /// The configuration of the most recent failed detached run, for the triage panel. + public private(set) var lastFailedConfig: RunConfiguration? + /// The most recent failed detached-run task (its transcript drives Inspect Logs). + public private(set) var lastFailedTask: OperationTask? + + private let backend: any ContainerBackend + private let taskCenter: TaskCenter + private let normalize: @Sendable (any Error) -> CapsuleError + private let onActivity: @MainActor (String) -> Void + private let reloadList: @MainActor () async -> Void + private let terminalAvailable: @MainActor () -> Bool + private let launchTerminal: @MainActor (TerminalRequest) -> Void + private let copyCommand: @MainActor ([String]) -> Void + + public init( + backend: any ContainerBackend, + taskCenter: TaskCenter, + normalize: @escaping @Sendable (any Error) -> CapsuleError = SystemStatusModel + .defaultNormalize, + onActivity: @escaping @MainActor (String) -> Void = { _ in }, + reloadList: @escaping @MainActor () async -> Void = {}, + terminalAvailable: @escaping @MainActor () -> Bool = { false }, + launchTerminal: @escaping @MainActor (TerminalRequest) -> Void = { _ in }, + copyCommand: @escaping @MainActor ([String]) -> Void = { _ in } + ) { + self.backend = backend + self.taskCenter = taskCenter + self.normalize = normalize + self.onActivity = onActivity + self.reloadList = reloadList + self.terminalAvailable = terminalAvailable + self.launchTerminal = launchTerminal + self.copyCommand = copyCommand + } + + /// Resets the draft, optionally prefilling the image (the contextual "Run Image…" path). + public func reset(image: String = "") { + draft = image.isEmpty ? RunDraft() : RunDraft(image: image) + lastFailedConfig = nil + lastFailedTask = nil + } + + /// Validates the draft into a `RunConfiguration`, or returns the first field error. + public func validatedConfiguration() -> Result { + let image = draft.image.trimmingCharacters(in: .whitespacesAndNewlines) + guard !image.isEmpty else { + return .failure(.invalidInput(field: "image", message: "Enter an image to run.")) + } + let name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines) + let workdir = draft.workdir.trimmingCharacters(in: .whitespacesAndNewlines) + let config = RunConfiguration( + image: image, + name: name.isEmpty ? nil : name, + command: CommandTokenizer.tokenize(draft.command), + env: cleaned(draft.envRows), + publishPorts: cleaned(draft.portRows), + volumes: cleaned(draft.volumeRows), + workdir: workdir.isEmpty ? nil : workdir, + user: nil, + interactive: draft.interactive, + tty: draft.interactive, + detach: draft.detach && !draft.interactive, + remove: draft.remove) + return .success(config) + } + + /// A live `container run …` preview — the "Run Inspector". Falls back to `container run` + /// while the image is empty so the field never shows a half-built command. + public var commandPreview: String { + switch validatedConfiguration() { + case let .success(config): + return (["container"] + config.arguments).joined(separator: " ") + case .failure: + return "container run" + } + } + + /// Runs the container attached with a TTY in the embedded terminal (or copies the command + /// when the terminal is unavailable). Forces `-i -t` and clears `--detach`. + public func runInTerminal() { + guard case var .success(config) = validatedConfiguration() else { return } + config.interactive = true + config.tty = true + config.detach = false + let argv = ["container"] + config.arguments + let request = TerminalRequest( + containerID: nil, title: "Run · \(config.image)", argv: argv, kind: .runInteractive) + if terminalAvailable() { + launchTerminal(request) + } else { + copyCommand(argv) + } + } + + /// Runs the container detached as an Activity task; reloads the container list on success + /// and keeps triage state on failure. + @discardableResult + public func runDetached() -> OperationTask? { + guard case var .success(config) = validatedConfiguration() else { return nil } + config.detach = true + config.interactive = false + config.tty = false + onActivity("Running \(config.image)…") + let task = taskCenter.runAsync( + kind: .run, title: "Run \(config.image)", + onSuccess: { [reloadList] in await reloadList() } + ) { [backend] in + _ = try await backend.runContainer(config) + } + // Record triage state as soon as the task settles into a failure. + Task { [weak self] in + await task.wait() + guard let self else { return } + if case .failed = task.state { + self.lastFailedConfig = config + self.lastFailedTask = task + } + } + return task + } + + /// The image reference of the last failed run — the Resolve Image triage action pulls it. + public var resolveImageReference: String? { lastFailedConfig?.image } + + /// Re-runs the last failed configuration in the embedded terminal (the triage retry). + public func retryInTerminal() { + guard let config = lastFailedConfig else { return } + draft.image = config.image + runInTerminal() + } + + private func cleaned(_ rows: [String]) -> [String] { + rows.map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + } +} diff --git a/Sources/CapsuleDomain/TerminalRequest.swift b/Sources/CapsuleDomain/TerminalRequest.swift index 9837b67..0f9c924 100644 --- a/Sources/CapsuleDomain/TerminalRequest.swift +++ b/Sources/CapsuleDomain/TerminalRequest.swift @@ -15,6 +15,8 @@ public struct TerminalRequest: Sendable, Equatable { case execShell case interactiveAttach case retry + /// An attached/TTY `container run -it …` launched from the Quick Run sheet. + case runInteractive } /// The container this terminal targets, when applicable. diff --git a/Tests/CapsuleUnitTests/RunModelTests.swift b/Tests/CapsuleUnitTests/RunModelTests.swift new file mode 100644 index 0000000..6bb44f6 --- /dev/null +++ b/Tests/CapsuleUnitTests/RunModelTests.swift @@ -0,0 +1,101 @@ +// +// RunModelTests.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// + +import CapsuleBackend +import XCTest + +@testable import CapsuleDomain + +@MainActor +final class RunModelTests: XCTestCase { + private func model( + backend: any ContainerBackend = MockBackend(), + terminalAvailable: @escaping @MainActor () -> Bool = { false }, + launchTerminal: @escaping @MainActor (TerminalRequest) -> Void = { _ in }, + copyCommand: @escaping @MainActor ([String]) -> Void = { _ in } + ) -> RunModel { + RunModel( + backend: backend, taskCenter: TaskCenter(), terminalAvailable: terminalAvailable, + launchTerminal: launchTerminal, copyCommand: copyCommand) + } + + func testValidationRejectsEmptyImage() { + let m = model() + m.draft.image = " " + guard case let .failure(error) = m.validatedConfiguration(), + case let .invalidInput(field, _) = error + else { return XCTFail("expected an invalidInput on image") } + XCTAssertEqual(field, "image") + } + + func testCommandPreviewReflectsToggles() { + let m = model() + m.draft.image = "alpine" + m.draft.interactive = true + XCTAssertEqual(m.commandPreview, "container run -i -t alpine") + } + + func testCommandPreviewFallsBackWhileEmpty() { + XCTAssertEqual(model().commandPreview, "container run") + } + + func testValidationTokenizesQuotedCommandAndDropsBlankRows() { + let m = model() + m.draft.image = "alpine" + m.draft.command = "sh -c \"echo hi\"" + m.draft.envRows = ["FOO=bar", " "] + guard case let .success(config) = m.validatedConfiguration() else { + return XCTFail("expected success") + } + XCTAssertEqual(config.command, ["sh", "-c", "echo hi"]) + XCTAssertEqual(config.env, ["FOO=bar"]) + } + + func testRunDetachedRegistersRunTaskAndReloads() async { + let center = TaskCenter() + var reloaded = false + let m = RunModel( + backend: MockBackend(), taskCenter: center, reloadList: { reloaded = true }) + m.draft.image = "nginx" + let task = m.runDetached() + await task?.wait() + XCTAssertEqual(center.tasks.first?.kind, .run) + XCTAssertEqual(task?.state, .succeeded) + XCTAssertTrue(reloaded) + } + + func testRunInTerminalEmitsInteractiveRequest() { + var launched: TerminalRequest? + let m = model(terminalAvailable: { true }, launchTerminal: { launched = $0 }) + m.draft.image = "alpine" + m.runInTerminal() + XCTAssertEqual(launched?.argv, ["container", "run", "-i", "-t", "alpine"]) + XCTAssertEqual(launched?.kind, .runInteractive) + } + + func testRunInTerminalCopiesWhenTerminalUnavailable() { + var copied: [String]? + let m = model(terminalAvailable: { false }, copyCommand: { copied = $0 }) + m.draft.image = "alpine" + m.runInTerminal() + XCTAssertEqual(copied, ["container", "run", "-i", "-t", "alpine"]) + } + + func testFailedDetachedRunRecordsTriageState() async { + let backend = MockBackend() + backend.failure = BackendError.nonZeroExit( + command: "run", code: 125, stderr: "no such image") + let m = RunModel(backend: backend, taskCenter: TaskCenter()) + m.draft.image = "ghost:latest" + let task = m.runDetached() + await task?.wait() + // Allow the triage-recording task to observe the settled failure. + for _ in 0..<5 where m.lastFailedConfig == nil { await Task.yield() } + XCTAssertEqual(m.resolveImageReference, "ghost:latest") + XCTAssertNotNil(m.lastFailedTask) + } +} From f5589b0039a588127da58f22d34c29876fef98ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barr=C4=B1=C5=9Fcan=20Aslan?= Date: Mon, 29 Jun 2026 01:09:55 +0300 Subject: [PATCH 07/13] feat(run): Quick Run sheet, Run Image action, failure triage panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QuickRunSheet: image/name + dynamic port/env/volume rows + command override, live command preview (Run Inspector), interactive→terminal / detached→task - RunFailureTriageView: resolve image / inspect logs (transcript) / retry in terminal - ImageListView gains a Run toolbar button + 'Run Image…' context action - composition: RunModel wired through the view chain; taskCenter now injected into SystemStatusModel + ContainerLifecycleModel for export/system-start tasks Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QRY8sjZCmM87FEsjo8A4he --- Sources/CapsuleApp/AppEnvironment.swift | 37 +++-- Sources/CapsuleApp/CapsuleScene.swift | 3 + Sources/CapsuleUI/AppShellView.swift | 6 +- Sources/CapsuleUI/ContentColumnView.swift | 3 +- Sources/CapsuleUI/ImageListView.swift | 30 +++- Sources/CapsuleUI/QuickRunSheet.swift | 159 +++++++++++++++++++ Sources/CapsuleUI/RootView.swift | 4 + Sources/CapsuleUI/RunFailureTriageView.swift | 53 +++++++ 8 files changed, 282 insertions(+), 13 deletions(-) create mode 100644 Sources/CapsuleUI/QuickRunSheet.swift create mode 100644 Sources/CapsuleUI/RunFailureTriageView.swift diff --git a/Sources/CapsuleApp/AppEnvironment.swift b/Sources/CapsuleApp/AppEnvironment.swift index e46577d..7897f57 100644 --- a/Sources/CapsuleApp/AppEnvironment.swift +++ b/Sources/CapsuleApp/AppEnvironment.swift @@ -33,6 +33,7 @@ public struct AppEnvironment { public var imageActionsModel: ImageActionsModel public var taskCenter: TaskCenter public var registriesModel: RegistriesModel + public var runModel: RunModel public var actions: ShellActions public var updater: any UpdaterController public var terminalSurfaceProvider: any TerminalSurfaceProviding @@ -48,6 +49,7 @@ public struct AppEnvironment { imageActionsModel: ImageActionsModel, taskCenter: TaskCenter, registriesModel: RegistriesModel, + runModel: RunModel, actions: ShellActions, updater: any UpdaterController, terminalSurfaceProvider: any TerminalSurfaceProviding = StubTerminalSurfaceProvider() @@ -62,6 +64,7 @@ public struct AppEnvironment { self.imageActionsModel = imageActionsModel self.taskCenter = taskCenter self.registriesModel = registriesModel + self.runModel = runModel self.actions = actions self.updater = updater self.terminalSurfaceProvider = terminalSurfaceProvider @@ -72,10 +75,12 @@ public struct AppEnvironment { let cliBackend = CLIContainerBackend() let backend: any ContainerBackend = cliBackend let shell = ShellState() + let taskCenter = TaskCenter(normalize: { ErrorNormalizer.normalize($0) }) let systemModel = SystemStatusModel( backend: backend, normalize: { ErrorNormalizer.normalize($0) }, - onActivity: { line in shell.appendActivity(line) } + onActivity: { line in shell.appendActivity(line) }, + taskCenter: taskCenter ) let workspaceModel = WorkspaceModel(backend: backend) let browserModel = ContainerBrowserModel( @@ -90,7 +95,6 @@ public struct AppEnvironment { normalize: { ErrorNormalizer.normalize($0) }, onActivity: { line in shell.appendActivity(line) } ) - let taskCenter = TaskCenter(normalize: { ErrorNormalizer.normalize($0) }) let registriesModel = RegistriesModel( backend: backend, normalize: { ErrorNormalizer.normalize($0) }, @@ -103,6 +107,13 @@ public struct AppEnvironment { reloadList: { await imageBrowserModel.refresh() }, taskCenter: taskCenter ) + let copyCommandToClipboard: @MainActor ([String]) -> Void = { argv in + let command = argv.joined(separator: " ") + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(command, forType: .string) + shell.appendActivity("Copied to clipboard: \(command)") + } let lifecycleModel = ContainerLifecycleModel( backend: backend, normalize: { ErrorNormalizer.normalize($0) }, @@ -112,14 +123,19 @@ public struct AppEnvironment { browserModel.allContainers.first { $0.id == id }?.state ?? .unknown }, terminalAvailable: { true }, - copyCommand: { argv in - let command = argv.joined(separator: " ") - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(command, forType: .string) - shell.appendActivity("Copied to clipboard: \(command)") - }, - launchTerminal: { request in shell.openTerminal(request) } + copyCommand: copyCommandToClipboard, + launchTerminal: { request in shell.openTerminal(request) }, + taskCenter: taskCenter + ) + let runModel = RunModel( + backend: backend, + taskCenter: taskCenter, + normalize: { ErrorNormalizer.normalize($0) }, + onActivity: { line in shell.appendActivity(line) }, + reloadList: { await browserModel.refresh() }, + terminalAvailable: { true }, + launchTerminal: { request in shell.openTerminal(request) }, + copyCommand: copyCommandToClipboard ) let terminalSurfaceProvider = SwiftTermSurfaceProvider(executablePath: { name in name == "container" ? cliBackend.executableURL.path : name @@ -136,6 +152,7 @@ public struct AppEnvironment { imageActionsModel: imageActionsModel, taskCenter: taskCenter, registriesModel: registriesModel, + runModel: runModel, actions: actions, updater: NoopUpdaterController(), terminalSurfaceProvider: terminalSurfaceProvider diff --git a/Sources/CapsuleApp/CapsuleScene.swift b/Sources/CapsuleApp/CapsuleScene.swift index 0f30559..13e018f 100644 --- a/Sources/CapsuleApp/CapsuleScene.swift +++ b/Sources/CapsuleApp/CapsuleScene.swift @@ -26,6 +26,7 @@ public struct CapsuleScene: Scene { @State private var imageActionsModel: ImageActionsModel @State private var taskCenter: TaskCenter @State private var registriesModel: RegistriesModel + @State private var runModel: RunModel private let actions: ShellActions private let updater: any UpdaterController private let terminalSurfaceProvider: any TerminalSurfaceProviding @@ -45,6 +46,7 @@ public struct CapsuleScene: Scene { self._imageActionsModel = State(initialValue: environment.imageActionsModel) self._taskCenter = State(initialValue: environment.taskCenter) self._registriesModel = State(initialValue: environment.registriesModel) + self._runModel = State(initialValue: environment.runModel) self.actions = environment.actions self.updater = environment.updater self.terminalSurfaceProvider = environment.terminalSurfaceProvider @@ -62,6 +64,7 @@ public struct CapsuleScene: Scene { imageBrowserModel: imageBrowserModel, imageActionsModel: imageActionsModel, taskCenter: taskCenter, + runModel: runModel, actions: actions, terminalSurfaceProvider: terminalSurfaceProvider ) diff --git a/Sources/CapsuleUI/AppShellView.swift b/Sources/CapsuleUI/AppShellView.swift index f1ee0e3..141b4de 100644 --- a/Sources/CapsuleUI/AppShellView.swift +++ b/Sources/CapsuleUI/AppShellView.swift @@ -22,6 +22,7 @@ public struct AppShellView: View { @Bindable var imageBrowserModel: ImageBrowserModel @Bindable var imageActionsModel: ImageActionsModel @Bindable var taskCenter: TaskCenter + @Bindable var runModel: RunModel let actions: ShellActions let terminalSurfaceProvider: any TerminalSurfaceProviding @@ -35,6 +36,7 @@ public struct AppShellView: View { imageBrowserModel: ImageBrowserModel, imageActionsModel: ImageActionsModel, taskCenter: TaskCenter, + runModel: RunModel, actions: ShellActions, terminalSurfaceProvider: any TerminalSurfaceProviding = StubTerminalSurfaceProvider() ) { @@ -47,6 +49,7 @@ public struct AppShellView: View { self.imageBrowserModel = imageBrowserModel self.imageActionsModel = imageActionsModel self.taskCenter = taskCenter + self.runModel = runModel self.actions = actions self.terminalSurfaceProvider = terminalSurfaceProvider } @@ -107,7 +110,8 @@ public struct AppShellView: View { lifecycleModel: lifecycleModel, statsModel: statsModel, imageBrowserModel: imageBrowserModel, - imageActionsModel: imageActionsModel + imageActionsModel: imageActionsModel, + runModel: runModel ) .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Sources/CapsuleUI/ContentColumnView.swift b/Sources/CapsuleUI/ContentColumnView.swift index 74fbd98..37876a2 100644 --- a/Sources/CapsuleUI/ContentColumnView.swift +++ b/Sources/CapsuleUI/ContentColumnView.swift @@ -20,6 +20,7 @@ struct ContentColumnView: View { let statsModel: ContainerStatsModel let imageBrowserModel: ImageBrowserModel let imageActionsModel: ImageActionsModel + let runModel: RunModel private var onRecover: (RecoveryAction) -> Void { actions.recover } @@ -44,7 +45,7 @@ struct ContentColumnView: View { case .containers: ContainerListView(model: browserModel, lifecycle: lifecycleModel, stats: statsModel) case .images: - ImageListView(model: imageBrowserModel, actions: imageActionsModel) + ImageListView(model: imageBrowserModel, actions: imageActionsModel, runModel: runModel) default: resourcePlaceholder } diff --git a/Sources/CapsuleUI/ImageListView.swift b/Sources/CapsuleUI/ImageListView.swift index 8d27698..4338166 100644 --- a/Sources/CapsuleUI/ImageListView.swift +++ b/Sources/CapsuleUI/ImageListView.swift @@ -20,12 +20,14 @@ private typealias Image = CapsuleDomain.Image struct ImageListView: View { @Bindable var model: ImageBrowserModel let actions: ImageActionsModel + @Bindable var runModel: RunModel @State private var activeSheet: ImageSheet? - init(model: ImageBrowserModel, actions: ImageActionsModel) { + init(model: ImageBrowserModel, actions: ImageActionsModel, runModel: RunModel) { self.model = model self.actions = actions + self.runModel = runModel } var body: some View { @@ -68,6 +70,13 @@ struct ImageListView: View { }, onCancel: { activeSheet = nil }) case .prune: ImagePruneSheet(actions: actions, onClose: { activeSheet = nil }) + case let .run(image): + QuickRunSheet( + model: runModel, + onResolveImage: { _ in activeSheet = .pull }, + onClose: { activeSheet = nil } + ) + .onAppear { runModel.reset(image: image) } } } } @@ -144,6 +153,9 @@ struct ImageListView: View { private func rowMenu(for ids: Set) -> some View { let targets = images(for: ids) if let single = targets.first, targets.count == 1 { + Button("Run Image…") { activeSheet = .run(image: single.reference) } + .disabled(single.isDangling) + Divider() Button("Copy Digest") { Pasteboard.copy(single.digest) } Button("Copy Reference") { Pasteboard.copy(single.reference) } .disabled(single.isDangling) @@ -166,6 +178,13 @@ struct ImageListView: View { @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { ToolbarItemGroup { + Button { + activeSheet = .run(image: selectedReference ?? "") + } label: { + Label("Run", systemImage: "play.rectangle") + } + .help("Run a container from an image") + Button { activeSheet = .pull } label: { @@ -245,6 +264,13 @@ struct ImageListView: View { private func images(for ids: Set) -> [Image] { model.allImages.filter { ids.contains($0.id) } } + + /// The reference of the single selected, non-dangling image (prefills the Run sheet). + private var selectedReference: String? { + let targets = images(for: model.selection) + guard targets.count == 1, let single = targets.first, !single.isDangling else { return nil } + return single.reference + } } /// Which image sheet is presented. @@ -255,6 +281,7 @@ enum ImageSheet: Identifiable { case load case confirm(ConfirmationRequest) case prune + case run(image: String) var id: String { switch self { @@ -264,6 +291,7 @@ enum ImageSheet: Identifiable { case .load: return "load" case let .confirm(request): return "confirm-\(request.id)" case .prune: return "prune" + case let .run(image): return "run-\(image)" } } } diff --git a/Sources/CapsuleUI/QuickRunSheet.swift b/Sources/CapsuleUI/QuickRunSheet.swift new file mode 100644 index 0000000..dca0eca --- /dev/null +++ b/Sources/CapsuleUI/QuickRunSheet.swift @@ -0,0 +1,159 @@ +// +// QuickRunSheet.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// The Quick Run sheet: image + name + dynamic port/env/volume rows + a command override and +// the run toggles, with a live `container run …` command preview (the Run Inspector). An +// interactive run hands off to the embedded terminal and dismisses; a detached run streams +// its task transcript inline and, on failure, shows the triage panel. + +import CapsuleDomain +import SwiftUI + +struct QuickRunSheet: View { + @Bindable var model: RunModel + var onResolveImage: (String) -> Void + var onClose: () -> Void + + @State private var activeTask: OperationTask? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + ScrollView { + VStack(alignment: .leading, spacing: 14) { + form + if let task = activeTask { + Divider() + runResult(task) + } + } + .padding(16) + } + Divider() + footer + } + .frame(width: 540, height: 620) + } + + private var header: some View { + HStack { + Label("Run a Container", systemImage: "play.rectangle") + .font(.headline) + Spacer() + } + .padding(12) + } + + @ViewBuilder + private var form: some View { + labeledField("Image", text: $model.draft.image, prompt: "alpine:latest") + labeledField("Name", text: $model.draft.name, prompt: "optional") + rowEditor("Ports", rows: $model.draft.portRows, example: "8080:80") + rowEditor("Environment", rows: $model.draft.envRows, example: "KEY=value") + rowEditor("Volumes", rows: $model.draft.volumeRows, example: "/host:/container") + labeledField("Working dir", text: $model.draft.workdir, prompt: "optional") + labeledField("Command", text: $model.draft.command, prompt: "optional override") + + VStack(alignment: .leading, spacing: 4) { + Toggle("Interactive (open a TTY in the terminal)", isOn: $model.draft.interactive) + Toggle("Remove on exit (--rm)", isOn: $model.draft.remove) + Toggle("Detach (run in background)", isOn: $model.draft.detach) + .disabled(model.draft.interactive) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Command preview").font(.caption).foregroundStyle(.secondary) + Text(model.commandPreview) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(CapsuleColors.activitySurface) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + + @ViewBuilder + private func runResult(_ task: OperationTask) -> some View { + if case .failed = task.state { + RunFailureTriageView( + task: task, + imageReference: model.resolveImageReference, + onResolveImage: { + if let ref = model.resolveImageReference { onResolveImage(ref) } + }, + onRetryInTerminal: { + model.retryInTerminal() + onClose() + }) + } else { + TaskTranscriptView(task: task) + } + } + + private var footer: some View { + HStack { + Button("Close", action: onClose) + .keyboardShortcut(.cancelAction) + Spacer() + Button(model.draft.interactive ? "Run in Terminal" : "Run") { + run() + } + .keyboardShortcut(.defaultAction) + .disabled(model.draft.image.trimmingCharacters(in: .whitespaces).isEmpty) + } + .padding(12) + } + + private func run() { + if model.draft.interactive { + model.runInTerminal() + onClose() + } else { + activeTask = model.runDetached() + } + } + + private func labeledField(_ label: String, text: Binding, prompt: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(label).font(.caption).foregroundStyle(.secondary) + TextField(label, text: text, prompt: Text(prompt)) + .textFieldStyle(.roundedBorder) + .labelsHidden() + } + } + + private func rowEditor( + _ label: String, rows: Binding<[String]>, example: String + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(label).font(.caption).foregroundStyle(.secondary) + Spacer() + Button { + rows.wrappedValue.append("") + } label: { + Image(systemName: "plus.circle") + } + .buttonStyle(.borderless) + .help("Add a \(label.lowercased()) row") + } + ForEach(rows.wrappedValue.indices, id: \.self) { index in + HStack { + TextField(example, text: rows[index]) + .textFieldStyle(.roundedBorder) + Button { + rows.wrappedValue.remove(at: index) + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + } + } + } + } +} diff --git a/Sources/CapsuleUI/RootView.swift b/Sources/CapsuleUI/RootView.swift index fc3782e..0b10627 100644 --- a/Sources/CapsuleUI/RootView.swift +++ b/Sources/CapsuleUI/RootView.swift @@ -24,6 +24,7 @@ public struct RootView: View { private let imageBrowserModel: ImageBrowserModel private let imageActionsModel: ImageActionsModel private let taskCenter: TaskCenter + private let runModel: RunModel private let actions: ShellActions private let terminalSurfaceProvider: any TerminalSurfaceProviding @@ -39,6 +40,7 @@ public struct RootView: View { imageBrowserModel: ImageBrowserModel, imageActionsModel: ImageActionsModel, taskCenter: TaskCenter, + runModel: RunModel, actions: ShellActions, terminalSurfaceProvider: any TerminalSurfaceProviding = StubTerminalSurfaceProvider() ) { @@ -51,6 +53,7 @@ public struct RootView: View { self.imageBrowserModel = imageBrowserModel self.imageActionsModel = imageActionsModel self.taskCenter = taskCenter + self.runModel = runModel self.actions = actions self.terminalSurfaceProvider = terminalSurfaceProvider } @@ -66,6 +69,7 @@ public struct RootView: View { imageBrowserModel: imageBrowserModel, imageActionsModel: imageActionsModel, taskCenter: taskCenter, + runModel: runModel, actions: actions, terminalSurfaceProvider: terminalSurfaceProvider ) diff --git a/Sources/CapsuleUI/RunFailureTriageView.swift b/Sources/CapsuleUI/RunFailureTriageView.swift new file mode 100644 index 0000000..3dc1908 --- /dev/null +++ b/Sources/CapsuleUI/RunFailureTriageView.swift @@ -0,0 +1,53 @@ +// +// RunFailureTriageView.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// Shown inline in the Quick Run sheet when a detached run fails. It keeps the full raw +// transcript visible and offers the three triage paths: resolve the image (pull it), +// inspect the logs (the transcript is right here), and retry the run in a terminal. + +import CapsuleDomain +import SwiftUI + +struct RunFailureTriageView: View { + let task: OperationTask + let imageReference: String? + var onResolveImage: () -> Void + var onRetryInTerminal: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label("The container didn’t start", systemImage: "exclamationmark.triangle.fill") + .font(.callout.weight(.semibold)) + .foregroundStyle(.orange) + + Text( + "The raw output is below. Resolve the image if it’s missing, or rerun in a " + + "terminal to watch it interactively." + ) + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + Button { + onResolveImage() + } label: { + Label("Resolve Image", systemImage: "arrow.down.circle") + } + .help("Pull \(imageReference ?? "the image") from a registry") + + Button { + onRetryInTerminal() + } label: { + Label("Retry in Terminal", systemImage: "terminal") + } + } + + // Inspect Logs: the failing transcript is kept visible (never hidden on failure). + TaskTranscriptView(task: task) + } + .padding(.top, 4) + } +} From 731fdcef4846fc02fd8b88d4d7d231cee2f16372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barr=C4=B1=C5=9Fcan=20Aslan?= Date: Mon, 29 Jun 2026 01:14:09 +0300 Subject: [PATCH 08/13] feat(build): Build sheet (folder drop, presets, live transcript, export, plain retry) - BuildModel: draft + presets (standard/no-cache/plain), validation, streaming .build task (reloads images on success), retryPlain() forces --progress plain - BuildSheet: drag/drop or choose a context folder, tag/Dockerfile/build-arg rows, live TaskTranscriptView (raw output never hidden), Export Transcript, plain retry - ImageListView gains a Build toolbar button; buildModel wired through composition Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QRY8sjZCmM87FEsjo8A4he --- Sources/CapsuleApp/AppEnvironment.swift | 11 ++ Sources/CapsuleApp/CapsuleScene.swift | 3 + Sources/CapsuleDomain/BuildModel.swift | 124 ++++++++++++ Sources/CapsuleUI/AppShellView.swift | 6 +- Sources/CapsuleUI/BuildSheet.swift | 190 +++++++++++++++++++ Sources/CapsuleUI/ContentColumnView.swift | 5 +- Sources/CapsuleUI/ImageListView.swift | 18 +- Sources/CapsuleUI/RootView.swift | 4 + Tests/CapsuleUnitTests/BuildModelTests.swift | 86 +++++++++ 9 files changed, 444 insertions(+), 3 deletions(-) create mode 100644 Sources/CapsuleDomain/BuildModel.swift create mode 100644 Sources/CapsuleUI/BuildSheet.swift create mode 100644 Tests/CapsuleUnitTests/BuildModelTests.swift diff --git a/Sources/CapsuleApp/AppEnvironment.swift b/Sources/CapsuleApp/AppEnvironment.swift index 7897f57..6e22b14 100644 --- a/Sources/CapsuleApp/AppEnvironment.swift +++ b/Sources/CapsuleApp/AppEnvironment.swift @@ -34,6 +34,7 @@ public struct AppEnvironment { public var taskCenter: TaskCenter public var registriesModel: RegistriesModel public var runModel: RunModel + public var buildModel: BuildModel public var actions: ShellActions public var updater: any UpdaterController public var terminalSurfaceProvider: any TerminalSurfaceProviding @@ -50,6 +51,7 @@ public struct AppEnvironment { taskCenter: TaskCenter, registriesModel: RegistriesModel, runModel: RunModel, + buildModel: BuildModel, actions: ShellActions, updater: any UpdaterController, terminalSurfaceProvider: any TerminalSurfaceProviding = StubTerminalSurfaceProvider() @@ -65,6 +67,7 @@ public struct AppEnvironment { self.taskCenter = taskCenter self.registriesModel = registriesModel self.runModel = runModel + self.buildModel = buildModel self.actions = actions self.updater = updater self.terminalSurfaceProvider = terminalSurfaceProvider @@ -137,6 +140,13 @@ public struct AppEnvironment { launchTerminal: { request in shell.openTerminal(request) }, copyCommand: copyCommandToClipboard ) + let buildModel = BuildModel( + backend: backend, + taskCenter: taskCenter, + normalize: { ErrorNormalizer.normalize($0) }, + onActivity: { line in shell.appendActivity(line) }, + reloadList: { await imageBrowserModel.refresh() } + ) let terminalSurfaceProvider = SwiftTermSurfaceProvider(executablePath: { name in name == "container" ? cliBackend.executableURL.path : name }) @@ -153,6 +163,7 @@ public struct AppEnvironment { taskCenter: taskCenter, registriesModel: registriesModel, runModel: runModel, + buildModel: buildModel, actions: actions, updater: NoopUpdaterController(), terminalSurfaceProvider: terminalSurfaceProvider diff --git a/Sources/CapsuleApp/CapsuleScene.swift b/Sources/CapsuleApp/CapsuleScene.swift index 13e018f..7bb4f4c 100644 --- a/Sources/CapsuleApp/CapsuleScene.swift +++ b/Sources/CapsuleApp/CapsuleScene.swift @@ -27,6 +27,7 @@ public struct CapsuleScene: Scene { @State private var taskCenter: TaskCenter @State private var registriesModel: RegistriesModel @State private var runModel: RunModel + @State private var buildModel: BuildModel private let actions: ShellActions private let updater: any UpdaterController private let terminalSurfaceProvider: any TerminalSurfaceProviding @@ -47,6 +48,7 @@ public struct CapsuleScene: Scene { self._taskCenter = State(initialValue: environment.taskCenter) self._registriesModel = State(initialValue: environment.registriesModel) self._runModel = State(initialValue: environment.runModel) + self._buildModel = State(initialValue: environment.buildModel) self.actions = environment.actions self.updater = environment.updater self.terminalSurfaceProvider = environment.terminalSurfaceProvider @@ -65,6 +67,7 @@ public struct CapsuleScene: Scene { imageActionsModel: imageActionsModel, taskCenter: taskCenter, runModel: runModel, + buildModel: buildModel, actions: actions, terminalSurfaceProvider: terminalSurfaceProvider ) diff --git a/Sources/CapsuleDomain/BuildModel.swift b/Sources/CapsuleDomain/BuildModel.swift new file mode 100644 index 0000000..660d6e6 --- /dev/null +++ b/Sources/CapsuleDomain/BuildModel.swift @@ -0,0 +1,124 @@ +// +// BuildModel.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// NOTE: This module must remain free of UI and of `Foundation.Process`. Owns the Build flow: +// a UI-friendly draft + presets, validation into a `BuildConfiguration`, a streaming build +// task (raw transcript never hidden), and a "plain progress" retry that re-runs the build +// with `--progress plain` for diagnosable, uncollapsed output. + +import CapsuleBackend +import Foundation +import Observation + +/// A one-tap build preset that seeds the cache/progress flags. +public enum BuildPreset: String, Sendable, CaseIterable, Identifiable { + case standard + case noCache + case plainProgress + + public var id: String { rawValue } + + public var title: String { + switch self { + case .standard: return "Standard" + case .noCache: return "No cache" + case .plainProgress: return "Plain progress" + } + } +} + +/// A UI-friendly draft of a build. +public struct BuildDraft: Sendable, Equatable { + public var contextDirectory: URL? + public var tag: String = "" + public var dockerfile: String = "" + /// `KEY=value` rows; blank rows are ignored. + public var buildArgRows: [String] = [] + public var noCache: Bool = false + public var preset: BuildPreset = .standard + + public init() {} +} + +@MainActor +@Observable +public final class BuildModel { + public var draft = BuildDraft() + + private let backend: any ContainerBackend + private let taskCenter: TaskCenter + private let normalize: @Sendable (any Error) -> CapsuleError + private let onActivity: @MainActor (String) -> Void + private let reloadList: @MainActor () async -> Void + + public init( + backend: any ContainerBackend, + taskCenter: TaskCenter, + normalize: @escaping @Sendable (any Error) -> CapsuleError = SystemStatusModel + .defaultNormalize, + onActivity: @escaping @MainActor (String) -> Void = { _ in }, + reloadList: @escaping @MainActor () async -> Void = {} + ) { + self.backend = backend + self.taskCenter = taskCenter + self.normalize = normalize + self.onActivity = onActivity + self.reloadList = reloadList + } + + public func reset() { + draft = BuildDraft() + } + + /// Validates the draft into a `BuildConfiguration`, applying the preset's cache/progress + /// flags (a preset OR's with the explicit no-cache toggle), or returns the first error. + public func validatedConfiguration() -> Result { + guard let context = draft.contextDirectory else { + return .failure( + .invalidInput(field: "context", message: "Choose a build context folder.")) + } + let tag = draft.tag.trimmingCharacters(in: .whitespacesAndNewlines) + guard !tag.isEmpty else { + return .failure(.invalidInput(field: "tag", message: "Enter a tag for the image.")) + } + let dockerfile = draft.dockerfile.trimmingCharacters(in: .whitespacesAndNewlines) + let config = BuildConfiguration( + contextDirectory: context, + tag: tag, + dockerfile: dockerfile.isEmpty ? nil : dockerfile, + buildArgs: draft.buildArgRows + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty }, + noCache: draft.noCache || draft.preset == .noCache, + plainProgress: draft.preset == .plainProgress) + return .success(config) + } + + /// Starts the build as a streaming Activity task; reloads the image list on success. + @discardableResult + public func build() -> OperationTask? { + guard case let .success(config) = validatedConfiguration() else { return nil } + return start(config) + } + + /// Re-runs the build with `--progress plain` (the diagnosable, uncollapsed retry). + @discardableResult + public func retryPlain() -> OperationTask? { + guard case var .success(config) = validatedConfiguration() else { return nil } + config.plainProgress = true + return start(config) + } + + private func start(_ config: BuildConfiguration) -> OperationTask { + onActivity("Building \(config.tag)…") + return taskCenter.runStreaming( + kind: .build, title: "Build \(config.tag)", + onSuccess: { [reloadList] in await reloadList() } + ) { [backend] in + backend.buildImage(config) + } + } +} diff --git a/Sources/CapsuleUI/AppShellView.swift b/Sources/CapsuleUI/AppShellView.swift index 141b4de..4a42e07 100644 --- a/Sources/CapsuleUI/AppShellView.swift +++ b/Sources/CapsuleUI/AppShellView.swift @@ -23,6 +23,7 @@ public struct AppShellView: View { @Bindable var imageActionsModel: ImageActionsModel @Bindable var taskCenter: TaskCenter @Bindable var runModel: RunModel + @Bindable var buildModel: BuildModel let actions: ShellActions let terminalSurfaceProvider: any TerminalSurfaceProviding @@ -37,6 +38,7 @@ public struct AppShellView: View { imageActionsModel: ImageActionsModel, taskCenter: TaskCenter, runModel: RunModel, + buildModel: BuildModel, actions: ShellActions, terminalSurfaceProvider: any TerminalSurfaceProviding = StubTerminalSurfaceProvider() ) { @@ -50,6 +52,7 @@ public struct AppShellView: View { self.imageActionsModel = imageActionsModel self.taskCenter = taskCenter self.runModel = runModel + self.buildModel = buildModel self.actions = actions self.terminalSurfaceProvider = terminalSurfaceProvider } @@ -111,7 +114,8 @@ public struct AppShellView: View { statsModel: statsModel, imageBrowserModel: imageBrowserModel, imageActionsModel: imageActionsModel, - runModel: runModel + runModel: runModel, + buildModel: buildModel ) .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Sources/CapsuleUI/BuildSheet.swift b/Sources/CapsuleUI/BuildSheet.swift new file mode 100644 index 0000000..afc5c13 --- /dev/null +++ b/Sources/CapsuleUI/BuildSheet.swift @@ -0,0 +1,190 @@ +// +// BuildSheet.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// The Build sheet: drag/drop (or choose) a context folder, set a tag + optional Dockerfile + +// build-args, pick a preset, and stream the build. Raw stdout/stderr is NEVER hidden — the +// full transcript stays visible, is exportable, and a "plain progress" retry re-runs the +// build with --progress plain for diagnosis. + +import AppKit +import CapsuleDomain +import SwiftUI +import UniformTypeIdentifiers + +struct BuildSheet: View { + @Bindable var model: BuildModel + var onClose: () -> Void + + @State private var activeTask: OperationTask? + @State private var isDropTargeted = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Label("Build an Image", systemImage: "hammer") + .font(.headline) + Spacer() + } + .padding(12) + Divider() + ScrollView { + VStack(alignment: .leading, spacing: 14) { + dropZone + labeledField("Tag (required)", text: $model.draft.tag, prompt: "app:dev") + labeledField( + "Dockerfile", text: $model.draft.dockerfile, prompt: "optional path") + rowEditor("Build args", rows: $model.draft.buildArgRows, example: "KEY=value") + HStack(spacing: 16) { + Picker("Preset", selection: $model.draft.preset) { + ForEach(BuildPreset.allCases) { Text($0.title).tag($0) } + } + .fixedSize() + Toggle("No cache", isOn: $model.draft.noCache) + } + if let task = activeTask { + Divider() + TaskTranscriptView(task: task) + transcriptActions(task) + } + } + .padding(16) + } + Divider() + footer + } + .frame(width: 560, height: 640) + } + + private var dropZone: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Build context").font(.caption).foregroundStyle(.secondary) + RoundedRectangle(cornerRadius: 8) + .strokeBorder( + isDropTargeted ? Color.accentColor : Color.secondary.opacity(0.4), + style: StrokeStyle(lineWidth: 1.5, dash: [6]) + ) + .frame(height: 64) + .overlay { + HStack { + Image(systemName: "folder") + Text(model.draft.contextDirectory?.path ?? "Drag a folder here, or Choose…") + .font(.callout) + .foregroundStyle( + model.draft.contextDirectory == nil ? .secondary : .primary + ) + .lineLimit(1).truncationMode(.middle) + Spacer() + Button("Choose…") { chooseFolder() } + } + .padding(.horizontal, 12) + } + .onDrop(of: [.fileURL], isTargeted: $isDropTargeted) { providers in + loadDroppedFolder(providers) + } + } + } + + @ViewBuilder + private func transcriptActions(_ task: OperationTask) -> some View { + HStack { + Button("Export Transcript…") { exportTranscript(task) } + .disabled(task.transcript.isEmpty) + if case .failed = task.state { + Button("Retry with Plain Progress") { activeTask = model.retryPlain() } + } + Spacer() + } + } + + private var footer: some View { + HStack { + Button("Close", action: onClose) + .keyboardShortcut(.cancelAction) + Spacer() + Button("Build") { activeTask = model.build() } + .keyboardShortcut(.defaultAction) + .disabled(!canBuild) + } + .padding(12) + } + + private var canBuild: Bool { + model.draft.contextDirectory != nil + && !model.draft.tag.trimmingCharacters(in: .whitespaces).isEmpty + } + + private func chooseFolder() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.title = "Choose Build Context" + if panel.runModal() == .OK, let url = panel.url { + model.draft.contextDirectory = url + } + } + + private func loadDroppedFolder(_ providers: [NSItemProvider]) -> Bool { + guard let provider = providers.first else { return false } + _ = provider.loadObject(ofClass: URL.self) { url, _ in + guard let url else { return } + var isDirectory: ObjCBool = false + FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) + // If a file was dropped, use its containing folder as the context. + let folder = isDirectory.boolValue ? url : url.deletingLastPathComponent() + Task { @MainActor in model.draft.contextDirectory = folder } + } + return true + } + + private func exportTranscript(_ task: OperationTask) { + let panel = NSSavePanel() + panel.nameFieldStringValue = "build-transcript.txt" + panel.allowedContentTypes = [.plainText, .log] + panel.canCreateDirectories = true + if panel.runModal() == .OK, let url = panel.url { + try? task.transcriptText.write(to: url, atomically: true, encoding: .utf8) + } + } + + private func labeledField(_ label: String, text: Binding, prompt: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(label).font(.caption).foregroundStyle(.secondary) + TextField(label, text: text, prompt: Text(prompt)) + .textFieldStyle(.roundedBorder) + .labelsHidden() + } + } + + private func rowEditor( + _ label: String, rows: Binding<[String]>, example: String + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(label).font(.caption).foregroundStyle(.secondary) + Spacer() + Button { + rows.wrappedValue.append("") + } label: { + Image(systemName: "plus.circle") + } + .buttonStyle(.borderless) + } + ForEach(rows.wrappedValue.indices, id: \.self) { index in + HStack { + TextField(example, text: rows[index]) + .textFieldStyle(.roundedBorder) + Button { + rows.wrappedValue.remove(at: index) + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + } + } + } + } +} diff --git a/Sources/CapsuleUI/ContentColumnView.swift b/Sources/CapsuleUI/ContentColumnView.swift index 37876a2..67940ab 100644 --- a/Sources/CapsuleUI/ContentColumnView.swift +++ b/Sources/CapsuleUI/ContentColumnView.swift @@ -21,6 +21,7 @@ struct ContentColumnView: View { let imageBrowserModel: ImageBrowserModel let imageActionsModel: ImageActionsModel let runModel: RunModel + let buildModel: BuildModel private var onRecover: (RecoveryAction) -> Void { actions.recover } @@ -45,7 +46,9 @@ struct ContentColumnView: View { case .containers: ContainerListView(model: browserModel, lifecycle: lifecycleModel, stats: statsModel) case .images: - ImageListView(model: imageBrowserModel, actions: imageActionsModel, runModel: runModel) + ImageListView( + model: imageBrowserModel, actions: imageActionsModel, runModel: runModel, + buildModel: buildModel) default: resourcePlaceholder } diff --git a/Sources/CapsuleUI/ImageListView.swift b/Sources/CapsuleUI/ImageListView.swift index 4338166..f18323c 100644 --- a/Sources/CapsuleUI/ImageListView.swift +++ b/Sources/CapsuleUI/ImageListView.swift @@ -21,13 +21,18 @@ struct ImageListView: View { @Bindable var model: ImageBrowserModel let actions: ImageActionsModel @Bindable var runModel: RunModel + @Bindable var buildModel: BuildModel @State private var activeSheet: ImageSheet? - init(model: ImageBrowserModel, actions: ImageActionsModel, runModel: RunModel) { + init( + model: ImageBrowserModel, actions: ImageActionsModel, runModel: RunModel, + buildModel: BuildModel + ) { self.model = model self.actions = actions self.runModel = runModel + self.buildModel = buildModel } var body: some View { @@ -77,6 +82,8 @@ struct ImageListView: View { onClose: { activeSheet = nil } ) .onAppear { runModel.reset(image: image) } + case .build: + BuildSheet(model: buildModel, onClose: { activeSheet = nil }) } } } @@ -185,6 +192,13 @@ struct ImageListView: View { } .help("Run a container from an image") + Button { + activeSheet = .build + } label: { + Label("Build", systemImage: "hammer") + } + .help("Build an image from a Dockerfile") + Button { activeSheet = .pull } label: { @@ -282,6 +296,7 @@ enum ImageSheet: Identifiable { case confirm(ConfirmationRequest) case prune case run(image: String) + case build var id: String { switch self { @@ -292,6 +307,7 @@ enum ImageSheet: Identifiable { case let .confirm(request): return "confirm-\(request.id)" case .prune: return "prune" case let .run(image): return "run-\(image)" + case .build: return "build" } } } diff --git a/Sources/CapsuleUI/RootView.swift b/Sources/CapsuleUI/RootView.swift index 0b10627..2dd5eb8 100644 --- a/Sources/CapsuleUI/RootView.swift +++ b/Sources/CapsuleUI/RootView.swift @@ -25,6 +25,7 @@ public struct RootView: View { private let imageActionsModel: ImageActionsModel private let taskCenter: TaskCenter private let runModel: RunModel + private let buildModel: BuildModel private let actions: ShellActions private let terminalSurfaceProvider: any TerminalSurfaceProviding @@ -41,6 +42,7 @@ public struct RootView: View { imageActionsModel: ImageActionsModel, taskCenter: TaskCenter, runModel: RunModel, + buildModel: BuildModel, actions: ShellActions, terminalSurfaceProvider: any TerminalSurfaceProviding = StubTerminalSurfaceProvider() ) { @@ -54,6 +56,7 @@ public struct RootView: View { self.imageActionsModel = imageActionsModel self.taskCenter = taskCenter self.runModel = runModel + self.buildModel = buildModel self.actions = actions self.terminalSurfaceProvider = terminalSurfaceProvider } @@ -70,6 +73,7 @@ public struct RootView: View { imageActionsModel: imageActionsModel, taskCenter: taskCenter, runModel: runModel, + buildModel: buildModel, actions: actions, terminalSurfaceProvider: terminalSurfaceProvider ) diff --git a/Tests/CapsuleUnitTests/BuildModelTests.swift b/Tests/CapsuleUnitTests/BuildModelTests.swift new file mode 100644 index 0000000..601cf60 --- /dev/null +++ b/Tests/CapsuleUnitTests/BuildModelTests.swift @@ -0,0 +1,86 @@ +// +// BuildModelTests.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// + +import CapsuleBackend +import XCTest + +@testable import CapsuleDomain + +@MainActor +final class BuildModelTests: XCTestCase { + private func model(backend: any ContainerBackend = MockBackend()) -> BuildModel { + BuildModel(backend: backend, taskCenter: TaskCenter()) + } + + func testValidationRequiresContext() { + let m = model() + m.draft.tag = "t:1" + guard case let .failure(error) = m.validatedConfiguration(), + case let .invalidInput(field, _) = error + else { return XCTFail("expected a context error") } + XCTAssertEqual(field, "context") + } + + func testValidationRequiresTag() { + let m = model() + m.draft.contextDirectory = URL(fileURLWithPath: "/p") + guard case let .failure(error) = m.validatedConfiguration(), + case let .invalidInput(field, _) = error + else { return XCTFail("expected a tag error") } + XCTAssertEqual(field, "tag") + } + + func testPresetMapsToConfig() { + let m = model() + m.draft.contextDirectory = URL(fileURLWithPath: "/p") + m.draft.tag = "t:1" + m.draft.preset = .plainProgress + guard case let .success(plain) = m.validatedConfiguration() else { return XCTFail() } + XCTAssertTrue(plain.plainProgress) + XCTAssertFalse(plain.noCache) + + m.draft.preset = .noCache + guard case let .success(noCache) = m.validatedConfiguration() else { return XCTFail() } + XCTAssertTrue(noCache.noCache) + XCTAssertFalse(noCache.plainProgress) + } + + func testBuildArgsAndDockerfileFlowIntoConfig() { + let m = model() + m.draft.contextDirectory = URL(fileURLWithPath: "/p") + m.draft.tag = "t:1" + m.draft.dockerfile = "Dockerfile.dev" + m.draft.buildArgRows = ["A=1", " "] + guard case let .success(config) = m.validatedConfiguration() else { return XCTFail() } + XCTAssertEqual(config.dockerfile, "Dockerfile.dev") + XCTAssertEqual(config.buildArgs, ["A=1"]) + } + + func testBuildRegistersStreamingTaskAndReloads() async { + let center = TaskCenter() + var reloaded = false + let m = BuildModel( + backend: MockBackend(), taskCenter: center, reloadList: { reloaded = true }) + m.draft.contextDirectory = URL(fileURLWithPath: "/p") + m.draft.tag = "t:1" + let task = m.build() + await task?.wait() + XCTAssertEqual(center.tasks.first?.kind, .build) + XCTAssertEqual(task?.state, .succeeded) + XCTAssertTrue(reloaded) + } + + func testRetryPlainForcesPlainProgress() async { + let backend = MockBackend() + let m = BuildModel(backend: backend, taskCenter: TaskCenter()) + m.draft.contextDirectory = URL(fileURLWithPath: "/p") + m.draft.tag = "t:1" + let task = m.retryPlain() + await task?.wait() + XCTAssertEqual(backend.lastBuildConfig?.plainProgress, true) + } +} From d7cb61cb54ba27d687b0201ae9edef7254ee85f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barr=C4=B1=C5=9Fcan=20Aslan?= Date: Mon, 29 Jun 2026 01:18:10 +0300 Subject: [PATCH 09/13] feat(exec): Exec sheet, machine shell, Open-in-Terminal.app detach fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ContainerLifecycleModel: execShell(id:command:) (custom command), openMachineShell, openInExternalTerminal (detach fallback seam) - ExecSheet: command field + live preview, Run (embedded terminal) or Open in Terminal.app - Container row menu: Open Shell + Exec… for a single running container - embedded terminal header gains an 'Open in Terminal.app' detach button - AppEnvironment writes an executable .command script + opens it via NSWorkspace Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QRY8sjZCmM87FEsjo8A4he --- Sources/CapsuleApp/AppEnvironment.swift | 32 +++++++++ .../ContainerLifecycleModel.swift | 29 ++++++++ Sources/CapsuleUI/ActivityPaneView.swift | 3 + Sources/CapsuleUI/AppShellView.swift | 5 +- Sources/CapsuleUI/ContainerListView.swift | 11 +++ Sources/CapsuleUI/ExecSheet.swift | 71 +++++++++++++++++++ .../ContainerLifecycleModelTests.swift | 39 ++++++++++ 7 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 Sources/CapsuleUI/ExecSheet.swift diff --git a/Sources/CapsuleApp/AppEnvironment.swift b/Sources/CapsuleApp/AppEnvironment.swift index 6e22b14..1879c06 100644 --- a/Sources/CapsuleApp/AppEnvironment.swift +++ b/Sources/CapsuleApp/AppEnvironment.swift @@ -117,6 +117,10 @@ public struct AppEnvironment { pasteboard.setString(command, forType: .string) shell.appendActivity("Copied to clipboard: \(command)") } + let openInTerminalApp: @MainActor ([String]) -> Void = { argv in + openCommandInTerminalApp(argv, executablePath: cliBackend.executableURL.path) + shell.appendActivity("Opened in Terminal: \(argv.joined(separator: " "))") + } let lifecycleModel = ContainerLifecycleModel( backend: backend, normalize: { ErrorNormalizer.normalize($0) }, @@ -128,6 +132,7 @@ public struct AppEnvironment { terminalAvailable: { true }, copyCommand: copyCommandToClipboard, launchTerminal: { request in shell.openTerminal(request) }, + openExternalTerminal: openInTerminalApp, taskCenter: taskCenter ) let runModel = RunModel( @@ -200,3 +205,30 @@ public struct AppEnvironment { ) } } + +/// The detach fallback: write the argv to a temporary executable `.command` script and open +/// it, which launches it in Terminal.app. The `container` token is resolved to its absolute +/// path so the external shell can find it. Best-effort — a write/open failure is non-fatal. +@MainActor +func openCommandInTerminalApp(_ argv: [String], executablePath: String) { + guard !argv.isEmpty else { return } + let resolved = argv.enumerated().map { index, token in + index == 0 && token == "container" ? executablePath : token + } + let command = resolved.map(shellQuote).joined(separator: " ") + let script = "#!/bin/sh\nexec \(command)\n" + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("capsule-\(UUID().uuidString).command") + do { + try script.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + NSWorkspace.shared.open(url) + } catch { + // Non-fatal: the embedded terminal remains available. + } +} + +/// Single-quotes a token for safe inclusion in a `/bin/sh` command line. +private func shellQuote(_ token: String) -> String { + "'" + token.replacingOccurrences(of: "'", with: "'\\''") + "'" +} diff --git a/Sources/CapsuleDomain/ContainerLifecycleModel.swift b/Sources/CapsuleDomain/ContainerLifecycleModel.swift index 42c514e..6bd2598 100644 --- a/Sources/CapsuleDomain/ContainerLifecycleModel.swift +++ b/Sources/CapsuleDomain/ContainerLifecycleModel.swift @@ -29,6 +29,7 @@ public final class ContainerLifecycleModel { private let terminalAvailable: @MainActor () -> Bool private let copyCommand: @MainActor ([String]) -> Void private let launchTerminal: @MainActor (TerminalRequest) -> Void + private let openExternalTerminal: @MainActor ([String]) -> Void private let taskCenter: TaskCenter? private let settleAttempts: Int private let settleDelay: Duration @@ -47,6 +48,7 @@ public final class ContainerLifecycleModel { terminalAvailable: @escaping @MainActor () -> Bool = { false }, copyCommand: @escaping @MainActor ([String]) -> Void = { _ in }, launchTerminal: @escaping @MainActor (TerminalRequest) -> Void = { _ in }, + openExternalTerminal: @escaping @MainActor ([String]) -> Void = { _ in }, taskCenter: TaskCenter? = nil, settleAttempts: Int = 4, settleDelay: Duration = .milliseconds(400), @@ -60,6 +62,7 @@ public final class ContainerLifecycleModel { self.terminalAvailable = terminalAvailable self.copyCommand = copyCommand self.launchTerminal = launchTerminal + self.openExternalTerminal = openExternalTerminal self.taskCenter = taskCenter self.settleAttempts = settleAttempts self.settleDelay = settleDelay @@ -78,6 +81,26 @@ public final class ContainerLifecycleModel { argv: ["container", "exec", "-it", id, "sh"], kind: .execShell)) } + /// Runs a custom command interactively (`exec -it … `) in the embedded terminal, + /// falling back to the clipboard. An empty command defaults to `sh`. + public func execShell(id: String, command: [String]) { + let argv = command.isEmpty ? ["sh"] : command + launchOrCopy( + TerminalRequest( + containerID: id, title: "Exec · \(id)", + argv: ["container", "exec", "-it", id] + argv, kind: .execShell)) + } + + /// Opens an interactive login shell in a container machine + /// (`machine run -it [-n ]`), falling back to the clipboard. + public func openMachineShell(name: String?) { + var argv = ["container", "machine", "run", "-it"] + if let name, !name.isEmpty { argv += ["-n", name] } + let title = name.map { "Machine · \($0)" } ?? "Machine shell" + launchOrCopy( + TerminalRequest(containerID: nil, title: title, argv: argv, kind: .execShell)) + } + /// Starts a stopped container attached to its main process (`start -ai`) in the embedded /// terminal — the interactive counterpart to the read-only `logs --follow` attach. public func attachInteractively(id: String) { @@ -104,6 +127,12 @@ public final class ContainerLifecycleModel { } } + /// Detach fallback: run the command in the external Terminal.app instead of the embedded + /// terminal (e.g. to keep a long interactive session open outside Capsule). + public func openInExternalTerminal(_ argv: [String]) { + openExternalTerminal(argv) + } + // MARK: - Start public func start(id: String, attach: Bool) async -> ContainerStartResult { diff --git a/Sources/CapsuleUI/ActivityPaneView.swift b/Sources/CapsuleUI/ActivityPaneView.swift index ae826dc..a48c96f 100644 --- a/Sources/CapsuleUI/ActivityPaneView.swift +++ b/Sources/CapsuleUI/ActivityPaneView.swift @@ -29,6 +29,7 @@ struct ActivityPaneView: View { var onRetryAttach: () -> Void = {} var onOpenShell: () -> Void = {} var onCloseTerminal: () -> Void = {} + var onOpenInTerminalApp: (TerminalRequest) -> Void = { _ in } @State private var dragStartHeight: Double? @@ -129,6 +130,8 @@ struct ActivityPaneView: View { Label(session.request.title, systemImage: "terminal") .font(.caption).foregroundStyle(.secondary) Spacer() + Button("Open in Terminal.app") { onOpenInTerminalApp(session.request) } + .help("Run this command in the external Terminal instead") Button("Close", action: onCloseTerminal) } .padding(.horizontal, 12) diff --git a/Sources/CapsuleUI/AppShellView.swift b/Sources/CapsuleUI/AppShellView.swift index 4a42e07..12c5d8a 100644 --- a/Sources/CapsuleUI/AppShellView.swift +++ b/Sources/CapsuleUI/AppShellView.swift @@ -130,7 +130,10 @@ public struct AppShellView: View { onDetach: { lifecycleModel.detach() }, onRetryAttach: { retryAttach() }, onOpenShell: { openShellForSelection() }, - onCloseTerminal: { shell.closeTerminal() } + onCloseTerminal: { shell.closeTerminal() }, + onOpenInTerminalApp: { request in + lifecycleModel.openInExternalTerminal(request.argv) + } ) } } diff --git a/Sources/CapsuleUI/ContainerListView.swift b/Sources/CapsuleUI/ContainerListView.swift index 6264f11..f8c42fc 100644 --- a/Sources/CapsuleUI/ContainerListView.swift +++ b/Sources/CapsuleUI/ContainerListView.swift @@ -84,6 +84,9 @@ struct ContainerListView: View { }, onCancel: { activeSheet = nil }) case .prune: PruneSheet(lifecycle: lifecycle, onClose: { activeSheet = nil }) + case let .exec(id): + ExecSheet( + containerID: id, lifecycle: lifecycle, onClose: { activeSheet = nil }) } } } @@ -181,6 +184,12 @@ struct ContainerListView: View { } } + if let single = stoppable.first, stoppable.count == 1 { + Divider() + Button("Open Shell") { lifecycle.openShell(id: single.id) } + Button("Exec…") { activeSheet = .exec(id: single.id) } + } + Divider() Button("Force Stop", role: .destructive) { requestKill(ids: Set(stoppable.map(\.id))) } .disabled(stoppable.isEmpty) @@ -344,6 +353,7 @@ enum LifecycleSheet: Identifiable { case stopOptions(id: String, name: String) case confirm(ConfirmationRequest) case prune + case exec(id: String) var id: String { switch self { @@ -351,6 +361,7 @@ enum LifecycleSheet: Identifiable { case let .stopOptions(id, _): return "stop-\(id)" case let .confirm(request): return "confirm-\(request.id)" case .prune: return "prune" + case let .exec(id): return "exec-\(id)" } } } diff --git a/Sources/CapsuleUI/ExecSheet.swift b/Sources/CapsuleUI/ExecSheet.swift new file mode 100644 index 0000000..4600167 --- /dev/null +++ b/Sources/CapsuleUI/ExecSheet.swift @@ -0,0 +1,71 @@ +// +// ExecSheet.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// Runs a command in a running container. The interactive command opens in the embedded +// AppKit terminal; the detach fallback opens the same command in Terminal.app. A live +// command preview shows exactly what will run. + +import CapsuleDomain +import SwiftUI + +struct ExecSheet: View { + let containerID: String + let lifecycle: ContainerLifecycleModel + var onClose: () -> Void + + @State private var command: String = "sh" + + private var argv: [String] { + let tokens = CommandTokenizer.tokenize(command) + return ["container", "exec", "-it", containerID] + (tokens.isEmpty ? ["sh"] : tokens) + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Label("Exec in \(containerID)", systemImage: "terminal") + .font(.headline) + Spacer() + } + + VStack(alignment: .leading, spacing: 4) { + Text("Command").font(.caption).foregroundStyle(.secondary) + TextField("Command", text: $command, prompt: Text("sh")) + .textFieldStyle(.roundedBorder) + .labelsHidden() + } + + VStack(alignment: .leading, spacing: 4) { + Text("Command preview").font(.caption).foregroundStyle(.secondary) + Text(argv.joined(separator: " ")) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(CapsuleColors.activitySurface) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + HStack { + Button("Cancel", action: onClose) + .keyboardShortcut(.cancelAction) + Spacer() + Button("Open in Terminal.app") { + lifecycle.openInExternalTerminal(argv) + onClose() + } + Button("Run") { + lifecycle.execShell( + id: containerID, command: CommandTokenizer.tokenize(command)) + onClose() + } + .keyboardShortcut(.defaultAction) + } + } + .padding(16) + .frame(width: 460) + } +} diff --git a/Tests/CapsuleUnitTests/ContainerLifecycleModelTests.swift b/Tests/CapsuleUnitTests/ContainerLifecycleModelTests.swift index 84cbe54..3d65f4d 100644 --- a/Tests/CapsuleUnitTests/ContainerLifecycleModelTests.swift +++ b/Tests/CapsuleUnitTests/ContainerLifecycleModelTests.swift @@ -191,6 +191,45 @@ final class ContainerLifecycleModelTests: XCTestCase { XCTAssertEqual(backend.lastExportURL, URL(fileURLWithPath: "/tmp/c.tar")) } + func testExecShellWithCustomCommandBuildsArgv() { + var request: TerminalRequest? + let m = ContainerLifecycleModel( + backend: MockBackend(), terminalAvailable: { true }, + launchTerminal: { request = $0 }) + m.execShell(id: "c1", command: ["bash", "-l"]) + XCTAssertEqual(request?.argv, ["container", "exec", "-it", "c1", "bash", "-l"]) + XCTAssertEqual(request?.kind, .execShell) + } + + func testExecShellEmptyCommandDefaultsToSh() { + var request: TerminalRequest? + let m = ContainerLifecycleModel( + backend: MockBackend(), terminalAvailable: { true }, + launchTerminal: { request = $0 }) + m.execShell(id: "c1", command: []) + XCTAssertEqual(request?.argv, ["container", "exec", "-it", "c1", "sh"]) + } + + func testMachineShellBuildsArgv() { + var request: TerminalRequest? + let m = ContainerLifecycleModel( + backend: MockBackend(), terminalAvailable: { true }, + launchTerminal: { request = $0 }) + m.openMachineShell(name: "default") + XCTAssertEqual(request?.argv, ["container", "machine", "run", "-it", "-n", "default"]) + m.openMachineShell(name: nil) + XCTAssertEqual(request?.argv, ["container", "machine", "run", "-it"]) + } + + func testExecShellCopiesWhenTerminalUnavailable() { + var copied: [String]? + let m = ContainerLifecycleModel( + backend: MockBackend(), terminalAvailable: { false }, + copyCommand: { copied = $0 }) + m.execShell(id: "c1", command: ["sh"]) + XCTAssertEqual(copied, ["container", "exec", "-it", "c1", "sh"]) + } + func testExportRegistersActivityTask() async { let center = TaskCenter() let m = ContainerLifecycleModel(backend: MockBackend(), taskCenter: center) From 17c06f52596d989e098d194662d9a00b6925a13b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barr=C4=B1=C5=9Fcan=20Aslan?= Date: Mon, 29 Jun 2026 01:22:45 +0300 Subject: [PATCH 10/13] feat(logs): logs pane + detachable log window (follow/tail/boot/search/save) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LogsModel: follow stream or bounded snapshot, case-insensitive search filter, saveable transcript, cancellable - LogsPaneView: follow/tail/boot controls, search, reload, save, auto-scroll - LogWindow: a detachable Window scene hosting the pane - container row 'Logs…' action starts capture + opens the window; View ▸ Open Log Window (⇧⌘L) command; logsModel wired through composition Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QRY8sjZCmM87FEsjo8A4he --- Sources/CapsuleApp/AppEnvironment.swift | 5 + Sources/CapsuleApp/CapsuleCommands.swift | 3 + Sources/CapsuleApp/CapsuleScene.swift | 7 ++ Sources/CapsuleDomain/LogsModel.swift | 92 ++++++++++++++++ Sources/CapsuleUI/AppShellView.swift | 6 +- Sources/CapsuleUI/ContainerListView.swift | 15 ++- Sources/CapsuleUI/ContentColumnView.swift | 5 +- Sources/CapsuleUI/LogWindow.swift | 31 ++++++ Sources/CapsuleUI/LogsPaneView.swift | 116 ++++++++++++++++++++ Sources/CapsuleUI/RootView.swift | 4 + Tests/CapsuleUnitTests/LogsModelTests.swift | 75 +++++++++++++ 11 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 Sources/CapsuleDomain/LogsModel.swift create mode 100644 Sources/CapsuleUI/LogWindow.swift create mode 100644 Sources/CapsuleUI/LogsPaneView.swift create mode 100644 Tests/CapsuleUnitTests/LogsModelTests.swift diff --git a/Sources/CapsuleApp/AppEnvironment.swift b/Sources/CapsuleApp/AppEnvironment.swift index 1879c06..6166975 100644 --- a/Sources/CapsuleApp/AppEnvironment.swift +++ b/Sources/CapsuleApp/AppEnvironment.swift @@ -35,6 +35,7 @@ public struct AppEnvironment { public var registriesModel: RegistriesModel public var runModel: RunModel public var buildModel: BuildModel + public var logsModel: LogsModel public var actions: ShellActions public var updater: any UpdaterController public var terminalSurfaceProvider: any TerminalSurfaceProviding @@ -52,6 +53,7 @@ public struct AppEnvironment { registriesModel: RegistriesModel, runModel: RunModel, buildModel: BuildModel, + logsModel: LogsModel, actions: ShellActions, updater: any UpdaterController, terminalSurfaceProvider: any TerminalSurfaceProviding = StubTerminalSurfaceProvider() @@ -68,6 +70,7 @@ public struct AppEnvironment { self.registriesModel = registriesModel self.runModel = runModel self.buildModel = buildModel + self.logsModel = logsModel self.actions = actions self.updater = updater self.terminalSurfaceProvider = terminalSurfaceProvider @@ -152,6 +155,7 @@ public struct AppEnvironment { onActivity: { line in shell.appendActivity(line) }, reloadList: { await imageBrowserModel.refresh() } ) + let logsModel = LogsModel(backend: backend) let terminalSurfaceProvider = SwiftTermSurfaceProvider(executablePath: { name in name == "container" ? cliBackend.executableURL.path : name }) @@ -169,6 +173,7 @@ public struct AppEnvironment { registriesModel: registriesModel, runModel: runModel, buildModel: buildModel, + logsModel: logsModel, actions: actions, updater: NoopUpdaterController(), terminalSurfaceProvider: terminalSurfaceProvider diff --git a/Sources/CapsuleApp/CapsuleCommands.swift b/Sources/CapsuleApp/CapsuleCommands.swift index 0e0b864..0658fdc 100644 --- a/Sources/CapsuleApp/CapsuleCommands.swift +++ b/Sources/CapsuleApp/CapsuleCommands.swift @@ -22,6 +22,7 @@ public struct CapsuleCommands: Commands { @Bindable private var shell: ShellState private let systemModel: SystemStatusModel private let actions: ShellActions + @Environment(\.openWindow) private var openWindow public init( updater: any UpdaterController, @@ -51,6 +52,8 @@ public struct CapsuleCommands: Commands { shell.toggleActivityPane() } .keyboardShortcut("j", modifiers: [.command]) + Button("Open Log Window") { openWindow(id: LogWindow.id) } + .keyboardShortcut("l", modifiers: [.shift, .command]) } // Resource — system lifecycle + a reserved command-palette hook (Milestone 11). diff --git a/Sources/CapsuleApp/CapsuleScene.swift b/Sources/CapsuleApp/CapsuleScene.swift index 7bb4f4c..e346f9e 100644 --- a/Sources/CapsuleApp/CapsuleScene.swift +++ b/Sources/CapsuleApp/CapsuleScene.swift @@ -28,6 +28,7 @@ public struct CapsuleScene: Scene { @State private var registriesModel: RegistriesModel @State private var runModel: RunModel @State private var buildModel: BuildModel + @State private var logsModel: LogsModel private let actions: ShellActions private let updater: any UpdaterController private let terminalSurfaceProvider: any TerminalSurfaceProviding @@ -49,6 +50,7 @@ public struct CapsuleScene: Scene { self._registriesModel = State(initialValue: environment.registriesModel) self._runModel = State(initialValue: environment.runModel) self._buildModel = State(initialValue: environment.buildModel) + self._logsModel = State(initialValue: environment.logsModel) self.actions = environment.actions self.updater = environment.updater self.terminalSurfaceProvider = environment.terminalSurfaceProvider @@ -68,6 +70,7 @@ public struct CapsuleScene: Scene { taskCenter: taskCenter, runModel: runModel, buildModel: buildModel, + logsModel: logsModel, actions: actions, terminalSurfaceProvider: terminalSurfaceProvider ) @@ -81,6 +84,10 @@ public struct CapsuleScene: Scene { ) } + Window("Logs", id: LogWindow.id) { + LogWindowView(model: logsModel) + } + Settings { PreferencesView(registriesModel: registriesModel) } diff --git a/Sources/CapsuleDomain/LogsModel.swift b/Sources/CapsuleDomain/LogsModel.swift new file mode 100644 index 0000000..fcdb70e --- /dev/null +++ b/Sources/CapsuleDomain/LogsModel.swift @@ -0,0 +1,92 @@ +// +// LogsModel.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// NOTE: This module must remain free of UI and of `Foundation.Process`. Backs the logs pane +// and the detachable log window: a scrollback buffer fed by `container logs` (follow or a +// bounded snapshot), a case-insensitive search filter, and a saveable transcript. + +import CapsuleBackend +import Foundation +import Observation + +@MainActor +@Observable +public final class LogsModel { + public private(set) var lines: [LogLine] = [] + public private(set) var containerID: String? + public private(set) var isStreaming = false + public var follow = true + public var tail: Int? + public var boot = false + public var searchText = "" + + private let backend: any ContainerBackend + private var nextLineID = 0 + private var task: Task? + + public init(backend: any ContainerBackend) { + self.backend = backend + } + + /// The lines matching the current search (case-insensitive substring); all lines when the + /// search is empty. + public var filteredLines: [LogLine] { + guard !searchText.isEmpty else { return lines } + return lines.filter { $0.text.localizedCaseInsensitiveContains(searchText) } + } + + /// The full (unfiltered) buffer joined for saving/copying. + public var transcriptText: String { + lines.map(\.text).joined(separator: "\n") + } + + /// Starts (or restarts) log capture for `id`: a live follow stream, or a bounded snapshot + /// when `follow` is off. + public func start(id: String) { + stop() + containerID = id + lines = [] + nextLineID = 0 + if follow { + isStreaming = true + task = Task { [weak self, backend] in + guard let self else { return } + do { + for try await line in backend.followLogs(container: id) { + self.append(line) + } + } catch { + // Stream ended or was cancelled; the buffer keeps what it captured. + } + self.isStreaming = false + } + } else { + task = Task { [weak self, backend, tail, boot] in + guard let self else { return } + let fetched = + (try? await backend.fetchLogs(container: id, tail: tail, boot: boot)) ?? [] + for line in fetched { self.append(line) } + } + } + } + + /// Stops any active capture. + public func stop() { + task?.cancel() + task = nil + isStreaming = false + } + + /// Awaits the in-flight load/stream (tests and any caller needing completion). + public func waitForLoad() async { + await task?.value + } + + private func append(_ line: OutputLine) { + nextLineID += 1 + lines.append(LogLine(id: nextLineID, source: line.source, text: line.text)) + } +} diff --git a/Sources/CapsuleUI/AppShellView.swift b/Sources/CapsuleUI/AppShellView.swift index 12c5d8a..c886890 100644 --- a/Sources/CapsuleUI/AppShellView.swift +++ b/Sources/CapsuleUI/AppShellView.swift @@ -24,6 +24,7 @@ public struct AppShellView: View { @Bindable var taskCenter: TaskCenter @Bindable var runModel: RunModel @Bindable var buildModel: BuildModel + @Bindable var logsModel: LogsModel let actions: ShellActions let terminalSurfaceProvider: any TerminalSurfaceProviding @@ -39,6 +40,7 @@ public struct AppShellView: View { taskCenter: TaskCenter, runModel: RunModel, buildModel: BuildModel, + logsModel: LogsModel, actions: ShellActions, terminalSurfaceProvider: any TerminalSurfaceProviding = StubTerminalSurfaceProvider() ) { @@ -53,6 +55,7 @@ public struct AppShellView: View { self.taskCenter = taskCenter self.runModel = runModel self.buildModel = buildModel + self.logsModel = logsModel self.actions = actions self.terminalSurfaceProvider = terminalSurfaceProvider } @@ -115,7 +118,8 @@ public struct AppShellView: View { imageBrowserModel: imageBrowserModel, imageActionsModel: imageActionsModel, runModel: runModel, - buildModel: buildModel + buildModel: buildModel, + logsModel: logsModel ) .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Sources/CapsuleUI/ContainerListView.swift b/Sources/CapsuleUI/ContainerListView.swift index f8c42fc..de6eeab 100644 --- a/Sources/CapsuleUI/ContainerListView.swift +++ b/Sources/CapsuleUI/ContainerListView.swift @@ -17,7 +17,9 @@ struct ContainerListView: View { @Bindable var model: ContainerBrowserModel let lifecycle: ContainerLifecycleModel let stats: ContainerStatsModel + let logsModel: LogsModel + @Environment(\.openWindow) private var openWindow @State private var showingSaveScope = false @State private var newScopeName = "" @State private var activeSheet: LifecycleSheet? @@ -25,11 +27,13 @@ struct ContainerListView: View { init( model: ContainerBrowserModel, lifecycle: ContainerLifecycleModel, - stats: ContainerStatsModel + stats: ContainerStatsModel, + logsModel: LogsModel ) { self.model = model self.lifecycle = lifecycle self.stats = stats + self.logsModel = logsModel } var body: some View { @@ -189,6 +193,9 @@ struct ContainerListView: View { Button("Open Shell") { lifecycle.openShell(id: single.id) } Button("Exec…") { activeSheet = .exec(id: single.id) } } + if let single = targets.first, targets.count == 1 { + Button("Logs…") { openLogs(id: single.id) } + } Divider() Button("Force Stop", role: .destructive) { requestKill(ids: Set(stoppable.map(\.id))) } @@ -271,6 +278,12 @@ struct ContainerListView: View { await stats.snapshot(ids: runningIDs) } + /// Starts log capture for a container and opens the detachable log window. + private func openLogs(id: String) { + logsModel.start(id: id) + openWindow(id: LogWindow.id) + } + // MARK: - Destructive actions /// Force Stop (kill): confirm only when more than one is targeted. diff --git a/Sources/CapsuleUI/ContentColumnView.swift b/Sources/CapsuleUI/ContentColumnView.swift index 67940ab..7e818a4 100644 --- a/Sources/CapsuleUI/ContentColumnView.swift +++ b/Sources/CapsuleUI/ContentColumnView.swift @@ -22,6 +22,7 @@ struct ContentColumnView: View { let imageActionsModel: ImageActionsModel let runModel: RunModel let buildModel: BuildModel + let logsModel: LogsModel private var onRecover: (RecoveryAction) -> Void { actions.recover } @@ -44,7 +45,9 @@ struct ContentColumnView: View { private var runningContent: some View { switch section { case .containers: - ContainerListView(model: browserModel, lifecycle: lifecycleModel, stats: statsModel) + ContainerListView( + model: browserModel, lifecycle: lifecycleModel, stats: statsModel, + logsModel: logsModel) case .images: ImageListView( model: imageBrowserModel, actions: imageActionsModel, runModel: runModel, diff --git a/Sources/CapsuleUI/LogWindow.swift b/Sources/CapsuleUI/LogWindow.swift new file mode 100644 index 0000000..8371cdc --- /dev/null +++ b/Sources/CapsuleUI/LogWindow.swift @@ -0,0 +1,31 @@ +// +// LogWindow.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// The detachable log window: a standalone window hosting `LogsPaneView` for the container +// the model is currently following. The window id is shared by the `Window` scene and the +// `openWindow` callers (the container Logs… action and the View ▸ Open Log Window command). + +import CapsuleDomain +import SwiftUI + +public enum LogWindow { + public static let id = "capsule.logs" +} + +/// The content of the detachable log window scene. +public struct LogWindowView: View { + @Bindable var model: LogsModel + + public init(model: LogsModel) { + self.model = model + } + + public var body: some View { + LogsPaneView(model: model) + .frame(minWidth: 480, minHeight: 320) + .navigationTitle(model.containerID.map { "Logs · \($0)" } ?? "Logs") + } +} diff --git a/Sources/CapsuleUI/LogsPaneView.swift b/Sources/CapsuleUI/LogsPaneView.swift new file mode 100644 index 0000000..1e13a78 --- /dev/null +++ b/Sources/CapsuleUI/LogsPaneView.swift @@ -0,0 +1,116 @@ +// +// LogsPaneView.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// A logs surface backed by `LogsModel`: follow toggle, tail count, boot toggle, search, and +// a save-transcript action over an auto-scrolling monospaced scrollback. Reused by both the +// embedded pane and the detachable log window. + +import AppKit +import CapsuleDomain +import SwiftUI + +struct LogsPaneView: View { + @Bindable var model: LogsModel + /// Shown by the detachable window only (the embedded pane omits it). + var onOpenInWindow: (() -> Void)? + + var body: some View { + VStack(spacing: 0) { + controls + Divider() + scrollback + } + } + + private var controls: some View { + HStack(spacing: 10) { + Toggle("Follow", isOn: $model.follow) + .toggleStyle(.switch) + .controlSize(.mini) + HStack(spacing: 4) { + Text("Tail").font(.caption).foregroundStyle(.secondary) + TextField("all", value: $model.tail, format: .number) + .frame(width: 56) + .textFieldStyle(.roundedBorder) + } + Toggle("Boot", isOn: $model.boot) + .controlSize(.mini) + Button { + if let id = model.containerID { model.start(id: id) } + } label: { + Image(systemName: "arrow.clockwise") + } + .help("Reload with the current follow / tail / boot settings") + .disabled(model.containerID == nil) + + TextField("Search", text: $model.searchText) + .textFieldStyle(.roundedBorder) + .frame(minWidth: 120) + + Button { + saveTranscript() + } label: { + Image(systemName: "square.and.arrow.down") + } + .help("Save the transcript") + .disabled(model.lines.isEmpty) + + if let onOpenInWindow { + Button { + onOpenInWindow() + } label: { + Image(systemName: "rectangle.on.rectangle") + } + .help("Open the logs in a separate window") + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + + private var scrollback: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 1) { + ForEach(model.filteredLines) { line in + Text(line.text) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(line.stream == .error ? .secondary : .primary) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .id(line.id) + } + Color.clear.frame(height: 1).id(bottomAnchor) + } + .padding(8) + } + .background(CapsuleColors.activitySurface) + .onChange(of: model.lines.count) { + withAnimation { proxy.scrollTo(bottomAnchor, anchor: .bottom) } + } + .overlay { + if model.containerID == nil { + ContentUnavailableView( + "No logs", systemImage: "doc.plaintext", + description: Text("Choose a container’s Logs… action to stream its output.") + ) + } + } + } + } + + private let bottomAnchor = "logs-bottom-anchor" + + private func saveTranscript() { + let panel = NSSavePanel() + panel.nameFieldStringValue = "\(model.containerID ?? "container")-logs.txt" + panel.allowedContentTypes = [.plainText, .log] + panel.canCreateDirectories = true + if panel.runModal() == .OK, let url = panel.url { + try? model.transcriptText.write(to: url, atomically: true, encoding: .utf8) + } + } +} diff --git a/Sources/CapsuleUI/RootView.swift b/Sources/CapsuleUI/RootView.swift index 2dd5eb8..61340bf 100644 --- a/Sources/CapsuleUI/RootView.swift +++ b/Sources/CapsuleUI/RootView.swift @@ -26,6 +26,7 @@ public struct RootView: View { private let taskCenter: TaskCenter private let runModel: RunModel private let buildModel: BuildModel + private let logsModel: LogsModel private let actions: ShellActions private let terminalSurfaceProvider: any TerminalSurfaceProviding @@ -43,6 +44,7 @@ public struct RootView: View { taskCenter: TaskCenter, runModel: RunModel, buildModel: BuildModel, + logsModel: LogsModel, actions: ShellActions, terminalSurfaceProvider: any TerminalSurfaceProviding = StubTerminalSurfaceProvider() ) { @@ -57,6 +59,7 @@ public struct RootView: View { self.taskCenter = taskCenter self.runModel = runModel self.buildModel = buildModel + self.logsModel = logsModel self.actions = actions self.terminalSurfaceProvider = terminalSurfaceProvider } @@ -74,6 +77,7 @@ public struct RootView: View { taskCenter: taskCenter, runModel: runModel, buildModel: buildModel, + logsModel: logsModel, actions: actions, terminalSurfaceProvider: terminalSurfaceProvider ) diff --git a/Tests/CapsuleUnitTests/LogsModelTests.swift b/Tests/CapsuleUnitTests/LogsModelTests.swift new file mode 100644 index 0000000..999f924 --- /dev/null +++ b/Tests/CapsuleUnitTests/LogsModelTests.swift @@ -0,0 +1,75 @@ +// +// LogsModelTests.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// + +import CapsuleBackend +import XCTest + +@testable import CapsuleDomain + +@MainActor +final class LogsModelTests: XCTestCase { + func testSnapshotPopulatesLines() async { + let backend = MockBackend(logLines: [ + OutputLine(source: .stdout, text: "hello"), OutputLine(source: .stdout, text: "world"), + ]) + let model = LogsModel(backend: backend) + model.follow = false + model.start(id: "c1") + await model.waitForLoad() + XCTAssertEqual(model.lines.map(\.text), ["hello", "world"]) + XCTAssertEqual(model.containerID, "c1") + } + + func testTailLimitsSnapshot() async { + let backend = MockBackend(logLines: [ + OutputLine(source: .stdout, text: "a"), OutputLine(source: .stdout, text: "b"), + OutputLine(source: .stdout, text: "c"), + ]) + let model = LogsModel(backend: backend) + model.follow = false + model.tail = 2 + model.start(id: "c1") + await model.waitForLoad() + XCTAssertEqual(model.lines.map(\.text), ["b", "c"]) + } + + func testSearchFilters() async { + let backend = MockBackend(logLines: [ + OutputLine(source: .stdout, text: "alpha"), OutputLine(source: .stdout, text: "beta"), + OutputLine(source: .stdout, text: "alpha-2"), + ]) + let model = LogsModel(backend: backend) + model.follow = false + model.start(id: "c1") + await model.waitForLoad() + model.searchText = "alpha" + XCTAssertEqual(model.filteredLines.map(\.text), ["alpha", "alpha-2"]) + } + + func testTranscriptTextJoinsLines() async { + let backend = MockBackend(logLines: [ + OutputLine(source: .stdout, text: "one"), OutputLine(source: .stdout, text: "two"), + ]) + let model = LogsModel(backend: backend) + model.follow = false + model.start(id: "c1") + await model.waitForLoad() + XCTAssertEqual(model.transcriptText, "one\ntwo") + } + + func testStopCancelsFollowStream() async { + let backend = MockBackend() + backend.neverEndingLogStream = true + let model = LogsModel(backend: backend) + model.follow = true + model.start(id: "c1") + await Task.yield() + model.stop() + await model.waitForLoad() + XCTAssertFalse(model.isStreaming) + } +} From 27292ae5f42efccfc052a49eb176731e9a9e8cd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barr=C4=B1=C5=9Fcan=20Aslan?= Date: Mon, 29 Jun 2026 01:28:08 +0300 Subject: [PATCH 11/13] feat(copy): copy sheet with host/container panels, drag-drop, path validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CopyModel: direction, host URL + container id:path with early validation (absolute path required, with an example), .copy Activity task, best-effort container directory browse (mapped to a UI-facing ContainerFileItem) - CopySheet: direction control, host drop-zone/Choose, container id:path with a Browse disclosure (navigate dirs, pick a file), drag-drop, inline validation - container row 'Copy Files…' action; copyModel wired through composition Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QRY8sjZCmM87FEsjo8A4he --- Sources/CapsuleApp/AppEnvironment.swift | 7 + Sources/CapsuleApp/CapsuleScene.swift | 3 + Sources/CapsuleDomain/CopyModel.swift | 128 ++++++++++++++ Sources/CapsuleUI/AppShellView.swift | 6 +- Sources/CapsuleUI/ContainerListView.swift | 13 +- Sources/CapsuleUI/ContentColumnView.swift | 3 +- Sources/CapsuleUI/CopySheet.swift | 180 ++++++++++++++++++++ Sources/CapsuleUI/RootView.swift | 4 + Tests/CapsuleUnitTests/CopyModelTests.swift | 70 ++++++++ 9 files changed, 411 insertions(+), 3 deletions(-) create mode 100644 Sources/CapsuleDomain/CopyModel.swift create mode 100644 Sources/CapsuleUI/CopySheet.swift create mode 100644 Tests/CapsuleUnitTests/CopyModelTests.swift diff --git a/Sources/CapsuleApp/AppEnvironment.swift b/Sources/CapsuleApp/AppEnvironment.swift index 6166975..ade74a1 100644 --- a/Sources/CapsuleApp/AppEnvironment.swift +++ b/Sources/CapsuleApp/AppEnvironment.swift @@ -36,6 +36,7 @@ public struct AppEnvironment { public var runModel: RunModel public var buildModel: BuildModel public var logsModel: LogsModel + public var copyModel: CopyModel public var actions: ShellActions public var updater: any UpdaterController public var terminalSurfaceProvider: any TerminalSurfaceProviding @@ -54,6 +55,7 @@ public struct AppEnvironment { runModel: RunModel, buildModel: BuildModel, logsModel: LogsModel, + copyModel: CopyModel, actions: ShellActions, updater: any UpdaterController, terminalSurfaceProvider: any TerminalSurfaceProviding = StubTerminalSurfaceProvider() @@ -71,6 +73,7 @@ public struct AppEnvironment { self.runModel = runModel self.buildModel = buildModel self.logsModel = logsModel + self.copyModel = copyModel self.actions = actions self.updater = updater self.terminalSurfaceProvider = terminalSurfaceProvider @@ -156,6 +159,9 @@ public struct AppEnvironment { reloadList: { await imageBrowserModel.refresh() } ) let logsModel = LogsModel(backend: backend) + let copyModel = CopyModel( + backend: backend, taskCenter: taskCenter, + onActivity: { line in shell.appendActivity(line) }) let terminalSurfaceProvider = SwiftTermSurfaceProvider(executablePath: { name in name == "container" ? cliBackend.executableURL.path : name }) @@ -174,6 +180,7 @@ public struct AppEnvironment { runModel: runModel, buildModel: buildModel, logsModel: logsModel, + copyModel: copyModel, actions: actions, updater: NoopUpdaterController(), terminalSurfaceProvider: terminalSurfaceProvider diff --git a/Sources/CapsuleApp/CapsuleScene.swift b/Sources/CapsuleApp/CapsuleScene.swift index e346f9e..b53ea49 100644 --- a/Sources/CapsuleApp/CapsuleScene.swift +++ b/Sources/CapsuleApp/CapsuleScene.swift @@ -29,6 +29,7 @@ public struct CapsuleScene: Scene { @State private var runModel: RunModel @State private var buildModel: BuildModel @State private var logsModel: LogsModel + @State private var copyModel: CopyModel private let actions: ShellActions private let updater: any UpdaterController private let terminalSurfaceProvider: any TerminalSurfaceProviding @@ -51,6 +52,7 @@ public struct CapsuleScene: Scene { self._runModel = State(initialValue: environment.runModel) self._buildModel = State(initialValue: environment.buildModel) self._logsModel = State(initialValue: environment.logsModel) + self._copyModel = State(initialValue: environment.copyModel) self.actions = environment.actions self.updater = environment.updater self.terminalSurfaceProvider = environment.terminalSurfaceProvider @@ -71,6 +73,7 @@ public struct CapsuleScene: Scene { runModel: runModel, buildModel: buildModel, logsModel: logsModel, + copyModel: copyModel, actions: actions, terminalSurfaceProvider: terminalSurfaceProvider ) diff --git a/Sources/CapsuleDomain/CopyModel.swift b/Sources/CapsuleDomain/CopyModel.swift new file mode 100644 index 0000000..07ec0c1 --- /dev/null +++ b/Sources/CapsuleDomain/CopyModel.swift @@ -0,0 +1,128 @@ +// +// CopyModel.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// NOTE: This module must remain free of UI and of `Foundation.Process`. Backs the copy sheet: +// a host endpoint (a local URL) and a container endpoint (`id:/abs/path`), validated BEFORE +// spawning so the `container:path` semantics are caught early with an example. The copy runs +// as a `.copy` Activity task; the container side can be browsed via a best-effort `ls`. + +import CapsuleBackend +import Foundation +import Observation + +/// A UI-facing directory entry — the domain's own shape so the UI never names a backend +/// type (mirrors how `LogLine` wraps `OutputLine`). +public struct ContainerFileItem: Sendable, Equatable, Identifiable { + public let name: String + public let isDirectory: Bool + + public var id: String { name } + + public init(name: String, isDirectory: Bool) { + self.name = name + self.isDirectory = isDirectory + } +} + +public enum CopyDirection: String, Sendable, CaseIterable, Identifiable { + case toContainer + case fromContainer + + public var id: String { rawValue } + + public var title: String { + switch self { + case .toContainer: return "Host → Container" + case .fromContainer: return "Container → Host" + } + } +} + +@MainActor +@Observable +public final class CopyModel { + public var direction: CopyDirection = .toContainer + public var hostURL: URL? + public var containerID: String = "" + public var containerPath: String = "" + + private let backend: any ContainerBackend + private let taskCenter: TaskCenter + private let onActivity: @MainActor (String) -> Void + + public init( + backend: any ContainerBackend, + taskCenter: TaskCenter, + onActivity: @escaping @MainActor (String) -> Void = { _ in } + ) { + self.backend = backend + self.taskCenter = taskCenter + self.onActivity = onActivity + } + + public func reset(containerID: String = "") { + direction = .toContainer + hostURL = nil + self.containerID = containerID + containerPath = "" + } + + /// Why the copy can't run yet (host endpoint, container id, and an ABSOLUTE container + /// path are all required), or nil when it's ready. The message carries an example so the + /// `container:path` semantics are obvious before spawning. + public var validationMessage: String? { + if hostURL == nil { return "Choose a host file or folder." } + if containerID.trimmingCharacters(in: .whitespaces).isEmpty { + return "Enter the container id." + } + let path = containerPath.trimmingCharacters(in: .whitespaces) + if path.isEmpty || !path.hasPrefix("/") { + return "Container path must be absolute, e.g. \(exampleID):/app/file" + } + return nil + } + + public var canCopy: Bool { validationMessage == nil } + + /// A sample `id:/path` for the UI's example/help text. + public var exampleID: String { + let trimmed = containerID.trimmingCharacters(in: .whitespaces) + return trimmed.isEmpty ? "container" : trimmed + } + + /// Runs the copy as a `.copy` Activity task in the validated direction. + @discardableResult + public func copy() -> OperationTask? { + guard canCopy, let hostURL else { return nil } + let id = containerID.trimmingCharacters(in: .whitespaces) + let path = containerPath.trimmingCharacters(in: .whitespaces) + let direction = direction + let title = + direction == .toContainer + ? "Copy \(hostURL.lastPathComponent) → \(id):\(path)" + : "Copy \(id):\(path) → \(hostURL.lastPathComponent)" + onActivity(title) + return taskCenter.runAsync(kind: .copy, title: title) { [backend] in + switch direction { + case .toContainer: + try await backend.copyToContainer( + source: hostURL, containerID: id, containerPath: path) + case .fromContainer: + try await backend.copyFromContainer( + containerID: id, containerPath: path, destination: hostURL) + } + } + } + + /// Best-effort one-level listing of a container directory (requires a running container + /// with `ls`); degrades to an empty list rather than throwing into the UI. + public func browse(path: String) async -> [ContainerFileItem] { + let id = containerID.trimmingCharacters(in: .whitespaces) + guard !id.isEmpty else { return [] } + let entries = (try? await backend.listContainerDirectory(id: id, path: path)) ?? [] + return entries.map { ContainerFileItem(name: $0.name, isDirectory: $0.isDirectory) } + } +} diff --git a/Sources/CapsuleUI/AppShellView.swift b/Sources/CapsuleUI/AppShellView.swift index c886890..c26db3e 100644 --- a/Sources/CapsuleUI/AppShellView.swift +++ b/Sources/CapsuleUI/AppShellView.swift @@ -25,6 +25,7 @@ public struct AppShellView: View { @Bindable var runModel: RunModel @Bindable var buildModel: BuildModel @Bindable var logsModel: LogsModel + @Bindable var copyModel: CopyModel let actions: ShellActions let terminalSurfaceProvider: any TerminalSurfaceProviding @@ -41,6 +42,7 @@ public struct AppShellView: View { runModel: RunModel, buildModel: BuildModel, logsModel: LogsModel, + copyModel: CopyModel, actions: ShellActions, terminalSurfaceProvider: any TerminalSurfaceProviding = StubTerminalSurfaceProvider() ) { @@ -56,6 +58,7 @@ public struct AppShellView: View { self.runModel = runModel self.buildModel = buildModel self.logsModel = logsModel + self.copyModel = copyModel self.actions = actions self.terminalSurfaceProvider = terminalSurfaceProvider } @@ -119,7 +122,8 @@ public struct AppShellView: View { imageActionsModel: imageActionsModel, runModel: runModel, buildModel: buildModel, - logsModel: logsModel + logsModel: logsModel, + copyModel: copyModel ) .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Sources/CapsuleUI/ContainerListView.swift b/Sources/CapsuleUI/ContainerListView.swift index de6eeab..6d765b5 100644 --- a/Sources/CapsuleUI/ContainerListView.swift +++ b/Sources/CapsuleUI/ContainerListView.swift @@ -18,6 +18,7 @@ struct ContainerListView: View { let lifecycle: ContainerLifecycleModel let stats: ContainerStatsModel let logsModel: LogsModel + @Bindable var copyModel: CopyModel @Environment(\.openWindow) private var openWindow @State private var showingSaveScope = false @@ -28,12 +29,14 @@ struct ContainerListView: View { model: ContainerBrowserModel, lifecycle: ContainerLifecycleModel, stats: ContainerStatsModel, - logsModel: LogsModel + logsModel: LogsModel, + copyModel: CopyModel ) { self.model = model self.lifecycle = lifecycle self.stats = stats self.logsModel = logsModel + self.copyModel = copyModel } var body: some View { @@ -91,6 +94,8 @@ struct ContainerListView: View { case let .exec(id): ExecSheet( containerID: id, lifecycle: lifecycle, onClose: { activeSheet = nil }) + case .copy: + CopySheet(model: copyModel, onClose: { activeSheet = nil }) } } } @@ -195,6 +200,10 @@ struct ContainerListView: View { } if let single = targets.first, targets.count == 1 { Button("Logs…") { openLogs(id: single.id) } + Button("Copy Files…") { + copyModel.reset(containerID: single.id) + activeSheet = .copy + } } Divider() @@ -367,6 +376,7 @@ enum LifecycleSheet: Identifiable { case confirm(ConfirmationRequest) case prune case exec(id: String) + case copy var id: String { switch self { @@ -375,6 +385,7 @@ enum LifecycleSheet: Identifiable { case let .confirm(request): return "confirm-\(request.id)" case .prune: return "prune" case let .exec(id): return "exec-\(id)" + case .copy: return "copy" } } } diff --git a/Sources/CapsuleUI/ContentColumnView.swift b/Sources/CapsuleUI/ContentColumnView.swift index 7e818a4..d6ffcae 100644 --- a/Sources/CapsuleUI/ContentColumnView.swift +++ b/Sources/CapsuleUI/ContentColumnView.swift @@ -23,6 +23,7 @@ struct ContentColumnView: View { let runModel: RunModel let buildModel: BuildModel let logsModel: LogsModel + let copyModel: CopyModel private var onRecover: (RecoveryAction) -> Void { actions.recover } @@ -47,7 +48,7 @@ struct ContentColumnView: View { case .containers: ContainerListView( model: browserModel, lifecycle: lifecycleModel, stats: statsModel, - logsModel: logsModel) + logsModel: logsModel, copyModel: copyModel) case .images: ImageListView( model: imageBrowserModel, actions: imageActionsModel, runModel: runModel, diff --git a/Sources/CapsuleUI/CopySheet.swift b/Sources/CapsuleUI/CopySheet.swift new file mode 100644 index 0000000..7d9e540 --- /dev/null +++ b/Sources/CapsuleUI/CopySheet.swift @@ -0,0 +1,180 @@ +// +// CopySheet.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// +// Copy files between the host and a container. A host panel (drop target + Choose…) and a +// container panel (`id:/abs/path` with a Browse disclosure backed by a best-effort `ls`) +// sit either side of a direction control. The container-path semantics are validated and +// illustrated with an example before the Copy runs as a `.copy` Activity task. + +import AppKit +import CapsuleDomain +import SwiftUI + +struct CopySheet: View { + @Bindable var model: CopyModel + var onClose: () -> Void + + @State private var activeTask: OperationTask? + @State private var isHostTargeted = false + @State private var browseEntries: [ContainerFileItem] = [] + @State private var browsePath = "/" + @State private var isBrowsing = false + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Label("Copy Files", systemImage: "doc.on.doc") + .font(.headline) + Spacer() + } + + Picker("Direction", selection: $model.direction) { + ForEach(CopyDirection.allCases) { Text($0.title).tag($0) } + } + .pickerStyle(.segmented) + + hostPanel + containerPanel + + if let message = model.validationMessage { + Label(message, systemImage: "info.circle") + .font(.caption) + .foregroundStyle(.secondary) + } + + if let task = activeTask { + TaskTranscriptView(task: task) + } + + HStack { + Button("Close", action: onClose) + .keyboardShortcut(.cancelAction) + Spacer() + Button("Copy") { activeTask = model.copy() } + .keyboardShortcut(.defaultAction) + .disabled(!model.canCopy) + } + } + .padding(16) + .frame(width: 520, height: 560) + } + + private var hostPanel: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Host").font(.caption).foregroundStyle(.secondary) + RoundedRectangle(cornerRadius: 8) + .strokeBorder( + isHostTargeted ? Color.accentColor : Color.secondary.opacity(0.4), + style: StrokeStyle(lineWidth: 1.5, dash: [6]) + ) + .frame(height: 56) + .overlay { + HStack { + Image(systemName: "macwindow") + Text(model.hostURL?.path ?? "Drag a file here, or Choose…") + .foregroundStyle(model.hostURL == nil ? .secondary : .primary) + .lineLimit(1).truncationMode(.middle) + Spacer() + Button("Choose…") { chooseHost() } + } + .padding(.horizontal, 12) + } + .onDrop(of: [.fileURL], isTargeted: $isHostTargeted) { providers in + loadDroppedHostURL(providers) + } + } + } + + private var containerPanel: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Container").font(.caption).foregroundStyle(.secondary) + HStack { + TextField("container id", text: $model.containerID) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 160) + Text(":").foregroundStyle(.secondary) + TextField("/app/file", text: $model.containerPath) + .textFieldStyle(.roundedBorder) + } + Text("Example: \(model.exampleID):/app/file") + .font(.caption2) + .foregroundStyle(.secondary) + + DisclosureGroup("Browse", isExpanded: $isBrowsing) { + browseList + } + .onChange(of: isBrowsing) { _, expanded in + if expanded { Task { await reloadBrowse(path: browsePath) } } + } + } + } + + private var browseList: some View { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(browsePath).font(.system(.caption, design: .monospaced)) + Spacer() + Button("Up") { + let parent = (browsePath as NSString).deletingLastPathComponent + Task { await reloadBrowse(path: parent.isEmpty ? "/" : parent) } + } + .disabled(browsePath == "/") + } + if browseEntries.isEmpty { + Text("No entries (the container must be running with ls).") + .font(.caption).foregroundStyle(.secondary) + } else { + ForEach(browseEntries) { entry in + Button { + selectEntry(entry) + } label: { + HStack { + Image(systemName: entry.isDirectory ? "folder" : "doc") + Text(entry.name) + Spacer() + } + } + .buttonStyle(.plain) + } + } + } + .frame(maxHeight: 160) + } + + private func selectEntry(_ entry: ContainerFileItem) { + let joined = (browsePath as NSString).appendingPathComponent(entry.name) + if entry.isDirectory { + Task { await reloadBrowse(path: joined) } + } else { + model.containerPath = joined + } + } + + private func reloadBrowse(path: String) async { + browsePath = path + browseEntries = await model.browse(path: path) + } + + private func chooseHost() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + panel.title = "Choose a File or Folder" + if panel.runModal() == .OK, let url = panel.url { + model.hostURL = url + } + } + + private func loadDroppedHostURL(_ providers: [NSItemProvider]) -> Bool { + guard let provider = providers.first else { return false } + _ = provider.loadObject(ofClass: URL.self) { url, _ in + guard let url else { return } + Task { @MainActor in model.hostURL = url } + } + return true + } +} diff --git a/Sources/CapsuleUI/RootView.swift b/Sources/CapsuleUI/RootView.swift index 61340bf..59c73dd 100644 --- a/Sources/CapsuleUI/RootView.swift +++ b/Sources/CapsuleUI/RootView.swift @@ -27,6 +27,7 @@ public struct RootView: View { private let runModel: RunModel private let buildModel: BuildModel private let logsModel: LogsModel + private let copyModel: CopyModel private let actions: ShellActions private let terminalSurfaceProvider: any TerminalSurfaceProviding @@ -45,6 +46,7 @@ public struct RootView: View { runModel: RunModel, buildModel: BuildModel, logsModel: LogsModel, + copyModel: CopyModel, actions: ShellActions, terminalSurfaceProvider: any TerminalSurfaceProviding = StubTerminalSurfaceProvider() ) { @@ -60,6 +62,7 @@ public struct RootView: View { self.runModel = runModel self.buildModel = buildModel self.logsModel = logsModel + self.copyModel = copyModel self.actions = actions self.terminalSurfaceProvider = terminalSurfaceProvider } @@ -78,6 +81,7 @@ public struct RootView: View { runModel: runModel, buildModel: buildModel, logsModel: logsModel, + copyModel: copyModel, actions: actions, terminalSurfaceProvider: terminalSurfaceProvider ) diff --git a/Tests/CapsuleUnitTests/CopyModelTests.swift b/Tests/CapsuleUnitTests/CopyModelTests.swift new file mode 100644 index 0000000..367a9a4 --- /dev/null +++ b/Tests/CapsuleUnitTests/CopyModelTests.swift @@ -0,0 +1,70 @@ +// +// CopyModelTests.swift +// Capsule +// +// Copyright © 2026 Capsule. All rights reserved. +// + +import CapsuleBackend +import XCTest + +@testable import CapsuleDomain + +@MainActor +final class CopyModelTests: XCTestCase { + func testValidationRequiresEndpointsAndAbsoluteContainerPath() { + let m = CopyModel(backend: MockBackend(), taskCenter: TaskCenter()) + XCTAssertNotNil(m.validationMessage) // nothing set + m.containerID = "c1" + m.containerPath = "relative" + m.hostURL = URL(fileURLWithPath: "/h/f") + XCTAssertFalse(m.canCopy) + XCTAssertTrue(m.validationMessage?.contains("absolute") ?? false) + m.containerPath = "/app" + XCTAssertTrue(m.canCopy) + XCTAssertNil(m.validationMessage) + } + + func testCopyToContainerRegistersTaskAndCallsBackend() async { + let backend = MockBackend() + let center = TaskCenter() + let m = CopyModel(backend: backend, taskCenter: center) + m.direction = .toContainer + m.hostURL = URL(fileURLWithPath: "/h/f") + m.containerID = "c1" + m.containerPath = "/app/f" + let task = m.copy() + await task?.wait() + XCTAssertEqual(center.tasks.first?.kind, .copy) + XCTAssertEqual(backend.lastCopy?.direction, .toContainer) + XCTAssertEqual(backend.lastCopy?.containerID, "c1") + XCTAssertEqual(backend.lastCopy?.containerPath, "/app/f") + } + + func testCopyFromContainerRoutesDirection() async { + let backend = MockBackend() + let m = CopyModel(backend: backend, taskCenter: TaskCenter()) + m.direction = .fromContainer + m.hostURL = URL(fileURLWithPath: "/h/dest") + m.containerID = "c2" + m.containerPath = "/var/log" + let task = m.copy() + await task?.wait() + XCTAssertEqual(backend.lastCopy?.direction, .fromContainer) + XCTAssertEqual(backend.lastCopy?.containerID, "c2") + } + + func testCopyReturnsNilWhenInvalid() { + let m = CopyModel(backend: MockBackend(), taskCenter: TaskCenter()) + XCTAssertNil(m.copy()) + } + + func testBrowseReturnsEntriesAndDegradesWhenNoID() async { + let m = CopyModel(backend: MockBackend(), taskCenter: TaskCenter()) + let emptyResult = await m.browse(path: "/") + XCTAssertTrue(emptyResult.isEmpty) // no container id yet + m.containerID = "c1" + let seededResult = await m.browse(path: "/") + XCTAssertFalse(seededResult.isEmpty) // seeded entries + } +} From b3f42fbfac25240048fd68112693bef39e13217f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barr=C4=B1=C5=9Fcan=20Aslan?= Date: Mon, 29 Jun 2026 01:29:22 +0300 Subject: [PATCH 12/13] test(app): assert the live environment exposes the M7 models + shared TaskCenter Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QRY8sjZCmM87FEsjo8A4he --- Tests/CapsuleUnitTests/CompositionTests.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Tests/CapsuleUnitTests/CompositionTests.swift b/Tests/CapsuleUnitTests/CompositionTests.swift index 3eee68c..aaecbb3 100644 --- a/Tests/CapsuleUnitTests/CompositionTests.swift +++ b/Tests/CapsuleUnitTests/CompositionTests.swift @@ -43,4 +43,16 @@ final class CompositionTests: XCTestCase { XCTAssertNil(environment.lifecycleModel.notice) XCTAssertTrue(environment.statsModel.metrics.isEmpty) } + + @MainActor + func testLiveEnvironmentExposesM7Models() { + let environment = AppEnvironment.live() + + // Run/Build/Copy share the single TaskCenter so their jobs land in the Activity pane. + XCTAssertTrue(environment.runModel.draft.image.isEmpty) + XCTAssertTrue(environment.buildModel.draft.tag.isEmpty) + XCTAssertNil(environment.logsModel.containerID) + XCTAssertEqual(environment.copyModel.direction, .toContainer) + XCTAssertTrue(environment.taskCenter.tasks.isEmpty) + } } From 423270f89c6d574021b94a4985e6c4b2437a4c56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barr=C4=B1=C5=9Fcan=20Aslan?= Date: Mon, 29 Jun 2026 02:59:30 +0300 Subject: [PATCH 13/13] fix(m7): address adversarial review (logs race, resolve-image ref, log tail, temp cleanup) - LogsModel: a replaced (cancelled) follow task no longer clears isStreaming on its successor (stale-task race); regression test added - Run triage 'Resolve Image' now prefills the Pull sheet with the failed image (ImageSheet.pull carries a reference; PullImageSheet gains initialReference) - fetchLogs drops the trailing newline so a snapshot has no spurious blank line - Open-in-Terminal.app sweeps its throwaway .command script 10s after launch No Critical/High findings; argv, cancellation, isolation, and arch boundary all verified correct by the review. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QRY8sjZCmM87FEsjo8A4he --- Sources/CapsuleApp/AppEnvironment.swift | 6 ++++++ .../CapsuleCLIBackend/CLIContainerBackend.swift | 7 ++++++- Sources/CapsuleDomain/LogsModel.swift | 4 +++- Sources/CapsuleUI/ImageListView.swift | 11 ++++++----- Sources/CapsuleUI/PullImageSheet.swift | 14 +++++++++++++- .../CLIContainerBackendTests.swift | 11 +++++++++++ Tests/CapsuleUnitTests/LogsModelTests.swift | 16 ++++++++++++++++ 7 files changed, 61 insertions(+), 8 deletions(-) diff --git a/Sources/CapsuleApp/AppEnvironment.swift b/Sources/CapsuleApp/AppEnvironment.swift index ade74a1..b71e402 100644 --- a/Sources/CapsuleApp/AppEnvironment.swift +++ b/Sources/CapsuleApp/AppEnvironment.swift @@ -235,6 +235,12 @@ func openCommandInTerminalApp(_ argv: [String], executablePath: String) { try script.write(to: url, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) NSWorkspace.shared.open(url) + // Sweep the throwaway script once Terminal has had time to read it, so they don't + // accumulate in the temp directory. + Task { + try? await Task.sleep(for: .seconds(10)) + try? FileManager.default.removeItem(at: url) + } } catch { // Non-fatal: the embedded terminal remains available. } diff --git a/Sources/CapsuleCLIBackend/CLIContainerBackend.swift b/Sources/CapsuleCLIBackend/CLIContainerBackend.swift index 035fe2c..ea9f008 100644 --- a/Sources/CapsuleCLIBackend/CLIContainerBackend.swift +++ b/Sources/CapsuleCLIBackend/CLIContainerBackend.swift @@ -148,7 +148,12 @@ public struct CLIContainerBackend: ContainerBackend { { let output = try await runChecked( CLICommand.fetchLogs(container: id, tail: tail, boot: boot)) - return output.stdout + // Drop the trailing newline so a normal `…\n`-terminated snapshot doesn't yield a + // spurious blank final line in the pane or the saved transcript. + let text = output.stdout.hasSuffix("\n") ? String(output.stdout.dropLast()) : output.stdout + guard !text.isEmpty else { return [] } + return + text .split(separator: "\n", omittingEmptySubsequences: false) .map { OutputLine(source: .stdout, text: String($0)) } } diff --git a/Sources/CapsuleDomain/LogsModel.swift b/Sources/CapsuleDomain/LogsModel.swift index fcdb70e..205b47e 100644 --- a/Sources/CapsuleDomain/LogsModel.swift +++ b/Sources/CapsuleDomain/LogsModel.swift @@ -61,7 +61,9 @@ public final class LogsModel { } catch { // Stream ended or was cancelled; the buffer keeps what it captured. } - self.isStreaming = false + // Only a naturally-ended stream clears the flag. A cancelled task (replaced by + // a restart) must NOT clobber the successor's `isStreaming = true`. + if !Task.isCancelled { self.isStreaming = false } } } else { task = Task { [weak self, backend, tail, boot] in diff --git a/Sources/CapsuleUI/ImageListView.swift b/Sources/CapsuleUI/ImageListView.swift index f18323c..50834e2 100644 --- a/Sources/CapsuleUI/ImageListView.swift +++ b/Sources/CapsuleUI/ImageListView.swift @@ -50,8 +50,9 @@ struct ImageListView: View { Task { await actions.tag(source: reference, target: target) } }, onCancel: { activeSheet = nil }) - case .pull: + case let .pull(reference): PullImageSheet( + initialReference: reference, onPull: { ref, plat in actions.pull(reference: ref, platform: plat) }, onRetry: { actions.retryTask($0) }, onClose: { activeSheet = nil }) @@ -78,7 +79,7 @@ struct ImageListView: View { case let .run(image): QuickRunSheet( model: runModel, - onResolveImage: { _ in activeSheet = .pull }, + onResolveImage: { ref in activeSheet = .pull(reference: ref) }, onClose: { activeSheet = nil } ) .onAppear { runModel.reset(image: image) } @@ -200,7 +201,7 @@ struct ImageListView: View { .help("Build an image from a Dockerfile") Button { - activeSheet = .pull + activeSheet = .pull(reference: "") } label: { Label("Pull", systemImage: "arrow.down.circle") } @@ -290,7 +291,7 @@ struct ImageListView: View { /// Which image sheet is presented. enum ImageSheet: Identifiable { case tag(reference: String, digest: String) - case pull + case pull(reference: String) case push(reference: String, digest: String) case load case confirm(ConfirmationRequest) @@ -302,7 +303,7 @@ enum ImageSheet: Identifiable { switch self { case let .tag(reference, _): return "tag-\(reference)" case .pull: return "pull" - case let .push(reference, _): return "push-\(reference)" + case let .push(reference, _): return "push-\(reference)" // id stays stable case .load: return "load" case let .confirm(request): return "confirm-\(request.id)" case .prune: return "prune" diff --git a/Sources/CapsuleUI/PullImageSheet.swift b/Sources/CapsuleUI/PullImageSheet.swift index d497901..1d3e5e4 100644 --- a/Sources/CapsuleUI/PullImageSheet.swift +++ b/Sources/CapsuleUI/PullImageSheet.swift @@ -16,10 +16,22 @@ struct PullImageSheet: View { let onRetry: (OperationTask) -> Void let onClose: () -> Void - @State private var reference = "" + @State private var reference: String @State private var platform = "" @State private var task: OperationTask? + init( + initialReference: String = "", + onPull: @escaping (String, String?) -> OperationTask, + onRetry: @escaping (OperationTask) -> Void, + onClose: @escaping () -> Void + ) { + self.onPull = onPull + self.onRetry = onRetry + self.onClose = onClose + self._reference = State(initialValue: initialReference) + } + private var trimmedReference: String { reference.trimmingCharacters(in: .whitespacesAndNewlines) } diff --git a/Tests/CapsuleUnitTests/CLIContainerBackendTests.swift b/Tests/CapsuleUnitTests/CLIContainerBackendTests.swift index 48f094d..4414802 100644 --- a/Tests/CapsuleUnitTests/CLIContainerBackendTests.swift +++ b/Tests/CapsuleUnitTests/CLIContainerBackendTests.swift @@ -417,6 +417,17 @@ final class CLIContainerBackendTests: XCTestCase { XCTAssertEqual(stub.lastCall, ["logs", "-n", "50", "c1"]) } + func testFetchLogsDropsTrailingNewline() async throws { + let stub = StubProcessRunner() + stub.result = CommandResult(exitCode: 0, stdout: "a\nb\n", stderr: "") + let lines = try await makeBackend(stub).fetchLogs(container: "c1", tail: nil, boot: false) + XCTAssertEqual(lines.map(\.text), ["a", "b"]) // no spurious blank final line + + stub.result = CommandResult(exitCode: 0, stdout: "", stderr: "") + let empty = try await makeBackend(stub).fetchLogs(container: "c1", tail: nil, boot: false) + XCTAssertTrue(empty.isEmpty) + } + func testListDirectoryParsesLsLeniently() async throws { let stub = StubProcessRunner() stub.result = CommandResult( diff --git a/Tests/CapsuleUnitTests/LogsModelTests.swift b/Tests/CapsuleUnitTests/LogsModelTests.swift index 999f924..a42bdea 100644 --- a/Tests/CapsuleUnitTests/LogsModelTests.swift +++ b/Tests/CapsuleUnitTests/LogsModelTests.swift @@ -61,6 +61,22 @@ final class LogsModelTests: XCTestCase { XCTAssertEqual(model.transcriptText, "one\ntwo") } + func testRestartWhileFollowingKeepsStreaming() async { + // Regression: the replaced (cancelled) follow task must not clear `isStreaming` after + // the successor has already set it true. + let backend = MockBackend() + backend.neverEndingLogStream = true + let model = LogsModel(backend: backend) + model.follow = true + model.start(id: "a") + await Task.yield() + model.start(id: "b") // cancels a's task, starts b's + for _ in 0..<10 { await Task.yield() } + XCTAssertTrue(model.isStreaming) + XCTAssertEqual(model.containerID, "b") + model.stop() + } + func testStopCancelsFollowStream() async { let backend = MockBackend() backend.neverEndingLogStream = true