Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 88 additions & 10 deletions Sources/CapsuleApp/AppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ public struct AppEnvironment {
public var imageActionsModel: ImageActionsModel
public var taskCenter: TaskCenter
public var registriesModel: RegistriesModel
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
Expand All @@ -48,6 +52,10 @@ public struct AppEnvironment {
imageActionsModel: ImageActionsModel,
taskCenter: TaskCenter,
registriesModel: RegistriesModel,
runModel: RunModel,
buildModel: BuildModel,
logsModel: LogsModel,
copyModel: CopyModel,
actions: ShellActions,
updater: any UpdaterController,
terminalSurfaceProvider: any TerminalSurfaceProviding = StubTerminalSurfaceProvider()
Expand All @@ -62,6 +70,10 @@ public struct AppEnvironment {
self.imageActionsModel = imageActionsModel
self.taskCenter = taskCenter
self.registriesModel = registriesModel
self.runModel = runModel
self.buildModel = buildModel
self.logsModel = logsModel
self.copyModel = copyModel
self.actions = actions
self.updater = updater
self.terminalSurfaceProvider = terminalSurfaceProvider
Expand All @@ -72,10 +84,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(
Expand All @@ -90,7 +104,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) },
Expand All @@ -103,6 +116,17 @@ 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 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) },
Expand All @@ -112,15 +136,32 @@ 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) },
openExternalTerminal: openInTerminalApp,
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 buildModel = BuildModel(
backend: backend,
taskCenter: taskCenter,
normalize: { ErrorNormalizer.normalize($0) },
onActivity: { line in shell.appendActivity(line) },
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
})
Expand All @@ -136,6 +177,10 @@ public struct AppEnvironment {
imageActionsModel: imageActionsModel,
taskCenter: taskCenter,
registriesModel: registriesModel,
runModel: runModel,
buildModel: buildModel,
logsModel: logsModel,
copyModel: copyModel,
actions: actions,
updater: NoopUpdaterController(),
terminalSurfaceProvider: terminalSurfaceProvider
Expand Down Expand Up @@ -172,3 +217,36 @@ 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)
// 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.
}
}

/// Single-quotes a token for safe inclusion in a `/bin/sh` command line.
private func shellQuote(_ token: String) -> String {
"'" + token.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
3 changes: 3 additions & 0 deletions Sources/CapsuleApp/CapsuleCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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).
Expand Down
16 changes: 16 additions & 0 deletions Sources/CapsuleApp/CapsuleScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ public struct CapsuleScene: Scene {
@State private var imageActionsModel: ImageActionsModel
@State private var taskCenter: TaskCenter
@State private var registriesModel: RegistriesModel
@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
Expand All @@ -45,6 +49,10 @@ 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._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
Expand All @@ -62,6 +70,10 @@ public struct CapsuleScene: Scene {
imageBrowserModel: imageBrowserModel,
imageActionsModel: imageActionsModel,
taskCenter: taskCenter,
runModel: runModel,
buildModel: buildModel,
logsModel: logsModel,
copyModel: copyModel,
actions: actions,
terminalSurfaceProvider: terminalSurfaceProvider
)
Expand All @@ -75,6 +87,10 @@ public struct CapsuleScene: Scene {
)
}

Window("Logs", id: LogWindow.id) {
LogWindowView(model: logsModel)
}

Settings {
PreferencesView(registriesModel: registriesModel)
}
Expand Down
51 changes: 51 additions & 0 deletions Sources/CapsuleBackend/BuildConfiguration.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
23 changes: 23 additions & 0 deletions Sources/CapsuleBackend/ContainerBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<OutputLine, Error>

/// 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 <src> <id>:<path>`).
func copyToContainer(source: URL, containerID: String, containerPath: String) async throws

/// Copies a path out of a running container to the host (`copy <id>:<path> <dst>`).
func copyFromContainer(
containerID: String, containerPath: String, destination: URL
) async throws

/// Lists a directory inside a running container (best-effort `exec <id> ls -la <path>`).
func listContainerDirectory(id: String, path: String) async throws -> [ContainerFileEntry]

// MARK: Images

func listImages() async throws -> [ImageSummary]
Expand All @@ -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<OutputLine, Error>

// MARK: Volumes / networks / registries / machines / builder

func listVolumes() async throws -> [VolumeSummary]
Expand Down
26 changes: 26 additions & 0 deletions Sources/CapsuleBackend/ContainerFileEntry.swift
Original file line number Diff line number Diff line change
@@ -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 <id> ls -la <path>` (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
}
}
Loading
Loading