Full Code of kabouzeid/turm for AI

main 80ce7cf9bd9a cached
17 files
92.9 KB
22.9k tokens
75 symbols
1 requests
Download .txt
Repository: kabouzeid/turm
Branch: main
Commit: 80ce7cf9bd9a
Files: 17
Total size: 92.9 KB

Directory structure:
gitextract_6kiytyky/

├── .github/
│   └── workflows/
│       ├── release.yml
│       └── test.yml
├── .gitignore
├── .vscode/
│   └── launch.json
├── CHANGELOG.md
├── Cargo.toml
├── LICENSE
├── README.md
├── scripts/
│   └── mock-slurm/
│       ├── bin/
│       │   ├── scancel
│       │   ├── scontrol
│       │   └── squeue
│       └── logs/
│           └── .gitkeep
└── src/
    ├── app.rs
    ├── file_watcher.rs
    ├── job_watcher.rs
    ├── main.rs
    └── squeue_args.rs

================================================
FILE CONTENTS
================================================

================================================
FILE: .github/workflows/release.yml
================================================
name: Release
on:
  push:
    branches: [main]
env:
  CARGO_INCREMENTAL: 0
  CARGO_NET_RETRY: 10
  CARGO_TERM_COLOR: always
  RUST_BACKTRACE: short
  RUSTUP_MAX_RETRIES: 10

jobs:
  # Update release PR
  release_please:
    name: Release Please
    runs-on: ubuntu-latest
    outputs:
      release_created: ${{ steps.release.outputs.release_created }}
      tag_name: ${{ steps.release.outputs.tag_name }}
    permissions:
      contents: write
      pull-requests: write
    steps:
      - uses: googleapis/release-please-action@v4
        id: release
        with:
          release-type: rust

  # Build sources for every OS
  github_build:
    name: Build release binaries
    needs: release_please
    if: ${{ needs.release_please.outputs.release_created == 'true' }}
    strategy:
      matrix:
        include:
          - target: x86_64-unknown-linux-gnu
            os: ubuntu-latest
            name: turm-x86_64-unknown-linux-gnu.tar.gz

          - target: x86_64-unknown-linux-musl
            os: ubuntu-latest
            name: turm-x86_64-unknown-linux-musl.tar.gz

          - target: i686-unknown-linux-musl
            os: ubuntu-latest
            name: turm-i686-unknown-linux-musl.tar.gz

          - target: aarch64-unknown-linux-gnu
            os: ubuntu-latest
            name: turm-aarch64-unknown-linux-gnu.tar.gz

          - target: aarch64-unknown-linux-musl
            os: ubuntu-latest
            name: turm-aarch64-unknown-linux-musl.tar.gz

          - target: arm-unknown-linux-musleabihf
            os: ubuntu-latest
            name: turm-arm-unknown-linux-musleabihf.tar.gz

    runs-on: ${{ matrix.os }}
    steps:
      - name: Setup | Checkout
        uses: actions/checkout@v5

      - name: Setup | Rust
        uses: actions-rs/toolchain@v1
        with:
          toolchain: stable
          override: true
          profile: minimal
          target: ${{ matrix.target }}

      - name: Build | Build
        uses: actions-rs/cargo@v1
        with:
          command: build
          args: --release --locked --target ${{ matrix.target }}
          use-cross: ${{ matrix.os == 'ubuntu-latest' }}

      - name: Post Build | Prepare artifacts
        run: |
          cd target/${{ matrix.target }}/release
          tar czvf ../../../${{ matrix.name }} turm
          cd -

      - name: Release | Upload artifacts
        uses: actions/upload-artifact@v4
        with:
          name: ${{ matrix.name }}
          path: ${{ matrix.name }}

  # Create GitHub release with Rust build targets and release notes
  upload_artifacts:
    name: Add Build Artifacts to Release
    needs: [release_please, github_build]
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: Setup | Artifacts
        uses: actions/download-artifact@v4

      - name: Setup | Checksums
        run: for file in turm-*/turm-*; do openssl dgst -sha256 -r "$file" | awk '{print $1}' > "${file}.sha256"; done

      - name: Build | Add Artifacts to Release
        uses: softprops/action-gh-release@v2
        with:
          files: turm-*/turm-*
          tag_name: ${{ needs.release_please.outputs.tag_name }}

  maturin_linux:
    name: Build Maturin Linux
    needs: release_please
    if: ${{ needs.release_please.outputs.release_created == 'true' }}
    runs-on: ${{ matrix.platform.runner }}
    strategy:
      matrix:
        platform:
          - runner: ubuntu-latest
            target: x86_64
          - runner: ubuntu-latest
            target: x86
          - runner: ubuntu-latest
            target: aarch64
          - runner: ubuntu-latest
            target: armv7
          - runner: ubuntu-latest
            target: s390x
          - runner: ubuntu-latest
            target: ppc64le
    steps:
      - uses: actions/checkout@v5
      - name: Build wheels
        uses: PyO3/maturin-action@v1
        with:
          target: ${{ matrix.platform.target }}
          args: --release --out dist
          sccache: 'true'
          manylinux: auto
      - name: Upload wheels
        uses: actions/upload-artifact@v4
        with:
          name: wheels-linux-${{ matrix.platform.target }}
          path: dist

  maturin_musllinux:
    name: Build Maturin Linux musl
    needs: release_please
    if: ${{ needs.release_please.outputs.release_created == 'true' }}
    runs-on: ${{ matrix.platform.runner }}
    strategy:
      matrix:
        platform:
          - runner: ubuntu-latest
            target: x86_64
          - runner: ubuntu-latest
            target: x86
          - runner: ubuntu-latest
            target: aarch64
          - runner: ubuntu-latest
            target: armv7
    steps:
      - uses: actions/checkout@v5
      - name: Build wheels
        uses: PyO3/maturin-action@v1
        with:
          target: ${{ matrix.platform.target }}
          args: --release --out dist
          sccache: 'true'
          manylinux: musllinux_1_2
      - name: Upload wheels
        uses: actions/upload-artifact@v4
        with:
          name: wheels-musllinux-${{ matrix.platform.target }}
          path: dist
          
  maturin_release:
    name: Maturin | Release
    runs-on: ubuntu-latest
    needs: [upload_artifacts, maturin_linux, maturin_musllinux] # only publish if everything else worked
    steps:
      - uses: actions/download-artifact@v4
      - name: Publish to PyPI
        uses: PyO3/maturin-action@v1
        env:
          MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
        with:
          command: upload
          args: --non-interactive --skip-existing wheels-*/*
          
  cargo_publish:
    name: Publish to crates.io
    needs: [upload_artifacts, maturin_release] # only publish if everything else worked
    runs-on: ubuntu-latest
    env:
      CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
    steps:
    - uses: actions/checkout@v5
    - name: Publish
      run: cargo publish


================================================
FILE: .github/workflows/test.yml
================================================
name: Test

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

env:
  CARGO_TERM_COLOR: always

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v5
    - name: Build
      run: cargo build --verbose
    - name: Run tests
      run: cargo test --verbose


================================================
FILE: .gitignore
================================================
/target
scripts/mock-slurm/logs/*
!scripts/mock-slurm/logs/.gitkeep


================================================
FILE: .vscode/launch.json
================================================
{
    // 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 'turm'",
            "cargo": {
                "args": [
                    "build",
                    "--bin=turm",
                    "--package=turm"
                ],
                "filter": {
                    "name": "turm",
                    "kind": "bin"
                }
            },
            "args": [],
            "cwd": "${workspaceFolder}",
            "env": {
                "RUST_BACKTRACE": "1"
            }
        },
        {
            "type": "lldb",
            "request": "launch",
            "name": "Debug unit tests in executable 'turm'",
            "cargo": {
                "args": [
                    "test",
                    "--no-run",
                    "--bin=turm",
                    "--package=turm"
                ],
                "filter": {
                    "name": "turm",
                    "kind": "bin"
                }
            },
            "args": [],
            "cwd": "${workspaceFolder}",
            "env": {
                "RUST_BACKTRACE": "1"
            }
        }
    ]
}

================================================
FILE: CHANGELOG.md
================================================
# Changelog

## [0.14.0](https://github.com/karimknaebel/turm/compare/v0.13.1...v0.14.0) (2026-03-07)


### Features

* add edit time limit dialog ([e376f89](https://github.com/karimknaebel/turm/commit/e376f89014a47d8362a4a6ad15a38cecaaa029ef))
* add signal picker for cancel action ([9562037](https://github.com/karimknaebel/turm/commit/9562037b72749a4c753de41d6d4dc0c1e49f52d9))
* change signal list style ([cd7fa24](https://github.com/karimknaebel/turm/commit/cd7fa241752a02016648dad1c58daf854cdd930f))
* show command errors in dialog (scancel, scontrol, ...) ([de4aed5](https://github.com/karimknaebel/turm/commit/de4aed57ddc0042faea4d9ecdddc8a9bc050e45c))

## [0.13.1](https://github.com/karimknaebel/turm/compare/v0.13.0...v0.13.1) (2026-03-03)


### Bug Fixes

* use bracketed paste mode to prevent accidental inputs ([19967cb](https://github.com/karimknaebel/turm/commit/19967cb2fcfedf8197ed94f86f815db1557818b5))

## [0.13.0](https://github.com/karimknaebel/turm/compare/v0.12.0...v0.13.0) (2026-02-14)


### Features

* add mouse support ([85bb9eb](https://github.com/karimknaebel/turm/commit/85bb9eb710a98281631a4e67ba11bd1f89e3abbd))

## [0.12.0](https://github.com/karimknaebel/turm/compare/v0.11.0...v0.12.0) (2026-02-10)


### Features

* add job name to detail window ([#42](https://github.com/karimknaebel/turm/issues/42)) ([0e40ab4](https://github.com/karimknaebel/turm/commit/0e40ab486bea2630f1717cc31e6380446ff0b87f))

## [0.11.0](https://github.com/karimknaebel/turm/compare/v0.10.0...v0.11.0) (2025-12-23)


### Features

* add g and G bindings to jump to first/last job ([2b92a8e](https://github.com/karimknaebel/turm/commit/2b92a8edb426dbec2c52e26127d8516a2e116dd2))
* scroll job list with ctrl+d/u ([3fe52ea](https://github.com/karimknaebel/turm/commit/3fe52eacc9b8745456e382281b1c58c1d21792e2))


### Bug Fixes

* clean up jobs refresh ([cc7b29b](https://github.com/karimknaebel/turm/commit/cc7b29b2f17c13b182c2171911da756c5bfc549b))
* clippy ([8cc6246](https://github.com/karimknaebel/turm/commit/8cc624612948bc0e57a5a14d73ac0f78c6040bdf))
* clippy ([6644393](https://github.com/karimknaebel/turm/commit/6644393ae45c50438e6315e6efd5676ad0680786))
* format ([bb28b2b](https://github.com/karimknaebel/turm/commit/bb28b2be8df5e53803a16847c1ce9c59f4458399))
* job list height ([9fdd3d7](https://github.com/karimknaebel/turm/commit/9fdd3d736d5ef65a72bf33da0103e952303dd574))
* preserve job selection across refresh ([e54ac01](https://github.com/karimknaebel/turm/commit/e54ac016bd844b545d53bccf1480a38214a690ed))
* remove unnecessary job_list_state modification in ui func ([50a780c](https://github.com/karimknaebel/turm/commit/50a780c967b41459dcb81702a2c28c0065e4d44d))
* reset terminal on panic ([bc1de57](https://github.com/karimknaebel/turm/commit/bc1de573353ab9cc19d123da1565b79e747da1c3)), closes [#52](https://github.com/karimknaebel/turm/issues/52)
* revert calling scancel inside of thread ([741198f](https://github.com/karimknaebel/turm/commit/741198f5a10724b74e7f094743ae4d7a4ee1abed))
* wait for scancel to finish ([8b11875](https://github.com/karimknaebel/turm/commit/8b1187543997c9b03e88a92d6183a0eb7f3b2edd))

## [0.10.0](https://github.com/karimknaebel/turm/compare/v0.9.0...v0.10.0) (2025-12-12)


### Features

* rounded corners ([a689259](https://github.com/karimknaebel/turm/commit/a6892592723c50d2d7ce48b9379b497a573c3a68))

## [0.9.0](https://github.com/karimknaebel/turm/compare/v0.8.0...v0.9.0) (2025-08-26)


### Features

* auto-select first job on start ([c39613c](https://github.com/karimknaebel/turm/commit/c39613c32ca18625807a56081655f14533d63d32))
* show estimated start time for pending jobs ([6ce5f01](https://github.com/karimknaebel/turm/commit/6ce5f01730d400f1a9be0d97dd17100728ce72d0))


### Bug Fixes

* cargo warning ([8979926](https://github.com/karimknaebel/turm/commit/8979926149d07b7e78e2839d767679efb4d52c2b))

## [0.8.0](https://github.com/karimknaebel/turm/compare/v0.7.3...v0.8.0) (2025-08-24)


### Features

* auto-refresh non-existing file paths until they are created ([3a3acc6](https://github.com/karimknaebel/turm/commit/3a3acc6480faf58dc93de31d031bd2a90db117e8))

## [0.7.3](https://github.com/karimknaebel/turm/compare/v0.7.2...v0.7.3) (2024-07-28)


### Miscellaneous Chores

* release 0.7.3 ([ae8665b](https://github.com/karimknaebel/turm/commit/ae8665b25d68842dc1100f85aee643bc122ef52f))

## [0.7.2](https://github.com/karimknaebel/turm/compare/v0.7.1...v0.7.2) (2024-07-28)


### Bug Fixes

* crash on resize ([96f4f16](https://github.com/karimknaebel/turm/commit/96f4f1683ee98547dadc610cf21f293858ba9d50))

## [0.7.1](https://github.com/karimknaebel/turm/compare/v0.6.0...v0.7.1) (2024-07-28)


### Features

* pretty text wrapping ([51dc964](https://github.com/karimknaebel/turm/commit/51dc9645f506b89a0444db64cab6ddc0d2ecdaf0))
* toggle log text wrapping. update deps ([5243a36](https://github.com/karimknaebel/turm/commit/5243a368c173070c58ce8a51bce56be9f916ec21))
* truncated line indicator ([f347664](https://github.com/karimknaebel/turm/commit/f347664ecd94db785140eac296e37d66e203a81b))


### Bug Fixes

* correctly resolve relative log file paths ([0ecc902](https://github.com/karimknaebel/turm/commit/0ecc902f036244ed67d29eb686dcbf2c413ec51c))
* crash on resize ([6dc3b1d](https://github.com/karimknaebel/turm/commit/6dc3b1d9f387d3b2accdc34b7e8a0c42995424c9))


### Miscellaneous Chores

* release 0.7.1 ([499a3a6](https://github.com/karimknaebel/turm/commit/499a3a69059adab68444d552acc4838962db4e0b))

## [0.6.0](https://github.com/karimknaebel/turm/compare/v0.5.0...v0.6.0) (2023-09-23)


### Features

* toggle stdout/stderr ([bcd773b](https://github.com/karimknaebel/turm/commit/bcd773bd21ccb64860e651e2da881d57253fecb8))

## [0.5.0](https://github.com/karimknaebel/turm/compare/v0.4.0...v0.5.0) (2023-09-15)


### Features

* show job count ([c169e18](https://github.com/karimknaebel/turm/commit/c169e1844574885246736dbde920ae0f77b121b2))

## [0.4.0](https://github.com/karimknaebel/turm/compare/v0.3.0...v0.4.0) (2023-04-23)


### Features

* faster fast scrolling (shift/control/alt) ([37e205a](https://github.com/karimknaebel/turm/commit/37e205aaf819e99e13aea70327de84289cba0482))
* scroll to top/bottom ([0022a70](https://github.com/karimknaebel/turm/commit/0022a70a58d6a0f2b1e159f0b5afef99ae6ea2c1))

## [0.3.0](https://github.com/karimknaebel/turm/compare/v0.2.0...v0.3.0) (2023-04-17)


### Features

* add shell completions ([e9b8de0](https://github.com/karimknaebel/turm/commit/e9b8de0018b3dd91d72db6e3c164aa18a1fe17d9))
* proper cli with help ([90988f6](https://github.com/karimknaebel/turm/commit/90988f65208b353204acd6a570be45e753bfcdfc))

## [0.2.0](https://github.com/karimknaebel/turm/compare/v0.1.0...v0.2.0) (2023-04-15)


### Features

* cancel jobs ([bc05723](https://github.com/karimknaebel/turm/commit/bc057230244ce215a585dbb318de762913524a5b))
* select first job on launch ([7c742fd](https://github.com/karimknaebel/turm/commit/7c742fdd3b66787b10df6a017de6c7522c8f9858))


### Bug Fixes

* clear the log on empty selection ([518afdb](https://github.com/karimknaebel/turm/commit/518afdbf67ada9ea1d7b2597765630cba8a00ee4))
* correctly display job ids in arrays ([bc05723](https://github.com/karimknaebel/turm/commit/bc057230244ce215a585dbb318de762913524a5b))

## 0.1.0 (2023-03-31)


### Features

* accept same cli args as `squeue` ([1f1a5ac](https://github.com/karimknaebel/turm/commit/1f1a5ac8f0b92b435b09e09981c95cbb00290a20))
* add cargo metadata ([78487bb](https://github.com/karimknaebel/turm/commit/78487bbe93c8c1efaef8b218e72c68a4dbe3c67a))
* better error handling ([ad47d19](https://github.com/karimknaebel/turm/commit/ad47d19ad6abccb80bc7d5c9ac3faf44ca03a92a))
* better layout ([67e24e0](https://github.com/karimknaebel/turm/commit/67e24e078df0eed492123e498282942400cbbcf9))
* config interval file ([7e6678d](https://github.com/karimknaebel/turm/commit/7e6678d834ce5535dfe2ede8e88974ccbf36c453))
* fast scroll ([8df9158](https://github.com/karimknaebel/turm/commit/8df91589f8ef6c3cd403faecfc40142fd238d0a4))
* faster log file loading ([9f954cc](https://github.com/karimknaebel/turm/commit/9f954ccff53fc7ffdb4412d1a490ef012bf4cc95))
* faster log loading ([b4b0fa4](https://github.com/karimknaebel/turm/commit/b4b0fa4df97d51976f2cadffd527a07fd3804346))
* help bar ([ab63a9e](https://github.com/karimknaebel/turm/commit/ab63a9e2cd9b2ea05a8d45789b8dfb04d580c932))
* partial reads (like tail -f) ([86f04af](https://github.com/karimknaebel/turm/commit/86f04af1bf78783c37c4cecbef4d3292280f4f5e))
* prettier ([c70de5e](https://github.com/karimknaebel/turm/commit/c70de5ea4f412531c203bb308ee769e6cc861828))
* scroll to bottom with end ([01423f1](https://github.com/karimknaebel/turm/commit/01423f1a8c5da16f97dc01efd4e73cbb96d8c810))
* show job details ([904ff7c](https://github.com/karimknaebel/turm/commit/904ff7cef52e8971f7c6146ec217065988001336))
* show state and reason in details panel ([a77d4a3](https://github.com/karimknaebel/turm/commit/a77d4a3ff7d823f89ea33921dee28aa9ff7b6a3f))
* show state in list ([823a0a2](https://github.com/karimknaebel/turm/commit/823a0a263bc33b7a1e77d92601820059dfc22a14))


### Bug Fixes

* error on shutdown ([ff516ca](https://github.com/karimknaebel/turm/commit/ff516cac734fcd06a443122aca408d228046484a))
* hide incomplete lines in log files ([28eb452](https://github.com/karimknaebel/turm/commit/28eb452f9b4e8900d74be491368787bbe2197fc1))
* log title ([d42f79a](https://github.com/karimknaebel/turm/commit/d42f79ae7dcfec4d33d29fdcc48e1e986d1ea8b9))
* warnings ([ffd1211](https://github.com/karimknaebel/turm/commit/ffd1211228490960186d9cf8dc1d773a38558b16))


================================================
FILE: Cargo.toml
================================================
[package]
name = "turm"
version = "0.14.0"
authors = ["Karim Knaebel <contact@knaebel.dev>"]
description = "A TUI for the Slurm Workload Manager."
repository = "https://github.com/karimknaebel/turm"
license = "MIT"
edition = "2024"
rust-version = "1.87"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
clap = { version = "4.5.40", features = ["derive"] }
clap_complete = "4.5.54"
crossbeam = "0.8.4"
crossterm = "0.29.0"
itertools = "0.14.0"
lazy_static = "1.5.0"
notify = "8.0.0"
ratatui = "0.30.0"
regex = "1.11.1"
tui-input = { version = "0.14.0", default-features = false, features = ["crossterm"] }


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2026 Karim Knaebel

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
# turm

[![image](https://img.shields.io/pypi/v/turm.svg)](https://pypi.python.org/pypi/turm)
[![image](https://img.shields.io/crates/v/turm.svg)](https://crates.io/crates/turm)
[![Conda Version](https://img.shields.io/conda/vn/conda-forge/turm.svg)](https://anaconda.org/conda-forge/turm)

A TUI for [Slurm](https://slurm.schedmd.com/), which provides a convenient way to manage your cluster jobs.

<img alt="turm demo" src="https://github.com/user-attachments/assets/7daade50-def3-4bf8-bf12-df311438094e" width="100%" />

`turm` accepts the same options as `squeue` (see [man squeue](https://slurm.schedmd.com/squeue.html#SECTION_OPTIONS)). Use `turm --help` to get a list of all available options. For example, to show only your own jobs, sorted by descending job ID, including all job states (i.e., including completed and failed jobs):
```shell
turm --me --sort=-id --states=ALL
```

## Installation

`turm` is available on [PyPI](https://pypi.org/project/turm/), [crates.io](https://crates.io/crates/turm), and [conda-forge](https://github.com/conda-forge/turm-feedstock):

```shell
# With uv.
uv tool install turm

# With pip.
pip install turm

# With cargo.
cargo install turm

# With pixi.
pixi global install turm

# With conda.
conda install --channel conda-forge turm

# With wget. Make sure ~/.local/bin is in your $PATH.
wget https://github.com/karimknaebel/turm/releases/latest/download/turm-x86_64-unknown-linux-musl.tar.gz -O - | tar -xz -C ~/.local/bin/
```

The [release page](https://github.com/karimknaebel/turm/releases) also contains precompiled binaries for Linux.

### Shell Completion (optional)

#### Bash

In your `.bashrc`, add the following line:
```bash
eval "$(turm completion bash)"
```

#### Zsh

In your `.zshrc`, add the following line:
```zsh
eval "$(turm completion zsh)"
```

#### Fish

In your `config.fish` or in a separate `completions/turm.fish` file, add the following line:
```fish
turm completion fish | source
```

## How it works

`turm` obtains information about jobs by parsing the output of `squeue`.
The reason for this is that `squeue` is available on all Slurm clusters, and running it periodically is not too expensive for the Slurm controller ( particularly when [filtering by user](https://slurm.schedmd.com/squeue.html#OPT_user)).
In contrast, Slurm's C API is unstable, and Slurm's REST API is not always available and can be costly for the Slurm controller.
Another advantage is that we get free support for the exact same CLI flags as `squeue`, which users are already familiar with, for filtering and sorting the jobs.

### Resource usage

TL;DR: `turm` ≈ `watch -n2 squeue` + `tail -f slurm-log.out`

Special care has been taken to ensure that `turm` is as lightweight as possible in terms of its impact on the Slurm controller and its file I/O operations.
The job queue is updated every two seconds by running `squeue`.
When there are many jobs in the queue, it is advisable to specify a single user to reduce the load on the Slurm controller (see [squeue --user](https://slurm.schedmd.com/squeue.html#OPT_user)).
`turm` updates the currently displayed log file on every inotify modify notification, and it only reads the newly appended lines after the initial read.
However, since inotify notifications are not supported for remote file systems, such as NFS, `turm` also polls the file for newly appended bytes every two seconds.

## Development without Slurm

For local UI testing, this repository includes mocks for `squeue`, `scancel`, and `scontrol`:

```shell
PATH=scripts/mock-slurm/bin:$PATH cargo run -- --me
```

The mock commands read/write files in `scripts/mock-slurm/logs`, so you can test log rendering and control actions without a Slurm install.

## Star History

[![Star History Chart](https://api.star-history.com/svg?repos=karimknaebel/turm&type=Date)](https://www.star-history.com/#karimknaebel/turm&Date)


================================================
FILE: scripts/mock-slurm/bin/scancel
================================================
#!/usr/bin/env bash
set -euo pipefail

script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
root_dir="$(cd -- "$script_dir/../../.." && pwd)"
log_dir="$root_dir/scripts/mock-slurm/logs"
log_file="$log_dir/scancel.log"

mkdir -p "$log_dir"

timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
job_id="unknown"
signal=""

while (($#)); do
    case "$1" in
    --signal|-s)
        signal="${2:-}"
        shift 2
        ;;
    --signal=*)
        signal="${1#*=}"
        shift
        ;;
    --*)
        shift
        ;;
    *)
        job_id="$1"
        shift
        ;;
    esac
done

if [[ -n "$signal" ]]; then
    printf '[%s] canceled %s with signal %s\n' "$timestamp" "$job_id" "$signal" >>"$log_file"
else
    printf '[%s] canceled %s\n' "$timestamp" "$job_id" >>"$log_file"
fi


================================================
FILE: scripts/mock-slurm/bin/scontrol
================================================
#!/usr/bin/env bash
set -euo pipefail

script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
root_dir="$(cd -- "$script_dir/../../.." && pwd)"
log_dir="$root_dir/scripts/mock-slurm/logs"
log_file="$log_dir/scontrol.log"

mkdir -p "$log_dir"

timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
job_id=""
time_limit=""

if [[ "${1:-}" == "update" ]]; then
    shift
fi

for arg in "$@"; do
    case "$arg" in
    JobId=*)
        job_id="${arg#JobId=}"
        ;;
    TimeLimit=*)
        time_limit="${arg#TimeLimit=}"
        ;;
    esac
done

if [[ -n "$job_id" && -n "$time_limit" ]]; then
    printf '[%s] updated %s TimeLimit=%s\n' "$timestamp" "$job_id" "$time_limit" >>"$log_file"
    exit 0
fi

printf '[%s] invalid scontrol call: %s\n' "$timestamp" "$*" >>"$log_file"
exit 1


================================================
FILE: scripts/mock-slurm/bin/squeue
================================================
#!/usr/bin/env bash
set -euo pipefail

script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
root_dir="$(cd -- "$script_dir/../../.." && pwd)"
log_dir="$root_dir/scripts/mock-slurm/logs"

mkdir -p "$log_dir"

stdout_running="$log_dir/job-1001.out"
stderr_running="$log_dir/job-1001.err"
stdout_pending="$log_dir/job-1002.out"
stderr_pending="$log_dir/job-1002.err"

if [ ! -f "$stdout_running" ]; then
    cat >"$stdout_running" <<'EOF'
Starting training loop...
Epoch 1/50 - loss=1.8421
Epoch 2/50 - loss=1.5033
EOF
fi

if [ ! -f "$stderr_running" ]; then
    : >"$stderr_running"
fi

sep='###turm###'

printf '%s\n' \
"1001${sep}train-model${sep}RUNNING${sep}${USER:-mock}${sep}00:12:37${sep}01:00:00${sep}N/A${sep}cpu=4,mem=8G${sep}debug${sep}mac-mini-01${sep}${stdout_running}${sep}${stderr_running}${sep}python train.py --epochs 50${sep}R${sep}None${sep}1001${sep}N/A${sep}mac-mini-01${sep}${root_dir}${sep}" \
"1002${sep}eval-suite${sep}PENDING${sep}${USER:-mock}${sep}0:00${sep}00:30:00${sep}2026-03-04T12:00:00${sep}cpu=2,mem=4G${sep}debug${sep}(null)${sep}${stdout_pending}${sep}${stderr_pending}${sep}python eval.py --dataset validation${sep}PD${sep}Resources${sep}1002${sep}N/A${sep}(null)${sep}${root_dir}${sep}"


================================================
FILE: scripts/mock-slurm/logs/.gitkeep
================================================



================================================
FILE: src/app.rs
================================================
use crossbeam::{
    channel::{Receiver, TryRecvError, unbounded},
    select,
};
use itertools::Either;
use std::{cmp::min, iter::once, path::PathBuf, process::Command, time::Duration};

use crate::file_watcher::{FileWatcherError, FileWatcherHandle};
use crate::job_watcher::JobWatcherHandle;

use crossterm::event::{Event, KeyCode, KeyEvent, MouseButton, MouseEventKind};
use ratatui::{
    Frame, Terminal,
    backend::Backend,
    layout::{Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span, Text},
    widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use std::io;
use tui_input::{Input, backend::crossterm::EventHandler};

pub enum Focus {
    Jobs,
}

pub enum Dialog {
    ConfirmCancelJob(String),
    SelectCancelSignal { id: String, selected_signal: usize },
    EditTimeLimit { id: String, input: Input },
    CommandError { command: String, output: String },
}

struct CommandFailure {
    command: String,
    output: String,
}

#[derive(Clone, Copy)]
pub enum ScrollAnchor {
    Top,
    Bottom,
}

#[derive(Default)]
pub enum OutputFileView {
    #[default]
    Stdout,
    Stderr,
}

pub struct App {
    focus: Focus,
    dialog: Option<Dialog>,
    jobs: Vec<Job>,
    job_list_state: ListState,
    job_output: Result<String, FileWatcherError>,
    job_output_anchor: ScrollAnchor,
    job_output_offset: u16,
    job_output_wrap: bool,
    _job_watcher: JobWatcherHandle,
    job_output_watcher: FileWatcherHandle,
    // sender: Sender<AppMessage>,
    receiver: Receiver<AppMessage>,
    input_receiver: Receiver<std::io::Result<Event>>,
    output_file_view: OutputFileView,
    job_list_height: u16,
    job_list_area: Rect,
    job_output_area: Rect,
    pending_input_event: Option<Event>,
}

pub struct Job {
    pub job_id: String,
    pub array_id: String,
    pub array_step: Option<String>,
    pub name: String,
    pub state: String,
    pub state_compact: String,
    pub reason: Option<String>,
    pub user: String,
    pub time: String,
    pub time_limit: String,
    pub start_time: String,
    pub tres: String,
    pub partition: String,
    pub nodelist: String,
    pub stdout: Option<PathBuf>,
    pub stderr: Option<PathBuf>,
    pub command: String,
}

impl Job {
    fn id(&self) -> String {
        match self.array_step.as_ref() {
            Some(array_step) => format!("{}_{}", self.array_id, array_step),
            None => self.job_id.clone(),
        }
    }
}

pub enum AppMessage {
    Jobs(Vec<Job>),
    JobOutput(Result<String, FileWatcherError>),
    Key(KeyEvent),
    MouseClick(usize),
    MouseWheel {
        target: MouseScrollTarget,
        direction: MouseWheelDirection,
        amount: u16,
    },
}

#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum MouseWheelDirection {
    Up,
    Down,
}

#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum MouseScrollTarget {
    Jobs,
    Output,
}

const SCANCEL_SIGNALS: &[&str] = &["TERM", "INT", "HUP", "USR1", "USR2", "STOP", "CONT", "KILL"];
const DIALOG_WIDTH: u16 = 80;

impl App {
    pub fn new(
        input_receiver: Receiver<std::io::Result<Event>>,
        slurm_refresh_rate: u64,
        file_refresh_rate: u64,
        squeue_args: Vec<String>,
    ) -> App {
        let (sender, receiver) = unbounded();
        Self {
            focus: Focus::Jobs,
            dialog: None,
            jobs: Vec::new(),
            _job_watcher: JobWatcherHandle::new(
                sender.clone(),
                Duration::from_secs(slurm_refresh_rate),
                squeue_args,
            ),
            job_list_state: ListState::default(),
            job_output: Ok("".to_string()),
            job_output_anchor: ScrollAnchor::Bottom,
            job_output_offset: 0,
            job_output_wrap: false,
            job_output_watcher: FileWatcherHandle::new(
                sender.clone(),
                Duration::from_secs(file_refresh_rate),
            ),
            // sender,
            receiver,
            input_receiver,
            output_file_view: OutputFileView::default(),
            job_list_height: 0,
            job_list_area: Rect::default(),
            job_output_area: Rect::default(),
            pending_input_event: None,
        }
    }
}

impl App {
    pub fn run<B: Backend<Error = io::Error>>(
        &mut self,
        terminal: &mut Terminal<B>,
    ) -> io::Result<()> {
        terminal.draw(|f| self.ui(f))?;

        loop {
            let (should_quit, should_draw) = if let Some(event) = self.pending_input_event.take() {
                self.handle_input_event(event)
            } else {
                select! {
                    recv(self.receiver) -> event => {
                        self.handle(event.unwrap());
                        (false, true)
                    }
                    recv(self.input_receiver) -> input_res => {
                        self.handle_input_event(input_res.unwrap().unwrap())
                    }
                }
            };
            if should_quit {
                return Ok(());
            }

            if should_draw {
                terminal.draw(|f| self.ui(f))?;
            }
        }
    }

    fn try_recv_input_event(&mut self) -> Option<Event> {
        if let Some(event) = self.pending_input_event.take() {
            return Some(event);
        }

        loop {
            match self.input_receiver.try_recv() {
                Ok(Ok(event)) => return Some(event),
                Ok(Err(_)) => continue,
                Err(TryRecvError::Empty | TryRecvError::Disconnected) => return None,
            }
        }
    }

    fn handle_input_event(&mut self, event: Event) -> (bool, bool) {
        match event {
            Event::Key(key) => {
                if key.code == KeyCode::Char('q') {
                    return (true, false);
                }
                self.handle(AppMessage::Key(key));
                (false, true)
            }
            Event::Paste(_) => (false, false),
            Event::Mouse(mouse) => match mouse.kind {
                MouseEventKind::Down(MouseButton::Left) => {
                    if self.dialog.is_some() {
                        return (false, false);
                    }
                    if let Some(index) = self.job_index_at(mouse.column, mouse.row) {
                        if self.job_list_state.selected() != Some(index) {
                            self.handle(AppMessage::MouseClick(index));
                            return (false, true);
                        }
                    }
                    (false, false)
                }
                MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
                    if self.dialog.is_some() {
                        return (false, false);
                    }
                    let Some(target) = self.mouse_scroll_target(mouse.column, mouse.row) else {
                        return (false, false);
                    };
                    let direction = mouse_wheel_direction(mouse.kind).unwrap();
                    let mut amount = 1u16;
                    while let Some(next_event) = self.try_recv_input_event() {
                        let should_merge = if let Event::Mouse(next_mouse) = &next_event {
                            mouse_wheel_direction(next_mouse.kind) == Some(direction)
                                && self.mouse_scroll_target(next_mouse.column, next_mouse.row)
                                    == Some(target)
                        } else {
                            false
                        };
                        if should_merge {
                            amount = amount.saturating_add(1);
                        } else {
                            self.pending_input_event = Some(next_event);
                            break;
                        }
                    }
                    self.handle(AppMessage::MouseWheel {
                        target,
                        direction,
                        amount,
                    });
                    (false, true)
                }
                _ => (false, false),
            },
            Event::Resize(_, _) => (false, true),
            _ => (false, false),
        }
    }

    fn mouse_scroll_target(&self, column: u16, row: u16) -> Option<MouseScrollTarget> {
        if rect_contains(self.job_list_area, column, row) {
            Some(MouseScrollTarget::Jobs)
        } else if rect_contains(self.job_output_area, column, row) {
            Some(MouseScrollTarget::Output)
        } else {
            None
        }
    }

    fn handle(&mut self, msg: AppMessage) {
        match msg {
            AppMessage::Jobs(jobs) => {
                // On refresh: keep the same job selected if it still exists
                let old_index = self.job_list_state.selected();
                let old_id = old_index.and_then(|i| self.jobs.get(i)).map(|j| j.id());

                self.jobs = jobs;

                if self.jobs.is_empty() {
                    self.job_list_state.select(None);
                } else if let Some(id) = old_id {
                    let new_index = self
                        .jobs
                        .iter()
                        .position(|j| j.id() == id)
                        .unwrap_or(old_index.unwrap_or(0).min(self.jobs.len() - 1));
                    self.job_list_state.select(Some(new_index));
                } else {
                    self.job_list_state.select_first();
                }
            }
            AppMessage::JobOutput(content) => self.job_output = content,
            AppMessage::Key(key) => {
                if self.dialog.is_some() {
                    let mut close_dialog = false;
                    let mut scancel_request = None;
                    let mut timelimit_request = None;
                    let mut command_failure = None;

                    match self.dialog.as_mut().expect("dialog must exist") {
                        Dialog::ConfirmCancelJob(id) => match key.code {
                            KeyCode::Enter | KeyCode::Char('y') => {
                                scancel_request = Some((id.clone(), None));
                                close_dialog = true;
                            }
                            KeyCode::Esc => {
                                close_dialog = true;
                            }
                            _ => {}
                        },
                        Dialog::SelectCancelSignal {
                            id,
                            selected_signal,
                        } => match key.code {
                            KeyCode::Up | KeyCode::Char('k') => {
                                *selected_signal = selected_signal.saturating_sub(1);
                            }
                            KeyCode::Down | KeyCode::Char('j') => {
                                *selected_signal = min(
                                    selected_signal.saturating_add(1),
                                    SCANCEL_SIGNALS.len().saturating_sub(1),
                                );
                            }
                            KeyCode::Enter => {
                                scancel_request =
                                    Some((id.clone(), Some(SCANCEL_SIGNALS[*selected_signal])));
                                close_dialog = true;
                            }
                            KeyCode::Esc => {
                                close_dialog = true;
                            }
                            KeyCode::Char(c) if c.is_ascii_digit() => {
                                if let Some(index) = signal_index_for_digit(c) {
                                    if index < SCANCEL_SIGNALS.len() {
                                        *selected_signal = index;
                                    }
                                }
                            }
                            _ => {}
                        },
                        Dialog::EditTimeLimit { id, input } => match key.code {
                            KeyCode::Enter => {
                                if let Some(time_limit) = validated_time_limit(input) {
                                    timelimit_request = Some((id.clone(), time_limit));
                                    close_dialog = true;
                                }
                            }
                            KeyCode::Esc => {
                                close_dialog = true;
                            }
                            _ => {
                                input.handle_event(&Event::Key(key));
                            }
                        },
                        Dialog::CommandError { .. } => match key.code {
                            KeyCode::Enter | KeyCode::Esc => {
                                close_dialog = true;
                            }
                            _ => {}
                        },
                    };

                    if let Some((id, signal)) = scancel_request {
                        command_failure = execute_scancel(&id, signal).err();
                    }
                    if let Some((id, time_limit)) = timelimit_request {
                        command_failure = execute_scontrol_update_timelimit(&id, &time_limit).err();
                    }
                    if let Some(CommandFailure { command, output }) = command_failure {
                        self.dialog = Some(Dialog::CommandError { command, output });
                    } else if close_dialog {
                        self.dialog = None;
                    }
                } else {
                    match key.code {
                        KeyCode::Char('h') | KeyCode::Left => self.focus_previous_panel(),
                        KeyCode::Char('l') | KeyCode::Right => self.focus_next_panel(),
                        KeyCode::Char('k') | KeyCode::Up => match self.focus {
                            Focus::Jobs => self.select_previous_job(),
                        },
                        KeyCode::Char('j') | KeyCode::Down => match self.focus {
                            Focus::Jobs => self.select_next_job(),
                        },
                        KeyCode::Char('g') => match self.focus {
                            Focus::Jobs => self.select_first_job(),
                        },
                        KeyCode::Char('G') => match self.focus {
                            Focus::Jobs => self.select_last_job(),
                        },
                        KeyCode::Char('u') => match self.focus {
                            Focus::Jobs => {
                                if key
                                    .modifiers
                                    .contains(crossterm::event::KeyModifiers::CONTROL)
                                {
                                    self.scroll_jobs_half_page_up()
                                }
                            }
                        },
                        KeyCode::Char('d') => match self.focus {
                            Focus::Jobs => {
                                if key
                                    .modifiers
                                    .contains(crossterm::event::KeyModifiers::CONTROL)
                                {
                                    self.scroll_jobs_half_page_down()
                                }
                            }
                        },
                        KeyCode::PageDown => {
                            let delta = if key.modifiers.intersects(
                                crossterm::event::KeyModifiers::SHIFT
                                    | crossterm::event::KeyModifiers::CONTROL
                                    | crossterm::event::KeyModifiers::ALT,
                            ) {
                                50
                            } else {
                                1
                            };
                            self.scroll_job_output_down_by(delta);
                        }
                        KeyCode::PageUp => {
                            let delta = if key.modifiers.intersects(
                                crossterm::event::KeyModifiers::SHIFT
                                    | crossterm::event::KeyModifiers::CONTROL
                                    | crossterm::event::KeyModifiers::ALT,
                            ) {
                                50
                            } else {
                                1
                            };
                            self.scroll_job_output_up_by(delta);
                        }
                        KeyCode::Home => {
                            self.job_output_offset = 0;
                            self.job_output_anchor = ScrollAnchor::Top;
                        }
                        KeyCode::End => {
                            self.job_output_offset = 0;
                            self.job_output_anchor = ScrollAnchor::Bottom;
                        }
                        KeyCode::Char('c') => {
                            if let Some(id) = self.selected_job_id() {
                                self.dialog = Some(Dialog::ConfirmCancelJob(id));
                            }
                        }
                        KeyCode::Char('C') => {
                            if let Some(id) = self.selected_job_id() {
                                self.dialog = Some(Dialog::SelectCancelSignal {
                                    id,
                                    selected_signal: 0,
                                });
                            }
                        }
                        KeyCode::Char('t') => {
                            if let Some(job) = self.selected_job() {
                                self.dialog = Some(Dialog::EditTimeLimit {
                                    id: job.id(),
                                    input: Input::new(job.time_limit.clone()),
                                });
                            }
                        }
                        KeyCode::Char('o') => {
                            self.output_file_view = match self.output_file_view {
                                OutputFileView::Stdout => OutputFileView::Stderr,
                                OutputFileView::Stderr => OutputFileView::Stdout,
                            };
                        }
                        KeyCode::Char('w') => {
                            self.job_output_wrap = !self.job_output_wrap;
                        }
                        _ => {}
                    };
                }
            }
            AppMessage::MouseClick(index) => {
                if self.dialog.is_none() && index < self.jobs.len() {
                    self.job_list_state.select(Some(index));
                }
            }
            AppMessage::MouseWheel {
                target,
                direction,
                amount,
            } => {
                if self.dialog.is_none() {
                    match target {
                        MouseScrollTarget::Jobs => match direction {
                            MouseWheelDirection::Up => self.job_list_state.scroll_up_by(amount),
                            MouseWheelDirection::Down => self.job_list_state.scroll_down_by(amount),
                        },
                        MouseScrollTarget::Output => match direction {
                            MouseWheelDirection::Up => self.scroll_job_output_up_by(amount),
                            MouseWheelDirection::Down => self.scroll_job_output_down_by(amount),
                        },
                    }
                }
            }
        }

        // update
        self.job_output_watcher
            .set_file_path(self.job_list_state.selected().and_then(|i| {
                self.jobs.get(i).and_then(|j| match self.output_file_view {
                    OutputFileView::Stdout => j.stdout.clone(),
                    OutputFileView::Stderr => j.stderr.clone(),
                })
            }));
    }

    fn ui(&mut self, f: &mut Frame) {
        // Layout

        let content_help = Layout::default()
            .direction(Direction::Vertical)
            .constraints([Constraint::Min(3), Constraint::Length(1)].as_ref())
            .split(f.area());

        let master_detail = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Min(50), Constraint::Percentage(70)].as_ref())
            .split(content_help[0]);

        let job_detail_log = Layout::default()
            .direction(Direction::Vertical)
            .constraints([Constraint::Length(8), Constraint::Min(3)].as_ref())
            .split(master_detail[1]);

        // Help
        let help_options = vec![
            ("q", "quit"),
            ("⏶/⏷", "navigate"),
            ("pgup/pgdown", "scroll"),
            ("home/end", "top/bottom"),
            ("esc", "cancel"),
            ("enter", "confirm"),
            ("c/C", "cancel/signal"),
            ("t", "set time limit"),
            ("o", "toggle stdout/stderr"),
            ("w", "toggle text wrap"),
        ];
        let blue_style = Style::default().fg(Color::Blue);
        let light_blue_style = Style::default().fg(Color::LightBlue);

        let help = Line::from(help_options.iter().fold(
            Vec::new(),
            |mut acc, (key, description)| {
                if !acc.is_empty() {
                    acc.push(Span::raw(" | "));
                }
                acc.push(Span::styled(*key, blue_style));
                acc.push(Span::raw(": "));
                acc.push(Span::styled(*description, light_blue_style));
                acc
            },
        ));

        let help = Paragraph::new(help);
        f.render_widget(help, content_help[1]);

        // Jobs
        let max_id_len = self.jobs.iter().map(|j| j.id().len()).max().unwrap_or(0);
        let max_user_len = self.jobs.iter().map(|j| j.user.len()).max().unwrap_or(0);
        let max_partition_len = self
            .jobs
            .iter()
            .map(|j| j.partition.len())
            .max()
            .unwrap_or(0);
        let max_time_len = self.jobs.iter().map(|j| j.time.len()).max().unwrap_or(0);
        let max_state_compact_len = self
            .jobs
            .iter()
            .map(|j| j.state_compact.len())
            .max()
            .unwrap_or(0);
        let jobs: Vec<ListItem> = self
            .jobs
            .iter()
            .map(|j| {
                ListItem::new(Line::from(vec![
                    Span::styled(
                        format!(
                            "{:<max$.max$}",
                            j.state_compact,
                            max = max_state_compact_len
                        ),
                        Style::default(),
                    ),
                    Span::raw(" "),
                    Span::styled(
                        format!("{:<max$.max$}", j.id(), max = max_id_len),
                        Style::default().fg(Color::Yellow),
                    ),
                    Span::raw(" "),
                    Span::styled(
                        format!("{:<max$.max$}", j.partition, max = max_partition_len),
                        Style::default().fg(Color::Blue),
                    ),
                    Span::raw(" "),
                    Span::styled(
                        format!("{:<max$.max$}", j.user, max = max_user_len),
                        Style::default().fg(Color::Green),
                    ),
                    Span::raw(" "),
                    Span::styled(
                        format!("{:>max$.max$}", j.time, max = max_time_len),
                        Style::default().fg(Color::Red),
                    ),
                    Span::raw(" "),
                    Span::raw(&j.name),
                ]))
            })
            .collect();
        let job_list = List::new(jobs)
            .block(
                Block::default()
                    .title(format!("─Jobs ({})", self.jobs.len()))
                    .borders(Borders::ALL)
                    .border_type(BorderType::Rounded)
                    .border_style(if self.dialog.is_some() {
                        Style::default()
                    } else {
                        match self.focus {
                            Focus::Jobs => Style::default().fg(Color::Green),
                        }
                    }),
            )
            .highlight_style(Style::default().bg(Color::Green).fg(Color::Black));
        f.render_stateful_widget(job_list, master_detail[0], &mut self.job_list_state);
        self.job_list_height = master_detail[0].height.saturating_sub(2); // account for borders
        self.job_list_area = master_detail[0];

        // Job details

        let job_detail = self
            .job_list_state
            .selected()
            .and_then(|i| self.jobs.get(i));

        let job_detail = job_detail.map(|j| {
            let mut state_spans = vec![
                Span::styled("State  ", Style::default().fg(Color::Yellow)),
                Span::raw(" "),
                Span::raw(&j.state),
            ];
            if j.state == "PENDING" {
                state_spans.extend([
                    Span::styled(" Start ", Style::default().fg(Color::Yellow)),
                    Span::raw(&j.start_time),
                ]);
            }
            if let Some(s) = j.reason.as_deref() {
                state_spans.extend([
                    Span::styled(" Reason ", Style::default().fg(Color::Yellow)),
                    Span::raw(s),
                ]);
            }
            let state = Line::from(state_spans);
            let name = Line::from(vec![
                Span::styled("Name   ", Style::default().fg(Color::Yellow)),
                Span::raw(" "),
                Span::raw(&j.name),
            ]);
            let command = Line::from(vec![
                Span::styled("Command", Style::default().fg(Color::Yellow)),
                Span::raw(" "),
                Span::raw(&j.command),
            ]);
            let nodes = Line::from(vec![
                Span::styled("Nodes  ", Style::default().fg(Color::Yellow)),
                Span::raw(" "),
                Span::raw(&j.nodelist),
            ]);
            let tres = Line::from(vec![
                Span::styled("TRES   ", Style::default().fg(Color::Yellow)),
                Span::raw(" "),
                Span::raw(&j.tres),
            ]);
            let ui_stdout_text = match self.output_file_view {
                OutputFileView::Stdout => "stdout ",
                OutputFileView::Stderr => "stderr ",
            };
            let stdout = Line::from(vec![
                Span::styled(ui_stdout_text, Style::default().fg(Color::Yellow)),
                Span::raw(" "),
                Span::raw(
                    match self.output_file_view {
                        OutputFileView::Stdout => &j.stdout,
                        OutputFileView::Stderr => &j.stderr,
                    }
                    .as_ref()
                    .map(|p| p.to_str().unwrap_or_default())
                    .unwrap_or_default(),
                ),
            ]);

            Text::from(vec![state, name, command, nodes, tres, stdout])
        });
        let job_detail = Paragraph::new(job_detail.unwrap_or_default()).block(
            Block::default()
                .title("─Details")
                .borders(Borders::ALL)
                .border_type(BorderType::Rounded),
        );
        f.render_widget(job_detail, job_detail_log[0]);

        // Log
        let log_area = job_detail_log[1];
        self.job_output_area = log_area;
        let log_title = Line::from(vec![
            Span::raw("─"),
            Span::raw(match self.output_file_view {
                OutputFileView::Stdout => "stdout",
                OutputFileView::Stderr => "stderr",
            }),
            Span::styled(
                match self.job_output_anchor {
                    ScrollAnchor::Top if self.job_output_offset == 0 => "[T]".to_string(),
                    ScrollAnchor::Top => format!("[T+{}]", self.job_output_offset),
                    ScrollAnchor::Bottom if self.job_output_offset == 0 => "".to_string(),
                    ScrollAnchor::Bottom => format!("[B-{}]", self.job_output_offset),
                },
                Style::default().add_modifier(Modifier::DIM),
            ),
        ]);
        let log_block = Block::default().title(log_title).borders(Borders::ALL);
        let log_block = log_block.border_type(BorderType::Rounded);

        // let job_log = self.job_stdout.as_deref().map(|s| {
        //     string_for_paragraph(
        //         s,
        //         log_block.inner(log_area).height as usize,
        //         log_block.inner(log_area).width as usize,
        //         self.job_stdout_offset as usize,
        //     )
        // }).unwrap_or_else(|e| {
        //     self.job_stdout_offset = 0;
        //     "".to_string()
        // });

        let log = match self.job_output.as_deref() {
            Ok(s) => Paragraph::new(fit_text(
                s,
                log_block.inner(log_area).height as usize,
                log_block.inner(log_area).width as usize,
                self.job_output_anchor,
                self.job_output_offset as usize,
                self.job_output_wrap,
            )),
            Err(e) => Paragraph::new(e.to_string())
                .style(Style::default().fg(Color::Red))
                .wrap(Wrap { trim: true }),
        }
        .block(log_block);

        f.render_widget(log, log_area);

        if let Some(dialog) = &self.dialog {
            fn centered_dialog_area(width: u16, lines: u16, viewport: Rect) -> Rect {
                let dialog_width = min(width, viewport.width);
                let dialog_height = min(lines, viewport.height);
                let dialog_x = viewport.x + viewport.width.saturating_sub(dialog_width) / 2;
                let dialog_y = viewport.y + viewport.height.saturating_sub(dialog_height) / 2;

                Rect::new(dialog_x, dialog_y, dialog_width, dialog_height)
            }

            match dialog {
                Dialog::ConfirmCancelJob(id) => {
                    let dialog = Paragraph::new(Line::from(vec![
                        Span::raw("Cancel job "),
                        Span::styled(id, Style::default().add_modifier(Modifier::BOLD)),
                        Span::raw("?"),
                    ]))
                    .style(Style::default().fg(Color::White))
                    .wrap(Wrap { trim: true })
                    .block(
                        Block::default()
                            .title("─Cancel")
                            .borders(Borders::ALL)
                            .border_type(BorderType::Rounded)
                            .style(Style::default().fg(Color::Green)),
                    );

                    let area = centered_dialog_area(DIALOG_WIDTH, 3, f.area());
                    f.render_widget(Clear, area);
                    f.render_widget(dialog, area);
                }
                Dialog::SelectCancelSignal {
                    id,
                    selected_signal,
                } => {
                    let mut rows = vec![
                        Line::from(vec![
                            Span::raw("Send signal to job "),
                            Span::styled(id, Style::default().add_modifier(Modifier::BOLD)),
                            Span::raw(":"),
                        ]),
                        Line::default(),
                    ];
                    rows.extend(SCANCEL_SIGNALS.iter().enumerate().map(|(i, signal)| {
                        let signal_style = if i == *selected_signal {
                            Style::default().fg(Color::Black).bg(Color::Green)
                        } else {
                            Style::default()
                        };
                        let shortcut_style = signal_style.add_modifier(Modifier::DIM);
                        Line::from(vec![
                            Span::styled(format!("{}. ", i + 1), shortcut_style),
                            Span::styled(*signal, signal_style),
                        ])
                    }));

                    let dialog = Paragraph::new(Text::from(rows))
                        .style(Style::default().fg(Color::White))
                        .wrap(Wrap { trim: true })
                        .block(
                            Block::default()
                                .title("─Signal")
                                .borders(Borders::ALL)
                                .border_type(BorderType::Rounded)
                                .style(Style::default().fg(Color::Green)),
                        );

                    let area = centered_dialog_area(
                        DIALOG_WIDTH,
                        SCANCEL_SIGNALS.len() as u16 + 4,
                        f.area(),
                    );
                    f.render_widget(Clear, area);
                    f.render_widget(dialog, area);
                }
                Dialog::EditTimeLimit { id, input } => {
                    let block = Block::default()
                        .title("─Time Limit")
                        .borders(Borders::ALL)
                        .border_type(BorderType::Rounded)
                        .style(Style::default().fg(Color::Green));

                    let area = centered_dialog_area(DIALOG_WIDTH, 3, f.area());
                    let inner = block.inner(area);
                    let prompt_prefix = "Set time limit for job ";
                    let prompt_suffix = ": ";
                    let prompt_width = (prompt_prefix.chars().count()
                        + id.chars().count()
                        + prompt_suffix.chars().count())
                        as u16;
                    let available_width = inner.width.saturating_sub(prompt_width).max(1) as usize;
                    let scroll = input.visual_scroll(available_width);
                    let visible_value = input
                        .value()
                        .chars()
                        .skip(scroll)
                        .take(available_width)
                        .collect::<String>();
                    let dialog = Paragraph::new(Line::from(vec![
                        Span::raw(prompt_prefix),
                        Span::styled(id, Style::default().add_modifier(Modifier::BOLD)),
                        Span::raw(prompt_suffix),
                        Span::styled(visible_value, Style::default().fg(Color::Blue)),
                    ]))
                    .style(Style::default().fg(Color::White))
                    .block(block);

                    f.render_widget(Clear, area);
                    f.render_widget(dialog, area);

                    let cursor_offset = input.visual_cursor().saturating_sub(scroll) as u16;
                    let cursor_x = inner
                        .x
                        .saturating_add(prompt_width)
                        .saturating_add(cursor_offset)
                        .min(inner.x.saturating_add(inner.width.saturating_sub(1)));
                    let cursor_y = inner.y;
                    f.set_cursor_position((cursor_x, cursor_y));
                }
                Dialog::CommandError { command, output } => {
                    let dialog_text = format!("Command: {command}\n\n{output}");
                    let lines = dialog_text
                        .lines()
                        .count()
                        .saturating_add(2)
                        .min(u16::MAX as usize) as u16;
                    let dialog = Paragraph::new(dialog_text)
                        .style(Style::default().fg(Color::White))
                        .wrap(Wrap { trim: false })
                        .block(
                            Block::default()
                                .title("─Command Error")
                                .borders(Borders::ALL)
                                .border_type(BorderType::Rounded)
                                .style(Style::default().fg(Color::Red)),
                        );

                    let area = centered_dialog_area(DIALOG_WIDTH, lines, f.area());
                    f.render_widget(Clear, area);
                    f.render_widget(dialog, area);
                }
            }
        }
    }
}

fn chunked_string(s: &str, first_chunk_size: usize, chunk_size: usize) -> Vec<&str> {
    let stepped_indices = s
        .char_indices()
        .map(|(i, _)| i)
        .enumerate()
        .filter(|&(i, _)| {
            if i > (first_chunk_size) {
                chunk_size > 0 && (i - first_chunk_size).is_multiple_of(chunk_size)
            } else {
                i == 0 || i == first_chunk_size
            }
        })
        .map(|(_, e)| e)
        .collect::<Vec<_>>();
    let windows = stepped_indices.windows(2).collect::<Vec<_>>();

    let iter = windows.iter().map(|w| &s[w[0]..w[1]]);
    let last_index = *stepped_indices.last().unwrap_or(&0);
    iter.chain(once(&s[last_index..])).collect()
}

fn fit_text(
    s: &'_ str,
    lines: usize,
    cols: usize,
    anchor: ScrollAnchor,
    offset: usize,
    wrap: bool,
) -> Text<'_> {
    let s = s.rsplit_once(['\r', '\n']).map_or(s, |(p, _)| p); // skip everything after last line delimiter
    let l = s.lines().flat_map(|l| l.split('\r')); // bandaid for term escape codes
    let iter = match anchor {
        ScrollAnchor::Top => Either::Left(l),
        ScrollAnchor::Bottom => Either::Right(l.rev()),
    };
    let iter = iter
        .skip(offset)
        .flat_map(|l| {
            let iter = if wrap {
                Either::Left(
                    chunked_string(l, cols, cols.saturating_sub(2))
                        .into_iter()
                        .enumerate()
                        .map(|(i, l)| {
                            if i == 0 {
                                Line::raw(l.chars().take(cols).collect::<String>())
                            } else {
                                Line::default().spans(vec![
                                    Span::styled(
                                        "↪ ",
                                        Style::default().add_modifier(Modifier::DIM),
                                    ),
                                    Span::raw(
                                        l.chars().take(cols.saturating_sub(2)).collect::<String>(),
                                    ),
                                ])
                            }
                        }),
                )
            } else {
                match l.chars().nth(cols) {
                    Some(_) => {
                        // has more chars than cols
                        Either::Right(once(Line::default().spans(vec![
                            Span::raw(l.chars().take(cols.saturating_sub(1)).collect::<String>()),
                            Span::styled("…", Style::default().add_modifier(Modifier::DIM)),
                        ])))
                    }
                    None => {
                        Either::Right(once(Line::raw(l.chars().take(cols).collect::<String>())))
                    }
                }
            };
            match anchor {
                ScrollAnchor::Top => Either::Left(iter),
                ScrollAnchor::Bottom => Either::Right(iter.rev()),
            }
        })
        .take(lines);

    match anchor {
        ScrollAnchor::Top => Text::from(iter.collect::<Vec<_>>()),
        ScrollAnchor::Bottom => Text::from(
            iter.collect::<Vec<_>>()
                .into_iter()
                .rev()
                .collect::<Vec<_>>(),
        ),
    }
}

impl App {
    fn selected_job(&self) -> Option<&Job> {
        self.job_list_state
            .selected()
            .and_then(|i| self.jobs.get(i))
    }

    fn selected_job_id(&self) -> Option<String> {
        self.selected_job().map(Job::id)
    }

    fn focus_next_panel(&mut self) {
        match self.focus {
            Focus::Jobs => self.focus = Focus::Jobs,
        }
    }

    fn focus_previous_panel(&mut self) {
        match self.focus {
            Focus::Jobs => self.focus = Focus::Jobs,
        }
    }

    fn select_next_job(&mut self) {
        self.job_list_state.select_next();
    }

    fn select_previous_job(&mut self) {
        self.job_list_state.select_previous();
    }

    fn select_first_job(&mut self) {
        self.job_list_state.select_first();
    }

    fn select_last_job(&mut self) {
        self.job_list_state.select_last();
    }

    fn scroll_jobs_half_page_down(&mut self) {
        self.job_list_state.scroll_down_by(self.job_list_height / 2);
    }

    fn scroll_jobs_half_page_up(&mut self) {
        self.job_list_state.scroll_up_by(self.job_list_height / 2);
    }

    fn job_index_at(&self, column: u16, row: u16) -> Option<usize> {
        if self.jobs.is_empty() {
            return None;
        }
        let inner = Rect::new(
            self.job_list_area.x.saturating_add(1),
            self.job_list_area.y.saturating_add(1),
            self.job_list_area.width.saturating_sub(2),
            self.job_list_area.height.saturating_sub(2),
        );
        if !rect_contains(inner, column, row) {
            return None;
        }

        let row_in_list = (row - inner.y) as usize;
        let index = self.job_list_state.offset().saturating_add(row_in_list);
        (index < self.jobs.len()).then_some(index)
    }

    fn scroll_job_output_down_by(&mut self, delta: u16) {
        match self.job_output_anchor {
            ScrollAnchor::Top => {
                self.job_output_offset = self.job_output_offset.saturating_add(delta)
            }
            ScrollAnchor::Bottom => {
                self.job_output_offset = self.job_output_offset.saturating_sub(delta)
            }
        }
    }

    fn scroll_job_output_up_by(&mut self, delta: u16) {
        match self.job_output_anchor {
            ScrollAnchor::Top => {
                self.job_output_offset = self.job_output_offset.saturating_sub(delta)
            }
            ScrollAnchor::Bottom => {
                self.job_output_offset = self.job_output_offset.saturating_add(delta)
            }
        }
    }
}

fn rect_contains(rect: Rect, column: u16, row: u16) -> bool {
    column >= rect.x
        && column < rect.x.saturating_add(rect.width)
        && row >= rect.y
        && row < rect.y.saturating_add(rect.height)
}

fn mouse_wheel_direction(kind: MouseEventKind) -> Option<MouseWheelDirection> {
    match kind {
        MouseEventKind::ScrollUp => Some(MouseWheelDirection::Up),
        MouseEventKind::ScrollDown => Some(MouseWheelDirection::Down),
        _ => None,
    }
}

fn signal_index_for_digit(digit: char) -> Option<usize> {
    let value = digit.to_digit(10)? as usize;
    if value == 0 { None } else { Some(value - 1) }
}

fn validated_time_limit(input: &Input) -> Option<String> {
    let time_limit = input.value().trim();
    if time_limit.is_empty() {
        None
    } else {
        Some(time_limit.to_string())
    }
}

fn execute_scancel(job_id: &str, signal: Option<&str>) -> Result<(), CommandFailure> {
    let mut command = Command::new("scancel");
    let mut command_display = String::from("scancel");

    if let Some(signal) = signal {
        command.arg("--signal").arg(signal);
        command_display.push_str(&format!(" --signal {signal}"));
    }
    command.arg(job_id);
    command_display.push_str(&format!(" {job_id}"));

    execute_command(command, command_display)
}

fn execute_scontrol_update_timelimit(job_id: &str, time_limit: &str) -> Result<(), CommandFailure> {
    let mut command = Command::new("scontrol");
    command
        .arg("update")
        .arg(format!("JobId={job_id}"))
        .arg(format!("TimeLimit={time_limit}"));

    execute_command(
        command,
        format!("scontrol update JobId={job_id} TimeLimit={time_limit}"),
    )
}

fn execute_command(mut command: Command, command_label: String) -> Result<(), CommandFailure> {
    let output = command.output().map_err(|error| CommandFailure {
        command: command_label.clone(),
        output: error.to_string(),
    })?;

    if output.status.success() {
        return Ok(());
    }

    let mut details = vec![match output.status.code() {
        Some(code) => format!("Exit code: {code}"),
        None => "Exit code: N/A".to_string(),
    }];

    let stdout = String::from_utf8_lossy(&output.stdout);
    let stdout = stdout.trim_end();
    let stderr = String::from_utf8_lossy(&output.stderr);
    let stderr = stderr.trim_end();
    let has_stdout = !stdout.is_empty();
    let has_stderr = !stderr.is_empty();
    match (has_stdout, has_stderr) {
        (true, true) => {
            details.push(format!("stdout:\n{stdout}"));
            details.push(format!("stderr:\n{stderr}"));
        }
        (true, false) => {
            details.push(stdout.to_string());
        }
        (false, true) => {
            details.push(stderr.to_string());
        }
        (false, false) => {}
    }

    if details.len() == 1 {
        details.push("No output.".to_string());
    }

    Err(CommandFailure {
        command: command_label,
        output: details.join("\n\n"),
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_chunked_string() {
        // Divisible
        let input = "abcdefghij";
        let expected = vec!["abcd", "ef", "gh", "ij"];
        assert_eq!(chunked_string(input, 4, 2), expected);

        // Not divisible
        let input = "123456789";
        let expected = vec!["1234", "56", "78", "9"];
        assert_eq!(chunked_string(input, 4, 2), expected);

        // Smaller
        let input = "abc";
        let expected = vec!["abc"];
        assert_eq!(chunked_string(input, 4, 2), expected);

        // Smaller
        let input = "abcde";
        let expected = vec!["abcd", "e"];
        assert_eq!(chunked_string(input, 4, 2), expected);

        // Empty
        let input = "";
        let expected: Vec<&str> = vec![""];
        assert_eq!(chunked_string(input, 4, 2), expected);

        let input = "123456789";
        let expected = vec!["1234", "56789"];
        assert_eq!(chunked_string(input, 4, 0), expected);

        let input = "123456789";
        let expected = vec!["12", "34", "56", "78", "9"];
        assert_eq!(chunked_string(input, 0, 2), expected);

        let input = "123456789";
        let expected = vec!["123456789"];
        assert_eq!(chunked_string(input, 0, 0), expected);
    }

    #[test]
    fn test_validated_time_limit() {
        assert_eq!(validated_time_limit(&Input::new("".to_string())), None);
        assert_eq!(validated_time_limit(&Input::new("   ".to_string())), None);
        assert_eq!(
            validated_time_limit(&Input::new(" 01:00:00 ".to_string())),
            Some("01:00:00".to_string())
        );
    }
}


================================================
FILE: src/file_watcher.rs
================================================
use std::{
    fmt,
    fs::File,
    io::{self, Read, Seek},
    path::{Path, PathBuf},
    thread,
    time::Duration,
};

use crossbeam::{
    channel::{Receiver, RecvError, SendError, Sender, unbounded},
    select,
};
use notify::{RecursiveMode, Watcher, event::ModifyKind};

use crate::app::AppMessage;

struct FileReader {
    content_sender: Sender<io::Result<String>>,
    receiver: Receiver<()>,
    file_path: PathBuf,
    interval: Duration,
    content: String,
    pos: u64,
}

struct FileWatcher {
    app: Sender<AppMessage>,
    receiver: Receiver<FileWatcherMessage>,
    file_path: Option<PathBuf>,
    watching: bool, // Whether notify watch was successfully started for file_path
    interval: Duration,
}
pub enum FileWatcherMessage {
    FilePath(Option<PathBuf>),
}

pub struct FileWatcherHandle {
    sender: Sender<FileWatcherMessage>,
    file_path: Option<PathBuf>,
}

pub enum FileWatcherError {
    File(io::Error),
}

impl fmt::Display for FileWatcherError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            FileWatcherError::File(e) => write!(f, "Read error: {}", e),
        }
    }
}

impl FileWatcher {
    fn new(
        app: Sender<AppMessage>,
        receiver: Receiver<FileWatcherMessage>,
        interval: Duration,
    ) -> Self {
        FileWatcher {
            app,
            receiver,
            file_path: None,
            watching: false,
            interval,
        }
    }

    fn run(&mut self) -> Result<(), RecvError> {
        let (watch_sender, watch_receiver) = unbounded::<()>();
        let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
            let event = res.unwrap();
            if let notify::EventKind::Modify(ModifyKind::Data(_)) = event.kind {
                watch_sender.send(()).unwrap();
            }
        })
        .unwrap();

        let (mut _content_sender, mut _content_receiver) = unbounded::<io::Result<String>>();
        let (mut _watch_sender, mut _watch_receiver) = unbounded::<()>();
        loop {
            select! {
                recv(self.receiver) -> msg => {
                    match msg? {
                        FileWatcherMessage::FilePath(file_path) => {
                            (_content_sender, _content_receiver) = unbounded();
                            (_watch_sender, _watch_receiver) = unbounded::<()>();

                            if self.watching {
                                let p = self.file_path.as_ref().expect("Inconsistent state");
                                watcher.unwatch(p).unwrap_or_else(|_| panic!("Failed to unwatch {:?}", p));
                            }
                            self.file_path = None;
                            self.watching = false;

                            if let Some(p) = file_path {
                                self.file_path = Some(p.clone());

                                let interval = self.interval;
                                thread::spawn({
                                    let p = p.clone();
                                    move || FileReader::new(_content_sender, _watch_receiver, p, interval).run()
                                });

                                self.watching = watcher.watch(Path::new(&p), RecursiveMode::NonRecursive).is_ok();
                            } else {
                                _content_sender.send(Ok("".to_string())).unwrap();
                            }
                        }
                    }
                }
                recv(watch_receiver) -> _ => { _watch_sender.send(()).unwrap(); }
                recv(_content_receiver) -> msg => {
                    let res = msg.unwrap();
                    // If we don't have a file watch yet but the file now reads OK, try enabling watch
                    if !self.watching {
                        if let (Ok(_), Some(p)) = (&res, &self.file_path) {
                            self.watching = watcher.watch(Path::new(p), RecursiveMode::NonRecursive).is_ok();
                        }
                    }
                    self.app
                        .send(AppMessage::JobOutput(res.map_err(FileWatcherError::File)))
                        .unwrap();
                }
            }
        }
    }
}

impl FileReader {
    fn new(
        content_sender: Sender<io::Result<String>>,
        receiver: Receiver<()>,
        file_path: PathBuf,
        interval: Duration,
    ) -> Self {
        FileReader {
            content_sender,
            receiver,
            file_path,
            interval,
            content: "".to_string(),
            pos: 0,
        }
    }

    fn run(&mut self) -> Result<(), ()> {
        loop {
            self.update().map_err(|_| ())?;
            select! {
                recv(self.receiver) -> msg => {
                    msg.map_err(|_| ())?;
                }
                // in case the file watcher doesn't work (e.g. network mounted fs)
                default(self.interval) => {}
            }
        }
    }

    fn update(&mut self) -> Result<(), SendError<io::Result<String>>> {
        let s = File::open(&self.file_path).and_then(|mut f| {
            // avoid reading the whole file every time
            self.pos = f.seek(io::SeekFrom::Start(self.pos))?;
            self.pos += f.read_to_string(&mut self.content)? as u64;
            Ok(self.content.clone())
        });
        // let s = fs::read_to_string(&self.file_path); // alternative: always read the whole file
        self.content_sender.send(s)
    }
}

impl FileWatcherHandle {
    pub fn new(app: Sender<AppMessage>, interval: Duration) -> Self {
        let (sender, receiver) = unbounded();
        let mut actor = FileWatcher::new(app, receiver, interval);
        thread::spawn(move || actor.run());

        Self {
            sender,
            file_path: None,
        }
    }

    pub fn set_file_path(&mut self, file_path: Option<PathBuf>) {
        if self.file_path != file_path {
            self.file_path = file_path.clone();
            self.sender
                .send(FileWatcherMessage::FilePath(file_path))
                .unwrap();
        }
    }
}


================================================
FILE: src/job_watcher.rs
================================================
use std::path::PathBuf;
use std::{io::BufRead, process::Command, thread, time::Duration};

use crossbeam::channel::Sender;
use regex::Regex;

use crate::app::AppMessage;
use crate::app::Job;

struct JobWatcher {
    app: Sender<AppMessage>,
    interval: Duration,
    squeue_args: Vec<String>,
}

pub struct JobWatcherHandle {}

impl JobWatcher {
    fn new(app: Sender<AppMessage>, interval: Duration, squeue_args: Vec<String>) -> Self {
        Self {
            app,
            interval,
            squeue_args,
        }
    }

    fn run(&mut self) -> Self {
        let output_separator = "###turm###";
        let fields = [
            "jobid",
            "name",
            "state",
            "username",
            "timeused",
            "timelimit",
            "StartTime",
            "tres-alloc",
            "partition",
            "nodelist",
            "stdout",
            "stderr",
            "command",
            "statecompact",
            "reason",
            "ArrayJobID",  // %A
            "ArrayTaskID", // %a
            "NodeList",    // %N
            "WorkDir",     // for fallback
        ];
        let output_format = fields
            .map(|s| s.to_owned() + ":" + output_separator)
            .join(",");

        loop {
            let jobs: Vec<Job> = Command::new("squeue")
                .args(&self.squeue_args)
                .arg("--array")
                .arg("--noheader")
                .arg("--Format")
                .arg(&output_format)
                .output()
                .expect("failed to execute process")
                .stdout
                .lines()
                .map(|l| l.unwrap().trim().to_string())
                .filter_map(|l| {
                    let parts: Vec<_> = l.split(output_separator).collect();

                    if parts.len() != fields.len() + 1 {
                        return None;
                    }

                    let id = parts[0];
                    let name = parts[1];
                    let state = parts[2];
                    let user = parts[3];
                    let time = parts[4];
                    let time_limit = parts[5];
                    let start_time = parts[6];
                    let tres = parts[7];
                    let partition = parts[8];
                    let nodelist = parts[9];
                    let stdout = parts[10];
                    let stderr = parts[11];
                    let command = parts[12];
                    let state_compact = parts[13];
                    let reason = parts[14];

                    let array_job_id = parts[15];
                    let array_task_id = parts[16];
                    let node_list = parts[17];
                    let working_dir = parts[18];

                    Some(Job {
                        job_id: id.to_owned(),
                        array_id: array_job_id.to_owned(),
                        array_step: match array_task_id {
                            "N/A" => None,
                            _ => Some(array_task_id.to_owned()),
                        },
                        name: name.to_owned(),
                        state: state.to_owned(),
                        state_compact: state_compact.to_owned(),
                        reason: if reason == "None" {
                            None
                        } else {
                            Some(reason.to_owned())
                        },
                        user: user.to_owned(),
                        time: time.to_owned(),
                        time_limit: time_limit.to_owned(),
                        start_time: start_time.to_owned(),
                        tres: tres.to_owned(),
                        partition: partition.to_owned(),
                        nodelist: nodelist.to_owned(),
                        command: command.to_owned(),
                        stdout: Self::resolve_path(
                            stdout,
                            array_job_id,
                            array_task_id,
                            id,
                            node_list,
                            user,
                            name,
                            working_dir,
                        ),
                        stderr: Self::resolve_path(
                            stderr,
                            array_job_id,
                            array_task_id,
                            id,
                            node_list,
                            user,
                            name,
                            working_dir,
                        ), // TODO fill all fields
                    })
                })
                .collect();
            self.app.send(AppMessage::Jobs(jobs)).unwrap();
            thread::sleep(self.interval);
        }
    }

    #[allow(clippy::too_many_arguments)]
    fn resolve_path(
        path: &str,
        array_master: &str,
        array_id: &str,
        id: &str,
        host: &str,
        user: &str,
        name: &str,
        working_dir: &str,
    ) -> Option<PathBuf> {
        // see https://slurm.schedmd.com/sbatch.html#SECTION_%3CB%3Efilename-pattern%3C/B%3E
        lazy_static::lazy_static! {
            static ref RE: Regex = Regex::new(r"%(%|A|a|J|j|N|n|s|t|u|x)").unwrap();
        }

        let mut path = path.to_owned();
        let slurm_no_val = "4294967294";
        let array_id = if array_id == "N/A" {
            slurm_no_val
        } else {
            array_id
        };

        if path.is_empty() {
            // never happens right now, because `squeue -O stdout` seems to always return something
            path = if array_id == slurm_no_val {
                PathBuf::from(working_dir).join("slurm-%J.out")
            } else {
                PathBuf::from(working_dir).join("slurm-%A_%a.out")
            }
            .to_str()
            .unwrap()
            .to_owned();
        };

        for cap in RE
            .captures_iter(&path.clone())
            .collect::<Vec<_>>() // TODO: this is stupid, there has to be a better way to reverse the captures...
            .iter()
            .rev()
        {
            let m = cap.get(0).unwrap();
            let replacement = match m.as_str() {
                "%%" => "%",
                "%A" => array_master,
                "%a" => array_id,
                "%J" => id,
                "%j" => id,
                "%N" => host.split(',').next().unwrap_or(host),
                "%n" => "0",
                "%s" => "batch",
                "%t" => "0",
                "%u" => user,
                "%x" => name,
                _ => unreachable!(),
            };

            path.replace_range(m.range(), replacement);
        }

        Some(PathBuf::from(working_dir).join(path)) // works even if `path` is absolute
    }
}

impl JobWatcherHandle {
    pub fn new(app: Sender<AppMessage>, interval: Duration, squeue_args: Vec<String>) -> Self {
        let mut actor = JobWatcher::new(app, interval, squeue_args);
        thread::spawn(move || actor.run());

        Self {}
    }
}


================================================
FILE: src/main.rs
================================================
mod app;
mod file_watcher;
mod job_watcher;
mod squeue_args;

use app::App;
use clap::CommandFactory;
use clap::Parser;
use clap::Subcommand;
use clap_complete::{Shell, generate};
use crossbeam::channel::{Sender, unbounded};
use crossterm::{
    cursor::Show,
    event::{
        self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
        Event,
    },
    execute,
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
    Terminal,
    backend::{Backend, CrosstermBackend},
};
use squeue_args::SqueueArgs;
use std::io::Write;
use std::{io, panic, thread};

#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
    /// Refresh rate for the job watcher.
    #[arg(long, value_name = "SECONDS", default_value_t = 2)]
    slurm_refresh: u64,

    /// Refresh rate for the file watcher.
    #[arg(long, value_name = "SECONDS", default_value_t = 2)]
    file_refresh: u64,

    /// squeue arguments
    #[command(flatten)]
    squeue_args: SqueueArgs,

    #[command(subcommand)]
    command: Option<CliCommand>,
}

#[derive(Subcommand)]
enum CliCommand {
    /// Print shell completion script to stdout.
    Completion {
        /// The shell to generate completion for.
        shell: Shell,
    },
}

fn main() -> io::Result<()> {
    let args = Cli::parse();
    match args.command {
        Some(CliCommand::Completion { shell }) => {
            let cmd = &mut Cli::command();
            generate(shell, cmd, cmd.get_name().to_string(), &mut io::stdout());
            return Ok(());
        }
        None => {}
    }

    install_panic_hook();

    let mut terminal_guard = TerminalGuard::new(io::stdout())?;
    run_app(terminal_guard.terminal_mut(), args)
}

fn install_panic_hook() {
    let default_hook = panic::take_hook();
    panic::set_hook(Box::new(move |panic_info| {
        let _ = disable_raw_mode();
        let _ = execute!(
            io::stdout(),
            LeaveAlternateScreen,
            DisableBracketedPaste,
            DisableMouseCapture,
            Show
        );
        default_hook(panic_info);
    }));
}

struct TerminalGuard<W: Write> {
    terminal: Terminal<CrosstermBackend<W>>,
}

impl<W: Write> TerminalGuard<W> {
    fn new(mut writer: W) -> io::Result<Self> {
        enable_raw_mode()?;
        execute!(
            writer,
            EnterAlternateScreen,
            EnableBracketedPaste,
            EnableMouseCapture
        )?;
        let backend = CrosstermBackend::new(writer);
        let terminal = Terminal::new(backend)?;
        Ok(Self { terminal })
    }

    fn terminal_mut(&mut self) -> &mut Terminal<CrosstermBackend<W>> {
        &mut self.terminal
    }
}

impl<W: Write> Drop for TerminalGuard<W> {
    fn drop(&mut self) {
        let _ = disable_raw_mode();
        let _ = execute!(
            self.terminal.backend_mut(),
            LeaveAlternateScreen,
            DisableBracketedPaste,
            DisableMouseCapture
        );
        let _ = self.terminal.show_cursor();
    }
}

fn input_loop(tx: Sender<std::io::Result<Event>>) {
    while tx.send(event::read()).is_ok() {}
}

fn run_app<B: Backend<Error = io::Error>>(terminal: &mut Terminal<B>, args: Cli) -> io::Result<()> {
    let (input_tx, input_rx) = unbounded();
    let mut app = App::new(
        input_rx,
        args.slurm_refresh,
        args.file_refresh,
        args.squeue_args.to_vec(),
    );
    thread::spawn(move || input_loop(input_tx));
    app.run(terminal)
}


================================================
FILE: src/squeue_args.rs
================================================
use clap::Args;
/// Doc comment
#[derive(Args, Debug)]
pub struct SqueueArgs {
    /// |squeue arg| Comma separated list of accounts to view, default is all accounts.
    #[arg(short = 'A', long)]
    account: Option<String>,

    /// |squeue arg| Display jobs in hidden partitions.
    #[arg(short, long)]
    all: bool,

    /// |squeue arg| Report federated information if a member of one.
    #[arg(long)]
    federation: bool,

    /// |squeue arg| Do not display jobs in hidden partitions.
    #[arg(long)]
    hide: bool,

    /// |squeue arg| Comma separated list of jobs IDs to view, default is all.
    #[arg(short, long, value_name = "JOBID")]
    job: Option<String>,

    /// |squeue arg| Report information only about jobs on the local cluster. Overrides `--federation`.
    #[arg(long)]
    local: bool,

    /// |squeue arg| Comma separated list of license names to view.
    #[arg(short = 'L', long)]
    licenses: Option<String>,

    /// |squeue arg| Cluster to issue commands to. Default is current cluster. Cluster with no name will reset to default. Implies `--local`.
    #[arg(short = 'M', long)]
    clusters: Option<String>,

    /// |squeue arg| Equivalent to `--user=<my username>`.
    #[arg(long)]
    me: bool,

    /// |squeue arg| Comma separated list of job names to view.
    #[arg(short = 'n', long)]
    name: Option<String>,

    /// |squeue arg| Don't convert units from their original type (e.g. 2048M won't be converted to 2G).
    #[arg(long)]
    noconvert: bool,

    /// |squeue arg| Comma separated list of partitions to view, default is all partitions.
    #[arg(short, long)]
    partition: Option<String>,

    /// |squeue arg| Comma separated list of qos's to view, default is all qos's.
    #[arg(short, long)]
    qos: Option<String>,

    /// |squeue arg| Reservation to view, default is all.
    #[arg(short = 'R', long)]
    reservation: Option<String>,

    /// |squeue arg| Report information about all sibling jobs on a federated cluster. Implies --federation.
    #[arg(long)]
    sibling: bool,

    /// |squeue arg| Comma separated list of job steps to view, default is all.
    #[arg(short, long)]
    step: Option<String>,

    /// |squeue arg| Comma separated list of fields to sort on.
    #[arg(short = 'S', long, value_name = "FIELDS")]
    sort: Option<String>,

    /// |squeue arg| Comma separated list of states to view, default is pending and running, `--states=all` reports all states.
    #[arg(short = 't', long)]
    states: Option<String>,

    /// |squeue arg| Comma separated list of users to view.
    #[arg(short = 'u', long)]
    user: Option<String>,

    /// |squeue arg| List of nodes to view, default is all nodes.
    #[arg(short = 'w', long, value_name = "NODES")]
    nodelist: Option<String>,
}

impl SqueueArgs {
    pub fn to_vec(&self) -> Vec<String> {
        let mut args = Vec::new();
        if let Some(account) = &self.account {
            args.push(format!("--account={}", account));
        }
        if self.all {
            args.push("--all".to_string());
        }
        if self.federation {
            args.push("--federation".to_string());
        }
        if self.hide {
            args.push("--hide".to_string());
        }
        if let Some(job) = &self.job {
            args.push(format!("--job={}", job));
        }
        if self.local {
            args.push("--local".to_string());
        }
        if let Some(licenses) = &self.licenses {
            args.push(format!("--licenses={}", licenses));
        }
        if let Some(clusters) = &self.clusters {
            args.push(format!("--clusters={}", clusters));
        }
        if self.me {
            args.push("--me".to_string());
        }
        if let Some(name) = &self.name {
            args.push(format!("--name={}", name));
        }
        if self.noconvert {
            args.push("--noconvert".to_string());
        }
        if let Some(partition) = &self.partition {
            args.push(format!("--partition={}", partition));
        }
        if let Some(qos) = &self.qos {
            args.push(format!("--qos={}", qos));
        }
        if let Some(reservation) = &self.reservation {
            args.push(format!("--reservation={}", reservation));
        }
        if self.sibling {
            args.push("--sibling".to_string());
        }
        if let Some(step) = &self.step {
            args.push(format!("--step={}", step));
        }
        if let Some(sort) = &self.sort {
            args.push(format!("--sort={}", sort));
        }
        if let Some(states) = &self.states {
            args.push(format!("--states={}", states));
        }
        if let Some(user) = &self.user {
            args.push(format!("--user={}", user));
        }
        if let Some(nodelist) = &self.nodelist {
            args.push(format!("--nodelist={}", nodelist));
        }
        args
    }
}
Download .txt
gitextract_6kiytyky/

├── .github/
│   └── workflows/
│       ├── release.yml
│       └── test.yml
├── .gitignore
├── .vscode/
│   └── launch.json
├── CHANGELOG.md
├── Cargo.toml
├── LICENSE
├── README.md
├── scripts/
│   └── mock-slurm/
│       ├── bin/
│       │   ├── scancel
│       │   ├── scontrol
│       │   └── squeue
│       └── logs/
│           └── .gitkeep
└── src/
    ├── app.rs
    ├── file_watcher.rs
    ├── job_watcher.rs
    ├── main.rs
    └── squeue_args.rs
Download .txt
SYMBOL INDEX (75 symbols across 5 files)

FILE: src/app.rs
  type Focus (line 23) | pub enum Focus {
  type Dialog (line 27) | pub enum Dialog {
  type CommandFailure (line 34) | struct CommandFailure {
  type ScrollAnchor (line 40) | pub enum ScrollAnchor {
  type OutputFileView (line 46) | pub enum OutputFileView {
  type App (line 52) | pub struct App {
    method new (line 130) | pub fn new(
    method run (line 168) | pub fn run<B: Backend<Error = io::Error>>(
    method try_recv_input_event (line 198) | fn try_recv_input_event(&mut self) -> Option<Event> {
    method handle_input_event (line 212) | fn handle_input_event(&mut self, event: Event) -> (bool, bool) {
    method mouse_scroll_target (line 273) | fn mouse_scroll_target(&self, column: u16, row: u16) -> Option<MouseSc...
    method handle (line 283) | fn handle(&mut self, msg: AppMessage) {
    method ui (line 524) | fn ui(&mut self, f: &mut Frame) {
    method selected_job (line 1010) | fn selected_job(&self) -> Option<&Job> {
    method selected_job_id (line 1016) | fn selected_job_id(&self) -> Option<String> {
    method focus_next_panel (line 1020) | fn focus_next_panel(&mut self) {
    method focus_previous_panel (line 1026) | fn focus_previous_panel(&mut self) {
    method select_next_job (line 1032) | fn select_next_job(&mut self) {
    method select_previous_job (line 1036) | fn select_previous_job(&mut self) {
    method select_first_job (line 1040) | fn select_first_job(&mut self) {
    method select_last_job (line 1044) | fn select_last_job(&mut self) {
    method scroll_jobs_half_page_down (line 1048) | fn scroll_jobs_half_page_down(&mut self) {
    method scroll_jobs_half_page_up (line 1052) | fn scroll_jobs_half_page_up(&mut self) {
    method job_index_at (line 1056) | fn job_index_at(&self, column: u16, row: u16) -> Option<usize> {
    method scroll_job_output_down_by (line 1075) | fn scroll_job_output_down_by(&mut self, delta: u16) {
    method scroll_job_output_up_by (line 1086) | fn scroll_job_output_up_by(&mut self, delta: u16) {
  type Job (line 73) | pub struct Job {
    method id (line 94) | fn id(&self) -> String {
  type AppMessage (line 102) | pub enum AppMessage {
  type MouseWheelDirection (line 115) | pub(crate) enum MouseWheelDirection {
  type MouseScrollTarget (line 121) | pub(crate) enum MouseScrollTarget {
  constant SCANCEL_SIGNALS (line 126) | const SCANCEL_SIGNALS: &[&str] = &["TERM", "INT", "HUP", "USR1", "USR2",...
  constant DIALOG_WIDTH (line 127) | const DIALOG_WIDTH: u16 = 80;
  function chunked_string (line 918) | fn chunked_string(s: &str, first_chunk_size: usize, chunk_size: usize) -...
  function fit_text (line 939) | fn fit_text(
  function rect_contains (line 1098) | fn rect_contains(rect: Rect, column: u16, row: u16) -> bool {
  function mouse_wheel_direction (line 1105) | fn mouse_wheel_direction(kind: MouseEventKind) -> Option<MouseWheelDirec...
  function signal_index_for_digit (line 1113) | fn signal_index_for_digit(digit: char) -> Option<usize> {
  function validated_time_limit (line 1118) | fn validated_time_limit(input: &Input) -> Option<String> {
  function execute_scancel (line 1127) | fn execute_scancel(job_id: &str, signal: Option<&str>) -> Result<(), Com...
  function execute_scontrol_update_timelimit (line 1141) | fn execute_scontrol_update_timelimit(job_id: &str, time_limit: &str) -> ...
  function execute_command (line 1154) | fn execute_command(mut command: Command, command_label: String) -> Resul...
  function test_chunked_string (line 1204) | fn test_chunked_string() {
  function test_validated_time_limit (line 1244) | fn test_validated_time_limit() {

FILE: src/file_watcher.rs
  type FileReader (line 18) | struct FileReader {
    method new (line 132) | fn new(
    method run (line 148) | fn run(&mut self) -> Result<(), ()> {
    method update (line 161) | fn update(&mut self) -> Result<(), SendError<io::Result<String>>> {
  type FileWatcher (line 27) | struct FileWatcher {
    method new (line 56) | fn new(
    method run (line 70) | fn run(&mut self) -> Result<(), RecvError> {
  type FileWatcherMessage (line 34) | pub enum FileWatcherMessage {
  type FileWatcherHandle (line 38) | pub struct FileWatcherHandle {
    method new (line 174) | pub fn new(app: Sender<AppMessage>, interval: Duration) -> Self {
    method set_file_path (line 185) | pub fn set_file_path(&mut self, file_path: Option<PathBuf>) {
  type FileWatcherError (line 43) | pub enum FileWatcherError {
    method fmt (line 48) | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {

FILE: src/job_watcher.rs
  type JobWatcher (line 10) | struct JobWatcher {
    method new (line 19) | fn new(app: Sender<AppMessage>, interval: Duration, squeue_args: Vec<S...
    method run (line 27) | fn run(&mut self) -> Self {
    method resolve_path (line 146) | fn resolve_path(
  type JobWatcherHandle (line 16) | pub struct JobWatcherHandle {}
    method new (line 211) | pub fn new(app: Sender<AppMessage>, interval: Duration, squeue_args: V...

FILE: src/main.rs
  type Cli (line 31) | struct Cli {
  type CliCommand (line 49) | enum CliCommand {
  function main (line 57) | fn main() -> io::Result<()> {
  function install_panic_hook (line 74) | fn install_panic_hook() {
  type TerminalGuard (line 89) | struct TerminalGuard<W: Write> {
  function new (line 94) | fn new(mut writer: W) -> io::Result<Self> {
  function terminal_mut (line 107) | fn terminal_mut(&mut self) -> &mut Terminal<CrosstermBackend<W>> {
  method drop (line 113) | fn drop(&mut self) {
  function input_loop (line 125) | fn input_loop(tx: Sender<std::io::Result<Event>>) {
  function run_app (line 129) | fn run_app<B: Backend<Error = io::Error>>(terminal: &mut Terminal<B>, ar...

FILE: src/squeue_args.rs
  type SqueueArgs (line 4) | pub struct SqueueArgs {
    method to_vec (line 87) | pub fn to_vec(&self) -> Vec<String> {
Condensed preview — 17 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (99K chars).
[
  {
    "path": ".github/workflows/release.yml",
    "chars": 5929,
    "preview": "name: Release\non:\n  push:\n    branches: [main]\nenv:\n  CARGO_INCREMENTAL: 0\n  CARGO_NET_RETRY: 10\n  CARGO_TERM_COLOR: alw"
  },
  {
    "path": ".github/workflows/test.yml",
    "chars": 316,
    "preview": "name: Test\n\non:\n  push:\n    branches: [ \"main\" ]\n  pull_request:\n    branches: [ \"main\" ]\n\nenv:\n  CARGO_TERM_COLOR: alwa"
  },
  {
    "path": ".gitignore",
    "chars": 68,
    "preview": "/target\nscripts/mock-slurm/logs/*\n!scripts/mock-slurm/logs/.gitkeep\n"
  },
  {
    "path": ".vscode/launch.json",
    "chars": 1434,
    "preview": "{\n    // Use IntelliSense to learn about possible attributes.\n    // Hover to view descriptions of existing attributes.\n"
  },
  {
    "path": "CHANGELOG.md",
    "chars": 9613,
    "preview": "# Changelog\n\n## [0.14.0](https://github.com/karimknaebel/turm/compare/v0.13.1...v0.14.0) (2026-03-07)\n\n\n### Features\n\n* "
  },
  {
    "path": "Cargo.toml",
    "chars": 670,
    "preview": "[package]\nname = \"turm\"\nversion = \"0.14.0\"\nauthors = [\"Karim Knaebel <contact@knaebel.dev>\"]\ndescription = \"A TUI for th"
  },
  {
    "path": "LICENSE",
    "chars": 1070,
    "preview": "MIT License\n\nCopyright (c) 2026 Karim Knaebel\n\nPermission is hereby granted, free of charge, to any person obtaining a c"
  },
  {
    "path": "README.md",
    "chars": 3891,
    "preview": "# turm\n\n[![image](https://img.shields.io/pypi/v/turm.svg)](https://pypi.python.org/pypi/turm)\n[![image](https://img.shie"
  },
  {
    "path": "scripts/mock-slurm/bin/scancel",
    "chars": 789,
    "preview": "#!/usr/bin/env bash\nset -euo pipefail\n\nscript_dir=\"$(cd -- \"$(dirname -- \"${BASH_SOURCE[0]}\")\" && pwd)\"\nroot_dir=\"$(cd -"
  },
  {
    "path": "scripts/mock-slurm/bin/scontrol",
    "chars": 784,
    "preview": "#!/usr/bin/env bash\nset -euo pipefail\n\nscript_dir=\"$(cd -- \"$(dirname -- \"${BASH_SOURCE[0]}\")\" && pwd)\"\nroot_dir=\"$(cd -"
  },
  {
    "path": "scripts/mock-slurm/bin/squeue",
    "chars": 1236,
    "preview": "#!/usr/bin/env bash\nset -euo pipefail\n\nscript_dir=\"$(cd -- \"$(dirname -- \"${BASH_SOURCE[0]}\")\" && pwd)\"\nroot_dir=\"$(cd -"
  },
  {
    "path": "scripts/mock-slurm/logs/.gitkeep",
    "chars": 1,
    "preview": "\n"
  },
  {
    "path": "src/app.rs",
    "chars": 47520,
    "preview": "use crossbeam::{\n    channel::{Receiver, TryRecvError, unbounded},\n    select,\n};\nuse itertools::Either;\nuse std::{cmp::"
  },
  {
    "path": "src/file_watcher.rs",
    "chars": 6231,
    "preview": "use std::{\n    fmt,\n    fs::File,\n    io::{self, Read, Seek},\n    path::{Path, PathBuf},\n    thread,\n    time::Duration,"
  },
  {
    "path": "src/job_watcher.rs",
    "chars": 7149,
    "preview": "use std::path::PathBuf;\nuse std::{io::BufRead, process::Command, thread, time::Duration};\n\nuse crossbeam::channel::Sende"
  },
  {
    "path": "src/main.rs",
    "chars": 3563,
    "preview": "mod app;\nmod file_watcher;\nmod job_watcher;\nmod squeue_args;\n\nuse app::App;\nuse clap::CommandFactory;\nuse clap::Parser;\n"
  },
  {
    "path": "src/squeue_args.rs",
    "chars": 4906,
    "preview": "use clap::Args;\n/// Doc comment\n#[derive(Args, Debug)]\npub struct SqueueArgs {\n    /// |squeue arg| Comma separated list"
  }
]

About this extraction

This page contains the full source code of the kabouzeid/turm GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 17 files (92.9 KB), approximately 22.9k tokens, and a symbol index with 75 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!