diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
deleted file mode 100644
index 18c6da1..0000000
--- a/.devcontainer/Dockerfile
+++ /dev/null
@@ -1,12 +0,0 @@
-ARG VARIANT="bullseye"
-FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT}
-
-RUN printf "alias cls='clear'\nalias ll='ls -l --human-readable --color=auto --group-directories-first --classify --time-style=long-iso -all'" >> /etc/bash.bashrc
-
-ENV PATH="/home/vscode/.cargo/bin:${PATH}"
-
-RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
- && apt-get -y install --no-install-recommends build-essential pkg-config libssl-dev
-
-USER vscode
-RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
\ No newline at end of file
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
deleted file mode 100644
index fb2d38a..0000000
--- a/.devcontainer/devcontainer.json
+++ /dev/null
@@ -1,53 +0,0 @@
-// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
-// https://github.com/microsoft/vscode-dev-containers/tree/v0.217.4/containers/rust
-{
- "name": "Rust",
- "build": {
- "dockerfile": "Dockerfile",
- "args": {
- // Use the VARIANT arg to pick a Debian OS version: buster, bullseye
- // Use bullseye when on local on arm64/Apple Silicon.
- "VARIANT": "bullseye"
- }
- },
- "runArgs": [
- "--cap-add=SYS_PTRACE",
- "--security-opt",
- "seccomp=unconfined"
- ],
- "postCreateCommand": "cargo install cross typos-cli cargo-expand cargo-insta",
- "customizations": {
- "vscode": {
- // Add the IDs of extensions you want installed when the container is created.
- "extensions": [
- "bmuskalla.vscode-tldr",
- "christian-kohler.path-intellisense",
- "citreae535.sparse-crates",
- "foxundermoon.shell-format",
- "gruntfuggly.todo-tree",
- "mutantdino.resourcemonitor",
- "redhat.vscode-yaml",
- "rust-lang.rust-analyzer",
- "tamasfe.even-better-toml",
- "timonwong.shellcheck",
- "vadimcn.vscode-lldb"
- ],
- // Set *default* container specific settings.json values on container create.
- "settings": {
- "lldb.executable": "/usr/bin/lldb",
- // VS Code don't watch files under ./target
- "files.watcherExclude": {
- "**/target/**": true
- }
- }
- }
- },
- // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
- "remoteUser": "vscode",
- "features": {
- "ghcr.io/devcontainers/features/docker-in-docker:2":{},
- "ghcr.io/devcontainers/features/git:1": {
- "version":"os-provided"
- }
- }
-}
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md
deleted file mode 100644
index 57f1613..0000000
--- a/.github/ISSUE_TEMPLATE/bug.md
+++ /dev/null
@@ -1,30 +0,0 @@
----
-name: Bug report
-about: Create a report to help us improve
-title: "[BUG] "
-labels: bug
-assignees: ''
-
----
-
-**Describe the bug**
-A clear and concise description of what the bug is.
-
-**To Reproduce**
-Steps to reproduce the behavior:
-1. Go to '...'
-2. Click on '....'
-3. Scroll down to '....'
-4. See error
-
-**Expected behavior**
-A clear and concise description of what you expected to happen.
-
-**Screenshots**
-If applicable, add screenshots to help explain your problem.
-
-**Desktop (please complete the following information):**
- - OS: [e.g. windows 11]
-
-**Additional context**
-Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md
deleted file mode 100644
index 82ee01a..0000000
--- a/.github/ISSUE_TEMPLATE/feature.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-name: New Feature
-about: Suggest an idea for this project
-title: "[NEW FEATURE] "
-labels: 'new feature'
-assignees: ''
-
----
-
-**Is your feature request related to a problem? Please describe.**
-A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
-
-**Describe the solution you'd like**
-A clear and concise description of what you want to happen.
-
-**Describe alternatives you've considered**
-A clear and concise description of any alternative solutions or features you've considered.
-
-**Additional context**
-Add any other context or screenshots about the feature request here.
diff --git a/.github/ISSUE_TEMPLATE/refactor.md b/.github/ISSUE_TEMPLATE/refactor.md
deleted file mode 100644
index 5a279bd..0000000
--- a/.github/ISSUE_TEMPLATE/refactor.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-name: Refactor
-about: Refactor a component
-title: "[REFACTOR] "
-labels: 'refactor'
-assignees: ''
-
----
-
-**Component to refactor.**
-What component(s) needs to be refactored?
-
-**Describe the solution you'd like**
-A clear and concise description of what you want to happen.
-
-**Describe alternatives you've considered**
-A clear and concise description of any alternative solutions or features you've considered.
-
-**Additional context**
-Add any other context or screenshots about the feature request here.
diff --git a/.github/demo_01.webp b/.github/demo_01.webp
deleted file mode 100644
index a159342..0000000
Binary files a/.github/demo_01.webp and /dev/null differ
diff --git a/.github/logo.svg b/.github/logo.svg
deleted file mode 100644
index 9f30c44..0000000
--- a/.github/logo.svg
+++ /dev/null
@@ -1,73 +0,0 @@
-
-
-
-
diff --git a/.github/release-body.md b/.github/release-body.md
deleted file mode 100644
index 4bf4712..0000000
--- a/.github/release-body.md
+++ /dev/null
@@ -1,25 +0,0 @@
-### 2026-02-23
-
-*BREAKING CHANGES*
-+ `log_scroll_forward`, `log_scroll_back` renamed to `scroll_forward`, `scroll_back`
-+ Additional KeyMap entry, `inspect` defaults to `i`, enables Inspect mode
-+ Docker Host priorities reordered, *should* now be, from high to low order, `--host` cli argument, `config.toml` `host` value, `DOCKER_HOST` env, [Docker library](https://github.com/fussybeaver/bollard) default setting.
-+ `config.toml` `host` value is now commented out by default - this should help with invalid Docker connection errors and enable easy Podman support
-
-### Chores
-+ dependencies updated, [4658a8de264698b0c8092e1227f0683527219a0b], [8b5899ca238bcbff32519b376b920cd7b7509809], [bebb687c59f3b408e69b23d2e68fa69f006a3231]
-+ GitHub workflow updated, [a0aa7918241ee8f702d6472c620287aa4be7d56c]
-
-### Features
-+ Network chart, closes #79, [99fcb8fedf01599ec346b65d435d4c301a7a8851]
-+ Inspect mode & help panel redesign, [ae7f3f4a9472b451c37c0ab97b1756b41a3529f5]
-+ set rust-version in Cargo.toml, closes #77, [0763a1024f44d98b8d9d65f57995da538e40963c]
-
-### Fixes
-+ Enable quit on Docker connect error screen, [5f942eb2e963660bd7fe9d80fa7ba8a83754803a]
-
-### Refactors
-+ dead code removed, [3e31a2a6bc02d6ef75bd6cbc18568e82e60e1ee3]
-+ docker data spawns, [cd943f67e465fff9726b40570a089301a4a8f534]
-
-see CHANGELOG.md for more details
diff --git a/.github/screenshot_01.png b/.github/screenshot_01.png
deleted file mode 100644
index 27b91c9..0000000
Binary files a/.github/screenshot_01.png and /dev/null differ
diff --git a/.github/workflows/create_release_and_build.yml b/.github/workflows/create_release_and_build.yml
deleted file mode 100644
index f8d0b56..0000000
--- a/.github/workflows/create_release_and_build.yml
+++ /dev/null
@@ -1,186 +0,0 @@
-name: Release CI
-
-permissions:
- contents: write
- packages: write
-
-on:
- push:
- tags:
- - "v[0-9]+.[0-9]+.[0-9]+"
-
-jobs:
-
- #################################################
- ## Cross platform binary build for release page #
- #################################################
-
- cross_platform_build:
- strategy:
- matrix:
- include:
- - target: x86_64-unknown-linux-musl
- output_name: linux_x86_64.tar.gz
-
- - target: aarch64-unknown-linux-musl
- output_name: linux_aarch64.tar.gz
-
- - target: arm-unknown-linux-musleabihf
- output_name: linux_armv6.tar.gz
-
- - target: aarch64-apple-darwin
- output_name: apple_darwin_aarch64.tar.gz
-
- - target: x86_64-pc-windows-gnu
- output_name: windows_x86_64.zip
-
- runs-on: ubuntu-latest
- steps:
- - name: Checkout code
- uses: actions/checkout@v5
-
- # Install stable rust, and associated tools
- - name: install rust
- uses: dtolnay/rust-toolchain@stable
-
- # Install cross-rs
- - name: install cross
- run: cargo install cross --git https://github.com/cross-rs/cross
-
- - name: build
- if: matrix.target == 'aarch64-apple-darwin'
- run: |
- docker run --rm \
- -v "$(pwd):/io" \
- -w /io \
- ghcr.io/rust-cross/cargo-zigbuild \
- bash -ec '
- rustup update stable
- rustup default stable
- rustup target add aarch64-apple-darwin
- cargo zigbuild --release --target aarch64-apple-darwin
- '
-
- # Build all other targets using Cross
- - name: build
- if: matrix.target != 'aarch64-apple-darwin'
- run: cross build --target ${{ matrix.target }} --release
-
- # Compress the output
- - name: compress windows
- if: matrix.target == 'x86_64-pc-windows-gnu'
- run: |
- zip -j "./oxker_${{ matrix.output_name }}" target/${{ matrix.target }}/release/oxker.exe
-
- # Compress the output
- - name: compress linux
- if: matrix.target != 'x86_64-pc-windows-gnu'
- run: |
- tar -C "target/${{ matrix.target }}/release" -czf "./oxker_${{ matrix.output_name }}" oxker
-
- # Upload output for release page
- - name: Upload Artifacts
- uses: actions/upload-artifact@v6
- with:
- if-no-files-found: error
- name: ${{ matrix.target }}
- path: oxker_${{ matrix.output_name }}
- retention-days: 1
-
- ###################
- ## Create release #
- ###################
-
- create_release:
- needs: [cross_platform_build]
- runs-on: ubuntu-latest
- steps:
- - name: Checkout code
- uses: actions/checkout@v5
-
- - name: Setup | Artifacts
- uses: actions/download-artifact@v7
-
- - name: Update Release
- uses: ncipollo/release-action@v1
- with:
- makeLatest: true
- name: ${{ github.ref_name }}
- tag: ${{ github.ref }}
- bodyFile: ".github/release-body.md"
- token: ${{ secrets.GITHUB_TOKEN }}
- artifacts: |
- **/oxker_*.zip
- **/oxker_*.tar.gz
-
- #########################################
- ## Build images for Dockerhub & ghcr.io #
- #########################################
-
- container_image_build:
- needs: [create_release]
- runs-on: ubuntu-latest
- steps:
- - name: Checkout code
- uses: actions/checkout@v5
-
- - name: Login to GitHub Container Registry
- uses: docker/login-action@v3
- with:
- registry: ghcr.io
- username: ${{ github.repository_owner }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Login to DockerHub
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - name: Write release version to env
- run: |
- CURRENT_SEMVER=${GITHUB_REF_NAME#v}
- echo "CURRENT_SEMVER=$CURRENT_SEMVER" >> $GITHUB_ENV
-
- - uses: docker/setup-buildx-action@v3
- id: buildx
- with:
- install: true
- - name: Build for Dockerhub & ghcr.io
- run: |
- docker build --platform linux/arm/v6,linux/arm64,linux/amd64 \
- -t ${{ secrets.DOCKERHUB_USERNAME }}/oxker:latest \
- -t ${{ secrets.DOCKERHUB_USERNAME }}/oxker:${{env.CURRENT_SEMVER}} \
- -t ghcr.io/${{ github.repository }}:latest \
- -t ghcr.io/${{ github.repository }}:${{env.CURRENT_SEMVER}} \
- --provenance=false --sbom=false \
- --push \
- -f containerised/Dockerfile .
-
- ########################
- # Publish to crates.io #
- ########################
-
- # This could be moved to before a github release is made
- dry_run_cargo_publish:
- runs-on: ubuntu-latest
- needs: [container_image_build]
- steps:
- - name: update rust stable
- run: rustup update stable
- - uses: actions/checkout@v5
- - name: Publish Dry Run
- run: cargo publish --dry-run
-
- cargo_publish:
- environment: crates.io
- runs-on: ubuntu-latest
- needs: [dry_run_cargo_publish]
- env:
- CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
- steps:
- - name: update rust stable
- run: rustup update stable
- - uses: actions/checkout@v5
- - name: Publish
- run: cargo publish
diff --git a/.vscode/launch.json b/.vscode/launch.json
deleted file mode 100644
index 660f195..0000000
--- a/.vscode/launch.json
+++ /dev/null
@@ -1,45 +0,0 @@
-{
- // Use IntelliSense to learn about possible attributes.
- // Hover to view descriptions of existing attributes.
- // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
- "version": "0.2.0",
- "configurations": [
- {
- "type": "lldb",
- "request": "launch",
- "name": "Debug executable 'oxker'",
- "cargo": {
- "args": [
- "build",
- "--bin=oxker",
- "--package=oxker"
- ],
- "filter": {
- "name": "oxker",
- "kind": "bin"
- }
- },
- "args": [],
- "cwd": "${workspaceFolder}"
- },
- {
- "type": "lldb",
- "request": "launch",
- "name": "Debug unit tests in executable 'oxker'",
- "cargo": {
- "args": [
- "test",
- "--no-run",
- "--bin=oxker",
- "--package=oxker"
- ],
- "filter": {
- "name": "oxker",
- "kind": "bin"
- }
- },
- "args": [],
- "cwd": "${workspaceFolder}"
- }
- ]
-}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index f6c05f8..0000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,805 +0,0 @@
-# v0.13.0
-### 2026-02-23
-
-*BREAKING CHANGES*
-+ `log_scroll_forward`, `log_scroll_back` renamed to `scroll_forward`, `scroll_back`
-+ Additional KeyMap entry, `inspect` defaults to `i`, enables Inspect mode
-+ Docker Host priorities reordered, *should* now be, from high to low order, `--host` cli argument, `config.toml` `host` value, `DOCKER_HOST` env, [Docker library](https://github.com/fussybeaver/bollard) default setting.
-+ `config.toml` `host` value is now commented out by default - this should help with invalid Docker connection errors and enable easy Podman support
-
-### Chores
-+ dependencies updated, [4658a8de](https://github.com/mrjackwills/oxker/commit/4658a8de264698b0c8092e1227f0683527219a0b), [8b5899ca](https://github.com/mrjackwills/oxker/commit/8b5899ca238bcbff32519b376b920cd7b7509809), [bebb687c](https://github.com/mrjackwills/oxker/commit/bebb687c59f3b408e69b23d2e68fa69f006a3231)
-+ GitHub workflow updated, [a0aa7918](https://github.com/mrjackwills/oxker/commit/a0aa7918241ee8f702d6472c620287aa4be7d56c)
-
-### Features
-+ Network chart, closes [#79](https://github.com/mrjackwills/oxker/issues/79), [99fcb8fe](https://github.com/mrjackwills/oxker/commit/99fcb8fedf01599ec346b65d435d4c301a7a8851)
-+ Inspect mode & help panel redesign, [ae7f3f4a](https://github.com/mrjackwills/oxker/commit/ae7f3f4a9472b451c37c0ab97b1756b41a3529f5)
-+ set rust-version in Cargo.toml, closes [#77](https://github.com/mrjackwills/oxker/issues/77), [0763a102](https://github.com/mrjackwills/oxker/commit/0763a1024f44d98b8d9d65f57995da538e40963c)
-
-### Fixes
-+ Enable quit on Docker connect error screen, [5f942eb2](https://github.com/mrjackwills/oxker/commit/5f942eb2e963660bd7fe9d80fa7ba8a83754803a)
-
-### Refactors
-+ dead code removed, [3e31a2a6](https://github.com/mrjackwills/oxker/commit/3e31a2a6bc02d6ef75bd6cbc18568e82e60e1ee3)
-+ docker data spawns, [cd943f67](https://github.com/mrjackwills/oxker/commit/cd943f67e465fff9726b40570a089301a4a8f534)
-
-# v0.12.0
-### 2025-09-28
-
-### Chores
-+ create_release.sh updated, [d4af754a](https://github.com/mrjackwills/oxker/commit/d4af754ad245540db60177f7b202b3c64519c961)
-+ dependencies updated, [03599b46](https://github.com/mrjackwills/oxker/commit/03599b46657d38d0c9f25c2ccfd9510f2b98dd84), [aef0c950](https://github.com/mrjackwills/oxker/commit/aef0c9503e7045a256856aa887d8c8d7722b9936), [f0771eab](https://github.com/mrjackwills/oxker/commit/f0771eab5d07d141fe7a8997db650f0f65ffe0a7), [1596de86](https://github.com/mrjackwills/oxker/commit/1596de8681ad6c0a7832eb922dd2dc36ab30eb41)
-+ GitHub workflow updated, [66dae5e6](https://github.com/mrjackwills/oxker/commit/66dae5e61ea294ac8ce134a6c32b27c04166b6eb)
-
-### Docs
-+ fix numerous typos, [618a43b5](https://github.com/mrjackwills/oxker/commit/618a43b501914fdf2659e171172ad180364cf87a)
-
-### Features
-+ *BREAKING CHANGE* - `scroll_down_many` & `scroll_up_many` removed, `scroll_down_one` `scroll_up_one` renamed `scroll_down`, `scroll_up`, see [example_config](https://github.com/mrjackwills/oxker/tree/main/example_config), [52a04ec1](https://github.com/mrjackwills/oxker/commit/52a04ec1d0b9e4877e304f60a857ebc00f88b4fd)
-+ log search feature, closes [#72](https://github.com/mrjackwills/oxker/issues/72). Use `#` button, remappable via `log_search_mode`, to enter log search mode. Case-sensitive by default, editable in `config.toml` with `log_search_case_sensitive` entry. Customise colours via `[colors.log_search]` entries, again see see [example_config](https://github.com/mrjackwills/oxker/tree/main/example_config), [96d94696](https://github.com/mrjackwills/oxker/commit/96d9469623a7c90b79aa8d82abf587290343ad37), [a2316a9c](https://github.com/mrjackwills/oxker/commit/a2316a9cac270790920a1ebd1be6532d51aba77c)
-+ `term` renamed `filter term`, tests updated, [487c3faf](https://github.com/mrjackwills/oxker/commit/487c3faf96f4c197c8b82644c02466ea40626a5e)
-
-My 32-bit armhf armv6 hardware no longer seems to be able to run Docker. Future `oxker` releases won't be tested on real hardware but will continue to be built and published for armv6.
-
-# v0.11.1
-### 2025-08-21
-
-### Reverts
-+ GitHub workflow reverted, [d1b69858](https://github.com/mrjackwills/oxker/commit/d1b69858c622636afbc46db7d37cf91e58d61212)
-
-see [v0.11.0 release notes](https://github.com/mrjackwills/oxker/releases/tag/v0.11.0) for more information about v0.11 release
-
-# v0.11.0
-### 2025-08-21
-
-### Chores
-+ Dependencies updated, [ced885e0](https://github.com/mrjackwills/oxker/commit/ced885e0128b6d5d3a3c7cb97d7e53bc2da64893), [f9b40ea0](https://github.com/mrjackwills/oxker/commit/f9b40ea03d0e70e235c28646ff3f9ebb468a904d)
-+ Rust 1.89 linting, [79d19cee](https://github.com/mrjackwills/oxker/commit/79d19ceeb81ae60bc5562683e405d6e74e6f2578)
-+ GitHub workflow updated, [08384200](https://github.com/mrjackwills/oxker/commit/08384200558fa1b9d378ea62ea832708caebaa91), [6573af1e](https://github.com/mrjackwills/oxker/commit/6573af1ed7d382a81c1305397e904066bb8395a8)
-
-### Features
-+ Horizontally scroll across logs. By default use `←` & `→` keys to traverse horizontally across the lines when logs panel selected. Updated `config.toml` with `log_scroll_forward` and `log_scroll_back` [c190f020](https://github.com/mrjackwills/oxker/commit/c190f0206cc55b8e45b8373f9be954e828c18b3b), [8939ac03](https://github.com/mrjackwills/oxker/commit/8939ac0345326633e794cc10a981a1f3c5c07549)
-+ Force clear screen & redraw of UI. By default uses `f` key, `config.toml` updated with `force_redraw` [50edbc0c](https://github.com/mrjackwills/oxker/commit/50edbc0cc09db864835fe81a03cba8eadafe548b)
-+ Increase scroll speed using the `ctrl` key in conjunction with a scroll key, `config.toml` updated with `scroll_modifier`. The next release will remove `scroll_down_many` & `scroll_down_up` keys, [c5bbffdb](https://github.com/mrjackwills/oxker/commit/c5bbffdb5f9e800951e4060aa6aee8e00db589aa)
-
-### Refactors
-+ remove macos cfg none-const functions, Zigbuild now uses Rust 1.87.0, [eb686e2c](https://github.com/mrjackwills/oxker/commit/eb686e2c952e04da74b3e12c0bfa015ec4615e1d)
-
-# v0.10.5
-### 2025-06-19
-
-### Reverts
-+ Bollard update rolled back, closes [#66](https://github.com/mrjackwills/oxker/issues/66), [aac9c6b5](https://github.com/mrjackwills/oxker/commit/aac9c6b598ce6c23b14f5a8b0116e662b18074d2)
-
- # v0.10.4
-### 2025-06-18
-
-### Chores
-+ .devcontainer updated, [324f8268](https://github.com/mrjackwills/oxker/commit/324f8268278081504d5357f2ed89b78ca2c25d04)
-+ dependencies updated, [0ace9dd6](https://github.com/mrjackwills/oxker/commit/0ace9dd662144a589341779a64d7fcd8de7d9978), [a6360075](https://github.com/mrjackwills/oxker/commit/a636007547280b3b3db69374601dbece4bc21eef)
-+ Rust 1.87.0 linting, [395b1aa7](https://github.com/mrjackwills/oxker/commit/395b1aa7e997a528e4f21e66f5f859001c1c3ec1), [67e5888e](https://github.com/mrjackwills/oxker/commit/67e5888e008cfd504c10e47f678f9351c838be99)
-back
-### Docs
-+ example config files updated, [63ab7de7](https://github.com/mrjackwills/oxker/commit/63ab7de72897de460f31181c5a42befbee2f91d3), [8fb5ac4a](https://github.com/mrjackwills/oxker/commit/8fb5ac4a945b75f3fcd118c53be1202ccbc43c59)
-+ README.md updated, link to directories crate, closes [#65](https://github.com/mrjackwills/oxker/issues/65), [c2bfe329](https://github.com/mrjackwills/oxker/commit/c2bfe3296563daf4b7f077469f3eeff6895720b0)
-
-### Features
-+ log panel size configurable, closes [#50](https://github.com/mrjackwills/oxker/issues/50), use the `-` or `=` keys to change the height of the logs panel, or `\` to toggle visibility. Automatically hide the logs panel using a new config item `show_logs`, see `example_config/*` files for more details, [6edf99e0](https://github.com/mrjackwills/oxker/commit/6edf99e0846bb4134d8ee5b646065b8cda8074d7)
-+ build release binaries for aarch64-apple-darwin, closes [#62](https://github.com/mrjackwills/oxker/issues/62), personally untested on MacOS - but others suggest it works as expected, [e7114d2f](https://github.com/mrjackwills/oxker/commit/e7114d2f5e0ed8935943be64726fc2d90464a777), [2e850090](https://github.com/mrjackwills/oxker/commit/2e8500902a515a246f9d9a503b4350849d634978)
-
-### Fixes
-+ merge args color/raw fix, [d1983987](https://github.com/mrjackwills/oxker/commit/d198398795698a21d81d3fd20231c482cc346ab5)
-
-### Refactors
-+ reduce cloning of the logs text items, can expect 40-50% reduction in CPU and memory usage in certain common situations, [ecefa302](https://github.com/mrjackwills/oxker/commit/ecefa302b9ef5320ad4cce0b606aca70a7b459e2)
-+ dead code removed, [b40b6b19](https://github.com/mrjackwills/oxker/commit/b40b6b197e4e5fbdab083bc918d1a5d2750597f3)
-
-### Tests
-+ add more whole layout tests, [4b81c6ca](https://github.com/mrjackwills/oxker/commit/4b81c6caaf12028d7527c3f23cd2de6d1503e223)
-
-# v0.10.3
-### 2025-04-22
-
-### Chores
-+ dependencies updated, [bbfd2462](https://github.com/mrjackwills/oxker/commit/bbfd2462a1f45008587b488e8c6049ee76da72f2)
-
-### Tests
-+ fix tests for MacOS, closes [#61](https://github.com/mrjackwills/oxker/issues/61), [cfc2decd](https://github.com/mrjackwills/oxker/commit/cfc2decd8d237f1ac3f0bdb2b3d5581684064448)
-
-# v0.10.2
-### 2025-04-19
-
-### Chores
-+ dependencies updated, [1345ecb1](https://github.com/mrjackwills/oxker/commit/1345ecb1a2b17ad3d288f2de2058c0777b84f93b)
-
-### Tests
-+ use a fixed version String, `0.00.000`, in tests, [230174b3](https://github.com/mrjackwills/oxker/commit/230174b3c327c3f217cdcf8fce07d5d9ddea1033)
-
-# v0.10.1
-### 2025-04-18
-
-### Chores
-+ dependencies updated, [8f959c54](https://github.com/mrjackwills/oxker/commit/8f959c5408995527485e817514c3f4a10bca31bd), [69d1801e](https://github.com/mrjackwills/oxker/commit/69d1801ea1e71e7d84c00fb2142ba27577e3bb73), [188490e1](https://github.com/mrjackwills/oxker/commit/188490e13fd1255eecb305ac0a99a7a1913e0294)
-+ Rust 1.86.0 linting, [9acf6033](https://github.com/mrjackwills/oxker/commit/9acf60334c5224faa9ee4ecf7030b2e9b13b7d67)
-
-### Docs
-+ comment typo, [723b220c](https://github.com/mrjackwills/oxker/commit/723b220c6aa6393b8eebd84a6ddba69f35dc18b8)
-
-### Fixes
-+ github workflow update, [997eebca](https://github.com/mrjackwills/oxker/commit/997eebca20a2883dcfcb35009dd4d7b0d438dec6)
-+ config merging, [a468827f](https://github.com/mrjackwills/oxker/commit/a468827f02c5243b9bd4e0183fed16854ae6c851)
-
-### Refactors
-+ rename ChartType to ChartVariant, [bca67116](https://github.com/mrjackwills/oxker/commit/bca67116f3b71451156a39a7b0957568b26fa183), [d0caa927](https://github.com/mrjackwills/oxker/commit/d0caa9271b6f92f52ccfe3dec69708efe54e5170)
-+ rename FileType to FileFormat, [848f64d0](https://github.com/mrjackwills/oxker/commit/848f64d0da429e547a2f6c8de62e4da5f5c9a187)
-
-### Tests
-+ Use insta, closes [#57](https://github.com/mrjackwills/oxker/issues/57), [9362d7b4](https://github.com/mrjackwills/oxker/commit/9362d7b481ea22eab6f902dc7f3c10150c7ddf22)
-
-# v0.10.0
-### 2025-02-23
-
-### Chores
-+ dependencies updated, [e5f355a1](https://github.com/mrjackwills/oxker/commit/e5f355a1928f78abdb64e4c5617d6fac06340016), [4539d8ad](https://github.com/mrjackwills/oxker/commit/4539d8ad0705b46d7c89c51c7be482b696d26e5f), [6aee6181](https://github.com/mrjackwills/oxker/commit/6aee6181136235a1a4f79af9b9748c1801be8bf8), [64d1bdf2](https://github.com/mrjackwills/oxker/commit/64d1bdf2bf88407e02f0eded1e03fcfc5ee2d8e3)
-+ .devcontainer dependencies updated, [5c8e76e7](https://github.com/mrjackwills/oxker/commit/5c8e76e7bb4d7aab8543c9be09fdbc4ffa446b10)
-+ example docker-compose.yml updated, [2354b0b9](https://github.com/mrjackwills/oxker/commit/2354b0b9be1ab3795a421512594b2650b9cbdd74)
-+ Rust 1.84 linting, [3065265e](https://github.com/mrjackwills/oxker/commit/3065265e26c30d78ba738cfe731d3901ec1948d0)
-
-### Features
-+ Config file introduced, including customizing color scheme of application, closes [#47](https://github.com/mrjackwills/oxker/issues/47), [f4d54e1b](https://github.com/mrjackwills/oxker/commit/f4d54e1ba8ea1516394aef19511a63e6271f27bf)
-+ Enable log timestamps to be set to any given timezone, plus custom timestamp format via the config file, closes [#56](https://github.com/mrjackwills/oxker/issues/56), [7a5e7a25873d2c270e5808730721ebb5427a051]
-+ update Rust edition to 2024, [7e4a960b](https://github.com/mrjackwills/oxker/commit/7e4a960b888f1dab524d6045504162cea1171d20)
-
-### Fixes
-+ Only draw screen if data or layout has changed, drastically reduces CPU usage, [bfc295c5](https://github.com/mrjackwills/oxker/commit/bfc295c50e982886ccaa5e60b57f10d3690b3f09)
-
- # v0.9.0
-### 2024-12-05
-
-### Chores
-+ dependencies updated, [b7871357](https://github.com/mrjackwills/oxker/commit/b78713579c4706d605e5b35fcd832610a0152294), [c6200e8f](https://github.com/mrjackwills/oxker/commit/c6200e8f77f8bb1f0152cb9374029d15cc45df9d)
-+ Rust 1.83 linting, [751d997a](https://github.com/mrjackwills/oxker/commit/751d997a3dac823e144ae62e6c1455676e50ddb8)
-
-### Features
-+ `--no-stderr` cli arg, removes Standard error output from logs, closes [#52](https://github.com/mrjackwills/oxker/issues/52), [c739637b](https://github.com/mrjackwills/oxker/commit/c739637b91c8fa742a69f4d888678d7b3964678c)
-+ ContainerPorts use ipaddr, [1b26997d](https://github.com/mrjackwills/oxker/commit/1b26997d25f748e0d452f41fe41791533046ecdf)
-
-### Fixes
-+ update containerised Dockerfile, [0c6f5322](https://github.com/mrjackwills/oxker/commit/0c6f53228f01196e352c2069383ba1e7a10950a8)
-+ calculate_usage overflow, [5106a01f](https://github.com/mrjackwills/oxker/commit/5106a01f3dcb87ce5a8f1fb7bf49dc6b3c25d03e)
-+ DockerData spawns insertion error, [d4906d33](https://github.com/mrjackwills/oxker/commit/d4906d33c26b75d92e7d80040c488faa90a257c6)
-
-### Refactors
-+ speed up docker logs init process, [8b9fe424](https://github.com/mrjackwills/oxker/commit/8b9fe4246865441704ae12dff0938868a4fe6f81)
-+ remove docker sleep, [f1562d10](https://github.com/mrjackwills/oxker/commit/f1562d1084336fe5be39894c93cb49107f0a4a6d)
-+ dead code removed, [5ee48d57](https://github.com/mrjackwills/oxker/commit/5ee48d5708fa6de0206c021db0bb611196e66fba), [ba6a9524](https://github.com/mrjackwills/oxker/commit/ba6a95241389f99d504ee4bf3e87e19006f12e49), [f0b11456](https://github.com/mrjackwills/oxker/commit/f0b1145651625ad4e577d79baaf902d4d3bc0579)
-+ input_handler, [7f423834](https://github.com/mrjackwills/oxker/commit/7f4238349525c01ae9fb8b1f6c0946e5364dd55e)
-+ statefulList get_state_title, [2d540b0e](https://github.com/mrjackwills/oxker/commit/2d540b0e2210cc04d73035ec59211ffc739174f6)
-+ statefulList next/previous, [7bb2bef2](https://github.com/mrjackwills/oxker/commit/7bb2bef28d90ebc58da86a0365a1904a0c32dffe)
-+ help_box closure fn, [2860426d](https://github.com/mrjackwills/oxker/commit/2860426d57a4458fcee49a2fd20e8e7bb9e71fb5)
-+ use check_sub for sleep calculations, [fe3696e5](https://github.com/mrjackwills/oxker/commit/fe3696e5576739d8b033d9e748b5ea696c4b4e4f)
-+ rename scheduler to heartbeat, [68a6551e](https://github.com/mrjackwills/oxker/commit/68a6551ed038a36330b2f098112829465a1c3c7a)
-+ remove unnecessary is_running load, [76ccf7c0](https://github.com/mrjackwills/oxker/commit/76ccf7c00691f815c3ab0bede838c99252ba84f0)
-+ execute_command(), [2a834d6c](https://github.com/mrjackwills/oxker/commit/2a834d6c2fa4a15124d24ddbd12f667829e148ad)
-+ Remove numerous clones(), [e5927f78](https://github.com/mrjackwills/oxker/commit/e5927f781a7e9517b9fa00a2d1a835d2774a9d26)
-+ remove app_data param from generate_lock(), [1a8dab65](https://github.com/mrjackwills/oxker/commit/1a8dab654a1fdbf351a72dc54fe3d1943355bba6)
-+ combine get_filter methods, [356ea554](https://github.com/mrjackwills/oxker/commit/356ea5549bb4877e9893fe0e1053e73c5a62e806)
-+ FrameData refactors, [57781701](https://github.com/mrjackwills/oxker/commit/57781701ff14c553dfbafb965ee8a33ab44dd36f), [6e2f82db](https://github.com/mrjackwills/oxker/commit/6e2f82db81caaa98ce4781fa15928eb9e246ace6)
-+ update_container_stat combine is_alive(), [55cc7467](https://github.com/mrjackwills/oxker/commit/55cc746736f6863aedc5ad838744a983796244d8)
-+ remove `input_poll_rate` from `Ui`, instead use const `POLL_RATE`, [69f6c96b](https://github.com/mrjackwills/oxker/commit/69f6c96b700b9fde5578ae204992a67986d456ab)
-+ pass `&FrameDate` into `draw_frame()`, [35aec506](https://github.com/mrjackwills/oxker/commit/35aec5060fdbe606267be26656b4aeee43d50c02)
-+ dead code removed, [caf23be4](https://github.com/mrjackwills/oxker/commit/caf23be4a7faff99aaca80b081a02e4e0a372009)
-+ input_handler, [9c4f8910](https://github.com/mrjackwills/oxker/commit/9c4f8910381b90b563da12eaba4b79cb60c40129)
-+ draw_block, [de76bc22](https://github.com/mrjackwills/oxker/commit/de76bc22936b124dcb9646f302f6cc14691dbb63)
-
-### Tests
-+ fix logs tests, [9b22f5da](https://github.com/mrjackwills/oxker/commit/9b22f5da18e4bf92766a68a7f4cd61ad72724cfd)
-
-# v0.8.0
-### 2024-10-22
-
-### Chores
-+ dependencies updated, [ea877d23](https://github.com/mrjackwills/oxker/commit/ea877d23711b98ffd1108a74206d93d43482d44d), [af609c0d](https://github.com/mrjackwills/oxker/commit/af609c0dbf0caab4a073f822166de34999afb41b)
-+ .devcontainer updated, [a9844436](https://github.com/mrjackwills/oxker/commit/a9844436d003b84a3e9d8b600ea029b232566f3a)
-+ create_release.sh updated, [c4943370](https://github.com/mrjackwills/oxker/commit/c4943370f4a67f6c01c75a8a7f825912427666a2), [1389d8ad](https://github.com/mrjackwills/oxker/commit/1389d8adbba75fef480eb1de09337eb7beb10ba3)
-
-### Features
-+ Add Stderr output to logs, thanks [vincentmasse](https://github.com/vincentmasse), closes [#48](https://github.com/mrjackwills/oxker/issues/48), merges [#49](https://github.com/mrjackwills/oxker/pull/49), [b95c9311](https://github.com/mrjackwills/oxker/commit/b95c9311416cd0dbcfa5de90c23f3065bc2d6b17), [9936ad45](https://github.com/mrjackwills/oxker/commit/9936ad45e186ee431aade920674a2dc283937355), [289ede3f](https://github.com/mrjackwills/oxker/commit/289ede3f2531feeec56094a76bf34f4c69431bbe)
-
-### Refactors
-+ Rust 1.82 linting, [c058c5a3](https://github.com/mrjackwills/oxker/commit/c058c5a301cfd4e8d7a0079c4c3f8fdeae2803e5)
-
-# v0.7.2
-### 2024-09-07
-
-### Reverts
-+ Expect lint was causing issues with crates/docker builds, revert until fix is found, [578ed9f0](https://github.com/mrjackwills/oxker/commit/578ed9f085df0d97c451c06dab4ecbfccd894c52)
-
-# v0.7.1
-### 2024-09-07
-
-### Chores
-+ dependencies updated, [d6238587](https://github.com/mrjackwills/oxker/commit/d6238587ffc536f1ea93a47dd4d2ee69c36f35e3), [a564ef80](https://github.com/mrjackwills/oxker/commit/a564ef80318adbde9f188dd2cf38626a98793c75), [1d82ff13](https://github.com/mrjackwills/oxker/commit/1d82ff1368f7b43e6df8478d5e7b8d682320010a), [99f05f2e](https://github.com/mrjackwills/oxker/commit/99f05f2e5b511d039804159c92ade6c77ff360b7)
-+ Rust 1.81.0 linting, [372f759c](https://github.com/mrjackwills/oxker/commit/372f759ca467e47c373a086c6a247c150b87d4bc)
-+ .devcontainer updated, [5d77f1e0](https://github.com/mrjackwills/oxker/commit/5d77f1e02a428b4e5ee1bf5466055ad17f6e05af)
-
-### Docs
-+ CHANGELOG.md duplicate removed, [16ecc5a5](https://github.com/mrjackwills/oxker/commit/16ecc5a51f7defcc9dd4f4c6e34fa5bbfc7fea78)
-+ Readme raspberry pi fix, [baf68783](https://github.com/mrjackwills/oxker/commit/baf68783929e5d6ac111a39dc62388cd24133da6)
-+ Add installation guide to README for macOS installation via `brew install oxker`, thanks [miketheman](https://github.com/miketheman), [59817311](https://github.com/mrjackwills/oxker/commit/59817311baea628d2691765ff9387e055dce3307), [895ec620](https://github.com/mrjackwills/oxker/commit/895ec6204cc8220be64be7622df6edb6ef6d795b)
-
-### Refactors
-+ switch lints from `allow(x)` to `expect(x)`, [2a0ab6d8](https://github.com/mrjackwills/oxker/commit/2a0ab6d81ce4062de053a92b85f8b25ea23412b6)
-
-# v0.7.0
-### 2024-08-01
-
-### Chores
-+ .devcontainer extensions updated, [0288cbc8](https://github.com/mrjackwills/oxker/commit/0288cbc8146cde1dd40ceaec9550198b635bb8f5)
-+ dependencies updated, [1df4f78d](https://github.com/mrjackwills/oxker/commit/1df4f78dc41013c33d901925933b1ccb29ad4bc8), [5ae253b8](https://github.com/mrjackwills/oxker/commit/5ae253b8734ba0495e4e8149b17d5228b3d86f8d), [7a517db9](https://github.com/mrjackwills/oxker/commit/7a517db9f7c14c35e56ff70cf76ffb608fd30e17), [9c291cd9](https://github.com/mrjackwills/oxker/commit/9c291cd9c81b6d9a02085878588ed3b845fd0046), [0e90f4eb](https://github.com/mrjackwills/oxker/commit/0e90f4eb55ac5fb5d45e7d212c3686027dd3913e), [fe71cbfb](https://github.com/mrjackwills/oxker/commit/fe71cbfb00f166b7c02a6e28e64650ed1b47d15d)
-+ docker-compose alpine version bump, [51ceab3e](https://github.com/mrjackwills/oxker/commit/51ceab3ebdb09356cd401d2f268840239255126f)
-+ Rust 1.80 linting, [93e1279b](https://github.com/mrjackwills/oxker/commit/93e1279b1fc77019442a385e2e36be2fe438e828)
-+ create_release v0.5.6, [f408acfe](https://github.com/mrjackwills/oxker/commit/f408acfe9a9f5a976735b8a8a51500fd7b865daf)
-
-### Docs
-+ screenshot updated, [6975ebe7](https://github.com/mrjackwills/oxker/commit/6975ebe70f7058229c232e4a56b090f55247d2a2)
-
-### Features
-+ left align all text, [e0d421c4](https://github.com/mrjackwills/oxker/commit/e0d421c4918a17c9e0e21fd214edb99d71281c9d)
-+ place image name in logs panel title, [12f24357](https://github.com/mrjackwills/oxker/commit/12f24357a68abe871f44d871d95b6e2ef062181e)
-+ distinguish between unhealthy & healthy running containers, closes [#43](https://github.com/mrjackwills/oxker/issues/43), [de876818](https://github.com/mrjackwills/oxker/commit/de8768181631c6d961ce0e4dacb50c2ed02abc36)
-+ filter containers, use `F1` or `/` to enter filter mode, closes [#37](https://github.com/mrjackwills/oxker/issues/37), thanks to [MohammadShabaniSBU](https://github.com/MohammadShabaniSBU) for the original PR, [d5d8a0db](https://github.com/mrjackwills/oxker/commit/d5d8a0dbc5437ff3b17f34b9dbb9589bb56b4a3e), [7ee1f06f](https://github.com/mrjackwills/oxker/commit/7ee1f06f804683e3395953a02138d4e9da115ea9)
-
-### Fixes
-+ log_sanitizer `raw()` & `remove_ansi()` now functioning as intended, [0dc98dfc](https://github.com/mrjackwills/oxker/commit/0dc98dfc8113869b81be9d697ca77418c919e4bf)
-+ Dockerfile command use uppercase, [068e4025](https://github.com/mrjackwills/oxker/commit/068e4025a5d6049a9a6951a0480a6bdef7379f88)
-+ heading section help margin, [0e927aae](https://github.com/mrjackwills/oxker/commit/0e927aae178c1d8f60561b93607a26d45a1d9331)
-+ install.sh use curl, [197a031b](https://github.com/mrjackwills/oxker/commit/197a031b8cf356f49f08e04472d0d1c489699415)
-
-### Tests
-+ fix layout tests with new left alignment, [dfced564](https://github.com/mrjackwills/oxker/commit/dfced564278eafdbb8a5b95badbae3a7c4bf87b3)
-
-# v0.6.4
-### 2024-05-25
-
-### Chores
-+ Dependencies updated, [51fdd26b](https://github.com/mrjackwills/oxker/commit/51fdd26be5b3166bcff5c26ece6d6ec0d893381e), [c1be658b](https://github.com/mrjackwills/oxker/commit/c1be658b8cc4786a9a7f2e0a88568019b3995c14)
-
-### Docs
-+ exec mode "not available on Windows", in both README.md and help panel, [df449a85](https://github.com/mrjackwills/oxker/commit/df449a85376bbeec87215952d6a9196721f7132e)
-
-### Fixes
-+ closes [#36](https://github.com/mrjackwills/oxker/issues/36) Double key strokes on Windows, [9b7d575a](https://github.com/mrjackwills/oxker/commit/9b7d575a76398cbe19e17f6494baf802dbb512b9)
-
-# v0.6.3
-### 2024-05-07
-
-### Chores
-+ Dependencies updated, [07e293ac](https://github.com/mrjackwills/oxker/commit/07e293ac2ce2e7deb5735154fcdb24ef83a19b67), [27d72c54](https://github.com/mrjackwills/oxker/commit/27d72c547e738f6816cd4b353ac881e454a0be70)
-
-### Features
-+ Allow closing dialogs with `Escape`, thanks [JCQuintas](https://github.com/JCQuintas), [0e4c3cea](https://github.com/mrjackwills/oxker/commit/0e4c3ceab933458d40b54d5fcff7e6cf7a3ab315)
-
-### Fixes
-+ correct header display when terminal width changes, [4628803b](https://github.com/mrjackwills/oxker/commit/4628803b2b9fe63522d033b192763ed6ff5b57dd)
-
-### Refactors
-+ use tokio CancellationToken, [0631a73e](https://github.com/mrjackwills/oxker/commit/0631a73ec27530f8fcc88988a0a02ca75e32c5ba)
-+ impl AsyncTTY, [bf33776e](https://github.com/mrjackwills/oxker/commit/bf33776e9a61684032a80d22d995ba7e0446620e)
-
-### Tests
-+ reduced header section test, [aa094740](https://github.com/mrjackwills/oxker/commit/aa0947405393db2c306e86986183514cbc0f5a75)
-+ test_draw_blocks_help() with add esc text, [ff839af4](https://github.com/mrjackwills/oxker/commit/ff839af4ef68193149d6456e70fee189228c4a44)
-
-# v0.6.2
-### 2024-03-31
-
-### Chores
-+ .devcontainer updated, [82a7f84e](https://github.com/mrjackwills/oxker/commit/82a7f84ed94a9bad6cc5fe078bfadbda65c8ea8f)
-+ Rust 1.77 linting, [dfd4948d](https://github.com/mrjackwills/oxker/commit/dfd4948d9c43cfbffb091c303d3cb2a05522047a)
-+ platform.sh formatted, [7953e68f](https://github.com/mrjackwills/oxker/commit/7953e68f3067ac3c9d4fe67e9f934c25036c0833)
-+ dependencies updated, [8e6c3ca6](https://github.com/mrjackwills/oxker/commit/8e6c3ca6d83768f043923ccf6836397beaaae639)
-
-# v0.6.1
-### 2024-02-14
-
-### Chores
-+ create_release v0.5.5, [616338b7](https://github.com/mrjackwills/oxker/commit/616338b7107036e968f51c3ff80739f9ffb40fbd)
-+ update dependencies, [10180d2e](https://github.com/mrjackwills/oxker/commit/10180d2e0817c00a198e27f7d71080c502639a6b)
-+ update to ratatui v0.26.0, [d33dce3e](https://github.com/mrjackwills/oxker/commit/d33dce3eec4c19cc3c3668dab77f7d25d6970c3c)
-+ GitHub workflow dependency bump, [0314eac9](https://github.com/mrjackwills/oxker/commit/0314eac9df6cf9fea1943dcd06bd6a0b27131c16)
-
-### Docs
-+ screenshot updated, [fe5ec4f5](https://github.com/mrjackwills/oxker/commit/fe5ec4f5dd25f11817be37f3f1867a6a2b0afc42)
-
-### Fixes
-+ ports all listed in white, [d3b23585](https://github.com/mrjackwills/oxker/commit/d3b23585b38045eb3bc827367eca90eb7f7a7dd5)
-+ use long container name in delete popup, [6202b7bb](https://github.com/mrjackwills/oxker/commit/6202b7bbfdfb04a94959b5143dac3f1aa59cd336)
-+ memory display, closes [#33](https://github.com/mrjackwills/oxker/issues/33), [a182d40a](https://github.com/mrjackwills/oxker/commit/a182d40a7463164ef5dcac379d1a1768d77209d2)
-
-### Refactors
-+ use &[T] instead of &Vec, [76cd08ab](https://github.com/mrjackwills/oxker/commit/76cd08ab2f98687a866a6bbb4fa93bbdedaa7699), [1f62bb50](https://github.com/mrjackwills/oxker/commit/1f62bb50210f2d66bb7215e42e8b21a3c1a6ec06)
-+ draw_block constraints into consts, [0436ff1b](https://github.com/mrjackwills/oxker/commit/0436ff1b7356c80532048c7d497c66d331092b01)
-
-### Tests
-+ update port test with new colour, [f74ae3f5](https://github.com/mrjackwills/oxker/commit/f74ae3f5c34d74b78822078291fed401427c4cba)
-+ color match tests updated, [5b287416](https://github.com/mrjackwills/oxker/commit/5b287416315942b19c62f8c66348ce28462d894c)
-
-# v0.6.0
-### 2024-01-18
-
-### Chores
-+ dependencies updated, [53b4bafb](https://github.com/mrjackwills/oxker/commit/53b4bafbe53312fe41608ddf33e865d474222aaa), [58ef1516](https://github.com/mrjackwills/oxker/commit/58ef151600e362048a607c8ae61a5edfe80ab1dd), [b6fd3502](https://github.com/mrjackwills/oxker/commit/b6fd35022a99ec0e982ddb154b0450d49c4840e9), [0438c108](https://github.com/mrjackwills/oxker/commit/0438c108bdd9815d7eae1b89c47c4e6438f358d6)
-+ files formatted, [1806165c](https://github.com/mrjackwills/oxker/commit/1806165c3e266876b2d1806f7b662d09705f3aad)
-+ create_release.sh check for unused lint, [d0b27211](https://github.com/mrjackwills/oxker/commit/d0b27211928f93f8455e1ee5a6a6485c6a21d382)
-
-### Docs
-= Readme updated, screenshot added, [7561a934](https://github.com/mrjackwills/oxker/commit/7561a93415c1e1f596b15edba95e7b32a939cd90), [4069e557](https://github.com/mrjackwills/oxker/commit/4069e5572f81cb689dbb9f735db919e4636cdccc)
-
-### Features
-+ Ports section added, closes [#21](https://github.com/mrjackwills/oxker/issues/21), [65a1afcb](https://github.com/mrjackwills/oxker/commit/65a1afcb0605604ede350a5630c775f94ebb74ee), [7a096a65](https://github.com/mrjackwills/oxker/commit/7a096a65c40924021fe643fe0aa1067095832df9)
-
-### Fixes
-+ sort arrow now on left of header, [40ddcb72](https://github.com/mrjackwills/oxker/commit/40ddcb727d2c1758d6dd26a58507b85b219f51e2)
-
-### Refactors
-+ rename string_wrapper > unit_struct, [27cf53e4](https://github.com/mrjackwills/oxker/commit/27cf53e41f8b379f606c1c27620ee08e79bac57e)
-
-### Tests
-+ Finally have tests, currently for layout and associated methods, at the moment running the tests will not interfere with any running Docker containers, [4bcf77db](https://github.com/mrjackwills/oxker/commit/4bcf77db776a36e0a8151ecfbda722a66c4ba46c)
-
-# v0.5.0
-### 2024-01-05
-
-### Chores
-+ .devcontainer updated, [2313618e](https://github.com/mrjackwills/oxker/commit/2313618eb1493ce41d70847b888c32b65fdc40ea), [5af6b8bc](https://github.com/mrjackwills/oxker/commit/5af6b8bcd31c3c38ff5a5799c76dc1cbe1167763), [9b0b6b10](https://github.com/mrjackwills/oxker/commit/9b0b6b10c3a0c1d5095490cfd3cda18d252f38f5)
-+ alpine version bump, [061de032](https://github.com/mrjackwills/oxker/commit/061de032dad935c56c6caab419ecb5c9bbac4c7e)
-+ dependencies updated, [0890991f](https://github.com/mrjackwills/oxker/commit/0890991ff1a239fe2d556a0c4eac6ae05beb9b50), [0a7b266b](https://github.com/mrjackwills/oxker/commit/0a7b266b2a358a4788ae877ca8a97f08eac4eef2), [333621f1](https://github.com/mrjackwills/oxker/commit/333621f1a7321c1fdf73fd35dd7f3ab165a9dc64), [3e51889c](https://github.com/mrjackwills/oxker/commit/3e51889cd8a552b1da463ae6a40d5de6eec188f5), [a179bb6f](https://github.com/mrjackwills/oxker/commit/a179bb6f6a7e076269fa830f56c0d4a31cf8488a)
-+ file formatting, [eb5e74ae](https://github.com/mrjackwills/oxker/commit/eb5e74ae67d815bf49f241d2baf319e41cf9adf8)
-+ Rust 1.75.0 linting, [81be75f2](https://github.com/mrjackwills/oxker/commit/81be75f27fd32a59ebff57e44c5022ff862df84b)
-
-### Docs
-+ screenshot updated, [0231d1bd](https://github.com/mrjackwills/oxker/commit/0231d1bdcda304300d289243a95044ab3bdce85c)
-+ comment typo, [0ad1ec9d](https://github.com/mrjackwills/oxker/commit/0ad1ec9d85d6f0cac743b4421d0ad03432c9d717)
-
-### Features
-+ re-arrange columns, container name is now the first column, added a ContainerName & ContainerImage struct via `string_wrapper` macro, closes [#32](https://github.com/mrjackwills/oxker/issues/32), [e936bb4b](https://github.com/mrjackwills/oxker/commit/e936bb4b78980d0e34a1ef5e9f6f82a9ed0ddc7f)
-
-### Fixes
-+ Docker Commands hidden, [4301e470](https://github.com/mrjackwills/oxker/commit/4301e4709f99fc23ee438bf345b0dc698a05dc4e)
-+ .gitattributes, [1234ea53](https://github.com/mrjackwills/oxker/commit/1234ea53897b2ed6ada0eb18cd81b8783a5dc5f5)
-
-### Refactors
-+ GitHub workflow action improved, [04b66af2](https://github.com/mrjackwills/oxker/commit/04b66af2b60c96cfbece0b13109e30b08ef35cc4)
-+ sort_containers, [ccf8b55a](https://github.com/mrjackwills/oxker/commit/ccf8b55a7495982f72b4fb3af6e11a9bd7465216)
-+ string_wrapper .get() return `&str`, [a722731c](https://github.com/mrjackwills/oxker/commit/a722731c6a77e00d1fb13967b51400aa34e72213)
-
-# v0.4.0
-### 2023-11-21
-
-### Chores
-+ workflow dependencies updated, [6a4cf649](https://github.com/mrjackwills/oxker/commit/6a4cf6490d08b976734e2bc8186d94c095700558)
-+ dependencies updated, [e301b518](https://github.com/mrjackwills/oxker/commit/e301b51891e03ea40b2f904583119da3bc4daf53), [81d5b326](https://github.com/mrjackwills/oxker/commit/81d5b326db8881263f2c9072e1426948e41b4a0f), [294cc268](https://github.com/mrjackwills/oxker/commit/294cc2684f42daab9d51601e235a384f55617678)
-+ lints moved from main.rs to Cargo.toml, [2de76e2f](https://github.com/mrjackwills/oxker/commit/2de76e2f358be9c1500ca3dc4f9df0979ed8ed28)
-+ .devcontainer updated, [37d2ee91](https://github.com/mrjackwills/oxker/commit/37d2ee915625806dd11c2cc816a892aae12a777c)
-
-### Features
-+ Docker exec mode - you are now able to attempt to exec into a container by pressing the `e` key, closes [#28](https://github.com/mrjackwills/oxker/issues/28), [c8077bca](https://github.com/mrjackwills/oxker/commit/c8077bca0b673478cfbb417e677a885136ba9eff), [0e5ee143](https://github.com/mrjackwills/oxker/commit/0e5ee143b008c9d0ee0b681231a1568be227150b), [0e5ee143](https://github.com/mrjackwills/oxker/commit/0e5ee143b008c9d0ee0b681231a1568be227150b)
-+ Export logs feature, press `s` to save logs, use `--save-dir` cli-arg to customise output location, closes [#1](https://github.com/mrjackwills/oxker/issues/1), [a15da5ed](https://github.com/mrjackwills/oxker/commit/a15da5ed43d07852504a4dd1884a189e3f5b9d84)
-
-### Fixes
-+ GitHub workflow, cargo publish before create release, [ae4ce3b5](https://github.com/mrjackwills/oxker/commit/ae4ce3b549c40cc8bd713f375f030b185179a6e2)
-+ sorted created_at clash, closes [#22](https://github.com/mrjackwills/oxker/issues/22), [3a648939](https://github.com/mrjackwills/oxker/commit/3a6489396e87702ce94b349a7f47028ece7922f6)
-+ `as_ref()` fixed, thanks [Daniel-Boll](https://github.com/Daniel-Boll), [77fbaa8b](https://github.com/mrjackwills/oxker/commit/77fbaa8b1669286369b6ec1edd80220c808b628f)
-
-# v0.3.3
-### 2023-10-21
-
-### Chores
-+ docker-compose Alpine bump, [d46c425f](https://github.com/mrjackwills/oxker/commit/d46c425fa29f3c1d27bd57764748bae7e0b82f69)
-+ dependencies updated, [e6eecbbd](https://github.com/mrjackwills/oxker/commit/e6eecbbdce9c0ccff42aa8806dddb6e3364f990c), [ec93115e](https://github.com/mrjackwills/oxker/commit/ec93115ece83002fa127f3358f573319e29357e1), [b36daa5a](https://github.com/mrjackwills/oxker/commit/b36daa5aeaa354b6c4f45b7ae67ac1a6345ea1c0), [9c0de1f0](https://github.com/mrjackwills/oxker/commit/9c0de1f0feff3165d0f5b6cb5dda843c124bcfa4), [6dd953df](https://github.com/mrjackwills/oxker/commit/6dd953df458096aee5914411ce40e46c3f600ede)
-+ Rust 1.73 linting, [21234c66](https://github.com/mrjackwills/oxker/commit/21234c66c3935330ccd58543dd3a915a293ac776)
-
-### Docs
-+ README.md updated, [3fd3915b](https://github.com/mrjackwills/oxker/commit/3fd3915b3e929742d8007109fd4c7b4a345eb0fa)
-
-### Refactors
-+ LogsTZ from `&str`, [44f581f5](https://github.com/mrjackwills/oxker/commit/44f581f5b3652cc4e623fe145141878754dca292)
-+ from string impl, [ca79893d](https://github.com/mrjackwills/oxker/commit/ca79893df5f05ebf445ce194d578cb8213c9755e)
-+ env handling, [18c3ed43](https://github.com/mrjackwills/oxker/commit/18c3ed43376a8b5e2d285d1b34a9f96843357d53)
-+ `parse_args/mod.rs` > `parse_args.rs`, [a6ff4124](https://github.com/mrjackwills/oxker/commit/a6ff4124319ed17d3f1c46c916418f850ef1d3b0)
-+ set_info_box take `&str`, [faeaca0c](https://github.com/mrjackwills/oxker/commit/faeaca0cd1bb243c7f4a7112b928be776b877ca1)
-+ GitHub action use concurrency matrix, re-roder workflow, [85f1982f](https://github.com/mrjackwills/oxker/commit/85f1982f4066bfdbc764ab7b88588eded6a17f96)
-
-# v0.3.2
-### 2023-08-28
-
-### Chores
-+ dependencies updated, [8ce5a187](https://github.com/mrjackwills/oxker/commit/8ce5a1877a8c56d9bbab560c97e2596ea87cc4c0), [94a20584](https://github.com/mrjackwills/oxker/commit/94a20584e6ef0701c9f36838b0dfbcd911698dbe), [29e02e0d](https://github.com/mrjackwills/oxker/commit/29e02e0d1faae4a836c7e5cfd0d791338ff586e3), [8e4c2e68](https://github.com/mrjackwills/oxker/commit/8e4c2e686761df56920df2267b765ab1297c9972)
-+ `_typos.toml` added, [84ba1020](https://github.com/mrjackwills/oxker/commit/84ba1020939606abf4a287cbd1de1f3a10d3f0c0)
-
-### Features
-+ Custom hostname. `oxker` will use `$DOCKER_HOST` env if set, or one can use the cli argument `--host`, which takes priority over the `$DOCKER_HOST`, closes [#30](https://github.com/mrjackwills/oxker/issues/30), [10950787](https://github.com/mrjackwills/oxker/commit/10950787649d2b66fc1e8cd8b85526df51479857)
-
-### Refactors
-+ `set_error()` takes `gui_state` and error enum, to make sure app_data & gui_state is in sync [62c78dfa](https://github.com/mrjackwills/oxker/commit/62c78dfaa50a8d8c084f7fbf7e203b50aaa731ae)
-+ `fn loading_spin` doesn't need to be async, [2e27462d](https://github.com/mrjackwills/oxker/commit/2e27462d1b3f0bdb27d7646511e36d0c9af07f3e)
-
-# v0.3.1
-### 2023-06-04
-
-### Chores
-+ github workflow ubuntu latest, build for x86 musl, [4fa841e6](https://github.com/mrjackwills/oxker/commit/4fa841e6e74e3e10e3d3e82eac1a1ca1338814cf)
-+ dependencies updated, [0caa92f6](https://github.com/mrjackwills/oxker/commit/0caa92f6a4728d50d8b2d8f15d96a21112732ec5), [1fd1dfc7](https://github.com/mrjackwills/oxker/commit/1fd1dfc75d6fa4e84451ebc845b9e1c730381f41)
-+ `Spans` -> `Line`, ratatui 0.21 update, [4679ddc8](https://github.com/mrjackwills/oxker/commit/4679ddc885a9b35c901f3600b63fd9e86118264c), [0d37ac55](https://github.com/mrjackwills/oxker/commit/0d37ac55018038363e5f92dc4215996f8cff7b2e)
-+ `create_release.sh` updated, [7dec5f14](https://github.com/mrjackwills/oxker/commit/7dec5f14a381d237c5e72fbf9551bcf398f93f3e)
-
-### Fixes
-+ workflow additional image fix, closes [#29](https://github.com/mrjackwills/oxker/issues/29), [47cda44b](https://github.com/mrjackwills/oxker/commit/47cda44b8213cfb8c3807df6c43e3f5dc2452b57)
-
-# v0.3.0
-### 2023-03-30
-
-### Chores
-+ dependencies updated, [7a9bdc96](https://github.com/mrjackwills/oxker/commit/7a9bdc9699594532e17a33e044ca0678693c8d3f), [58e03a75](https://github.com/mrjackwills/oxker/commit/58e03a750fe89b914b9069cb0c6c02a3d0929439), [b246e8c2](https://github.com/mrjackwills/oxker/commit/b246e8c25af0c5136953afca7c694cda66550d9b)
-
-### Docs
-+ README.md and screenshot updated, [73ab7580](https://github.com/mrjackwills/oxker/commit/73ab7580c61dd59c59f10872629111360afb9033)
-
-### Features
-+ Ability to delete a container, be warned, as this will force delete, closes [#27](https://github.com/mrjackwills/oxker/issues/27), [937202fe](https://github.com/mrjackwills/oxker/commit/937202fe34d1692693c62dd1a7ad19db37651233), [b25f8b18](https://github.com/mrjackwills/oxker/commit/b25f8b18f4f2acd5c9af4a1d40655761d1bd720e)
-+ Publish images to `ghcr.io` as well as Docker Hub, and correctly tag images with `latest` and the current sermver, [cb1271cf](https://github.com/mrjackwills/oxker/commit/cb1271cf7f21c898020481ad85914a3dcc83ec93)
-+ Replace `tui-rs` with [ratatui](https://github.com/tui-rs-revival/ratatui), [d431f850](https://github.com/mrjackwills/oxker/commit/d431f850219b28af2bc45f3b6917377604596a40)
-
-### Fixes
-+ out of bound bug in `heading_bar()`, [b9c125da](https://github.com/mrjackwills/oxker/commit/b9c125da46fe0eb4aae15c354d87ac824e9cb83a)
-+ `-d` arg error text updated, [e0b49be8](https://github.com/mrjackwills/oxker/commit/e0b49be84062abdfcb636418f57043fad37d06ec)
-
-### Refactors
-+ `popup()` use `saturating_x()` rather than `checked_x()`, [d628e802](https://github.com/mrjackwills/oxker/commit/d628e8029942916053b3b7e72d363b1290fc5711)
-+ button_item() include brackets, [7c92ffef](https://github.com/mrjackwills/oxker/commit/7c92ffef7da20143a31706a310b5e6f2c3e0554f)
-
-# v0.2.5
-### 2023-03-13
-
-### Chores
-+ Rust 1.68.0 clippy linting, [5582c454](https://github.com/mrjackwills/oxker/commit/5582c45403413d3355bbcd629cfad559296f5e5b)
-+ devcontainer use sparse protocol index, [20b79e9c](https://github.com/mrjackwills/oxker/commit/20b79e9cd5bf75bb253158c0b590284139e0291d)
-+ dependencies updated, [0c07d4b4](https://github.com/mrjackwills/oxker/commit/0c07d4b40607a0eba003b6dcd0345ec0543c6264), [601a73d2](https://github.com/mrjackwills/oxker/commit/601a73d2c830043a25d64922c4d4aa38f8801912), [5aaa3c1a](https://github.com/mrjackwills/oxker/commit/5aaa3c1ab08b0c85df9bfce18a3e60206556fa58), [7a156303](https://github.com/mrjackwills/oxker/commit/7a1563030e48499da7f41033673c70deefe3de8a), [45715775](https://github.com/mrjackwills/oxker/commit/457157755baa1f9e9cfef9315a7940c357b0953d)
-
-### Features
-+ increase mpsc channel size from 16 to 32 messages, [924f14e9](https://github.com/mrjackwills/oxker/commit/924f14e998f79f731447a2eded038eab51f2e932)
-+ KeyEvents send modifier, so can quit on `ctrl + c`, [598f67c6](https://github.com/mrjackwills/oxker/commit/598f67c6f6a8713102bcc415f0409911763bb914)
-+ only send relevant mouse events to input handler, [507660d8](https://github.com/mrjackwills/oxker/commit/507660d835d0beaa8cd021110401ecc58c0613c6)
-
-### Fixes
-+ GitHub workflow on SEMEVR tag only, [14077386](https://github.com/mrjackwills/oxker/commit/140773865165bf006e74f9d436fc744220f5eae7)
-
-### Refactors
-+ replace `unwrap_or(())` with `.ok()`, [8ba37a16](https://github.com/mrjackwills/oxker/commit/8ba37a165bb89277ab957194da6464bdb35be2e6)
-+ use `unwrap_or_default()`, [79de92c3](https://github.com/mrjackwills/oxker/commit/79de92c3921702417bb2df1f44939a7b09cb7fa0)
-+ Result return, [d9f0bd55](https://github.com/mrjackwills/oxker/commit/d9f0bd5566e27218b8c8eaba6ece237907771c1d)
-
-### Reverts
-+ temporary devcontainer buildkit fix removed, [d1497a44](https://github.com/mrjackwills/oxker/commit/d1497a4451f4de54d3cc26c5a3957cd636c29118)
-
-# v0.2.4
-### 2023-03-02
-
-### Chores
-+ dependencies updated, [aac3ef2b](https://github.com/mrjackwills/oxker/commit/aac3ef2b1def3345d749d813d9b76020d6b5e5ca), [4723be7f](https://github.com/mrjackwills/oxker/commit/4723be7fb2eb101024bb9d5a514e2c6cc51eb6f6), [c69ab4f7](https://github.com/mrjackwills/oxker/commit/c69ab4f7c3b873f25ea46958add37be78d23e9cf), [ba643786](https://github.com/mrjackwills/oxker/commit/ba6437862dae0f422660a602aeabd6217d023fac), [2bb4c338](https://github.com/mrjackwills/oxker/commit/2bb4c338903e09856053894d9646307e31d32f1c)
-+ dev container install x86 musl toolchain, [e650034d](https://github.com/mrjackwills/oxker/commit/e650034d50f01a7598876d4f2887df691700e06a)
-
-### Docs
-+ typos removed, [23ad9a5f](https://github.com/mrjackwills/oxker/commit/23ad9a5fb3cacf3fb8cb70c65ca9133ed9949e45), [cebb975c](https://github.com/mrjackwills/oxker/commit/cebb975cb82f653407ec801fd8c726ca6ed68289), [fdc67c92](https://github.com/mrjackwills/oxker/commit/fdc67c9249a239bac97a78b20c9378472865209c)
-+ comments improved, [ec962295](https://github.com/mrjackwills/oxker/commit/ec962295a8789ff8010604e974969bf618ea7108)
-
-### Features
-+ mouse capture is now more specific, should have substantial performance impact, 10x reduction in cpu usage when mouse is moved observed, as well as fixing intermittent mouse events output bug, [0a1b5311](https://github.com/mrjackwills/oxker/commit/0a1b53111627206cc7436589e5b7212e1b72edb8), [93f7c07f](https://github.com/mrjackwills/oxker/commit/93f7c07f708885f8870da5dfb6d57c62f93c9c78), [c74f6c11](https://github.com/mrjackwills/oxker/commit/c74f6c1179b5f62989eb74f395a56b43a8781b03)
-+ improve the styling of the help information popup, [28de74b8](https://github.com/mrjackwills/oxker/commit/28de74b866f07c8543e46be3cab929eff28953fd)
-+ use checked_sub & checked_div for bounds checks, [72279e26](https://github.com/mrjackwills/oxker/commit/72279e26ae996353c95a75527f704bac1e4bcf4d)
-
-### Fixes
-+ correctly set gui error, [340893a8](https://github.com/mrjackwills/oxker/commit/340893a860e99ec4029d12613f2a6de3cb7b47e2)
-
-### Refactors
-+ dead code removed, [b8f5792d](https://github.com/mrjackwills/oxker/commit/b8f5792d1865d3a398cd7f23aa9473a55dc6ea44)
-+ improve the get_width function, [04c26fe8](https://github.com/mrjackwills/oxker/commit/04c26fe8fc7c79506921b9cff42825b1ee132737)
-+ place ui methods into a Ui struct, [3437df59](https://github.com/mrjackwills/oxker/commit/3437df59884f084624031fceb34ea3012a8e2251)
-+ get_horizontal/vertical constraints into single method, [e8f5cf9c](https://github.com/mrjackwills/oxker/commit/e8f5cf9c6f8cd5f807a05fb61e31d7cd1426486f)
-+ docker update_everything variables, [074cb957](https://github.com/mrjackwills/oxker/commit/074cb957f274675a468f08fecb1c43ff7453217d)
-
-# v0.2.3
-### 2023-02-04
-
-### Fixes
-+ Container runner `FROM scratch` (missing from v0.2.2 D'oh), this now should actually reduce Docker image size by ~60%, [0bd317b7](https://github.com/mrjackwills/oxker/commit/0bd317b7ce6f9f42a614c488099b5fc7a14d91c7)
-
-# v0.2.2
-### 2023-02-04
-
-### Chores
-+ devcontainer.json updated, typos-cli installed, temporary(?) buildkit fix, [3c6a8db6](https://github.com/mrjackwills/oxker/commit/3c6a8db6ef74d499b49fabe8912785cac16d9c4b)
-+ create_release.sh check for typos, [310a63f4](https://github.com/mrjackwills/oxker/commit/310a63f4cabaa374797a7e4ed0d7fd1f5e79c8fe)
-
-### Docs
-+ AUR install instructions, thanks [orhun](https://github.com/orhun), [c5aa346b](https://github.com/mrjackwills/oxker/commit/c5aa346bca139cc5ece1f4127293977924d16fca)
-+ typos fixes, thanks [kianmeng](https://github.com/kianmeng), [5052d7ab](https://github.com/mrjackwills/oxker/commit/5052d7ab0a156c43cadbd922c0019b284f24943a)
-+ Readme.md styling tweak, [310a63f4](https://github.com/mrjackwills/oxker/commit/310a63f4cabaa374797a7e4ed0d7fd1f5e79c8fe)
-+ Contributing guide, [5aaa00d6](https://github.com/mrjackwills/oxker/commit/5aaa00d6a3c58d98cb250b7b14584238df02961c), [a44b15f7](https://github.com/mrjackwills/oxker/commit/a44b15f76088561a0e272d4e7456197c2aaabdb4)
-
-### Features
-+ Use a scratch container for the docker image, should reduce image size by around 60%. This checks for the ENV `OXKER_RUNTIME=container`, which is automatically set by the docker image, [17b71b6b](https://github.com/mrjackwills/oxker/commit/17b71b6b41f6a98a0f92277f40a88f4b1b8a1328)
-
-# v0.2.1
-### 2023-01-29
-
-### Chores
-+ dependencies updated, [c129f474](https://github.com/mrjackwills/oxker/commit/c129f474fe2976454b1868d00e8d7d99b87ec23b), [9788b8af](https://github.com/mrjackwills/oxker/commit/9788b8afd98e59b1d4412a8adc54b34d2c5671fd), [2ab88eb2](https://github.com/mrjackwills/oxker/commit/2ab88eb26e9bbbc4dad4651256d8d9b044ea3272)
-
-### Docs
-+ comment typo, [10255791](https://github.com/mrjackwills/oxker/commit/1025579138f11e4987263c7bfe936c4c8542f8b3)
-
-### Fixes
-+ deadlock on draw logs when no containers found, [68e444bf](https://github.com/mrjackwills/oxker/commit/68e444bfc393eb46bac2b99eb57697bb9b0451af)
-+ github workflow release on main only (with semver tag), [e4ca41df](https://github.com/mrjackwills/oxker/commit/e4ca41dfd8ec3acae202a2d2464b8e18f5c5bdd5), [749ec712](https://github.com/mrjackwills/oxker/commit/749ec712f07cff2c941aed6726c56bdbd5cb8d2c)
-
-### Refactors
-+ major refactor of internal data handling, [b4488e4b](https://github.com/mrjackwills/oxker/commit/b4488e4bdb0252f5c5680cee6a46427f22a282ab)
-+ needless (double) referencing removed, [a174dafe](https://github.com/mrjackwills/oxker/commit/a174dafe1b05908735680a874dc551a86da24777)
-+ app_data methods re-ordered & renamed, [c0bb5355](https://github.com/mrjackwills/oxker/commit/c0bb5355d6a5d352260655110ce3d5ab695acda9)
-
-### Reverts
-+ is_running AtomicBool back to SeqCst, [c4d80061](https://github.com/mrjackwills/oxker/commit/c4d80061dab94afd08d4d793dc147f878c965ad6)
-
-# v0.2.0
-### 2023-01-21
-
-### Chores
-+ dependencies updated, [8cd199db](https://github.com/mrjackwills/oxker/commit/8cd199db49186fad6ce432bb277e3a10f0a08d34), [d880b829](https://github.com/mrjackwills/oxker/commit/d880b829c123dbe57deccadef97810e45c083737), [66d57c99](https://github.com/mrjackwills/oxker/commit/66d57c99558ca14d9593d6dbfd5b0e8e5d59055d), [33f93749](https://github.com/mrjackwills/oxker/commit/33f9374908942f4a3b90be227fad94ca353cf351), [007d5d83](https://github.com/mrjackwills/oxker/commit/007d5d83d7f1b93e1e78777a4417b2740db706bd)
-+ create_release.sh typos, [9a27d46a](https://github.com/mrjackwills/oxker/commit/9a27d46a044452080144ee1367dc95886b10abf8)
-+ dev container post create install cross, [2d253f03](https://github.com/mrjackwills/oxker/commit/2d253f034182741d434e4bac12317f24221d0d4a)
-
-### Features
-**all potentially considered breaking changes**
-+ store Logs in own struct, use a hashset to track timestamps, hopefully closes [#11](https://github.com/mrjackwills/oxker/issues/11), [657ea2d7](https://github.com/mrjackwills/oxker/commit/657ea2d751a71f05b17547b47c492d5676817336)
-+ Spawn docker commands into own thread, can now execute multiple docker commands at the same time, [9ec43e12](https://github.com/mrjackwills/oxker/commit/9ec43e124a62a80f4e78acba85fc3af5980ce260)
-+ align memory columns correctly, minimum byte display value now `0.00 kB`, rather than `0 B`, closes [#20](https://github.com/mrjackwills/oxker/issues/20), [bd7dfcd2](https://github.com/mrjackwills/oxker/commit/bd7dfcd2c512a527d66a1388f90006988a487186), [51c58001](https://github.com/mrjackwills/oxker/commit/51c580010a24de2427373795803936d498dc8cee)
-
-### Refactors
-+ main.rs tidy up, [97b89349](https://github.com/mrjackwills/oxker/commit/97b89349dc2de275ca514a1e6420255a63d775e8)
-+ derive Default for GuiState, [9dcd0509](https://github.com/mrjackwills/oxker/commit/9dcd0509efeb464f58fb53d813bd78de2447949d)
-+ param reduction, AtomicBool to Relaxed, [0350293d](https://github.com/mrjackwills/oxker/commit/0350293de3c00c6e5e5d787b7596bb3413d1cda1)
-
-# v0.1.11
-### 2023-01-03
-
-### Chores
-+ dependencies updated, [9b09146a](https://github.com/mrjackwills/oxker/commit/9b09146aadae5727a5fee4de5fe0c1d70c581c22)
-
-### Features
-+ `install.sh` script added, for automated platform selection, download, and installation, [7a42eba6](https://github.com/mrjackwills/oxker/commit/7a42eba6b0968314af40ff87bcc42d288f6860bc), [e0703b76](https://github.com/mrjackwills/oxker/commit/e0703b76a1a28cfe266f130a7f7dec92f1b5ad58)
-
-### Fixes
-+ If a sort order is set, sort containers on every `update_stats()` execution, [cfdea775](https://github.com/mrjackwills/oxker/commit/cfdea77594e48c8c20a4d6e6c7ea31c9181361a1)
-
-### Refactors
-+ input sort executed in app_data struct `sort_by_header()`, [3cdc5fae](https://github.com/mrjackwills/oxker/commit/3cdc5fae02097628799209f371ae9292e513e76c)
-
-# v0.1.10
-### 2022-12-25
-
-### Chores
-+ dependencies updated, [1525b315](https://github.com/mrjackwills/oxker/commit/1525b3150293015c0fb2f2161da463b21ac2694c), [8d539ab1](https://github.com/mrjackwills/oxker/commit/8d539ab14809136d743c49d60779687fc8eeef6d), [1774217a](https://github.com/mrjackwills/oxker/commit/1774217a8a657d261397d213e5ecee667cf3b6b1)
-+ Rust 1.66 linting, [bf9dcac7](https://github.com/mrjackwills/oxker/commit/bf9dcac7045c0d2314df147ec2744a3ad886564b)
-
-### Features
-+ Caching on github action, [a91c9aa4](https://github.com/mrjackwills/oxker/commit/a91c9aa45ffd5c998cd1b83d8e90d0912893c31f)
-
-### Fixes
-+ comment typo, [7899b773](https://github.com/mrjackwills/oxker/commit/7899b773569fed86343a035d3023bf34297fdabb)
-
-### Refactors
-+ remove_ansi() to single liner, [57c3a6c1](https://github.com/mrjackwills/oxker/commit/57c3a6c186b916faba24bf7b5cdbbda31d636a7e)
-
-# v0.1.9
-### 2022-12-05
-
-### Fixes
-+ disallow commands to be sent to a dockerised oxker container, closes [#19](https://github.com/mrjackwills/oxker/issues/19), [160b8021](https://github.com/mrjackwills/oxker/commit/160b8021b1de898064756b53c127d49b8096ce4d)
-+ if no container created time, use 0, instead of system_time(), [1adb61ce](https://github.com/mrjackwills/oxker/commit/1adb61ce3b029d4fcf51961958d483b2fae8825a)
-
-# v0.1.8
-### 2022-12-05
-
-### Chores
-+ dependencies updated, [e3aa4420](https://github.com/mrjackwills/oxker/commit/e3aa4420cb510df0381e311d37e768937070387a)
-+ docker-compose.yml alpine bump, [911c6596](https://github.com/mrjackwills/oxker/commit/911c6596684db4ccbe7a55aadd6f595a95f89bb0)
-+ github workflow use dtolnay/rust-toolchain, [57c18878](https://github.com/mrjackwills/oxker/commit/57c18878690477a05d7330112a65d1d58a07901e)
-
-### Features
-+ Clicking a header now toggles between Ascending -> Descending -> Default. Use the containers created_time as the default order - maybe add created column in future version, closes [#18](https://github.com/mrjackwills/oxker/issues/18), [cf14ba49](https://github.com/mrjackwills/oxker/commit/cf14ba498987db587c0f5bef8a67cf4113ffcb1e), [d1de2914](https://github.com/mrjackwills/oxker/commit/d1de291473d8a1028f1936429832d3820d75df54)
-+ `-s` flag for showing the oxker container when executing the docker image, [c93870e5](https://github.com/mrjackwills/oxker/commit/c93870e5fbbc7df35c69d32e4460d2104e521e33)
-
-# v0.1.7
-### 2022-11-13
-
-### Chores
-+ update dependencies, closes [#17](https://github.com/mrjackwills/oxker/issues/17), [8a5d0ef8](https://github.com/mrjackwills/oxker/commit/8a5d0ef8376e3739dda5b0ed4c3e75e565deed45), [eadfc3d6](https://github.com/mrjackwills/oxker/commit/eadfc3d6c6896ecc8cff88c6a9e9c8b3e477c0cd)
-+ aggressive linting with Rust 1.65.0, [8f3a1513](https://github.com/mrjackwills/oxker/commit/8f3a15137155dc374e6b2822c9155c07d05d5e28)
-
-### Docs
-+ README.md improved Download & Install section, and now available on [NixPkg](https://search.nixos.org/packages?channel=unstable&show=oxker&from=0&size=50&sort=relevance&type=packages&query=oxker), thanks [siph](https://github.com/siph), [67a9e183](https://github.com/mrjackwills/oxker/commit/67a9e183ca04199da758255075ff7e73061eb850)
-
-# v0.1.6
-### 2022-10-16
-
-### Chores
-+ Cargo update, [c3e72ae7](https://github.com/mrjackwills/oxker/commit/c3e72ae7369a25d903f39e55a4349cb005671dd4),
-+ create_release.sh v0.1.0, [3c8d59c6](https://github.com/mrjackwills/oxker/commit/3c8d59c666bd4cda9ca54989b2f1b48bba17bc57),
-+ uuid updated to version 1.2, [438ad770](https://github.com/mrjackwills/oxker/commit/438ad770f4a5ecb5f4bbc308066ad9e808f66514),
-
-### Fixes
-+ loading icon shifting error fix, also make icon white, closes [#15](https://github.com/mrjackwills/oxker/issues/15), [59797685](https://github.com/mrjackwills/oxker/commit/59797685dffa29752a48c98e6cf465884d6d9df6),
-
-### Features
-+ Show container name in log panel title, closes [#16](https://github.com/mrjackwills/oxker/issues/16), [9cb0c414](https://github.com/mrjackwills/oxker/commit/9cb0c414afc284947fc2b8494504387e4e7edd87),
-+ use gui_state HashSet to keep track of application gui state, [9e9d5155](https://github.com/mrjackwills/oxker/commit/9e9d51559a13944622abf4fcbd3bd63766d11467),
-+ terminal.clear() after run_app finished, [67c49575](https://github.com/mrjackwills/oxker/commit/67c49575682cb271fac0998ff377a6504cd0bc86),
-
-### Refactors
-+ CpuStats & MemStats use tuple struct, [a060d032](https://github.com/mrjackwills/oxker/commit/a060d032586a0707ac91cb13d922aae0850449c5),
-
-# v0.1.5
-### 2022-10-07
-
-### Chores
-+ Update clap to v4, [15597dbe](https://github.com/mrjackwills/oxker/commit/15597dbe6942ec053541398ce0e9dedc10a4d3ea),
-
-### Docs
-+ readme.md updated, [a05bf561](https://github.com/mrjackwills/oxker/commit/a05bf561cc6d96237f683ab0b3c782d6841974d9),
-
-### Features
-+ use newtype construct for container id, [41cbb84f](https://github.com/mrjackwills/oxker/commit/41cbb84f2896f8be2c37eba87e390d998aff7382),
-
-### Refactors
-+ Impl Copy where able to, [e76878f4](https://github.com/mrjackwills/oxker/commit/e76878f424d72b943713ef84e95e25fada77d79e),
-+ replace async fn with just fn, [17dc604b](https://github.com/mrjackwills/oxker/commit/17dc604befac75cb9dc0311a0e43f9927fe0ca30),
-+ remove pointless clone()'s & variable declarations, [6731002e](https://github.com/mrjackwills/oxker/commit/6731002ee42c9460042c2c38aff5101b1bcebbe6),
-+ replace String::from("") with String::new(), [62fb2247](https://github.com/mrjackwills/oxker/commit/62fb22478697cc9a7ab9fb562a724965b437233a),
-+ replace map_or_else with map_or, [3e26f292](https://github.com/mrjackwills/oxker/commit/3e26f292c7dc5e13af4580952767ebe821aa5183), [5660b34d](https://github.com/mrjackwills/oxker/commit/5660b34d5149dce27706ff6daa90b854e6f84e14),
-
-# v0.1.4
-### 2022-09-07
-
-### Chores
-+ dependencies updated, [a3168daa](https://github.com/mrjackwills/oxker/commit/a3168daa3f769a6747dfbe61103073a7e80a1485),[78e59160](https://github.com/mrjackwills/oxker/commit/78e59160bb6a978ee80e3a99eb72f051fb64e737),
-
-### Features
-+ containerize self, github action to build and push to [Docker Hub](https://hub.docker.com/r/mrjackwills/oxker), [07f97202](https://github.com/mrjackwills/oxker/commit/07f972022a69f22bac57925e6ad84234381f7890),
-+ gui_state is_loading use a HashSet to enable multiple things be loading at the same time, [66583e1b](https://github.com/mrjackwills/oxker/commit/66583e1b037b7e2f3e47948d70d8a4c6f6a2f2d5),
-+ github action publish to crates.io, [90b2e3f6](https://github.com/mrjackwills/oxker/commit/90b2e3f6db0d5f63840cd80888a30da6ecc22f20),
-+ derive Eq where appropriate, [d7c2601f](https://github.com/mrjackwills/oxker/commit/d7c2601f959bc12a64cd25cef59c837e1e8c2b2a),
-+ ignore containers 'oxker' containers, [1be9f52a](https://github.com/mrjackwills/oxker/commit/1be9f52ad4a68f93142784e9df630c59cdec0a79),
-+ update container info if container is either running OR restarting, [5f12362d](https://github.com/mrjackwills/oxker/commit/5f12362db7cb61ca68f75b99ecfc9725380d87d2),
-
-### Fixes
-+ devcontainer updated, [3bde4f56](https://github.com/mrjackwills/oxker/commit/3bde4f5629539cab3dbb57556663ab81685f9d7a),
-+ Use Binate enum to enable two cycles of cpu/mem update to be executed (for each container) at the same time, refactor hashmap spawn insertions, [7ec58e79](https://github.com/mrjackwills/oxker/commit/7ec58e79a1316ad1f7e50a2781dea0fe8422c588),
-
-### Refactors
-+ improved way to remove leading '/' of container name, [832e9782](https://github.com/mrjackwills/oxker/commit/832e9782d7765872cbb84df6b3703fc08cb353c9),
-
-# v0.1.3
-### 2022-08-04
-
-### Chores
-+ dependencies updated, [d9801cdf](https://github.com/mrjackwills/oxker/commit/d9801cdf372521fe5624a8d68fac83ed39ef81f4),
-+ linting: nursery, pedantic, unused_unwraps, [1bd61d4c](https://github.com/mrjackwills/oxker/commit/1bd61d4ce8b369d6d078201add3eea0f59fe0dea), [1263662b](https://github.com/mrjackwills/oxker/commit/1263662bd9412afacddbc10721bf216ae3a843f1), [ca3315a6](https://github.com/mrjackwills/oxker/commit/ca3315a69f593ad705eb637f227f195edd7781b2),
-
-### Features
-+ build all production targets on release, [44f8140e](https://github.com/mrjackwills/oxker/commit/44f8140eaec330abe5a94f3ddae9e8b223688aa8),
-
-### Fixes
-+ toml keywords, [dd2d82d1](https://github.com/mrjackwills/oxker/commit/dd2d82d114537e09dbeb12f360157f0e68e7846e),
-
-# v0.1.2
-### 2022-07-23
-
-### Fixes
-+ remove reqwest dependency, [10ff8bab](https://github.com/mrjackwills/oxker/commit/10ff8bab5f01f097fd6cdec60b2be947f238197b),
-
-# v0.1.1
-### 2022-07-23
-
-### Chores
-+ update Cargo.toml, in preparation for crates.io publishing, [fdc6898e](https://github.com/mrjackwills/oxker/commit/fdc6898e20c41415f03e310d7b84af4b6c39ab62),
-
-### Docs
-+ added cargo install instructions, [c774b10d](https://github.com/mrjackwills/oxker/commit/c774b10d557b10885b9d3a0b3612330a8ecb1cd5),
-
-### Fixes
-+ use SpawnId for docker hashmap JoinHandle mapping, [1ae95d58](https://github.com/mrjackwills/oxker/commit/1ae95d58c3302a95d5a0a2f0b61b126c72b6e166),
-
-# v0.1.0
-### 2022-07-23
-
-### Chores
-+ dependencies updated, [cf7e02dd](https://github.com/mrjackwills/oxker/commit/cf7e02dde94f69832a2e485b99785afc66a5bc15),
-
-### Features
-+ Enable sorting of containers by each, and every, heading. Either via keyboard or mouse, closes [#3](https://github.com/mrjackwills/oxker/issues/3), [a6c296f2](https://github.com/mrjackwills/oxker/commit/a6c296f2cde56cf241bcd696cab8bd477270e5f4),
-+ Spawn & track docker information update requests, multiple identical requests cannot be executed, [740c059b](https://github.com/mrjackwills/oxker/commit/740c059b276f35acd1cb03f1030134646bf8a07d),
-
-# v0.0.6
-### 2022-07-06
-
-### Docs
-+ readme update, [f29e29ad](https://github.com/mrjackwills/oxker/commit/f29e29ad151ddf424ba630e6d33edf19acfd7636),
-+ comments improved, [1674db8a](https://github.com/mrjackwills/oxker/commit/1674db8a20aafa447732deb2e44ac8b97cf0471b),
-+ readme logo size, [a733efa6](https://github.com/mrjackwills/oxker/commit/a733efa65865e04d9ec86c7ca8785dfbae635695),
-
-### Fixes
-+ Remove unwraps(), [61db81ec](https://github.com/mrjackwills/oxker/commit/61db81ecfe5684ddb8a360715f43357a042162c0),
-+ Help menu alt+tab > shift+tab typo, thanks [siph](https://github.com/siph), [04466803](https://github.com/mrjackwills/oxker/commit/04466803481b75feb7d7f275248279fdb8729862),
-
-### Refactors
-+ tokio spawns, [1fd230f2](https://github.com/mrjackwills/oxker/commit/1fd230f2f3cf4e376058359515e76f4fa6e425c2),
-+ max_line_width(), [a5d7dabb](https://github.com/mrjackwills/oxker/commit/a5d7dabbd68dc15a081df33352ce3b55d9a9891c),
-+ create_release dead code removed, [297979c1](https://github.com/mrjackwills/oxker/commit/297979c197c2defd409053d8da724f922b0bba1b),
-
-
-# v0.0.5
-### 2022-05-30
-
-### Docs
-+ Readme one-liner to download & install latest version, [11d5ba36](https://github.com/mrjackwills/oxker/commit/11d5ba361ee4c11d080f1c3c14d8bb677cbfd1fc),
-+ Example docker-compose.yml bump alpine version to 3.16, [98c83f2f](https://github.com/mrjackwills/oxker/commit/98c83f2f68f59e78f0c78270c59886630d98913c),
-
-### Fixes
-+ use Some() checks to make sure that container item indexes are still valid, else can create out-of-bounds errors, closes [#8](https://github.com/mrjackwills/oxker/issues/8), [4cf02e3f](https://github.com/mrjackwills/oxker/commit/4cf02e3f04426ef44ec5a7421687f2104ac5102f),
-+ Remove + replace as many unwrap()'s as possible, [d8e22d74](https://github.com/mrjackwills/oxker/commit/d8e22d7444965f1874d7367259310440a889432b),
-+ Help panel typo, [e497f3f2](https://github.com/mrjackwills/oxker/commit/e497f3f2d9e1dca99469860c2e728c99e29353ad),
-
-# v0.0.4
-### 2022-05-08
-
-### Fixes
-+ Help menu logo corrected, [2f545202](https://github.com/mrjackwills/oxker/commit/2f5452027e86f714729b804d4bf65306e755df7f),
-
-# v0.0.3
-### 2022-05-08
-
-### Docs
-+ slight readme tweaks, [eb9184a1](https://github.com/mrjackwills/oxker/commit/eb9184a1aee64be1c20fabd482bfcbe676bed049),
-
-### Features
-+ vim movement keys, 'j' & 'k', to move through menus, thanks [siph](https://github.com/siph), [77eb33c0](https://github.com/mrjackwills/oxker/commit/77eb33c008e36965d84d1eafbbc3733af19fd262),
-
-### Fixes
-+ create_release.sh correctly link to closed issues, [5820d0a9](https://github.com/mrjackwills/oxker/commit/5820d0a9b68ead71d031377c5d22138675d7dfa8),
-
-### Refactors
-+ generate_block reduce params, insert into area hashmap from inside generate_block function, [32705a60](https://github.com/mrjackwills/oxker/commit/32705a60c4f865eb829cc460b2ac82db79107c1a),
-+ dead code removed, [d20e1bcd](https://github.com/mrjackwills/oxker/commit/d20e1bcd47965859a04f8e080509a5afb2de36d9),
-+ create_release.sh improved flow & comments, [4283a285](https://github.com/mrjackwills/oxker/commit/4283a285e2e60907e432294e3b97a759ec06a23d),
-
-
-# v0.0.2
-### 2022-04-29
-
-### Features
-+ allow toggling of mouse capture, to select & copy text with mouse, closes [#2](https://github.com/mrjackwills/oxker/issues/2), [aec184ea](https://github.com/mrjackwills/oxker/commit/aec184ea22b289e91942a4c3e6a415685884bc47),
-+ show id column, [b10f9274](https://github.com/mrjackwills/oxker/commit/b10f927481c9e38a48c1d4b94e744ec48e8b6ba6),
-+ draw_popup, using enum to draw in one of 9 areas, closes [#6](https://github.com/mrjackwills/oxker/issues/6), [1017850a](https://github.com/mrjackwills/oxker/commit/1017850a6cc91328abc1127bdb117495f5e909d8),
-+ use a message rx/sx for all docker commands, remove update loop, wait for update message from gui instead, [9b70fdfa](https://github.com/mrjackwills/oxker/commit/9b70fdfad7b38361ebee301bdc2545d3f0dfcf9e),
-
-### Fixes
-+ readme.md typo, [589501f9](https://github.com/mrjackwills/oxker/commit/589501f9a4a0bfabdb0654e68cc0c752c529d97a),
-+ column heading mem > memory, [5e8e6b59](https://github.com/mrjackwills/oxker/commit/5e8e6b590b06f01a542fdd10bae8f14d303ab08a),
-+ cargo fmt added to create_release.sh, [bb29c0eb](https://github.com/mrjackwills/oxker/commit/bb29c0ebfafd6a9a036eb317a240954d1405966e),
-
-# v0.0.1
-### 2022-04-25
-
-+ init commit
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..ca07a43
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,105 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+Oxker is a Go rewrite of the original Rust-based TUI for viewing and controlling Docker containers. It uses the Bubbletea framework for the terminal UI and the Docker SDK for container management.
+
+**Note:** The git history still contains deleted Rust source files (src/, Cargo.toml, etc.). Original Rust source is preserved in `source/` for reference. The active codebase is entirely Go.
+
+## Build & Run
+
+```bash
+# Build
+go build -o oxker ./cmd/oxker
+
+# Run
+./oxker
+
+# Run with debug mode (no TUI)
+./oxker -g
+
+# Vet
+go vet ./...
+
+# Test
+go test ./...
+
+# Run a single test
+go test ./cmd/oxker -run TestAppStart
+
+# Tidy modules
+go mod tidy
+
+# All-in-one build script (build + vet + tidy)
+./build.sh
+```
+
+## Architecture
+
+The app follows the Elm architecture (Model-View-Update) via Bubbletea:
+
+- **`cmd/oxker/`** - Entry point and CLI argument parsing
+ - `main.go` - Creates `tea.Program` with AltScreen + mouse support, passes config to `app.New(cfg)`
+ - `cli.go` - Flag definitions, config loading, CLI-to-config mapping
+
+- **`internal/app/app.go`** - Central application model (~1850 lines, self-contained). Contains:
+ - `App` struct implementing `tea.Model` (Init/Update/View)
+ - `Container` data type with 60-sample rolling stats buffers (CPU, mem, RX, TX)
+ - Container state machine: RunningHealthy, RunningUnhealthy, Paused, Exited, Dead, Restarting, Removing, Created, Unknown
+ - Catppuccin Mocha color palette
+ - Sorting (9 columns, asc/desc toggle), filtering (4 modes: Name/Image/Status/All), log search with match navigation
+ - Sparkline charts for CPU, Memory, and Network bandwidth
+ - Mouse support (wheel scroll, click to select containers, click headers to sort)
+ - Docker exec via `tea.ExecProcess` (suspends TUI, runs `docker exec -it sh`)
+ - Inspect panel (JSON), delete confirmation popup, error/info overlays
+ - Log panel: resize, toggle, horizontal scroll, save to file
+ - SI units (1000-based) for byte formatting
+ - CPU calculation: `(cpu_delta / system_delta) * online_cpus * 100.0`
+
+- **`internal/docker/client.go`** - Thin wrapper around `github.com/docker/docker/client`. Key methods:
+ - `ContainerStatsOneShot` - Single stats snapshot with proper JSON decoding
+ - `Logs` - Container log streaming
+ - `ContainerExec` - Docker exec API (used by client, app uses CLI `docker exec` via `tea.ExecProcess`)
+ - Standard CRUD: Start, Stop, Restart, Delete, Pause, Unpause, Inspect
+
+- **`internal/config/config.go`** - Configuration with TOML and JSONC support. Includes:
+ - `AppColors` - Full color theming for every UI element
+ - `Keymap` - Customizable key bindings with primary/secondary support
+ - Config file resolution: absolute path, relative to CWD, then `~/.config/oxker/`
+
+- **`internal/input/`**, **`internal/ui/`**, **`internal/utils/`** - Legacy modules from earlier architecture, currently unused by app.go
+
+## Key Dependencies
+
+- `github.com/charmbracelet/bubbletea` v1.3.10 - TUI framework (Elm architecture)
+- `github.com/charmbracelet/lipgloss` - Terminal styling
+- `github.com/docker/docker` - Docker SDK
+- `github.com/BurntSushi/toml` - TOML config parsing
+
+## Data Flow
+
+1. `main.go` parses CLI args -> loads config -> creates `App` model with config -> starts `tea.Program`
+2. `App.Init()` fires `loadContainers()` and `tickCmd()` concurrently
+3. `tickCmd()` fires every `DockerIntervalMs` (default 1000ms), triggers container list reload + stats updates
+4. Stats collected via `ContainerStatsOneShot` (one-shot, not streaming) for each running container
+5. Key/mouse events route through `App.Update()` -> mode-specific handler (normal, filter, search, inspect, delete confirm)
+6. Container actions dispatch via `doAction()` which calls Docker SDK through `CmdMgr`
+
+## CLI Flags
+
+Key flags: `-d` (update interval ms), `-r` (raw logs), `-c` (color logs), `-t` (timestamps), `-s` (show self), `-g` (debug/no-TUI), `-config-file`, `-host`, `-timezone`, `-use-cli`, `-no-stderr`, `-save-dir`
+
+## Mouse API (bubbletea v1.3.10)
+
+`tea.MouseMsg` is a struct (not interface). Use `msg.Action` (MouseActionPress/Release/Motion) and `msg.Button` (MouseButtonLeft/WheelUp/WheelDown etc.) directly. No subtypes like MouseWheelMsg or MouseClickMsg.
+
+## JCodeMunch (jmunch) Usage
+
+- Start with `get_repo_outline` to quickly understand the repository structure.
+- Use `get_file_outline` before reading source to understand the API surface first.
+- Narrow searches using `kind`, `language`, and `file_pattern`.
+- Batch-retrieve related symbols with `get_symbols` instead of repeated `get_symbol` calls.
+- Use `search_text` when symbol search does not locate the needed content.
+- Use `verify: true` on `get_symbol` to detect source drift since indexing.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
deleted file mode 100644
index 1005181..0000000
--- a/CONTRIBUTING.md
+++ /dev/null
@@ -1,14 +0,0 @@
-# Contributing to oxker
-
-oxker encourages any, and all, suggestions, bug reports, pull requests, and/or feedback.
-
-## Submitting Issues
-
-Please use the oxker [issue templates](https://github.com/mrjackwills/oxker/issues/new/choose) for any Bug Report, Feature Suggestions,
-Refactor Idea, or Security Vulnerabilities.
-
-Don't hesitate to submit any issues or pull requests, regardless of size.
-
-## Conduct
-
-oxker follows the [Rust Code of Conduct](https://www.rust-lang.org/policies/code-of-conduct)
\ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
deleted file mode 100644
index 3912ed1..0000000
--- a/Cargo.lock
+++ /dev/null
@@ -1,2989 +0,0 @@
-# This file is automatically @generated by Cargo.
-# It is not intended for manual editing.
-version = 4
-
-[[package]]
-name = "aho-corasick"
-version = "1.1.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
-dependencies = [
- "memchr",
-]
-
-[[package]]
-name = "allocator-api2"
-version = "0.2.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
-
-[[package]]
-name = "anstream"
-version = "0.6.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
-dependencies = [
- "anstyle",
- "anstyle-parse",
- "anstyle-query",
- "anstyle-wincon",
- "colorchoice",
- "is_terminal_polyfill",
- "utf8parse",
-]
-
-[[package]]
-name = "anstyle"
-version = "1.0.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
-
-[[package]]
-name = "anstyle-parse"
-version = "0.2.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
-dependencies = [
- "utf8parse",
-]
-
-[[package]]
-name = "anstyle-query"
-version = "1.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
-dependencies = [
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "anstyle-wincon"
-version = "3.0.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
-dependencies = [
- "anstyle",
- "once_cell_polyfill",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "anyhow"
-version = "1.0.102"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
-
-[[package]]
-name = "atomic"
-version = "0.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340"
-dependencies = [
- "bytemuck",
-]
-
-[[package]]
-name = "atomic-waker"
-version = "1.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
-
-[[package]]
-name = "autocfg"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
-
-[[package]]
-name = "base64"
-version = "0.22.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
-
-[[package]]
-name = "bit-set"
-version = "0.5.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
-dependencies = [
- "bit-vec",
-]
-
-[[package]]
-name = "bit-vec"
-version = "0.6.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
-
-[[package]]
-name = "bitflags"
-version = "1.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
-
-[[package]]
-name = "bitflags"
-version = "2.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
-
-[[package]]
-name = "block-buffer"
-version = "0.10.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
-dependencies = [
- "generic-array",
-]
-
-[[package]]
-name = "bollard"
-version = "0.20.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "227aa051deec8d16bd9c34605e7aaf153f240e35483dd42f6f78903847934738"
-dependencies = [
- "base64",
- "bollard-stubs",
- "bytes",
- "futures-core",
- "futures-util",
- "hex",
- "http",
- "http-body-util",
- "hyper",
- "hyper-named-pipe",
- "hyper-util",
- "hyperlocal",
- "log",
- "pin-project-lite",
- "serde",
- "serde_derive",
- "serde_json",
- "serde_urlencoded",
- "thiserror 2.0.18",
- "tokio",
- "tokio-util",
- "tower-service",
- "url",
- "winapi",
-]
-
-[[package]]
-name = "bollard-stubs"
-version = "1.52.1-rc.29.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066"
-dependencies = [
- "serde",
- "serde_json",
- "serde_repr",
-]
-
-[[package]]
-name = "bumpalo"
-version = "3.20.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
-
-[[package]]
-name = "bytemuck"
-version = "1.25.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
-
-[[package]]
-name = "bytes"
-version = "1.11.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
-
-[[package]]
-name = "cansi"
-version = "2.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4bdcae87153686017415ce77e48c53e6818a0a058f0e21b56640d1e944967ef8"
-
-[[package]]
-name = "castaway"
-version = "0.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
-dependencies = [
- "rustversion",
-]
-
-[[package]]
-name = "cfg-if"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
-
-[[package]]
-name = "cfg_aliases"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
-
-[[package]]
-name = "clap"
-version = "4.5.60"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
-dependencies = [
- "clap_builder",
- "clap_derive",
-]
-
-[[package]]
-name = "clap_builder"
-version = "4.5.60"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
-dependencies = [
- "anstream",
- "anstyle",
- "clap_lex",
- "strsim",
- "unicase",
- "unicode-width",
-]
-
-[[package]]
-name = "clap_derive"
-version = "4.5.55"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
-dependencies = [
- "heck",
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "clap_lex"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
-
-[[package]]
-name = "colorchoice"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
-
-[[package]]
-name = "compact_str"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a"
-dependencies = [
- "castaway",
- "cfg-if",
- "itoa",
- "rustversion",
- "ryu",
- "static_assertions",
-]
-
-[[package]]
-name = "console"
-version = "0.15.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
-dependencies = [
- "encode_unicode",
- "libc",
- "once_cell",
- "windows-sys 0.59.0",
-]
-
-[[package]]
-name = "convert_case"
-version = "0.10.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
-dependencies = [
- "unicode-segmentation",
-]
-
-[[package]]
-name = "cpufeatures"
-version = "0.2.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "crossterm"
-version = "0.29.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
-dependencies = [
- "bitflags 2.11.0",
- "crossterm_winapi",
- "derive_more",
- "document-features",
- "mio",
- "parking_lot",
- "rustix",
- "signal-hook",
- "signal-hook-mio",
- "winapi",
-]
-
-[[package]]
-name = "crossterm_winapi"
-version = "0.9.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
-dependencies = [
- "winapi",
-]
-
-[[package]]
-name = "crypto-common"
-version = "0.1.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
-dependencies = [
- "generic-array",
- "typenum",
-]
-
-[[package]]
-name = "csscolorparser"
-version = "0.6.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf"
-dependencies = [
- "lab",
- "phf",
-]
-
-[[package]]
-name = "darling"
-version = "0.23.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
-dependencies = [
- "darling_core",
- "darling_macro",
-]
-
-[[package]]
-name = "darling_core"
-version = "0.23.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
-dependencies = [
- "ident_case",
- "proc-macro2",
- "quote",
- "strsim",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "darling_macro"
-version = "0.23.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
-dependencies = [
- "darling_core",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "deltae"
-version = "0.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
-
-[[package]]
-name = "deranged"
-version = "0.5.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
-dependencies = [
- "powerfmt",
-]
-
-[[package]]
-name = "derive_more"
-version = "2.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
-dependencies = [
- "derive_more-impl",
-]
-
-[[package]]
-name = "derive_more-impl"
-version = "2.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
-dependencies = [
- "convert_case",
- "proc-macro2",
- "quote",
- "rustc_version",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "digest"
-version = "0.10.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
-dependencies = [
- "block-buffer",
- "crypto-common",
-]
-
-[[package]]
-name = "directories"
-version = "6.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
-dependencies = [
- "dirs-sys",
-]
-
-[[package]]
-name = "dirs-sys"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
-dependencies = [
- "libc",
- "option-ext",
- "redox_users",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "displaydoc"
-version = "0.2.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "document-features"
-version = "0.2.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
-dependencies = [
- "litrs",
-]
-
-[[package]]
-name = "either"
-version = "1.15.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
-
-[[package]]
-name = "encode_unicode"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
-
-[[package]]
-name = "equivalent"
-version = "1.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
-
-[[package]]
-name = "errno"
-version = "0.3.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
-dependencies = [
- "libc",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "euclid"
-version = "0.22.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63"
-dependencies = [
- "num-traits",
-]
-
-[[package]]
-name = "fancy-regex"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
-dependencies = [
- "bit-set",
- "regex",
-]
-
-[[package]]
-name = "fastrand"
-version = "2.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
-
-[[package]]
-name = "filedescriptor"
-version = "0.8.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
-dependencies = [
- "libc",
- "thiserror 1.0.69",
- "winapi",
-]
-
-[[package]]
-name = "finl_unicode"
-version = "1.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
-
-[[package]]
-name = "fixedbitset"
-version = "0.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
-
-[[package]]
-name = "fnv"
-version = "1.0.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
-
-[[package]]
-name = "foldhash"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
-
-[[package]]
-name = "foldhash"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
-
-[[package]]
-name = "form_urlencoded"
-version = "1.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
-dependencies = [
- "percent-encoding",
-]
-
-[[package]]
-name = "futures-channel"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
-dependencies = [
- "futures-core",
-]
-
-[[package]]
-name = "futures-core"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
-
-[[package]]
-name = "futures-macro"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "futures-sink"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
-
-[[package]]
-name = "futures-task"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
-
-[[package]]
-name = "futures-util"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
-dependencies = [
- "futures-core",
- "futures-macro",
- "futures-task",
- "pin-project-lite",
- "slab",
-]
-
-[[package]]
-name = "generic-array"
-version = "0.14.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
-dependencies = [
- "typenum",
- "version_check",
-]
-
-[[package]]
-name = "getrandom"
-version = "0.2.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
-dependencies = [
- "cfg-if",
- "libc",
- "wasi",
-]
-
-[[package]]
-name = "getrandom"
-version = "0.3.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
-dependencies = [
- "cfg-if",
- "libc",
- "r-efi",
- "wasip2",
-]
-
-[[package]]
-name = "getrandom"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
-dependencies = [
- "cfg-if",
- "libc",
- "r-efi",
- "wasip2",
- "wasip3",
-]
-
-[[package]]
-name = "hashbrown"
-version = "0.15.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
-dependencies = [
- "foldhash 0.1.5",
-]
-
-[[package]]
-name = "hashbrown"
-version = "0.16.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
-dependencies = [
- "allocator-api2",
- "equivalent",
- "foldhash 0.2.0",
-]
-
-[[package]]
-name = "heck"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
-
-[[package]]
-name = "hex"
-version = "0.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
-
-[[package]]
-name = "http"
-version = "1.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
-dependencies = [
- "bytes",
- "itoa",
-]
-
-[[package]]
-name = "http-body"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
-dependencies = [
- "bytes",
- "http",
-]
-
-[[package]]
-name = "http-body-util"
-version = "0.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
-dependencies = [
- "bytes",
- "futures-core",
- "http",
- "http-body",
- "pin-project-lite",
-]
-
-[[package]]
-name = "httparse"
-version = "1.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
-
-[[package]]
-name = "httpdate"
-version = "1.0.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
-
-[[package]]
-name = "hyper"
-version = "1.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
-dependencies = [
- "atomic-waker",
- "bytes",
- "futures-channel",
- "futures-core",
- "http",
- "http-body",
- "httparse",
- "httpdate",
- "itoa",
- "pin-project-lite",
- "pin-utils",
- "smallvec",
- "tokio",
- "want",
-]
-
-[[package]]
-name = "hyper-named-pipe"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278"
-dependencies = [
- "hex",
- "hyper",
- "hyper-util",
- "pin-project-lite",
- "tokio",
- "tower-service",
- "winapi",
-]
-
-[[package]]
-name = "hyper-util"
-version = "0.1.20"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
-dependencies = [
- "bytes",
- "futures-channel",
- "futures-util",
- "http",
- "http-body",
- "hyper",
- "libc",
- "pin-project-lite",
- "socket2",
- "tokio",
- "tower-service",
- "tracing",
-]
-
-[[package]]
-name = "hyperlocal"
-version = "0.9.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7"
-dependencies = [
- "hex",
- "http-body-util",
- "hyper",
- "hyper-util",
- "pin-project-lite",
- "tokio",
- "tower-service",
-]
-
-[[package]]
-name = "icu_collections"
-version = "2.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
-dependencies = [
- "displaydoc",
- "potential_utf",
- "yoke",
- "zerofrom",
- "zerovec",
-]
-
-[[package]]
-name = "icu_locale_core"
-version = "2.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
-dependencies = [
- "displaydoc",
- "litemap",
- "tinystr",
- "writeable",
- "zerovec",
-]
-
-[[package]]
-name = "icu_normalizer"
-version = "2.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
-dependencies = [
- "icu_collections",
- "icu_normalizer_data",
- "icu_properties",
- "icu_provider",
- "smallvec",
- "zerovec",
-]
-
-[[package]]
-name = "icu_normalizer_data"
-version = "2.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
-
-[[package]]
-name = "icu_properties"
-version = "2.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
-dependencies = [
- "icu_collections",
- "icu_locale_core",
- "icu_properties_data",
- "icu_provider",
- "zerotrie",
- "zerovec",
-]
-
-[[package]]
-name = "icu_properties_data"
-version = "2.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
-
-[[package]]
-name = "icu_provider"
-version = "2.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
-dependencies = [
- "displaydoc",
- "icu_locale_core",
- "writeable",
- "yoke",
- "zerofrom",
- "zerotrie",
- "zerovec",
-]
-
-[[package]]
-name = "id-arena"
-version = "2.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
-
-[[package]]
-name = "ident_case"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
-
-[[package]]
-name = "idna"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
-dependencies = [
- "idna_adapter",
- "smallvec",
- "utf8_iter",
-]
-
-[[package]]
-name = "idna_adapter"
-version = "1.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
-dependencies = [
- "icu_normalizer",
- "icu_properties",
-]
-
-[[package]]
-name = "indexmap"
-version = "2.13.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
-dependencies = [
- "equivalent",
- "hashbrown 0.16.1",
- "serde",
- "serde_core",
-]
-
-[[package]]
-name = "indoc"
-version = "2.0.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
-dependencies = [
- "rustversion",
-]
-
-[[package]]
-name = "insta"
-version = "1.46.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4"
-dependencies = [
- "console",
- "once_cell",
- "similar",
- "tempfile",
-]
-
-[[package]]
-name = "instability"
-version = "0.3.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d"
-dependencies = [
- "darling",
- "indoc",
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "is_terminal_polyfill"
-version = "1.70.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
-
-[[package]]
-name = "itertools"
-version = "0.14.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
-dependencies = [
- "either",
-]
-
-[[package]]
-name = "itoa"
-version = "1.0.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
-
-[[package]]
-name = "jiff"
-version = "0.2.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b3e3d65f018c6ae946ab16e80944b97096ed73c35b221d1c478a6c81d8f57940"
-dependencies = [
- "jiff-static",
- "jiff-tzdb",
- "jiff-tzdb-platform",
- "log",
- "portable-atomic",
- "portable-atomic-util",
- "serde_core",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "jiff-static"
-version = "0.2.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a17c2b211d863c7fde02cbea8a3c1a439b98e109286554f2860bdded7ff83818"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "jiff-tzdb"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68971ebff725b9e2ca27a601c5eb38a4c5d64422c4cbab0c535f248087eda5c2"
-
-[[package]]
-name = "jiff-tzdb-platform"
-version = "0.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
-dependencies = [
- "jiff-tzdb",
-]
-
-[[package]]
-name = "js-sys"
-version = "0.3.88"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c7e709f3e3d22866f9c25b3aff01af289b18422cc8b4262fb19103ee80fe513d"
-dependencies = [
- "once_cell",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "kasuari"
-version = "0.4.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b"
-dependencies = [
- "hashbrown 0.16.1",
- "portable-atomic",
- "thiserror 2.0.18",
-]
-
-[[package]]
-name = "lab"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f"
-
-[[package]]
-name = "lazy_static"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
-
-[[package]]
-name = "leb128fmt"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
-
-[[package]]
-name = "libc"
-version = "0.2.182"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
-
-[[package]]
-name = "libredox"
-version = "0.1.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
-dependencies = [
- "bitflags 2.11.0",
- "libc",
-]
-
-[[package]]
-name = "line-clipping"
-version = "0.3.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a"
-dependencies = [
- "bitflags 2.11.0",
-]
-
-[[package]]
-name = "linux-raw-sys"
-version = "0.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
-
-[[package]]
-name = "litemap"
-version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
-
-[[package]]
-name = "litrs"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
-
-[[package]]
-name = "lock_api"
-version = "0.4.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
-dependencies = [
- "scopeguard",
-]
-
-[[package]]
-name = "log"
-version = "0.4.29"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
-
-[[package]]
-name = "lru"
-version = "0.16.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593"
-dependencies = [
- "hashbrown 0.16.1",
-]
-
-[[package]]
-name = "mac_address"
-version = "1.1.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
-dependencies = [
- "nix",
- "winapi",
-]
-
-[[package]]
-name = "memchr"
-version = "2.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
-
-[[package]]
-name = "memmem"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15"
-
-[[package]]
-name = "memoffset"
-version = "0.9.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
-dependencies = [
- "autocfg",
-]
-
-[[package]]
-name = "minimal-lexical"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
-
-[[package]]
-name = "mio"
-version = "1.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
-dependencies = [
- "libc",
- "log",
- "wasi",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "nix"
-version = "0.29.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
-dependencies = [
- "bitflags 2.11.0",
- "cfg-if",
- "cfg_aliases",
- "libc",
- "memoffset",
-]
-
-[[package]]
-name = "nom"
-version = "7.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
-dependencies = [
- "memchr",
- "minimal-lexical",
-]
-
-[[package]]
-name = "nu-ansi-term"
-version = "0.50.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
-dependencies = [
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "num-conv"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
-
-[[package]]
-name = "num-derive"
-version = "0.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "num-traits"
-version = "0.2.19"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
-dependencies = [
- "autocfg",
-]
-
-[[package]]
-name = "num_threads"
-version = "0.1.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "once_cell"
-version = "1.21.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
-
-[[package]]
-name = "once_cell_polyfill"
-version = "1.70.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
-
-[[package]]
-name = "option-ext"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
-
-[[package]]
-name = "ordered-float"
-version = "4.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
-dependencies = [
- "num-traits",
-]
-
-[[package]]
-name = "oxker"
-version = "0.13.0"
-dependencies = [
- "anyhow",
- "bollard",
- "cansi",
- "clap",
- "crossterm",
- "directories",
- "futures-util",
- "insta",
- "jiff",
- "parking_lot",
- "ratatui",
- "serde",
- "serde_json",
- "serde_jsonc",
- "tokio",
- "tokio-util",
- "toml",
- "tracing",
- "tracing-subscriber",
- "uuid",
-]
-
-[[package]]
-name = "parking_lot"
-version = "0.12.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
-dependencies = [
- "lock_api",
- "parking_lot_core",
-]
-
-[[package]]
-name = "parking_lot_core"
-version = "0.9.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
-dependencies = [
- "cfg-if",
- "libc",
- "redox_syscall",
- "smallvec",
- "windows-link",
-]
-
-[[package]]
-name = "percent-encoding"
-version = "2.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
-
-[[package]]
-name = "pest"
-version = "2.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662"
-dependencies = [
- "memchr",
- "ucd-trie",
-]
-
-[[package]]
-name = "pest_derive"
-version = "2.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77"
-dependencies = [
- "pest",
- "pest_generator",
-]
-
-[[package]]
-name = "pest_generator"
-version = "2.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f"
-dependencies = [
- "pest",
- "pest_meta",
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "pest_meta"
-version = "2.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
-dependencies = [
- "pest",
- "sha2",
-]
-
-[[package]]
-name = "phf"
-version = "0.11.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
-dependencies = [
- "phf_macros",
- "phf_shared",
-]
-
-[[package]]
-name = "phf_codegen"
-version = "0.11.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
-dependencies = [
- "phf_generator",
- "phf_shared",
-]
-
-[[package]]
-name = "phf_generator"
-version = "0.11.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
-dependencies = [
- "phf_shared",
- "rand 0.8.5",
-]
-
-[[package]]
-name = "phf_macros"
-version = "0.11.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
-dependencies = [
- "phf_generator",
- "phf_shared",
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "phf_shared"
-version = "0.11.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
-dependencies = [
- "siphasher",
-]
-
-[[package]]
-name = "pin-project-lite"
-version = "0.2.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
-
-[[package]]
-name = "pin-utils"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
-
-[[package]]
-name = "portable-atomic"
-version = "1.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
-
-[[package]]
-name = "portable-atomic-util"
-version = "0.2.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5"
-dependencies = [
- "portable-atomic",
-]
-
-[[package]]
-name = "potential_utf"
-version = "0.1.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
-dependencies = [
- "zerovec",
-]
-
-[[package]]
-name = "powerfmt"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
-
-[[package]]
-name = "ppv-lite86"
-version = "0.2.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
-dependencies = [
- "zerocopy",
-]
-
-[[package]]
-name = "prettyplease"
-version = "0.2.37"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
-dependencies = [
- "proc-macro2",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "proc-macro2"
-version = "1.0.106"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
-dependencies = [
- "unicode-ident",
-]
-
-[[package]]
-name = "quote"
-version = "1.0.44"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
-dependencies = [
- "proc-macro2",
-]
-
-[[package]]
-name = "r-efi"
-version = "5.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
-
-[[package]]
-name = "rand"
-version = "0.8.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
-dependencies = [
- "rand_core 0.6.4",
-]
-
-[[package]]
-name = "rand"
-version = "0.9.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
-dependencies = [
- "rand_chacha",
- "rand_core 0.9.5",
-]
-
-[[package]]
-name = "rand_chacha"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
-dependencies = [
- "ppv-lite86",
- "rand_core 0.9.5",
-]
-
-[[package]]
-name = "rand_core"
-version = "0.6.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
-
-[[package]]
-name = "rand_core"
-version = "0.9.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
-dependencies = [
- "getrandom 0.3.4",
-]
-
-[[package]]
-name = "ratatui"
-version = "0.30.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc"
-dependencies = [
- "instability",
- "ratatui-core",
- "ratatui-crossterm",
- "ratatui-macros",
- "ratatui-termwiz",
- "ratatui-widgets",
-]
-
-[[package]]
-name = "ratatui-core"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293"
-dependencies = [
- "bitflags 2.11.0",
- "compact_str",
- "hashbrown 0.16.1",
- "indoc",
- "itertools",
- "kasuari",
- "lru",
- "strum",
- "thiserror 2.0.18",
- "unicode-segmentation",
- "unicode-truncate",
- "unicode-width",
-]
-
-[[package]]
-name = "ratatui-crossterm"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3"
-dependencies = [
- "cfg-if",
- "crossterm",
- "instability",
- "ratatui-core",
-]
-
-[[package]]
-name = "ratatui-macros"
-version = "0.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4"
-dependencies = [
- "ratatui-core",
- "ratatui-widgets",
-]
-
-[[package]]
-name = "ratatui-termwiz"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c"
-dependencies = [
- "ratatui-core",
- "termwiz",
-]
-
-[[package]]
-name = "ratatui-widgets"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db"
-dependencies = [
- "bitflags 2.11.0",
- "hashbrown 0.16.1",
- "indoc",
- "instability",
- "itertools",
- "line-clipping",
- "ratatui-core",
- "strum",
- "time",
- "unicode-segmentation",
- "unicode-width",
-]
-
-[[package]]
-name = "redox_syscall"
-version = "0.5.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
-dependencies = [
- "bitflags 2.11.0",
-]
-
-[[package]]
-name = "redox_users"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
-dependencies = [
- "getrandom 0.2.17",
- "libredox",
- "thiserror 2.0.18",
-]
-
-[[package]]
-name = "regex"
-version = "1.12.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
-dependencies = [
- "aho-corasick",
- "memchr",
- "regex-automata",
- "regex-syntax",
-]
-
-[[package]]
-name = "regex-automata"
-version = "0.4.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
-dependencies = [
- "aho-corasick",
- "memchr",
- "regex-syntax",
-]
-
-[[package]]
-name = "regex-syntax"
-version = "0.8.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
-
-[[package]]
-name = "rustc_version"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
-dependencies = [
- "semver",
-]
-
-[[package]]
-name = "rustix"
-version = "1.1.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
-dependencies = [
- "bitflags 2.11.0",
- "errno",
- "libc",
- "linux-raw-sys",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "rustversion"
-version = "1.0.22"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
-
-[[package]]
-name = "ryu"
-version = "1.0.23"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
-
-[[package]]
-name = "scopeguard"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
-
-[[package]]
-name = "semver"
-version = "1.0.27"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
-
-[[package]]
-name = "serde"
-version = "1.0.228"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
-dependencies = [
- "serde_core",
- "serde_derive",
-]
-
-[[package]]
-name = "serde_core"
-version = "1.0.228"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
-dependencies = [
- "serde_derive",
-]
-
-[[package]]
-name = "serde_derive"
-version = "1.0.228"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "serde_json"
-version = "1.0.149"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
-dependencies = [
- "indexmap",
- "itoa",
- "memchr",
- "serde",
- "serde_core",
- "zmij",
-]
-
-[[package]]
-name = "serde_jsonc"
-version = "1.0.108"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a58154381df481a41b7536101c0daccdaf2426f244334074c4c77b89b6253a7"
-dependencies = [
- "itoa",
- "ryu",
- "serde",
-]
-
-[[package]]
-name = "serde_repr"
-version = "0.1.20"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "serde_spanned"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
-dependencies = [
- "serde_core",
-]
-
-[[package]]
-name = "serde_urlencoded"
-version = "0.7.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
-dependencies = [
- "form_urlencoded",
- "itoa",
- "ryu",
- "serde",
-]
-
-[[package]]
-name = "sha2"
-version = "0.10.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
-dependencies = [
- "cfg-if",
- "cpufeatures",
- "digest",
-]
-
-[[package]]
-name = "sharded-slab"
-version = "0.1.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
-dependencies = [
- "lazy_static",
-]
-
-[[package]]
-name = "signal-hook"
-version = "0.3.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
-dependencies = [
- "libc",
- "signal-hook-registry",
-]
-
-[[package]]
-name = "signal-hook-mio"
-version = "0.2.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
-dependencies = [
- "libc",
- "mio",
- "signal-hook",
-]
-
-[[package]]
-name = "signal-hook-registry"
-version = "1.4.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
-dependencies = [
- "errno",
- "libc",
-]
-
-[[package]]
-name = "similar"
-version = "2.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
-
-[[package]]
-name = "siphasher"
-version = "1.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
-
-[[package]]
-name = "slab"
-version = "0.4.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
-
-[[package]]
-name = "smallvec"
-version = "1.15.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
-
-[[package]]
-name = "socket2"
-version = "0.6.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
-dependencies = [
- "libc",
- "windows-sys 0.60.2",
-]
-
-[[package]]
-name = "stable_deref_trait"
-version = "1.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
-
-[[package]]
-name = "static_assertions"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
-
-[[package]]
-name = "strsim"
-version = "0.11.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
-
-[[package]]
-name = "strum"
-version = "0.27.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
-dependencies = [
- "strum_macros",
-]
-
-[[package]]
-name = "strum_macros"
-version = "0.27.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
-dependencies = [
- "heck",
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "syn"
-version = "1.0.109"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
-dependencies = [
- "proc-macro2",
- "quote",
- "unicode-ident",
-]
-
-[[package]]
-name = "syn"
-version = "2.0.117"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
-dependencies = [
- "proc-macro2",
- "quote",
- "unicode-ident",
-]
-
-[[package]]
-name = "synstructure"
-version = "0.13.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "tempfile"
-version = "3.25.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
-dependencies = [
- "fastrand",
- "getrandom 0.4.1",
- "once_cell",
- "rustix",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "terminfo"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662"
-dependencies = [
- "fnv",
- "nom",
- "phf",
- "phf_codegen",
-]
-
-[[package]]
-name = "termios"
-version = "0.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "termwiz"
-version = "0.23.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
-dependencies = [
- "anyhow",
- "base64",
- "bitflags 2.11.0",
- "fancy-regex",
- "filedescriptor",
- "finl_unicode",
- "fixedbitset",
- "hex",
- "lazy_static",
- "libc",
- "log",
- "memmem",
- "nix",
- "num-derive",
- "num-traits",
- "ordered-float",
- "pest",
- "pest_derive",
- "phf",
- "sha2",
- "signal-hook",
- "siphasher",
- "terminfo",
- "termios",
- "thiserror 1.0.69",
- "ucd-trie",
- "unicode-segmentation",
- "vtparse",
- "wezterm-bidi",
- "wezterm-blob-leases",
- "wezterm-color-types",
- "wezterm-dynamic",
- "wezterm-input-types",
- "winapi",
-]
-
-[[package]]
-name = "thiserror"
-version = "1.0.69"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
-dependencies = [
- "thiserror-impl 1.0.69",
-]
-
-[[package]]
-name = "thiserror"
-version = "2.0.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
-dependencies = [
- "thiserror-impl 2.0.18",
-]
-
-[[package]]
-name = "thiserror-impl"
-version = "1.0.69"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "thiserror-impl"
-version = "2.0.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "thread_local"
-version = "1.1.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
-dependencies = [
- "cfg-if",
-]
-
-[[package]]
-name = "time"
-version = "0.3.47"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
-dependencies = [
- "deranged",
- "libc",
- "num-conv",
- "num_threads",
- "powerfmt",
- "serde_core",
- "time-core",
-]
-
-[[package]]
-name = "time-core"
-version = "0.1.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
-
-[[package]]
-name = "tinystr"
-version = "0.8.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
-dependencies = [
- "displaydoc",
- "zerovec",
-]
-
-[[package]]
-name = "tokio"
-version = "1.49.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
-dependencies = [
- "bytes",
- "libc",
- "mio",
- "parking_lot",
- "pin-project-lite",
- "signal-hook-registry",
- "socket2",
- "tokio-macros",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "tokio-macros"
-version = "2.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "tokio-util"
-version = "0.7.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
-dependencies = [
- "bytes",
- "futures-core",
- "futures-sink",
- "pin-project-lite",
- "tokio",
-]
-
-[[package]]
-name = "toml"
-version = "1.0.3+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c7614eaf19ad818347db24addfa201729cf2a9b6fdfd9eb0ab870fcacc606c0c"
-dependencies = [
- "serde_core",
- "serde_spanned",
- "toml_datetime",
- "toml_parser",
- "winnow",
-]
-
-[[package]]
-name = "toml_datetime"
-version = "1.0.0+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
-dependencies = [
- "serde_core",
-]
-
-[[package]]
-name = "toml_parser"
-version = "1.0.9+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
-dependencies = [
- "winnow",
-]
-
-[[package]]
-name = "tower-service"
-version = "0.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
-
-[[package]]
-name = "tracing"
-version = "0.1.44"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
-dependencies = [
- "pin-project-lite",
- "tracing-attributes",
- "tracing-core",
-]
-
-[[package]]
-name = "tracing-attributes"
-version = "0.1.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "tracing-core"
-version = "0.1.36"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
-dependencies = [
- "once_cell",
- "valuable",
-]
-
-[[package]]
-name = "tracing-log"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
-dependencies = [
- "log",
- "once_cell",
- "tracing-core",
-]
-
-[[package]]
-name = "tracing-subscriber"
-version = "0.3.22"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
-dependencies = [
- "nu-ansi-term",
- "sharded-slab",
- "smallvec",
- "thread_local",
- "tracing-core",
- "tracing-log",
-]
-
-[[package]]
-name = "try-lock"
-version = "0.2.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
-
-[[package]]
-name = "typenum"
-version = "1.19.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
-
-[[package]]
-name = "ucd-trie"
-version = "0.1.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
-
-[[package]]
-name = "unicase"
-version = "2.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
-
-[[package]]
-name = "unicode-ident"
-version = "1.0.24"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
-
-[[package]]
-name = "unicode-segmentation"
-version = "1.12.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
-
-[[package]]
-name = "unicode-truncate"
-version = "2.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
-dependencies = [
- "itertools",
- "unicode-segmentation",
- "unicode-width",
-]
-
-[[package]]
-name = "unicode-width"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
-
-[[package]]
-name = "unicode-xid"
-version = "0.2.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
-
-[[package]]
-name = "url"
-version = "2.5.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
-dependencies = [
- "form_urlencoded",
- "idna",
- "percent-encoding",
- "serde",
-]
-
-[[package]]
-name = "utf8_iter"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
-
-[[package]]
-name = "utf8parse"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
-
-[[package]]
-name = "uuid"
-version = "1.21.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
-dependencies = [
- "atomic",
- "getrandom 0.4.1",
- "js-sys",
- "rand 0.9.2",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "valuable"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
-
-[[package]]
-name = "version_check"
-version = "0.9.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
-
-[[package]]
-name = "vtparse"
-version = "0.6.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0"
-dependencies = [
- "utf8parse",
-]
-
-[[package]]
-name = "want"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
-dependencies = [
- "try-lock",
-]
-
-[[package]]
-name = "wasi"
-version = "0.11.1+wasi-snapshot-preview1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
-
-[[package]]
-name = "wasip2"
-version = "1.0.2+wasi-0.2.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
-dependencies = [
- "wit-bindgen",
-]
-
-[[package]]
-name = "wasip3"
-version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
-dependencies = [
- "wit-bindgen",
-]
-
-[[package]]
-name = "wasm-bindgen"
-version = "0.2.111"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec1adf1535672f5b7824f817792b1afd731d7e843d2d04ec8f27e8cb51edd8ac"
-dependencies = [
- "cfg-if",
- "once_cell",
- "rustversion",
- "wasm-bindgen-macro",
- "wasm-bindgen-shared",
-]
-
-[[package]]
-name = "wasm-bindgen-macro"
-version = "0.2.111"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "19e638317c08b21663aed4d2b9a2091450548954695ff4efa75bff5fa546b3b1"
-dependencies = [
- "quote",
- "wasm-bindgen-macro-support",
-]
-
-[[package]]
-name = "wasm-bindgen-macro-support"
-version = "0.2.111"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2c64760850114d03d5f65457e96fc988f11f01d38fbaa51b254e4ab5809102af"
-dependencies = [
- "bumpalo",
- "proc-macro2",
- "quote",
- "syn 2.0.117",
- "wasm-bindgen-shared",
-]
-
-[[package]]
-name = "wasm-bindgen-shared"
-version = "0.2.111"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "60eecd4fe26177cfa3339eb00b4a36445889ba3ad37080c2429879718e20ca41"
-dependencies = [
- "unicode-ident",
-]
-
-[[package]]
-name = "wasm-encoder"
-version = "0.244.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
-dependencies = [
- "leb128fmt",
- "wasmparser",
-]
-
-[[package]]
-name = "wasm-metadata"
-version = "0.244.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
-dependencies = [
- "anyhow",
- "indexmap",
- "wasm-encoder",
- "wasmparser",
-]
-
-[[package]]
-name = "wasmparser"
-version = "0.244.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
-dependencies = [
- "bitflags 2.11.0",
- "hashbrown 0.15.5",
- "indexmap",
- "semver",
-]
-
-[[package]]
-name = "wezterm-bidi"
-version = "0.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec"
-dependencies = [
- "log",
- "wezterm-dynamic",
-]
-
-[[package]]
-name = "wezterm-blob-leases"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7"
-dependencies = [
- "getrandom 0.3.4",
- "mac_address",
- "sha2",
- "thiserror 1.0.69",
- "uuid",
-]
-
-[[package]]
-name = "wezterm-color-types"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296"
-dependencies = [
- "csscolorparser",
- "deltae",
- "lazy_static",
- "wezterm-dynamic",
-]
-
-[[package]]
-name = "wezterm-dynamic"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac"
-dependencies = [
- "log",
- "ordered-float",
- "strsim",
- "thiserror 1.0.69",
- "wezterm-dynamic-derive",
-]
-
-[[package]]
-name = "wezterm-dynamic-derive"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 1.0.109",
-]
-
-[[package]]
-name = "wezterm-input-types"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e"
-dependencies = [
- "bitflags 1.3.2",
- "euclid",
- "lazy_static",
- "serde",
- "wezterm-dynamic",
-]
-
-[[package]]
-name = "winapi"
-version = "0.3.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
-dependencies = [
- "winapi-i686-pc-windows-gnu",
- "winapi-x86_64-pc-windows-gnu",
-]
-
-[[package]]
-name = "winapi-i686-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
-
-[[package]]
-name = "winapi-x86_64-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
-
-[[package]]
-name = "windows-link"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
-
-[[package]]
-name = "windows-sys"
-version = "0.59.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
-dependencies = [
- "windows-targets 0.52.6",
-]
-
-[[package]]
-name = "windows-sys"
-version = "0.60.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
-dependencies = [
- "windows-targets 0.53.5",
-]
-
-[[package]]
-name = "windows-sys"
-version = "0.61.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
-dependencies = [
- "windows-link",
-]
-
-[[package]]
-name = "windows-targets"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
-dependencies = [
- "windows_aarch64_gnullvm 0.52.6",
- "windows_aarch64_msvc 0.52.6",
- "windows_i686_gnu 0.52.6",
- "windows_i686_gnullvm 0.52.6",
- "windows_i686_msvc 0.52.6",
- "windows_x86_64_gnu 0.52.6",
- "windows_x86_64_gnullvm 0.52.6",
- "windows_x86_64_msvc 0.52.6",
-]
-
-[[package]]
-name = "windows-targets"
-version = "0.53.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
-dependencies = [
- "windows-link",
- "windows_aarch64_gnullvm 0.53.1",
- "windows_aarch64_msvc 0.53.1",
- "windows_i686_gnu 0.53.1",
- "windows_i686_gnullvm 0.53.1",
- "windows_i686_msvc 0.53.1",
- "windows_x86_64_gnu 0.53.1",
- "windows_x86_64_gnullvm 0.53.1",
- "windows_x86_64_msvc 0.53.1",
-]
-
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
-
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
-
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
-
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
-
-[[package]]
-name = "windows_i686_gnu"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
-
-[[package]]
-name = "windows_i686_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
-
-[[package]]
-name = "windows_i686_gnullvm"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
-
-[[package]]
-name = "windows_i686_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
-
-[[package]]
-name = "windows_i686_msvc"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
-
-[[package]]
-name = "windows_i686_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
-
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
-
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
-
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
-
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
-
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
-
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
-
-[[package]]
-name = "winnow"
-version = "0.7.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
-
-[[package]]
-name = "wit-bindgen"
-version = "0.51.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
-dependencies = [
- "wit-bindgen-rust-macro",
-]
-
-[[package]]
-name = "wit-bindgen-core"
-version = "0.51.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
-dependencies = [
- "anyhow",
- "heck",
- "wit-parser",
-]
-
-[[package]]
-name = "wit-bindgen-rust"
-version = "0.51.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
-dependencies = [
- "anyhow",
- "heck",
- "indexmap",
- "prettyplease",
- "syn 2.0.117",
- "wasm-metadata",
- "wit-bindgen-core",
- "wit-component",
-]
-
-[[package]]
-name = "wit-bindgen-rust-macro"
-version = "0.51.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
-dependencies = [
- "anyhow",
- "prettyplease",
- "proc-macro2",
- "quote",
- "syn 2.0.117",
- "wit-bindgen-core",
- "wit-bindgen-rust",
-]
-
-[[package]]
-name = "wit-component"
-version = "0.244.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
-dependencies = [
- "anyhow",
- "bitflags 2.11.0",
- "indexmap",
- "log",
- "serde",
- "serde_derive",
- "serde_json",
- "wasm-encoder",
- "wasm-metadata",
- "wasmparser",
- "wit-parser",
-]
-
-[[package]]
-name = "wit-parser"
-version = "0.244.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
-dependencies = [
- "anyhow",
- "id-arena",
- "indexmap",
- "log",
- "semver",
- "serde",
- "serde_derive",
- "serde_json",
- "unicode-xid",
- "wasmparser",
-]
-
-[[package]]
-name = "writeable"
-version = "0.6.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
-
-[[package]]
-name = "yoke"
-version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
-dependencies = [
- "stable_deref_trait",
- "yoke-derive",
- "zerofrom",
-]
-
-[[package]]
-name = "yoke-derive"
-version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
- "synstructure",
-]
-
-[[package]]
-name = "zerocopy"
-version = "0.8.39"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
-dependencies = [
- "zerocopy-derive",
-]
-
-[[package]]
-name = "zerocopy-derive"
-version = "0.8.39"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "zerofrom"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
-dependencies = [
- "zerofrom-derive",
-]
-
-[[package]]
-name = "zerofrom-derive"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
- "synstructure",
-]
-
-[[package]]
-name = "zerotrie"
-version = "0.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
-dependencies = [
- "displaydoc",
- "yoke",
- "zerofrom",
-]
-
-[[package]]
-name = "zerovec"
-version = "0.11.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
-dependencies = [
- "yoke",
- "zerofrom",
- "zerovec-derive",
-]
-
-[[package]]
-name = "zerovec-derive"
-version = "0.11.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "zmij"
-version = "1.0.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/Cargo.toml b/Cargo.toml
deleted file mode 100644
index 1125a77..0000000
--- a/Cargo.toml
+++ /dev/null
@@ -1,59 +0,0 @@
-[package]
-name = "oxker"
-version = "0.13.0"
-edition = "2024"
-authors = ["Jack Wills "]
-description = "A simple tui to view & control docker containers"
-repository = "https://github.com/mrjackwills/oxker"
-homepage = "https://github.com/mrjackwills/oxker"
-license = "MIT"
-rust-version = "1.90.0"
-readme = "README.md"
-keywords = ["docker", "tui", "tokio", "terminal", "podman"]
-categories = ["command-line-utilities"]
-
-[lints.rust]
-unsafe_code = "forbid"
-
-[lints.clippy]
-expect_used = "warn"
-todo = "warn"
-unused_async = "warn"
-unwrap_used = "warn"
-module_name_repetitions = "allow"
-doc_markdown = "allow"
-similar_names = "allow"
-
-[dependencies]
-anyhow = "1.0"
-bollard = "0.20"
-cansi = "2.2"
-clap = { version = "4.5", features = ["color", "derive", "unicode"] }
-crossterm = "0.29"
-directories = "6.0"
-futures-util = "0.3"
-jiff = { version = "0.2", features = ["tzdb-bundle-always"] }
-parking_lot = { version = "0.12" }
-ratatui = "0.30"
-serde = { version = "1.0", features = ["derive"] }
-serde_json = { version = "1.0"}
-serde_jsonc = "1.0"
-tokio = { version = "1.49", features = ["full"] }
-tokio-util = "0.7"
-toml = { version = "1.0", default-features = false, features = ["parse", "serde"] }
-tracing = "0.1"
-tracing-subscriber = "0.3"
-uuid = { version = "1.21", features = ["fast-rng", "v4"] }
-
-[dev-dependencies]
-insta = "1.46"
-serde_json = { version = "1.0", features = ["preserve_order"]}
-
-[profile.release]
-lto = true
-codegen-units = 1
-panic = 'abort'
-strip = true
-debug = false
-
-
diff --git a/README.md b/README.md
index 394121f..9d4b53c 100644
--- a/README.md
+++ b/README.md
@@ -1,238 +1,47 @@
-
-
-
-
oxker
- A simple tui to view & control docker containers
-
+# Oxker - Go Version
-
- Built in Rust, making heavy use of ratatui & Bollard
-
+A simple TUI to view & control Docker containers, built with Bubbletea.
-
-
-
-
-
-
-
-
- link to alternative screenshot
-
-
-
+## Features
-- [Download & install](#download--install)
-- [Run](#run)
-- [Build step](#build-step)
-- [Tests](#tests)
+- View Docker containers in a table format
+- Filter containers
+- Start/Stop containers
+- Container details
-## Download & install
+## Requirements
-### Cargo
-Published on crates.io, so if you have cargo installed, simply run
+- Go 1.23+
+- Docker installed and running
+## Installation
-```shell
-cargo install oxker
+```bash
+go install github.com/oxker/oxker@latest
```
-### Docker
+## Usage
-Published on ghcr.io and Docker Hub,
-with images built for `linux/amd64`, `linux/arm64`, and `linux/arm/v6`
-
-
-**via ghcr.io**
-
-```shell
-docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock:ro --pull=always ghcr.io/mrjackwills/oxker
-```
-
-**via Docker Hub**
-```shell
-docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock:ro --pull=always mrjackwills/oxker
-```
-
-### Nix
-Using nix flakes, oxker can be ran directly with
-
-```shell
-nix run nixpkgs#oxker
-```
-
-Without flakes, you can build a shell that contains oxker using
-
-```shell
-nix-shell -p oxker
-```
-
-### AUR
-
-oxker can be installed from the [AUR](https://aur.archlinux.org/packages/oxker) with using an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers):
-
-```shell
-paru -S oxker
-```
-
-### Homebrew
-
-oxker can be installed on macOS using [Homebrew](https://formulae.brew.sh/formula/oxker):
-
-```shell
-brew install oxker
-```
-
-### Pre-Built
-See the pre-built binaries
-
-or, download & install (x86_64 one liner)
-
-```shell
-wget https://www.github.com/mrjackwills/oxker/releases/latest/download/oxker_linux_x86_64.tar.gz &&
-tar xzvf oxker_linux_x86_64.tar.gz oxker &&
-install -Dm 755 oxker -t "${HOME}/.local/bin" &&
-rm oxker_linux_x86_64.tar.gz oxker
-```
-
-or, for automatic platform selection, download, and installation (to `$HOME/.local/bin`)
-
-*One should always verify script content before running in a shell*
-
-```shell
-curl https://raw.githubusercontent.com/mrjackwills/oxker/main/install.sh | bash
-```
-
-## Run
-
-```shell
+```bash
oxker
```
-In application controls, these, amongst many other settings, can be customized with the [config file](#Config-File)
-| button| result|
-|--|--|
-| ```( tab )``` or ```( shift+tab )``` | Change panel, clicking on a panel also changes the selected panel.|
-| ```( ↑ ↓ )``` or ```( j k )``` or ```( Home End )```| Scroll line in selected panel - mouse wheel will also scroll.|
-| ```( ← → )``` | Scroll horizontally across text.|
-| ```( ctrl )``` | Increase scroll speed, used in conjunction with scroll keys.|
-| ```( enter )```| Run selected docker command.|
-| ```( 1-9 )``` | Sort containers by heading, clicking on headings also sorts the selected column. |
-| ```( 0 )``` | Stop sorting.|
-| ```( F1 )``` or ```( / )``` | Enter filter mode. |
-| ```( # )``` | Enter log search mode. |
-| ```( - ) ``` or ```(=)``` | Reduce or increase the height of the logs panel.|
-| ```( \ )``` | Toggle the visibility of the logs panel.|
-| ```( e )``` | Exec into the selected container - not available on Windows.|
-| ```( i )``` | Enter container inspect mode. |
-| ```( f )``` | Force clear the screen & redraw the gui.|
-| ```( h )``` | Toggle help menu.|
-| ```( m )``` | Toggle mouse capture - if disabled, text on screen can be selected.|
-| ```( q )``` | Quit.|
-| ```( s )``` | Save logs to `$HOME/[container_name]_[timestamp].log`, or the directory set by `--save-dir`.|
-| ```( esc )``` | Close dialog.|
-Available command line arguments
+## Controls
-| argument|result|
-|--|--|
-|```-d [number > 0]```| Set the minimum update interval for docker information in milliseconds. Defaults to 1000 (1 second).|
-|```-r```| Show raw logs. By default, removes ANSI formatting (conflicts with `-c`).|
-|```-c```| Attempt to color the logs (conflicts with `-r`).|
-|```-t```| Remove timestamps from each log entry.|
-|```-s```| If running via Docker, will display the oxker container.|
-|```-g```| No TUI, essentially a debugging mode with limited functionality, for now.|
-|```--config-file [string]```| Location of a `config.toml`/`config.json`/`config.jsonc`. By default will check the users local config directory.|
-|```--host [string]```| Connect to Docker with a custom hostname. Defaults to `/var/run/docker.sock`. Will use `$DOCKER_HOST` environment variable if set.|
-|```--no-stderr```| Do not include stderr output in logs.|
-|```--save-dir [string]```| Save exported logs into a custom directory. Defaults to `$HOME`.|
-|```--timezone [string]```| Display the Docker logs timestamps in a given [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). Defaults to `Etc/UTC`.|
-|```--use-cli```| Use the Docker application when exec-ing into a container, instead of the Docker API.|
+| Key | Action |
+|-----|--------|
+| q | Quit |
+| r | Refresh containers |
+| j/k | Navigate up/down |
+| enter | View container details |
+| ctrl+c | Quit |
-### Config File
+## Building
-
-A config file enables the user to persist settings, create a custom keymap, set the color scheme used by the application, and more.
-
-
-Examples of the config file, alsong with explanations of each value, can be found in the [example_config](https://github.com/mrjackwills/oxker/tree/main/example_config) directory. `oxker` supports `.toml`,`.json`, and `.jsonc` file formats.
-
-
-If no config file is found, a `config.toml` file will be created in an `oxker` directory in the user's local config directory, as found by the [directories crate](https://docs.rs/directories/6.0.0/directories/struct.BaseDirs.html#method.config_local_dir).
-
-
-Command line arguments will take priority over values from the config file.
-
-
-If running an `oxker` container, the default config location will be `/` rather than the automatically detected platform-specific local config directory, and can be mounted as follows;
-
-```shell
-docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock:ro -v /some_location/config.toml:/config.toml:ro ghcr.io/mrjackwills/oxker
+```bash
+go build -o oxker
```
-## Build step
+## License
-### x86_64
-
-```shell
-cargo build --release
-```
-
-### Raspberry pi
-
-requires docker & cross-rs
-
-#### 64bit pi (pi 4, pi zero w 2)
-
-```shell
-cross build --target aarch64-unknown-linux-gnu --release
-```
-
-#### 32bit pi (pi zero w)
-
-Tested, and fully working on pi zero w, running Raspberry Pi OS 32 bit, the initial logs parsing can take an extended period of time if thousands of lines long, suggest running with a -d argument of 5000
-
-```shell
-cross build --target arm-unknown-linux-musleabihf --release
-```
-
-If no memory information available, try appending either ```/boot/cmdline.txt``` or ```/boot/firmware/cmdline.txt``` with
-
-```cgroup_enable=cpuset cgroup_enable=memory```
-
-see https://forums.raspberrypi.com/viewtopic.php?t=203128 and https://github.com/docker/for-linux/issues/1112
-
-
-### Untested on other platforms
-
-
-## Tests
-
-~~As of yet untested, needs work~~
-
-The work has been done, so far the tests don't effect any running containers, but this may change in the future.
-
-```shell
-cargo test
-```
-
-Run some example docker images
-
-using docker/docker-compose.yml;
-
-```shell
-docker compose -f ./docker/docker-compose.yml up -d
-```
-
-or individually
-
-```shell
-docker run --name redis -d redis:alpine3.21
-```
-
-```shell
-docker run --name postgres -e POSTGRES_PASSWORD=never_use_this_password_in_production -d postgres:alpine3.21
-```
-
-```shell
-docker run -d --hostname my-rabbit --name rabbitmq rabbitmq:3
-```
+MIT
\ No newline at end of file
diff --git a/REVIEW_FINDINGS.md b/REVIEW_FINDINGS.md
new file mode 100644
index 0000000..63de67a
--- /dev/null
+++ b/REVIEW_FINDINGS.md
@@ -0,0 +1,454 @@
+# Oxker Go Rewrite — Review Findings
+
+Systematischer Vergleich aller Original-Rust-Dateien mit der Go-Reimplementierung.
+6 parallele Review-Agenten, ~130 Findings gesamt.
+
+---
+
+## CRITICAL (App funktioniert falsch)
+
+### C1. CPU immer 0 — ContainerStatsOneShot liefert leere precpu_stats
+- **Source:** Docker Integration Review #11
+- **Rust:** `docker_data/mod.rs:136-143` — `stream: false, one_shot: false` + `.take(1)`
+- **Go:** `docker/client.go:70` — `ContainerStatsOneShot` setzt `one-shot=true`
+- **Problem:** Mit `one-shot=true` liefert Docker leere `precpu_stats`, CPU-Berechnung ergibt 0
+- **Fix:** `ContainerStats` mit `stream=false` statt `ContainerStatsOneShot` verwenden
+- **Status:** [x] FIXED
+
+### C2. uint64 Underflow bei CPU-Berechnung
+- **Source:** Docker Integration Review #6/#7
+- **Rust:** `docker_data/mod.rs:86-91` — `saturating_sub` verhindert Underflow
+- **Go:** `app.go:1120-1121` — plain uint64 Subtraktion, kann wrappen
+- **Fix:** Guard: `if TotalUsage < PreTotalUsage { return 0 }`
+- **Status:** [x] FIXED
+
+### C3. --host Flag wird ignoriert
+- **Source:** Docker Integration Review #1
+- **Rust:** `main.rs:47-57` — Config Host > DOCKER_HOST env > default socket
+- **Go:** `docker/client.go:17-22` — nur `FromEnv`, ignoriert `Config.Host`
+- **Fix:** `docker.New()` soll optionalen Host akzeptieren, `client.WithHost(host)` verwenden
+- **Status:** [x] FIXED
+
+### C4. Logs ShowStderr Config ignoriert
+- **Source:** Docker Integration Review #8
+- **Rust:** `docker_data/mod.rs:256-258` — `config.show_std_err`
+- **Go:** `app.go:1089-1091` — hardcoded `ShowStderr: true`
+- **Fix:** `ShowStderr: a.Config.ShowStdErr`
+- **Status:** [x] FIXED
+
+### C5. Bandwidth-Chart zeigt kumulative Bytes statt Rate
+- **Source:** Data Model Review #8
+- **Rust:** `container_state.rs:627-672` — `NetworkBandwidth.to_vec_f64` berechnet Deltas
+- **Go:** `app.go:1493-1508` — `fmtRate(c.RxBytes)` auf kumulative Rohwerte
+- **Fix:** Bandwidth als Differenz zwischen aufeinanderfolgenden Einträgen berechnen
+- **Status:** [x] FIXED
+
+### C6. RunningUnhealthy — fehlt pause/restart in Commands
+- **Source:** Data Model Review #2, Main UI Review #6
+- **Rust:** `container_state.rs:451` — `Running(_) => [Pause, Restart, Stop, Delete]`
+- **Go:** `app.go:1749-1750` — `RunningUnhealthy` nur `["stop", "delete"]`
+- **Fix:** Gleiche Commands wie RunningHealthy
+- **Status:** [x] FIXED
+
+### C7. Enter führt immer ersten Command aus — kein Command-Selection
+- **Source:** Input Review #6, Data Model Review #4, Main UI Review #8
+- **Rust:** `input_handler/mod.rs:267` — prüft `panel == Commands`, nutzt `selected_docker_controls()`
+- **Go:** `app.go:922` — `cmds[0]` immer, unabhängig von Panel
+- **Fix:** `CmdSelectedIdx` Feld, nur bei `ActivePanel == PanelCommands` ausführen
+- **Status:** [x] FIXED
+
+### C8. Ctrl+C quit nicht in Filter/Search-Modus
+- **Source:** Input Review #9
+- **Rust:** `input_handler/mod.rs:762-763` — Ctrl+C immer quit
+- **Go:** `app.go:360-367` — Filter/Search early return, Ctrl+C wird nie erreicht
+- **Fix:** Ctrl+C Check vor Mode-Dispatch
+- **Status:** [x] FIXED
+
+### C9. Mouse-Events nicht blockiert in Overlay-Modi
+- **Source:** Input Review #10
+- **Rust:** `input_handler/mod.rs:74-84` — blockiert Mouse in Error/Help/Filter/Search/DeleteConfirm
+- **Go:** `app.go:638` — keine Mode-Checks, Mouse immer aktiv
+- **Fix:** Mode-Checks am Anfang von `handleMouse`
+- **Status:** [x] FIXED
+
+### C10. Info-Popup Auto-Dismiss fehlt (4 Sekunden)
+- **Source:** Popups Review #9
+- **Rust:** `info.rs:52-54` — `instant.elapsed() > 4000ms`
+- **Go:** `app.go:1003-1006` — Kommentar sagt "handled by tick", aber kein Timer
+- **Fix:** `tea.Tick(4*time.Second)` → `InfoDismissMsg`
+- **Status:** [x] FIXED
+
+---
+
+## HIGH (UI sieht falsch aus)
+
+### H1. CPU-Format ohne Zero-Padding
+- **Source:** Main UI Review #2
+- **Rust:** `container_state.rs:521` — `{:05.2}%` → `00.00%`, `05.34%`
+- **Go:** `app.go:1325` — `%.2f%%` → `0.00%`, `5.34%`
+- **Fix:** `fmt.Sprintf("%05.2f%%", c.CPUPercent)`
+- **Status:** [x] FIXED
+
+### H2. Container-Spalten ohne Farben (Name/ID/Image/RX/TX)
+- **Source:** Main UI Review #10/#11
+- **Rust:** `containers.rs:26-33` — Name/ID/Image mit `containers.text` (Blue), RX/TX eigene Farben
+- **Go:** `app.go:1321-1332` — kein Styling
+- **Fix:** lipgloss.Foreground für Name/ID/Image und separate Farben für RX/TX
+- **Status:** [x] FIXED
+
+### H3. Command-Farben falsch
+- **Source:** Main UI Review #5
+- **Rust:** restart=Magenta, delete=Gray, resume=Blue, start=Green, stop=Red, pause=Yellow
+- **Go:** restart/resume/start=Green, stop/delete=Red, pause=Yellow
+- **Fix:** Jeder Command eigene Farbe
+- **Status:** [x] FIXED
+
+### H4. Layout-Proportionen falsch
+- **Source:** Main UI Review #17
+- **Rust:** `ui/mod.rs:400` — 75%/25% (oben/unten)
+- **Go:** `app.go:1664-1686` — 30% Container, 22% Charts, Rest Logs
+- **Fix:** 75%/25% Split, dann Container/Logs innerhalb der 75%
+- **Status:** [x] FIXED
+
+### H5. Ports: `::` und `0` statt leer
+- **Source:** Charts/Ports Review #15/#16
+- **Rust:** `container_state.rs:156-160` — None → leerer String
+- **Go:** `app.go:1522-1523` — `::` für leere IP, `0` für fehlenden Public Port
+- **Fix:** Leere Strings statt `::` und `0`
+- **Status:** [x] FIXED
+
+### H6. Falsches Highlight-Symbol
+- **Source:** Main UI Review #12
+- **Rust:** `mod.rs:47` — `⚪ ` (offener Kreis)
+- **Go:** `app.go:1315` — `●` (gefüllter Kreis, blau)
+- **Fix:** `⚪ ` verwenden
+- **Status:** [x] FIXED
+
+### H7. Default-Sort fehlt (nach CreatedAt)
+- **Source:** Data Model Review #13
+- **Rust:** `mod.rs:492-499` — sort by `created` + Name als Tiebreaker
+- **Go:** `app.go:829-831` — kein Sort bei `SortNone`
+- **Fix:** Bei `SortNone` nach `CreatedAt` sortieren, Name als Tiebreaker
+- **Status:** [x] FIXED
+
+### H8. Sort verliert Selection
+- **Source:** Data Model Review #6
+- **Rust:** `mod.rs:377-387` — re-selects Container by ID nach Sort
+- **Go:** `app.go:812-827` — SelectedIdx bleibt gleich, Container an Position ändert sich
+- **Fix:** Nach Sort alten Container per ID finden und SelectedIdx aktualisieren
+- **Status:** [x] FIXED
+
+### H9. Sort Tiebreaker by Name fehlt
+- **Source:** Data Model Review #15
+- **Rust:** `mod.rs:437-484` — `.then_with(|| name.cmp(name))`
+- **Go:** `app.go:834-866` — kein Tiebreaker
+- **Fix:** Name-Vergleich als Tiebreaker bei Gleichheit
+- **Status:** [x] FIXED
+
+### H10. State-Reihenfolge beim Sort falsch
+- **Source:** Data Model Review #14
+- **Rust:** `container_state.rs:316-327` — Healthy=0, Unhealthy=1, Paused=2, Restarting=3, etc.
+- **Go:** iota-Reihenfolge weicht ab (Exited vor Restarting)
+- **Fix:** `order()` Methode mit Rust-Mapping
+- **Status:** [x] FIXED
+
+### H11. Search-Highlight: ganze Zeile statt Substring
+- **Source:** Charts/Ports Review #22
+- **Rust:** Substring-Level Highlighting
+- **Go:** `app.go:1422-1423` — ganze Zeile gelb
+- **Fix:** Nur gematchten Substring highlighten
+- **Status:** [x] FIXED
+
+### H12. Help wird als Vollbild gerendert statt Overlay
+- **Source:** Popups Review #11
+- **Rust:** `mod.rs:477-480` — Overlay auf Base View
+- **Go:** `app.go:1175-1177` — ersetzt komplett den View
+- **Fix:** Base View rendern, dann Help darüber legen
+- **Status:** [x] FIXED
+
+### H13. Log-Zeilen: Byte-Length statt Rune-Length für UTF-8
+- **Source:** Main UI Review #28
+- **Rust:** `.chars().count()` für Width
+- **Go:** `app.go:1409-1414` — `len(line)` (Bytes)
+- **Fix:** `[]rune(line)` oder `runewidth` verwenden
+- **Status:** [x] FIXED
+
+### H14. Bandwidth-Chart: Keine historischen Daten gerendert
+- **Source:** Charts/Ports Review #6
+- **Rust:** `chart_bandwidth.rs:92-109` — zwei Line-Datasets (RX/TX)
+- **Go:** `app.go:1493-1508` — nur Text-Labels, kein Sparkline
+- **Fix:** Sparklines für RX/TX History hinzufügen
+- **Status:** [x] FIXED
+
+### H15. LogHeight Default: 40 statt 75
+- **Source:** Popups Review #23
+- **Rust:** `gui_state.rs:220` — default `75`
+- **Go:** `app.go:213` — default `40`
+- **Fix:** Default auf `75` ändern
+- **Status:** [x] FIXED
+
+---
+
+## MEDIUM (Verhaltensunterschiede)
+
+### M1. Binate Stats-Sammlung fehlt (keine Deduplizierung)
+- **Source:** Docker Integration Review #2
+- **Rust:** `docker_data/mod.rs:44-61` — Binate-Enum, Spawn-Deduplizierung
+- **Go:** `app.go:1043-1081` — keine Tracking, Duplikate möglich
+- **Fix:** `pendingStats map[string]bool` für In-Flight Tracking
+- **Status:** [x] FIXED
+
+### M2. Logs: Tail-500 statt inkrementell (since-Timestamp)
+- **Source:** Docker Integration Review #9
+- **Rust:** `docker_data/mod.rs:256-273` — `since` Parameter, akkumuliert
+- **Go:** `app.go:1091` — `Tail: "500"`, ersetzt jedes Mal
+- **Status:** [x] FIXED — uses `since` for incremental fetching after initial load
+
+### M3. Per-Container Log-Speicher fehlt
+- **Source:** Data Model Review #3
+- **Rust:** `ContainerItem.logs` — HashSet mit Dedup, per-Container Scroll
+- **Go:** `App.LogLines` — einzelner globaler Slice
+- **Status:** [x] FIXED — Container.Logs/LogScroll/LogHScroll with save/restore on selection change
+
+### M4. Container-Selbstfilterung (oxker-Container verstecken) fehlt
+- **Source:** Docker Integration Review #12, Data Model Review #9
+- **Rust:** `docker_data/mod.rs:229-234` — ENTRY_POINT Filter
+- **Go:** keine Selbstfilterung
+- **Status:** [x] FIXED — respects Config.ShowSelf
+
+### M5. Docker Ping-Check bei Startup fehlt
+- **Source:** Docker Integration Review #15
+- **Rust:** `main.rs:68-88` — Ping + Error mit Host-Pfad
+- **Go:** `app.go:207` — `docker.New()` Error wird ignoriert
+- **Status:** [x] FIXED — Ping + Host in error message
+
+### M6. Filter-Mode: `/` beendet nicht den Modus (wird als Text eingegeben)
+- **Source:** Input Review #14
+- **Rust:** `input_handler:523-528` — `/` beendet Filter-Mode
+- **Go:** `app.go:570-590` — `/` wird als Zeichen eingefügt
+- **Status:** [x] FIXED (already implemented — `/` in enter case)
+
+### M7. Search-Mode: `#` beendet nicht den Modus
+- **Source:** Input Review #15
+- **Rust:** `input_handler:409-414` — `#` beendet Search-Mode
+- **Go:** `app.go:598-631` — `#` wird als Zeichen eingefügt
+- **Status:** [x] FIXED (already implemented — `#` in enter case)
+
+### M8. Direkte Action-Keys (s/x/r/d/p/u) existieren in Rust nicht
+- **Source:** Input Review #17
+- **Rust:** Actions nur über Commands-Panel mit Enter
+- **Go:** `app.go:478-489` — direkte Shortcuts
+- **Note:** Go-Erweiterung, könnte gewollt sein, aber kollidiert mit Rust-Keybindings
+- **Status:** [x] WONTFIX — bewusste Erweiterung, Shortcuts sind nützlich
+
+### M9. Mouse-Scroll-Amount: 3 statt 1 (mit 10x Modifier)
+- **Source:** Input Review #19
+- **Rust:** `input_handler:828-829` — Scroll by 1 oder 10 mit Ctrl
+- **Go:** `app.go:639-644` — hardcoded 3
+- **Status:** [x] FIXED (already 1)
+
+### M10. Mouse-Scroll in Inspect-Mode nicht unterstützt
+- **Source:** Input Review #20
+- **Rust:** `input_handler:810-818` — Mouse-Scroll im Inspect
+- **Go:** `handleMouse` hat keinen Inspect-Check
+- **Status:** [x] FIXED (via C9)
+
+### M11. Error-Mode: andere Keys nicht blockiert
+- **Source:** Input Review #24
+- **Rust:** `handle_error` — nur clear/quit Keys
+- **Go:** Sort-Keys, Scroll-Keys etc. funktionieren während Error
+- **Status:** [x] FIXED (via C8)
+
+### M12. Unknown-State: falsche Commands
+- **Source:** Main UI Review #7
+- **Rust:** `container_state.rs:452` — `Unknown => [Delete]` nur
+- **Go:** `app.go:1753-1754` — `Unknown => [start, restart, delete]`
+- **Status:** [x] FIXED (via C6)
+
+### M13. Commands-Panel Scroll fehlt
+- **Source:** Input Review #5
+- **Rust:** `docker_controls_scroll` bewegt Command-Selection
+- **Go:** Scroll im Commands-Panel ändert Container-Selection
+- **Status:** [x] FIXED (via C7)
+
+### M14. Commands-Panel Breite: fest 14 statt 10%
+- **Source:** Main UI Review #4
+- **Rust:** `ui/mod.rs:419` — `Percentage(10)`
+- **Go:** `app.go:1278` — `cmdW := 14`
+- **Status:** [x] FIXED — 10% mit min 12
+
+### M15. DockerConnect Error: kein Countdown, kein Auto-Exit
+- **Source:** Popups Review #6/#17
+- **Rust:** 5-Sekunden Countdown, Auto-Exit, Host-Anzeige
+- **Go:** Standard-Error-Popup
+- **Status:** [x] FIXED — 5s countdown + auto-exit + host in error message
+
+### M16. Delete-Dialog: Name nicht bold/highlighted
+- **Source:** Popups Review #1
+- **Rust:** `delete_confirm.rs:44-53` — Name bold + highlighted
+- **Go:** `app.go:1542` — einfacher String
+- **Status:** [x] FIXED
+
+### M17. Help-View: stark reduzierter Inhalt
+- **Source:** Popups Review #12
+- **Rust:** ~570 Zeilen, Logo, Version, Config-Pfad, Timezone, alle Keys
+- **Go:** ~20 Zeilen, nur Basis-Keys
+- **Status:** [x] FIXED — erweitert mit Config-Pfad, Panel-Info, vollständiger Keybinding-Liste
+
+### M18. Container-Name: führender `/` wird nicht entfernt
+- **Source:** Charts/Ports Review #30
+- **Rust:** `inspect.rs:35-38` — Strip `/` Prefix
+- **Go:** Name direkt verwendet
+- **Status:** [x] FIXED (already done via TrimPrefix)
+
+### M19. Inspect: Wrapping statt Truncation
+- **Source:** Charts/Ports Review #29
+- **Rust:** `inspect.rs:133` — `Wrap { trim: false }`
+- **Go:** `app.go:1603` — Truncation
+- **Status:** [x] FIXED
+
+### M20. Spaltenbreiten fest statt dynamisch
+- **Source:** Main UI Review #1, Data Model Review #17
+- **Rust:** `mod.rs:845-879` — max Width pro Spalte aus Daten
+- **Go:** `app.go:1264-1273` — feste Prozent-Widths
+- **Status:** [x] FIXED — dynamic widths based on container data
+
+### M21. State-Icons: andere Unicode-Zeichen
+- **Source:** Main UI Review #24
+- **Rust:** `✓`, `✖`, `॥`, `↻`, `!`, `?`
+- **Go:** `✔`, `✖`, `‖`, `↻`, `!`, `?`
+- **Status:** [x] FIXED — ✓ statt ✔
+
+### M22. Search-Navigation: Ctrl+N/P statt Down/Up
+- **Source:** Input Review #16
+- **Rust:** Down/Up Arrow für nächsten/vorherigen Match
+- **Go:** Ctrl+N/Ctrl+P
+- **Status:** [x] FIXED (already supports both Down/Up and Ctrl+N/P)
+
+### M23. Log-Search Case-Sensitivity nicht konfigurierbar
+- **Source:** Data Model Review #19
+- **Rust:** `config.log_search_case_sensitive`
+- **Go:** immer case-insensitive
+- **Status:** [x] FIXED — respects Config.LogSearchCaseSensitive
+
+### M24. Filter wraps around (Go), Rust stoppt an Grenzen
+- **Source:** Data Model Review #16
+- **Rust:** `FilterBy::next()/prev()` gibt None an Grenzen
+- **Go:** Modulo-Wrapping
+- **Status:** [x] FIXED — stoppt an Grenzen
+
+### M25. Overlay-Popups: `base` Parameter wird ignoriert
+- **Source:** Popups Review #16
+- **Go:** `overlayError(base)`, `overlayInfo(base)` — base wird nie verwendet
+- **Status:** [x] FIXED — overlay placement with transparent bg
+
+---
+
+## LOW (Kosmetische/Konfigurationsunterschiede)
+
+### L1. Alle Farben hardcoded statt aus Config
+- Betrifft: alle Panels, Popups, Charts, Headers
+- Config `AppColors` existiert, wird aber nie gelesen
+- **Status:** [x] FIXED — colorOr() resolver, borders/commands/state colors from config
+
+### L2. Custom Keymaps nicht implementiert
+- Config `Keymap` existiert, wird aber nie verwendet
+- Alle Keys sind hardcoded Strings
+- **Status:** [x] FIXED — keyMatch() checks config keymap with hardcoded fallbacks
+
+### L3. Mouse-Capture Toggle (`m` Key) fehlt
+- **Source:** Input Review #1
+- **Status:** [x] FIXED
+
+### L4. Force-Redraw Key (`f`) fehlt
+- **Source:** Input Review #2
+- **Status:** [x] FIXED — tea.ClearScreen
+
+### L5. Container-Name/Image Truncation bei 30 Zeichen
+- **Source:** Data Model Review #18
+- **Status:** [x] FIXED (handled by dynamic column widths M20)
+
+### L6. CpuStats Epsilon: 0.001 statt 0.01
+- **Source:** Data Model Review #24
+- **Status:** [x] FIXED (already 0.001)
+
+### L7. fmtRate fehlt GB/s Tier
+- **Source:** Data Model Review #23
+- **Status:** [x] FIXED
+
+### L8. InspectData: Name/ID nicht beim Inspizieren gespeichert
+- **Source:** Data Model Review #21
+- **Status:** [x] FIXED (already shown in viewInspect title)
+
+### L9. Scroll-Speed Modifier: Shift statt Ctrl
+- **Source:** Input Review #3
+- **Status:** [x] FIXED (already uses J/K = Shift+j/k)
+
+### L10. Horizontal-Scroll in Search-Mode fehlt
+- **Source:** Input Review #8
+- **Status:** [x] FIXED — left/right in search mode
+
+### L11. Header: "show help" → "exit help" Toggle fehlt
+- **Source:** Main UI Review #19
+- **Status:** [x] FIXED
+
+### L12. Header: `↓ rx` / `↑ tx` Pfeile existieren nicht in Rust
+- **Source:** Main UI Review #20
+- **Status:** [x] FIXED — removed arrows
+
+### L13. Charts: State-abhängige Farben fehlen
+- **Source:** Charts/Ports Review #4/#8
+- **Status:** [x] FIXED — dim color for non-running states
+
+### L14. Charts: Y-Achse Max-Wert fehlt
+- **Source:** Charts/Ports Review #2
+- **Status:** [x] FIXED — max label shown
+
+### L15. Filter-Bar: kein dediziertes UI (nur inline Text)
+- **Source:** Charts/Ports Review #17/#18
+- **Status:** [x] FIXED — dedicated styled filter bar
+
+### L16. Search-Bar: kein dediziertes UI (nur inline Text)
+- **Source:** Charts/Ports Review #20/#21
+- **Status:** [x] FIXED — dedicated styled search bar
+
+### L17. Ports: Title nicht zentriert, nicht als Border-Title
+- **Source:** Charts/Ports Review #31
+- **Status:** [x] FIXED — centered title
+
+### L18. Inspect: Scroll-Clamping fehlt
+- **Source:** Charts/Ports Review #27
+- **Status:** [x] FIXED — clampInspectScroll()
+
+### L19. "No containers" Message fehlt
+- **Source:** Main UI Review #29
+- **Status:** [x] FIXED
+
+### L20. Minimum Terminal-Size Handling fehlt
+- **Source:** Main UI Review #30
+- **Status:** [x] FIXED — 60x10 minimum with message
+
+### L21. Logs: "parsing logs" Init-State fehlt
+- **Source:** Main UI Review #14
+- **Status:** [x] FIXED
+
+### L22. Logs: scroll_padding fehlt
+- **Source:** Main UI Review #15
+- **Status:** [x] FIXED — auto-follow when at bottom
+
+### L23. Logs: color_logs Toggle fehlt
+- **Source:** Main UI Review #16
+- **Status:** [x] FIXED — stripAnsi when ColorLogs=false
+
+### L24. appendMax Slice-Leak (kein Ring-Buffer)
+- **Source:** Data Model Review #22
+- **Status:** [x] FIXED — in-place copy instead of reslice
+
+### L25. Delete-Dialog: Container-Not-Found Guard fehlt
+- **Source:** Popups Review #2
+- **Status:** [x] FIXED — guard + default name
+
+### L26. Network: alle Interfaces statt nur eth0
+- **Source:** Docker Integration Review #3
+- **Note:** Go-Verhalten ist besser, bewusste Abweichung
+- **Status:** [x] WONTFIX — bewusste Verbesserung
diff --git a/_typos.toml b/_typos.toml
deleted file mode 100644
index 372dc95..0000000
--- a/_typos.toml
+++ /dev/null
@@ -1,2 +0,0 @@
-[default.extend-words]
-ratatui = "ratatui"
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..4d7645a
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,18 @@
+#!/bin/bash
+
+# Build script for Oxker Go TUI
+# This script builds the Go application
+
+set -e
+
+cd "$(dirname "$0")"
+
+echo "Building Oxker..."
+go build -o oxker ./cmd/oxker
+echo "Build complete: ./oxker"
+
+echo "Running go vet..."
+go vet ./...
+
+echo "Running go mod tidy..."
+go mod tidy
\ No newline at end of file
diff --git a/cmd/oxker/cli.go b/cmd/oxker/cli.go
new file mode 100644
index 0000000..b961844
--- /dev/null
+++ b/cmd/oxker/cli.go
@@ -0,0 +1,110 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/oxker/oxker/internal/config"
+)
+
+// CLIArgs represents parsed command-line arguments
+type CLIArgs struct {
+ DockerIntervalMs int
+ RawLogs bool
+ ColorLogs bool
+ ShowTimestamp bool
+ ShowSelf bool
+ DebugMode bool
+ ConfigFile string
+ Host string
+ NoStdErr bool
+ SaveDir string
+ Timezone string
+ UseCLI bool
+}
+
+// ParseCLIArgs parses command-line arguments
+func ParseCLIArgs() (*CLIArgs, error) {
+ args := &CLIArgs{}
+
+ // Define flags
+ flag.IntVar(&args.DockerIntervalMs, "d", 1000, "Minimum update interval for docker information in milliseconds")
+ flag.BoolVar(&args.RawLogs, "r", false, "Show raw logs (no ANSI formatting)")
+ flag.BoolVar(&args.ColorLogs, "c", false, "Attempt to color the logs")
+ flag.BoolVar(&args.ShowTimestamp, "t", false, "Remove timestamps from each log entry")
+ flag.BoolVar(&args.ShowSelf, "s", false, "Display the oxker container")
+ flag.BoolVar(&args.DebugMode, "g", false, "No TUI, debugging mode")
+ flag.StringVar(&args.ConfigFile, "config-file", "", "Location of config file (TOML/JSON/JSONC)")
+ flag.StringVar(&args.Host, "host", "", "Connect to Docker with a custom hostname")
+ flag.BoolVar(&args.NoStdErr, "no-stderr", false, "Do not include stderr output in logs")
+ flag.StringVar(&args.SaveDir, "save-dir", "", "Save exported logs into a custom directory")
+ flag.StringVar(&args.Timezone, "timezone", "", "Display Docker logs timestamps in a given timezone")
+ flag.BoolVar(&args.UseCLI, "use-cli", false, "Use Docker CLI when exec-ing into a container")
+
+ flag.Parse()
+
+ // Validate docker interval
+ if args.DockerIntervalMs < 1 {
+ return nil, fmt.Errorf("docker interval must be greater than 0")
+ }
+
+ // Handle conflicting log options
+ if args.RawLogs && args.ColorLogs {
+ args.RawLogs = false
+ }
+
+ return args, nil
+}
+
+// LoadConfig loads configuration from file or returns defaults
+func LoadConfig(cliArgs *CLIArgs) (*config.Config, error) {
+ cfg := config.NewConfig()
+
+ // If config file specified, try to load it
+ if cliArgs.ConfigFile != "" {
+ path := cliArgs.ConfigFile
+ // Check if absolute or relative path
+ if !filepath.IsAbs(path) {
+ // Try relative to current directory
+ fullPath := filepath.Join(".", path)
+ if err := config.LoadConfigPath(fullPath, cfg); err != nil {
+ // Try relative to config directory
+ home, _ := os.UserHomeDir()
+ fullPath = filepath.Join(home, ".config", "oxker", path)
+ if err := config.LoadConfigPath(fullPath, cfg); err != nil {
+ return nil, fmt.Errorf("failed to load config file: %w", err)
+ }
+ }
+ } else {
+ if err := config.LoadConfigPath(path, cfg); err != nil {
+ return nil, fmt.Errorf("failed to load config file: %w", err)
+ }
+ }
+ }
+
+ // Override with CLI args
+ if cliArgs.DockerIntervalMs > 0 {
+ cfg.DockerIntervalMs = cliArgs.DockerIntervalMs
+ }
+ cfg.RawLogs = cliArgs.RawLogs
+ cfg.ColorLogs = cliArgs.ColorLogs
+ cfg.ShowTimestamp = !cliArgs.ShowTimestamp
+ cfg.UseCLI = cliArgs.UseCLI
+ cfg.GUI = !cliArgs.DebugMode
+ if cliArgs.Host != "" {
+ cfg.Host = cliArgs.Host
+ }
+ cfg.DirConfig = cliArgs.ConfigFile
+
+ return cfg, nil
+}
+
+// ShowDebugInfo displays information in debug mode (no TUI)
+func ShowDebugInfo(containers interface{}) {
+ // Debug mode - display basic information without TUI
+ fmt.Println("Oxker Debug Mode - Container Information")
+ fmt.Println("=========================================")
+ // TODO: Implement debug mode display
+}
diff --git a/cmd/oxker/main.go b/cmd/oxker/main.go
new file mode 100644
index 0000000..749ec31
--- /dev/null
+++ b/cmd/oxker/main.go
@@ -0,0 +1,40 @@
+package main
+
+import (
+ "fmt"
+ "os"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/oxker/oxker/internal/app"
+)
+
+func main() {
+ // Parse command-line arguments
+ cliArgs, err := ParseCLIArgs()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error parsing CLI args: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Load configuration
+ cfg, err := LoadConfig(cliArgs)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Debug mode - no TUI
+ if !cfg.GUI {
+ ShowDebugInfo(nil)
+ return
+ }
+
+ // Create and start the Bubble Tea program
+ // AltScreen and MouseMode are now controlled via View() return value
+ p := tea.NewProgram(app.New(cfg))
+
+ if _, err := p.Run(); err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ os.Exit(1)
+ }
+}
diff --git a/cmd/oxker/main_test.go b/cmd/oxker/main_test.go
new file mode 100644
index 0000000..fcd9d1f
--- /dev/null
+++ b/cmd/oxker/main_test.go
@@ -0,0 +1,42 @@
+package main
+
+import (
+ "testing"
+)
+
+func TestAppStart(t *testing.T) {
+ // Test that the application can be instantiated
+ // This is a simple smoke test to verify the app compiles and starts
+
+ // Parse default CLI args
+ cliArgs, err := ParseCLIArgs()
+ if err != nil {
+ t.Fatalf("Failed to parse CLI args: %v", err)
+ }
+
+ if cliArgs == nil {
+ t.Fatal("CLI args should not be nil")
+ }
+
+ // Test config loading
+ cfg, err := LoadConfig(cliArgs)
+ if err != nil {
+ t.Fatalf("Failed to load config: %v", err)
+ }
+
+ if cfg == nil {
+ t.Fatal("Config should not be nil")
+ }
+
+ // Test that debug mode works
+ cfg.GUI = false
+ if !cfg.GUI {
+ // Debug mode - should not error
+ }
+
+ // Test that GUI mode is enabled by default
+ cfg.GUI = true
+ if !cfg.GUI {
+ t.Fatal("GUI mode should be enabled")
+ }
+}
\ No newline at end of file
diff --git a/containerised/DOCKERHUB_README.md b/containerised/DOCKERHUB_README.md
deleted file mode 100644
index 4a0b238..0000000
--- a/containerised/DOCKERHUB_README.md
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
oxker
-
- A simple tui to view & control docker containers
-
-
-
-
-
-
-
-
-
-
- link to alternative screenshot
-
-
-
-
-## Run
-
-Images built for `linux/amd64`, `linux/arm64`, and `linux/arm/v6`
-
-`docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock:ro --pull=always mrjackwills/oxker`
-
-## Help
-
-visit the Github repo
\ No newline at end of file
diff --git a/containerised/Dockerfile b/containerised/Dockerfile
deleted file mode 100644
index 0f93266..0000000
--- a/containerised/Dockerfile
+++ /dev/null
@@ -1,61 +0,0 @@
-#############
-## Builder ##
-#############
-
-FROM --platform=$BUILDPLATFORM rust:slim AS builder
-
-ARG TARGETARCH
-
-# These are build platform dependant, but will be ignored if not needed
-ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER="aarch64-linux-gnu-gcc"
-ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS="-C target-feature=+crt-static -C link-arg=-lgcc"
-ENV CARGO_TARGET_ARM_UNKNOWN_LINUX_MUSLEABIHF_LINKER="arm-linux-gnueabihf-ld"
-
-COPY ./containerised/target.sh .
-
-RUN chmod +x ./target.sh && ./target.sh
-
-RUN apt-get update && apt-get install $(cat /.compiler) -y
-
-WORKDIR /usr/src
-
-# Create blank project
-RUN cargo new oxker
-
-# We want dependencies cached, so copy those first
-COPY Cargo.* /usr/src/oxker/
-
-# Set the working directory
-WORKDIR /usr/src/oxker
-
-# Install target platform (Cross-Compilation)
-RUN rustup target add $(cat /.target)
-
-# This is a dummy build to get the dependencies cached - probably not needed - as run via a github action
-RUN cargo build --target $(cat /.target) --release
-
-# Now copy in the rest of the sources
-COPY src /usr/src/oxker/src/
-
-# Touch main.rs to prevent cached release build
-RUN touch /usr/src/oxker/src/main.rs
-
-# This is the actual application build
-RUN cargo build --release --target $(cat /.target)
-
-RUN cp /usr/src/oxker/target/$(cat /.target)/release/oxker /
-
-#############
-## Runtime ##
-#############
-
-FROM scratch
-
-# Set an ENV to indicate that we're running in a container
-ENV OXKER_RUNTIME=container
-
-COPY --from=builder /oxker /app/
-
-# Run the application
-# this is used in the application itself so DO NOT EDIT
-ENTRYPOINT [ "/app/oxker"]
\ No newline at end of file
diff --git a/containerised/Dockerfile_dev b/containerised/Dockerfile_dev
deleted file mode 100644
index f8315da..0000000
--- a/containerised/Dockerfile_dev
+++ /dev/null
@@ -1,39 +0,0 @@
-#############
-## Runtime ##
-#############
-FROM scratch
-
-# Set env that we're running in a container
-ENV OXKER_RUNTIME=container
-
-# Copy application binary from builder image
-COPY ./target/x86_64-unknown-linux-musl/release/oxker /app/
-
-# Run the application
-# this is used in the application itself, to stop itself show when running from a docker container, so DO NOT EDIT
-ENTRYPOINT [ "/app/oxker"]
-
-
-# Dev build for testing
-# docker build -t oxker_dev -f containerised/Dockerfile_dev . && docker run --rm -it --volume /var/run/docker.sock:/var/run/docker.sock:ro oxker_dev
-
-# Dev build one liner, x86 host
-# docker image prune -a; cargo build --release --target x86_64-unknown-linux-musl && docker build -t oxker_dev -f containerised/Dockerfile_dev . && docker run --rm -it --volume /var/run/docker.sock:/var/run/docker.sock:ro oxker_dev
-
-# Buildx command to build musl version for all three platforms, should probably be executed in create_release
-# docker buildx create --use
-# docker buildx build --platform linux/arm/v6,linux/arm64,linux/amd64 -t oxker_dev_all -o type=tar,dest=/tmp/oxker_dev_all.tar -f containerised/Dockerfile .
-
-### Build docker files and save to .tar file
-
-# docker build --platform linux/amd64 -t oxker_amd64 -f containerised/Dockerfile .; docker save -o ./oxker_amd64.tar oxker_amd64
-# docker load -i oxker_amd64.tar
-# docker run --rm -it --volume /var/run/docker.sock:/var/run/docker.sock:ro oxker_amd64
-
-# docker build --platform linux/arm64 -t oxker_arm64 -f containerised/Dockerfile .; docker save -o ./oxker_arm64.tar oxker_arm64
-# docker load -i oxker_arm64.tar
-# docker run --rm -it --volume /var/run/docker.sock:/var/run/docker.sock:ro oxker_arm64
-
-# docker build --platform linux/arm/v6 -t oxker_armv6 -f containerised/Dockerfile .; docker save -o ./oxker_armv6.tar oxker_armv6
-# docker load -i oxker_armv6.tar
-# docker run --rm -it --volume /var/run/docker.sock:/var/run/docker.sock:ro oxker_armv6
\ No newline at end of file
diff --git a/containerised/target.sh b/containerised/target.sh
deleted file mode 100644
index 40ba3d6..0000000
--- a/containerised/target.sh
+++ /dev/null
@@ -1,18 +0,0 @@
-#!/bin/sh
-# Used in Docker build to set platform dependent variables
-
-case $TARGETARCH in
-
-"amd64")
- echo "x86_64-unknown-linux-musl" >/.target
- echo "" >/.compiler
- ;;
-"arm64")
- echo "aarch64-unknown-linux-musl" >/.target
- echo "gcc-aarch64-linux-gnu" >/.compiler
- ;;
-"arm")
- echo "arm-unknown-linux-musleabihf" >/.target
- echo "gcc-arm-linux-gnueabihf" >/.compiler
- ;;
-esac
diff --git a/create_release.sh b/create_release.sh
deleted file mode 100755
index 393ccbf..0000000
--- a/create_release.sh
+++ /dev/null
@@ -1,541 +0,0 @@
-#!/bin/bash
-
-# rust create_release v0.6.3
-# 2025-09-20
-
-STAR_LINE='****************************************'
-CWD=$(pwd)
-
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[0;33m'
-PURPLE='\033[0;35m'
-RESET='\033[0m'
-
-# $1 string - error message
-error_close() {
- echo -e "\n${RED}ERROR - EXITED: ${YELLOW}$1${RESET}\n"
- exit 1
-}
-
-# Check that dialog is installed
-if ! [ -x "$(command -v dialog)" ]; then
- error_close "dialog is not installed"
-fi
-
-# $1 string - question to ask
-# $1 question to ask
-# Ask a yes no question, only accepts `y` or `n` as a valid answer, returns 0 for yes, 1 for no
-ask_yn() {
- while true; do
- printf "\n%b%s? [y/N]:%b " "${GREEN}" "$1" "${RESET}"
- read -r answer
- if [[ "$answer" == "y" ]]; then
- return 0
- elif [[ "$answer" == "n" ]]; then
- return 1
- else
- echo -e "${RED}\nPlease enter 'y' or 'n'${RESET}"
- fi
- done
-}
-
-# ask continue, or quit
-ask_continue() {
- if ! ask_yn "continue"; then
- exit
- fi
-}
-
-# semver major update
-update_major() {
- local bumped_major
- bumped_major=$((MAJOR + 1))
- echo "${bumped_major}.0.0"
-}
-
-# semver minor update
-update_minor() {
- local bumped_minor
- bumped_minor=$((MINOR + 1))
- MINOR=bumped_minor
- echo "${MAJOR}.${bumped_minor}.0"
-}
-
-# semver patch update
-update_patch() {
- local bumped_patch
- bumped_patch=$((PATCH + 1))
- PATCH=bumped_patch
- echo "${MAJOR}.${MINOR}.${bumped_patch}"
-}
-
-# Get the url of the github repo, strip .git from the end of it
-get_git_remote_url() {
- GIT_REPO_URL="$(git config --get remote.origin.url | sed 's/\.git$//')"
-}
-
-# Check that git status is clean
-check_git_clean() {
- GIT_CLEAN=$(git status --porcelain)
- if [[ -n $GIT_CLEAN ]]; then
- error_close "git dirty"
- fi
-}
-
-# Check currently on dev branch
-check_git() {
- CURRENT_GIT_BRANCH=$(git branch --show-current)
- check_git_clean
- if [[ ! "$CURRENT_GIT_BRANCH" =~ ^dev$ ]]; then
- error_close "not on dev branch"
- fi
-}
-
-# Ask user if current changelog is acceptable
-ask_changelog_update() {
- echo "${STAR_LINE}"
- RELEASE_BODY_TEXT=$(sed '/# CHANGELOG.md for more details" >.github/release-body.md
-
- # Add subheading with release version and date of release
- echo -e "# ${NEW_TAG_WITH_V}\n${DATE_SUBHEADING}${CHANGELOG_ADDITION}$(cat CHANGELOG.md)" >CHANGELOG.md
-
- # Update changelog to add links to commits [hex:8](url_with_full_commit)
- # "[aaaaaaaaaabbbbbbbbbbccccccccccddddddddd]" -> "[aaaaaaaa](https:/www.../commit/aaaaaaaaaabbbbbbbbbbccccccccccddddddddd)"
- sed -i -E "s=(\s)\[([0-9a-f]{8})([0-9a-f]{32})\]= [\2](${GIT_REPO_URL}/commit/\2\3)=g" CHANGELOG.md
-
- # Update changelog to add links to closed issues
- # "closes #1" -> "closes [#1](https:/www.../issues/1)""
- sed -i -r -E "s=closes \#([0-9]+)=closes [#\1](${GIT_REPO_URL}/issues/\1)=g" CHANGELOG.md
-
- # Update changelog to add links to merged PR's
- # "merges #1" -> "merges [#1](https:/www.../pull/1)""
- sed -i -r -E "s=merges \#([0-9]+)=merges [#\1](${GIT_REPO_URL}/pull/\1)=g" CHANGELOG.md
-}
-
-# update version in cargo.toml, to match selected current version
-update_version_number_in_files() {
- sed -i "s|^version = .*|version = \"${MAJOR}.${MINOR}.${PATCH}\"|" Cargo.toml
-}
-
-# Work out the current version, based on git tags
-# create new semver version based on user input
-# Set MAJOR MINOR PATCH
-check_tag() {
- LATEST_TAG=$(git describe --tags "$(git rev-list --tags --max-count=1)")
- echo -e "\nCurrent tag: ${PURPLE}${LATEST_TAG}${RESET}\n"
- echo -e "${YELLOW}Choose new tag version:${RESET}\n"
- if [[ $LATEST_TAG =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*))?(\+([0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*))?$ ]]; then
- IFS="." read -r MAJOR MINOR PATCH <<<"${LATEST_TAG:1}"
- else
- MAJOR="0"
- MINOR="0"
- PATCH="0"
- fi
- OP_MAJOR="major___v$(update_major)"
- OP_MINOR="minor___v$(update_minor)"
- OP_PATCH="patch___v$(update_patch)"
- OPTIONS=("$OP_MAJOR" "$OP_MINOR" "$OP_PATCH")
- select choice in "${OPTIONS[@]}"; do
- case $choice in
- "$OP_MAJOR")
- MAJOR=$((MAJOR + 1))
- MINOR=0
- PATCH=0
- break
- ;;
- "$OP_MINOR")
- MINOR=$((MINOR + 1))
- PATCH=0
- break
- ;;
- "$OP_PATCH")
- PATCH=$((PATCH + 1))
- break
- ;;
- *)
- error_close "invalid option $REPLY"
- ;;
- esac
- done
-}
-
-# run all tests
-cargo_test() {
- cargo test -- --test-threads=1
- ask_continue
-}
-
-# Simulate publishing to crates.io
-cargo_publish_dry_run() {
- echo -e "${PURPLE}cargo publish --dry-run${RESET}"
- cargo publish --dry-run
- ask_continue
-}
-
-# Check to see if cross is installed - if not then install
-check_cross() {
- if ! [ -x "$(command -v cross)" ]; then
- echo -e "${GREEN}cargo install cross${RESET}"
- cargo install cross
- fi
-}
-
-# Build, using cross-rs, for linux x86 musl
-cross_build_x86_linux() {
- check_cross
- echo -e "${YELLOW}cross build --target x86_64-unknown-linux-musl --release${RESET}"
- cross build --target x86_64-unknown-linux-musl --release
-}
-
-# Build, using cross-rs, for linux arm64 musl
-cross_build_aarch64_linux() {
- check_cross
- echo -e "${YELLOW}cross build --target aarch64-unknown-linux-musl --release${RESET}"
- cross build --target aarch64-unknown-linux-musl --release
-}
-
-# Build, using cross-rs, for linux armv6 musl
-cross_build_armv6_linux() {
- check_cross
- echo -e "${YELLOW}cross build --target arm-unknown-linux-musleabihf --release${RESET}"
- cross build --target arm-unknown-linux-musleabihf --release
-}
-
-# Build, using cross-rs, for windows x86
-cross_build_x86_windows() {
- check_cross
- echo -e "${YELLOW}cross build --target x86_64-pc-windows-gnu --release${RESET}"
- cross build --target x86_64-pc-windows-gnu --release
-}
-
-# Build, using zig-build, for Apple silicon
-zig_build_aarch64_apple() {
- # mkdir /workspace/oxker/target
- echo -e "${YELLOW}docker run --rm -v $(pwd):/io -w /io ghcr.io/rust-cross/cargo-zigbuild bash -e -c 'rustup update stable && rustup default stable && rustup target add aarch64-apple-darwin && cargo zigbuild --release --target aarch64-apple-darwin${RESET}"
-
- docker run --rm -v "$(pwd):/io" -w /io \
- ghcr.io/rust-cross/cargo-zigbuild \
- bash -ec 'rustup update stable && rustup default stable && rustup target add aarch64-apple-darwin && cargo zigbuild --release --target aarch64-apple-darwin'
-
- if ask_yn "sudo chown $(pwd)/target"; then
- echo -e "${YELLOW}sudo chown -R vscode:vscode $(pwd)/target${RESET}"
- sudo chown -R vscode:vscode "$(pwd)/target"
- fi
-}
-
-cargo_clean() {
- echo -e "${YELLOW}cargo clean${RESET}"
- cargo clean
-}
-
-# Build all releases that GitHub workflow would
-# This will download GB's of docker images
-# $1 is 0 or 1, if 1 won't run ask_continue
-cross_build_all() {
- if ask_yn "cargo clean"; then
- cargo_clean
- fi
- skip_confirm=$1
- cross_build_armv6_linux
- [ "$skip_confirm" -ne 1 ] && ask_continue
- cross_build_aarch64_linux
- [ "$skip_confirm" -ne 1 ] && ask_continue
- cross_build_x86_linux
- [ "$skip_confirm" -ne 1 ] && ask_continue
- cross_build_x86_windows
- [ "$skip_confirm" -ne 1 ] && ask_continue
- zig_build_aarch64_apple
- [ "$skip_confirm" -ne 1 ] && ask_continue
-}
-
-# $1 text to colourise
-release_continue() {
- echo -e "\n${PURPLE}$1${RESET}"
- ask_continue
-}
-
-# Check repository for typos
-check_typos() {
- echo -e "\n${PURPLE}check typos${RESET}"
- typos
- ask_continue
-}
-
-# Make sure the unused lint isn't used
-check_allow_unused() {
- matches_any=$(find . -type d \( -name .git -o -name target \) -prune -o -type f -exec grep -lE '^#!\[allow\(unused\)\]$' {} +)
- matches_cargo=$(grep "^unused = \"allow\"" ./Cargo.toml)
- if [ -n "$matches_any" ]; then
- echo "\"#[allow(unused)]\" in ${matches_any}"
- ask_continue
- elif [ -n "$matches_cargo" ]; then
- echo "\"unused = \"allow\"\" in Cargo.toml"
- ask_continue
- fi
-}
-
-# build container for amd64 platform
-build_container_amd64() {
- echo -e "${YELLOW}docker build --platform linux/amd64 --no-cache -t oxker_amd64 --no-cache -f containerised/Dockerfile .; docker save -o /tmp/oxker_amd64.tar oxker_amd64${RESET}"
- docker build --platform linux/amd64 --no-cache -t oxker_amd64 -f containerised/Dockerfile .
- docker save -o /tmp/oxker_amd64.tar oxker_amd64
-}
-
-# build container for aarm64 platform
-build_container_arm64() {
- echo -e "${YELLOW}docker build --platform linux/arm64 --no-cache -t oxker_arm64 --no-cache -f containerised/Dockerfile .; docker save -o /tmp/oxker_arm64.tar oxker_arm64${RESET}"
- docker build --platform linux/arm64 --no-cache -t oxker_arm64 -f containerised/Dockerfile .
- docker save -o /tmp/oxker_arm64.tar oxker_arm64
-}
-
-# build container for armv6 platform
-build_container_armv6() {
- echo -e "${YELLOW}docker build --platform linux/arm/v6 --no-cache -t oxker_armv6 --no-cache -f containerised/Dockerfile .; docker save -o /tmp/oxker_armv6.tar oxker_armv6${RESET}"
- docker build --platform linux/arm/v6 --no-cache -t oxker_armv6 -f containerised/Dockerfile .
- docker save -o /tmp/oxker_armv6.tar oxker_armv6
-}
-
-# Build all the containers, this get executed in the github action
-# $1 is 0 or 1, if 1 won't run ask_continue
-build_container_all() {
- skip_confirm=$1
- build_container_amd64
- [ "$skip_confirm" -ne 1 ] && ask_continue
- build_container_arm64
- [ "$skip_confirm" -ne 1 ] && ask_continue
- build_container_armv6
- [ "$skip_confirm" -ne 1 ] && ask_continue
-}
-
-# Full flow to create a new release
-release_flow() {
- check_allow_unused
- check_typos
-
- check_git
- get_git_remote_url
-
- cargo_test
- cross_build_all 0
- build_container_all 0
- cargo_publish_dry_run
-
- cd "${CWD}" || error_close "Can't find ${CWD}"
- check_tag
-
- NEW_TAG_WITH_V="v${MAJOR}.${MINOR}.${PATCH}"
- printf "\nnew tag chosen: %s\n\n" "${NEW_TAG_WITH_V}"
-
- RELEASE_BRANCH=release-$NEW_TAG_WITH_V
- echo -e
- ask_changelog_update
-
- release_continue "checkout ${RELEASE_BRANCH}"
- git checkout -b "$RELEASE_BRANCH"
-
- release_continue "update_version_number_in_files"
- update_version_number_in_files
-
- echo -e "\ncargo fmt"
- cargo fmt
-
- echo -e "\n${PURPLE}cargo check${RESET}\n"
- cargo check
-
- release_continue "git add ."
- git add .
-
- release_continue "git commit -m \"chore: release \"${NEW_TAG_WITH_V}\""
- git commit -m "chore: release ${NEW_TAG_WITH_V}"
-
- release_continue "git checkout main"
- git checkout main
-
- echo -e "${PURPLE}git pull origin main${RESET}"
- git pull origin main
-
- echo -e "${PURPLE}git merge --no-ff \"${RELEASE_BRANCH}\" -m \"chore: merge ${RELEASE_BRANCH} into main\"${RESET}"
- git merge --no-ff "$RELEASE_BRANCH" -m "chore: merge ${RELEASE_BRANCH} into main"
-
- echo -e "\n${PURPLE}cargo check${RESET}\n"
- cargo check
-
- release_continue "git tag -am \"${RELEASE_BRANCH}\" \"$NEW_TAG_WITH_V\""
- git tag -am "${RELEASE_BRANCH}" "$NEW_TAG_WITH_V"
-
- release_continue "git push --atomic origin main \"$NEW_TAG_WITH_V\""
- git push --atomic origin main "$NEW_TAG_WITH_V"
-
- release_continue "git checkout dev"
- git checkout dev
-
- release_continue "git merge --no-ff main -m \"chore: merge main into dev\""
- git merge --no-ff main -m "chore: merge main into dev"
-
- release_continue "git push origin dev"
- git push origin dev
-
- release_continue "git branch -d \"$RELEASE_BRANCH\""
- git branch -d "$RELEASE_BRANCH"
-}
-
-build_choice() {
- cmd=(dialog --backtitle "Choose option" --keep-tite --radiolist "choose" 14 80 16)
- options=(
- 1 "x86 musl linux" off
- 2 "aarch64 musl linux" off
- 3 "armv6 musl linux" off
- 4 "aarch64 apple" off
- 5 "x86 windows" off
- 6 "all" off
- 7 "all automatic" off
- )
- choices=$("${cmd[@]}" "${options[@]}" 2>&1 >/dev/tty)
- exitStatus=$?
- if [ $exitStatus -ne 0 ]; then
- exit
- fi
- for choice in $choices; do
- case $choice in
- 0)
- exit
- ;;
- 1)
- cross_build_x86_linux
- exit
- ;;
- 2)
- cross_build_aarch64_linux
- exit
- ;;
- 3)
- cross_build_armv6_linux
- exit
- ;;
- 4)
- zig_build_aarch64_apple
- exit
- ;;
- 5)
- cross_build_x86_windows
- exit
- ;;
- 6)
- cross_build_all 0
- exit
- ;;
- 7)
- cross_build_all 1
- exit
- ;;
- esac
- done
-}
-
-build_container_choice() {
- cmd=(dialog --backtitle "Choose option" --keep-tite --radiolist "choose" 14 80 16)
- options=(
- 1 "x86 " off
- 2 "aarch64" off
- 3 "armv6" off
- 4 "all" off
- 5 "all automatic" off
- )
- choices=$("${cmd[@]}" "${options[@]}" 2>&1 >/dev/tty)
- exitStatus=$?
- if [ $exitStatus -ne 0 ]; then
- exit
- fi
- for choice in $choices; do
- case $choice in
- 0)
- exit
- ;;
- 1)
- build_container_amd64
- exit
- ;;
- 2)
- build_container_arm64
- exit
- ;;
- 3)
- build_container_armv6
- exit
- ;;
- 4)
- build_container_all 0
- exit
- ;;
- 5)
- build_container_all 1
- exit
- ;;
- esac
- done
-
-}
-
-main() {
- cmd=(dialog --backtitle "Choose option" --keep-tite --radiolist "choose" 14 80 16)
- options=(
- 1 "test" off
- 2 "release" off
- 3 "build" off
- 4 "docker builds" off
- )
- choices=$("${cmd[@]}" "${options[@]}" 2>&1 >/dev/tty)
- exitStatus=$?
- if [ $exitStatus -ne 0 ]; then
- exit
- fi
- for choice in $choices; do
- case $choice in
- 0)
- exit
- ;;
- 1)
- cargo_test
- main
- break
- ;;
- 2)
- release_flow
- break
- ;;
- 3)
- build_choice
- main
- break
- ;;
- 4)
- build_container_choice
- main
- break
- ;;
- esac
- done
-}
-
-main
diff --git a/docker/Dockerfile.unhealthy b/docker/Dockerfile.unhealthy
deleted file mode 100644
index 4a6d10a..0000000
--- a/docker/Dockerfile.unhealthy
+++ /dev/null
@@ -1,17 +0,0 @@
-# Use an official lightweight image as a base
-FROM alpine:latest
-
-# Install a simple utility (e.g., curl) to run as a health check
-RUN apk --no-cache add curl speedtest-cli
-
-# Create a dummy file that we will use in our health check
-RUN touch /tmp/healthy
-
-# Define a simple health check
-HEALTHCHECK --interval=5s --timeout=3s --retries=3 \
- CMD [ ! -f /tmp/healthy ] || exit 1
-
-# Start a basic loop that keeps the container running
-CMD ["sh", "-c", "speedtest-cli; while :; do >&2 echo 'Container is running but will be unhealthy (also printing to stderr)'; sleep 30; done"]
-
-# docker build -t unhealthy-container . -f Dockerfile.unhealthy; docker run -d --name unhealthy unhealthy-container
\ No newline at end of file
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
deleted file mode 100644
index 2ef1418..0000000
--- a/docker/docker-compose.yml
+++ /dev/null
@@ -1,58 +0,0 @@
-
-networks:
- oxker-example-net:
- name: oxker-examaple-net
-services:
- postgres:
- image: postgres:18-alpine
- container_name: postgres
- environment:
- - POSTGRES_PASSWORD=never_use_this_password_in_production
- ipc: private
- restart: always
- shm_size: 256MB
- networks:
- - oxker-example-net
- deploy:
- resources:
- limits:
- memory: 1024M
- redis:
- image: redis:latest
- container_name: redis
- ipc: private
- restart: always
- networks:
- - oxker-example-net
- deploy:
- resources:
- limits:
- memory: 384M
- rabbitmq:
- image: rabbitmq:3
- container_name: rabbitmq
- ipc: private
- restart: always
- networks:
- - oxker-example-net
- deploy:
- resources:
- limits:
- memory: 512M
- some_container:
- container_name: some_container
- image: some_container
- build:
- context: .
- dockerfile: Dockerfile.unhealthy
- ipc: private
- restart: always
- networks:
- - oxker-example-net
- deploy:
- resources:
- limits:
- memory: 128M
-
-
-
diff --git a/example_config/example.config.jsonc b/example_config/example.config.jsonc
deleted file mode 100644
index db1251f..0000000
--- a/example_config/example.config.jsonc
+++ /dev/null
@@ -1,372 +0,0 @@
-{
- // Example JSONC config file
- // This needs to be renamed to "config.jsonc" ("config.json" will also work, even if the file is actually a jsonc) in order for oxker to automatically load
- // oxker will also read .toml and .json files which use the same key/value structure & format as this file
- // Every key is optional, with defaults that oxker will choose if missing or invalid
- // The `--config-file` cli argument can be used to load configuration files from any readable location
- // Docker update interval in ms, minimum effectively 1000
- "docker_interval": 1000,
- // Attempt to colorize the logs, conflicts with "raw"
- "color_logs": false,
- // Show raw logs, default is to remove ansi formatting, conflicts with "color"
- "raw_logs": false,
- // Show self (the oxker container) when running as a docker container
- "show_self": false,
- // Show std_err in logs
- "show_std_err": true,
- // Show a timestamp for every log entry
- "show_timestamp": true,
- // Don't draw gui - for debugging - mostly pointless
- "gui": true,
- // Docker host location. Will take priority over a DOCKER_HOST env.
- // "host": "/var/run/docker.sock",
- // Display the timestamp in a custom format, if given option is invalid, it will default to %Y-%m-%dT%H:%M:%S.%8f -> 2025-02-18T12:34:56.01234567
- // *Should* accept any valid strftime string up to 32 chars, see https://strftime.org/
- "timestamp_format": "%Y-%m-%dT%H:%M:%S.%8f",
- // Display the container logs timestamp with a given timezone, if timezone is unknown, defaults to UTC
- "timezone": "Etc/UTC",
- // Directory for saving exported logs, defaults to `$HOME`, this is automatically *correctly* calculated for Linux, Mac, and Windows
- // "save_dir": "$HOME",
- // Force use of docker cli when execing into containers, honestly mostly pointless
- "use_cli": false,
- // Show the logs section - this can be changed during operation with the log_section_toggle key
- "show_logs": true,
- // Use case-sensitive matching for logs
- "log_search_case_sensitive": true,
- //////////////////
- // Custom Keymap //
- //////////////////
- // Available keys are;
- // 1) a-z and A-Z
- // 2) 0-9
- // WARNING if using the \ key, it needs to be escaped, e.g. "log_section_toggle": ["\\"]
- // 3) / \ , . # ' [ ] ; = -
- // 3) F1-F12
- // 4) backspace, tab, backtab, delete, end, esc, home, insert, pagedown, pageup, left, right, up, down
- // Each definition can have two keys associated with it
- // WARNING "scroll_many" only accepts control, alt, shift, with no secondary option
- // If any key clashes are found, oxker will revert to it's default keymap
- "keymap": {
- // Clear any popup boxes, filter panel, or help panel
- "clear": [
- "c",
- "esc"
- ],
- // Cancel delete - clear also works here
- "delete_deny": [
- "n"
- ],
- // Confirm Delete
- "delete_confirm": [
- "y"
- ],
- // Exec into the selected container
- "exec": [
- "e"
- ],
- // Enter filter mode
- "filter_mode": [
- "/",
- "F1"
- ],
- // Enter log search mode
- "log_search_mode": [
- "#"
- ],
- // Quit at anytime
- "quit": [
- "q"
- ],
- // Save logs of selected container to file on disk
- "save_logs": [
- "s"
- ],
- // Scroll down a list by one item
- "scroll_down": [
- "down",
- "j"
- ],
- // Scroll down to the end of a list
- "scroll_end": [
- "end"
- ],
- // Modifier to scroll by 10 lines instead of one, used in conjunction with scroll_up/scroll_down
- "scroll_many": [
- "control"
- ],
- // Scroll up to the start of a list
- "scroll_start": [
- "home"
- ],
- // Scroll up a list by one item
- "scroll_up": [
- "up",
- "k"
- ],
- // Horizontal scroll of the logs
- "scroll_forward": [
- "right"
- ],
- "scroll_back": [
- "left"
- ],
- // Select next panel
- "select_next_panel": [
- "tab"
- ],
- // Select previous panel
- "select_previous_panel": [
- "backtab"
- ],
- // Sort the containers based on specific column
- "sort_by_name": [
- "1"
- ],
- "sort_by_state": [
- "2"
- ],
- "sort_by_status": [
- "3"
- ],
- "sort_by_cpu": [
- "4"
- ],
- "sort_by_memory": [
- "5"
- ],
- "sort_by_id": [
- "6"
- ],
- "sort_by_image": [
- "7"
- ],
- "sort_by_rx": [
- "8"
- ],
- "sort_by_tx": [
- "9"
- ],
- // Reset the sorted containers
- "sort_reset": [
- "0"
- ],
- // Toggle the help panel
- "toggle_help": [
- "h"
- ],
- // Toggle mouse capture
- "toggle_mouse_capture": [
- "m"
- ],
- // Reduce the height of the logs list section
- "log_section_height_decrease": [
- "-"
- ],
- // Increase the height of the logs list section
- "log_section_height_increase": [
- "+"
- ],
- // Toggle visibility of the log section
- "log_section_toggle": [
- "\\"
- ],
- // Toggle to inspect container screen
- "inspect": [
- "i"
- ],
- // Force a complete clear & redraw of the screen
- "force_redraw": [
- "f"
- ]
- },
- ////////////////////
- // Custom Colors //
- ////////////////////
- // Colors be listed as either;
- // 1) named ANSI: 'red', case insensitive, see https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
- // 2) Hex values: '#FF0000', case insensitive
- // 3) 'reset' for transparency
- // Some background/foreground combinations don't work, I *think* this is an issue/feature of ratatui - but I may have just made a mistake somewhere
- "colors": {
- // The single line bar at the uppermost of the display. Contains name/state/status headings etc
- "headers_bar": {
- // Background color of the entire line
- "background": "magenta",
- // Animated loading icon at the start of the bar
- "loading_spinner": "white",
- // Text color
- "text": "black",
- // Text color of a selected header
- "text_selected": "gray"
- },
- // The borders around the selectable panels - Containers, Commands, Logs
- "borders": {
- // Border when selected
- "selected": "lightcyan",
- // Border when not selected
- "unselected": "grey"
- },
- // The containers sections, in the future more color customization options should be made available in this section
- "containers": {
- // The icon use to illustrate which container is currently selected
- "icon": "white",
- // Background color of panel
- "background": "reset",
- // At the moment, this will only change the color of the name, id, and image columns
- "text": "blue",
- // Text color of the RX column
- "text_rx": "#FFE9C1",
- // Text color of the TX column
- "text_tx": "#CD8C8C"
- },
- // Each state of a container has a color, which is used in multiple places, i.e. chart titles, state/status/cpu/memory columns in the container section
- "container_state": {
- "dead": "red",
- "exited": "red",
- "paused": "yellow",
- "removing": "lightred",
- "restarting": "lightgreen",
- "running_healthy": "green",
- "running_unhealthy": "#FFB224",
- "unknown": "red"
- },
- // The color the of Docker commands available for each container
- "commands": {
- // Background color of panel
- "background": "reset",
- "pause": "yellow",
- "restart": "magenta",
- "stop": "red",
- "delete": "gray",
- "resume": "blue",
- "start": "green"
- },
- // The cpu chart
- "chart_cpu": {
- // Background color of panel
- "background": "reset",
- // Border color
- "border": "white",
- // Chart title
- "title": "green",
- // Maximum CPU percentage
- "max": "#FFB224",
- // Points on the chart
- "points": "magenta",
- // The charts y-axis
- "y_axis": "white"
- },
- // The memory chart
- "chart_memory": {
- // Background color of panel
- "background": "reset",
- // Border color
- "border": "white",
- // Chart title
- "title": "green",
- // Maximum memory use
- "max": "#FFB224",
- // Points on the chart
- "points": "cyan",
- // The charts y-axis
- "y_axis": "white"
- },
- // The ports chart
- "chart_ports": {
- // Background color of panel
- "background": "reset",
- // Border color
- "border": "white",
- // Chart title
- "title": "green",
- // Private/Public/IP headings
- "headings": "yellow",
- // Ports & IP listing text
- "text": "white"
- },
- // The bandwidth chart
- "chart_bandwidth": {
- //Background color of panel
- "background": "reset",
- // Border color
- "border": "white",
- // Maximum RX value - again paused & stopped colors not yet customizable
- "max_rx": "#FFE9C1",
- // # Maximum TX value - again paused & stopped colors not yet customizable
- "max_tx": "#CD8C8C",
- // RX points on the chart - again paused & stopped colors not yet customizable
- "points_rx": "#FFE9C1",
- // TX points on the chart - again paused & stopped colors not yet customizable
- "points_tx": "#CD8C8C",
- // RX title color
- "title_rx": "#FFE9C1",
- // TX title color
- "title_tx": "#CD8C8C",
- // The charts y-axis
- "y_axis": "white"
- },
- // The filter panel
- "filter": {
- // Background color of panel
- "background": "reset",
- // color of text
- "text": "gray",
- // background color of the selected filter by item (Name/Image/Status/All)
- "selected_filter_background": "gray",
- // text color of the selected filter by item (Name/Image/Status/All)
- "selected_filter_text": "black",
- // Highlighted text color
- "highlight": "magenta"
- },
- // The log search panel
- "log_search": {
- // Background color of panel
- "background": "reset",
- // color of text
- "text": "gray",
- // text color of the buttons text
- "button_text": "black",
- // Highlighted text color
- "highlight": "magenta"
- },
- // The logs panel, will only be applied if color_logs is false
- "logs": {
- // Background color of panel
- "background": "reset",
- // text color
- "text": "reset"
- },
- // The help popup
- "popup_help": {
- // Background color
- "background": "magenta",
- // Text color
- "text": "black",
- // Highlighted text color
- "text_highlight": "white"
- },
- // The info popup - used to display small messages - such as saving logs to disk, or change of mouse capture settings
- "popup_info": {
- // Background color
- "background": "blue",
- // Text color
- "text": "white"
- },
- // The delete popup - used to display a confirmation warning when about to delete a container
- "popup_delete": {
- // Background color
- "background": "white",
- // Text color
- "text": "black",
- // Highlighted text color
- "text_highlight": "red"
- },
- // The error popup - hopefully you'll never have to see this
- "popup_error": {
- // Background color
- "background": "red",
- // Text color
- "text": "white"
- }
- }
-}
\ No newline at end of file
diff --git a/example_config/example.config.toml b/example_config/example.config.toml
deleted file mode 100644
index 1b1a0c1..0000000
--- a/example_config/example.config.toml
+++ /dev/null
@@ -1,323 +0,0 @@
-# oxker config file
-# oxker will also read .jsonc and .json files which use the same key/value structure & format as this file
-# Every key is optional, with defaults that oxker will choose if missing or invalid
-# The `--config-file` cli argument can be used to load configuration files from any readable location
-
-# Docker update interval in ms, minimum effectively 1000
-docker_interval = 1000
-
-# Attempt to colorize the logs, conflicts with "raw"
-color_logs = false
-
-# Show raw logs, default is to remove ansi formatting, conflicts with "color"
-raw_logs = false
-
-# Show self (the oxker container) when running as a docker container
-show_self = false
-
-# Show std_err in logs
-show_std_err = true
-
-# Show a timestamp for every log entry
-show_timestamp = true
-
-# Don't draw gui - for debugging - mostly pointless
-gui = true
-
-# Docker host location. Will take priority over a DOCKER_HOST env.
-# host = "/var/run/docker.sock"
-
-# Display the container logs timestamp with a given timezone, if timezone is unknown, defaults to UTC
-timezone = "Etc/UTC"
-
-# Display the timestamp in a custom format, if given option is invalid, it will default to %Y-%m-%dT%H:%M:%S.%8f -> 2025-02-18T12:34:56.012345678Z
-# *Should* accept any valid strftime string up to 32 chars, see https://strftime.org/
-timestamp_format = "%Y-%m-%dT%H:%M:%S.%8f"
-
-# Directory for saving exported logs, defaults to `$HOME`, this is automatically *correctly* calculated for Linux, Mac, and Windows
-# save_dir = "$HOME"
-
-# Force use of docker cli when execing into containers, honestly mostly pointless
-use_cli = false
-
-# Show the logs section - this can be changed during operation with the log_section_toggle key
-show_logs = true
-
-# Use case-sensitive matching for logs
-log_search_case_sensitive = true
-
-#################
-# Custom Keymap #
-#################
-
-# Available keys are;
-# 1) a-z and A-Z
-# 2) 0-9
-# WARNING if using the \ key, it needs to be escaped, e.g. log_section_toggle = ["\\"]
-# 3) / \ , . # ' [ ] ; = -
-# 3) F1-F12
-# 4) backspace, tab, backtab, delete, end, esc, home, insert, pagedown, pageup, left, right, up, down
-
-# Each definition can have two keys associated with it
-
-# WARNING "scroll_many" only accepts control, alt, shift, with no secondary option
-
-# If any key clashes are found, oxker will revert to it's default keymap
-
-[keymap]
-# Clear any popup boxes, filter panel, or help panel
-clear = ["c", "esc"]
-# Cancel delete - clear also works here
-delete_deny = ["n"]
-# Confirm Delete
-delete_confirm = ["y"]
-# Exec into the selected container
-exec = ["e"]
-# Enter filter mode
-filter_mode = ["/", "F1"]
-
-# Enter log search mode
-log_search_mode = ["#"]
-
-# Quit at anytime
-quit = ["q"]
-# Save logs of selected container to file on disk
-save_logs = ["s"]
-# scroll down a list by one item
-scroll_down = ["down", "j"]
-
-# scroll down to the end of a list
-scroll_end = ["end"]
-# Modifier to scroll by 10 lines instead of one, used in conjunction with scroll_up/scroll_down
-scroll_many = ["control"]
-# scroll up to the start of a list
-scroll_start = ["home"]
-# scroll up a list by one item
-scroll_up = ["up", "k"]
-# Horizontal scroll of the logs
-scroll_forward = ["right"]
-scroll_back = ["left"]
-# Select next panel
-select_next_panel = ["tab"]
-# Select previous panel
-select_previous_panel = ["backtab"]
-# Sort the containers based on specific column
-sort_by_name = ["1"]
-sort_by_state = ["2"]
-sort_by_status = ["3"]
-sort_by_cpu = ["4"]
-sort_by_memory = ["5"]
-sort_by_id = ["6"]
-sort_by_image = ["7"]
-sort_by_rx = ["8"]
-sort_by_tx = ["9"]
-# Reset the sorted containers
-sort_reset = ["0"]
-# Toggle the help panel
-toggle_help = ["h"]
-# Toggle mouse capture
-toggle_mouse_capture = ["m"]
-# Reduce the height of the logs list section
-log_section_height_decrease = ["-"]
-log_section_height_increase = ["+"]
-# Toggle visibility of the log section
-log_section_toggle = ["\\"]
-# Toggle to inspect container screen
-inspect = ["i"]
-
-
-
-# Force a complete clear & redraw of the screen
-force_redraw = ["f"]
-
-#################
-# Custom Colors #
-#################
-
-# Colors be listed as either;
-# 1) named ANSI: 'red', case insensitive, see https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
-# 2) Hex values: '#FF0000', case insensitive
-# 3) 'reset' for transparency
-
-# Some background/foreground combinations don't work, I *think* this is an issue/feature of ratatui - but I may have just made a mistake somewhere
-
-# The single line bar at the uppermost of the display. Contains name/state/status headings etc
-[colors.headers_bar]
-# Background color of the entire line
-background = "magenta"
-# Animated loading icon at the start of the bar
-loading_spinner = "white"
-# Text color
-text = "black"
-# Text color of a selected header
-text_selected = "gray"
-
-# The borders around the selectable panels - Containers, Commands, Logs
-[colors.borders]
-# Border when selected
-selected = "lightcyan"
-# Border when not selected
-unselected = "grey"
-
-# The containers sections, in the future more color customization options should be made available in this section
-[colors.containers]
-# The icon use to illustrate which container is currently selected - at the moment the TUI library, ratatui, doesn't seem allow changing the color of the highlight symbol
-icon = "white"
-# Background color of panel
-background = "reset"
-# At the moment, this will only change the color of the name, id, and image columns
-text = "blue"
-# Text color of the RX column
-text_rx = "#FFE9C1"
-# Text color of the TX column
-text_tx = "#CD8C8C"
-
-# The logs panel, will only be applied if color_logs is false
-[colors.logs]
-# Background color of panel
-background = "reset"
-# text color
-text = "reset"
-
-# Each state of a container has a color, which is used in multiple places, i.e. chart titles, state/status/cpu/memory columns in the container section
-[colors.container_state]
-dead = "red"
-exited = "red"
-paused = "yellow"
-removing = "lightred"
-restarting = "lightgreen"
-running_healthy = "green"
-running_unhealthy = "#FFB224"
-unknown = "red"
-
-# The filter panel
-[colors.filter]
-# Background color of panel
-background = "reset"
-# color of text
-text = "gray"
-# background color of the selected filter by item (Name/Image/Status/All)
-selected_filter_background = "gray"
-# text color of the selected filter by item (Name/Image/Status/All)
-selected_filter_text = "black"
-# Highlighted text color
-highlight = "magenta"
-
-
-# The log search panel
-[colors.log_search]
-# Background color of panel
-background = "reset"
-# color of text
-text = "gray"
-# text color of the buttons text
-button_text = "black"
-# Highlighted text color
-highlight = "magenta"
-
-# The color the of Docker commands available for each container
-[colors.commands]
-# Background color of panel
-background = "reset"
-pause = "yellow"
-restart = "magenta"
-stop = "red"
-delete = "gray"
-resume = "blue"
-start = "green"
-
-# The cpu chart
-[colors.chart_cpu]
-# Background color of panel
-background = "reset"
-# Border color
-border = "white"
-# Chart title - only whilst container is running, paused & stopped colors not yet customizable - or could just use state color?
-title = "green"
-# Maximum CPU percentage - again paused & stopped colors not yet customizable
-max = "#FFB224"
-# Points on the chart - again paused & stopped colors not yet customizable
-points = "magenta"
-# The charts y-axis
-y_axis = "white"
-
-# The memory chart
-[colors.chart_memory]
-# Background color of panel
-background = "reset"
-# Border color
-border = "white"
-# Chart title - only whilst container is running, paused & stopped will use colors.container_state
-title = "green"
-# Maximum memory use - again paused & stopped will use colors.container_state
-max = "#FFB224"
-# Points on the chart - again paused & stopped will use colors.container_state
-points = "cyan"
-# The charts y-axis
-y_axis = "white"
-
-# The bandwidth chart
-[colors.chart_bandwidth]
-# Background color of panel
-background = "reset"
-# Border color
-border = "white"
-# Maximum RX value - again paused & stopped colors not yet customizable
-max_rx = "#FFE9C1"
-# Maximum TX value - again paused & stopped colors not yet customizable
-max_tx = "#CD8C8C"
-# RX points on the chart - again paused & stopped colors not yet customizable
-points_rx = "#FFE9C1"
-# TX points on the chart - again paused & stopped colors not yet customizable
-points_tx = "#CD8C8C"
-# TX title color
-title_tx = "#FFE9C1"
-# RX title color
-title_rx = "#CD8C8C"
-# The charts y-axis
-y_axis = "white"
-
-# The ports chart
-[colors.chart_ports]
-# Background color of panel
-background = "reset"
-# Border color
-border = "white"
-# Chart title - only whilst container is running, paused & stopped will use colors.container_state
-title = "green"
-# Private/Public/IP headings
-headings = "yellow"
-# Ports & IP listing text
-text = "white"
-
-# The help popup
-[colors.popup_help]
-# Background color
-background = "magenta"
-# Text color
-text = "black"
-# Highlighted text color
-text_highlight = "white"
-
-# The info popup - used to display small messages - such as saving logs to disk, or change of mouse capture settings
-[colors.popup_info]
-# Background color
-background = "blue"
-# Text color
-text = "white"
-
-# The delete popup - used to display a confirmation warning when about to delete a container
-[colors.popup_delete]
-# Background color
-background = "white"
-# Text color
-text = "black"
-# Highlighted text color
-text_highlight = "red"
-
-# The error popup - hopefully you'll never have to see this
-[colors.popup_error]
-# Background color
-background = "red"
-# Text color
-text = "white"
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..0f757aa
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,63 @@
+module github.com/oxker/oxker
+
+go 1.24.4
+
+require (
+ charm.land/bubbletea/v2 v2.0.2
+ github.com/BurntSushi/toml v1.3.2
+ github.com/charmbracelet/lipgloss v1.1.0
+ github.com/creack/pty v1.1.24
+ github.com/docker/docker v28.5.2+incompatible
+ github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02
+ github.com/olekukonko/tablewriter v0.0.5
+)
+
+require (
+ github.com/Microsoft/go-winio v0.6.2 // indirect
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/aymanbagabas/go-udiff v0.3.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/charmbracelet/colorprofile v0.4.1 // indirect
+ github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
+ github.com/charmbracelet/x/ansi v0.11.6 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
+ github.com/charmbracelet/x/term v0.2.2 // indirect
+ github.com/charmbracelet/x/termios v0.1.1 // indirect
+ github.com/charmbracelet/x/windows v0.2.2 // indirect
+ github.com/clipperhouse/displaywidth v0.9.0 // indirect
+ github.com/clipperhouse/stringish v0.1.1 // indirect
+ github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
+ github.com/containerd/errdefs v1.0.0 // indirect
+ github.com/containerd/errdefs/pkg v0.3.0 // indirect
+ github.com/containerd/log v0.1.0 // indirect
+ github.com/distribution/reference v0.6.0 // indirect
+ github.com/docker/go-connections v0.6.0 // indirect
+ github.com/docker/go-units v0.5.0 // indirect
+ github.com/felixge/httpsnoop v1.0.4 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-runewidth v0.0.19 // indirect
+ github.com/moby/docker-image-spec v1.3.1 // indirect
+ github.com/moby/sys/atomicwriter v0.1.0 // indirect
+ github.com/moby/term v0.5.2 // indirect
+ github.com/morikuni/aec v1.1.0 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/muesli/termenv v0.16.0 // indirect
+ github.com/opencontainers/go-digest v1.0.0 // indirect
+ github.com/opencontainers/image-spec v1.1.1 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 // indirect
+ go.opentelemetry.io/otel v1.41.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 // indirect
+ go.opentelemetry.io/otel/metric v1.41.0 // indirect
+ go.opentelemetry.io/otel/trace v1.41.0 // indirect
+ golang.org/x/sync v0.19.0 // indirect
+ golang.org/x/sys v0.41.0 // indirect
+ golang.org/x/time v0.14.0 // indirect
+ gotest.tools/v3 v3.5.2 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..23c937f
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,157 @@
+charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
+charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
+github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
+github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
+github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
+github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
+github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
+github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
+github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
+github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
+github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
+github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f h1:UytXHv0UxnsDFmL/7Z9Q5SBYPwSuRLXHbwx+6LycZ2w=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
+github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
+github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
+github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
+github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
+github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
+github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
+github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
+github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
+github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
+github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
+github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
+github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
+github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
+github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
+github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
+github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
+github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
+github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
+github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
+github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
+github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=
+github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY=
+github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
+github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
+github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
+github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
+github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
+github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
+github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
+github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
+github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
+github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
+github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
+github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
+github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
+github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
+github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
+github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
+github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 h1:PnV4kVnw0zOmwwFkAzCN5O07fw1YOIQor120zrh0AVo=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0/go.mod h1:ofAwF4uinaf8SXdVzzbL4OsxJ3VfeEg3f/F6CeF49/Y=
+go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
+go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 h1:BEj3SPM81McUZHYjRS5pEgNgnmzGJ5tRpU5krWnV8Bs=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0/go.mod h1:9cKLGBDzI/F3NoHLQGm4ZrYdIHsvGt6ej6hUowxY0J4=
+go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
+go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
+go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
+go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
+go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
+go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
+go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
+go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
+go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
+go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
+golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
+golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
+google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f h1:gap6+3Gk41EItBuyi4XX/bp4oqJ3UwuIMl25yGinuAA=
+google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:Ic02D47M+zbarjYYUlK57y316f2MoN0gjAwI3f2S95o=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50=
+google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A=
+google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
+google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
+google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
+gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
diff --git a/install.sh b/install.sh
deleted file mode 100755
index 394c113..0000000
--- a/install.sh
+++ /dev/null
@@ -1,16 +0,0 @@
-#!/bin/bash
-
-UNAME_CMD="$(uname -m)"
-case "$UNAME_CMD" in
-x86_64) SUFFIX="x86_64" ;;
-aarch64) SUFFIX="aarch64" ;;
-armv6l) SUFFIX="armv6" ;;
-esac
-
-if [ -n "$SUFFIX" ]; then
- OXKER_GZ="oxker_linux_${SUFFIX}.tar.gz"
- curl -L -O "https://github.com/mrjackwills/oxker/releases/latest/download/${OXKER_GZ}"
- tar xzvf "${OXKER_GZ}" oxker
- install -Dm 755 oxker -t "${HOME}/.local/bin"
- rm "${OXKER_GZ}" oxker
-fi
diff --git a/internal/app/app.go b/internal/app/app.go
new file mode 100644
index 0000000..8033a97
--- /dev/null
+++ b/internal/app/app.go
@@ -0,0 +1,3255 @@
+package app
+
+import (
+ "bufio"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "math"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/creack/pty"
+ "github.com/docker/docker/api/types/container"
+ "github.com/hinshun/vt10x"
+ "github.com/oxker/oxker/internal/config"
+ "github.com/oxker/oxker/internal/docker"
+)
+
+// ============================================================
+// TYPES
+// ============================================================
+
+type AppState int
+
+const (
+ StateLoading AppState = iota
+ StateReady
+ StateError
+)
+
+type Panel int
+
+const (
+ PanelContainers Panel = iota
+ PanelLogs
+ PanelCommands
+)
+
+type ContainerState int
+
+const (
+ RunningHealthy ContainerState = iota
+ RunningUnhealthy
+ Paused
+ Exited
+ Dead
+ Restarting
+ Removing
+ Created
+ Unknown
+)
+
+type SortOrder int
+
+const (
+ SortNone SortOrder = iota
+ SortAsc
+ SortDesc
+)
+
+type SortColumn int
+
+const (
+ SortName SortColumn = iota
+ SortState
+ SortStatus
+ SortCPU
+ SortMemory
+ SortID
+ SortImage
+ SortRX
+ SortTX
+)
+
+type FilterBy int
+
+const (
+ FilterByName FilterBy = iota
+ FilterByImage
+ FilterByStatus
+ FilterByAll
+)
+
+func (f FilterBy) String() string {
+ return [...]string{"Name", "Image", "Status", "All"}[f]
+}
+
+type ContainerPorts struct {
+ IP string
+ Private int
+ Public int
+}
+
+type Container struct {
+ ID string
+ Name string
+ Image string
+ State ContainerState
+ StateStr string
+ Status string
+ Ports []ContainerPorts
+ CreatedAt int64
+ CPUPercent float64
+ MemUsage uint64
+ MemLimit uint64
+ RxBytes uint64
+ TxBytes uint64
+ CPUHist []float64
+ MemHist []uint64
+ RxHist []uint64
+ TxHist []uint64
+ // Per-container log storage
+ Logs []string
+ LogScroll int
+ LogHScroll int
+ LogsSince string // timestamp for incremental fetching
+}
+
+// ============================================================
+// MESSAGES
+// ============================================================
+
+type ContainersLoadedMsg struct{ Containers []Container }
+type StatsUpdatedMsg struct {
+ ID string
+ CPU float64
+ MemUsage uint64
+ MemLimit uint64
+ Rx, Tx uint64
+}
+type ContainerActionMsg struct{ Action, ID string; Err error }
+type ErrorMsg struct{ Err error }
+type TickMsg struct{}
+type LogsLoadedMsg struct{ ID string; Lines []string; Since string }
+type InspectLoadedMsg struct{ Data string }
+type InfoDismissMsg struct{}
+type ExecDoneMsg struct{ Err error }
+type ConnectCountdownMsg struct{ SecsLeft int }
+type ExecOutputMsg struct{} // tick to refresh VTE display
+type ExecExitMsg struct{ Err error }
+
+// ============================================================
+// APP MODEL
+// ============================================================
+
+type App struct {
+ // State
+ State AppState
+ CmdMgr *docker.Client
+ Config *config.Config
+ Width int
+ Height int
+ Error error
+
+ // Containers
+ Containers []Container
+ SelectedIdx int
+ SelectedID string // persistent selected container ID
+ ScrollOffset int
+ PrevSelectedID string // track selection changes for log save/restore
+
+ // Panels
+ ActivePanel Panel
+
+ // Logs
+ LogLines []string
+ LogScroll int
+ LogHScroll int
+ LogHeight int // percentage 5-80
+ ShowLogs bool
+
+ // Sorting
+ SortCol SortColumn
+ SortOrd SortOrder
+
+ // Filtering
+ FilterMode bool
+ FilterText string
+ FilterBy FilterBy
+
+ // Log Search
+ SearchMode bool
+ SearchText string
+ SearchMatches []int
+ SearchIdx int
+
+ // Inspect
+ InspectMode bool
+ InspectData string
+ InspectScrollY int
+ InspectScrollX int
+
+ // Delete confirm
+ DeleteConfirm bool
+ DeleteTarget string
+
+ // Help
+ ShowHelp bool
+
+ // Command selection
+ CmdSelectedIdx int
+
+ // Stats dedup — prevent duplicate in-flight stats requests
+ PendingStats map[string]bool
+
+ // Info box (bottom-right, auto-dismiss)
+ InfoText string
+ InfoTimeout *time.Timer
+
+ // Loading animation
+ LoadingIdx int
+
+ // Mouse capture
+ MouseEnabled bool
+
+ // Docker connect error countdown (auto-exit)
+ ConnectCountdown int
+
+ // Embedded exec terminal (PTY + VTE)
+ ExecMode bool
+ ExecPTY *os.File // PTY master fd
+ ExecCmd *exec.Cmd // running docker exec process
+ ExecVT vt10x.Terminal // virtual terminal emulator
+ ExecMu sync.Mutex // protects ExecVT
+ ExecDone chan struct{} // closed when exec process exits
+ ExecContainer string // container name
+ ExecImage string // container image
+ ExecID string // container ID short
+
+ // Exec mouse selection
+ ExecSelecting bool // drag in progress
+ ExecSelActive bool // selection exists (show highlight)
+ ExecSelStartR int // start row (in visible area, 0-based)
+ ExecSelStartC int // start col
+ ExecSelEndR int // end row
+ ExecSelEndC int // end col
+}
+
+func New(cfg ...*config.Config) *App {
+ var c *config.Config
+ if len(cfg) > 0 && cfg[0] != nil {
+ c = cfg[0]
+ } else {
+ c = config.NewConfig()
+ }
+ cli, err := docker.New(c.Host)
+
+ app := &App{
+ State: StateLoading,
+ Config: c,
+ LogHeight: 75,
+ ShowLogs: c.ShowLogs,
+ MouseEnabled: true,
+ }
+
+ if err != nil {
+ app.Error = fmt.Errorf("Docker connect failed: %v", err)
+ app.ConnectCountdown = 5
+ } else if pingErr := cli.Ping(); pingErr != nil {
+ host := c.Host
+ if host == "" { host = "default socket" }
+ app.Error = fmt.Errorf("Cannot connect to Docker at %s: %v", host, pingErr)
+ app.ConnectCountdown = 5
+ } else {
+ app.CmdMgr = cli
+ }
+
+ return app
+}
+
+func (a *App) Init() tea.Cmd {
+ if a.ConnectCountdown > 0 {
+ return tea.Tick(time.Second, func(t time.Time) tea.Msg {
+ return ConnectCountdownMsg{SecsLeft: a.ConnectCountdown}
+ })
+ }
+ return tea.Batch(a.loadContainers(), a.tickCmd())
+}
+
+func (a *App) tickCmd() tea.Cmd {
+ return tea.Tick(time.Duration(a.Config.DockerIntervalMs)*time.Millisecond, func(t time.Time) tea.Msg {
+ return TickMsg{}
+ })
+}
+
+// quit cancels in-flight Docker calls and returns tea.Quit.
+func (a *App) quit() (*App, tea.Cmd) {
+ if a.CmdMgr != nil {
+ a.CmdMgr.Cancel()
+ }
+ return a, tea.Quit
+}
+
+// ============================================================
+// UPDATE
+// ============================================================
+
+func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ // Exec mode: route most messages to exec handler
+ if a.ExecMode {
+ return a.updateExecMode(msg)
+ }
+
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ a.Width = msg.Width
+ a.Height = msg.Height
+ return a, nil
+
+ case tea.KeyPressMsg:
+ return a.handleKey(msg)
+
+ case tea.MouseClickMsg:
+ return a.handleMouseClick(msg)
+
+ case tea.MouseWheelMsg:
+ return a.handleMouseWheel(msg)
+
+ case tea.MouseMotionMsg:
+ // No-op in normal mode
+ return a, nil
+
+ case tea.MouseReleaseMsg:
+ // No-op in normal mode
+ return a, nil
+
+ case ContainersLoadedMsg:
+ return a.handleContainersLoaded(msg)
+
+ case StatsUpdatedMsg:
+ return a.handleStatsUpdated(msg)
+
+ case LogsLoadedMsg:
+ for i := range a.Containers {
+ if a.Containers[i].ID == msg.ID {
+ c := &a.Containers[i]
+ if msg.Since != "" && len(c.Logs) > 0 {
+ // Incremental: append new lines, dedup
+ existing := make(map[string]bool, len(c.Logs))
+ for _, l := range c.Logs {
+ existing[l] = true
+ }
+ for _, l := range msg.Lines {
+ if !existing[l] {
+ c.Logs = append(c.Logs, l)
+ }
+ }
+ } else {
+ c.Logs = msg.Lines
+ }
+ c.LogsSince = time.Now().UTC().Format(time.RFC3339Nano)
+ break
+ }
+ }
+ // Sync to active view if this is the selected container
+ if sel := a.selected(); sel != nil && sel.ID == msg.ID {
+ // Auto-follow: if we were at/near the bottom, scroll to new bottom
+ wasAtBottom := a.LogScroll >= len(a.LogLines) - a.logsVisibleRows() - 2
+ a.LogLines = sel.Logs
+ if wasAtBottom || len(a.LogLines) <= a.logsVisibleRows() {
+ max := len(a.LogLines) - a.logsVisibleRows()
+ if max < 0 { max = 0 }
+ a.LogScroll = max
+ }
+ }
+ return a, nil
+
+ case InspectLoadedMsg:
+ a.InspectData = msg.Data
+ a.InspectMode = true
+ a.InspectScrollX = 0
+ a.InspectScrollY = 0
+ return a, nil
+
+ case ContainerActionMsg:
+ if msg.Err != nil {
+ a.Error = msg.Err
+ } else {
+ dismissCmd := a.setInfo(fmt.Sprintf("%s: success", msg.Action))
+ return a, tea.Batch(a.loadContainers(), dismissCmd)
+ }
+ return a, a.loadContainers()
+
+ case ErrorMsg:
+ a.Error = msg.Err
+ a.State = StateError
+ return a, nil
+
+ case TickMsg:
+ a.LoadingIdx = (a.LoadingIdx + 1) % 10
+ cmds := []tea.Cmd{a.tickCmd(), a.loadContainers()}
+ // Update stats for all running containers
+ cmds = append(cmds, a.updateAllStats()...)
+ return a, tea.Batch(cmds...)
+
+ case InfoDismissMsg:
+ a.InfoText = ""
+ return a, nil
+
+ case ExecDoneMsg:
+ if msg.Err != nil {
+ a.Error = msg.Err
+ }
+ return a, a.loadContainers()
+
+ case ConnectCountdownMsg:
+ a.ConnectCountdown = msg.SecsLeft - 1
+ if a.ConnectCountdown <= 0 {
+ return a.quit()
+ }
+ return a, tea.Tick(time.Second, func(t time.Time) tea.Msg {
+ return ConnectCountdownMsg{SecsLeft: a.ConnectCountdown}
+ })
+ }
+
+ return a, nil
+}
+
+func (a *App) handleContainersLoaded(msg ContainersLoadedMsg) (*App, tea.Cmd) {
+ // Merge stats from existing containers
+ oldMap := make(map[string]*Container)
+ for i := range a.Containers {
+ oldMap[a.Containers[i].ID] = &a.Containers[i]
+ }
+ for i := range msg.Containers {
+ if old, ok := oldMap[msg.Containers[i].ID]; ok {
+ msg.Containers[i].CPUPercent = old.CPUPercent
+ msg.Containers[i].MemUsage = old.MemUsage
+ msg.Containers[i].MemLimit = old.MemLimit
+ msg.Containers[i].RxBytes = old.RxBytes
+ msg.Containers[i].TxBytes = old.TxBytes
+ msg.Containers[i].CPUHist = old.CPUHist
+ msg.Containers[i].MemHist = old.MemHist
+ msg.Containers[i].RxHist = old.RxHist
+ msg.Containers[i].TxHist = old.TxHist
+ // Preserve per-container logs
+ msg.Containers[i].Logs = old.Logs
+ msg.Containers[i].LogScroll = old.LogScroll
+ msg.Containers[i].LogHScroll = old.LogHScroll
+ msg.Containers[i].LogsSince = old.LogsSince
+ }
+ }
+ a.Containers = msg.Containers
+ a.State = StateReady
+ // Use persistent SelectedID to restore selection after sort
+ a.sortContainersWithID(a.SelectedID)
+ return a, a.loadLogsForSelected()
+}
+
+func (a *App) handleStatsUpdated(msg StatsUpdatedMsg) (*App, tea.Cmd) {
+ delete(a.PendingStats, msg.ID)
+ for i := range a.Containers {
+ if a.Containers[i].ID == msg.ID {
+ c := &a.Containers[i]
+ c.CPUPercent = msg.CPU
+ c.MemUsage = msg.MemUsage
+ c.MemLimit = msg.MemLimit
+ c.RxBytes = msg.Rx
+ c.TxBytes = msg.Tx
+ // History (max 60 samples)
+ c.CPUHist = appendMax(c.CPUHist, msg.CPU, 60)
+ c.MemHist = appendMaxU(c.MemHist, msg.MemUsage, 60)
+ c.RxHist = appendMaxU(c.RxHist, msg.Rx, 60)
+ c.TxHist = appendMaxU(c.TxHist, msg.Tx, 60)
+ break
+ }
+ }
+ return a, nil
+}
+
+// ============================================================
+// KEY HANDLING
+// ============================================================
+
+func (a *App) handleKey(msg tea.KeyPressMsg) (*App, tea.Cmd) {
+ // Ctrl+C always quits regardless of mode
+ if msg.String() == "ctrl+c" {
+ return a.quit()
+ }
+
+ // Inspect mode - separate handling
+ if a.InspectMode {
+ return a.handleInspectKey(msg)
+ }
+
+ // Delete confirm
+ if a.DeleteConfirm {
+ return a.handleDeleteConfirmKey(msg)
+ }
+
+ // Error mode - only allow clear/quit
+ if a.Error != nil {
+ k := msg.String()
+ if keyMatch(k, a.Config.Keymap.Clear) || k == "c" || k == "esc" {
+ a.Error = nil
+ a.ConnectCountdown = 0
+ if a.State == StateError {
+ a.State = StateReady
+ }
+ } else if keyMatch(k, a.Config.Keymap.Quit) || k == "q" {
+ return a.quit()
+ }
+ return a, nil
+ }
+
+ // Filter mode
+ if a.FilterMode {
+ return a.handleFilterKey(msg)
+ }
+
+ // Search mode
+ if a.SearchMode {
+ return a.handleSearchKey(msg)
+ }
+
+ // Help overlay
+ if a.ShowHelp {
+ switch msg.String() {
+ case "h", "esc", "q":
+ a.ShowHelp = false
+ }
+ return a, nil
+ }
+
+ key := msg.String()
+ km := &a.Config.Keymap
+
+ // Keymap-aware dispatch: check config keybindings first, fall back to defaults
+ switch {
+ case keyMatch(key, km.Quit) || key == "q":
+ return a.quit()
+
+ // Panel navigation
+ case keyMatch(key, km.SelectNextPanel) || key == "tab":
+ a.nextPanel()
+ case keyMatch(key, km.SelectPreviousPanel) || key == "shift+tab":
+ a.prevPanel()
+
+ // Scroll
+ case keyMatch(key, km.ScrollDown) || key == "j" || key == "down":
+ a.scrollDown(1)
+ return a, a.loadLogsForSelected()
+ case keyMatch(key, km.ScrollUp) || key == "k" || key == "up":
+ a.scrollUp(1)
+ return a, a.loadLogsForSelected()
+ case key == "J":
+ a.scrollDown(10)
+ return a, a.loadLogsForSelected()
+ case key == "K":
+ a.scrollUp(10)
+ return a, a.loadLogsForSelected()
+ case keyMatch(key, km.ScrollStart) || key == "g" || key == "home":
+ a.scrollHome()
+ return a, a.loadLogsForSelected()
+ case keyMatch(key, km.ScrollEnd) || key == "G" || key == "end":
+ a.scrollEnd()
+ return a, a.loadLogsForSelected()
+ case keyMatch(key, km.ScrollForward) || key == "pgdown":
+ a.scrollDown(a.visibleRows())
+ return a, a.loadLogsForSelected()
+ case keyMatch(key, km.ScrollBack) || key == "pgup":
+ a.scrollUp(a.visibleRows())
+ return a, a.loadLogsForSelected()
+
+ // Sorting
+ case keyMatch(key, km.SortByName) || key == "1":
+ a.toggleSort(SortName)
+ case keyMatch(key, km.SortByState) || key == "2":
+ a.toggleSort(SortState)
+ case keyMatch(key, km.SortByStatus) || key == "3":
+ a.toggleSort(SortStatus)
+ case keyMatch(key, km.SortByCPU) || key == "4":
+ a.toggleSort(SortCPU)
+ case keyMatch(key, km.SortByMemory) || key == "5":
+ a.toggleSort(SortMemory)
+ case keyMatch(key, km.SortByID) || key == "6":
+ a.toggleSort(SortID)
+ case keyMatch(key, km.SortByImage) || key == "7":
+ a.toggleSort(SortImage)
+ case keyMatch(key, km.SortByRX) || key == "8":
+ a.toggleSort(SortRX)
+ case keyMatch(key, km.SortByTX) || key == "9":
+ a.toggleSort(SortTX)
+ case keyMatch(key, km.SortReset) || key == "0":
+ a.SortOrd = SortNone
+ a.sortContainers()
+
+ // Filter
+ case keyMatch(key, km.FilterMode) || key == "/":
+ a.FilterMode = true
+ a.FilterText = ""
+
+ // Log search
+ case keyMatch(key, km.LogSearchMode) || key == "#":
+ a.SearchMode = true
+ a.SearchText = ""
+ a.SearchMatches = nil
+
+ // Log panel controls
+ case keyMatch(key, km.LogSectionToggle) || key == "l":
+ a.ShowLogs = !a.ShowLogs
+ case keyMatch(key, km.LogSectionHeightIncrease) || key == "]":
+ if a.LogHeight < 80 {
+ a.LogHeight += 5
+ }
+ case keyMatch(key, km.LogSectionHeightDecrease) || key == "[":
+ if a.LogHeight > 5 {
+ a.LogHeight -= 5
+ }
+
+ // Log horizontal scroll
+ case key == "right":
+ if a.ActivePanel == PanelLogs {
+ a.LogHScroll += 10
+ }
+ case key == "left":
+ if a.ActivePanel == PanelLogs {
+ a.LogHScroll -= 10
+ if a.LogHScroll < 0 {
+ a.LogHScroll = 0
+ }
+ }
+
+ // Container actions
+ case key == "enter":
+ return a, a.execSelectedCommand()
+ case key == "s":
+ return a, a.doAction((*docker.Client).StartContainer, "start")
+ case key == "x":
+ return a, a.doAction((*docker.Client).StopContainer, "stop")
+ case key == "r":
+ return a, a.doAction((*docker.Client).RestartContainer, "restart")
+ case key == "d":
+ a.confirmDelete()
+ case key == "p":
+ return a, a.doAction((*docker.Client).PauseContainer, "pause")
+ case key == "u":
+ return a, a.doAction((*docker.Client).UnpauseContainer, "unpause")
+
+ // Docker exec
+ case keyMatch(key, km.Exec) || key == "e":
+ return a, a.execIntoContainer()
+
+ // Inspect
+ case keyMatch(key, km.Inspect) || key == "i":
+ return a, a.inspectSelected()
+
+ // Save logs
+ case keyMatch(key, km.SaveLogs) || key == "ctrl+s" || key == "S":
+ return a, a.saveLogs()
+
+ // Help
+ case keyMatch(key, km.ToggleHelp) || key == "h":
+ a.ShowHelp = true
+
+ // Force redraw
+ case keyMatch(key, km.ForceRedraw) || key == "f":
+ return a, tea.ClearScreen
+
+ // Toggle mouse capture (controlled via View return)
+ case keyMatch(key, km.ToggleMouseCapture) || key == "m":
+ a.MouseEnabled = !a.MouseEnabled
+
+ // Clear
+ case keyMatch(key, km.Clear) || key == "esc":
+ a.ShowHelp = false
+ }
+
+ return a, nil
+}
+
+func (a *App) handleInspectKey(msg tea.KeyPressMsg) (*App, tea.Cmd) {
+ switch msg.String() {
+ case "esc", "i", "q":
+ a.InspectMode = false
+ case "j", "down":
+ a.InspectScrollY++
+ a.clampInspectScroll()
+ case "k", "up":
+ if a.InspectScrollY > 0 {
+ a.InspectScrollY--
+ }
+ case "l", "right":
+ a.InspectScrollX += 5
+ case "h", "left":
+ a.InspectScrollX -= 5
+ if a.InspectScrollX < 0 {
+ a.InspectScrollX = 0
+ }
+ case "g", "home":
+ a.InspectScrollY = 0
+ a.InspectScrollX = 0
+ case "G", "end":
+ lines := strings.Count(a.InspectData, "\n")
+ a.InspectScrollY = lines
+ a.clampInspectScroll()
+ }
+ return a, nil
+}
+
+func (a *App) handleDeleteConfirmKey(msg tea.KeyPressMsg) (*App, tea.Cmd) {
+ k := msg.String()
+ km := &a.Config.Keymap
+ switch {
+ case keyMatch(k, km.DeleteConfirm) || k == "y" || k == "enter":
+ a.DeleteConfirm = false
+ id := a.DeleteTarget
+ if id == "" || a.CmdMgr == nil {
+ return a, nil
+ }
+ return a, func() tea.Msg {
+ err := a.CmdMgr.DeleteContainer(id, true)
+ return ContainerActionMsg{Action: "delete", ID: id, Err: err}
+ }
+ case keyMatch(k, km.DeleteDeny) || k == "n" || k == "esc":
+ a.DeleteConfirm = false
+ a.DeleteTarget = ""
+ }
+ return a, nil
+}
+
+func (a *App) handleFilterKey(msg tea.KeyPressMsg) (*App, tea.Cmd) {
+ switch msg.String() {
+ case "esc":
+ a.FilterMode = false
+ a.FilterText = ""
+ case "enter", "/":
+ a.FilterMode = false
+ case "backspace":
+ if len(a.FilterText) > 0 {
+ a.FilterText = a.FilterText[:len(a.FilterText)-1]
+ }
+ case "ctrl+u":
+ a.FilterText = ""
+ case "left":
+ if a.FilterBy > 0 {
+ a.FilterBy--
+ }
+ case "right":
+ if a.FilterBy < FilterByAll {
+ a.FilterBy++
+ }
+ default:
+ if msg.Text != "" {
+ a.FilterText += msg.Text
+ }
+ }
+ return a, nil
+}
+
+func (a *App) handleSearchKey(msg tea.KeyPressMsg) (*App, tea.Cmd) {
+ switch msg.String() {
+ case "esc":
+ a.SearchMode = false
+ a.SearchText = ""
+ a.SearchMatches = nil
+ case "enter", "#":
+ a.SearchMode = false
+ case "backspace":
+ if len(a.SearchText) > 0 {
+ a.SearchText = a.SearchText[:len(a.SearchText)-1]
+ a.updateSearchMatches()
+ }
+ case "ctrl+n", "down":
+ if len(a.SearchMatches) > 0 {
+ a.SearchIdx = (a.SearchIdx + 1) % len(a.SearchMatches)
+ a.LogScroll = a.SearchMatches[a.SearchIdx]
+ }
+ case "ctrl+p", "up":
+ if len(a.SearchMatches) > 0 {
+ a.SearchIdx--
+ if a.SearchIdx < 0 {
+ a.SearchIdx = len(a.SearchMatches) - 1
+ }
+ a.LogScroll = a.SearchMatches[a.SearchIdx]
+ }
+ case "right":
+ a.LogHScroll += 10
+ case "left":
+ a.LogHScroll -= 10
+ if a.LogHScroll < 0 { a.LogHScroll = 0 }
+ default:
+ if msg.Text != "" {
+ a.SearchText += msg.Text
+ a.updateSearchMatches()
+ }
+ }
+ return a, nil
+}
+
+// ============================================================
+// MOUSE HANDLING
+// ============================================================
+
+func (a *App) handleMouseWheel(msg tea.MouseWheelMsg) (*App, tea.Cmd) {
+ // Block mouse in overlay modes
+ if a.DeleteConfirm || a.ShowHelp || a.FilterMode || a.SearchMode || a.Error != nil {
+ return a, nil
+ }
+
+ // Inspect mode: route scroll to inspect panel
+ if a.InspectMode {
+ if msg.Button == tea.MouseWheelUp {
+ if a.InspectScrollY > 0 {
+ a.InspectScrollY--
+ }
+ } else if msg.Button == tea.MouseWheelDown {
+ a.InspectScrollY++
+ }
+ return a, nil
+ }
+
+ if msg.Button == tea.MouseWheelUp {
+ a.scrollUp(1)
+ return a, a.loadLogsForSelected()
+ } else if msg.Button == tea.MouseWheelDown {
+ a.scrollDown(1)
+ return a, a.loadLogsForSelected()
+ }
+ return a, nil
+}
+
+func (a *App) handleMouseClick(msg tea.MouseClickMsg) (*App, tea.Cmd) {
+ // Block mouse in overlay modes
+ if a.DeleteConfirm || a.ShowHelp || a.FilterMode || a.SearchMode || a.Error != nil {
+ return a, nil
+ }
+ if a.InspectMode {
+ return a, nil
+ }
+ x, y := msg.X, msg.Y
+
+ // Header row (y == 0) → sort by column
+ if y == 0 {
+ col := a.headerColumnAt(x)
+ if col >= 0 {
+ a.toggleSort(SortColumn(col))
+ }
+ return a, nil
+ }
+
+ // Container area
+ containerH := a.containerAreaHeight()
+ cmdW := a.Width * 10 / 100
+ if cmdW < 12 { cmdW = 12 }
+ tableW := a.Width - cmdW - 4
+
+ if y >= 1 && y <= containerH {
+ if x < tableW+2 {
+ // Click in container table → select container
+ row := y - 3 + a.ScrollOffset // 3 = header + border + title
+ if row >= 0 && row < len(a.filtered()) {
+ a.SelectedIdx = row
+ a.CmdSelectedIdx = 0
+ a.syncSelectedID()
+ a.ensureVisible()
+ return a, a.loadLogsForSelected()
+ }
+ } else {
+ // Click in commands panel
+ a.ActivePanel = PanelCommands
+ }
+ return a, nil
+ }
+
+ // Logs area
+ logsStart := 1 + containerH
+ logsEnd := logsStart + a.logsAreaHeight()
+ if y >= logsStart && y < logsEnd {
+ a.ActivePanel = PanelLogs
+ return a, nil
+ }
+
+ return a, nil
+}
+
+func (a *App) headerColumnAt(x int) int {
+ cw := a.colWidths()
+ cols := []int{3, cw.name, cw.state, cw.status, cw.cpu, cw.mem, cw.id, cw.image, cw.rx, cw.tx}
+ pos := 0
+ for i, w := range cols {
+ pos += w
+ if x < pos {
+ if i == 0 {
+ return -1 // indicator column
+ }
+ return i - 1 // SortColumn index
+ }
+ }
+ return -1
+}
+
+// ============================================================
+// NAVIGATION & SCROLL
+// ============================================================
+
+func (a *App) nextPanel() {
+ if a.ShowLogs {
+ a.ActivePanel = Panel((int(a.ActivePanel) + 1) % 3)
+ } else {
+ if a.ActivePanel == PanelContainers {
+ a.ActivePanel = PanelCommands
+ } else {
+ a.ActivePanel = PanelContainers
+ }
+ }
+}
+
+func (a *App) prevPanel() {
+ if a.ShowLogs {
+ p := int(a.ActivePanel) - 1
+ if p < 0 {
+ p = 2
+ }
+ a.ActivePanel = Panel(p)
+ } else {
+ if a.ActivePanel == PanelContainers {
+ a.ActivePanel = PanelCommands
+ } else {
+ a.ActivePanel = PanelContainers
+ }
+ }
+}
+
+func (a *App) scrollDown(n int) {
+ if a.ActivePanel == PanelCommands {
+ c := a.selected()
+ if c == nil { return }
+ cmds := commandsForState(c.State)
+ a.CmdSelectedIdx += n
+ if a.CmdSelectedIdx >= len(cmds) {
+ a.CmdSelectedIdx = len(cmds) - 1
+ }
+ if a.CmdSelectedIdx < 0 { a.CmdSelectedIdx = 0 }
+ return
+ }
+ if a.ActivePanel == PanelLogs {
+ a.LogScroll += n
+ max := len(a.LogLines) - a.logsVisibleRows()
+ if max < 0 { max = 0 }
+ if a.LogScroll > max { a.LogScroll = max }
+ } else {
+ cs := a.filtered()
+ a.SelectedIdx += n
+ if a.SelectedIdx >= len(cs) {
+ a.SelectedIdx = len(cs) - 1
+ }
+ if a.SelectedIdx < 0 { a.SelectedIdx = 0 }
+ a.CmdSelectedIdx = 0
+ a.syncSelectedID()
+ a.ensureVisible()
+ }
+}
+
+func (a *App) scrollUp(n int) {
+ if a.ActivePanel == PanelCommands {
+ a.CmdSelectedIdx -= n
+ if a.CmdSelectedIdx < 0 { a.CmdSelectedIdx = 0 }
+ return
+ }
+ if a.ActivePanel == PanelLogs {
+ a.LogScroll -= n
+ if a.LogScroll < 0 { a.LogScroll = 0 }
+ } else {
+ a.SelectedIdx -= n
+ if a.SelectedIdx < 0 { a.SelectedIdx = 0 }
+ a.CmdSelectedIdx = 0
+ a.syncSelectedID()
+ a.ensureVisible()
+ }
+}
+
+func (a *App) scrollHome() {
+ if a.ActivePanel == PanelLogs {
+ a.LogScroll = 0
+ } else {
+ a.SelectedIdx = 0
+ a.ScrollOffset = 0
+ a.syncSelectedID()
+ }
+}
+
+func (a *App) scrollEnd() {
+ if a.ActivePanel == PanelLogs {
+ max := len(a.LogLines) - a.logsVisibleRows()
+ if max < 0 { max = 0 }
+ a.LogScroll = max
+ } else {
+ cs := a.filtered()
+ if len(cs) > 0 {
+ a.SelectedIdx = len(cs) - 1
+ a.syncSelectedID()
+ a.ensureVisible()
+ }
+ }
+}
+
+func (a *App) ensureVisible() {
+ vis := a.containerVisibleRows()
+ if a.SelectedIdx < a.ScrollOffset {
+ a.ScrollOffset = a.SelectedIdx
+ }
+ if a.SelectedIdx >= a.ScrollOffset+vis {
+ a.ScrollOffset = a.SelectedIdx - vis + 1
+ }
+}
+
+func (a *App) visibleRows() int {
+ return a.containerVisibleRows()
+}
+
+func (a *App) clampInspectScroll() {
+ total := strings.Count(a.InspectData, "\n") + 1
+ vis := a.Height - 4
+ if vis < 1 { vis = 1 }
+ max := total - vis
+ if max < 0 { max = 0 }
+ if a.InspectScrollY > max { a.InspectScrollY = max }
+ if a.InspectScrollY < 0 { a.InspectScrollY = 0 }
+}
+
+// ============================================================
+// SORTING
+// ============================================================
+
+func (a *App) toggleSort(col SortColumn) {
+ if a.SortCol == col {
+ switch a.SortOrd {
+ case SortNone:
+ a.SortOrd = SortAsc
+ case SortAsc:
+ a.SortOrd = SortDesc
+ case SortDesc:
+ a.SortOrd = SortNone
+ }
+ } else {
+ a.SortCol = col
+ a.SortOrd = SortAsc
+ }
+ a.sortContainers()
+}
+
+func (a *App) sortContainers() {
+ var selectedID string
+ if sel := a.selected(); sel != nil {
+ selectedID = sel.ID
+ }
+ a.sortContainersWithID(selectedID)
+}
+
+func (a *App) sortContainersWithID(selectedID string) {
+ cs := a.Containers
+ less := func(i, j int) bool {
+ var cmp int
+ if a.SortOrd == SortNone {
+ // Default sort: by CreatedAt, Name as tiebreaker
+ cmp = compareInt64(cs[i].CreatedAt, cs[j].CreatedAt)
+ } else {
+ switch a.SortCol {
+ case SortName:
+ cmp = strings.Compare(strings.ToLower(cs[i].Name), strings.ToLower(cs[j].Name))
+ case SortState:
+ cmp = int(stateOrder(cs[i].State)) - int(stateOrder(cs[j].State))
+ case SortStatus:
+ cmp = strings.Compare(cs[i].Status, cs[j].Status)
+ case SortCPU:
+ cmp = compareFloat(cs[i].CPUPercent, cs[j].CPUPercent)
+ case SortMemory:
+ cmp = compareUint(cs[i].MemUsage, cs[j].MemUsage)
+ case SortID:
+ cmp = strings.Compare(cs[i].ID, cs[j].ID)
+ case SortImage:
+ cmp = strings.Compare(strings.ToLower(cs[i].Image), strings.ToLower(cs[j].Image))
+ case SortRX:
+ cmp = compareUint(cs[i].RxBytes, cs[j].RxBytes)
+ case SortTX:
+ cmp = compareUint(cs[i].TxBytes, cs[j].TxBytes)
+ }
+ }
+ // Tiebreaker: sort by name
+ if cmp == 0 {
+ cmp = strings.Compare(strings.ToLower(cs[i].Name), strings.ToLower(cs[j].Name))
+ }
+ if a.SortOrd == SortDesc {
+ return cmp > 0
+ }
+ return cmp < 0
+ }
+ // Simple insertion sort (few items, stable)
+ for i := 1; i < len(cs); i++ {
+ for j := i; j > 0 && less(j, j-1); j-- {
+ cs[j], cs[j-1] = cs[j-1], cs[j]
+ }
+ }
+ // Restore selection by ID (H8)
+ if selectedID != "" {
+ for i, c := range cs {
+ if c.ID == selectedID {
+ a.SelectedIdx = i
+ break
+ }
+ }
+ }
+ // Clamp and sync SelectedID
+ if a.SelectedIdx >= len(cs) {
+ a.SelectedIdx = len(cs) - 1
+ }
+ if a.SelectedIdx < 0 {
+ a.SelectedIdx = 0
+ }
+ a.syncSelectedID()
+}
+
+// stateOrder returns semantic sort order matching Rust (H10)
+func stateOrder(s ContainerState) int {
+ switch s {
+ case RunningHealthy: return 0
+ case RunningUnhealthy: return 1
+ case Paused: return 2
+ case Restarting: return 3
+ case Removing: return 4
+ case Exited: return 5
+ case Dead: return 6
+ case Created: return 7
+ case Unknown: return 8
+ default: return 9
+ }
+}
+
+func compareInt64(a, b int64) int {
+ if a < b { return -1 }
+ if a > b { return 1 }
+ return 0
+}
+
+// ============================================================
+// SEARCH
+// ============================================================
+
+func (a *App) updateSearchMatches() {
+ a.SearchMatches = nil
+ a.SearchIdx = 0
+ if a.SearchText == "" {
+ return
+ }
+ caseSensitive := a.Config != nil && a.Config.LogSearchCaseSensitive
+ term := a.SearchText
+ if !caseSensitive {
+ term = strings.ToLower(term)
+ }
+ for i, line := range a.LogLines {
+ haystack := line
+ if !caseSensitive {
+ haystack = strings.ToLower(haystack)
+ }
+ if strings.Contains(haystack, term) {
+ a.SearchMatches = append(a.SearchMatches, i)
+ }
+ }
+}
+
+// ============================================================
+// CONTAINER ACTIONS
+// ============================================================
+
+func (a *App) selected() *Container {
+ cs := a.filtered()
+ if a.SelectedIdx >= 0 && a.SelectedIdx < len(cs) {
+ id := cs[a.SelectedIdx].ID
+ for i := range a.Containers {
+ if a.Containers[i].ID == id {
+ return &a.Containers[i]
+ }
+ }
+ }
+ return nil
+}
+
+// syncSelectedID updates SelectedID to match current SelectedIdx
+func (a *App) syncSelectedID() {
+ cs := a.filtered()
+ if a.SelectedIdx >= 0 && a.SelectedIdx < len(cs) {
+ a.SelectedID = cs[a.SelectedIdx].ID
+ }
+}
+
+// syncLogSelection saves scroll state to old container and restores from new one
+func (a *App) syncLogSelection() {
+ sel := a.selected()
+ newID := ""
+ if sel != nil { newID = sel.ID }
+
+ if newID == a.PrevSelectedID { return }
+
+ // Save scroll position to previous container
+ if a.PrevSelectedID != "" {
+ for i := range a.Containers {
+ if a.Containers[i].ID == a.PrevSelectedID {
+ a.Containers[i].LogScroll = a.LogScroll
+ a.Containers[i].LogHScroll = a.LogHScroll
+ break
+ }
+ }
+ }
+
+ // Restore from new container
+ if sel != nil {
+ a.LogLines = sel.Logs
+ a.LogScroll = sel.LogScroll
+ a.LogHScroll = sel.LogHScroll
+ } else {
+ a.LogLines = nil
+ a.LogScroll = 0
+ a.LogHScroll = 0
+ }
+ a.PrevSelectedID = newID
+}
+
+func (a *App) doAction(fn func(*docker.Client, string) error, action string) tea.Cmd {
+ c := a.selected()
+ if c == nil || a.CmdMgr == nil { return nil }
+ id := c.ID
+ mgr := a.CmdMgr
+ return func() tea.Msg {
+ err := fn(mgr, id)
+ return ContainerActionMsg{Action: action, ID: id, Err: err}
+ }
+}
+
+func (a *App) confirmDelete() {
+ c := a.selected()
+ if c == nil { return }
+ a.DeleteConfirm = true
+ a.DeleteTarget = c.ID
+}
+
+func (a *App) execSelectedCommand() tea.Cmd {
+ c := a.selected()
+ if c == nil { return nil }
+ cmds := commandsForState(c.State)
+ if len(cmds) == 0 { return nil }
+ idx := a.CmdSelectedIdx
+ if idx >= len(cmds) { idx = 0 }
+ cmd := cmds[idx]
+ switch cmd {
+ case "start":
+ return a.doAction((*docker.Client).StartContainer, "start")
+ case "stop":
+ return a.doAction((*docker.Client).StopContainer, "stop")
+ case "pause":
+ return a.doAction((*docker.Client).PauseContainer, "pause")
+ case "resume":
+ return a.doAction((*docker.Client).UnpauseContainer, "resume")
+ case "restart":
+ return a.doAction((*docker.Client).RestartContainer, "restart")
+ case "delete":
+ a.confirmDelete()
+ }
+ return nil
+}
+
+func (a *App) inspectSelected() tea.Cmd {
+ c := a.selected()
+ if c == nil || a.CmdMgr == nil { return nil }
+ id := c.ID
+ mgr := a.CmdMgr
+ return func() tea.Msg {
+ details, err := mgr.InspectContainer(id)
+ if err != nil {
+ return ErrorMsg{Err: err}
+ }
+ data, _ := json.MarshalIndent(details, "", " ")
+ return InspectLoadedMsg{Data: string(data)}
+ }
+}
+
+func (a *App) saveLogs() tea.Cmd {
+ c := a.selected()
+ if c == nil { return nil }
+ name := c.Name
+ lines := a.LogLines
+ saveDir := a.Config.DirSave
+ return func() tea.Msg {
+ filename := fmt.Sprintf("oxker_%s_%s.log", name, time.Now().Format("20060102_150405"))
+ if saveDir != "" {
+ filename = filepath.Join(saveDir, filename)
+ }
+ var sb strings.Builder
+ for _, l := range lines {
+ sb.WriteString(l + "\n")
+ }
+ if err := os.WriteFile(filename, []byte(sb.String()), 0644); err != nil {
+ return ErrorMsg{Err: err}
+ }
+ return ContainerActionMsg{Action: fmt.Sprintf("saved to %s", filename), Err: nil}
+ }
+}
+
+func (a *App) execIntoContainer() tea.Cmd {
+ c := a.selected()
+ if c == nil {
+ return nil
+ }
+ if c.State != RunningHealthy && c.State != RunningUnhealthy {
+ return a.setInfo("can only exec into running containers")
+ }
+
+ id := c.ID
+ shortID := id
+ if len(shortID) > 12 { shortID = shortID[:12] }
+
+ w, h := a.execPanelSize()
+ cmd := exec.Command("docker", "exec", "-it", id, "sh")
+
+ // Start with PTY
+ ptmx, err := pty.Start(cmd)
+ if err != nil {
+ return a.setInfo(fmt.Sprintf("exec failed: %v", err))
+ }
+ _ = pty.Setsize(ptmx, &pty.Winsize{Rows: uint16(h), Cols: uint16(w)})
+
+ // Create VTE with matching size
+ vt := vt10x.New(vt10x.WithSize(w, h), vt10x.WithWriter(ptmx))
+
+ a.ExecMode = true
+ a.ExecPTY = ptmx
+ a.ExecCmd = cmd
+ a.ExecVT = vt
+ a.ExecDone = make(chan struct{})
+ a.ExecContainer = c.Name
+ a.ExecImage = c.Image
+ a.ExecID = shortID
+ a.ExecSelActive = false
+ a.ExecSelecting = false
+
+ // Start VTE parser in background — it reads PTY and updates the screen buffer.
+ // When PTY closes (shell exits), signal via ExecDone.
+ done := a.ExecDone
+ go func() {
+ defer close(done)
+ br := bufio.NewReader(ptmx)
+ for {
+ if err := vt.Parse(br); err != nil {
+ return
+ }
+ }
+ // Also wait for the process to finish
+ }()
+
+ // Tick to refresh the exec view
+ return a.execTick()
+}
+
+func (a *App) execTick() tea.Cmd {
+ return tea.Tick(50*time.Millisecond, func(t time.Time) tea.Msg {
+ return ExecOutputMsg{}
+ })
+}
+
+func (a *App) execPanelSize() (w, h int) {
+ w = a.Width * 70 / 100
+ if w < 40 { w = 40 }
+ w -= 2 // subtract border columns
+ h = a.Height * 70 / 100
+ if h < 10 { h = 10 }
+ h -= 2 // subtract border rows (top + bottom)
+ return
+}
+
+func (a *App) execClose() {
+ if a.ExecPTY != nil {
+ a.ExecPTY.Close()
+ a.ExecPTY = nil
+ }
+ if a.ExecCmd != nil && a.ExecCmd.Process != nil {
+ a.ExecCmd.Process.Kill()
+ a.ExecCmd.Wait()
+ a.ExecCmd = nil
+ }
+ a.ExecVT = nil
+ a.ExecDone = nil
+ a.ExecMode = false
+}
+
+func (a *App) execWriteKey(msg tea.KeyPressMsg) {
+ if a.ExecPTY == nil { return }
+
+ var b []byte
+
+ // Check for ctrl+key combinations first
+ if msg.Mod&tea.ModCtrl != 0 {
+ switch msg.Code {
+ case 'c':
+ b = []byte{3}
+ case 'd':
+ b = []byte{4}
+ case 'z':
+ b = []byte{26}
+ case 'l':
+ b = []byte{12}
+ case 'a':
+ b = []byte{1}
+ case 'e':
+ b = []byte{5}
+ case 'u':
+ b = []byte{21}
+ case 'k':
+ b = []byte{11}
+ case 'w':
+ b = []byte{23}
+ }
+ } else {
+ switch msg.Code {
+ case tea.KeyEnter:
+ b = []byte{'\r'}
+ case tea.KeyTab:
+ b = []byte{'\t'}
+ case tea.KeyBackspace:
+ b = []byte{127}
+ case tea.KeyEscape:
+ b = []byte{27}
+ case tea.KeyUp:
+ b = []byte{27, '[', 'A'}
+ case tea.KeyDown:
+ b = []byte{27, '[', 'B'}
+ case tea.KeyRight:
+ b = []byte{27, '[', 'C'}
+ case tea.KeyLeft:
+ b = []byte{27, '[', 'D'}
+ case tea.KeySpace:
+ b = []byte{' '}
+ default:
+ // Regular character input
+ if msg.Text != "" {
+ b = []byte(msg.Text)
+ } else if msg.Code > 0 && msg.Code < 128 {
+ b = []byte{byte(msg.Code)}
+ }
+ }
+ }
+ if len(b) > 0 {
+ a.ExecPTY.Write(b)
+ }
+}
+
+// execPanelOrigin returns the top-left screen position of the content area
+func (a *App) execPanelOrigin() (x, y int) {
+ w, h := a.execPanelSize()
+ panelW := w + 2
+ panelH := h + 2
+ x = (a.Width - panelW) / 2
+ y = (a.Height - panelH) / 2
+ x += 1
+ y += 1
+ return
+}
+
+// execMouseToContent maps screen mouse position to VTE row/col
+func (a *App) execMouseToContent(mx, my int) (row, col int) {
+ ox, oy := a.execPanelOrigin()
+ w, h := a.execPanelSize()
+ col = mx - ox
+ row = my - oy
+ if col < 0 || col >= w || row < 0 || row >= h {
+ return -1, -1
+ }
+ return row, col
+}
+
+// execGetVisibleLines reads the VTE screen buffer into strings
+func (a *App) execGetVisibleLines() []string {
+ if a.ExecVT == nil {
+ return nil
+ }
+ a.ExecVT.Lock()
+ defer a.ExecVT.Unlock()
+ cols, rows := a.ExecVT.Size()
+ lines := make([]string, rows)
+ for y := 0; y < rows; y++ {
+ var sb strings.Builder
+ for x := 0; x < cols; x++ {
+ g := a.ExecVT.Cell(x, y)
+ if g.Char == 0 {
+ sb.WriteRune(' ')
+ } else {
+ sb.WriteRune(g.Char)
+ }
+ }
+ lines[y] = strings.TrimRight(sb.String(), " ")
+ }
+ return lines
+}
+
+// execSelectedText extracts text from the selection range
+func (a *App) execSelectedText(visible []string, w int) string {
+ if !a.ExecSelActive {
+ return ""
+ }
+ sr, sc, er, ec := a.ExecSelStartR, a.ExecSelStartC, a.ExecSelEndR, a.ExecSelEndC
+ if sr > er || (sr == er && sc > ec) {
+ sr, sc, er, ec = er, ec, sr, sc
+ }
+
+ var sb strings.Builder
+ for r := sr; r <= er && r < len(visible); r++ {
+ runes := []rune(visible[r])
+ if len(runes) > w {
+ runes = runes[:w]
+ }
+ cStart := 0
+ cEnd := len(runes)
+ if r == sr { cStart = sc }
+ if r == er { cEnd = ec + 1 }
+ if cStart > len(runes) { cStart = len(runes) }
+ if cEnd > len(runes) { cEnd = len(runes) }
+ if cStart < cEnd {
+ sb.WriteString(string(runes[cStart:cEnd]))
+ }
+ if r < er {
+ sb.WriteString("\n")
+ }
+ }
+ return sb.String()
+}
+
+// execCopySelection copies the mouse-selected text to clipboard
+func (a *App) execCopySelection() tea.Cmd {
+ w, _ := a.execPanelSize()
+ visible := a.execGetVisibleLines()
+ text := a.execSelectedText(visible, w)
+ if text == "" {
+ return nil
+ }
+ return a.copyToClipboard(text)
+}
+
+// copyToClipboard sends text to system clipboard
+func (a *App) copyToClipboard(text string) tea.Cmd {
+ return func() tea.Msg {
+ // Try native clipboard tools first
+ for _, cmd := range [][]string{
+ {"wl-copy"},
+ {"xclip", "-selection", "clipboard"},
+ {"xsel", "--clipboard", "--input"},
+ } {
+ c := exec.Command(cmd[0], cmd[1:]...)
+ c.Stdin = strings.NewReader(text)
+ if err := c.Run(); err == nil {
+ return ContainerActionMsg{Action: "copied to clipboard"}
+ }
+ }
+ // Fallback: OSC 52 escape sequence (works in most modern terminals)
+ encoded := base64.StdEncoding.EncodeToString([]byte(text))
+ fmt.Fprintf(os.Stderr, "\x1b]52;c;%s\x07", encoded)
+ return ContainerActionMsg{Action: "copied to clipboard (OSC 52)"}
+ }
+}
+
+func (a *App) updateExecMode(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case ContainerActionMsg:
+ return a, a.setInfo(msg.Action)
+
+ case InfoDismissMsg:
+ a.InfoText = ""
+ return a, nil
+
+ case ExecOutputMsg:
+ // VTE parses in background; just keep ticking to refresh the view
+ if a.ExecMode {
+ // Check if the exec process has exited
+ select {
+ case <-a.ExecDone:
+ a.execClose()
+ return a, nil
+ default:
+ }
+ return a, a.execTick()
+ }
+ return a, nil
+
+ case ExecExitMsg:
+ a.execClose()
+ return a, nil
+
+ case tea.WindowSizeMsg:
+ a.Width = msg.Width
+ a.Height = msg.Height
+ // Resize VTE + PTY to match new panel size
+ if a.ExecVT != nil && a.ExecPTY != nil {
+ w, h := a.execPanelSize()
+ a.ExecVT.Lock()
+ a.ExecVT.Resize(w, h)
+ a.ExecVT.Unlock()
+ _ = pty.Setsize(a.ExecPTY, &pty.Winsize{Rows: uint16(h), Cols: uint16(w)})
+ }
+ return a, nil
+
+ case tea.KeyPressMsg:
+ // Clear selection on any keypress
+ a.ExecSelActive = false
+ a.ExecSelecting = false
+ // Ctrl+Q exits exec mode
+ if msg.Code == 'q' && msg.Mod&tea.ModCtrl != 0 {
+ a.execClose()
+ return a, nil
+ }
+ // Forward all keys directly to PTY
+ a.execWriteKey(msg)
+ return a, nil
+
+ case tea.MouseClickMsg:
+ if msg.Button == tea.MouseLeft {
+ r, c := a.execMouseToContent(msg.X, msg.Y)
+ if r >= 0 {
+ a.ExecSelecting = true
+ a.ExecSelActive = true
+ a.ExecSelStartR = r
+ a.ExecSelStartC = c
+ a.ExecSelEndR = r
+ a.ExecSelEndC = c
+ }
+ }
+ return a, nil
+
+ case tea.MouseMotionMsg:
+ if a.ExecSelecting {
+ r, c := a.execMouseToContent(msg.X, msg.Y)
+ if r >= 0 {
+ a.ExecSelEndR = r
+ a.ExecSelEndC = c
+ }
+ }
+ return a, nil
+
+ case tea.MouseReleaseMsg:
+ if a.ExecSelecting {
+ a.ExecSelecting = false
+ r, c := a.execMouseToContent(msg.X, msg.Y)
+ if r >= 0 {
+ a.ExecSelEndR = r
+ a.ExecSelEndC = c
+ }
+ if a.ExecSelStartR == a.ExecSelEndR && a.ExecSelStartC == a.ExecSelEndC {
+ a.ExecSelActive = false
+ } else {
+ return a, a.execCopySelection()
+ }
+ }
+ return a, nil
+ }
+ return a, nil
+}
+
+func (a *App) viewExec() string {
+ w, h := a.execPanelSize()
+
+ // Title bar
+ title := fmt.Sprintf(" exec: %s (%s) [%s] ", a.ExecContainer, a.ExecImage, a.ExecID)
+ hint := " Ctrl+Q to exit "
+
+ // Styles
+ borderColor := lipgloss.NewStyle().Foreground(cGreen).Background(cMantle)
+ titleStyle := lipgloss.NewStyle().Foreground(cPeach).Background(cMantle).Bold(true)
+ hintStyle := lipgloss.NewStyle().Foreground(cOverlay0).Background(cMantle)
+ lineStyle := lipgloss.NewStyle().Foreground(cText).Background(cBase)
+ cursorStyle := lipgloss.NewStyle().Foreground(cBase).Background(cText)
+ selStyle := lipgloss.NewStyle().Foreground(cBase).Background(cBlue)
+
+ // Top border
+ titleLen := len([]rune(title))
+ hintLen := len([]rune(hint))
+ topW := w + 2
+ topAvail := topW - 2 - titleLen
+ if topAvail < 0 { topAvail = 0 }
+ topBorder := borderColor.Render("╭") +
+ borderColor.Render("─") +
+ titleStyle.Render(title) +
+ borderColor.Render(strings.Repeat("─", max(0, topAvail-1))) +
+ borderColor.Render("╮")
+
+ // Bottom border
+ botAvail := topW - 2 - hintLen
+ if botAvail < 0 { botAvail = 0 }
+ botBorder := borderColor.Render("╰") +
+ borderColor.Render("─") +
+ hintStyle.Render(hint) +
+ borderColor.Render(strings.Repeat("─", max(0, botAvail-1))) +
+ borderColor.Render("╯")
+
+ // Read VTE screen buffer
+ var curX, curY int
+ visible := make([]string, h)
+ if a.ExecVT != nil {
+ a.ExecVT.Lock()
+ cur := a.ExecVT.Cursor()
+ curX, curY = cur.X, cur.Y
+ for y := 0; y < h; y++ {
+ var sb strings.Builder
+ for x := 0; x < w; x++ {
+ g := a.ExecVT.Cell(x, y)
+ if g.Char == 0 {
+ sb.WriteRune(' ')
+ } else {
+ sb.WriteRune(g.Char)
+ }
+ }
+ visible[y] = sb.String()
+ }
+ a.ExecVT.Unlock()
+ }
+
+ // Normalize selection bounds
+ selSR, selSC, selER, selEC := a.ExecSelStartR, a.ExecSelStartC, a.ExecSelEndR, a.ExecSelEndC
+ if selSR > selER || (selSR == selER && selSC > selEC) {
+ selSR, selSC, selER, selEC = selER, selEC, selSR, selSC
+ }
+
+ // Render content lines from VTE buffer
+ var content strings.Builder
+ for y := 0; y < h; y++ {
+ var line string
+ if y < len(visible) {
+ line = visible[y]
+ }
+ runes := []rune(line)
+ // Pad to width
+ for len(runes) < w {
+ runes = append(runes, ' ')
+ }
+ if len(runes) > w {
+ runes = runes[:w]
+ }
+
+ // Check if this line has selection or cursor
+ hasSel := a.ExecSelActive && y >= selSR && y <= selER
+ hasCursor := y == curY
+
+ if !hasSel && !hasCursor {
+ // Fast path: render entire line at once
+ content.WriteString(borderColor.Render("│") + lineStyle.Render(string(runes)) + borderColor.Render("│") + "\n")
+ } else {
+ // Char-by-char for cursor and selection
+ var sb strings.Builder
+ for x := 0; x < w; x++ {
+ ch := string(runes[x])
+ isCursor := hasCursor && x == curX
+ inSel := hasSel
+ if inSel {
+ colStart := 0
+ colEnd := w
+ if y == selSR { colStart = selSC }
+ if y == selER { colEnd = selEC + 1 }
+ inSel = x >= colStart && x < colEnd
+ }
+
+ if inSel {
+ sb.WriteString(selStyle.Render(ch))
+ } else if isCursor {
+ sb.WriteString(cursorStyle.Render(ch))
+ } else {
+ sb.WriteString(lineStyle.Render(ch))
+ }
+ }
+ content.WriteString(borderColor.Render("│") + sb.String() + borderColor.Render("│") + "\n")
+ }
+ }
+
+ panel := topBorder + "\n" + content.String() + botBorder
+ bg := a.viewNormal()
+ return placeOverlay(bg, panel, lipgloss.Center, lipgloss.Center, a.Width, a.Height)
+}
+
+var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]`)
+
+func stripAnsi(s string) string {
+ return ansiRegex.ReplaceAllString(s, "")
+}
+
+func (a *App) setInfo(text string) tea.Cmd {
+ a.InfoText = text
+ return tea.Tick(4*time.Second, func(t time.Time) tea.Msg {
+ return InfoDismissMsg{}
+ })
+}
+
+// ============================================================
+// DATA LOADING
+// ============================================================
+
+func (a *App) loadContainers() tea.Cmd {
+ mgr := a.CmdMgr
+ showSelf := a.Config.ShowSelf
+ return func() tea.Msg {
+ if mgr == nil {
+ return ErrorMsg{Err: fmt.Errorf("docker not connected")}
+ }
+ containers, err := mgr.ListContainers(true)
+ if err != nil {
+ return ErrorMsg{Err: err}
+ }
+ result := make([]Container, 0, len(containers))
+ for _, c := range containers {
+ name := ""
+ if len(c.Names) > 0 {
+ name = strings.TrimPrefix(c.Names[0], "/")
+ }
+ // Skip self (oxker container) unless ShowSelf is set
+ if !showSelf && strings.Contains(c.Image, "oxker") {
+ continue
+ }
+ result = append(result, Container{
+ ID: c.ID,
+ Name: name,
+ Image: c.Image,
+ State: mapState(c.State, c.Status),
+ StateStr: c.State,
+ Status: c.Status,
+ Ports: mapPorts(c.Ports),
+ CreatedAt: c.Created,
+ })
+ }
+ return ContainersLoadedMsg{Containers: result}
+ }
+}
+
+func (a *App) updateAllStats() []tea.Cmd {
+ if a.CmdMgr == nil { return nil }
+ if a.PendingStats == nil { a.PendingStats = make(map[string]bool) }
+ var cmds []tea.Cmd
+ mgr := a.CmdMgr
+ for _, c := range a.Containers {
+ if c.State != RunningHealthy && c.State != RunningUnhealthy && c.State != Paused {
+ continue
+ }
+ id := c.ID
+ if a.PendingStats[id] {
+ continue // already in-flight
+ }
+ a.PendingStats[id] = true
+ cmds = append(cmds, func() tea.Msg {
+ stats, err := mgr.ContainerStats(id)
+ if err != nil {
+ return nil // silently ignore stats errors
+ }
+ cpu := calculateCPU(stats)
+ memUsage := stats.MemoryStats.Usage
+ // Subtract cache
+ if cache, ok := stats.MemoryStats.Stats["inactive_file"]; ok {
+ if memUsage > cache {
+ memUsage -= cache
+ }
+ }
+ memLimit := stats.MemoryStats.Limit
+
+ var rx, tx uint64
+ for _, net := range stats.Networks {
+ rx += net.RxBytes
+ tx += net.TxBytes
+ }
+
+ return StatsUpdatedMsg{
+ ID: id, CPU: cpu,
+ MemUsage: memUsage, MemLimit: memLimit,
+ Rx: rx, Tx: tx,
+ }
+ })
+ }
+ return cmds
+}
+
+func (a *App) loadLogsForSelected() tea.Cmd {
+ a.syncLogSelection()
+ c := a.selected()
+ if c == nil || a.CmdMgr == nil { return nil }
+ id := c.ID
+ since := c.LogsSince
+ mgr := a.CmdMgr
+ showStderr := a.Config.ShowStdErr
+ return func() tea.Msg {
+ opts := container.LogsOptions{
+ Follow: false, ShowStdout: true, ShowStderr: showStderr,
+ Timestamps: true,
+ }
+ if since != "" {
+ opts.Since = since
+ } else {
+ opts.Tail = "500"
+ }
+ rc, err := mgr.Logs(id, opts)
+ if err != nil {
+ // Container may have been removed between list and log fetch; silently return empty
+ return LogsLoadedMsg{ID: id, Lines: nil}
+ }
+ defer rc.Close()
+ data, err := io.ReadAll(rc)
+ if err != nil {
+ return LogsLoadedMsg{ID: id, Lines: nil}
+ }
+ raw := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
+ var cleaned []string
+ for _, line := range raw {
+ if len(line) > 8 && (line[0] == 0 || line[0] == 1 || line[0] == 2) {
+ line = line[8:]
+ }
+ if line != "" {
+ cleaned = append(cleaned, line)
+ }
+ }
+ return LogsLoadedMsg{ID: id, Lines: cleaned, Since: since}
+ }
+}
+
+// ============================================================
+// CPU CALCULATION (matching original Rust formula)
+// ============================================================
+
+func calculateCPU(stats *container.StatsResponse) float64 {
+ // Guard against uint64 underflow (saturating_sub equivalent)
+ if stats.CPUStats.CPUUsage.TotalUsage < stats.PreCPUStats.CPUUsage.TotalUsage {
+ return 0
+ }
+ if stats.CPUStats.SystemUsage < stats.PreCPUStats.SystemUsage {
+ return 0
+ }
+ cpuDelta := float64(stats.CPUStats.CPUUsage.TotalUsage - stats.PreCPUStats.CPUUsage.TotalUsage)
+ systemDelta := float64(stats.CPUStats.SystemUsage - stats.PreCPUStats.SystemUsage)
+
+ if cpuDelta <= 0 || systemDelta <= 0 {
+ return 0
+ }
+
+ onlineCPUs := float64(stats.CPUStats.OnlineCPUs)
+ if onlineCPUs == 0 {
+ onlineCPUs = float64(len(stats.CPUStats.CPUUsage.PercpuUsage))
+ }
+ if onlineCPUs == 0 {
+ onlineCPUs = 1
+ }
+
+ return (cpuDelta / systemDelta) * onlineCPUs * 100.0
+}
+
+// ============================================================
+// VIEW
+// ============================================================
+
+// Charm.sh / Catppuccin Mocha inspired color palette
+var (
+ cBase = lipgloss.Color("#1e1e2e")
+ cMantle = lipgloss.Color("#181825")
+ cSurface0 = lipgloss.Color("#313244")
+ cSurface1 = lipgloss.Color("#45475a")
+ cOverlay0 = lipgloss.Color("#6c7086")
+ cText = lipgloss.Color("#cdd6f4")
+ cSubtext0 = lipgloss.Color("#a6adc8")
+ cRed = lipgloss.Color("#f38ba8")
+ cGreen = lipgloss.Color("#a6e3a1")
+ cYellow = lipgloss.Color("#f9e2af")
+ cBlue = lipgloss.Color("#89b4fa")
+ cMauve = lipgloss.Color("#cba6f7")
+ cTeal = lipgloss.Color("#94e2d5")
+ cPeach = lipgloss.Color("#fab387")
+ cLavender = lipgloss.Color("#b4befe")
+ cFlamingo = lipgloss.Color("#f2cdcd")
+)
+
+// colorOr returns the config color if non-empty, otherwise the default
+func colorOr(cfgColor string, def lipgloss.Color) lipgloss.Color {
+ if cfgColor != "" { return lipgloss.Color(cfgColor) }
+ return def
+}
+
+// Resolved colors from config with defaults
+func (a *App) borderSelected() lipgloss.Color { return colorOr(a.Config.AppColors.Borders.Selected, cBlue) }
+func (a *App) borderUnselected() lipgloss.Color { return colorOr(a.Config.AppColors.Borders.Unselected, cSurface0) }
+
+func (a *App) cmdColorCfg(cmd string) lipgloss.Color {
+ cc := a.Config.AppColors.Commands
+ switch cmd {
+ case "start": return colorOr(cc.Start, cGreen)
+ case "resume": return colorOr(cc.Resume, cBlue)
+ case "restart": return colorOr(cc.Restart, cMauve)
+ case "pause": return colorOr(cc.Pause, cYellow)
+ case "stop": return colorOr(cc.Stop, cRed)
+ case "delete": return colorOr(cc.Delete, cOverlay0)
+ default: return cText
+ }
+}
+
+func (a *App) stateColorCfg(s ContainerState) lipgloss.Color {
+ cs := a.Config.AppColors.ContainerState
+ switch s {
+ case RunningHealthy: return colorOr(cs.RunningHealthy, cGreen)
+ case RunningUnhealthy: return colorOr(cs.RunningUnhealthy, cPeach)
+ case Paused: return colorOr(cs.Paused, cYellow)
+ case Exited: return colorOr(cs.Exited, cRed)
+ case Dead: return colorOr(cs.Dead, cRed)
+ case Restarting: return colorOr(cs.Restarting, cTeal)
+ case Removing: return colorOr(cs.Removing, cRed)
+ default: return colorOr(cs.Unknown, cOverlay0)
+ }
+}
+
+func (a *App) View() tea.View {
+ var content string
+ if a.Width == 0 || a.Height == 0 {
+ content = "Loading..."
+ } else if a.Width < 60 || a.Height < 10 {
+ content = a.centeredMsg(fmt.Sprintf("Terminal too small (%dx%d)\nMinimum: 60x10", a.Width, a.Height))
+ } else if a.State == StateLoading {
+ content = a.centeredMsg("Loading Docker containers...")
+ } else if a.ExecMode {
+ content = a.viewExec()
+ } else if a.InspectMode {
+ content = a.viewInspect()
+ } else {
+ content = a.viewNormal()
+ }
+
+ mouseMode := tea.MouseModeCellMotion
+ if !a.MouseEnabled {
+ mouseMode = tea.MouseModeNone
+ }
+
+ return tea.View{
+ Content: content,
+ AltScreen: true,
+ MouseMode: mouseMode,
+ }
+}
+
+func (a *App) viewNormal() string {
+ containerH := a.containerAreaHeight()
+ logsH := a.logsAreaHeight()
+ chartsH := a.chartsAreaHeight()
+
+ // Clamp
+ total := 1 + containerH + logsH + chartsH
+ if total > a.Height {
+ logsH -= total - a.Height
+ if logsH < 3 { logsH = 3 }
+ }
+
+ var sections []string
+ sections = append(sections, a.viewHeader())
+ sections = append(sections, a.viewContainerSection(containerH))
+ if a.ShowLogs {
+ sections = append(sections, a.viewLogs(logsH))
+ }
+ sections = append(sections, a.viewBottom(chartsH))
+
+ result := lipgloss.JoinVertical(lipgloss.Left, sections...)
+
+ // Clamp to terminal height to prevent overflow causing layout jumping
+ lines := strings.Split(result, "\n")
+ if len(lines) > a.Height {
+ lines = lines[:a.Height]
+ result = strings.Join(lines, "\n")
+ }
+
+ // Overlays
+ if a.ShowHelp {
+ result = a.overlayHelp(result)
+ }
+ if a.DeleteConfirm {
+ result = a.overlayDeleteConfirm(result)
+ }
+ if a.Error != nil {
+ result = a.overlayError(result)
+ }
+ if a.InfoText != "" {
+ result = a.overlayInfo(result)
+ }
+
+ return result
+}
+
+func (a *App) centeredMsg(msg string) string {
+ return lipgloss.Place(a.Width, a.Height, lipgloss.Center, lipgloss.Center,
+ lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(cBlue).Padding(1, 3).Render(msg))
+}
+
+// --- Header ---
+
+func (a *App) viewHeader() string {
+ bg := cMantle
+ fg := cText
+ hdr := lipgloss.NewStyle().Background(bg).Foreground(fg)
+ cw := a.colWidths()
+
+ spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
+ spinner := lipgloss.NewStyle().Background(bg).Foreground(cMauve).Render(spinnerFrames[a.LoadingIdx])
+
+ sortIcon := func(col SortColumn) string {
+ if a.SortCol == col && a.SortOrd != SortNone {
+ if a.SortOrd == SortAsc { return " ▲" }
+ return " ▼"
+ }
+ return ""
+ }
+
+ var b strings.Builder
+ b.WriteString(hdr.Width(1).Render(spinner))
+ b.WriteString(hdr.Width(2).Render(" "))
+ b.WriteString(hdr.Width(cw.name).Render("name" + sortIcon(SortName)))
+ b.WriteString(hdr.Width(cw.state).Render("state" + sortIcon(SortState)))
+ b.WriteString(hdr.Width(cw.status).Render("status" + sortIcon(SortStatus)))
+ b.WriteString(hdr.Width(cw.cpu).Render("cpu" + sortIcon(SortCPU)))
+ b.WriteString(hdr.Width(cw.mem).Render("memory/limit" + sortIcon(SortMemory)))
+ b.WriteString(hdr.Width(cw.id).Render("id" + sortIcon(SortID)))
+ b.WriteString(hdr.Width(cw.image).Render("image" + sortIcon(SortImage)))
+ b.WriteString(hdr.Width(cw.rx).Align(lipgloss.Right).Render("rx" + sortIcon(SortRX)))
+ b.WriteString(hdr.Width(cw.tx).Align(lipgloss.Right).Render("tx" + sortIcon(SortTX)))
+
+ content := b.String()
+ contentW := lipgloss.Width(content)
+ remaining := a.Width - contentW
+ if remaining > 0 {
+ helpText := "( h ) show help"
+ if a.ShowHelp { helpText = "( h ) exit help" }
+ hint := lipgloss.NewStyle().Background(bg).Foreground(cPeach).Width(remaining).Align(lipgloss.Right).Render(helpText)
+ content += hint
+ }
+
+ return content
+}
+
+type colW struct{ name, state, status, cpu, mem, id, image, rx, tx int }
+
+func (a *App) colWidths() colW {
+ cmdW := a.Width * 10 / 100
+ if cmdW < 12 { cmdW = 12 }
+ inner := a.Width - cmdW - 6 - 3 // -6 for borders, -3 for row selector prefix
+ if inner < 60 { inner = 60 }
+
+ // Fixed-width columns (content has predictable size)
+ const (
+ cpuW = 8 // "99.99%"
+ idW = 10 // 8 chars + pad
+ rxW = 9 // "999.9 MB"
+ txW = 9
+ )
+
+ // Compute max widths from data for variable columns (use rune count for unicode)
+ nameMax, stateMax, statusMax, memMax, imageMax := 6, 7, 8, 8, 7 // header minimums
+ for _, c := range a.filtered() {
+ if n := len([]rune(c.Name)); n > nameMax { nameMax = n }
+ stateLen := len([]rune(c.StateStr)) + 2 // icon + space
+ if stateLen > stateMax { stateMax = stateLen }
+ if n := len([]rune(c.Status)); n > statusMax { statusMax = n }
+ memStr := fmtBytes(c.MemUsage) + " / " + fmtBytes(c.MemLimit)
+ if n := len([]rune(memStr)); n > memMax { memMax = n }
+ if n := len([]rune(c.Image)); n > imageMax { imageMax = n }
+ }
+
+ fixed := cpuW + idW + rxW + txW
+ avail := inner - fixed
+ if avail < 30 { avail = 30 }
+
+ // Proportional distribution of variable columns
+ total := nameMax + stateMax + statusMax + memMax + imageMax
+ if total == 0 { total = 1 }
+
+ nameW := avail * nameMax / total
+ stateW := avail * stateMax / total
+ statusW := avail * statusMax / total
+ memW := avail * memMax / total
+ imageW := avail - nameW - stateW - statusW - memW // give remainder to image
+
+ // Apply minimums
+ if nameW < 6 { nameW = 6 }
+ if stateW < 7 { stateW = 7 }
+ if statusW < 8 { statusW = 8 }
+ if memW < 8 { memW = 8 }
+ if imageW < 7 { imageW = 7 }
+
+ return colW{
+ name: nameW, state: stateW, status: statusW,
+ cpu: cpuW, mem: memW, id: idW,
+ image: imageW, rx: rxW, tx: txW,
+ }
+}
+
+// --- Container Section ---
+
+func (a *App) viewContainerSection(h int) string {
+ cmdW := a.Width * 10 / 100
+ if cmdW < 12 { cmdW = 12 }
+ tableW := a.Width - cmdW - 4
+ table := a.viewContainerTable(tableW, h)
+ cmds := a.viewCommands(cmdW, h)
+ return lipgloss.JoinHorizontal(lipgloss.Top, table, cmds)
+}
+
+func (a *App) viewContainerTable(w, h int) string {
+ bc := a.borderUnselected()
+ if a.ActivePanel == PanelContainers { bc = a.borderSelected() }
+ style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(bc).Width(w).Height(h - 2)
+
+ cs := a.filtered()
+ total := len(cs)
+ sel := a.SelectedIdx + 1
+ if total == 0 { sel = 0 }
+
+ title := fmt.Sprintf("Containers %d/%d", sel, total)
+
+ var sb strings.Builder
+ sb.WriteString(lipgloss.NewStyle().Foreground(cPeach).Bold(true).Render(title) + "\n")
+
+ if a.FilterMode {
+ filterLabel := lipgloss.NewStyle().Foreground(cYellow).Bold(true).Render(fmt.Sprintf("Filter(%s): ", a.FilterBy))
+ filterInput := lipgloss.NewStyle().Foreground(cText).Render(a.FilterText + "_")
+ sb.WriteString(filterLabel + filterInput + "\n")
+ }
+
+ vis := h - 4
+ if vis < 1 { vis = 1 }
+ end := a.ScrollOffset + vis
+ if end > len(cs) { end = len(cs) }
+ cw := a.colWidths()
+
+ if total == 0 {
+ sb.WriteString(lipgloss.NewStyle().Foreground(cOverlay0).Render(" No containers found"))
+ return style.Render(sb.String())
+ }
+
+ for idx := a.ScrollOffset; idx < end; idx++ {
+ c := cs[idx]
+ selected := idx == a.SelectedIdx
+
+ var row strings.Builder
+ if selected {
+ row.WriteString(lipgloss.NewStyle().Foreground(cBlue).Render(" > "))
+ } else {
+ row.WriteString(" ")
+ }
+
+ textStyle := lipgloss.NewStyle().Foreground(cBlue)
+ rxStyle := lipgloss.NewStyle().Foreground(cTeal)
+ txStyle := lipgloss.NewStyle().Foreground(cPeach)
+ icon, _ := stateStyle(c.State)
+ sc := a.stateColorCfg(c.State)
+ row.WriteString(textStyle.Render(padR(c.Name, cw.name)))
+ stateText := icon + " " + c.StateStr
+ row.WriteString(lipgloss.NewStyle().Foreground(sc).Render(padR(stateText, cw.state)))
+ row.WriteString(lipgloss.NewStyle().Foreground(sc).Render(padR(trunc(c.Status, cw.status-1), cw.status)))
+ row.WriteString(padR(fmt.Sprintf("%05.2f%%", c.CPUPercent), cw.cpu))
+ row.WriteString(padR(fmtBytes(c.MemUsage)+" / "+fmtBytes(c.MemLimit), cw.mem))
+ idStr := c.ID
+ if len(idStr) > 8 { idStr = idStr[:8] }
+ row.WriteString(textStyle.Render(padR(idStr, cw.id)))
+ row.WriteString(textStyle.Render(padR(trunc(c.Image, cw.image-1), cw.image)))
+ row.WriteString(rxStyle.Render(padL(fmtBytes(c.RxBytes), cw.rx)))
+ row.WriteString(txStyle.Render(padL(fmtBytes(c.TxBytes), cw.tx)))
+
+ if selected {
+ sb.WriteString(lipgloss.NewStyle().Bold(true).Render(row.String()))
+ } else {
+ sb.WriteString(row.String())
+ }
+ sb.WriteString("\n")
+ }
+
+ return style.Render(sb.String())
+}
+
+func (a *App) viewCommands(w, h int) string {
+ bc := a.borderUnselected()
+ if a.ActivePanel == PanelCommands { bc = a.borderSelected() }
+ style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(bc).Width(w).Height(h - 2)
+
+ c := a.selected()
+ if c == nil { return style.Render("") }
+
+ cmds := commandsForState(c.State)
+ if a.CmdSelectedIdx >= len(cmds) {
+ a.CmdSelectedIdx = 0
+ }
+ arrow := lipgloss.NewStyle().Foreground(cBlue).Bold(true).Render("▶ ")
+
+ var sb strings.Builder
+ for i, cmd := range cmds {
+ color := a.cmdColorCfg(cmd)
+ if i == a.CmdSelectedIdx {
+ sb.WriteString(arrow + lipgloss.NewStyle().Foreground(color).Bold(true).Render(cmd) + "\n")
+ } else {
+ sb.WriteString(" " + lipgloss.NewStyle().Foreground(color).Bold(true).Render(cmd) + "\n")
+ }
+ }
+
+ return style.Render(sb.String())
+}
+
+// --- Logs ---
+
+func (a *App) viewLogs(h int) string {
+ bc := a.borderUnselected()
+ if a.ActivePanel == PanelLogs { bc = a.borderSelected() }
+ style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(bc).Width(a.Width - 2).Height(h - 2)
+
+ c := a.selected()
+ name := ""
+ if c != nil { name = c.Name }
+
+ total := len(a.LogLines)
+ vis := a.logsVisibleRows()
+ end := a.LogScroll + vis
+ if end > total { end = total }
+
+ title := fmt.Sprintf("Logs %d/%d - %s", end, total, name)
+
+ var sb strings.Builder
+ sb.WriteString(lipgloss.NewStyle().Foreground(cPeach).Bold(true).Render(title) + "\n")
+
+ if a.SearchMode {
+ searchLabel := lipgloss.NewStyle().Foreground(cYellow).Bold(true).Render("Search: ")
+ searchInput := lipgloss.NewStyle().Foreground(cText).Render(a.SearchText + "_")
+ matchInfo := lipgloss.NewStyle().Foreground(cSubtext0).Render(fmt.Sprintf(" (%d matches)", len(a.SearchMatches)))
+ sb.WriteString(searchLabel + searchInput + matchInfo + "\n")
+ }
+
+ rows := h - 4
+ if rows < 1 { rows = 1 }
+
+ if total == 0 {
+ if c != nil && c.LogsSince == "" {
+ sb.WriteString(lipgloss.NewStyle().Foreground(cOverlay0).Render("parsing logs..."))
+ } else {
+ sb.WriteString(lipgloss.NewStyle().Foreground(cOverlay0).Render("No logs available"))
+ }
+ } else {
+ start := a.LogScroll
+ end := start + rows
+ if end > total { end = total; start = end - rows; if start < 0 { start = 0 } }
+
+ maxW := a.Width - 8
+ if maxW < 20 { maxW = 20 }
+
+ for i := start; i < end; i++ {
+ line := stripAnsi(a.LogLines[i])
+ runes := []rune(line)
+ // Horizontal scroll (rune-based for UTF-8)
+ if a.LogHScroll > 0 && len(runes) > a.LogHScroll {
+ runes = runes[a.LogHScroll:]
+ } else if a.LogHScroll > 0 {
+ runes = nil
+ }
+ if len(runes) > maxW { runes = runes[:maxW] }
+ line = string(runes)
+
+ prefix := " "
+ if i == end-1 {
+ prefix = lipgloss.NewStyle().Foreground(cBlue).Render("▶ ")
+ }
+
+ // Highlight search matches (substring-level)
+ if a.SearchText != "" {
+ caseSensitive := a.Config != nil && a.Config.LogSearchCaseSensitive
+ line = highlightMatches(line, a.SearchText, caseSensitive)
+ }
+
+ sb.WriteString(prefix + line + "\n")
+ }
+ }
+
+ return style.Render(sb.String())
+}
+
+// --- Bottom: Charts + Ports ---
+
+func (a *App) viewBottom(h int) string {
+ c := a.selected()
+ portsW := a.Width * 18 / 100
+ if portsW < 22 { portsW = 22 }
+ remaining := a.Width - portsW - 8
+ chartW := remaining / 3
+
+ cpu := a.viewSparkChart("cpu", chartW, h, c)
+ mem := a.viewSparkChart("memory", chartW, h, c)
+ bw := a.viewBWChart(chartW, h, c)
+ ports := a.viewPorts(portsW, h, c)
+
+ return lipgloss.JoinHorizontal(lipgloss.Top, cpu, mem, bw, ports)
+}
+
+func (a *App) viewSparkChart(typ string, w, h int, c *Container) string {
+ bc := a.borderUnselected()
+ var title string
+ var data []float64
+ var color lipgloss.Color
+ var yLabel string
+
+ if c != nil {
+ isActive := c.State == RunningHealthy || c.State == RunningUnhealthy
+ switch typ {
+ case "cpu":
+ title = fmt.Sprintf("cpu %05.2f%%", c.CPUPercent)
+ data = c.CPUHist
+ if isActive { color = cGreen } else { color = cOverlay0 }
+ case "memory":
+ title = fmt.Sprintf("memory %s", fmtBytes(c.MemUsage))
+ for _, v := range c.MemHist { data = append(data, float64(v)) }
+ if isActive { color = cBlue } else { color = cOverlay0 }
+ }
+ } else {
+ switch typ {
+ case "cpu":
+ title = "cpu 00.00%"
+ color = cGreen
+ case "memory":
+ title = "memory 0.00 kB"
+ color = cBlue
+ }
+ }
+
+ // Y-axis label: max value
+ maxVal := 0.0
+ for _, v := range data {
+ if v > maxVal { maxVal = v }
+ }
+ if typ == "cpu" && maxVal > 0 {
+ yLabel = fmt.Sprintf("%05.2f%%", maxVal)
+ } else if typ == "memory" && maxVal > 0 {
+ yLabel = fmtBytes(uint64(maxVal))
+ }
+
+ return a.renderBrailleChart(title, yLabel, data, color, bc, w, h)
+}
+
+func (a *App) viewBWChart(w, h int, c *Container) string {
+ bc := a.borderUnselected()
+
+ rxRate, txRate := "0.00 kB/s", "0.00 kB/s"
+ var rxData, txData []float64
+ if c != nil {
+ rxData = histDeltas(c.RxHist)
+ txData = histDeltas(c.TxHist)
+ if len(rxData) > 0 {
+ rxRate = fmtRate(uint64(rxData[len(rxData)-1]))
+ }
+ if len(txData) > 0 {
+ txRate = fmtRate(uint64(txData[len(txData)-1]))
+ }
+ }
+
+ title := lipgloss.NewStyle().Foreground(cTeal).Render("rx: "+rxRate) +
+ " " + lipgloss.NewStyle().Foreground(cPeach).Render("tx: "+txRate)
+
+ return a.renderBrailleBWChart(title, rxData, txData, rxRate, txRate, bc, w, h)
+}
+
+// renderBrailleChart renders a single-dataset braille chart with Y-axis label and title in border
+func (a *App) renderBrailleChart(title, yLabel string, data []float64, color, borderColor lipgloss.Color, w, h int) string {
+ innerH := h - 2 // border takes 2
+ innerW := w - 2 // border takes 2
+ if innerH < 1 { innerH = 1 }
+ if innerW < 4 { innerW = 4 }
+
+ yLabelW := 0
+ if yLabel != "" {
+ yLabelW = len([]rune(yLabel)) + 1 // +1 for separator
+ }
+ chartW := innerW - yLabelW
+ if chartW < 2 { chartW = 2 }
+
+ chart := brailleChart(data, chartW, innerH, color)
+ chartLines := strings.Split(chart, "\n")
+ if len(chartLines) == 0 {
+ chartLines = []string{""}
+ }
+
+ // Build content with Y-axis label
+ var sb strings.Builder
+ yStyle := lipgloss.NewStyle().Foreground(color)
+ for i, line := range chartLines {
+ if i == 0 && yLabel != "" {
+ sb.WriteString(yStyle.Render(padL(yLabel, yLabelW-1)))
+ sb.WriteString(lipgloss.NewStyle().Foreground(cSurface0).Render("│"))
+ } else if yLabelW > 0 {
+ sb.WriteString(strings.Repeat(" ", yLabelW-1))
+ sb.WriteString(lipgloss.NewStyle().Foreground(cSurface0).Render("│"))
+ }
+ sb.WriteString(line)
+ if i < len(chartLines)-1 {
+ sb.WriteString("\n")
+ }
+ }
+
+ // Title in top border (like original Rust)
+ titleRendered := " " + lipgloss.NewStyle().Foreground(cText).Bold(true).Render(title) + " "
+ titleW := lipgloss.Width(titleRendered)
+ topBorder := "╭"
+ leftDash := (w - 2 - titleW) / 2
+ if leftDash < 1 { leftDash = 1 }
+ rightDash := w - 2 - titleW - leftDash
+ if rightDash < 1 { rightDash = 1 }
+ topBorder += strings.Repeat("─", leftDash) + titleRendered + strings.Repeat("─", rightDash) + "╮"
+
+ // Bottom border
+ bottomBorder := "╰" + strings.Repeat("─", w-2) + "╯"
+
+ // Side borders around content
+ contentLines := strings.Split(sb.String(), "\n")
+ var result strings.Builder
+ bStyle := lipgloss.NewStyle().Foreground(borderColor)
+ result.WriteString(bStyle.Render(topBorder) + "\n")
+ for i := 0; i < innerH; i++ {
+ line := ""
+ if i < len(contentLines) { line = contentLines[i] }
+ lineW := lipgloss.Width(line)
+ pad := innerW - lineW
+ if pad < 0 { pad = 0 }
+ result.WriteString(bStyle.Render("│") + line + strings.Repeat(" ", pad) + bStyle.Render("│"))
+ if i < innerH-1 {
+ result.WriteString("\n")
+ }
+ }
+ result.WriteString("\n" + bStyle.Render(bottomBorder))
+
+ return result.String()
+}
+
+// renderBrailleBWChart renders RX/TX as two braille charts stacked, with title in border
+func (a *App) renderBrailleBWChart(title string, rxData, txData []float64, rxRate, txRate string, borderColor lipgloss.Color, w, h int) string {
+ innerH := h - 2
+ innerW := w - 2
+ if innerH < 2 { innerH = 2 }
+ if innerW < 4 { innerW = 4 }
+
+ halfH := (innerH - 1) / 2 // -1 for separator between rx/tx
+ if halfH < 1 { halfH = 1 }
+
+ // Y-axis label width (use rx or tx rate)
+ yLabelW := 0
+ rxYLabel := fmtRateShort(rxData)
+ txYLabel := fmtRateShort(txData)
+ if len([]rune(rxYLabel)) > yLabelW { yLabelW = len([]rune(rxYLabel)) }
+ if len([]rune(txYLabel)) > yLabelW { yLabelW = len([]rune(txYLabel)) }
+ if yLabelW > 0 { yLabelW += 1 } // separator
+ chartW := innerW - yLabelW
+ if chartW < 2 { chartW = 2 }
+
+ rxChart := brailleChart(rxData, chartW, halfH, cTeal)
+ txChart := brailleChart(txData, chartW, halfH, cPeach)
+
+ rxLines := strings.Split(rxChart, "\n")
+ txLines := strings.Split(txChart, "\n")
+
+ // Build content
+ var sb strings.Builder
+ yStyle := lipgloss.NewStyle()
+ sepStyle := lipgloss.NewStyle().Foreground(cSurface0)
+
+ // RX section
+ for i, line := range rxLines {
+ if i == 0 && rxYLabel != "" {
+ sb.WriteString(yStyle.Foreground(cTeal).Render(padL(rxYLabel, yLabelW-1)))
+ sb.WriteString(sepStyle.Render("│"))
+ } else if yLabelW > 0 {
+ sb.WriteString(strings.Repeat(" ", yLabelW-1))
+ sb.WriteString(sepStyle.Render("│"))
+ }
+ sb.WriteString(line + "\n")
+ }
+
+ // TX section
+ for i, line := range txLines {
+ if i == 0 && txYLabel != "" {
+ sb.WriteString(yStyle.Foreground(cPeach).Render(padL(txYLabel, yLabelW-1)))
+ sb.WriteString(sepStyle.Render("│"))
+ } else if yLabelW > 0 {
+ sb.WriteString(strings.Repeat(" ", yLabelW-1))
+ sb.WriteString(sepStyle.Render("│"))
+ }
+ sb.WriteString(line)
+ if i < len(txLines)-1 {
+ sb.WriteString("\n")
+ }
+ }
+
+ // Title in top border
+ titleW := lipgloss.Width(title)
+ titleRendered := " " + title + " "
+ titleRW := titleW + 2
+ topBorder := "╭"
+ leftDash := (w - 2 - titleRW) / 2
+ if leftDash < 1 { leftDash = 1 }
+ rightDash := w - 2 - titleRW - leftDash
+ if rightDash < 1 { rightDash = 1 }
+ topBorder += strings.Repeat("─", leftDash) + titleRendered + strings.Repeat("─", rightDash) + "╮"
+
+ bottomBorder := "╰" + strings.Repeat("─", w-2) + "╯"
+
+ contentLines := strings.Split(sb.String(), "\n")
+ var result strings.Builder
+ bStyle := lipgloss.NewStyle().Foreground(borderColor)
+ result.WriteString(bStyle.Render(topBorder) + "\n")
+ for i := 0; i < innerH; i++ {
+ line := ""
+ if i < len(contentLines) { line = contentLines[i] }
+ lineW := lipgloss.Width(line)
+ pad := innerW - lineW
+ if pad < 0 { pad = 0 }
+ result.WriteString(bStyle.Render("│") + line + strings.Repeat(" ", pad) + bStyle.Render("│"))
+ if i < innerH-1 {
+ result.WriteString("\n")
+ }
+ }
+ result.WriteString("\n" + bStyle.Render(bottomBorder))
+
+ return result.String()
+}
+
+func fmtRateShort(data []float64) string {
+ if len(data) == 0 { return "" }
+ maxV := 0.0
+ for _, v := range data {
+ if v > maxV { maxV = v }
+ }
+ if maxV == 0 { return "" }
+ return fmtRate(uint64(maxV))
+}
+
+// histDeltas computes per-interval deltas from cumulative history
+func histDeltas(hist []uint64) []float64 {
+ if len(hist) < 2 {
+ return nil
+ }
+ deltas := make([]float64, 0, len(hist)-1)
+ for i := 1; i < len(hist); i++ {
+ if hist[i] >= hist[i-1] {
+ deltas = append(deltas, float64(hist[i]-hist[i-1]))
+ } else {
+ deltas = append(deltas, 0)
+ }
+ }
+ return deltas
+}
+
+func (a *App) viewPorts(w, h int, c *Container) string {
+ style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(cSurface0).Width(w).Height(h - 2)
+
+ var sb strings.Builder
+ titleStr := "ports"
+ pad := (w - len(titleStr)) / 2
+ if pad < 0 { pad = 0 }
+ sb.WriteString(strings.Repeat(" ", pad) + lipgloss.NewStyle().Foreground(cText).Bold(true).Render(titleStr) + "\n")
+
+ if c == nil || len(c.Ports) == 0 {
+ sb.WriteString(lipgloss.NewStyle().Foreground(cOverlay0).Render("no ports"))
+ } else {
+ sb.WriteString(lipgloss.NewStyle().Foreground(cOverlay0).Render(fmt.Sprintf("%-10s %8s %8s", "ip", "private", "public")) + "\n")
+ for _, p := range c.Ports {
+ ip := p.IP
+ pubStr := ""
+ if p.Public > 0 {
+ pubStr = fmt.Sprintf("%d", p.Public)
+ }
+ sb.WriteString(fmt.Sprintf("%-10s %8d %8s\n", ip, p.Private, pubStr))
+ }
+ }
+
+ return style.Render(sb.String())
+}
+
+// --- Overlays ---
+
+func (a *App) overlayDeleteConfirm(base string) string {
+ name := ""
+ for _, c := range a.Containers {
+ if c.ID == a.DeleteTarget { name = c.Name; break }
+ }
+ if a.DeleteTarget == "" {
+ a.DeleteConfirm = false
+ return base
+ }
+ box := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(cRed).
+ Background(cMantle).
+ Padding(1, 3).
+ Render(fmt.Sprintf("Delete container %s?\n\n (y) Confirm (n) Cancel",
+ lipgloss.NewStyle().Bold(true).Foreground(cPeach).Render(name)))
+
+ return placeOverlay(base, box, lipgloss.Center, lipgloss.Center, a.Width, a.Height)
+}
+
+func (a *App) overlayError(base string) string {
+ msg := fmt.Sprintf("Error: %v\n\nPress (c) to clear or (esc) to dismiss", a.Error)
+ if a.ConnectCountdown > 0 {
+ msg = fmt.Sprintf("Error: %v\n\nExiting in %d seconds... Press (q) to quit now", a.Error, a.ConnectCountdown)
+ }
+ box := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(cRed).
+ Foreground(cRed).
+ Padding(1, 3).
+ Render(msg)
+
+ return placeOverlay(base, box, lipgloss.Center, lipgloss.Center, a.Width, a.Height)
+}
+
+func (a *App) overlayInfo(base string) string {
+ box := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(cTeal).
+ Padding(0, 2).
+ Render(a.InfoText)
+
+ // Bottom right — overlay on base
+ return placeOverlay(base, box, lipgloss.Right, lipgloss.Bottom, a.Width, a.Height)
+}
+
+// --- Inspect View ---
+
+func (a *App) viewInspect() string {
+ lines := strings.Split(a.InspectData, "\n")
+ total := len(lines)
+ maxLineW := 0
+ for _, l := range lines {
+ if len(l) > maxLineW { maxLineW = len(l) }
+ }
+
+ vis := a.Height - 4
+ if vis < 1 { vis = 1 }
+ start := a.InspectScrollY
+ if start >= total { start = total - 1 }
+ if start < 0 { start = 0 }
+ end := start + vis
+ if end > total { end = total }
+
+ c := a.selected()
+ name := ""
+ if c != nil { name = c.Name + " " + c.ID[:minI(8, len(c.ID))] }
+
+ titleTop := fmt.Sprintf("inspecting: %s (esc/i/q to exit)", name)
+ titleBot := fmt.Sprintf("↑ %d/%d ↓ ← %d/%d →", a.InspectScrollY, total, a.InspectScrollX, maxLineW)
+
+ var sb strings.Builder
+ for i := start; i < end; i++ {
+ line := lines[i]
+ if a.InspectScrollX > 0 && len(line) > a.InspectScrollX {
+ line = line[a.InspectScrollX:]
+ } else if a.InspectScrollX > 0 {
+ line = ""
+ }
+ // Wrap long lines instead of truncating
+ lineW := a.Width - 6
+ if lineW < 10 { lineW = 10 }
+ for len(line) > lineW {
+ sb.WriteString(line[:lineW] + "\n")
+ line = line[lineW:]
+ }
+ sb.WriteString(line + "\n")
+ }
+
+ style := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(cBlue).
+ Width(a.Width - 2).
+ Height(a.Height - 2)
+
+ header := lipgloss.NewStyle().Foreground(cPeach).Bold(true).Render(titleTop)
+ footer := lipgloss.NewStyle().Foreground(cSubtext0).Render(titleBot)
+ content := lipgloss.NewStyle().Foreground(cOverlay0).Render(sb.String())
+
+ return style.Render(header + "\n" + content + "\n" + footer)
+}
+
+// --- Help View ---
+
+func (a *App) overlayHelp(base string) string {
+ configPath := ""
+ if a.Config != nil && a.Config.DirConfig != "" {
+ configPath = a.Config.DirConfig
+ }
+
+ var sb strings.Builder
+ sb.WriteString(" oxker — Docker TUI (Go)\n\n")
+ if configPath != "" {
+ sb.WriteString(fmt.Sprintf(" Config: %s\n\n", configPath))
+ }
+ sb.WriteString(` Navigation
+ j/k ↑/↓ Scroll containers/logs/commands
+ J/K Scroll 10x speed
+ PgUp/PgDn Page scroll
+ g/G Top / Bottom
+ Tab/Shift+Tab Switch panels (Containers → Commands → Logs)
+ ←/→ Scroll logs horizontally
+
+ Sorting
+ 1 Name 2 State 3 Status
+ 4 CPU 5 Memory 6 ID
+ 7 Image 8 RX 9 TX
+ 0 Reset sort
+
+ Filtering & Search
+ / Filter containers (←/→ change filter: Name/Image/Status/All)
+ # Search logs (↑/↓ or Ctrl+N/P next/prev match)
+
+ Container Actions
+ s Start x Stop r Restart
+ p Pause u Unpause d Delete
+ e Exec into container (sh)
+ Enter Execute selected command (in Commands panel)
+
+ Display
+ l Toggle logs panel
+ [ / ] Resize logs panel
+ i Inspect container (JSON, scroll with j/k/h/l)
+ S / Ctrl+S Save logs to file
+ c Clear errors
+ h Toggle help
+ Esc Dismiss overlays
+
+ Quit
+ q / Ctrl+C`)
+
+ help := sb.String()
+
+ box := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(cMauve).
+ Padding(1, 3).
+ Render(help)
+
+ return placeOverlay(base, box, lipgloss.Center, lipgloss.Center, a.Width, a.Height)
+}
+
+// ============================================================
+// OVERLAY HELPER
+// ============================================================
+
+// placeOverlay renders fg on top of bg at the given position.
+// hPos: lipgloss.Left, Center, Right. vPos: lipgloss.Top, Center, Bottom.
+func placeOverlay(bg string, fg string, hPos, vPos lipgloss.Position, bgW, bgH int) string {
+ bgLines := strings.Split(bg, "\n")
+ fgLines := strings.Split(fg, "\n")
+ fgW := 0
+ for _, l := range fgLines {
+ if w := lipgloss.Width(l); w > fgW { fgW = w }
+ }
+ fgH := len(fgLines)
+
+ // Compute top-left corner of overlay
+ var x, y int
+ switch hPos {
+ case lipgloss.Left:
+ x = 0
+ case lipgloss.Right:
+ x = bgW - fgW
+ default: // Center
+ x = (bgW - fgW) / 2
+ }
+ switch vPos {
+ case lipgloss.Top:
+ y = 0
+ case lipgloss.Bottom:
+ y = bgH - fgH
+ default: // Center
+ y = (bgH - fgH) / 2
+ }
+ if x < 0 { x = 0 }
+ if y < 0 { y = 0 }
+
+ // Pad bg to ensure enough lines
+ for len(bgLines) < bgH {
+ bgLines = append(bgLines, strings.Repeat(" ", bgW))
+ }
+
+ // Overlay fg onto bg line by line
+ for i, fgLine := range fgLines {
+ bgIdx := y + i
+ if bgIdx >= len(bgLines) { break }
+
+ // Pad the fg line to fgW so overlay has consistent width
+ fgLineW := lipgloss.Width(fgLine)
+ if fgLineW < fgW {
+ fgLine += strings.Repeat(" ", fgW-fgLineW)
+ }
+
+ // Build: [left padding] + [fg line] + [right padding]
+ // We sacrifice the bg content under the overlay (acceptable for small overlays)
+ bgLine := bgLines[bgIdx]
+ bgLineW := lipgloss.Width(bgLine)
+
+ left := strings.Repeat(" ", x)
+ rightW := bgW - x - fgW
+ right := ""
+ if rightW > 0 {
+ right = strings.Repeat(" ", rightW)
+ }
+
+ // Try to preserve bg content outside the overlay area
+ // Left part: use bg line if it has content
+ if bgLineW > 0 && x > 0 {
+ left = lipgloss.NewStyle().Width(x).MaxWidth(x).Inline(true).Render(bgLine)
+ if lipgloss.Width(left) < x {
+ left += strings.Repeat(" ", x-lipgloss.Width(left))
+ }
+ }
+
+ bgLines[bgIdx] = left + fgLine + right
+ }
+
+ return strings.Join(bgLines[:bgH], "\n")
+}
+
+// ============================================================
+// LAYOUT HELPERS
+// ============================================================
+
+func (a *App) containerAreaHeight() int {
+ h := a.Height * 30 / 100
+ if h < 6 { h = 6 }
+ return h
+}
+
+func (a *App) logsAreaHeight() int {
+ if !a.ShowLogs { return 0 }
+ ch := a.containerAreaHeight()
+ bh := a.chartsAreaHeight()
+ lh := a.Height - ch - bh - 1
+ if lh < 5 { lh = 5 }
+ // Apply log height percentage
+ maxLogs := a.Height * a.LogHeight / 100
+ if lh > maxLogs { lh = maxLogs }
+ return lh
+}
+
+func (a *App) chartsAreaHeight() int {
+ h := a.Height * 22 / 100
+ if h < 7 { h = 7 }
+ return h
+}
+
+func (a *App) containerVisibleRows() int {
+ h := a.containerAreaHeight() - 4
+ if h < 1 { h = 1 }
+ return h
+}
+
+func (a *App) logsVisibleRows() int {
+ h := a.logsAreaHeight() - 4
+ if h < 1 { h = 1 }
+ return h
+}
+
+// ============================================================
+// FILTERING
+// ============================================================
+
+func (a *App) filtered() []Container {
+ if a.FilterText == "" { return a.Containers }
+ term := strings.ToLower(a.FilterText)
+ var out []Container
+ for _, c := range a.Containers {
+ match := false
+ switch a.FilterBy {
+ case FilterByName:
+ match = strings.Contains(strings.ToLower(c.Name), term)
+ case FilterByImage:
+ match = strings.Contains(strings.ToLower(c.Image), term)
+ case FilterByStatus:
+ match = strings.Contains(strings.ToLower(c.Status), term)
+ case FilterByAll:
+ match = strings.Contains(strings.ToLower(c.Name), term) ||
+ strings.Contains(strings.ToLower(c.Image), term) ||
+ strings.Contains(strings.ToLower(c.Status), term)
+ }
+ if match { out = append(out, c) }
+ }
+ return out
+}
+
+// ============================================================
+// HELPERS
+// ============================================================
+
+func stateStyle(s ContainerState) (string, lipgloss.Color) {
+ switch s {
+ case RunningHealthy: return "✓", cGreen
+ case RunningUnhealthy: return "!", cPeach
+ case Paused: return "‖", cYellow
+ case Exited: return "✖", cRed
+ case Dead: return "✖", cRed
+ case Restarting: return "↻", cTeal
+ case Removing: return "…", cRed
+ case Created: return "?", cOverlay0
+ default: return "?", cOverlay0
+ }
+}
+
+func commandsForState(s ContainerState) []string {
+ switch s {
+ case RunningHealthy:
+ return []string{"pause", "restart", "stop", "delete"}
+ case RunningUnhealthy:
+ return []string{"pause", "restart", "stop", "delete"}
+ case Paused:
+ return []string{"resume", "stop", "delete"}
+ case Exited, Dead, Created:
+ return []string{"start", "restart", "delete"}
+ case Unknown:
+ return []string{"delete"}
+ case Restarting:
+ return []string{"stop", "delete"}
+ case Removing:
+ return []string{"delete"}
+ default:
+ return []string{"delete"}
+ }
+}
+
+func cmdColor(cmd string) lipgloss.Color {
+ switch cmd {
+ case "start":
+ return cGreen
+ case "resume":
+ return cBlue
+ case "restart":
+ return cMauve
+ case "pause":
+ return cYellow
+ case "stop":
+ return cRed
+ case "delete":
+ return cOverlay0
+ default:
+ return cText
+ }
+}
+
+// brailleChart renders data as a braille dot chart (matching original Rust ratatui style).
+// Each terminal cell is a 2×4 braille grid, giving (w*2) × (h*4) resolution.
+func brailleChart(data []float64, w, h int, color lipgloss.Color) string {
+ if w <= 0 || h <= 0 { return "" }
+
+ // Braille dot offsets: each char is 2 cols × 4 rows
+ // Bit positions: col0=[0,1,2,6] col1=[3,4,5,7] for rows 0,1,2,3
+ dotBits := [2][4]rune{
+ {0x01, 0x02, 0x04, 0x40}, // left column (col 0)
+ {0x08, 0x10, 0x20, 0x80}, // right column (col 1)
+ }
+
+ pixW := w * 2 // horizontal pixel resolution
+ pixH := h * 4 // vertical pixel resolution
+
+ // Take last pixW data points (each data point = 1 pixel column)
+ start := 0
+ if len(data) > pixW { start = len(data) - pixW }
+ visible := data[start:]
+
+ maxV := 0.0
+ for _, v := range visible {
+ if v > maxV { maxV = v }
+ }
+ if maxV == 0 { maxV = 1 }
+
+ // Create pixel grid (row 0 = bottom)
+ grid := make([][]bool, pixH)
+ for i := range grid {
+ grid[i] = make([]bool, pixW)
+ }
+
+ // Plot data points
+ for i, v := range visible {
+ py := int(v / maxV * float64(pixH-1))
+ if py >= pixH { py = pixH - 1 }
+ if py < 0 { py = 0 }
+ grid[py][i] = true
+ }
+
+ // Render braille characters
+ s := lipgloss.NewStyle().Foreground(color)
+ dimS := lipgloss.NewStyle().Foreground(cSurface1)
+ var lines []string
+
+ for cellRow := h - 1; cellRow >= 0; cellRow-- {
+ var b strings.Builder
+ for cellCol := 0; cellCol < w; cellCol++ {
+ var ch rune = 0x2800 // braille base
+ hasDot := false
+ for dc := 0; dc < 2; dc++ {
+ for dr := 0; dr < 4; dr++ {
+ px := cellCol*2 + dc
+ // Map: cellRow*4 + dr, but row 0 of cell = top visually = highest pixel
+ py := cellRow*4 + (3 - dr)
+ if px < len(grid[0]) && py >= 0 && py < pixH && grid[py][px] {
+ ch |= dotBits[dc][dr]
+ hasDot = true
+ }
+ }
+ }
+ if hasDot {
+ b.WriteString(s.Render(string(ch)))
+ } else {
+ b.WriteString(dimS.Render(string(rune(0x2800)))) // empty braille
+ }
+ }
+ lines = append(lines, b.String())
+ }
+ return strings.Join(lines, "\n")
+}
+
+// SI units (1000-based, matching original)
+func fmtBytes(b uint64) string {
+ if b == 0 { return "0.00 kB" }
+ kb := float64(b) / 1000
+ if kb < 1000 { return fmt.Sprintf("%.2f kB", kb) }
+ mb := kb / 1000
+ if mb < 1000 { return fmt.Sprintf("%.2f MB", mb) }
+ gb := mb / 1000
+ return fmt.Sprintf("%.2f GB", gb)
+}
+
+func fmtRate(b uint64) string {
+ if b == 0 { return "0.00 kb/s" }
+ kb := float64(b) / 1000
+ if kb < 1000 { return fmt.Sprintf("%.2f kb/s", kb) }
+ mb := kb / 1000
+ if mb < 1000 { return fmt.Sprintf("%.2f Mb/s", mb) }
+ gb := mb / 1000
+ return fmt.Sprintf("%.2f Gb/s", gb)
+}
+
+func trunc(s string, n int) string {
+ if n <= 0 { return "" }
+ r := []rune(s)
+ if len(r) <= n { return s }
+ if n <= 3 { return string(r[:n]) }
+ return string(r[:n-3]) + "..."
+}
+
+func padR(s string, w int) string {
+ r := []rune(s)
+ if len(r) >= w { return string(r[:w]) }
+ return s + strings.Repeat(" ", w-len(r))
+}
+
+func padL(s string, w int) string {
+ r := []rune(s)
+ if len(r) >= w { return string(r[:w]) }
+ return strings.Repeat(" ", w-len(r)) + s
+}
+
+func highlightMatches(line, search string, caseSensitive bool) string {
+ haystack := line
+ term := search
+ if !caseSensitive {
+ haystack = strings.ToLower(line)
+ term = strings.ToLower(search)
+ }
+ idx := strings.Index(haystack, term)
+ if idx < 0 {
+ return line
+ }
+ hl := lipgloss.NewStyle().Foreground(cYellow).Bold(true)
+ var result strings.Builder
+ for idx >= 0 {
+ result.WriteString(line[:idx])
+ result.WriteString(hl.Render(line[idx : idx+len(search)]))
+ line = line[idx+len(search):]
+ if !caseSensitive {
+ haystack = strings.ToLower(line)
+ } else {
+ haystack = line
+ }
+ idx = strings.Index(haystack, term)
+ }
+ result.WriteString(line)
+ return result.String()
+}
+
+func minI(a, b int) int { if a < b { return a }; return b }
+func maxI(a, b int) int { if a > b { return a }; return b }
+
+// keyMatch checks if a key string matches any key in the slice (from config keymap)
+func keyMatch(key string, bindings []string) bool {
+ for _, b := range bindings {
+ if key == b { return true }
+ }
+ return false
+}
+
+func compareFloat(a, b float64) int {
+ if math.Abs(a-b) < 0.001 { return 0 }
+ if a < b { return -1 }
+ return 1
+}
+
+func compareUint(a, b uint64) int {
+ if a == b { return 0 }
+ if a < b { return -1 }
+ return 1
+}
+
+func appendMax(s []float64, v float64, max int) []float64 {
+ if len(s) >= max {
+ copy(s, s[1:])
+ s[len(s)-1] = v
+ return s
+ }
+ return append(s, v)
+}
+
+func appendMaxU(s []uint64, v uint64, max int) []uint64 {
+ if len(s) >= max {
+ copy(s, s[1:])
+ s[len(s)-1] = v
+ return s
+ }
+ return append(s, v)
+}
+
+func mapState(state, status string) ContainerState {
+ switch strings.ToLower(state) {
+ case "running":
+ if strings.Contains(strings.ToLower(status), "unhealthy") {
+ return RunningUnhealthy
+ }
+ return RunningHealthy
+ case "exited":
+ return Exited
+ case "paused":
+ return Paused
+ case "restarting":
+ return Restarting
+ case "removing":
+ return Removing
+ case "dead":
+ return Dead
+ case "created":
+ return Created
+ default:
+ return Unknown
+ }
+}
+
+func mapPorts(ports []container.Port) []ContainerPorts {
+ result := make([]ContainerPorts, len(ports))
+ for i, p := range ports {
+ result[i] = ContainerPorts{IP: p.IP, Private: int(p.PrivatePort), Public: int(p.PublicPort)}
+ }
+ return result
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..3ef5ff3
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,645 @@
+package config
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/BurntSushi/toml"
+)
+
+type AppColors struct {
+ Borders Borders `toml:"borders,omitempty" json:"borders,omitempty"`
+ ChartCPU ChartCPU `toml:"chart_cpu,omitempty" json:"chart_cpu,omitempty"`
+ ChartMemory ChartMemory `toml:"chart_memory,omitempty" json:"chart_memory,omitempty"`
+ ChartBandwidth ChartBandwidth `toml:"chart_bandwidth,omitempty" json:"chart_bandwidth,omitempty"`
+ ChartPorts ChartPorts `toml:"chart_ports,omitempty" json:"chart_ports,omitempty"`
+ Commands Commands `toml:"commands,omitempty" json:"commands,omitempty"`
+ ContainerState ContainerState `toml:"container_state,omitempty" json:"container_state,omitempty"`
+ Containers Containers `toml:"containers,omitempty" json:"containers,omitempty"`
+ LogSearch LogSearch `toml:"log_search,omitempty" json:"log_search,omitempty"`
+ Filter Filter `toml:"filter,omitempty" json:"filter,omitempty"`
+ HeadersBar HeadersBar `toml:"headers_bar,omitempty" json:"headers_bar,omitempty"`
+ Logs Logs `toml:"logs,omitempty" json:"logs,omitempty"`
+ PopupDelete PopupDelete `toml:"popup_delete,omitempty" json:"popup_delete,omitempty"`
+ PopupError PopupError `toml:"popup_error,omitempty" json:"popup_error,omitempty"`
+ PopupHelp PopupHelp `toml:"popup_help,omitempty" json:"popup_help,omitempty"`
+ PopupInfo PopupInfo `toml:"popup_info,omitempty" json:"popup_info,omitempty"`
+}
+
+type Borders struct {
+ Selected string `toml:"selected,omitempty" json:"selected,omitempty"`
+ Unselected string `toml:"unselected,omitempty" json:"unselected,omitempty"`
+}
+
+type ChartCPU struct {
+ Background string `toml:"background,omitempty" json:"background,omitempty"`
+ Border string `toml:"border,omitempty" json:"border,omitempty"`
+ Title string `toml:"title,omitempty" json:"title,omitempty"`
+ Max string `toml:"max,omitempty" json:"max,omitempty"`
+ Points string `toml:"points,omitempty" json:"points,omitempty"`
+ YAxis string `toml:"y_axis,omitempty" json:"y_axis,omitempty"`
+}
+
+type ChartMemory struct {
+ Background string `toml:"background,omitempty" json:"background,omitempty"`
+ Border string `toml:"border,omitempty" json:"border,omitempty"`
+ Title string `toml:"title,omitempty" json:"title,omitempty"`
+ Max string `toml:"max,omitempty" json:"max,omitempty"`
+ Points string `toml:"points,omitempty" json:"points,omitempty"`
+ YAxis string `toml:"y_axis,omitempty" json:"y_axis,omitempty"`
+}
+
+type ChartBandwidth struct {
+ Background string `toml:"background,omitempty" json:"background,omitempty"`
+ Border string `toml:"border,omitempty" json:"border,omitempty"`
+ MaxRX string `toml:"max_rx,omitempty" json:"max_rx,omitempty"`
+ MaxTX string `toml:"max_tx,omitempty" json:"max_tx,omitempty"`
+ PointsRX string `toml:"points_rx,omitempty" json:"points_rx,omitempty"`
+ PointsTX string `toml:"points_tx,omitempty" json:"points_tx,omitempty"`
+ TitleRX string `toml:"title_rx,omitempty" json:"title_rx,omitempty"`
+ TitleTX string `toml:"title_tx,omitempty" json:"title_tx,omitempty"`
+ YAxis string `toml:"y_axis,omitempty" json:"y_axis,omitempty"`
+}
+
+type ChartPorts struct {
+ Background string `toml:"background,omitempty" json:"background,omitempty"`
+ Border string `toml:"border,omitempty" json:"border,omitempty"`
+ Title string `toml:"title,omitempty" json:"title,omitempty"`
+ Headings string `toml:"headings,omitempty" json:"headings,omitempty"`
+ Text string `toml:"text,omitempty" json:"text,omitempty"`
+}
+
+type Commands struct {
+ Background string `toml:"background,omitempty" json:"background,omitempty"`
+ Pause string `toml:"pause,omitempty" json:"pause,omitempty"`
+ Restart string `toml:"restart,omitempty" json:"restart,omitempty"`
+ Stop string `toml:"stop,omitempty" json:"stop,omitempty"`
+ Delete string `toml:"delete,omitempty" json:"delete,omitempty"`
+ Resume string `toml:"resume,omitempty" json:"resume,omitempty"`
+ Start string `toml:"start,omitempty" json:"start,omitempty"`
+}
+
+type ContainerState struct {
+ Dead string `toml:"dead,omitempty" json:"dead,omitempty"`
+ Exited string `toml:"exited,omitempty" json:"exited,omitempty"`
+ Paused string `toml:"paused,omitempty" json:"paused,omitempty"`
+ Removing string `toml:"removing,omitempty" json:"removing,omitempty"`
+ Restarting string `toml:"restarting,omitempty" json:"restarting,omitempty"`
+ RunningHealthy string `toml:"running_healthy,omitempty" json:"running_healthy,omitempty"`
+ RunningUnhealthy string `toml:"running_unhealthy,omitempty" json:"running_unhealthy,omitempty"`
+ Unknown string `toml:"unknown,omitempty" json:"unknown,omitempty"`
+}
+
+type Containers struct {
+ Background string `toml:"background,omitempty" json:"background,omitempty"`
+ Icon string `toml:"icon,omitempty" json:"icon,omitempty"`
+ Text string `toml:"text,omitempty" json:"text,omitempty"`
+ TextRX string `toml:"text_rx,omitempty" json:"text_rx,omitempty"`
+ TextTX string `toml:"text_tx,omitempty" json:"text_tx,omitempty"`
+}
+
+type LogSearch struct {
+ Background string `toml:"background,omitempty" json:"background,omitempty"`
+ Text string `toml:"text,omitempty" json:"text,omitempty"`
+ ButtonText string `toml:"button_text,omitempty" json:"button_text,omitempty"`
+ Highlight string `toml:"highlight,omitempty" json:"highlight,omitempty"`
+}
+
+type Filter struct {
+ Background string `toml:"background,omitempty" json:"background,omitempty"`
+ Text string `toml:"text,omitempty" json:"text,omitempty"`
+ SelectedFilterBackground string `toml:"selected_filter_background,omitempty" json:"selected_filter_background,omitempty"`
+ SelectedFilterText string `toml:"selected_filter_text,omitempty" json:"selected_filter_text,omitempty"`
+ Highlight string `toml:"highlight,omitempty" json:"highlight,omitempty"`
+}
+
+type HeadersBar struct {
+ Background string `toml:"background,omitempty" json:"background,omitempty"`
+ LoadingSpinner string `toml:"loading_spinner,omitempty" json:"loading_spinner,omitempty"`
+ Text string `toml:"text,omitempty" json:"text,omitempty"`
+ TextSelected string `toml:"text_selected,omitempty" json:"text_selected,omitempty"`
+}
+
+type Logs struct {
+ Background string `toml:"background,omitempty" json:"background,omitempty"`
+ Text string `toml:"text,omitempty" json:"text,omitempty"`
+}
+
+type PopupDelete struct {
+ Background string `toml:"background,omitempty" json:"background,omitempty"`
+ Text string `toml:"text,omitempty" json:"text,omitempty"`
+ TextHighlight string `toml:"text_highlight,omitempty" json:"text_highlight,omitempty"`
+}
+
+type PopupError struct {
+ Background string `toml:"background,omitempty" json:"background,omitempty"`
+ Text string `toml:"text,omitempty" json:"text,omitempty"`
+}
+
+type PopupHelp struct {
+ Background string `toml:"background,omitempty" json:"background,omitempty"`
+ Text string `toml:"text,omitempty" json:"text,omitempty"`
+ TextHighlight string `toml:"text_highlight,omitempty" json:"text_highlight,omitempty"`
+}
+
+type PopupInfo struct {
+ Background string `toml:"background,omitempty" json:"background,omitempty"`
+ Text string `toml:"text,omitempty" json:"text,omitempty"`
+}
+
+type Keymap struct {
+ Clear []string `toml:"clear,omitempty" json:"clear,omitempty"`
+ ClearSecondary []string `toml:"-" json:"-"`
+ DeleteConfirm []string `toml:"delete_confirm,omitempty" json:"delete_confirm,omitempty"`
+ DeleteConfirmSecondary []string `toml:"-" json:"-"`
+ DeleteDeny []string `toml:"delete_deny,omitempty" json:"delete_deny,omitempty"`
+ DeleteDenySecondary []string `toml:"-" json:"-"`
+ Exec []string `toml:"exec,omitempty" json:"exec,omitempty"`
+ ExecSecondary []string `toml:"-" json:"-"`
+ FilterMode []string `toml:"filter_mode,omitempty" json:"filter_mode,omitempty"`
+ FilterModeSecondary []string `toml:"-" json:"-"`
+ Inspect []string `toml:"inspect,omitempty" json:"inspect,omitempty"`
+ InspectSecondary []string `toml:"-" json:"-"`
+ ForceRedraw []string `toml:"force_redraw,omitempty" json:"force_redraw,omitempty"`
+ ForceRedrawSecondary []string `toml:"-" json:"-"`
+ ScrollBack []string `toml:"scroll_back,omitempty" json:"scroll_back,omitempty"`
+ ScrollBackSecondary []string `toml:"-" json:"-"`
+ ScrollForward []string `toml:"scroll_forward,omitempty" json:"scroll_forward,omitempty"`
+ ScrollForwardSecondary []string `toml:"-" json:"-"`
+ LogSearchMode []string `toml:"log_search_mode,omitempty" json:"log_search_mode,omitempty"`
+ LogSearchModeSecondary []string `toml:"-" json:"-"`
+ LogSectionHeightDecrease []string `toml:"log_section_height_decrease,omitempty" json:"log_section_height_decrease,omitempty"`
+ LogSectionHeightDecreaseSecondary []string `toml:"-" json:"-"`
+ LogSectionHeightIncrease []string `toml:"log_section_height_increase,omitempty" json:"log_section_height_increase,omitempty"`
+ LogSectionHeightIncreaseSecondary []string `toml:"-" json:"-"`
+ LogSectionToggle []string `toml:"log_section_toggle,omitempty" json:"log_section_toggle,omitempty"`
+ LogSectionToggleSecondary []string `toml:"-" json:"-"`
+ Quit []string `toml:"quit,omitempty" json:"quit,omitempty"`
+ QuitSecondary []string `toml:"-" json:"-"`
+ SaveLogs []string `toml:"save_logs,omitempty" json:"save_logs,omitempty"`
+ SaveLogsSecondary []string `toml:"-" json:"-"`
+ ScrollDown []string `toml:"scroll_down,omitempty" json:"scroll_down,omitempty"`
+ ScrollDownSecondary []string `toml:"-" json:"-"`
+ ScrollEnd []string `toml:"scroll_end,omitempty" json:"scroll_end,omitempty"`
+ ScrollEndSecondary []string `toml:"-" json:"-"`
+ ScrollStart []string `toml:"scroll_start,omitempty" json:"scroll_start,omitempty"`
+ ScrollStartSecondary []string `toml:"-" json:"-"`
+ ScrollUp []string `toml:"scroll_up,omitempty" json:"scroll_up,omitempty"`
+ ScrollUpSecondary []string `toml:"-" json:"-"`
+ SelectNextPanel []string `toml:"select_next_panel,omitempty" json:"select_next_panel,omitempty"`
+ SelectNextPanelSecondary []string `toml:"-" json:"-"`
+ SelectPreviousPanel []string `toml:"select_previous_panel,omitempty" json:"select_previous_panel,omitempty"`
+ SelectPreviousPanelSecondary []string `toml:"-" json:"-"`
+ SortByName []string `toml:"sort_by_name,omitempty" json:"sort_by_name,omitempty"`
+ SortByNameSecondary []string `toml:"-" json:"-"`
+ SortByState []string `toml:"sort_by_state,omitempty" json:"sort_by_state,omitempty"`
+ SortByStateSecondary []string `toml:"-" json:"-"`
+ SortByStatus []string `toml:"sort_by_status,omitempty" json:"sort_by_status,omitempty"`
+ SortByStatusSecondary []string `toml:"-" json:"-"`
+ SortByCPU []string `toml:"sort_by_cpu,omitempty" json:"sort_by_cpu,omitempty"`
+ SortByCPUSecondary []string `toml:"-" json:"-"`
+ SortByMemory []string `toml:"sort_by_memory,omitempty" json:"sort_by_memory,omitempty"`
+ SortByMemorySecondary []string `toml:"-" json:"-"`
+ SortByID []string `toml:"sort_by_id,omitempty" json:"sort_by_id,omitempty"`
+ SortByIDSecondary []string `toml:"-" json:"-"`
+ SortByImage []string `toml:"sort_by_image,omitempty" json:"sort_by_image,omitempty"`
+ SortByImageSecondary []string `toml:"-" json:"-"`
+ SortByRX []string `toml:"sort_by_rx,omitempty" json:"sort_by_rx,omitempty"`
+ SortByRXSecondary []string `toml:"-" json:"-"`
+ SortByTX []string `toml:"sort_by_tx,omitempty" json:"sort_by_tx,omitempty"`
+ SortByTXSecondary []string `toml:"-" json:"-"`
+ SortReset []string `toml:"sort_reset,omitempty" json:"sort_reset,omitempty"`
+ SortResetSecondary []string `toml:"-" json:"-"`
+ ToggleHelp []string `toml:"toggle_help,omitempty" json:"toggle_help,omitempty"`
+ ToggleHelpSecondary []string `toml:"-" json:"-"`
+ ToggleMouseCapture []string `toml:"toggle_mouse_capture,omitempty" json:"toggle_mouse_capture,omitempty"`
+ ToggleMouseCaptureSecondary []string `toml:"-" json:"-"`
+ LogSectionToggleAlt []string `toml:"-" json:"-"`
+ ScrollMany string `toml:"scroll_many,omitempty" json:"scroll_many,omitempty"`
+}
+
+type Config struct {
+ AppColors AppColors `toml:"colors,omitempty" json:"colors,omitempty"`
+ ColorLogs bool `toml:"color_logs,omitempty" json:"color_logs,omitempty"`
+ DirSave string `toml:"save_dir,omitempty" json:"save_dir,omitempty"`
+ DirConfig string `toml:"-" json:"-"`
+ DockerIntervalMs int `toml:"docker_interval,omitempty" json:"docker_interval,omitempty"`
+ GUI bool `toml:"gui,omitempty" json:"gui,omitempty"`
+ Host string `toml:"host,omitempty" json:"host,omitempty"`
+ InContainer bool `toml:"-" json:"-"`
+ Keymap Keymap `toml:"keymap,omitempty" json:"keymap,omitempty"`
+ LogSearchCaseSensitive bool `toml:"log_search_case_sensitive,omitempty" json:"log_search_case_sensitive,omitempty"`
+ RawLogs bool `toml:"raw_logs,omitempty" json:"raw_logs,omitempty"`
+ ShowLogs bool `toml:"show_logs,omitempty" json:"show_logs,omitempty"`
+ ShowSelf bool `toml:"show_self,omitempty" json:"show_self,omitempty"`
+ ShowStdErr bool `toml:"show_std_err,omitempty" json:"show_std_err,omitempty"`
+ ShowTimestamp bool `toml:"show_timestamp,omitempty" json:"show_timestamp,omitempty"`
+ TimestampFormat string `toml:"timestamp_format,omitempty" json:"timestamp_format,omitempty"`
+ Timezone string `toml:"timezone,omitempty" json:"timezone,omitempty"`
+ UseCLI bool `toml:"use_cli,omitempty" json:"use_cli,omitempty"`
+}
+
+func NewConfig() *Config {
+ return &Config{
+ DockerIntervalMs: 1000,
+ ShowLogs: true,
+ ColorLogs: false,
+ RawLogs: false,
+ ShowTimestamp: true,
+ TimestampFormat: "%Y-%m-%dT%H:%M:%S.%8f",
+ Timezone: "UTC",
+ UseCLI: false,
+ GUI: true,
+ LogSearchCaseSensitive: true,
+ ShowSelf: false,
+ ShowStdErr: true,
+ DirSave: "",
+ Host: "",
+ }
+}
+
+func LoadConfigPath(path string, cfg *Config) error {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+
+ content := string(data)
+
+ if strings.HasSuffix(path, ".toml") {
+ _, err = toml.Decode(content, cfg)
+ if err != nil {
+ return err
+ }
+ } else if strings.HasSuffix(path, ".jsonc") || strings.HasSuffix(path, ".json") {
+ err = parseJSONC(content, cfg)
+ if err != nil {
+ return err
+ }
+ }
+
+ cfg.DirConfig = path
+ return nil
+}
+
+func LoadConfig(path string) (*Config, error) {
+ cfg := NewConfig()
+ err := LoadConfigPath(path, cfg)
+ if err != nil {
+ return nil, err
+ }
+ return cfg, nil
+}
+
+func parseJSONC(content string, cfg *Config) error {
+ var raw map[string]interface{}
+ commentsRemoved := removeJSONCComments(content)
+ err := json.Unmarshal([]byte(commentsRemoved), &raw)
+ if err != nil {
+ return err
+ }
+
+ if colors, ok := raw["colors"].(map[string]interface{}); ok {
+ cfg.AppColors = parseAppColors(colors)
+ }
+
+ if keymap, ok := raw["keymap"].(map[string]interface{}); ok {
+ cfg.Keymap = parseKeymap(keymap)
+ }
+
+ if val, ok := raw["color_logs"].(bool); ok {
+ cfg.ColorLogs = val
+ }
+ if val, ok := raw["docker_interval"].(float64); ok {
+ cfg.DockerIntervalMs = int(val)
+ }
+ if val, ok := raw["gui"].(bool); ok {
+ cfg.GUI = val
+ }
+ if val, ok := raw["host"].(string); ok {
+ cfg.Host = val
+ }
+ if val, ok := raw["log_search_case_sensitive"].(bool); ok {
+ cfg.LogSearchCaseSensitive = val
+ }
+ if val, ok := raw["raw_logs"].(bool); ok {
+ cfg.RawLogs = val
+ }
+ if val, ok := raw["save_dir"].(string); ok {
+ cfg.DirSave = val
+ }
+ if val, ok := raw["show_logs"].(bool); ok {
+ cfg.ShowLogs = val
+ }
+ if val, ok := raw["show_self"].(bool); ok {
+ cfg.ShowSelf = val
+ }
+ if val, ok := raw["show_std_err"].(bool); ok {
+ cfg.ShowStdErr = val
+ }
+ if val, ok := raw["show_timestamp"].(bool); ok {
+ cfg.ShowTimestamp = val
+ }
+ if val, ok := raw["timestamp_format"].(string); ok {
+ cfg.TimestampFormat = val
+ }
+ if val, ok := raw["timezone"].(string); ok {
+ cfg.Timezone = val
+ }
+ if val, ok := raw["use_cli"].(bool); ok {
+ cfg.UseCLI = val
+ }
+
+ return nil
+}
+
+func removeJSONCComments(content string) string {
+ lines := strings.Split(content, "\n")
+ var result []string
+ for _, line := range lines {
+ trimmed := strings.TrimSpace(line)
+ if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || strings.HasPrefix(trimmed, "*") || strings.HasPrefix(trimmed, "*/") {
+ continue
+ }
+ if idx := strings.Index(trimmed, "//"); idx >= 0 {
+ result = append(result, strings.TrimSpace(line[:idx]))
+ } else {
+ result = append(result, line)
+ }
+ }
+ return strings.Join(result, "\n")
+}
+
+func parseAppColors(colors map[string]interface{}) AppColors {
+ acc := AppColors{}
+ if borders, ok := colors["borders"].(map[string]interface{}); ok {
+ acc.Borders = Borders{
+ Selected: toString(borders["selected"]),
+ Unselected: toString(borders["unselected"]),
+ }
+ }
+ if chartCPU, ok := colors["chart_cpu"].(map[string]interface{}); ok {
+ acc.ChartCPU = ChartCPU{
+ Background: toString(chartCPU["background"]),
+ Border: toString(chartCPU["border"]),
+ Title: toString(chartCPU["title"]),
+ Max: toString(chartCPU["max"]),
+ Points: toString(chartCPU["points"]),
+ YAxis: toString(chartCPU["y_axis"]),
+ }
+ }
+ if chartMemory, ok := colors["chart_memory"].(map[string]interface{}); ok {
+ acc.ChartMemory = ChartMemory{
+ Background: toString(chartMemory["background"]),
+ Border: toString(chartMemory["border"]),
+ Title: toString(chartMemory["title"]),
+ Max: toString(chartMemory["max"]),
+ Points: toString(chartMemory["points"]),
+ YAxis: toString(chartMemory["y_axis"]),
+ }
+ }
+ if chartBandwidth, ok := colors["chart_bandwidth"].(map[string]interface{}); ok {
+ acc.ChartBandwidth = ChartBandwidth{
+ Background: toString(chartBandwidth["background"]),
+ Border: toString(chartBandwidth["border"]),
+ MaxRX: toString(chartBandwidth["max_rx"]),
+ MaxTX: toString(chartBandwidth["max_tx"]),
+ PointsRX: toString(chartBandwidth["points_rx"]),
+ PointsTX: toString(chartBandwidth["points_tx"]),
+ TitleRX: toString(chartBandwidth["title_rx"]),
+ TitleTX: toString(chartBandwidth["title_tx"]),
+ YAxis: toString(chartBandwidth["y_axis"]),
+ }
+ }
+ if chartPorts, ok := colors["chart_ports"].(map[string]interface{}); ok {
+ acc.ChartPorts = ChartPorts{
+ Background: toString(chartPorts["background"]),
+ Border: toString(chartPorts["border"]),
+ Title: toString(chartPorts["title"]),
+ Headings: toString(chartPorts["headings"]),
+ Text: toString(chartPorts["text"]),
+ }
+ }
+ if commands, ok := colors["commands"].(map[string]interface{}); ok {
+ acc.Commands = Commands{
+ Background: toString(commands["background"]),
+ Pause: toString(commands["pause"]),
+ Restart: toString(commands["restart"]),
+ Stop: toString(commands["stop"]),
+ Delete: toString(commands["delete"]),
+ Resume: toString(commands["resume"]),
+ Start: toString(commands["start"]),
+ }
+ }
+ if containerState, ok := colors["container_state"].(map[string]interface{}); ok {
+ acc.ContainerState = ContainerState{
+ Dead: toString(containerState["dead"]),
+ Exited: toString(containerState["exited"]),
+ Paused: toString(containerState["paused"]),
+ Removing: toString(containerState["removing"]),
+ Restarting: toString(containerState["restarting"]),
+ RunningHealthy: toString(containerState["running_healthy"]),
+ RunningUnhealthy: toString(containerState["running_unhealthy"]),
+ Unknown: toString(containerState["unknown"]),
+ }
+ }
+ if containers, ok := colors["containers"].(map[string]interface{}); ok {
+ acc.Containers = Containers{
+ Background: toString(containers["background"]),
+ Icon: toString(containers["icon"]),
+ Text: toString(containers["text"]),
+ TextRX: toString(containers["text_rx"]),
+ TextTX: toString(containers["text_tx"]),
+ }
+ }
+ if logSearch, ok := colors["log_search"].(map[string]interface{}); ok {
+ acc.LogSearch = LogSearch{
+ Background: toString(logSearch["background"]),
+ Text: toString(logSearch["text"]),
+ ButtonText: toString(logSearch["button_text"]),
+ Highlight: toString(logSearch["highlight"]),
+ }
+ }
+ if filter, ok := colors["filter"].(map[string]interface{}); ok {
+ acc.Filter = Filter{
+ Background: toString(filter["background"]),
+ Text: toString(filter["text"]),
+ SelectedFilterBackground: toString(filter["selected_filter_background"]),
+ SelectedFilterText: toString(filter["selected_filter_text"]),
+ Highlight: toString(filter["highlight"]),
+ }
+ }
+ if headersBar, ok := colors["headers_bar"].(map[string]interface{}); ok {
+ acc.HeadersBar = HeadersBar{
+ Background: toString(headersBar["background"]),
+ LoadingSpinner: toString(headersBar["loading_spinner"]),
+ Text: toString(headersBar["text"]),
+ TextSelected: toString(headersBar["text_selected"]),
+ }
+ }
+ if logs, ok := colors["logs"].(map[string]interface{}); ok {
+ acc.Logs = Logs{
+ Background: toString(logs["background"]),
+ Text: toString(logs["text"]),
+ }
+ }
+ if popupDelete, ok := colors["popup_delete"].(map[string]interface{}); ok {
+ acc.PopupDelete = PopupDelete{
+ Background: toString(popupDelete["background"]),
+ Text: toString(popupDelete["text"]),
+ TextHighlight: toString(popupDelete["text_highlight"]),
+ }
+ }
+ if popupError, ok := colors["popup_error"].(map[string]interface{}); ok {
+ acc.PopupError = PopupError{
+ Background: toString(popupError["background"]),
+ Text: toString(popupError["text"]),
+ }
+ }
+ if popupHelp, ok := colors["popup_help"].(map[string]interface{}); ok {
+ acc.PopupHelp = PopupHelp{
+ Background: toString(popupHelp["background"]),
+ Text: toString(popupHelp["text"]),
+ TextHighlight: toString(popupHelp["text_highlight"]),
+ }
+ }
+ if popupInfo, ok := colors["popup_info"].(map[string]interface{}); ok {
+ acc.PopupInfo = PopupInfo{
+ Background: toString(popupInfo["background"]),
+ Text: toString(popupInfo["text"]),
+ }
+ }
+ return acc
+}
+
+func parseKeymap(keymap map[string]interface{}) Keymap {
+ k := Keymap{}
+ applyKeymapEntry(&k.Clear, keymap, "clear")
+ applyKeymapEntry(&k.DeleteConfirm, keymap, "delete_confirm")
+ applyKeymapEntry(&k.DeleteDeny, keymap, "delete_deny")
+ applyKeymapEntry(&k.Exec, keymap, "exec")
+ applyKeymapEntry(&k.FilterMode, keymap, "filter_mode")
+ applyKeymapEntry(&k.Inspect, keymap, "inspect")
+ applyKeymapEntry(&k.ForceRedraw, keymap, "force_redraw")
+ applyKeymapEntry(&k.ScrollBack, keymap, "scroll_back")
+ applyKeymapEntry(&k.ScrollForward, keymap, "scroll_forward")
+ applyKeymapEntry(&k.LogSearchMode, keymap, "log_search_mode")
+ applyKeymapEntry(&k.LogSectionHeightDecrease, keymap, "log_section_height_decrease")
+ applyKeymapEntry(&k.LogSectionHeightIncrease, keymap, "log_section_height_increase")
+ applyKeymapEntry(&k.LogSectionToggle, keymap, "log_section_toggle")
+ applyKeymapEntry(&k.Quit, keymap, "quit")
+ applyKeymapEntry(&k.SaveLogs, keymap, "save_logs")
+ applyKeymapEntry(&k.ScrollDown, keymap, "scroll_down")
+ applyKeymapEntry(&k.ScrollEnd, keymap, "scroll_end")
+ applyKeymapEntry(&k.ScrollStart, keymap, "scroll_start")
+ applyKeymapEntry(&k.ScrollUp, keymap, "scroll_up")
+ applyKeymapEntry(&k.SelectNextPanel, keymap, "select_next_panel")
+ applyKeymapEntry(&k.SelectPreviousPanel, keymap, "select_previous_panel")
+ applyKeymapEntry(&k.SortByName, keymap, "sort_by_name")
+ applyKeymapEntry(&k.SortByState, keymap, "sort_by_state")
+ applyKeymapEntry(&k.SortByStatus, keymap, "sort_by_status")
+ applyKeymapEntry(&k.SortByCPU, keymap, "sort_by_cpu")
+ applyKeymapEntry(&k.SortByMemory, keymap, "sort_by_memory")
+ applyKeymapEntry(&k.SortByID, keymap, "sort_by_id")
+ applyKeymapEntry(&k.SortByImage, keymap, "sort_by_image")
+ applyKeymapEntry(&k.SortByRX, keymap, "sort_by_rx")
+ applyKeymapEntry(&k.SortByTX, keymap, "sort_by_tx")
+ applyKeymapEntry(&k.SortReset, keymap, "sort_reset")
+ applyKeymapEntry(&k.ToggleHelp, keymap, "toggle_help")
+ applyKeymapEntry(&k.ToggleMouseCapture, keymap, "toggle_mouse_capture")
+ if scrollMany, ok := keymap["scroll_many"].(string); ok {
+ k.ScrollMany = scrollMany
+ }
+ return k
+}
+
+func applyKeymapEntry(target *[]string, keymap map[string]interface{}, key string) {
+ if val, ok := keymap[key].([]interface{}); ok {
+ for _, v := range val {
+ if s, ok := v.(string); ok {
+ *target = append(*target, s)
+ }
+ }
+ }
+}
+
+func toString(val interface{}) string {
+ if s, ok := val.(string); ok {
+ return s
+ }
+ return ""
+}
+
+func (c *Config) SaveConfig() error {
+ path := c.DirConfig
+ if path == "" {
+ var err error
+ path, err = GetDefaultConfigPath()
+ if err != nil {
+ return err
+ }
+ }
+
+ file, err := os.Create(path)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ if strings.HasSuffix(path, ".toml") {
+ return toml.NewEncoder(file).Encode(c)
+ } else if strings.HasSuffix(path, ".jsonc") || strings.HasSuffix(path, ".json") {
+ data, err := json.MarshalIndent(c, "", " ")
+ if err != nil {
+ return err
+ }
+ _, err = file.Write(data)
+ return err
+ }
+
+ return nil
+}
+
+func GetDefaultConfigPath() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ configDir := filepath.Join(home, ".config", "oxker")
+ return filepath.Join(configDir, "config.toml"), nil
+}
+
+func (c *Config) MergeCLIArgs(cliArgs *CLIArgs) {
+ if cliArgs == nil {
+ return
+ }
+ if cliArgs.DockerIntervalMs > 0 {
+ c.DockerIntervalMs = cliArgs.DockerIntervalMs
+ }
+ c.RawLogs = cliArgs.RawLogs
+ c.ColorLogs = cliArgs.ColorLogs
+ c.ShowTimestamp = !cliArgs.ShowTimestamp
+ c.UseCLI = cliArgs.UseCLI
+ c.GUI = !cliArgs.DebugMode
+ if cliArgs.Host != "" {
+ c.Host = cliArgs.Host
+ }
+ c.DirConfig = cliArgs.ConfigFile
+}
+
+type CLIArgs struct {
+ DockerIntervalMs int
+ RawLogs bool
+ ColorLogs bool
+ ShowTimestamp bool
+ ShowSelf bool
+ DebugMode bool
+ ConfigFile string
+ Host string
+ NoStdErr bool
+ SaveDir string
+ Timezone string
+ UseCLI bool
+}
diff --git a/internal/docker/client.go b/internal/docker/client.go
new file mode 100644
index 0000000..66a82d6
--- /dev/null
+++ b/internal/docker/client.go
@@ -0,0 +1,112 @@
+package docker
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+
+ "github.com/docker/docker/api/types"
+ "github.com/docker/docker/api/types/container"
+ "github.com/docker/docker/client"
+)
+
+type Client struct {
+ cli *client.Client
+ ctx context.Context
+ cancel context.CancelFunc
+}
+
+func New(host ...string) (*Client, error) {
+ opts := []client.Opt{client.FromEnv, client.WithAPIVersionNegotiation()}
+ if len(host) > 0 && host[0] != "" {
+ opts = append(opts, client.WithHost(host[0]))
+ }
+ cli, err := client.NewClientWithOpts(opts...)
+ if err != nil {
+ return nil, err
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ return &Client{cli: cli, ctx: ctx, cancel: cancel}, nil
+}
+
+// Cancel cancels all in-flight Docker API calls.
+func (c *Client) Cancel() {
+ c.cancel()
+}
+
+func (c *Client) Ping() error {
+ _, err := c.cli.Ping(c.ctx)
+ return err
+}
+
+func (c *Client) ListContainers(all bool) ([]types.Container, error) {
+ opts := container.ListOptions{All: all}
+ return c.cli.ContainerList(c.ctx, opts)
+}
+
+func (c *Client) InspectContainer(id string) (types.ContainerJSON, error) {
+ return c.cli.ContainerInspect(c.ctx, id)
+}
+
+func (c *Client) StartContainer(id string) error {
+ return c.cli.ContainerStart(c.ctx, id, container.StartOptions{})
+}
+
+func (c *Client) StopContainer(id string) error {
+ return c.cli.ContainerStop(c.ctx, id, container.StopOptions{})
+}
+
+func (c *Client) RestartContainer(id string) error {
+ return c.cli.ContainerRestart(c.ctx, id, container.StopOptions{})
+}
+
+func (c *Client) DeleteContainer(id string, force bool) error {
+ return c.cli.ContainerRemove(c.ctx, id, container.RemoveOptions{Force: force})
+}
+
+func (c *Client) PauseContainer(id string) error {
+ return c.cli.ContainerPause(c.ctx, id)
+}
+
+func (c *Client) UnpauseContainer(id string) error {
+ return c.cli.ContainerUnpause(c.ctx, id)
+}
+
+func (c *Client) Logs(id string, options container.LogsOptions) (io.ReadCloser, error) {
+ return c.cli.ContainerLogs(c.ctx, id, options)
+}
+
+// ContainerStats gets a single stats snapshot for a container.
+// Uses stream=false (NOT one-shot) so Docker fills in precpu_stats for accurate CPU delta.
+func (c *Client) ContainerStats(id string) (*container.StatsResponse, error) {
+ resp, err := c.cli.ContainerStats(c.ctx, id, false)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var stats container.StatsResponse
+ if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
+ return nil, err
+ }
+ return &stats, nil
+}
+
+func (c *Client) ContainerExec(id string, cmd []string, tty bool) (types.HijackedResponse, error) {
+ execConfig := container.ExecOptions{
+ Cmd: cmd,
+ Tty: tty,
+ AttachStdin: true,
+ AttachStdout: true,
+ AttachStderr: true,
+ }
+ exec, err := c.cli.ContainerExecCreate(c.ctx, id, execConfig)
+ if err != nil {
+ return types.HijackedResponse{}, err
+ }
+ return c.cli.ContainerExecAttach(c.ctx, exec.ID, container.ExecStartOptions{})
+}
+
+func (c *Client) Close() error {
+ return c.cli.Close()
+}
diff --git a/internal/utils/ratelimiter.go b/internal/utils/ratelimiter.go
new file mode 100644
index 0000000..421f433
--- /dev/null
+++ b/internal/utils/ratelimiter.go
@@ -0,0 +1,29 @@
+package utils
+
+import (
+ "time"
+)
+
+type RateLimiter struct {
+ lastTime time.Time
+ minInterval time.Duration
+}
+
+func NewRateLimiter(interval time.Duration) *RateLimiter {
+ return &RateLimiter{
+ minInterval: interval,
+ }
+}
+
+func (r *RateLimiter) Allow() bool {
+ now := time.Now()
+ if now.Sub(r.lastTime) >= r.minInterval {
+ r.lastTime = now
+ return true
+ }
+ return false
+}
+
+func (r *RateLimiter) Reset() {
+ r.lastTime = time.Time{}
+}
diff --git a/internal/utils/scroll.go b/internal/utils/scroll.go
new file mode 100644
index 0000000..6297b63
--- /dev/null
+++ b/internal/utils/scroll.go
@@ -0,0 +1,31 @@
+package utils
+
+type ScrollDirection int
+
+const (
+ ScrollUp ScrollDirection = iota
+ ScrollDown
+ ScrollPageUp
+ ScrollPageDown
+ ScrollHome
+ ScrollEnd
+)
+
+func (sd ScrollDirection) String() string {
+ switch sd {
+ case ScrollUp:
+ return "up"
+ case ScrollDown:
+ return "down"
+ case ScrollPageUp:
+ return "pageup"
+ case ScrollPageDown:
+ return "pagedown"
+ case ScrollHome:
+ return "home"
+ case ScrollEnd:
+ return "end"
+ default:
+ return "unknown"
+ }
+}
diff --git a/internal/utils/stats.go b/internal/utils/stats.go
new file mode 100644
index 0000000..b7ebbda
--- /dev/null
+++ b/internal/utils/stats.go
@@ -0,0 +1,24 @@
+package utils
+
+type ContainerStats struct {
+ ID string
+ CPU float64
+ Memory uint64
+ MemoryLimit uint64
+ RX uint64
+ TX uint64
+}
+
+func ContainerStatsFromDocker(stats interface{}, id string) *ContainerStats {
+ var cpu float64
+ var mem, memLimit, rx, tx uint64
+
+ return &ContainerStats{
+ ID: id,
+ CPU: cpu,
+ Memory: mem,
+ MemoryLimit: memLimit,
+ RX: rx,
+ TX: tx,
+ }
+}
diff --git a/internal/utils/table.go b/internal/utils/table.go
new file mode 100644
index 0000000..216c9d8
--- /dev/null
+++ b/internal/utils/table.go
@@ -0,0 +1,26 @@
+package utils
+
+import (
+ "strings"
+
+ "github.com/olekukonko/tablewriter"
+)
+
+func FormatTable(data [][]string) string {
+ var sb strings.Builder
+ table := tablewriter.NewWriter(&sb)
+ table.SetHeader([]string{})
+ table.SetBorder(false)
+ table.SetColMinWidth(0, 10)
+ table.SetColMinWidth(1, 20)
+ table.SetColMinWidth(2, 25)
+ table.SetColMinWidth(3, 15)
+ table.SetColMinWidth(4, 20)
+
+ for _, row := range data {
+ table.Append(row)
+ }
+
+ table.Render()
+ return sb.String()
+}
diff --git a/source/oxker b/source/oxker
new file mode 160000
index 0000000..e020eb1
--- /dev/null
+++ b/source/oxker
@@ -0,0 +1 @@
+Subproject commit e020eb157ca317b0a2ba100f0024f444698552a2
diff --git a/src/app_data/container_state.rs b/src/app_data/container_state.rs
deleted file mode 100644
index 252d982..0000000
--- a/src/app_data/container_state.rs
+++ /dev/null
@@ -1,1684 +0,0 @@
-use std::{
- cmp::Ordering,
- collections::{HashSet, VecDeque},
- fmt,
- net::IpAddr,
-};
-
-use bollard::secret::{ContainerSummaryHealthStatusEnum, PortSummary};
-use jiff::{Timestamp, tz::TimeZone};
-use ratatui::{
- layout::Size,
- style::Color,
- text::{Line, Text},
- widgets::ListState,
-};
-
-use crate::config::AppColors;
-
-use super::Header;
-
-const ONE_KB: f64 = 1000.0;
-const ONE_MB: f64 = ONE_KB * 1000.0;
-const ONE_GB: f64 = ONE_MB * 1000.0;
-
-#[derive(Debug, Clone, Eq, Hash, PartialEq)]
-pub enum ScrollDirection {
- // Next,
- // Previous,
- Up,
- Down,
- Left,
- Right,
-}
-
-#[derive(Debug, Clone, Eq, Hash, PartialEq)]
-pub struct ContainerId(String);
-
-impl From<&str> for ContainerId {
- fn from(x: &str) -> Self {
- Self(x.to_owned())
- }
-}
-
-impl ContainerId {
- pub const fn get(&self) -> &str {
- self.0.as_str()
- }
-
- /// Only return first 8 chars of id, is usually more than enough for uniqueness
- /// need to update tests to use real ids, or atleast strings of the correct-ish length
- pub fn get_short(&self) -> String {
- self.0.chars().take(8).collect::()
- }
-}
-
-impl Ord for ContainerId {
- fn cmp(&self, other: &Self) -> Ordering {
- self.0.cmp(&other.0)
- }
-}
-
-impl PartialOrd for ContainerId {
- fn partial_cmp(&self, other: &Self) -> Option {
- Some(self.cmp(other))
- }
-}
-
-pub trait Contains {
- fn contains(&self, input: &str) -> bool;
-}
-/// ContainerName and ContainerImage are simple structs, used so can implement custom fmt functions to them
-macro_rules! unit_struct {
- ($name:ident) => {
- #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
- pub struct $name(String);
-
- impl From for $name {
- fn from(value: String) -> Self {
- Self(value)
- }
- }
-
- #[cfg(test)]
- impl From<&str> for $name {
- fn from(value: &str) -> Self {
- Self(value.to_owned())
- }
- }
-
- impl $name {
- pub const fn get(&self) -> &str {
- self.0.as_str()
- }
-
- pub fn set(&mut self, value: String) {
- self.0 = value;
- }
- }
-
- impl Contains for $name {
- fn contains(&self, input: &str) -> bool {
- self.0.to_lowercase().contains(input)
- }
- }
-
- impl std::fmt::Display for $name {
- fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
- if self.0.chars().count() >= 30 {
- write!(f, "{}…", self.0.chars().take(29).collect::())
- } else {
- write!(f, "{}", self.0)
- }
- }
- }
- };
-}
-
-unit_struct!(ContainerName);
-unit_struct!(ContainerImage);
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct ContainerPorts {
- pub ip: Option,
- pub private: u16,
- pub public: Option,
-}
-
-impl From for ContainerPorts {
- fn from(value: PortSummary) -> Self {
- Self {
- ip: value.ip.and_then(|i| i.parse::().ok()),
- private: value.private_port,
- public: value.public_port,
- }
- }
-}
-
-impl ContainerPorts {
- pub fn len_ip(&self) -> usize {
- self.ip
- .as_ref()
- .map_or(0, |i| i.to_string().chars().count())
- }
- pub fn len_private(&self) -> usize {
- format!("{}", self.private).chars().count()
- }
- pub fn len_public(&self) -> usize {
- format!("{}", self.public.unwrap_or_default())
- .chars()
- .count()
- }
-
- /// Return as tuple of Strings, ip address, private port, and public port
- pub fn get_all(&self) -> (String, String, String) {
- (
- self.ip
- .as_ref()
- .map_or(String::new(), std::string::ToString::to_string),
- format!("{}", self.private),
- self.public.map_or(String::new(), |s| s.to_string()),
- )
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct StatefulList {
- pub state: ListState,
- pub items: Vec,
-}
-
-impl StatefulList {
- pub fn new(items: Vec) -> Self {
- Self {
- state: ListState::default(),
- items,
- }
- }
-
- pub fn end(&mut self) {
- let len = self.items.len();
- if len > 0 {
- self.state.select(Some(self.items.len() - 1));
- }
- }
-
- pub fn start(&mut self) {
- self.state.select(Some(0));
- }
-
- pub fn scroll(&mut self, scroll: &ScrollDirection) {
- match scroll {
- ScrollDirection::Down => self.next(),
- ScrollDirection::Up => self.previous(),
- // TODO set offset
- _ => (),
- }
- }
-
- fn next(&mut self) {
- if !self.items.is_empty() {
- self.state.select(Some(
- self.state.selected().map_or(
- 0,
- |i| {
- if i < self.items.len() - 1 { i + 1 } else { i }
- },
- ),
- ));
- }
- }
-
- fn previous(&mut self) {
- if !self.items.is_empty() {
- self.state.select(Some(
- self.state
- .selected()
- .map_or(0, |i| if i == 0 { 0 } else { i - 1 }),
- ));
- }
- }
-
- /// Return the current status of the select list, e.g. 2/5,
- /// MAYBE add up down arrows, check if at start or end etc
- pub fn get_state_title(&self) -> String {
- if self.items.is_empty() {
- String::new()
- } else {
- let len = self.items.len();
- let count = self
- .state
- .selected()
- .map_or(0, |value| if len > 0 { value + 1 } else { value });
- format!(" {count}/{len}")
- }
- }
-}
-
-/// Store the containers status in a struct, so can then check for healthy/unhealthy status
-/// It's usually something like "Up 1 hour", "Exited (0) 10 hours ago", "Up 10 minutes (unhealthy)"
-#[derive(Debug, Clone, Eq, PartialEq, PartialOrd)]
-pub struct ContainerStatus(String);
-
-impl From for ContainerStatus {
- fn from(value: String) -> Self {
- Self(value)
- }
-}
-
-impl ContainerStatus {
- /// Check if a container is unhealthy
- pub fn unhealthy(&self) -> bool {
- self.contains("(unhealthy)")
- }
-
- /// Get a reference to the source string
- pub const fn get(&self) -> &String {
- &self.0
- }
-}
-
-impl Contains for ContainerStatus {
- /// Check if the state contains a specific string
- fn contains(&self, item: &str) -> bool {
- self.0.to_lowercase().contains(item)
- }
-}
-
-/// By default a container's running status will be healthy
-#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd)]
-pub enum RunningState {
- Healthy,
- Unhealthy,
-}
-/// States of the container
-#[derive(Debug, Clone, Copy, Eq, PartialEq)]
-pub enum State {
- Dead,
- Exited,
- Paused,
- Removing,
- Restarting,
- Running(RunningState),
- Unknown,
-}
-
-impl State {
- /// The container is alive if the start is Running, either healthy or unhealthy
- pub const fn is_alive(self) -> bool {
- matches!(self, Self::Running(_))
- }
-
- /// Check if state is running & healthy
- pub const fn is_healthy(self) -> bool {
- match self {
- Self::Running(x) => match x {
- RunningState::Healthy => true,
- RunningState::Unhealthy => false,
- },
- _ => false,
- }
- }
- /// Color of the state for the containers section
- pub const fn get_color(self, colors: AppColors) -> Color {
- match self {
- Self::Dead => colors.container_state.dead,
- Self::Exited => colors.container_state.exited,
- Self::Paused => colors.container_state.paused,
- Self::Removing => colors.container_state.removing,
- Self::Restarting => colors.container_state.restarting,
- Self::Running(RunningState::Healthy) => colors.container_state.running_healthy,
- Self::Running(RunningState::Unhealthy) => colors.container_state.running_unhealthy,
- Self::Unknown => colors.container_state.unknown,
- }
- }
- /// Dirty way to create order for the state, rather than impl Ord
- pub const fn order(self) -> u8 {
- match self {
- Self::Running(RunningState::Healthy) => 0,
- Self::Running(RunningState::Unhealthy) => 1,
- Self::Paused => 2,
- Self::Restarting => 3,
- Self::Removing => 4,
- Self::Exited => 5,
- Self::Dead => 6,
- Self::Unknown => 7,
- }
- }
-}
-
-/// Need status, to check if container is unhealthy or not
-impl From<(&str, &ContainerStatus)> for State {
- fn from((input, status): (&str, &ContainerStatus)) -> Self {
- match input {
- "dead" => Self::Dead,
- "exited" => Self::Exited,
- "paused" => Self::Paused,
- "removing" => Self::Removing,
- "restarting" => Self::Restarting,
- "running" => {
- if status.unhealthy() {
- Self::Running(RunningState::Unhealthy)
- } else {
- Self::Running(RunningState::Healthy)
- }
- }
- _ => Self::Unknown,
- }
- }
-}
-
-/// Need status, to check if container is unhealthy or not
-impl
- From<(
- &bollard::secret::ContainerSummaryStateEnum,
- &ContainerStatus,
- )> for State
-{
- fn from(
- (input, status): (
- &bollard::secret::ContainerSummaryStateEnum,
- &ContainerStatus,
- ),
- ) -> Self {
- match input {
- bollard::secret::ContainerSummaryStateEnum::DEAD => Self::Dead,
- bollard::secret::ContainerSummaryStateEnum::EXITED => Self::Exited,
- bollard::secret::ContainerSummaryStateEnum::PAUSED => Self::Paused,
- bollard::secret::ContainerSummaryStateEnum::REMOVING => Self::Removing,
- bollard::secret::ContainerSummaryStateEnum::RESTARTING => Self::Restarting,
- bollard::secret::ContainerSummaryStateEnum::RUNNING => {
- if status.unhealthy() {
- Self::Running(RunningState::Unhealthy)
- } else {
- Self::Running(RunningState::Healthy)
- }
- }
- _ => Self::Unknown,
- }
- }
-}
-
-/// Again, need status, to check if container is unhealthy or not
-impl
- From<(
- Option<&bollard::secret::ContainerSummaryStateEnum>,
- &ContainerStatus,
- )> for State
-{
- fn from(
- (input, status): (
- Option<&bollard::secret::ContainerSummaryStateEnum>,
- &ContainerStatus,
- ),
- ) -> Self {
- input.map_or(Self::Unknown, |input| Self::from((input, status)))
- }
-}
-
-/// Again, need status, to check if container is unhealthy or not
-impl From<(Option, &ContainerStatus)> for State {
- fn from((input, status): (Option, &ContainerStatus)) -> Self {
- input.map_or(Self::Unknown, |input| Self::from((input.as_str(), status)))
- }
-}
-
-impl fmt::Display for State {
- fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
- let disp = match self {
- Self::Dead => "✖ dead",
- Self::Exited => "✖ exited",
- Self::Paused => "॥ paused",
- Self::Removing => "removing",
- Self::Restarting => "↻ restarting",
- Self::Running(RunningState::Healthy) => "✓ running",
- Self::Running(RunningState::Unhealthy) => "! running",
- Self::Unknown => "? unknown",
- };
- write!(f, "{disp}")
- }
-}
-
-/// Items for the container control list
-#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
-pub enum DockerCommand {
- Pause,
- Restart,
- Start,
- Stop,
- Resume,
- Delete,
-}
-
-impl DockerCommand {
- pub const fn get_color(self, colors: AppColors) -> Color {
- match self {
- Self::Pause => colors.commands.pause,
- Self::Restart => colors.commands.restart,
- Self::Start => colors.commands.start,
- Self::Stop => colors.commands.stop,
- Self::Delete => colors.commands.delete,
- Self::Resume => colors.commands.resume,
- }
- }
-
- /// Docker commands available depending on the containers state
- pub fn gen_vec(state: State) -> Vec {
- match state {
- State::Dead | State::Exited => vec![Self::Start, Self::Restart, Self::Delete],
- State::Paused => vec![Self::Resume, Self::Stop, Self::Delete],
- State::Restarting => vec![Self::Stop, Self::Delete],
- State::Running(_) => vec![Self::Pause, Self::Restart, Self::Stop, Self::Delete],
- _ => vec![Self::Delete],
- }
- }
-}
-
-impl fmt::Display for DockerCommand {
- fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
- let disp = match self {
- Self::Pause => "pause",
- Self::Delete => "delete",
- Self::Restart => "restart",
- Self::Start => "start",
- Self::Stop => "stop",
- Self::Resume => "resume",
- };
- write!(f, "{disp}")
- }
-}
-
-pub trait Stats {
- fn get_value(&self) -> f64;
-}
-
-/// Struct for frequently updated CPU stats
-/// So can use custom display formatter
-/// Use trait Stats for use as generic in draw_chart function
-#[derive(Debug, Default, Clone, Copy)]
-pub struct CpuStats(f64);
-
-impl CpuStats {
- pub const fn new(value: f64) -> Self {
- Self(value)
- }
-}
-
-impl Eq for CpuStats {}
-
-impl PartialEq for CpuStats {
- fn eq(&self, other: &Self) -> bool {
- self.0 == other.0
- }
-}
-
-impl PartialOrd for CpuStats {
- fn partial_cmp(&self, other: &Self) -> Option {
- Some(self.cmp(other))
- }
-}
-
-impl Ord for CpuStats {
- fn cmp(&self, other: &Self) -> Ordering {
- if self.0 > other.0 {
- Ordering::Greater
- } else if (self.0 - other.0).abs() < 0.01 {
- Ordering::Equal
- } else {
- Ordering::Less
- }
- }
-}
-
-impl Stats for CpuStats {
- fn get_value(&self) -> f64 {
- self.0
- }
-}
-
-impl fmt::Display for CpuStats {
- fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
- let disp = format!("{:05.2}%", self.0);
- write!(f, "{disp:>x$}", x = f.width().unwrap_or(1))
- }
-}
-
-/// Struct for frequently updated memory usage stats
-/// So can use custom display formatter
-/// Use trait Stats for use as generic in draw_chart function
-#[derive(Debug, Default, Clone, Copy, Eq)]
-pub struct ByteStats(u64);
-
-impl PartialEq for ByteStats {
- fn eq(&self, other: &Self) -> bool {
- self.0 == other.0
- }
-}
-
-impl PartialOrd for ByteStats {
- fn partial_cmp(&self, other: &Self) -> Option {
- Some(self.cmp(other))
- }
-}
-
-impl Ord for ByteStats {
- fn cmp(&self, other: &Self) -> Ordering {
- self.0.cmp(&other.0)
- }
-}
-
-impl ByteStats {
- pub const fn new(value: u64) -> Self {
- Self(value)
- }
- pub const fn update(&mut self, value: u64) {
- self.0 = value;
- }
-}
-
-#[allow(clippy::cast_precision_loss)]
-impl Stats for ByteStats {
- fn get_value(&self) -> f64 {
- self.0 as f64
- }
-}
-
-/// convert from bytes to kB, MB, GB etc
-impl fmt::Display for ByteStats {
- fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
- let as_f64 = self.get_value();
- let p = match as_f64 {
- x if x >= ONE_GB => format!("{y:.2} GB", y = as_f64 / ONE_GB),
- x if x >= ONE_MB => format!("{y:.2} MB", y = as_f64 / ONE_MB),
- _ => format!("{y:.2} kB", y = as_f64 / ONE_KB),
- };
- write!(f, "{p:>x$}", x = f.width().unwrap_or(1))
- }
-}
-
-#[derive(Debug, Default, Clone, Copy, Eq)]
-pub struct BandwidthStat(u64);
-
-#[cfg(test)]
-impl BandwidthStat {
- pub fn new(x: u64) -> Self {
- Self(x)
- }
-}
-
-impl PartialEq for BandwidthStat {
- fn eq(&self, other: &Self) -> bool {
- self.0 == other.0
- }
-}
-
-impl PartialOrd for BandwidthStat {
- fn partial_cmp(&self, other: &Self) -> Option {
- Some(self.cmp(other))
- }
-}
-
-impl Ord for BandwidthStat {
- fn cmp(&self, other: &Self) -> Ordering {
- self.0.cmp(&other.0)
- }
-}
-
-#[allow(clippy::cast_precision_loss)]
-impl Stats for BandwidthStat {
- fn get_value(&self) -> f64 {
- self.0 as f64
- }
-}
-
-/// convert from bytes to per second, using 1000 instead of 1024
-impl fmt::Display for BandwidthStat {
- fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
- let as_f64 = self.get_value();
- let p = match as_f64 {
- x if x >= ONE_GB => format!("{y:.2} GB/s", y = as_f64 / ONE_GB),
- x if x >= ONE_MB => format!("{y:.2} Mb/s", y = as_f64 / ONE_MB),
- _ => format!("{y:.2} kb/s", y = as_f64 / ONE_KB),
- };
- write!(f, "{p:>x$}", x = f.width().unwrap_or(1))
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct NetworkBandwidth(VecDeque);
-
-impl NetworkBandwidth {
- pub fn new() -> Self {
- Self(VecDeque::with_capacity(60))
- }
-
- pub fn is_empty(&self) -> bool {
- self.0.is_empty()
- }
-
- /// Find the highest speed recorded in the vecque
- pub fn max(&self) -> BandwidthStat {
- self.to_vec_f64()
- .iter()
- .map(|(_, speed)| *speed)
- .max_by(|a, b| a.total_cmp(b))
- .map(|m| BandwidthStat(m as u64))
- .unwrap_or(BandwidthStat(0))
- }
-
- pub fn push(&mut self, x: u64) {
- if self.0.len() >= 60 {
- self.0.pop_front();
- }
- self.0.push_back(BandwidthStat(x));
- }
-
- /// Get the current total amount of traffic on a given device
- pub fn current_total(&self) -> ByteStats {
- self.0
- .back()
- .map_or(ByteStats::default(), |i| ByteStats::new(i.0))
- }
-
- /// Convert to f64 for use in the network graph
- pub fn to_vec_f64(&self) -> Vec<(f64, f64)> {
- self.0
- .iter()
- .zip(self.0.iter().skip(1))
- .enumerate()
- .map(|(i, (prev, current))| (i as f64, current.0.saturating_sub(prev.0) as f64))
- .collect()
- }
-}
-
-#[derive(Debug, Clone, PartialEq)]
-pub struct ChartsData {
- pub memory: ChartSeries,
- pub cpu: ChartSeries,
- pub rx: ChartSeries,
- pub tx: ChartSeries,
- pub state: State,
-}
-
-#[derive(Debug, Clone, PartialEq)]
-pub struct ChartSeries {
- pub dataset: Vec<(f64, f64)>,
- pub max: T,
- pub current: T,
-}
-
-/// Used to make sure that each log entry, for each container, is unique,
-/// will only push a log entry into the logs vec if timestamp of said log entry isn't in the hashset
-#[derive(Debug, Clone, Hash, PartialEq, Eq)]
-pub struct LogsTz(String);
-
-impl fmt::Display for LogsTz {
- fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
- write!(f, "{}", self.0)
- }
-}
-
-impl LogsTz {
- /// With a given &str, split into a logtz and content, so that we only need to `use split_once()` once
- /// The docker log, which should always contain a timestamp, is in the format `2023-01-14T19:13:30.783138328Z Lorem ipsum dolor sit amet`
- pub fn splitter(input: &str) -> (Self, String) {
- let (tz, content) = input.split_once(' ').unwrap_or_default();
- (Self(tz.to_owned()), content.to_owned())
- }
-
- /// Display the timestamp in a given format, and if provided, with a timezone offset
- pub fn display_with_formatter(&self, tz: Option<&TimeZone>, format: &str) -> Option {
- self.0.parse::().map_or(None, |t| {
- if let Some(tz) = tz.as_ref() {
- let tz = tz.iana_name()?;
- let z = t.in_tz(tz).ok()?;
- Some(z.strftime(format).to_string())
- } else {
- Some(t.strftime(format).to_string())
- }
- })
- }
-}
-
-/// Store the logs alongside a HashSet, each log *should* generate a unique timestamp,
-/// so if we store the timestamp separately in a HashSet, we can then check if we should insert a log line into the
-/// stateful list dependent on whether the timestamp is in the HashSet or not
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct Logs {
- lines: StatefulList>,
- tz: HashSet,
- search_results: Vec,
- search_term: Option,
- offset: usize,
- max_offset: usize,
- max_log_len: usize,
- adjusted_max_width: usize,
- adjust_max_width_text_len: usize,
-}
-
-impl Default for Logs {
- fn default() -> Self {
- let mut lines = StatefulList::new(vec![]);
- lines.end();
- Self {
- lines,
- tz: HashSet::new(),
- offset: 0,
- max_offset: 0,
- search_term: None,
- search_results: vec![],
- adjusted_max_width: 0,
- adjust_max_width_text_len: 0,
- max_log_len: 0,
- }
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Ord, PartialOrd, Hash)]
-pub enum LogsButton {
- Both,
- Next,
- Previous,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Hash)]
-pub struct LogSearch {
- pub term: Option,
- pub result: Option,
- pub buttons: Option,
-}
-
-/// LogSearch is used in FrameData
-impl From<&Logs> for LogSearch {
- fn from(l: &Logs) -> Self {
- let buttons = l.lines.state.selected().as_ref().and_then(|x| {
- let show_next = l.search_results.iter().any(|n| n > x);
- let show_previous = l.search_results.iter().any(|n| n < x);
- match (show_next, show_previous) {
- (true, true) => Some(LogsButton::Both),
- (true, false) => Some(LogsButton::Next),
- (false, true) => Some(LogsButton::Previous),
- (false, false) => None,
- }
- });
- Self {
- term: l.search_term.clone(),
- result: l.get_search_result(),
- buttons,
- }
- }
-}
-
-impl Logs {
- pub fn gen_log_search(&self) -> LogSearch {
- LogSearch::from(self)
- }
-
- /// Scroll to the next or previous search result, accounts for when currently selected line isn't in the results vec
- pub fn search_scroll(&mut self, sd: &ScrollDirection) -> Option<()> {
- if let Some(current_selected) = self.lines.state.selected() {
- if let Some(current_position) = self
- .search_results
- .iter()
- .position(|i| i == ¤t_selected)
- {
- if let Some(new_index) = match sd {
- ScrollDirection::Down => current_position.checked_add(1),
- ScrollDirection::Up => current_position.checked_sub(1),
- // TODO set offset
- _ => None,
- } && let Some(f) = self.search_results.get(new_index)
- {
- self.lines.state.select(Some(*f));
- return Some(());
- }
- } else {
- let range = match sd {
- ScrollDirection::Up => (0..=current_selected).rev().collect::>(),
- ScrollDirection::Down => (current_selected
- ..=self
- .search_results
- .last()
- .map_or_else(|| current_selected, |i| *i))
- .collect::>(),
- // TODO set offset
- _ => vec![],
- };
- for i in range {
- if self.search_results.contains(&i) {
- self.lines.state.select(Some(i));
- return Some(());
- }
- }
- }
- }
- None
- }
-
- /// Get a string x/y, where y is total matches found, and x is current ordered selected line
- /// WIll be padded by max chars of total matches
- fn get_search_result(&self) -> Option {
- if self.search_results.is_empty() {
- return None;
- }
- Some(self.lines.state.selected().map_or_else(
- || format!("{}", self.search_results.len()),
- |current_index| {
- self.search_results
- .iter()
- .position(|i| i == ¤t_index)
- .map_or_else(
- || format!("{}", self.search_results.len()),
- |index| {
- let len = format!("{}", self.search_results.len());
- let len_width = len.chars().count();
- format!("{:>len_width$}/{len:>len_width$}", index + 1)
- },
- )
- },
- ))
- }
-
- /// Search through the logs for a matching string
- pub fn search(&mut self, case_sensitive: bool, scroll: bool) {
- if let Some(search_term) = self.search_term.as_ref() {
- let term = if case_sensitive {
- search_term.to_owned()
- } else {
- search_term.to_lowercase()
- };
- self.search_results = self
- .lines
- .items
- .iter()
- .enumerate()
- .filter_map(|(index, a)| {
- a.lines
- .iter()
- .any(|b| {
- b.spans.iter().any(|c| {
- if case_sensitive {
- c.content.contains(&term)
- } else {
- c.content.to_lowercase().contains(&term)
- }
- })
- })
- .then_some(index)
- })
- .collect();
- if !self.search_results.is_empty() && scroll {
- self.lines.state.select(self.search_results.last().copied());
- self.offset = 0;
- }
- } else {
- self.search_results.clear();
- }
- }
-
- /// Set a single char into the filter term
- pub fn search_term_push(&mut self, c: char, case_sensitive: bool) {
- if let Some(term) = self.search_term.as_mut() {
- term.push(c);
- } else {
- self.search_term = Some(format!("{c}"));
- }
- self.search(case_sensitive, true);
- }
-
- /// Delete the final char of the filter term
- pub fn search_term_pop(&mut self, case_sensitive: bool) {
- if let Some(term) = self.search_term.as_mut() {
- term.pop();
- if term.is_empty() {
- self.search_term = None;
- }
- }
- self.search(case_sensitive, true);
- }
-
- /// Remove the filter completely
- pub fn search_term_clear(&mut self) {
- self.search_term = None;
- self.search_results.clear();
- }
-
- /// Only allow a new log line to be inserted if the log timestamp isn't in the tz HashSet
- pub fn insert(&mut self, line: Text<'static>, tz: LogsTz, case_sensitive: bool) {
- if self.tz.insert(tz) {
- self.max_log_len = self.max_log_len.max(line.width());
- self.lines.items.push(line);
- // Maybe - Ideally we'd re-render here
- if self.search_term.is_some() {
- self.search(case_sensitive, false);
- }
- }
- }
-
- /// If scrolling horizontally along the logs, display a counter of the position in the in the scroll, `x/y`
- pub fn get_scroll_title(&mut self, width: u16) -> Option {
- if self.horizontal_scroll_able(width) {
- let text_width = self.adjust_max_width_text_len;
- let arrow_left = if self.offset > 0 { " ←" } else { " " };
- let arrow_right = if self.offset < self.adjusted_max_width {
- "→ "
- } else {
- " "
- };
- Some(format!(
- "{left} {offset:>text_width$}/{adjusted_max_width} {right}",
- offset = self.offset,
- adjusted_max_width = self.adjusted_max_width,
- left = arrow_left,
- right = arrow_right,
- ))
- } else {
- None
- }
- }
-
- /// Format a log lone. Only return screen width amount of chars
- /// If offset set, remove `char_offset` number of chars from a Text
- /// `text` *should* only be a single line, so just use the .first() method rather than trying to iterate
- fn format_log_line(text: &Text<'static>, char_offset: usize, width: u16) -> Text<'static> {
- let mut skipped = 0;
- text.lines.first().map_or_else(Text::default, |line| {
- Text::from(Line::from(
- line.spans
- .iter()
- .filter_map(|span| {
- if skipped >= char_offset {
- Some(ratatui::text::Span::styled(
- span.content.chars().take(width.into()).collect::(),
- span.style,
- ))
- } else {
- let span_len = span.content.chars().count();
- if skipped + span_len <= char_offset {
- skipped += span_len;
- None
- } else {
- let start_index = char_offset - skipped;
- skipped = char_offset;
- Some(ratatui::text::Span::styled(
- span.content
- .chars()
- .skip(start_index)
- .take(width.into())
- .collect::(),
- span.style,
- ))
- }
- }
- })
- .collect::>(),
- ))
- })
- }
-
- /// Get the logs vec, but instead of cloning to whole vec, only clone items within x of the currently selected index, as well as only the current screen widths number of chars
- /// Where x is the abs different of the index plus the panel height & a padding
- /// Take into account the char offset, so that can scroll a line
- /// The rest can be just empty list items
- pub fn get_visible_logs(&self, size: Size, padding: usize) -> Vec> {
- let current_index = self.lines.state.selected().unwrap_or_default();
- let height_padding = usize::from(size.height) + padding;
- let char_offset = if self.offset > self.max_log_len {
- self.max_log_len
- } else {
- self.offset
- };
-
- self.lines
- .items
- .iter()
- .enumerate()
- .map(|(index, item)| {
- if current_index.abs_diff(index) <= height_padding {
- Self::format_log_line(item, char_offset, size.width)
- } else {
- Text::from("")
- }
- })
- .collect()
- }
-
- /// The rest of the methods are basically forwarding from the underlying StatefulList
- pub fn get_state_title(&self) -> String {
- self.lines.get_state_title()
- }
-
- /// Return true it currently selected container logs are wide enough to horizontally scroll
- pub fn horizontal_scroll_able(&mut self, width: u16) -> bool {
- if self.lines.items.is_empty() {
- return false;
- }
- self.adjusted_max_width = self.max_log_len.saturating_sub(width.into()) + 4;
- self.adjust_max_width_text_len = self.adjusted_max_width.to_string().chars().count();
- self.max_log_len + 4 > usize::from(width)
- }
-
- /// Add a padding so one char will always be visilbe?
- pub fn forward(&mut self, width: u16) {
- // Need to set a max_offset, instead of using a width each time
- if self.horizontal_scroll_able(width)
- && self.adjusted_max_width > 0
- && self.offset < self.adjusted_max_width
- {
- self.offset = self.offset.saturating_add(1);
- }
- }
-
- /// Reduce the char offset
- pub const fn back(&mut self) {
- self.offset = self.offset.saturating_sub(1);
- }
-
- /// Scroll lines down by one
- pub fn next(&mut self) {
- self.lines.next();
- }
-
- /// Scroll lines up by one
- pub fn previous(&mut self) {
- self.lines.previous();
- }
-
- /// Go to the end of the lines
- pub fn end(&mut self) {
- self.lines.end();
- }
-
- /// Go to the start of the lines
- pub fn start(&mut self) {
- self.lines.start();
- }
-
- /// Get total number of log lines
- pub const fn len(&self) -> usize {
- self.lines.items.len()
- }
-
- pub const fn state(&mut self) -> &mut ListState {
- &mut self.lines.state
- }
-}
-
-/// Info for each container
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct ContainerItem {
- pub cpu_stats: VecDeque,
- pub created: u64,
- pub docker_controls: StatefulList,
- pub health: Option,
- pub id: ContainerId,
- pub image: ContainerImage,
- pub is_oxker: bool,
- pub last_updated: u64,
- pub logs: Logs,
- pub mem_limit: ByteStats,
- pub mem_stats: VecDeque,
- pub name: ContainerName,
- pub ports: Vec,
- pub rx: NetworkBandwidth,
- pub state: State,
- pub status: ContainerStatus,
- pub tx: NetworkBandwidth,
-}
-
-/// Basic display information, for when running in debug mode
-impl fmt::Display for ContainerItem {
- fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
- write!(
- f,
- "{}, {}, {}, {}",
- self.id.get_short(),
- self.name,
- self.cpu_stats.back().unwrap_or(&CpuStats::new(0.0)),
- self.mem_stats.back().unwrap_or(&ByteStats::new(0))
- )
- }
-}
-
-impl ContainerItem {
- #[allow(clippy::too_many_arguments)]
- /// Create a new container item
- pub fn new(
- created: u64,
- id: ContainerId,
- image: String,
- is_oxker: bool,
- name: String,
- ports: Vec,
- state: State,
- status: ContainerStatus,
- ) -> Self {
- let mut docker_controls = StatefulList::new(DockerCommand::gen_vec(state));
- docker_controls.start();
-
- Self {
- cpu_stats: VecDeque::with_capacity(60),
- created,
- docker_controls,
- health: None,
- id,
- image: image.into(),
- is_oxker,
- last_updated: 0,
- logs: Logs::default(),
- mem_limit: ByteStats::default(),
- mem_stats: VecDeque::with_capacity(60),
- name: name.into(),
- ports,
- rx: NetworkBandwidth::new(),
- state,
- status,
- tx: NetworkBandwidth::new(),
- }
- }
-
- /// Find the max value in the cpu stats VecDeque
- fn max_cpu_stats(&self) -> CpuStats {
- self.cpu_stats
- .iter()
- .max()
- .map_or_else(CpuStats::default, |value| *value)
- }
-
- /// Find the max value in the mem stats VecDeque
- fn max_mem_stats(&self) -> ByteStats {
- self.mem_stats
- .iter()
- .max()
- .map_or_else(ByteStats::default, |value| *value)
- }
-
- /// Convert cpu stats into a vec for the charts function
- #[allow(clippy::cast_precision_loss)]
- fn get_cpu_dataset(&self) -> Vec<(f64, f64)> {
- self.cpu_stats
- .iter()
- .enumerate()
- .map(|(i, v)| (i as f64, v.0))
- .collect::>()
- }
-
- /// Convert mem stats into a Vec for the charts function
- #[allow(clippy::cast_precision_loss)]
- fn get_mem_dataset(&self) -> Vec<(f64, f64)> {
- self.mem_stats
- .iter()
- .enumerate()
- .map(|(i, v)| (i as f64, v.0 as f64))
- .collect::>()
- }
-
- /// Get all cpu chart data
- fn get_cpu_chart_data(&self) -> ChartSeries {
- ChartSeries {
- dataset: self.get_cpu_dataset(),
- max: self.max_cpu_stats(),
- current: self
- .cpu_stats
- .back()
- .map_or_else(|| CpuStats::new(0.0), |i| *i),
- }
- }
-
- /// Get all mem chart data
- fn get_mem_chart_data(&self) -> ChartSeries {
- ChartSeries {
- dataset: self.get_mem_dataset(),
- max: self.max_mem_stats(),
- current: self
- .mem_stats
- .back()
- .map_or_else(|| ByteStats::new(0), |i| *i),
- }
- }
-
- /// Get all mem chart data
- /// Don't understand what we are doing here
- fn get_bandwidth_chart_tx_data(&self) -> ChartSeries {
- let data = self.tx.to_vec_f64();
- ChartSeries {
- current: BandwidthStat(data.last().map_or(0, |i| i.1 as u64)),
- dataset: data,
- max: self.tx.max(),
- }
- }
-
- /// Get all mem chart data
- fn get_bandwidth_chart_rx_data(&self) -> ChartSeries {
- let data = self.rx.to_vec_f64();
- ChartSeries {
- current: BandwidthStat(data.last().map_or(0, |i| i.1 as u64)),
- dataset: data,
- max: self.rx.max(),
- }
- }
-
- /// Get chart info for cpu & memory in one function
- /// So only need to call .lock() once
- pub fn get_chart_data(&self) -> ChartsData {
- ChartsData {
- memory: self.get_mem_chart_data(),
- cpu: self.get_cpu_chart_data(),
- rx: self.get_bandwidth_chart_rx_data(),
- tx: self.get_bandwidth_chart_tx_data(),
- state: self.state,
- }
- }
-}
-
-/// Container information panel headings + widths, for nice pretty formatting
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct Columns {
- pub name: (Header, u8),
- pub state: (Header, u8),
- pub status: (Header, u8),
- pub cpu: (Header, u8),
- pub mem: (Header, u8, u8),
- pub id: (Header, u8),
- pub image: (Header, u8),
- pub net_rx: (Header, u8),
- pub net_tx: (Header, u8),
-}
-
-impl Columns {
- /// (Column titles, minimum header string length)
- pub const fn new() -> Self {
- Self {
- name: (Header::Name, 4),
- state: (Header::State, 5),
- status: (Header::Status, 6),
- cpu: (Header::Cpu, 3),
- mem: (Header::Memory, 7, 7),
- id: (Header::Id, 8),
- image: (Header::Image, 5),
- net_rx: (Header::Rx, 4),
- net_tx: (Header::Tx, 4),
- }
- }
-}
-
-#[cfg(test)]
-#[allow(clippy::unwrap_used)]
-mod tests {
-
- use jiff::tz::TimeZone;
- use ratatui::{
- layout::Size,
- text::{Line, Text},
- };
-
- use crate::{
- app_data::{ContainerImage, LogSearch, Logs, LogsTz, RunningState},
- ui::log_sanitizer,
- };
-
- use super::{ByteStats, ContainerName, ContainerStatus, CpuStats, State};
-
- #[test]
- /// Display CpuStats as a string
- fn test_container_state_cpustats_to_string() {
- let test = |f: f64, s: &str| {
- assert_eq!(CpuStats::new(f).to_string(), s);
- };
-
- test(0.0, "00.00%");
- test(1.5, "01.50%");
- test(15.15, "15.15%");
- test(150.15, "150.15%");
- }
-
- #[test]
- /// Display bytestats as a string, convert into correct data unit (Kb, MB, GB)
- fn test_container_state_bytestats_to_string() {
- let test = |u: u64, s: &str| {
- assert_eq!(ByteStats::new(u).to_string(), s);
- };
-
- test(0, "0.00 kB");
- test(150, "0.15 kB");
- test(1500, "1.50 kB");
- test(150_000, "150.00 kB");
- test(1_500_000, "1.50 MB");
- test(15_000_000, "15.00 MB");
- test(150_000_000, "150.00 MB");
- test(1_500_000_000, "1.50 GB");
- test(15_000_000_000, "15.00 GB");
- test(150_000_000_000, "150.00 GB");
- }
-
- #[test]
- /// ContainerName as string truncated correctly
- fn test_container_state_container_name_to_string() {
- let result = ContainerName::from("name_01");
- assert_eq!(result.to_string(), "name_01");
-
- let result = ContainerName::from("name_01_name_01_name_01_name_01_");
- assert_eq!(result.to_string(), "name_01_name_01_name_01_name_…");
-
- let result = result.get();
- assert_eq!(result, "name_01_name_01_name_01_name_01_");
- }
-
- #[test]
- /// ContainerImage as string truncated correctly
- fn test_container_state_container_image() {
- let result = ContainerImage::from("name_01");
- assert_eq!(result.to_string(), "name_01");
-
- let result = ContainerImage::from("name_01_name_01_name_01_name_01_");
- assert_eq!(result.to_string(), "name_01_name_01_name_01_name_…");
-
- let result = result.get();
- assert_eq!(result, "name_01_name_01_name_01_name_01_");
- }
-
- #[test]
- /// LogzTz correctly splits a line by timestamp
- fn test_container_state_logz_splitter() {
- let input = "2023-01-14T12:01:20.012345678Z Lorem ipsum dolor sit amet";
- let log_tz = LogsTz::splitter(input);
-
- assert_eq!(
- log_tz.0,
- super::LogsTz("2023-01-14T12:01:20.012345678Z".to_owned())
- );
- assert_eq!(log_tz.1, "Lorem ipsum dolor sit amet");
- }
-
- #[test]
- /// LogsTz display correctly formats with a given timestamp string
- fn test_container_state_logz_display() {
- let input = "2023-01-14T12:01:20.012345678Z Lorem ipsum dolor sit amet";
- let log_tz = LogsTz::splitter(input);
-
- let result = log_tz
- .0
- .display_with_formatter(None, "%Y-%m-%dT%H:%M:%S.%8f");
- assert!(result.is_some());
- let result = result.unwrap();
- assert_eq!(result, "2023-01-14T12:01:20.01234567");
-
- let result = log_tz.0.display_with_formatter(None, "%Y-%m-%d %H:%M:%S");
- assert!(result.is_some());
- let result = result.unwrap();
- assert_eq!(result, "2023-01-14 12:01:20");
-
- let result = log_tz.0.display_with_formatter(None, "%Y-%j");
- assert!(result.is_some());
- let result = result.unwrap();
-
- assert_eq!(result, "2023-014");
- }
-
- #[test]
- /// LogsTz display correctly formats with a given timestamp string & timezone
- fn test_container_state_logz_display_with_timezone() {
- let input = "2023-01-14T12:01:20.012345678Z Lorem ipsum dolor sit amet";
- let log_tz = LogsTz::splitter(input);
-
- let timezone = Some(TimeZone::get("Asia/Tokyo").unwrap());
- let result = log_tz
- .0
- .display_with_formatter(timezone.as_ref(), "%Y-%m-%dT%H:%M:%S.%8f");
- assert!(result.is_some());
- let result = result.unwrap();
- assert_eq!(result, "2023-01-14T21:01:20.01234567");
-
- let result = log_tz
- .0
- .display_with_formatter(timezone.as_ref(), "%Y-%m-%d %H:%M:%S");
- assert!(result.is_some());
- let result = result.unwrap();
- assert_eq!(result, "2023-01-14 21:01:20");
-
- let result = log_tz.0.display_with_formatter(timezone.as_ref(), "%Y-%j");
- assert!(result.is_some());
- let result = result.unwrap();
- assert_eq!(result, "2023-014");
- }
-
- #[test]
- /// Logs can only contain 1 entry per LogzTz
- fn test_container_state_logz() {
- let input = "2023-01-14T19:13:30.783138328Z Lorem ipsum dolor sit amet";
- let (tz, _) = LogsTz::splitter(input);
- let mut logs = Logs::default();
- let line = log_sanitizer::remove_ansi(input);
-
- logs.insert(Text::from(line.clone()), tz.clone(), true);
- logs.insert(Text::from(line.clone()), tz.clone(), true);
- logs.insert(Text::from(line), tz, true);
-
- assert_eq!(logs.lines.items.len(), 1);
-
- let input = "2023-01-15T19:13:30.783138328Z Lorem ipsum dolor sit amet";
- let (tz, _) = LogsTz::splitter(input);
- let line = log_sanitizer::remove_ansi(input);
-
- logs.insert(Text::from(line.clone()), tz.clone(), true);
- logs.insert(Text::from(line.clone()), tz.clone(), true);
- logs.insert(Text::from(line), tz, true);
-
- assert_eq!(logs.lines.items.len(), 2);
- }
-
- #[test]
- /// check ContainerStatus unhealthy state
- fn test_container_state_unhealthy() {
- let input = ContainerStatus::from("Up 1 hour".to_owned());
-
- assert!(!input.unhealthy());
-
- let input = ContainerStatus::from("Up 1 hour (unhealthy)".to_owned());
-
- assert!(input.unhealthy());
- }
-
- #[test]
- /// Generate container State from a &str and &ContainerStatus
- fn test_container_status_unhealthy() {
- let healthy = ContainerStatus::from("Up 1 hour".to_owned());
- let unhealthy = ContainerStatus::from("Up 1 hour (unhealthy)".to_owned());
-
- // Running and healthy
- let input = State::from(("running", &healthy));
- assert_eq!(input, State::Running(RunningState::Healthy));
-
- // Running and unhealthy
- let input = State::from(("running", &unhealthy));
- assert_eq!(input, State::Running(RunningState::Unhealthy));
-
- // Dead
- let input = State::from(("dead", &healthy));
- assert_eq!(input, State::Dead);
-
- // Exited
- let input = State::from(("exited", &healthy));
- assert_eq!(input, State::Exited);
-
- // Paused
- let input = State::from(("paused", &healthy));
- assert_eq!(input, State::Paused);
-
- // Removing
- let input = State::from(("removing", &healthy));
- assert_eq!(input, State::Removing);
-
- // Restarting
- let input = State::from(("restarting", &healthy));
- assert_eq!(input, State::Restarting);
-
- // Unknown
- let input = State::from(("oxker", &healthy));
- assert_eq!(input, State::Unknown);
- }
-
- #[test]
- /// Test the format_log_line methods, should ideally check colours are being correct kept as well
- fn test_to_vec() {
- let mut logs = Logs::default();
-
- let input = "2023-01-14T19:13:30.783138328Z Hello world some long line".to_owned();
- let (tz, _) = LogsTz::splitter(&input);
- logs.insert(Text::from(input), tz, true);
-
- let input = "2023-01-14T19:13:31.783138328Z Hello world some line".to_owned();
- let (tz, _) = LogsTz::splitter(&input);
- logs.insert(Text::from(input), tz, true);
-
- let input = "2023-01-14T19:13:32.783138328Z Hello world".to_owned();
- let (tz, _) = LogsTz::splitter(&input);
- logs.insert(Text::from(input), tz, true);
-
- logs.offset = 43;
- let result = logs.get_visible_logs(
- Size {
- width: 14,
- height: 10,
- },
- 10,
- );
- assert_eq!(
- vec![
- Text::from(Line::from("some long line")),
- Text::from(Line::from("some line")),
- Text::from(Line::default())
- ],
- result
- );
- }
-
- #[test]
- /// Test the get_scroll_title methods
- fn test_scroll_title() {
- let mut logs = Logs::default();
-
- let result = logs.get_scroll_title(10);
- assert!(result.is_none());
-
- let input = "short".to_owned();
- let (tz, _) = LogsTz::splitter(&input);
- logs.insert(Text::from(input), tz, true);
-
- let result = logs.get_scroll_title(10);
- assert!(result.is_none());
-
- let input = "2023-01-14T19:13:30.783138328Z Hello world some long line".to_owned();
- let (tz, _) = LogsTz::splitter(&input);
- logs.insert(Text::from(input), tz, true);
-
- let result = logs.get_scroll_title(10);
- assert_eq!(result, Some(" 0/51 → ".to_owned()));
-
- logs.forward(10);
-
- let result = logs.get_scroll_title(10);
- assert_eq!(result, Some(" ← 1/51 → ".to_owned()));
-
- for _ in 0..=49 {
- logs.forward(10);
- }
- let result = logs.get_scroll_title(10);
- assert_eq!(result, Some(" ← 51/51 ".to_owned()));
- }
-
- #[test]
- /// Test the log search
- fn test_logsearch() {
- let mut logs = Logs::default();
-
- for i in 1..=10 {
- let input = if i % 2 == 0 {
- format!("{i}, hello world some long line {i}")
- } else {
- format!("{i}, Hello world some long line {i}")
- };
- let (tz, _) = LogsTz::splitter(&input);
- logs.insert(Text::from(input), tz, true);
- }
-
- logs.search_term_push('H', true);
- assert_eq!(logs.search_results, [0, 2, 4, 6, 8]);
- logs.search_term_clear();
- logs.search_term_push('H', false);
- assert_eq!(logs.search_results, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
- }
-
- #[test]
- /// Test the LogSearch::From() methods
- fn test_logsearch_from() {
- let mut logs = Logs::default();
-
- for i in 1..=10 {
- let input = format!("{i}, Hello world some long line {i}");
- let (tz, _) = LogsTz::splitter(&input);
- logs.insert(Text::from(input), tz, true);
- }
-
- let log_search = LogSearch::from(&logs);
- assert_eq!(
- log_search,
- LogSearch {
- term: None,
- result: None,
- buttons: None
- }
- );
-
- logs.search_term_push('H', true);
- let log_search = LogSearch::from(&logs);
- assert_eq!(
- log_search,
- LogSearch {
- term: Some("H".to_owned()),
- result: Some("10/10".to_owned()),
- buttons: Some(crate::app_data::LogsButton::Previous)
- }
- );
-
- logs.previous();
-
- let log_search = LogSearch::from(&logs);
- assert_eq!(
- log_search,
- LogSearch {
- term: Some("H".to_owned()),
- result: Some(" 9/10".to_owned()),
- buttons: Some(crate::app_data::LogsButton::Both)
- }
- );
-
- logs.start();
-
- let log_search = LogSearch::from(&logs);
- assert_eq!(
- log_search,
- LogSearch {
- term: Some("H".to_owned()),
- result: Some(" 1/10".to_owned()),
- buttons: Some(crate::app_data::LogsButton::Next)
- }
- );
-
- logs.search_term_push('H', true);
- let log_search = LogSearch::from(&logs);
- assert_eq!(
- log_search,
- LogSearch {
- term: Some("HH".to_owned()),
- result: None,
- buttons: None
- }
- );
-
- logs.search_term_clear();
- logs.search_term_push('2', true);
- let log_search = LogSearch::from(&logs);
- assert_eq!(logs.lines.state.selected(), Some(1));
- assert_eq!(
- log_search,
- LogSearch {
- term: Some("2".to_owned()),
- result: Some("1/1".to_owned()),
- buttons: None
- }
- );
-
- logs.next();
-
- let log_search = LogSearch::from(&logs);
- assert_eq!(
- log_search,
- LogSearch {
- term: Some("2".to_owned()),
- result: Some("1".to_owned()),
- buttons: Some(crate::app_data::LogsButton::Previous)
- }
- );
- }
-}
diff --git a/src/app_data/mod.rs b/src/app_data/mod.rs
deleted file mode 100644
index 9e05986..0000000
--- a/src/app_data/mod.rs
+++ /dev/null
@@ -1,2571 +0,0 @@
-use bollard::{models::ContainerSummary, secret::ContainerInspectResponse};
-use core::fmt;
-use parking_lot::Mutex;
-use ratatui::{layout::Size, text::Text, widgets::ListState};
-use std::{
- hash::Hash,
- sync::Arc,
- time::{SystemTime, UNIX_EPOCH},
-};
-
-mod container_state;
-
-use crate::{
- ENTRY_POINT,
- app_error::AppError,
- config::Config,
- ui::{GuiState, Rerender, Status, log_sanitizer},
-};
-pub use container_state::*;
-
-#[derive(Debug, Clone, Copy, Eq, PartialEq)]
-pub enum SortedOrder {
- Asc,
- Desc,
-}
-
-#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)]
-pub enum Header {
- State,
- Status,
- Cpu,
- Memory,
- Id,
- Name,
- Image,
- Rx,
- Tx,
-}
-
-/// Convert Header enum into strings to display
-impl fmt::Display for Header {
- fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
- let disp = match self {
- Self::State => "state",
- Self::Status => "status",
- Self::Cpu => "cpu",
- Self::Memory => "memory/limit",
- Self::Id => "id",
- Self::Name => "name",
- Self::Image => "image",
- Self::Rx => "↓ rx",
- Self::Tx => "↑ tx",
- };
- write!(f, "{disp:>x$}", x = f.width().unwrap_or(1))
- }
-}
-
-#[derive(Debug, Clone, Default, Copy, PartialEq, Eq, PartialOrd, Ord)]
-pub enum FilterBy {
- #[default]
- Name,
- Image,
- Status,
- All,
-}
-
-/// Convert errors into strings to display
-impl fmt::Display for FilterBy {
- fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
- write!(
- f,
- "{}",
- match self {
- Self::Name => "Name",
- Self::Image => "Image",
- Self::Status => "Status",
- Self::All => "All",
- }
- )
- }
-}
-
-impl FilterBy {
- const fn next(self) -> Option {
- match self {
- Self::Name => Some(Self::Image),
- Self::Image => Some(Self::Status),
- Self::Status => Some(Self::All),
- Self::All => None,
- }
- }
-
- const fn prev(self) -> Option {
- match self {
- Self::Name => None,
- Self::Image => Some(Self::Name),
- Self::Status => Some(Self::Image),
- Self::All => Some(Self::Status),
- }
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct Filter {
- pub term: Option,
- pub by: FilterBy,
-}
-impl Filter {
- pub fn new() -> Self {
- Self {
- term: None,
- by: FilterBy::default(),
- }
- }
-}
-
-#[derive(Debug, Clone)]
-pub struct InspectData {
- pub width: usize,
- pub height: usize,
- pub as_string: String,
- pub name: String,
- pub id: ContainerId, // pub as_lines: Vec>,
-}
-
-impl From for InspectData {
- fn from(input: ContainerInspectResponse) -> Self {
- let as_string = serde_json::to_string_pretty(&input)
- .unwrap_or_default()
- .lines()
- .skip(1)
- .collect::>()
- .split_last()
- .map(|(_, data)| data)
- .unwrap_or_default()
- .join("\n");
-
- let height = as_string.lines().count();
-
- let mut width = 0;
- for i in as_string.lines() {
- width = width.max(i.chars().count());
- }
-
- Self {
- name: input.name.unwrap_or_default(),
- // TODO maybe make this an Option?
- id: ContainerId::from(input.id.unwrap_or_default().as_str()),
- width,
- height,
- as_string,
- }
- }
-}
-
-/// Global app_state, stored in an Arc
-#[derive(Debug, Clone)]
-#[cfg(not(test))]
-pub struct AppData {
- containers: StatefulList,
- error: Option,
- filter: Filter,
- hidden_containers: Vec,
- inspect_data: Option,
- rerender: Arc,
- sorted_by: Option<(Header, SortedOrder)>,
- current_sorted_id: Vec,
- pub config: Config,
-}
-
-#[derive(Debug, Clone)]
-#[cfg(test)]
-pub struct AppData {
- pub config: Config,
- pub containers: StatefulList,
- pub error: Option,
- pub filter: Filter,
- pub hidden_containers: Vec,
- pub inspect_data: Option,
- pub current_sorted_id: Vec,
- pub rerender: Arc,
- pub sorted_by: Option<(Header, SortedOrder)>,
-}
-
-impl AppData {
- /// Generate a default app_state
- pub fn new(config: Config, redraw: &Arc) -> Self {
- Self {
- config,
- containers: StatefulList::new(vec![]),
- current_sorted_id: vec![],
- error: None,
- filter: Filter::new(),
- hidden_containers: vec![],
- inspect_data: None,
- rerender: Arc::clone(redraw),
- sorted_by: None,
- }
- }
-
- /// Current time as unix timestamp
- #[allow(clippy::expect_used)]
- fn get_systemtime() -> u64 {
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .expect("In our known reality, this error should never occur")
- .as_secs()
- }
-
- pub fn clear_inspect_data(&mut self) {
- self.inspect_data = None;
- }
-
- pub fn set_inspect_data(&mut self, data: ContainerInspectResponse) {
- self.inspect_data = Some(InspectData::from(data))
- // self.inspect_data = Some(data)
- }
-
- pub fn get_inspect_data(&self) -> Option {
- self.inspect_data.clone()
- }
- /// Filter related methods
- /// Get the filterby and filter_term
- pub const fn get_filter(&self) -> (FilterBy, Option<&String>) {
- (self.filter.by, self.filter.term.as_ref())
- }
-
- pub fn log_search_scroll(&mut self, np: &ScrollDirection) {
- if let Some(i) = self.get_mut_selected_container()
- && i.logs.search_scroll(np).is_some()
- {
- self.rerender.update_draw();
- }
- }
-
- pub fn gen_log_search(&self) -> Option {
- self.get_selected_container()
- .map(|i| i.logs.gen_log_search())
- }
-
- /// Check if a given container can be inserted into the "visible" list, based on current filter term and filter_by
- fn can_insert(&self, container: &ContainerItem) -> bool {
- self.filter.term.as_ref().is_none_or(|term| {
- let term = term.to_lowercase();
- match self.filter.by {
- FilterBy::All => {
- container.name.contains(&term)
- || container.image.contains(&term)
- || container.status.contains(&term)
- }
- FilterBy::Image => container.image.contains(&term),
- FilterBy::Name => container.name.contains(&term),
- FilterBy::Status => container.status.contains(&term),
- }
- })
- }
-
- /// Remove items from the containers list based on the filter term, and insert into a "hidden" vec
- /// sets the state to start if any filtering has occurred
- /// Also search in the "hidden" vec for items and insert back into the main containers vec
- fn filter_containers(&mut self) {
- self.rerender.update_draw();
- let pre_len = self.get_container_len();
-
- if !self.hidden_containers.is_empty() {
- let (mut new_items, tmp_items): (Vec<_>, Vec<_>) = self
- .hidden_containers
- .iter()
- .cloned()
- .partition(|item| self.can_insert(item));
-
- while let Some(x) = new_items.pop() {
- self.containers.items.push(x);
- }
- self.hidden_containers = tmp_items;
- }
-
- let (new_items, tmp_items) = self
- .containers
- .items
- .iter()
- .cloned()
- .partition(|item| self.can_insert(item));
-
- self.containers.items = new_items;
- self.hidden_containers.extend(tmp_items);
-
- self.sort_containers();
- if self.get_container_len() != pre_len {
- self.containers.start();
- }
- }
-
- pub fn logs_search_clear(&mut self) {
- if let Some(selected_container) = self.get_mut_selected_container() {
- selected_container.logs.search_term_clear();
- self.rerender.update_draw();
- }
- }
-
- /// Set a single char into the filter term
- pub fn log_search_push(&mut self, c: char) {
- let cs = self.config.log_search_case_sensitive;
- if let Some(selected_container) = self.get_mut_selected_container() {
- selected_container.logs.search_term_push(c, cs);
- self.rerender.update_draw();
- }
- }
-
- /// Delete the final char of the filter term
- pub fn log_search_pop(&mut self) {
- let cs = self.config.log_search_case_sensitive;
- if let Some(selected_container) = self.get_mut_selected_container() {
- selected_container.logs.search_term_pop(cs);
- self.rerender.update_draw();
- }
- }
-
- /// Re-filter the containers, used after the filter.by has been changed
- fn re_filter(&mut self) {
- self.containers.items.append(&mut self.hidden_containers);
- self.hidden_containers = vec![];
- self.filter_containers();
- }
-
- /// Set a single char into the filter term
- pub fn filter_term_push(&mut self, c: char) {
- if let Some(term) = self.filter.term.as_mut() {
- term.push(c);
- } else {
- self.filter.term = Some(format!("{c}"));
- }
- self.filter_containers();
- }
-
- /// Delete the final char of the filter term
- pub fn filter_term_pop(&mut self) {
- if let Some(term) = self.filter.term.as_mut() {
- // should now search for items in the tmp vec, and insert into containers if found
- term.pop();
- if term.is_empty() {
- self.filter.term = None;
- }
- }
- self.filter_containers();
- }
-
- /// change the filter_by option
- pub fn filter_by_next(&mut self) {
- if let Some(by) = self.filter.by.next() {
- self.filter.by = by;
- self.re_filter();
- }
- }
-
- /// change the filter_by option
- pub fn filter_by_prev(&mut self) {
- if let Some(by) = self.filter.by.prev() {
- self.filter.by = by;
- self.re_filter();
- }
- }
-
- /// Remove the filter completely
- pub fn filter_term_clear(&mut self) {
- self.filter.term = None;
- while let Some(i) = self.hidden_containers.pop() {
- if self.get_container_by_id(&i.id).is_none() {
- self.containers.items.push(i);
- }
- }
- self.sort_containers();
- }
-
- /// Container sort related methods
- /// Change the sorted order, also set the selected container state to match new order
- fn set_sorted(&mut self, x: Option<(Header, SortedOrder)>) {
- self.sorted_by = x;
- self.sort_containers();
- self.containers.state.select(
- self.containers
- .items
- .iter()
- .position(|i| self.get_selected_container_id().as_ref() == Some(&i.id)),
- );
- self.rerender.update_draw();
- }
-
- /// Remove the sorted header & order, and sort by default - created datetime
- pub fn reset_sorted(&mut self) {
- self.set_sorted(None);
- self.rerender.update_draw();
- }
-
- /// Sort containers based on a given header, if headings match, and already ascending, remove sorting
- pub fn set_sort_by_header(&mut self, selected_header: Header) {
- let mut output = Some((selected_header, SortedOrder::Asc));
- if let Some((current_header, order)) = self.get_sorted()
- && current_header == selected_header
- {
- match order {
- SortedOrder::Desc => output = None,
- SortedOrder::Asc => output = Some((selected_header, SortedOrder::Desc)),
- }
- }
- self.set_sorted(output);
- }
-
- pub const fn get_sorted(&self) -> Option<(Header, SortedOrder)> {
- self.sorted_by
- }
-
- /// Get a vec of the containers ID's in the order they are displayed in the containers panel
- fn get_current_ids(&self) -> Vec {
- self.containers
- .items
- .iter()
- .map(|i| i.id.clone())
- .collect::>()
- }
- /// Sort the containers vec, based on a heading (and if clash, then by name), either ascending or descending,
- /// If not sort set, then sort by created time
- pub fn sort_containers(&mut self) {
- if let Some((head, ord)) = self.sorted_by {
- let pre_order = self.get_current_ids();
- let sort_closure = |a: &ContainerItem, b: &ContainerItem| -> std::cmp::Ordering {
- let item_ord = match ord {
- SortedOrder::Asc => (a, b),
- SortedOrder::Desc => (b, a),
- };
- match head {
- Header::State => item_ord
- .0
- .state
- .order()
- .cmp(&item_ord.1.state.order())
- .then_with(|| item_ord.0.name.get().cmp(item_ord.1.name.get())),
- Header::Status => item_ord
- .0
- .status
- .get()
- .cmp(item_ord.1.status.get())
- .then_with(|| item_ord.0.name.get().cmp(item_ord.1.name.get())),
- Header::Cpu => item_ord
- .0
- .cpu_stats
- .back()
- .cmp(&item_ord.1.cpu_stats.back())
- .then_with(|| item_ord.0.name.get().cmp(item_ord.1.name.get())),
- Header::Memory => item_ord
- .0
- .mem_stats
- .back()
- .cmp(&item_ord.1.mem_stats.back())
- .then_with(|| item_ord.0.name.get().cmp(item_ord.1.name.get())),
- Header::Id => item_ord
- .0
- .id
- .cmp(&item_ord.1.id)
- .then_with(|| item_ord.0.name.get().cmp(item_ord.1.name.get())),
- Header::Image => item_ord
- .0
- .image
- .get()
- .cmp(item_ord.1.image.get())
- .then_with(|| item_ord.0.name.get().cmp(item_ord.1.name.get())),
- Header::Rx => item_ord
- .0
- .rx
- .current_total()
- .cmp(&item_ord.1.rx.current_total())
- .then_with(|| item_ord.0.name.get().cmp(item_ord.1.name.get())),
- Header::Tx => item_ord
- .0
- .tx
- .current_total()
- .cmp(&item_ord.1.tx.current_total())
- .then_with(|| item_ord.0.name.get().cmp(item_ord.1.name.get())),
- Header::Name => item_ord
- .0
- .name
- .get()
- .cmp(item_ord.1.name.get())
- .then_with(|| item_ord.0.id.cmp(&item_ord.1.id)),
- }
- };
-
- self.containers.items.sort_by(sort_closure);
- if pre_order != self.get_current_ids() {
- self.rerender.update_draw();
- }
- } else if self.current_sorted_id != self.get_current_ids() {
- self.containers.items.sort_by(|a, b| {
- a.created
- .cmp(&b.created)
- .then_with(|| a.name.get().cmp(b.name.get()))
- });
- self.rerender.update_draw();
- self.current_sorted_id = self.get_current_ids();
- }
- }
-
- /// Container state methods
- /// Get the total number of none "hidden" containers
- pub const fn get_container_len(&self) -> usize {
- self.containers.items.len()
- }
-
- pub fn get_all_id_state(&self) -> Vec<(State, ContainerId)> {
- self.containers
- .items
- .iter()
- .map(|i| (i.state, i.id.clone()))
- .collect::>()
- }
-
- /// Get all the ContainerItems
- /// Thnk this allow block can be removed with the 1.87 release of Clippy
- pub fn get_container_items(&self) -> &[ContainerItem] {
- &self.containers.items
- }
-
- /// Get title for containers section, add a suffix indicating if the containers are currently under filter
- pub fn get_container_title(&self) -> String {
- let suffix = if !self.hidden_containers.is_empty() && !self.containers.items.is_empty() {
- " - filtered"
- } else {
- ""
- };
- format!("{}{}", self.containers.get_state_title(), suffix)
- }
-
- /// Select the first container
- pub fn containers_start(&mut self) {
- self.containers.start();
- self.rerender.update_draw();
- }
-
- /// select the last container
- pub fn containers_end(&mut self) {
- self.containers.end();
- self.rerender.update_draw();
- }
-
- pub fn containers_scroll(&mut self, scroll: &ScrollDirection) {
- self.containers.scroll(scroll);
- self.rerender.update_draw();
- }
-
- /// Get ListState of containers
- pub const fn get_container_state(&mut self) -> &mut ListState {
- &mut self.containers.state
- }
-
- /// Get Option of the current selected container
- pub fn get_selected_container(&self) -> Option<&ContainerItem> {
- self.containers
- .state
- .selected()
- .and_then(|i| self.containers.items.get(i))
- }
-
- /// Find the longest port when it's transformed into a string, defaults are header lens (ip, private, public)
- ///display like this: "│ ip, private, public│", so (5,10,9) are the minimum lengths required
- pub fn get_longest_port(&self) -> (usize, usize, usize) {
- let mut output = (5, 10, 9);
-
- for item in [&self.containers.items, &self.hidden_containers] {
- for item in item {
- output.0 = output.0.max(
- item.ports
- .iter()
- .map(ContainerPorts::len_ip)
- .max()
- .unwrap_or(output.0),
- );
- output.1 = output.1.max(
- item.ports
- .iter()
- .map(ContainerPorts::len_private)
- .max()
- .unwrap_or(output.1),
- );
- output.2 = output.2.max(
- item.ports
- .iter()
- .map(ContainerPorts::len_public)
- .max()
- .unwrap_or(output.2),
- );
- }
- }
- output
- }
-
- /// Get Option of the current selected container's ports, sorted by private port
- pub fn get_selected_ports(&self) -> Option<(Vec, State)> {
- if let Some(item) = self.get_selected_container() {
- let mut ports = item.ports.clone();
- ports.sort_by(|a, b| a.private.cmp(&b.private));
- return Some((ports, item.state));
- }
- None
- }
-
- /// Get mutable Option of the current selected container
- fn get_mut_selected_container(&mut self) -> Option<&mut ContainerItem> {
- self.containers
- .state
- .selected()
- .and_then(|i| self.containers.items.get_mut(i))
- }
-
- /// Get a mutable container by given id
- #[cfg(not(test))]
- fn get_container_by_id(&mut self, id: &ContainerId) -> Option<&mut ContainerItem> {
- self.containers.items.iter_mut().find(|i| &i.id == id)
- }
-
- /// As above, but make it public to testing
- #[cfg(test)]
- pub fn get_container_by_id(&mut self, id: &ContainerId) -> Option<&mut ContainerItem> {
- self.containers.items.iter_mut().find(|i| &i.id == id)
- }
-
- /// Get a mutable container by given id in the tmp_container vec
- fn get_hidden_container_by_id(&mut self, id: &ContainerId) -> Option<&mut ContainerItem> {
- self.hidden_containers.iter_mut().find(|i| &i.id == id)
- }
-
- /// Get the ContainerName of by ID
- pub fn get_container_name_by_id(&mut self, id: &ContainerId) -> Option<&ContainerName> {
- self.containers
- .items
- .iter_mut()
- .find(|i| &i.id == id)
- .map(|i| &i.name)
- }
-
- /// Find the id of the currently selected container.
- /// If any containers on system, will always return a ContainerId
- /// Only returns None when no containers found.
- pub fn get_selected_container_id(&self) -> Option {
- self.get_selected_container().map(|i| i.id.clone())
- }
-
- /// Check if a given ID matches the currently selected container
- pub fn is_selected_container(&self, id: &ContainerId) -> bool {
- self.get_selected_container().is_some_and(|i| &i.id == id)
- }
-
- /// Get the Id and State for the currently selected container - used by the exec check method
- pub fn get_selected_container_id_state_name(&self) -> Option<(ContainerId, State, String)> {
- self.get_selected_container()
- .map(|i| (i.id.clone(), i.state, i.name.get().to_owned()))
- }
-
- /// Selected DockerCommand methods
- /// Get the current selected docker command
- /// So know which command to execute
- pub fn selected_docker_controls(&self) -> Option {
- self.get_selected_container().and_then(|i| {
- i.docker_controls.state.selected().and_then(|x| {
- i.docker_controls
- .items
- .get(x)
- .map(std::borrow::ToOwned::to_owned)
- })
- })
- }
-
- /// Change selected choice of docker commands of selected container
- pub fn docker_controls_scroll(&mut self, scroll: &ScrollDirection) {
- if let Some(i) = self.get_mut_selected_container() {
- i.docker_controls.scroll(scroll);
- // i.docker_controls.next();
- self.rerender.update_draw();
- }
- }
-
- /// Change selected choice of docker commands of selected container
- pub fn docker_controls_start(&mut self) {
- if let Some(i) = self.get_mut_selected_container() {
- i.docker_controls.start();
- self.rerender.update_draw();
- }
- }
-
- /// Change selected choice of docker commands of selected container
- pub fn docker_controls_end(&mut self) {
- if let Some(i) = self.get_mut_selected_container() {
- i.docker_controls.end();
- self.rerender.update_draw();
- }
- }
-
- /// Get mutable Option of the currently selected container DockerCommand state
- pub fn get_control_state(&mut self) -> Option<&mut ListState> {
- self.get_mut_selected_container()
- .map(|i| &mut i.docker_controls.state)
- }
-
- /// Get mutable Option of the currently selected container DockerCommand items
- pub fn get_control_items(&mut self) -> Option<&mut Vec> {
- self.get_mut_selected_container()
- .map(|i| &mut i.docker_controls.items)
- }
-
- /// Logs related methods
- /// Get the title for log panel for selected container, will be either
- /// 1) "logs x/x - container_name - container_image"
- /// 2) "logs - container_name - container_image" when no logs found
- /// 3) " " no container currently selected - aka no containers on system
- pub fn get_log_title(&self) -> String {
- self.get_selected_container()
- .map_or_else(String::new, |ci| {
- let logs_len = ci.logs.get_state_title();
- let prefix = if logs_len.is_empty() {
- String::from(" ")
- } else {
- format!("{logs_len} ")
- };
- format!("{}- {} - {}", prefix, ci.name.get(), ci.image.get())
- })
- }
-
- /// If scrolling horizontally along the logs, display a counter of the position in the in the scroll, `x/y`
- pub fn get_scroll_title(&mut self, width: u16) -> Option {
- self.get_mut_selected_container()
- .and_then(|i| i.logs.get_scroll_title(width))
- }
-
- pub fn logs_horizontal_scroll(&mut self, sd: &ScrollDirection, width: u16) {
- // Change this to set a max_offset, instead of taking in width each time, then can be combined with the log_scroll beneath
- match sd {
- ScrollDirection::Down => {
- if let Some(i) = self.get_mut_selected_container() {
- i.logs.forward(width);
- self.rerender.update_draw();
- }
- }
- ScrollDirection::Up => {
- if let Some(i) = self.get_mut_selected_container() {
- i.logs.back();
- self.rerender.update_draw();
- }
- }
- // TODO set offset
- _ => (),
- }
- }
-
- /// select next selected log line
- pub fn log_scroll(&mut self, scroll: &ScrollDirection) {
- if let Some(i) = self.get_mut_selected_container() {
- match scroll {
- ScrollDirection::Down => i.logs.next(),
- ScrollDirection::Up => i.logs.previous(),
- // TODO set offset
- _ => (),
- }
- self.rerender.update_draw();
- }
- }
-
- /// select last selected log line
- pub fn log_end(&mut self) {
- if let Some(i) = self.get_mut_selected_container() {
- i.logs.end();
- self.rerender.update_draw();
- }
- }
-
- /// select first selected log line
- pub fn log_start(&mut self) {
- if let Some(i) = self.get_mut_selected_container() {
- i.logs.start();
- self.rerender.update_draw();
- }
- }
-
- /// Get mutable Vec of current containers logs
- pub fn get_logs(&self, size: Size, padding: usize) -> Vec> {
- self.containers
- .state
- .selected()
- .and_then(|i| self.containers.items.get(i))
- .map_or(vec![], |i| i.logs.get_visible_logs(size, padding))
- }
-
- /// Get mutable Option of the currently selected container Logs state
- pub fn get_log_state(&mut self) -> Option<&mut ListState> {
- self.containers
- .state
- .selected()
- .and_then(|i| self.containers.items.get_mut(i))
- .map(|i| i.logs.state())
- }
-
- /// Chart data related methods
- /// Get mutable Option of the currently selected container chart data
- pub fn get_chart_data(&self) -> Option {
- self.containers
- .state
- .selected()
- .and_then(|i| self.containers.items.get(i))
- .map(container_state::ContainerItem::get_chart_data)
- }
-
- /// Error related methods
- /// Get single app_state error
- pub fn get_error(&self) -> Option {
- self.error.clone()
- }
-
- /// Remove single app_state error
- pub fn remove_error(&mut self) {
- self.error = None;
- self.rerender.update_draw();
- }
-
- /// Insert single app_state error
- pub fn set_error(&mut self, error: AppError, gui_state: &Arc>, status: Status) {
- gui_state.lock().status_push(status);
- self.error = Some(error);
- self.rerender.update_draw();
- }
-
- /// Check if the selected container is a dockerised version of oxker
- /// So that can disallow commands to be send
- /// Is a shabby way of implementing this
- pub fn is_oxker(&self) -> bool {
- self.get_selected_container().is_some_and(|i| i.is_oxker)
- }
-
- /// Check if selected container is oxker and also that oxker is being run in a container
- pub fn is_oxker_in_container(&self) -> bool {
- self.get_selected_container()
- .is_some_and(|i| i.is_oxker && self.config.in_container)
- }
-
- /// Find the widths for the strings in the containers panel.
- /// So can display nicely and evenly
- /// Searches in both contains & hidden_containers
- pub fn get_width(&self) -> Columns {
- let mut columns = Columns::new();
- let count = |x: &str| u8::try_from(x.chars().count()).unwrap_or(12);
-
- for container in [&self.containers.items, &self.hidden_containers] {
- for container in container {
- // TODO refactor these
- let cpu_count = container.cpu_stats.back().map_or_else(
- || count(&CpuStats::default().to_string()),
- |i| count(&i.to_string()),
- );
-
- let mem_current_count = container.mem_stats.back().map_or_else(
- || count(&ByteStats::default().to_string()),
- |i| count(&i.to_string()),
- );
- columns.cpu.1 = columns.cpu.1.max(cpu_count);
- columns.image.1 = columns.image.1.max(count(&container.image.to_string()));
- columns.mem.1 = columns.mem.1.max(mem_current_count);
- columns.mem.2 = columns.mem.2.max(count(&container.mem_limit.to_string()));
- columns.name.1 = columns.name.1.max(count(&container.name.to_string()));
- columns.net_rx.1 = columns
- .net_rx
- .1
- .max(count(&container.rx.current_total().to_string()));
- columns.net_tx.1 = columns
- .net_tx
- .1
- .max(count(&container.tx.current_total().to_string()));
- columns.state.1 = columns.state.1.max(count(&container.state.to_string()));
- columns.status.1 = columns.status.1.max(count(container.status.get()));
- }
- }
- columns
- }
-
- /// Update related methods
- /// Get mutable reference to a container in the containers vec & the hidden_containers vec
- fn get_any_container_by_id(&mut self, id: &ContainerId) -> Option<&mut ContainerItem> {
- if self.get_hidden_container_by_id(id).is_some() {
- self.get_hidden_container_by_id(id)
- } else {
- self.get_container_by_id(id)
- }
- }
-
- /// Update container mem, cpu, & network stats, in single function so only need to call .lock() once
- /// Will also, if a sort is set, sort the containers
- pub fn update_stats_by_id(
- &mut self,
- id: &ContainerId,
- cpu_stat: Option,
- mem_stat: Option,
- mem_limit: u64,
- rx: u64,
- tx: u64,
- ) {
- if let Some(container) = self.get_any_container_by_id(id) {
- if container.cpu_stats.len() >= 60 {
- container.cpu_stats.pop_front();
- }
- if container.mem_stats.len() >= 60 {
- container.mem_stats.pop_front();
- }
-
- if let Some(cpu) = cpu_stat {
- container.cpu_stats.push_back(CpuStats::new(cpu));
- }
- if let Some(mem) = mem_stat {
- container.mem_stats.push_back(ByteStats::new(mem));
- }
-
- // Only insert if alive, or if is empty, need two to create an entry in the bandwidth chart, so instead this fills in the RX/TX total columns
- if container.rx.is_empty() || container.state.is_alive() {
- container.rx.push(rx);
- container.tx.push(tx);
- }
-
- container.mem_limit.update(mem_limit);
- }
- if self.is_selected_container(id) {
- self.rerender.update_draw();
- }
- self.sort_containers();
- }
-
- /// Update, or insert, containers
- pub fn update_containers(&mut self, mut all_containers: Vec) {
- let all_ids = self
- .containers
- .items
- .iter()
- .map(|i| i.id.clone())
- .collect::>();
-
- // Only sort it no containers currently set, as afterwards the order is fixed
- if self.containers.items.is_empty() {
- all_containers.sort_by(|a, b| a.created.cmp(&b.created));
- }
-
- if !all_containers.is_empty() && self.containers.state.selected().is_none() {
- self.containers.start();
- }
-
- for (index, id) in all_ids.iter().enumerate() {
- if !all_containers
- .iter()
- .filter_map(|i| i.id.as_ref())
- .any(|x| x == id.get())
- {
- // If removed container is currently selected, then change selected to previous
- // This will default to 0 in any edge cases
- if self.containers.state.selected().is_some() {
- self.containers.scroll(&ScrollDirection::Up);
- }
- // Check is some, else can cause out of bounds error, if containers get removed before a docker update
- if self.containers.items.get(index).is_some() {
- self.containers.items.remove(index);
- if self.is_selected_container(id) {
- self.rerender.update_draw();
- }
- }
- }
- }
-
- for mut i in all_containers {
- if let Some(id) = i.id.as_ref() {
- let name = i.names.as_mut().map_or(String::new(), |names| {
- names.first_mut().map_or(String::new(), |f| {
- if f.starts_with('/') {
- f.remove(0);
- }
- (*f).clone()
- })
- });
-
- let ports = i.ports.map_or(vec![], |i| {
- i.into_iter().map(ContainerPorts::from).collect::>()
- });
-
- let id = ContainerId::from(id.as_str());
-
- let is_oxker = i
- .command
- .as_ref()
- .is_some_and(|i| i.starts_with(ENTRY_POINT));
-
- let status = ContainerStatus::from(
- i.status
- .as_ref()
- .map_or(String::new(), std::clone::Clone::clone),
- );
- let state = State::from((
- i.state
- .as_ref()
- .map_or(&bollard::secret::ContainerSummaryStateEnum::DEAD, |z| z),
- &status,
- ));
- let image = i
- .image
- .as_ref()
- .map_or(String::new(), std::clone::Clone::clone);
-
- let created = i
- .created
- .map_or(0, |i| u64::try_from(i).unwrap_or_default());
-
- if let Some(item) = self.get_any_container_by_id(&id) {
- if item.name.get() != name {
- item.name.set(name);
- }
- if item.status != status {
- item.status = status;
- }
- if item.state != state {
- item.docker_controls.items = DockerCommand::gen_vec(state);
- // Update the list state, needs to be None if the gen_vec returns an empty vec
- match state {
- State::Removing | State::Restarting | State::Unknown => {
- item.docker_controls.state.select(None);
- }
- _ => item.docker_controls.start(),
- }
- item.state = state;
- }
-
- item.ports = ports;
-
- if item.image.get() != image {
- item.image.set(image);
- }
- } else {
- // container not known, so make new ContainerItem and push into containers Ve
- let container = ContainerItem::new(
- created, id, image, is_oxker, name, ports, state, status,
- );
- let can_insert = self.can_insert(&container);
- if can_insert {
- self.containers.items.push(container);
- } else {
- self.hidden_containers.push(container);
- }
- }
- }
- // self.redraw.set_true("update_containers");
- }
- }
-
- /// Update logs of a given container, based on id
- pub fn update_log_by_id(&mut self, logs: Vec, id: &ContainerId) {
- let color = self.config.color_logs;
- let raw = self.config.raw_logs;
- let format = self.config.timestamp_format.clone();
- let config_tz = self.config.timezone.clone();
-
- let cs = self.config.log_search_case_sensitive;
-
- let show_timestamp = self.config.show_timestamp;
-
- if let Some(container) = self.get_any_container_by_id(id) {
- if !container.is_oxker {
- container.last_updated = Self::get_systemtime();
- let current_len = container.logs.len();
- for mut i in logs {
- let (log_tz, log_content) = LogsTz::splitter(i.as_str());
- if show_timestamp {
- i = format!(
- "{} {}",
- log_tz
- .display_with_formatter(config_tz.as_ref(), &format)
- .unwrap_or_else(|| log_tz.to_string()),
- log_content
- );
- } else {
- i = log_content;
- }
- let lines = if color {
- log_sanitizer::colorize_logs(&i)
- } else if raw {
- log_sanitizer::raw(&i)
- } else {
- log_sanitizer::remove_ansi(&i)
- };
- container.logs.insert(Text::from(lines), log_tz, cs);
- }
-
- // Set the logs selected row for each container
- // Either when no long currently selected, or currently selected (before updated) is already at end
- if container.logs.state().selected().is_none()
- || container.logs.state().selected().map_or(1, |f| f + 1) == current_len
- {
- container.logs.end();
- }
- }
- if self.is_selected_container(id) {
- self.rerender.update_draw();
- }
- }
- }
-}
-
-#[cfg(test)]
-#[allow(clippy::unwrap_used)]
-mod tests {
-
- use super::*;
- use crate::tests::{gen_appdata, gen_container_summary, gen_containers};
- use std::collections::VecDeque;
-
- // ******* //
- // Sort by //
- // ******* //
-
- #[test]
- /// Sort by header: name
- fn test_app_data_set_sort_by_header_name() {
- let (_ids, containers) = gen_containers();
-
- let mut app_data = gen_appdata(&containers);
-
- let result = app_data.get_container_items();
- assert_eq!(result, &containers);
-
- // descending
- app_data.set_sorted(Some((Header::Name, SortedOrder::Desc)));
- let result = app_data.get_container_items();
- let (a, b, c) = (&result[0], &result[1], &result[2]);
- assert_eq!(a.id, ContainerId::from("3"));
- assert_eq!(b.id, ContainerId::from("2"));
- assert_eq!(c.id, ContainerId::from("1"));
-
- // ascending
- app_data.set_sorted(Some((Header::Name, SortedOrder::Asc)));
- let result = app_data.get_container_items();
- let (a, b, c) = (&result[0], &result[1], &result[2]);
- assert_eq!(a.id, ContainerId::from("1"));
- assert_eq!(b.id, ContainerId::from("2"));
- assert_eq!(c.id, ContainerId::from("3"));
- }
-
- #[test]
- /// Sort by header: state
- fn test_app_data_set_sort_by_header_state() {
- let (_ids, containers) = gen_containers();
-
- let mut app_data = gen_appdata(&containers);
-
- let result = app_data.get_container_items();
- assert_eq!(result, &containers);
-
- if let Some(i) = app_data.get_container_by_id(&ContainerId::from("1")) {
- i.state = State::Exited;
- }
- if let Some(i) = app_data.get_container_by_id(&ContainerId::from("2")) {
- i.state = State::Running(RunningState::Healthy);
- }
- if let Some(i) = app_data.get_container_by_id(&ContainerId::from("3")) {
- i.state = State::Paused;
- }
-
- // descending
- app_data.set_sorted(Some((Header::State, SortedOrder::Desc)));
- let result = app_data.get_container_items();
- let (a, b, c) = (&result[0], &result[1], &result[2]);
- assert_eq!(a.id, ContainerId::from("1"));
- assert_eq!(b.id, ContainerId::from("3"));
- assert_eq!(c.id, ContainerId::from("2"));
-
- // ascending
- app_data.set_sorted(Some((Header::State, SortedOrder::Asc)));
- let result = app_data.get_container_items();
- let (a, b, c) = (&result[0], &result[1], &result[2]);
- assert_eq!(a.id, ContainerId::from("2"));
- assert_eq!(b.id, ContainerId::from("3"));
- assert_eq!(c.id, ContainerId::from("1"));
- }
-
- #[test]
- /// Sort by header: status
- fn test_app_data_set_sort_by_header_status() {
- let (_ids, containers) = gen_containers();
-
- let mut app_data = gen_appdata(&containers);
-
- let result = app_data.get_container_items();
- assert_eq!(result, &containers);
-
- if let Some(i) = app_data.get_container_by_id(&ContainerId::from("2")) {
- ContainerStatus::from("Exited (0) 10 minutes ago".to_owned()).clone_into(&mut i.status);
- }
-
- if let Some(i) = app_data.get_container_by_id(&ContainerId::from("3")) {
- // "Up 2 hours (Paused)".clone_into(&mut i.status);
- ContainerStatus::from("Up 2 hours (Paused)".to_owned()).clone_into(&mut i.status);
- }
-
- // Sort by status
- // descending
- app_data.set_sorted(Some((Header::Status, SortedOrder::Desc)));
- let result = app_data.get_container_items();
- let (a, b, c) = (&result[0], &result[1], &result[2]);
- assert_eq!(a.id, ContainerId::from("3"));
- assert_eq!(b.id, ContainerId::from("1"));
- assert_eq!(c.id, ContainerId::from("2"));
-
- // ascending
- app_data.set_sorted(Some((Header::Status, SortedOrder::Asc)));
- let result = app_data.get_container_items();
- let (a, b, c) = (&result[0], &result[1], &result[2]);
- assert_eq!(a.id, ContainerId::from("2"));
- assert_eq!(b.id, ContainerId::from("1"));
- assert_eq!(c.id, ContainerId::from("3"));
- }
-
- #[test]
- /// Sort by header: cpu
- fn test_app_data_set_sort_by_header_cpu() {
- let (_ids, containers) = gen_containers();
-
- let mut app_data = gen_appdata(&containers);
-
- let result = app_data.get_container_items();
- assert_eq!(result, &containers);
-
- if let Some(i) = app_data.get_container_by_id(&ContainerId::from("1")) {
- i.cpu_stats = VecDeque::from([CpuStats::new(10.1)]);
- }
- if let Some(i) = app_data.get_container_by_id(&ContainerId::from("2")) {
- i.cpu_stats = VecDeque::from([CpuStats::new(8.1)]);
- }
- if let Some(i) = app_data.get_container_by_id(&ContainerId::from("3")) {
- i.cpu_stats = VecDeque::from([CpuStats::new(20.3)]);
- }
-
- // descending
- app_data.set_sorted(Some((Header::Cpu, SortedOrder::Desc)));
- let result = app_data.get_container_items();
- let (a, b, c) = (&result[0], &result[1], &result[2]);
- assert_eq!(a.id, ContainerId::from("3"));
- assert_eq!(b.id, ContainerId::from("1"));
- assert_eq!(c.id, ContainerId::from("2"));
-
- // ascending
- app_data.set_sorted(Some((Header::Cpu, SortedOrder::Asc)));
- let result = app_data.get_container_items();
- let (a, b, c) = (&result[0], &result[1], &result[2]);
- assert_eq!(a.id, ContainerId::from("2"));
- assert_eq!(b.id, ContainerId::from("1"));
- assert_eq!(c.id, ContainerId::from("3"));
- }
-
- #[test]
- /// Sort by header: memory
- fn test_app_data_set_sort_by_header_mem() {
- let (_ids, containers) = gen_containers();
-
- let mut app_data = gen_appdata(&containers);
-
- let result = app_data.get_container_items();
- assert_eq!(result, &containers);
-
- if let Some(i) = app_data.get_container_by_id(&ContainerId::from("1")) {
- i.mem_stats = VecDeque::from([ByteStats::new(40)]);
- }
- if let Some(i) = app_data.get_container_by_id(&ContainerId::from("2")) {
- i.mem_stats = VecDeque::from([ByteStats::new(80)]);
- }
- if let Some(i) = app_data.get_container_by_id(&ContainerId::from("3")) {
- i.mem_stats = VecDeque::from([ByteStats::new(2)]);
- }
-
- // descending
- app_data.set_sorted(Some((Header::Memory, SortedOrder::Desc)));
- let result = app_data.get_container_items();
- let (a, b, c) = (&result[0], &result[1], &result[2]);
- assert_eq!(a.id, ContainerId::from("2"));
- assert_eq!(b.id, ContainerId::from("1"));
- assert_eq!(c.id, ContainerId::from("3"));
-
- // ascending
- app_data.set_sorted(Some((Header::Memory, SortedOrder::Asc)));
- let result = app_data.get_container_items();
- let (a, b, c) = (&result[0], &result[1], &result[2]);
- assert_eq!(a.id, ContainerId::from("3"));
- assert_eq!(b.id, ContainerId::from("1"));
- assert_eq!(c.id, ContainerId::from("2"));
- }
-
- #[test]
- /// Sort by header: id
- fn test_app_data_set_sort_by_header_id() {
- let (_ids, containers) = gen_containers();
-
- let mut app_data = gen_appdata(&containers);
-
- let result = app_data.get_container_items();
- assert_eq!(result, &containers);
-
- // descending
- app_data.set_sorted(Some((Header::Id, SortedOrder::Desc)));
- let result = app_data.get_container_items();
- let (a, b, c) = (&result[0], &result[1], &result[2]);
- assert_eq!(a.id, ContainerId::from("3"));
- assert_eq!(b.id, ContainerId::from("2"));
- assert_eq!(c.id, ContainerId::from("1"));
-
- // ascending
- app_data.set_sorted(Some((Header::Id, SortedOrder::Asc)));
- let result = app_data.get_container_items();
- let (a, b, c) = (&result[0], &result[1], &result[2]);
- assert_eq!(a.id, ContainerId::from("1"));
- assert_eq!(b.id, ContainerId::from("2"));
- assert_eq!(c.id, ContainerId::from("3"));
- }
-
- #[test]
- /// Sort by header: image
- fn test_app_data_set_sort_by_header_image() {
- let (_ids, containers) = gen_containers();
-
- let mut app_data = gen_appdata(&containers);
-
- let result = app_data.get_container_items();
- assert_eq!(result, &containers);
-
- // descending
- app_data.set_sorted(Some((Header::Image, SortedOrder::Desc)));
- let result = app_data.get_container_items();
- let (a, b, c) = (&result[0], &result[1], &result[2]);
- assert_eq!(a.id, ContainerId::from("3"));
- assert_eq!(b.id, ContainerId::from("2"));
- assert_eq!(c.id, ContainerId::from("1"));
-
- // ascending
- app_data.set_sorted(Some((Header::Image, SortedOrder::Asc)));
- let result = app_data.get_container_items();
- let (a, b, c) = (&result[0], &result[1], &result[2]);
- assert_eq!(a.id, ContainerId::from("1"));
- assert_eq!(b.id, ContainerId::from("2"));
- assert_eq!(c.id, ContainerId::from("3"));
- }
-
- #[test]
- /// Sort by header: rx
- fn test_app_data_set_sort_by_header_rx() {
- let (_ids, containers) = gen_containers();
-
- let mut app_data = gen_appdata(&containers);
-
- let result = app_data.get_container_items();
- assert_eq!(result, &containers);
-
- if let Some(i) = app_data.get_container_by_id(&ContainerId::from("1")) {
- i.rx = NetworkBandwidth::new();
- i.rx.push(40);
- }
- if let Some(i) = app_data.get_container_by_id(&ContainerId::from("2")) {
- i.rx = NetworkBandwidth::new();
- i.rx.push(80);
- }
- if let Some(i) = app_data.get_container_by_id(&ContainerId::from("3")) {
- i.rx = NetworkBandwidth::new();
- i.rx.push(2);
- }
-
- // descending
- app_data.set_sorted(Some((Header::Rx, SortedOrder::Desc)));
- let result = app_data.get_container_items();
- let (a, b, c) = (&result[0], &result[1], &result[2]);
- assert_eq!(a.id, ContainerId::from("2"));
- assert_eq!(b.id, ContainerId::from("1"));
- assert_eq!(c.id, ContainerId::from("3"));
-
- // ascending
- app_data.set_sorted(Some((Header::Rx, SortedOrder::Asc)));
- let result = app_data.get_container_items();
- let (a, b, c) = (&result[0], &result[1], &result[2]);
- assert_eq!(a.id, ContainerId::from("3"));
- assert_eq!(b.id, ContainerId::from("1"));
- assert_eq!(c.id, ContainerId::from("2"));
- }
-
- #[test]
- /// Sort by header: tx
- fn test_app_data_set_sort_by_header_tx() {
- let (_ids, containers) = gen_containers();
-
- let mut app_data = gen_appdata(&containers);
-
- let result = app_data.get_container_items();
- assert_eq!(result, &containers);
-
- if let Some(i) = app_data.get_container_by_id(&ContainerId::from("1")) {
- i.rx = NetworkBandwidth::new();
- i.rx.push(400);
- }
- if let Some(i) = app_data.get_container_by_id(&ContainerId::from("2")) {
- i.rx = NetworkBandwidth::new();
- i.rx.push(80);
- }
- if let Some(i) = app_data.get_container_by_id(&ContainerId::from("3")) {
- i.rx = NetworkBandwidth::new();
- i.rx.push(83);
- }
-
- // descending
- app_data.set_sorted(Some((Header::Rx, SortedOrder::Desc)));
- let result = app_data.get_container_items();
- let (a, b, c) = (&result[0], &result[1], &result[2]);
- assert_eq!(a.id, ContainerId::from("1"));
- assert_eq!(b.id, ContainerId::from("3"));
- assert_eq!(c.id, ContainerId::from("2"));
-
- // ascending
- app_data.set_sorted(Some((Header::Rx, SortedOrder::Asc)));
- let result = app_data.get_container_items();
- let (a, b, c) = (&result[0], &result[1], &result[2]);
- assert_eq!(a.id, ContainerId::from("2"));
- assert_eq!(b.id, ContainerId::from("3"));
- assert_eq!(c.id, ContainerId::from("1"));
- }
-
- #[test]
- /// Sort by header when selected headers match
- fn test_app_data_set_sort_by_header_match() {
- let (_ids, containers) = gen_containers();
-
- let mut app_data = gen_appdata(&containers);
-
- let result = app_data.get_container_items();
- assert_eq!(result, &containers);
-
- // descending
- app_data.set_sorted(Some((Header::Rx, SortedOrder::Desc)));
- let result = app_data.get_container_items();
- let (a, b, c) = (&result[0], &result[1], &result[2]);
- assert_eq!(a.id, ContainerId::from("3"));
- assert_eq!(b.id, ContainerId::from("2"));
- assert_eq!(c.id, ContainerId::from("1"));
-
- // ascending
- app_data.set_sorted(Some((Header::Rx, SortedOrder::Asc)));
- let result = app_data.get_container_items();
- let (a, b, c) = (&result[0], &result[1], &result[2]);
- assert_eq!(a.id, ContainerId::from("1"));
- assert_eq!(b.id, ContainerId::from("2"));
- assert_eq!(c.id, ContainerId::from("3"));
- }
-
- #[test]
- /// reset sorted
- fn test_app_data_reset_sorted() {
- let (_ids, containers) = gen_containers();
-
- let mut app_data = gen_appdata(&containers);
-
- let result = app_data.get_container_items();
- assert_eq!(result, &containers);
-
- if let Some(i) = app_data.get_container_by_id(&ContainerId::from("1")) {
- i.rx = NetworkBandwidth::new();
- i.rx.push(400);
- }
- if let Some(i) = app_data.get_container_by_id(&ContainerId::from("2")) {
- i.rx = NetworkBandwidth::new();
- i.rx.push(80);
- }
- if let Some(i) = app_data.get_container_by_id(&ContainerId::from("3")) {
- i.rx = NetworkBandwidth::new();
- i.rx.push(83);
- }
-
- app_data.set_sorted(Some((Header::Rx, SortedOrder::Asc)));
- let result = app_data.get_container_items();
- let (a, b, c) = (&result[0], &result[1], &result[2]);
- assert_eq!(a.id, ContainerId::from("2"));
- assert_eq!(b.id, ContainerId::from("3"));
- assert_eq!(c.id, ContainerId::from("1"));
-
- app_data.set_sorted(None);
- let result = app_data.get_container_items();
- let (a, b, c) = (&result[0], &result[1], &result[2]);
- assert_eq!(a.id, ContainerId::from("1"));
- assert_eq!(b.id, ContainerId::from("2"));
- assert_eq!(c.id, ContainerId::from("3"));
- }
-
- // **************** //
- // Container state //
- // **************** //
-
- #[test]
- /// Get len of current containers vec
- fn test_app_data_get_container_len() {
- let (_ids, containers) = gen_containers();
- let app_data = gen_appdata(&containers);
- assert_eq!(app_data.get_container_len(), 3);
- }
-
- #[test]
- /// Select the first container
- fn test_app_data_containers_start() {
- let (_ids, containers) = gen_containers();
- let mut app_data = gen_appdata(&containers);
-
- // No container selected
- let result = app_data.get_container_state();
- assert_eq!(result.selected(), None);
- assert_eq!(result.offset(), 0);
-
- // First container selected
- app_data.containers_start();
- let result = app_data.get_container_state();
- assert_eq!(result.selected(), Some(0));
- assert_eq!(result.offset(), 0);
-
- let result = app_data.get_selected_container_id();
- assert_eq!(result, Some(ContainerId::from("1")));
- let result = app_data.get_selected_container_id_state_name();
- assert_eq!(
- result,
- Some((
- ContainerId::from("1"),
- State::Running(RunningState::Healthy),
- "container_1".to_owned()
- ))
- );
-
- // Calling previous when at start has no effect
- app_data.containers_scroll(&ScrollDirection::Up);
- let result = app_data.get_selected_container_id();
- assert_eq!(result, Some(ContainerId::from("1")));
- let result = app_data.get_selected_container_id_state_name();
- assert_eq!(
- result,
- Some((
- ContainerId::from("1"),
- State::Running(RunningState::Healthy),
- "container_1".to_owned()
- ))
- );
- }
-
- #[test]
- /// advance container list state by one
- /// get get_selected_container_id() & get_selected_container_id_state_name() return valid Some data
- fn test_app_data_containers_next() {
- let (_ids, containers) = gen_containers();
- let mut app_data = gen_appdata(&containers);
-
- // Advance list state by 1
- app_data.containers_start();
- app_data.containers.scroll(&ScrollDirection::Down);
-
- let result = app_data.get_container_state();
- assert_eq!(result.selected(), Some(1));
- assert_eq!(result.offset(), 0);
-
- let result = app_data.get_selected_container_id();
- assert_eq!(result, Some(ContainerId::from("2")));
- let result = app_data.get_selected_container_id_state_name();
- assert_eq!(
- result,
- Some((
- ContainerId::from("2"),
- State::Running(RunningState::Healthy),
- "container_2".to_owned()
- ))
- );
- }
-
- #[test]
- /// advance container list state to the end
- /// get get_selected_container_id() & get_selected_container_id_state_name() return valid Some data
- fn test_app_data_containers_end() {
- let (_ids, containers) = gen_containers();
- let mut app_data = gen_appdata(&containers);
-
- app_data.containers_end();
- let result = app_data.get_container_state();
- assert_eq!(result.selected(), Some(2));
- assert_eq!(result.offset(), 0);
-
- let result = app_data.get_selected_container_id();
- assert_eq!(result, Some(ContainerId::from("3")));
- let result = app_data.get_selected_container_id_state_name();
- assert_eq!(
- result,
- Some((
- ContainerId::from("3"),
- State::Running(RunningState::Healthy),
- "container_3".to_owned()
- ))
- );
-
- // Calling previous when at end has no effect
- app_data.containers.scroll(&ScrollDirection::Down);
- let result = app_data.get_selected_container_id();
- assert_eq!(result, Some(ContainerId::from("3")));
- let result = app_data.get_selected_container_id_state_name();
- assert_eq!(
- result,
- Some((
- ContainerId::from("3"),
- State::Running(RunningState::Healthy),
- "container_3".to_owned()
- ))
- );
- }
-
- #[test]
- /// go to previous container
- fn test_app_data_containers_prev() {
- let (_ids, containers) = gen_containers();
- let mut app_data = gen_appdata(&containers);
-
- app_data.containers_end();
- app_data.containers.scroll(&ScrollDirection::Up);
- let result = app_data.get_container_state();
- assert_eq!(result.selected(), Some(1));
- assert_eq!(result.offset(), 0);
- }
-
- #[test]
- /// Get the currently selected container
- fn test_app_data_get_selected_container() {
- let (_ids, mut containers) = gen_containers();
- let mut app_data = gen_appdata(&containers);
-
- let result = app_data.get_selected_container();
- assert_eq!(result, None);
-
- app_data.containers.start();
- app_data.containers.scroll(&ScrollDirection::Down);
-
- let result = app_data.get_selected_container();
- assert_eq!(result, Some(&containers[1]));
-
- // As above, but now as mut
- let result = app_data.get_mut_selected_container();
- assert_eq!(result, Some(&mut containers[1]));
- }
-
- #[test]
- /// Get mut container by id
- fn test_app_data_get_container_by_id() {
- let (_ids, mut containers) = gen_containers();
- let mut app_data = gen_appdata(&containers);
-
- let result = app_data.get_container_by_id(&ContainerId::from("2"));
- assert_eq!(result, Some(&mut containers[1]));
- }
-
- #[test]
- /// Get just the containers name by id
- fn test_app_data_get_container_name_by_id() {
- let (_ids, containers) = gen_containers();
- let mut app_data = gen_appdata(&containers);
-
- let result = app_data.get_container_name_by_id(&ContainerId::from("2"));
- assert_eq!(result, Some(&ContainerName::from("container_2")));
- }
-
- #[test]
- /// Get the id of the currently selected container
- fn test_app_data_get_selected_container_id() {
- let (_ids, containers) = gen_containers();
- let mut app_data = gen_appdata(&containers);
- app_data.containers_end();
-
- let result = app_data.get_selected_container_id();
- assert_eq!(result, Some(ContainerId::from("3")));
- }
-
- #[test]
- fn test_app_data_get_selected_container_id_state_name() {
- let (_ids, containers) = gen_containers();
- let mut app_data = gen_appdata(&containers);
- app_data.containers_end();
-
- let result = app_data.get_selected_container_id_state_name();
- assert_eq!(
- result,
- Some((
- ContainerId::from("3"),
- State::Running(RunningState::Healthy),
- "container_3".to_owned()
- ))
- );
- }
-
- // ************** //
- // DockerControls //
- // ************** //
-
- #[test]
- /// Docker commands returned correctly
- fn test_app_data_selected_docker_command() {
- let (_ids, containers) = gen_containers();
- let mut app_data = gen_appdata(&containers);
-
- // No commands when no container selected
- let result = app_data.selected_docker_controls();
- assert!(result.is_none());
-
- // Correct commands returned
- app_data.containers_start();
- app_data.docker_controls_start();
-
- let result = app_data.selected_docker_controls();
- assert_eq!(result, Some(DockerCommand::Pause));
- }
-
- #[test]
- /// Docker command next works
- fn test_app_data_selected_docker_command_next() {
- let (_ids, containers) = gen_containers();
- let mut app_data = gen_appdata(&containers);
- app_data.containers_start();
- app_data.docker_controls_start();
- app_data.docker_controls_scroll(&ScrollDirection::Down);
-
- let result = app_data.selected_docker_controls();
- assert_eq!(result, Some(DockerCommand::Restart));
- }
-
- #[test]
- /// Dockercommand end works, and next has no effect when at end
- fn test_app_data_selected_docker_command_end() {
- let (_ids, containers) = gen_containers();
- let mut app_data = gen_appdata(&containers);
- app_data.containers_start();
- app_data.docker_controls_end();
-
- let result = app_data.selected_docker_controls();
- assert_eq!(result, Some(DockerCommand::Delete));
-
- // Next has no effect when at end
- app_data.docker_controls_scroll(&ScrollDirection::Down);
- let result = app_data.selected_docker_controls();
- assert_eq!(result, Some(DockerCommand::Delete));
- }
-
- #[test]
- /// Docker commands previous works, and has no effect when at start
- fn test_app_data_selected_docker_command_previous() {
- let (_ids, containers) = gen_containers();
- let mut app_data = gen_appdata(&containers);
- app_data.containers_start();
- app_data.docker_controls_end();
- app_data.docker_controls_scroll(&ScrollDirection::Up);
-
- let result = app_data.selected_docker_controls();
- assert_eq!(result, Some(DockerCommand::Stop));
-
- // previous has no effect when at start
- app_data.docker_controls_start();
- app_data.docker_controls_scroll(&ScrollDirection::Up);
- let result = app_data.selected_docker_controls();
- assert_eq!(result, Some(DockerCommand::Pause));
- }
-
- #[test]
- /// DockerCommands get correct controls dependant on container state
- fn test_app_data_get_control_items() {
- let test_state = |state: State, expected: &mut Vec| {
- let gen_item_state = |state: State| {
- ContainerItem::new(
- 1,
- ContainerId::from("1"),
- "image_1".to_owned(),
- false,
- "container_1".to_owned(),
- vec![],
- state,
- ContainerStatus::from("Up 1 hour".to_owned()),
- )
- };
- let mut app_data = gen_appdata(&[gen_item_state(state)]);
- app_data.containers_start();
- app_data.docker_controls_start();
-
- let result = app_data.get_control_items();
- assert_eq!(result, Some(expected));
- };
-
- test_state(
- State::Dead,
- &mut vec![
- DockerCommand::Start,
- DockerCommand::Restart,
- DockerCommand::Delete,
- ],
- );
- test_state(
- State::Exited,
- &mut vec![
- DockerCommand::Start,
- DockerCommand::Restart,
- DockerCommand::Delete,
- ],
- );
- test_state(
- State::Paused,
- &mut vec![
- DockerCommand::Resume,
- DockerCommand::Stop,
- DockerCommand::Delete,
- ],
- );
- test_state(State::Removing, &mut vec![DockerCommand::Delete]);
- test_state(
- State::Restarting,
- &mut vec![DockerCommand::Stop, DockerCommand::Delete],
- );
- test_state(
- State::Running(RunningState::Healthy),
- &mut vec![
- DockerCommand::Pause,
- DockerCommand::Restart,
- DockerCommand::Stop,
- DockerCommand::Delete,
- ],
- );
- test_state(State::Unknown, &mut vec![DockerCommand::Delete]);
- }
-
- // ****** //
- // Filter //
- // ****** //
-
- #[test]
- /// Data is filtered correctly by name
- fn test_app_data_filter_by_name() {
- let (_, containers) = gen_containers();
-
- let mut app_data = gen_appdata(&containers);
-
- assert!(app_data.get_filter().1.is_none());
-
- let pre_len = app_data.containers.items.len();
- app_data.filter_term_push('_');
- app_data.filter_term_push('2');
-
- assert_eq!(app_data.get_filter().1, Some(&"_2".to_string()));
-
- app_data.filter_containers();
- let post_len = app_data.containers.items.len();
- assert!(pre_len != post_len);
- assert_eq!(post_len, 1);
-
- // Can insert checks against the current filter term
- assert!(app_data.can_insert(&containers[1]));
- assert!(!app_data.can_insert(&containers[0]));
- assert!(!app_data.can_insert(&containers[2]));
- }
-
- #[test]
- /// Data is filtered correctly by image
- fn test_app_data_filter_by_image() {
- let (_, containers) = gen_containers();
-
- let mut app_data = gen_appdata(&containers);
-
- assert!(app_data.get_filter().1.is_none());
-
- let pre_len = app_data.containers.items.len();
- for c in ['i', 'm', 'a', 'g', 'e', '_', '2'] {
- app_data.filter_term_push(c);
- }
- // app_data.filter_term_push('2');
- app_data.filter_by_next();
-
- assert_eq!(
- app_data.get_filter(),
- (FilterBy::Image, Some(&"image_2".to_string()))
- );
-
- app_data.filter_containers();
- let post_len = app_data.containers.items.len();
- assert!(pre_len != post_len);
- assert_eq!(post_len, 1);
-
- assert!(!app_data.can_insert(&containers[0]));
- assert!(app_data.can_insert(&containers[1]));
- assert!(!app_data.can_insert(&containers[2]));
- }
-
- #[test]
- /// Data is filtered correctly by status
- fn test_app_data_filter_by_status() {
- let (_, mut containers) = gen_containers();
- ContainerStatus::from("Exited".to_owned()).clone_into(&mut containers[0].status);
- let mut app_data = gen_appdata(&containers);
-
- assert!(app_data.get_filter().1.is_none());
-
- let pre_len = app_data.containers.items.len();
- app_data.filter_term_push('x');
-
- app_data.filter_by_next();
- app_data.filter_by_next();
-
- assert_eq!(
- app_data.get_filter(),
- (FilterBy::Status, Some(&"x".to_string()))
- );
-
- app_data.filter_containers();
- let post_len = app_data.containers.items.len();
- assert!(pre_len != post_len);
- assert_eq!(post_len, 1);
-
- assert!(app_data.can_insert(&containers[0]));
- assert!(!app_data.can_insert(&containers[1]));
- assert!(!app_data.can_insert(&containers[2]));
- }
-
- #[test]
- /// Data is filtered correctly by all
- fn test_app_data_filter_by_all() {
- let (_, mut containers) = gen_containers();
- ContainerStatus::from("Exited".to_owned()).clone_into(&mut containers[0].status);
- let mut app_data = gen_appdata(&containers);
-
- assert!(app_data.get_filter().1.is_none());
-
- let pre_len = app_data.containers.items.len();
- app_data.filter_term_push('x');
-
- app_data.filter_by_next();
- app_data.filter_by_next();
- app_data.filter_by_next();
-
- assert_eq!(
- app_data.get_filter(),
- (FilterBy::All, Some(&"x".to_string()))
- );
-
- app_data.filter_containers();
- let post_len = app_data.containers.items.len();
- assert!(pre_len != post_len);
- assert_eq!(post_len, 1);
-
- assert!(app_data.can_insert(&containers[0]));
- assert!(!app_data.can_insert(&containers[1]));
- assert!(!app_data.can_insert(&containers[2]));
- }
-
- #[test]
- /// Data is filtered correctly after various next() and previous() commands
- fn test_app_data_filter_prev() {
- let (_, mut containers) = gen_containers();
- ContainerStatus::from("Exited".to_owned()).clone_into(&mut containers[0].status);
- let mut app_data = gen_appdata(&containers);
-
- assert!(app_data.get_filter().1.is_none());
-
- let pre_len = app_data.containers.items.len();
- app_data.filter_term_push('x');
-
- app_data.filter_by_next();
- app_data.filter_by_next();
-
- assert_eq!(
- app_data.get_filter(),
- (FilterBy::Status, Some(&"x".to_string()))
- );
-
- app_data.filter_containers();
- let post_len = app_data.containers.items.len();
- assert!(pre_len != post_len);
- assert_eq!(post_len, 1);
-
- assert!(app_data.can_insert(&containers[0]));
- assert!(!app_data.can_insert(&containers[1]));
- assert!(!app_data.can_insert(&containers[2]));
-
- app_data.filter_by_prev();
- assert_eq!(
- app_data.get_filter(),
- (FilterBy::Image, Some(&"x".to_string()))
- );
-
- app_data.filter_containers();
- let post_len = app_data.containers.items.len();
- assert!(pre_len != post_len);
- assert_eq!(post_len, 0);
-
- assert!(!app_data.can_insert(&containers[0]));
- assert!(!app_data.can_insert(&containers[1]));
- assert!(!app_data.can_insert(&containers[2]));
- }
-
- // **** //
- // Logs //
- // **** //
-
- #[test]
- /// log title string generated correctly
- fn test_app_data_get_log_title() {
- let (ids, containers) = gen_containers();
-
- let mut app_data = gen_appdata(&containers);
-
- // No container selected select
- let result = app_data.get_log_title();
- assert_eq!(result, "");
-
- // No logs
- app_data.containers.start();
- let result = app_data.get_log_title();
- assert_eq!(result, " - container_1 - image_1");
-
- // On last line of logs
- let logs = (1..=3).map(|i| format!("{i} {i}")).collect::>();
- app_data.update_log_by_id(logs, &ids[0]);
- let result = app_data.get_log_title();
- assert_eq!(result, " 3/3 - container_1 - image_1");
-
- // Change log state to no longer be at the end
- app_data.log_scroll(&ScrollDirection::Up);
- let result = app_data.get_log_title();
- assert_eq!(result, " 2/3 - container_1 - image_1");
- }
-
- #[test]
- /// log title string generated correctly after container change
- fn test_app_data_get_log_title_after_container_change() {
- let (ids, containers) = gen_containers();
- let mut app_data = gen_appdata(&containers);
-
- // No container selected select
- let result = app_data.get_log_title();
- assert_eq!(result, "");
-
- app_data.containers_start();
-
- let result = app_data.get_log_title();
- assert_eq!(result, " - container_1 - image_1");
-
- // change container
- app_data.containers_scroll(&ScrollDirection::Down);
- let result = app_data.get_log_title();
- assert_eq!(result, " - container_2 - image_2");
-
- // On last line of logs
- let logs = (1..=3).map(|i| format!("{i} {i}")).collect::>();
- app_data.update_log_by_id(logs, &ids[1]);
- let result = app_data.get_log_title();
- assert_eq!(result, " 3/3 - container_2 - image_2");
-
- // Change log state to no longer be at the end
- app_data.log_scroll(&ScrollDirection::Up);
- let result = app_data.get_log_title();
- assert_eq!(result, " 2/3 - container_2 - image_2");
- }
-
- #[test]
- /// update logs by id works
- fn test_app_data_update_log_by_id() {
- let (ids, containers) = gen_containers();
- let mut app_data = gen_appdata(&containers);
-
- // No container selected select
- let result = app_data.get_log_title();
- assert_eq!(result, "");
-
- app_data.containers_start();
- let logs = (1..=3).map(|i| format!("{i} {i}")).collect::>();
-
- app_data.update_log_by_id(logs, &ids[0]);
-
- let result = app_data.get_log_state();
- assert!(result.is_some());
- assert_eq!(result.as_ref().unwrap().selected(), Some(2));
- assert_eq!(result.unwrap().offset(), 0);
-
- let result = app_data.get_logs(
- Size {
- width: 20,
- height: 4,
- },
- 1,
- );
- assert_eq!(result.len(), 3);
-
- let result = app_data.get_log_title();
- assert_eq!(result, " 3/3 - container_1 - image_1");
- }
-
- #[test]
- /// logs state reset to start
- fn test_app_data_logs_start() {
- let (ids, containers) = gen_containers();
- let mut app_data = gen_appdata(&containers);
- let logs = (1..=3).map(|i| format!("{i} {i}")).collect::>();
- app_data.containers_start();
- app_data.update_log_by_id(logs, &ids[0]);
-
- app_data.log_start();
-
- let result = app_data.get_log_state();
- assert!(result.is_some());
- assert_eq!(result.as_ref().unwrap().selected(), Some(0));
- assert_eq!(result.unwrap().offset(), 0);
-
- let result = app_data.get_log_title();
- assert_eq!(result, " 1/3 - container_1 - image_1");
- }
-
- #[test]
- /// logs state end goes to the end of the logs list
- fn test_app_data_logs_end() {
- let (ids, containers) = gen_containers();
- let mut app_data = gen_appdata(&containers);
- let logs = (1..=3).map(|i| format!("{i} {i}")).collect::>();
- app_data.containers_start();
- app_data.update_log_by_id(logs, &ids[0]);
-
- app_data.log_start();
-
- let result = app_data.get_log_state();
- assert!(result.is_some());
- assert_eq!(result.as_ref().unwrap().selected(), Some(0));
- assert_eq!(result.unwrap().offset(), 0);
-
- let result = app_data.get_log_title();
- assert_eq!(result, " 1/3 - container_1 - image_1");
-
- app_data.log_end();
- let result = app_data.get_log_state();
- assert!(result.is_some());
- assert_eq!(result.as_ref().unwrap().selected(), Some(2));
- assert_eq!(result.unwrap().offset(), 0);
-
- let result = app_data.get_log_title();
- assert_eq!(result, " 3/3 - container_1 - image_1");
- }
-
- #[test]
- /// logs state next works
- /// At end has no effect
- fn test_app_data_logs_next() {
- let (ids, containers) = gen_containers();
- let mut app_data = gen_appdata(&containers);
- let logs = (1..=3).map(|i| format!("{i} {i}")).collect::>();
- app_data.containers_start();
- app_data.update_log_by_id(logs, &ids[0]);
-
- app_data.log_start();
-
- let result = app_data.get_log_state();
- assert!(result.is_some());
- assert_eq!(result.as_ref().unwrap().selected(), Some(0));
- assert_eq!(result.unwrap().offset(), 0);
-
- let result = app_data.get_log_title();
- assert_eq!(result, " 1/3 - container_1 - image_1");
-
- app_data.log_scroll(&ScrollDirection::Down);
- let result = app_data.get_log_state();
- assert!(result.is_some());
- assert_eq!(result.as_ref().unwrap().selected(), Some(1));
- assert_eq!(result.unwrap().offset(), 0);
-
- let result = app_data.get_log_title();
- assert_eq!(result, " 2/3 - container_1 - image_1");
-
- app_data.log_scroll(&ScrollDirection::Down);
- let result = app_data.get_log_state();
- assert!(result.is_some());
- assert_eq!(result.as_ref().unwrap().selected(), Some(2));
- assert_eq!(result.unwrap().offset(), 0);
-
- let result = app_data.get_log_title();
- assert_eq!(result, " 3/3 - container_1 - image_1");
- app_data.log_scroll(&ScrollDirection::Down);
-
- let result = app_data.get_log_state();
- assert!(result.is_some());
- assert_eq!(result.as_ref().unwrap().selected(), Some(2));
- assert_eq!(result.unwrap().offset(), 0);
-
- let result = app_data.get_log_title();
- assert_eq!(result, " 3/3 - container_1 - image_1");
- }
-
- #[test]
- /// logs state previous works
- /// previous at start has no effect
- fn test_app_data_logs_previous() {
- let (ids, containers) = gen_containers();
- let mut app_data = gen_appdata(&containers);
- let logs = (1..=3).map(|i| format!("{i} {i}")).collect::>();
- app_data.containers_start();
- app_data.update_log_by_id(logs, &ids[0]);
-
- app_data.log_end();
-
- let result = app_data.get_log_state();
- assert!(result.is_some());
- assert_eq!(result.as_ref().unwrap().selected(), Some(2));
- assert_eq!(result.unwrap().offset(), 0);
-
- let result = app_data.get_log_title();
- assert_eq!(result, " 3/3 - container_1 - image_1");
-
- app_data.log_scroll(&ScrollDirection::Up);
-
- let result = app_data.get_log_state();
- assert!(result.is_some());
- assert_eq!(result.as_ref().unwrap().selected(), Some(1));
- assert_eq!(result.unwrap().offset(), 0);
- let result = app_data.get_log_title();
- assert_eq!(result, " 2/3 - container_1 - image_1");
-
- app_data.log_scroll(&ScrollDirection::Up);
- let result = app_data.get_log_state();
- assert!(result.is_some());
- assert_eq!(result.as_ref().unwrap().selected(), Some(0));
- assert_eq!(result.unwrap().offset(), 0);
- let result = app_data.get_log_title();
- assert_eq!(result, " 1/3 - container_1 - image_1");
-
- app_data.log_scroll(&ScrollDirection::Up);
- let result = app_data.get_log_state();
- assert!(result.is_some());
- assert_eq!(result.as_ref().unwrap().selected(), Some(0));
- assert_eq!(result.unwrap().offset(), 0);
- let result = app_data.get_log_title();
- assert_eq!(result, " 1/3 - container_1 - image_1");
- }
-
- // ********** //
- // Chart data //
- // ********** //
-
- #[test]
- /// Chart data returned correctly
- fn test_app_data_get_chart_data() {
- let (_ids, containers) = gen_containers();
- let mut app_data = gen_appdata(&containers);
-
- let result = app_data.get_chart_data();
- assert!(result.is_none());
-
- app_data.containers_start();
-
- let mut rx = NetworkBandwidth::new();
- rx.push(200);
- rx.push(100);
- rx.push(200);
-
- let mut tx = NetworkBandwidth::new();
- tx.push(300);
- tx.push(600);
- tx.push(900);
-
- if let Some(item) = app_data.get_container_by_id(&ContainerId::from("1")) {
- item.cpu_stats = VecDeque::from([CpuStats::new(1.2), CpuStats::new(1.2)]);
- item.mem_stats = VecDeque::from([ByteStats::new(1), ByteStats::new(2)]);
- item.rx = rx;
- item.tx = tx;
- }
-
- let result = app_data.get_chart_data();
- assert_eq!(
- result,
- Some(ChartsData {
- memory: ChartSeries {
- dataset: vec![(0.0, 1.0), (1.0, 2.0)],
- max: ByteStats::new(2),
- current: ByteStats::new(2)
- },
- cpu: ChartSeries {
- dataset: vec![(0.0, 1.2), (1.0, 1.2)],
- max: CpuStats::new(1.2),
- current: CpuStats::new(1.2)
- },
- rx: ChartSeries {
- dataset: vec![(0.0, 0.0), (1.0, 100.0)],
- max: BandwidthStat::new(100),
- current: BandwidthStat::new(100)
- },
- tx: ChartSeries {
- dataset: vec![(0.0, 300.0), (1.0, 300.0)],
- max: BandwidthStat::new(300),
- current: BandwidthStat::new(300)
- },
- state: State::Running(RunningState::Healthy)
- })
- );
- }
-
- // ************* //
- // Header Widths //
- // ************* //
-
- #[test]
- /// Header widths return correctly
- fn test_app_data_get_width() {
- let (_ids, containers) = gen_containers();
- let app_data = gen_appdata(&containers);
-
- let result = app_data.get_width();
- let expected = Columns {
- name: (Header::Name, 11),
- state: (Header::State, 9),
- status: (Header::Status, 9),
- cpu: (Header::Cpu, 6),
- mem: (Header::Memory, 7, 7),
- id: (Header::Id, 8),
- image: (Header::Image, 7),
- net_rx: (Header::Rx, 7),
- net_tx: (Header::Tx, 7),
- };
- assert_eq!(result, expected);
- }
-
- #[test]
- /// Header widths return correctly when some containers hidden
- fn test_app_data_get_width_filtered() {
- let (_ids, mut containers) = gen_containers();
- containers[0].name = ContainerName::from("some_longer_name_with_filter");
- let mut app_data = gen_appdata(&containers);
-
- let result = app_data.get_width();
- let expected = Columns {
- name: (Header::Name, 28),
- state: (Header::State, 9),
- status: (Header::Status, 9),
- cpu: (Header::Cpu, 6),
- mem: (Header::Memory, 7, 7),
- id: (Header::Id, 8),
- image: (Header::Image, 7),
- net_rx: (Header::Rx, 7),
- net_tx: (Header::Tx, 7),
- };
-
- assert_eq!(result, expected);
- app_data.filter_term_push('c');
- app_data.filter_containers();
- assert_eq!(result, expected);
- }
-
- // ***** //
- // Ports //
- // ***** //
-
- #[test]
- /// Returns selected containers ports ordered by private ip
- fn test_app_data_get_selected_ports() {
- let (_ids, containers) = gen_containers();
- let mut app_data = gen_appdata(&containers);
-
- app_data.containers.items[0].ports.push(ContainerPorts {
- ip: None,
- private: 10,
- public: Some(1),
- });
- app_data.containers.items[0].ports.push(ContainerPorts {
- ip: None,
- private: 11,
- public: Some(3),
- });
- app_data.containers.items[0].ports.push(ContainerPorts {
- ip: None,
- private: 4,
- public: Some(2),
- });
-
- // No containers selected
- let result = app_data.get_selected_ports();
- assert!(result.is_none());
-
- // Selected container & ports
- app_data.containers_start();
- let result = app_data.get_selected_ports();
-
- assert_eq!(
- result,
- Some((
- vec![
- ContainerPorts {
- ip: None,
- private: 4,
- public: Some(2)
- },
- ContainerPorts {
- ip: None,
- private: 10,
- public: Some(1)
- },
- ContainerPorts {
- ip: None,
- private: 11,
- public: Some(3)
- },
- ContainerPorts {
- ip: None,
- private: 8001,
- public: None
- }
- ],
- State::Running(RunningState::Healthy),
- ))
- );
-
- // Selected container & no ports
- app_data.containers_start();
- app_data.containers.items[0].ports = vec![];
- let result = app_data.get_selected_ports();
-
- assert_eq!(
- result,
- Some((vec![], State::Running(RunningState::Healthy)))
- );
- }
-
- // ************** //
- // Update mtehods //
- // ************** //
-
- #[test]
- /// Update stats functioning
- fn test_app_data_update_stats() {
- let (ids, containers) = gen_containers();
-
- let mut app_data = gen_appdata(&containers);
-
- let result = app_data.get_container_items();
- assert_eq!(result[0], containers[0]);
-
- app_data.update_stats_by_id(&ids[0], Some(10.0), Some(10), 10, 10, 10);
-
- let result = app_data.get_container_items();
- assert_ne!(result[0], containers[0]);
- assert_eq!(result[0].cpu_stats, VecDeque::from([CpuStats::new(10.0)]));
- assert_eq!(result[0].mem_stats, VecDeque::from([ByteStats::new(10)]));
- assert_eq!(result[0].mem_limit, ByteStats::new(10));
-
- let mut rx = NetworkBandwidth::new();
- rx.push(10);
- let mut tx = NetworkBandwidth::new();
- tx.push(10);
- assert_eq!(result[0].rx, rx);
- // VecDeque::from([ByteStats::new(10)]));
- assert_eq!(result[0].tx, tx);
- // VecDeque::from([ByteStats::new(10)]));
- }
-
- #[test]
- /// Update stats functioning
- fn test_app_data_update_containers() {
- let (_ids, containers) = gen_containers();
- let mut app_data = gen_appdata(&containers);
- let result_pre = app_data.get_container_items().to_owned();
- let input = vec![
- gen_container_summary(1, "paused"),
- gen_container_summary(2, "dead"),
- ];
-
- app_data.update_containers(input);
- let result_post = app_data.get_container_items().to_owned();
- assert_ne!(result_pre, result_post);
- assert_eq!(result_post[0].state, State::Paused);
- assert_eq!(result_post[1].state, State::Dead);
- }
-
- #[test]
- /// Update logs don't work if container is_oxker: true
- fn test_app_data_update_log_by_id_is_oxker() {
- let (ids, mut containers) = gen_containers();
- containers[0].is_oxker = true;
- let mut app_data = gen_appdata(&containers);
- let logs = (1..=3).map(|i| format!("{i} {i}")).collect::>();
-
- app_data.update_log_by_id(logs, &ids[0]);
- app_data.log_start();
-
- let result = app_data.get_log_state();
- assert!(result.is_none());
- }
-
- // *************** //
- // Get logs method //
- // *************** //
-
- #[test]
- /// get_logs() returns vec of item, but the items are empty unless they are in the *visible" zone, based on height, index, and padding
- fn test_app_data_update_get_logs() {
- let (ids, containers) = gen_containers();
-
- let mut app_data = gen_appdata(&containers);
-
- app_data.containers_start();
- let logs = (0..=999).map(|i| format!("{i} {i}")).collect::>();
-
- app_data.update_log_by_id(logs, &ids[0]);
-
- let result = app_data.get_logs(
- Size {
- width: 20,
- height: 10,
- },
- 10,
- );
- for (index, item) in result.iter().enumerate() {
- if index < 979 {
- assert_eq!(item, &Text::from(""));
- } else {
- assert_eq!(item, &Text::from(format!("{index}")));
- }
- }
-
- let result = app_data.get_logs(
- Size {
- width: 20,
- height: 100,
- },
- 20,
- );
- for (index, item) in result.iter().enumerate() {
- if index < 879 {
- assert_eq!(item, &Text::from(""));
- } else {
- assert_eq!(item, &Text::from(format!("{index}")));
- }
- }
-
- app_data.log_start();
-
- let result = app_data.get_logs(
- Size {
- width: 20,
- height: 10,
- },
- 10,
- );
- for (index, item) in result.iter().enumerate() {
- if index > 20 {
- assert_eq!(item, &Text::from(""));
- } else {
- assert_eq!(item, &Text::from(format!("{index}")));
- }
- }
-
- for _ in 0..=500 {
- app_data.log_scroll(&ScrollDirection::Down);
- }
- let result = app_data.get_logs(
- Size {
- width: 20,
- height: 10,
- },
- 10,
- );
- for (index, item) in result.iter().enumerate() {
- if (481..=521).contains(&index) {
- assert_eq!(item, &Text::from(format!("{index}")));
- } else {
- assert_eq!(item, &Text::from(""));
- }
- }
- }
-}
diff --git a/src/app_error.rs b/src/app_error.rs
deleted file mode 100644
index ce4b5f6..0000000
--- a/src/app_error.rs
+++ /dev/null
@@ -1,34 +0,0 @@
-use crate::app_data::DockerCommand;
-use std::fmt;
-
-/// app errors to set in global state
-#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
-pub enum AppError {
- DockerCommand(DockerCommand),
- DockerExec,
- DockerLogs,
- DockerConnect,
- IO(String),
- MouseCapture(bool),
- Parse(String),
- Terminal,
-}
-
-/// Convert errors into strings to display
-impl fmt::Display for AppError {
- fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
- match self {
- Self::DockerCommand(s) => write!(f, "Unable to {s} container"),
- Self::DockerExec => write!(f, "Unable to exec into container"),
- Self::DockerLogs => write!(f, "Unable to save logs"),
- Self::DockerConnect => write!(f, "Unable to access docker daemon"),
- Self::IO(msg) => write!(f, "IO error with: {msg}"),
- Self::MouseCapture(x) => {
- let reason = if *x { "en" } else { "dis" };
- write!(f, "Unable to {reason}able mouse capture")
- }
- Self::Parse(msg) => write!(f, "Parsing error: {msg}"),
- Self::Terminal => write!(f, "Unable to fully render to terminal"),
- }
- }
-}
diff --git a/src/config/color_parser.rs b/src/config/color_parser.rs
deleted file mode 100644
index 8afded3..0000000
--- a/src/config/color_parser.rs
+++ /dev/null
@@ -1,551 +0,0 @@
-use ratatui::style::Color;
-
-static COLOR_RX: Color = Color::Rgb(255, 233, 193);
-static COLOR_TX: Color = Color::Rgb(205, 140, 140);
-
-/// The macro accepts a list of struct names with key names
-/// Returns a struct where every key name is an Option, with the correct derived attributes
-macro_rules! optional_config_struct {
- ($($struct_name:ident, $($key_name:ident),*);*) => {
- $(
- #[derive(Debug, serde::Deserialize, Clone, PartialEq, Eq)]
- struct $struct_name {
- $(
- $key_name: Option,
- )*
- }
- )*
- };
-}
-
-/// The macro accepts a list of struct names with key names
-macro_rules! config_struct {
- ($($struct_name:ident, $($key_name:ident),*);*) => {
- $(
- #[derive(Debug, Clone, PartialEq, Eq, Copy)]
- pub struct $struct_name {
- $(
- pub $key_name: Color,
- )*
- }
- )*
- };
-}
-
-impl AppColors {
- fn map_color(color_str: Option<&str>, setter: &mut Color) {
- color_str.map(|i| i.parse::().map(|i| *setter = i));
- }
-}
-
-impl From