{
  "format" : 1,
  "manifest" : {
    "capabilities" : [
      {
        "run" : {
          "commands" : [
            "docker"
          ]
        }
      }
    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/Developer/Docker",
    "id" : "io.navbytes.stackbar.plugins.docker",
    "minimumStackbar" : "0.11.0",
    "name" : "Docker",
    "summary" : "Your containers, with start, stop, restart and logs a click away.",
    "surfaces" : [
      "menuBar",
      "panel",
      "settings"
    ],
    "triggers" : [
      {
        "launch" : {

        }
      },
      {
        "every" : {
          "seconds" : 15
        }
      },
      {
        "wake" : {

        }
      }
    ],
    "version" : "1.0.0"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "wPS7BrdpyPWgsMSkeK90Bwy4UIsEZPa1pCMToBn1m5bYClZO7W/CaHEfCo5PX8lYGOubZ/t29vxD3qu00QUnAg==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// Your Docker containers: what is running, with start, stop, restart, logs\n/// and cleanup a click away.\n///\n/// It runs the `docker` CLI through Stackbar (Docker Desktop, OrbStack,\n/// Colima and Rancher Desktop all install one), every 15 seconds while a\n/// container runs and every minute otherwise. Nothing runs in a shell.\n@main\nfinal class Docker: StackbarPlugin {\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.plugins.docker\",\n        name: \"Docker\",\n        version: \"1.0.0\",\n        summary: \"Your containers, with start, stop, restart and logs a click away.\",\n        surfaces: [.menuBar, .panel, .settings],\n        triggers: [.launch, .every(.seconds(15)), .wake],\n        capabilities: [.run(commands: [\"docker\"])],\n        minimumStackbar: \"0.11.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/Developer/Docker\"\n    )\n\n    /// One line of `docker ps --format '{{json .}}'`.\n    struct Container: Decodable, Equatable, Identifiable {\n        let id: String\n        let names: String\n        let image: String\n        let state: String\n        let status: String\n        let ports: String?\n        let labels: String?\n\n        enum CodingKeys: String, CodingKey {\n            case id = \"ID\", names = \"Names\", image = \"Image\", state = \"State\", status = \"Status\", ports = \"Ports\", labels = \"Labels\"\n        }\n\n        var name: String { names.split(separator: \",\").first.map(String.init) ?? names }\n        var isRunning: Bool { state == \"running\" || state == \"restarting\" }\n        /// The compose project, from its label.\n        var project: String? {\n            labels?.split(separator: \",\").first { $0.hasPrefix(\"com.docker.compose.project=\") }\n                .map { String($0.dropFirst(\"com.docker.compose.project=\".count)) }\n        }\n        /// `0.0.0.0:8080->80/tcp` becomes `http://localhost:8080`.\n        var localURLs: [String] {\n            (ports ?? \"\").split(separator: \",\").compactMap { mapping -> String? in\n                let part = mapping.trimmingCharacters(in: .whitespaces)\n                guard let arrow = part.range(of: \"->\"), part[arrow.upperBound...].hasSuffix(\"/tcp\") else { return nil }\n                guard let port = part[..<arrow.lowerBound].split(separator: \":\").last, Int(port) != nil else { return nil }\n                return \"http://localhost:\\(port)\"\n            }.reduce(into: [String]()) { if !$0.contains($1) { $0.append($1) } }\n        }\n    }\n\n    var containers: [Container] = []\n    var loaded = false\n    var problem: String?\n    var busy: Set<String> = []\n    var logs: [String: String] = [:]\n    var lastSlowCheck = Date.distantPast\n\n    @Stored(\"showStopped\") var showStopped = true\n    @Stored(\"groupByProject\") var groupByProject = true\n\n    var running: [Container] { containers.filter(\\.isRunning) }\n    var shown: [Container] { showStopped ? containers : running }\n\n    func refresh() async throws {\n        // Every 15 s while something runs; once a minute otherwise.\n        if running.isEmpty, loaded, problem == nil, Date().timeIntervalSince(lastSlowCheck) < 55 { return }\n        await load()\n    }\n\n    func load() async {\n        lastSlowCheck = Date()\n        do {\n            let result = try await Stackbar.run(\"docker\", [\"ps\", \"--all\", \"--no-trunc\", \"--format\", \"{{json .}}\"], timeout: .seconds(20))\n            guard result.succeeded else {\n                problem = Self.explain(result)\n                containers = []\n                loaded = true\n                return\n            }\n            let decoder = JSONDecoder()\n            containers = result.text.split(separator: \"\\n\").compactMap { try? decoder.decode(Container.self, from: Data($0.utf8)) }\n                .sorted { ($0.isRunning ? 0 : 1, $0.name) < ($1.isRunning ? 0 : 1, $1.name) }\n            problem = nil\n        } catch {\n            problem = \"\\(error)\".contains(\"not installed\")\n                ? \"The docker command isn't installed. Install Docker Desktop, OrbStack or Colima, then refresh.\"\n                : \"\\(error)\"\n        }\n        loaded = true\n    }\n\n    static func explain(_ result: RunResult) -> String {\n        let text = result.errorText\n        if text.contains(\"Cannot connect to the Docker daemon\") || text.contains(\"docker daemon running\") || text.contains(\"connect: no such file\") {\n            return \"Docker isn't running. Start Docker Desktop, OrbStack or Colima.\"\n        }\n        if result.timedOut { return \"docker took too long to answer\" }\n        return \"docker failed (\\(result.status)): \" + (text.split(separator: \"\\n\").first.map(String.init) ?? \"\")\n    }\n\n    func act(_ verb: String, _ container: Container) async throws {\n        busy.insert(container.id)\n        defer { busy.remove(container.id) }\n        let result = try await Stackbar.run(\"docker\", [verb, container.id], timeout: .seconds(90))\n        guard result.succeeded else { throw HostError(\"\\(verb) \\(container.name): \" + Self.explain(result)) }\n        await load()\n    }\n\n    func loadLogs(_ id: String, lines: Int = 200) async {\n        do {\n            let result = try await Stackbar.run(\"docker\", [\"logs\", \"--tail\", \"\\(lines)\", \"--timestamps\", id], timeout: .seconds(20))\n            // docker logs writes the container's stderr to stderr.\n            let text = [result.text, result.errorText].filter { !$0.isEmpty }.joined(separator: \"\\n\")\n            logs[id] = result.succeeded ? (text.isEmpty ? \"(no output)\" : text) : Self.explain(result)\n        } catch {\n            logs[id] = \"\\(error)\"\n        }\n    }\n\n    // MARK: Surfaces\n\n    var menuBar: ViewContent {\n        if problem != nil, loaded {\n            Label(\"\", symbol: \"shippingbox\").role(.secondary)\n        } else {\n            Label(running.isEmpty ? \"\" : \"\\(running.count)\", symbol: running.isEmpty ? \"shippingbox\" : \"shippingbox.fill\").role(running.isEmpty ? .secondary : .accent)\n        }\n    }\n\n    var panel: ViewContent {\n        if let problem {\n            ContentUnavailableView(\"Docker isn't available\", symbol: \"shippingbox\", message: problem)\n            Button(\"Try Again\", symbol: \"arrow.clockwise\") { await self.load() }\n        } else if !loaded {\n            ContentUnavailableView(\"Loading…\", symbol: \"shippingbox\")\n        } else if shown.isEmpty {\n            ContentUnavailableView(containers.isEmpty ? \"No containers\" : \"Nothing running\", symbol: \"shippingbox\", message: containers.isEmpty ? \"Containers you create show up here.\" : \"\\(containers.count) stopped; show them in Settings.\")\n        } else if groupByProject {\n            let groups = Dictionary(grouping: shown) { $0.project ?? \"\" }\n            ForEach(groups.keys.sorted { ($0.isEmpty ? 1 : 0, $0) < ($1.isEmpty ? 1 : 0, $1) }) { project in\n                Section(project.isEmpty ? \"Containers\" : project) {\n                    ForEach(groups[project] ?? []) { container in self.row(container) }\n                }\n            }\n        } else {\n            List(shown, searchable: shown.count > 8) { container in self.row(container) }\n        }\n    }\n\n    func row(_ container: Container) -> Row {\n        var row = Row(container.name, subtitle: \"\\(container.image) · \\(container.status)\", symbol: container.isRunning ? \"play.circle.fill\" : \"stop.circle\", key: container.id)\n            .badge(busy.contains(container.id) ? \"…\" : container.state, role: container.isRunning ? .success : .secondary)\n        if let url = container.localURLs.first {\n            row = row.action(\"Open \\(url.dropFirst(\"http://\".count))\") { Stackbar.open(url) }\n        }\n        if container.isRunning {\n            row = row\n                .action(\"Stop\", shortcut: \"cmd+.\") { try await self.act(\"stop\", container) }\n                .action(\"Restart\", shortcut: \"cmd+r\") { try await self.act(\"restart\", container) }\n        } else {\n            row = row.action(\"Start\", shortcut: \"cmd+return\") { try await self.act(\"start\", container) }\n        }\n        return row\n            .navigate(\"Logs\", to: \"logs\", arguments: [\"container\": container.id], shortcut: \"cmd+l\")\n            .action(\"Copy ID\", shortcut: \"cmd+shift+c\") { Stackbar.copy(String(container.id.prefix(12))) }\n            .action(\"Copy Exec Command\") { Stackbar.copy(\"docker exec -it \\(container.name) sh\") }\n            .action(\"Remove\", shortcut: \"ctrl+x\", role: .critical) {\n                guard !container.isRunning else { throw HostError(\"Stop \\(container.name) before removing it\") }\n                try await self.act(\"rm\", container)\n            }\n    }\n\n    var settings: ViewContent {\n        Section(\"Show\") {\n            Toggle(\"Stopped containers\", isOn: showStopped) { self.showStopped = $0 }\n            Toggle(\"Group by Compose project\", isOn: groupByProject) { self.groupByProject = $0 }\n        }\n        Section(\"Cleanup\") {\n            Button(\"Remove Stopped Containers\", symbol: \"trash\", role: .critical) { _ = try await self.prune() }\n            Text(\"Runs docker container prune. Images and volumes are left alone.\").style(.caption)\n        }\n    }\n\n    func prune() async throws -> String {\n        let result = try await Stackbar.run(\"docker\", [\"container\", \"prune\", \"--force\"], timeout: .seconds(120))\n        guard result.succeeded else { throw HostError(Self.explain(result)) }\n        await load()\n        return result.text.split(separator: \"\\n\").last.map(String.init) ?? \"Removed stopped containers\"\n    }\n\n    // MARK: Commands\n\n    func find(_ text: String?) -> Container? {\n        guard let text, !text.isEmpty else { return nil }\n        return containers.first { $0.id.hasPrefix(text) || $0.name == text } ?? containers.first { $0.name.localizedCaseInsensitiveContains(text) }\n    }\n\n    var commands: [Command] {\n        Command(\"containers\", title: \"Docker Containers\", symbol: \"shippingbox\", keywords: [\"docker\", \"container\", \"compose\"])\n            .view(search: { _ in await self.load() }) { _ in\n                if let problem = self.problem {\n                    ContentUnavailableView(\"Docker isn't available\", symbol: \"shippingbox\", message: problem)\n                } else {\n                    List(self.shown, searchable: true) { container in self.row(container) }\n                }\n            }\n        Command(\"logs\", title: \"Docker Logs\", symbol: \"text.alignleft\", keywords: [\"docker\"])\n            .argument(\"container\", placeholder: \"Container\")\n            .view(search: { invocation in\n                if self.containers.isEmpty { await self.load() }\n                if let container = self.find(invocation[\"container\"]) { await self.loadLogs(container.id) }\n            }) { invocation in\n                if let container = self.find(invocation[\"container\"]) {\n                    Section(\"\\(container.name) · last 200 lines\") {\n                        Text(String((self.logs[container.id] ?? \"Loading…\").suffix(20_000))).style(.monospaced)\n                    }\n                    Button(\"Refresh\", symbol: \"arrow.clockwise\") { await self.loadLogs(container.id) }\n                    Button(\"Copy\", symbol: \"doc.on.doc\") { Stackbar.copy(self.logs[container.id] ?? \"\") }\n                } else {\n                    ContentUnavailableView(\"No container \\(invocation[\"container\"] ?? \"\")\", symbol: \"shippingbox\")\n                }\n            }\n            .stackbarOnly()\n        Command(\"prune\", title: \"Remove Stopped Docker Containers\", symbol: \"trash\", keywords: [\"docker\", \"prune\", \"clean\"]) { _ in\n            .message(try await self.prune())\n        }\n        .stackbarOnly()\n    }\n\n    var tools: [AgentTool] {\n        AgentTool(\"containers\", description: \"Docker containers with name, image, state and status\") { _ in\n            if self.containers.isEmpty { await self.load() }\n            if let problem = self.problem { return problem }\n            return self.containers.map { \"\\($0.name)\\t\\($0.image)\\t\\($0.state)\\t\\($0.status)\" }.joined(separator: \"\\n\")\n        }\n    }\n}\n",
  "sourceDigest" : "d7dc95e3dd2972097c9db89035c9f05e9a7560d5e1b65134f49998610e9f1716"
}