diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0b54f82 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + +jobs: + check-and-test: + name: Check, Test & Build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + + - name: Cargo Format Check + run: cargo fmt --check + + - name: Clippy Linter + run: cargo clippy -- -D warnings + + - name: Run Tests + run: cargo test --verbose + + - name: Build Release + run: cargo build --release --verbose diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0d327ed --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,56 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + build-and-release: + name: Build (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + asset_name: satra-x86_64-linux + use_cross: false + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + asset_name: satra-aarch64-linux + use_cross: true + + steps: + - uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install cross + if: matrix.use_cross + run: cargo install cross --git https://github.com/cross-rs/cross + + - name: Build binary + run: | + if [ "${{ matrix.use_cross }}" = "true" ]; then + cross build --release --target ${{ matrix.target }} + else + cargo build --release --target ${{ matrix.target }} + fi + + - name: Package asset + run: | + cp "target/${{ matrix.target }}/release/satra" "${{ matrix.asset_name }}" + + - name: Upload binary to release + uses: softprops/action-gh-release@v2 + with: + files: ${{ matrix.asset_name }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed5c094 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target +**/*.rs.bk +*.swp +*.bak +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..622642d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,512 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[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 = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "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 = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[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 = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[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 = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "itertools 0.12.1", + "lru", + "paste", + "stability", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "satra" +version = "0.1.0" +dependencies = [ + "crossterm", + "libc", + "ratatui", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[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 = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "stability" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[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.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..093ce38 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "satra" +version = "0.1.0" +edition = "2021" +authors = ["TattvaOrg"] +description = "A lightweight terminal-based process launcher and tracker" +license = "MIT" + +[dependencies] +ratatui = "0.26" +crossterm = "0.27" +libc = "0.2" diff --git a/README.md b/README.md index 389ed2b..d1177af 100644 --- a/README.md +++ b/README.md @@ -1 +1,64 @@ -# Satra \ No newline at end of file +# Satra + +```text +┌──────────────────────────────┐ +│ ★ Satra v0.1.0 │ +├──────────────────────────────┤ +│ [1] ● brave PID:1234 ↑5m │ +│ [2] ● thunar PID:5678 ↑2m │ +│ [3] ● code PID:9012 ↑1m │ +├──────────────────────────────┤ +│ Tab:⇆ ↑↓:nav q:quit │ +├──────────────────────────────┤ +│ > _ │ +└──────────────────────────────┘ +``` + +A lightweight terminal-based process launcher and tracker. + +## Installation + +```sh +curl -sSL https://raw.githubusercontent.com/TattvaOrg/Satra/main/install.sh | bash +``` + +## Features + +- Detached process launching +- Simple TUI with minimal dark theme +- Process status and uptime tracking +- Process management (Kill, Restart, Delete) +- Auto-clears exited processes + +## Usage + +Run `satra` in your terminal: + +```sh +satra +``` + +### Keybindings + +| Key | Mode | Action | +| --- | --- | --- | +| `Tab` | Any | Toggle focus between input bar and session list | +| `Enter` | Input | Launch command as detached background process | +| `↑` / `↓` | List | Navigate through active sessions | +| `Enter` | List | Open Session Detail panel | +| `ESC` | List | Return focus to input bar | +| `k` | Detail Panel | Send SIGKILL to the process | +| `r` | Detail Panel | Restart the process | +| `d` | Detail Panel | Terminate and remove from list | +| `q` / `ESC` | Detail Panel | Close detail panel | +| `Ctrl+C` | Any | Quit Satra (launched processes continue running) | + +> **Note**: Launched processes run detached via `setsid`. Quitting Satra will not terminate your running applications. Exited sessions are kept in the list with exit codes and automatically clean up after 5 minutes. + +## Build from Source + +```sh +git clone https://github.com/TattvaOrg/Satra.git +cd Satra +cargo build --release +``` \ No newline at end of file diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..df610c6 --- /dev/null +++ b/install.sh @@ -0,0 +1,31 @@ +#!/bin/bash +set -e + +echo "Installing Satra..." + +ARCH=$(uname -m) +case $ARCH in + x86_64) + TARGET_ARCH="x86_64" + ;; + aarch64|arm64) + TARGET_ARCH="aarch64" + ;; + *) + echo "Unsupported architecture: $ARCH" + exit 1 + ;; +esac + +BIN_DIR="$HOME/.local/bin" +mkdir -p "$BIN_DIR" + +URL="https://github.com/TattvaOrg/Satra/releases/latest/download/satra-$TARGET_ARCH-linux" + +echo "Downloading from $URL" +curl -sSL "$URL" -o "$BIN_DIR/satra" +chmod +x "$BIN_DIR/satra" + +echo "✅ Installed successfully to $BIN_DIR/satra" +echo "Make sure $BIN_DIR is in your PATH." +echo "Run 'satra' to start the application." diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..f87a193 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,191 @@ +use crate::session::Session; +use crossterm::event::{self, KeyCode, KeyEventKind}; +use std::time::Duration; + +#[derive(Debug, PartialEq, Eq)] +pub enum Focus { + Input, + List, +} + +pub struct App { + pub input: String, + pub focus: Focus, + pub sessions: Vec, + pub selected_index: usize, + pub show_detail: bool, + pub should_quit: bool, + next_id: usize, +} + +impl App { + pub fn new() -> Self { + Self { + input: String::new(), + focus: Focus::Input, + sessions: Vec::new(), + selected_index: 0, + show_detail: false, + should_quit: false, + next_id: 1, + } + } + + pub fn tick(&mut self) { + let mut to_remove = Vec::new(); + for (i, session) in self.sessions.iter_mut().enumerate() { + session.update_status(); + if let Some(exited_at) = session.exited_at { + if exited_at.elapsed() > Duration::from_secs(5 * 60) { + to_remove.push(i); + } + } + } + + for i in to_remove.into_iter().rev() { + self.sessions.remove(i); + } + + if self.selected_index >= self.sessions.len() && !self.sessions.is_empty() { + self.selected_index = self.sessions.len() - 1; + } + } + + pub fn handle_key(&mut self, key: event::KeyEvent) { + if key.kind != KeyEventKind::Press { + return; + } + + if key.code == KeyCode::Char('c') && key.modifiers.contains(event::KeyModifiers::CONTROL) { + self.should_quit = true; + return; + } + + if self.show_detail { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => self.show_detail = false, + KeyCode::Char('k') => { + if let Some(session) = self.sessions.get_mut(self.selected_index) { + let _ = session.kill(); + } + } + KeyCode::Char('r') => { + if let Some(session) = self.sessions.get(self.selected_index) { + if let Ok(new_session) = Session::new(self.next_id, &session.command) { + self.next_id += 1; + self.sessions.push(new_session); + } + } + self.show_detail = false; + } + KeyCode::Char('d') => { + if self.selected_index < self.sessions.len() { + let mut session = self.sessions.remove(self.selected_index); + let _ = session.kill(); + if self.selected_index >= self.sessions.len() && !self.sessions.is_empty() { + self.selected_index = self.sessions.len() - 1; + } + } + self.show_detail = false; + } + _ => {} + } + return; + } + + match key.code { + KeyCode::Tab => { + self.focus = match self.focus { + Focus::Input => Focus::List, + Focus::List => Focus::Input, + }; + } + KeyCode::Esc => { + if matches!(self.focus, Focus::List) { + self.focus = Focus::Input; + } + } + KeyCode::Down if matches!(self.focus, Focus::List) => { + if !self.sessions.is_empty() && self.selected_index < self.sessions.len() - 1 { + self.selected_index += 1; + } + } + KeyCode::Up if matches!(self.focus, Focus::List) => { + if self.selected_index > 0 { + self.selected_index -= 1; + } + } + KeyCode::Enter => match self.focus { + Focus::Input => { + if !self.input.is_empty() { + if let Ok(session) = Session::new(self.next_id, &self.input) { + self.sessions.push(session); + self.next_id += 1; + self.input.clear(); + } + } + } + Focus::List => { + if !self.sessions.is_empty() { + self.show_detail = true; + } + } + }, + KeyCode::Char(c) if matches!(self.focus, Focus::Input) => { + self.input.push(c); + } + KeyCode::Backspace if matches!(self.focus, Focus::Input) => { + self.input.pop(); + } + _ => {} + } + } +} + +impl Default for App { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyEvent, KeyModifiers}; + + #[test] + fn test_app_init() { + let app = App::new(); + assert_eq!(app.selected_index, 0); + assert_eq!(app.focus, Focus::Input); + assert!(!app.show_detail); + assert!(!app.should_quit); + } + + #[test] + fn test_focus_toggle() { + let mut app = App::new(); + let tab_key = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); + app.handle_key(tab_key); + assert_eq!(app.focus, Focus::List); + app.handle_key(tab_key); + assert_eq!(app.focus, Focus::Input); + } + + #[test] + fn test_esc_returns_to_input() { + let mut app = App::new(); + app.focus = Focus::List; + let esc_key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); + app.handle_key(esc_key); + assert_eq!(app.focus, Focus::Input); + } + + #[test] + fn test_ctrl_c_quits() { + let mut app = App::new(); + let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + app.handle_key(ctrl_c); + assert!(app.should_quit); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..9966ecb --- /dev/null +++ b/src/main.rs @@ -0,0 +1,62 @@ +mod app; +mod session; +mod ui; + +use app::App; +use crossterm::{ + event::{self, Event}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; +use std::{io, time::Duration}; + +fn main() -> Result<(), io::Error> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let mut app = App::new(); + let tick_rate = Duration::from_millis(250); + + let res = run_app(&mut terminal, &mut app, tick_rate); + + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + if let Err(err) = res { + println!("{:?}", err); + } + + Ok(()) +} + +fn run_app( + terminal: &mut Terminal, + app: &mut App, + tick_rate: Duration, +) -> io::Result<()> { + let mut last_tick = std::time::Instant::now(); + loop { + terminal.draw(|f| ui::draw(f, app))?; + + let timeout = tick_rate.saturating_sub(last_tick.elapsed()); + if event::poll(timeout)? { + if let Event::Key(key) = event::read()? { + app.handle_key(key); + } + } + + if last_tick.elapsed() >= tick_rate { + app.tick(); + last_tick = std::time::Instant::now(); + } + + if app.should_quit { + return Ok(()); + } + } +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..6e8b49e --- /dev/null +++ b/src/session.rs @@ -0,0 +1,99 @@ +use std::os::unix::process::CommandExt; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +pub enum Status { + Running, + Exited(Option), +} + +pub struct Session { + pub id: usize, + pub command: String, + pub pid: u32, + pub child: Child, + pub status: Status, + pub started_at: Instant, + pub exited_at: Option, +} + +impl Session { + pub fn new(id: usize, cmd_str: &str) -> Result { + let parts: Vec<&str> = cmd_str.split_whitespace().collect(); + if parts.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Empty command", + )); + } + let program = parts[0]; + let args = &parts[1..]; + + let mut cmd = Command::new(program); + cmd.args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + unsafe { + cmd.pre_exec(|| { + libc::setsid(); + Ok(()) + }); + } + let child = cmd.spawn()?; + let pid = child.id(); + + Ok(Self { + id, + command: cmd_str.to_string(), + pid, + child, + status: Status::Running, + started_at: Instant::now(), + exited_at: None, + }) + } + + pub fn update_status(&mut self) { + if matches!(self.status, Status::Running) { + match self.child.try_wait() { + Ok(Some(status)) => { + self.status = Status::Exited(status.code()); + self.exited_at = Some(Instant::now()); + } + Ok(None) => {} // Still running + Err(_) => {} // Error waiting + } + } + } + + pub fn kill(&mut self) -> Result<(), std::io::Error> { + self.child.kill() + } + + pub fn uptime(&self) -> Duration { + if let Some(exited) = self.exited_at { + exited.duration_since(self.started_at) + } else { + self.started_at.elapsed() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_command_fails() { + let res = Session::new(1, ""); + assert!(res.is_err()); + } + + #[test] + fn test_whitespace_command_fails() { + let res = Session::new(1, " "); + assert!(res.is_err()); + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..8aca0e7 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,194 @@ +use crate::app::{App, Focus}; +use crate::session::Status; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}, + Frame, +}; + +pub fn draw(f: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(5), // List + Constraint::Length(3), // Hints + Constraint::Length(3), // Input + ]) + .split(f.size()); + + // Header + let header = Paragraph::new("★ Satra v0.1.0") + .style( + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ); + f.render_widget(header, chunks[0]); + + // Session List + let items: Vec = app + .sessions + .iter() + .enumerate() + .map(|(i, s)| { + let status_color = match s.status { + Status::Running => Color::Green, + Status::Exited(_) => Color::Red, + }; + let status_dot = Span::styled("●", Style::default().fg(status_color)); + let pid = s.pid; + let uptime = s.uptime().as_secs(); + let mins = uptime / 60; + let secs = uptime % 60; + + let exit_info = match s.status { + Status::Exited(Some(code)) => format!(" [exit:{}]", code), + Status::Exited(None) => " [killed]".to_string(), + Status::Running => "".to_string(), + }; + + let content = format!( + " [{}] {} PID:{} ↑{}m{}s{}", + s.id, s.command, pid, mins, secs, exit_info + ); + let line = vec![status_dot, Span::raw(content)]; + + if matches!(app.focus, Focus::List) && i == app.selected_index { + ListItem::new(Line::from(line)).style(Style::default().bg(Color::DarkGray)) + } else { + ListItem::new(Line::from(line)) + } + }) + .collect(); + + let list_border_color = if matches!(app.focus, Focus::List) { + Color::Cyan + } else { + Color::DarkGray + }; + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(list_border_color)) + .title(" Sessions "), + ); + + f.render_widget(list, chunks[1]); + + // Hints + let hints = Paragraph::new("Tab:⇆ ↑↓:nav q:quit(detail) Enter:action/detail") + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ); + f.render_widget(hints, chunks[2]); + + // Input + let input_border_color = if matches!(app.focus, Focus::Input) { + Color::Cyan + } else { + Color::DarkGray + }; + let input_text = format!("> {}", app.input); + let input = Paragraph::new(input_text).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(input_border_color)), + ); + f.render_widget(input, chunks[3]); + + // Show cursor in input when focused + if matches!(app.focus, Focus::Input) { + f.set_cursor( + chunks[3].x + app.input.len() as u16 + 3, // +3 for border + "> " + chunks[3].y + 1, + ); + } + + if app.show_detail { + draw_detail_popup(f, app); + } +} + +fn draw_detail_popup(f: &mut Frame, app: &App) { + if let Some(session) = app.sessions.get(app.selected_index) { + let area = centered_rect(60, 40, f.size()); + f.render_widget(Clear, area); + + let status_str = match session.status { + Status::Running => "Running".to_string(), + Status::Exited(Some(c)) => format!("Exited ({})", c), + Status::Exited(None) => "Terminated".to_string(), + }; + + let text = vec![ + Line::from(vec![ + Span::styled("Command: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(&session.command), + ]), + Line::from(vec![ + Span::styled("PID: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(session.pid.to_string()), + ]), + Line::from(vec![ + Span::styled("Status: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(status_str), + ]), + Line::from(vec![ + Span::styled("Uptime: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!( + "{}m {}s", + session.uptime().as_secs() / 60, + session.uptime().as_secs() % 60 + )), + ]), + Line::from(""), + Line::from("Actions:"), + Line::from(" [k] Kill process"), + Line::from(" [r] Restart process"), + Line::from(" [d] Delete from list"), + Line::from(" [q] Close panel"), + ]; + + let p = Paragraph::new(text) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Session Detail ") + .border_style(Style::default().fg(Color::Cyan)), + ) + .wrap(Wrap { trim: true }); + + f.render_widget(p, area); + } +} + +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +}