├── docs ├── static │ ├── CNAME │ └── media │ │ └── laio.svg ├── content │ ├── docs │ │ ├── _index.md │ │ ├── getting-started │ │ │ ├── _index.md │ │ │ ├── installing.md │ │ │ ├── quick-start.md │ │ │ └── your-first-session.md │ │ ├── configuration │ │ │ ├── _index.md │ │ │ ├── layouts.md │ │ │ ├── lifecycle.md │ │ │ └── yaml-reference.md │ │ ├── workflow │ │ │ ├── _index.md │ │ │ ├── managing-configs.md │ │ │ ├── local-configs.md │ │ │ └── session-export.md │ │ └── reference │ │ │ ├── _index.md │ │ │ └── cli-commands.md │ └── _index.md ├── config.toml └── templates │ └── index.html ├── src ├── app │ ├── cli │ │ ├── config │ │ │ ├── mod.rs │ │ │ └── cli.rs │ │ ├── session │ │ │ ├── mod.rs │ │ │ └── cli.rs │ │ ├── mod.rs │ │ ├── completion.rs │ │ └── command_line.rs │ ├── manager │ │ ├── config │ │ │ ├── mod.rs │ │ │ ├── tmpl.yaml │ │ │ ├── test.rs │ │ │ └── manager.rs │ │ ├── session │ │ │ ├── mod.rs │ │ │ ├── test.rs │ │ │ └── manager.rs │ │ └── mod.rs │ └── mod.rs ├── lib.rs ├── common │ ├── config │ │ ├── model │ │ │ ├── common.rs │ │ │ ├── mod.rs │ │ │ ├── flex_direction.rs │ │ │ ├── window.rs │ │ │ ├── command.rs │ │ │ ├── script.rs │ │ │ ├── pane.rs │ │ │ └── session.rs │ │ ├── test │ │ │ ├── no_windows.yaml │ │ │ ├── to_yaml.kdl │ │ │ ├── to_yaml.yaml │ │ │ ├── no_panes.yaml │ │ │ ├── multi_focus.yaml │ │ │ ├── multi_zoom.yaml │ │ │ └── valid.yaml │ │ ├── mod.rs │ │ ├── util.rs │ │ ├── validation.rs │ │ └── schema.json │ ├── mod.rs │ ├── muxer │ │ ├── mod.rs │ │ ├── multiplexer.rs │ │ ├── test.rs │ │ └── client.rs │ ├── cmd │ │ ├── mod.rs │ │ ├── test.rs │ │ ├── model.rs │ │ └── shell_runner.rs │ ├── session_info.rs │ └── path.rs ├── muxer │ ├── zellij │ │ ├── mod.rs │ │ ├── client.rs │ │ ├── test.rs │ │ └── mux.rs │ ├── tmux │ │ ├── mod.rs │ │ └── target.rs │ └── mod.rs └── main.rs ├── .gitignore ├── .gitmodules ├── data ├── aarch64-linux.json ├── x86_64-linux.json └── aarch64-darwin.json ├── .envrc ├── .vscode └── settings.json ├── nix └── devenv │ ├── ci.nix │ └── developer.nix ├── .editorconfig ├── .github ├── dependabot.yml ├── workflows │ ├── flakestry-publish-tag.yaml │ ├── ci.yaml │ ├── dependabot-auto-merge.yaml │ ├── docs.yaml │ ├── flakehub-publish-tagged.yml │ ├── update-flake-lock.yaml │ └── release.yaml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── .laio.yaml ├── Makefile ├── .laio-test.yaml ├── Formula └── laio.rb ├── Cargo.toml ├── media └── laio.svg ├── flake.nix ├── README.md └── flake.lock /docs/static/CNAME: -------------------------------------------------------------------------------- 1 | laio.sh 2 | -------------------------------------------------------------------------------- /src/app/cli/config/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod cli; 2 | -------------------------------------------------------------------------------- /src/app/cli/session/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod cli; 2 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | mod common; 3 | mod muxer; 4 | -------------------------------------------------------------------------------- /src/common/config/model/common.rs: -------------------------------------------------------------------------------- 1 | pub(crate) fn default_path() -> String { 2 | ".".to_string() 3 | } 4 | -------------------------------------------------------------------------------- /src/app/cli/mod.rs: -------------------------------------------------------------------------------- 1 | mod command_line; 2 | mod completion; 3 | mod config; 4 | mod session; 5 | pub use command_line::Cli; 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .crush/ 2 | .direnv/ 3 | .devenv/ 4 | docs/public 5 | result 6 | target/ 7 | tarpaulin-* 8 | .pre-commit-config.yaml 9 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "docs/themes/adidoks"] 2 | path = docs/themes/adidoks 3 | url = https://github.com/aaranxu/adidoks.git 4 | -------------------------------------------------------------------------------- /src/app/manager/config/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod manager; 2 | 3 | pub(crate) use manager::ConfigManager; 4 | 5 | #[cfg(test)] 6 | mod test; 7 | -------------------------------------------------------------------------------- /src/app/manager/session/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod manager; 2 | 3 | pub(crate) use manager::SessionManager; 4 | 5 | #[cfg(test)] 6 | mod test; 7 | -------------------------------------------------------------------------------- /src/muxer/zellij/mod.rs: -------------------------------------------------------------------------------- 1 | mod client; 2 | mod model; 3 | mod mux; 4 | 5 | pub(crate) use mux::Zellij; 6 | 7 | #[cfg(test)] 8 | mod test; 9 | -------------------------------------------------------------------------------- /src/app/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod cli; 2 | pub(crate) mod manager; 3 | pub(crate) use manager::ConfigManager; 4 | pub(crate) use manager::SessionManager; 5 | -------------------------------------------------------------------------------- /docs/content/docs/_index.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "Docs" 3 | template = "docs/section.html" 4 | sort_by = "weight" 5 | weight = 1 6 | draft = false 7 | +++ 8 | -------------------------------------------------------------------------------- /src/common/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod cmd; 2 | pub(crate) mod config; 3 | pub(crate) mod muxer; 4 | pub(crate) mod path; 5 | pub(crate) mod session_info; 6 | -------------------------------------------------------------------------------- /src/app/manager/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod config; 2 | pub(crate) mod session; 3 | 4 | pub(crate) use config::ConfigManager; 5 | pub(crate) use session::SessionManager; 6 | -------------------------------------------------------------------------------- /data/aarch64-linux.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "https://github.com/ck3mp3r/laio-cli/releases/download/v0.15.0/laio-0.15.0-aarch64-linux.tgz", 3 | "hash": "0vnpjzcad0ddqlfwcbl5gwgjp9p4jmga0drzcwc93bb1vy35yb4j" 4 | } -------------------------------------------------------------------------------- /data/x86_64-linux.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "https://github.com/ck3mp3r/laio-cli/releases/download/v0.15.0/laio-0.15.0-x86_64-linux.tgz", 3 | "hash": "0i2wyk54pdf9zanhykhba1n2zgx1kdqm2fgigh9iqdla0p0bk1vf" 4 | } -------------------------------------------------------------------------------- /data/aarch64-darwin.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "https://github.com/ck3mp3r/laio-cli/releases/download/v0.15.0/laio-0.15.0-aarch64-darwin.tgz", 3 | "hash": "17gqvwpwskdbal675c9l11bzxi1d0qlqlnsdl9gvxk445px0i77r" 4 | } -------------------------------------------------------------------------------- /src/common/muxer/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod multiplexer; 2 | pub(crate) use multiplexer::Multiplexer; 3 | pub(crate) mod client; 4 | pub(crate) use client::Client; 5 | 6 | #[cfg(test)] 7 | pub(crate) mod test; 8 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # ^ added for shellcheck and file-type detection 3 | 4 | # Watch & reload direnv on change 5 | watch_file nix/devenv/developer.nix 6 | 7 | use flake . --no-pure-eval 8 | use vim 9 | -------------------------------------------------------------------------------- /docs/content/docs/getting-started/_index.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "Getting Started" 3 | description = "Getting started with laio." 4 | template = "docs/section.html" 5 | sort_by = "weight" 6 | weight = 1 7 | draft = false 8 | +++ 9 | -------------------------------------------------------------------------------- /src/common/config/model/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod command; 2 | pub(crate) mod common; 3 | pub(crate) mod flex_direction; 4 | pub(crate) mod pane; 5 | pub(crate) mod script; 6 | pub(crate) mod session; 7 | pub(crate) mod window; 8 | -------------------------------------------------------------------------------- /docs/content/docs/configuration/_index.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "Configuration" 3 | description = "Configure laio sessions with YAML." 4 | template = "docs/section.html" 5 | sort_by = "weight" 6 | weight = 2 7 | draft = false 8 | +++ 9 | -------------------------------------------------------------------------------- /docs/content/docs/workflow/_index.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "Workflow" 3 | description = "Working with laio configurations and sessions." 4 | template = "docs/section.html" 5 | sort_by = "weight" 6 | weight = 3 7 | draft = false 8 | +++ 9 | -------------------------------------------------------------------------------- /docs/content/docs/reference/_index.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "Reference" 3 | description = "Complete reference for laio CLI and features." 4 | template = "docs/section.html" 5 | sort_by = "weight" 6 | weight = 4 7 | draft = false 8 | +++ 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer": { 3 | "files": { 4 | "excludeDirs": [ 5 | "./.git", 6 | "./.github", 7 | "./.direnv", 8 | "./.devenv" 9 | ] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/common/cmd/mod.rs: -------------------------------------------------------------------------------- 1 | mod model; 2 | mod shell_runner; 3 | pub(crate) use model::Cmd; 4 | pub(crate) use model::Runner; 5 | pub(crate) use model::Type; 6 | pub(crate) use shell_runner::ShellRunner; 7 | 8 | #[cfg(test)] 9 | pub(crate) mod test; 10 | -------------------------------------------------------------------------------- /src/muxer/tmux/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod client; 2 | pub(crate) mod mux; 3 | pub(crate) mod parser; 4 | pub(crate) mod target; 5 | 6 | pub(crate) use client::Dimensions; 7 | pub(crate) use mux::Tmux; 8 | pub(crate) use target::Target; 9 | 10 | #[cfg(test)] 11 | pub(crate) mod test; 12 | -------------------------------------------------------------------------------- /nix/devenv/ci.nix: -------------------------------------------------------------------------------- 1 | { 2 | pkgs, 3 | inputs, 4 | ... 5 | }: { 6 | # Minimal CI environment - only essentials for build processes 7 | packages = [ 8 | inputs.fenix.packages.${pkgs.system}.stable.toolchain 9 | ]; 10 | 11 | # No scripts, git hooks, or development tools needed for CI 12 | } 13 | -------------------------------------------------------------------------------- /src/common/config/test/no_windows.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: no_windows 3 | 4 | path: /tmp 5 | 6 | startup: 7 | - command: date 8 | - command: echo 9 | args: 10 | - Hi 11 | 12 | env: 13 | FOO: "BAR" 14 | 15 | shutdown: 16 | - command: date 17 | - command: echo 18 | args: 19 | - Bye 20 | 21 | windows: 22 | -------------------------------------------------------------------------------- /src/common/config/mod.rs: -------------------------------------------------------------------------------- 1 | mod model; 2 | pub(crate) mod util; 3 | mod validation; 4 | 5 | pub(crate) use model::command::Command; 6 | pub(crate) use model::flex_direction::FlexDirection; 7 | pub(crate) use model::pane::Pane; 8 | pub(crate) use model::script::Script; 9 | pub(crate) use model::session::Session; 10 | pub(crate) use model::window::Window; 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps maintain consistent coding styles for multiple developers 2 | # working on the same project across various editors and IDEs. 3 | 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | 14 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use env_logger::Builder; 3 | use laio::app::cli::Cli; 4 | use miette::Result; 5 | 6 | fn main() -> Result<()> { 7 | let cli = Cli::parse(); 8 | 9 | Builder::new() 10 | .filter_level(cli.verbose.log_level_filter()) 11 | .format_timestamp(None) 12 | .init(); 13 | 14 | cli.run() 15 | } 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | schedule: 5 | interval: "weekly" 6 | directory: "/" 7 | commit-message: 8 | prefix: "deps" 9 | - package-ecosystem: "cargo" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | commit-message: 14 | prefix: "deps" 15 | -------------------------------------------------------------------------------- /src/common/config/model/flex_direction.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq)] 4 | pub enum FlexDirection { 5 | #[serde(rename = "row")] 6 | #[default] 7 | Row, 8 | #[serde(rename = "column")] 9 | Column, 10 | } 11 | 12 | impl FlexDirection { 13 | pub(crate) fn is_default(&self) -> bool { 14 | *self == FlexDirection::Row 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.laio.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: laio 3 | 4 | path: . 5 | 6 | windows: 7 | - name: code 8 | flex_direction: column 9 | panes: 10 | - commands: 11 | - command: nvim 12 | 13 | - name: misc 14 | panes: 15 | - flex: 1 16 | - flex: 1 17 | flex_direction: column 18 | panes: 19 | - flex: 1 20 | path: docs 21 | commands: 22 | - command: ^sleep 23 | args: 24 | - 5 25 | - command: zola 26 | args: 27 | - serve 28 | - flex: 4 29 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | act-test-release: 2 | @act workflow_dispatch \ 3 | --rm \ 4 | # --container-architecture linux/amd64 \ 5 | -s GITHUB_TOKEN=${GITHUB_TOKEN} \ 6 | -s ACTIONS_RUNTIME_TOKEN=${GITHUB_TOKEN} \ 7 | -P ubuntu-latest=catthehacker/ubuntu:js-latest \ 8 | -W .github/workflows/release.yaml 9 | 10 | act-test: 11 | act push \ 12 | --pull=false \ 13 | --container-architecture linux/arm64 \ 14 | -s GITHUB_TOKEN=${GITHUB_TOKEN} \ 15 | -s ACTIONS_RUNTIME_TOKEN=${GITHUB_TOKEN} \ 16 | -P ubuntu-latest=catthehacker/ubuntu:act-latest \ 17 | -W .github/workflows/test.yaml \ 18 | -j test 19 | -------------------------------------------------------------------------------- /.laio-test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: laio-test-session 3 | 4 | path: . 5 | 6 | windows: 7 | - name: code 8 | flex_direction: column 9 | panes: 10 | - commands: 11 | - command: nvim 12 | 13 | - name: misc 14 | panes: 15 | - flex: 1 16 | - flex: 1 17 | flex_direction: column 18 | panes: 19 | - flex: 1 20 | path: docs 21 | commands: 22 | - command: ^sleep 23 | args: 24 | - 5 25 | - command: zola 26 | args: 27 | - serve 28 | - flex: 4 29 | -------------------------------------------------------------------------------- /.github/workflows/flakestry-publish-tag.yaml: -------------------------------------------------------------------------------- 1 | name: "Publish a flake to flakestry" 2 | on: 3 | # push: 4 | # tags: 5 | # - "v?[0-9]+.[0-9]+.[0-9]+" 6 | # - "v?[0-9]+.[0-9]+" 7 | workflow_dispatch: 8 | inputs: 9 | tag: 10 | description: "The existing tag to publish" 11 | type: "string" 12 | required: true 13 | jobs: 14 | publish-flake: 15 | runs-on: ubuntu-latest 16 | permissions: 17 | id-token: "write" 18 | contents: "read" 19 | steps: 20 | - uses: flakestry/flakestry-publish@main 21 | with: 22 | version: "${{ inputs.tag || github.ref_name }}" 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches-ignore: 7 | - main 8 | paths: 9 | - "**/*.nix" 10 | - "Cargo.lock" 11 | - "Cargo.toml" 12 | - "flake.lock" 13 | - "flake.nix" 14 | - "src/**" 15 | - "tests/**" 16 | jobs: 17 | test: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Code checkout 21 | uses: actions/checkout@v6 22 | 23 | - name: Install Determinate Nix 24 | uses: DeterminateSystems/nix-installer-action@main 25 | 26 | - name: Run tests in Nix develop shell 27 | run: nix build .#laio -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Overview 11 | Brief summary of the feature. 12 | 13 | ## Detailed Description 14 | A more detailed explanation of the feature and how it should work. 15 | 16 | ## Benefits 17 | Explain why this feature would be beneficial. Include potential use cases and any problems it would solve. 18 | 19 | ## Alternatives 20 | Describe any alternative solutions or features you've considered. 21 | 22 | ## Additional Context 23 | Add any other context about the feature request here. 24 | -------------------------------------------------------------------------------- /src/common/muxer/multiplexer.rs: -------------------------------------------------------------------------------- 1 | use miette::Result; 2 | 3 | use crate::common::{config::Session, session_info::SessionInfo}; 4 | pub(crate) trait Multiplexer { 5 | fn start( 6 | &self, 7 | session: &Session, 8 | config: &str, 9 | skip_attach: bool, 10 | skip_cmds: bool, 11 | ) -> Result<()>; 12 | fn stop( 13 | &self, 14 | name: &Option, 15 | skip_cmds: bool, 16 | stop_all: bool, 17 | stop_other: bool, 18 | ) -> Result<()>; 19 | fn list_sessions(&self) -> Result>; 20 | fn switch(&self, name: &str, skip_attach: bool) -> Result; 21 | fn get_session(&self) -> Result; 22 | } 23 | -------------------------------------------------------------------------------- /docs/content/docs/getting-started/installing.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "Installing" 3 | description = "Installing laio." 4 | draft = false 5 | weight = 10 6 | sort_by = "weight" 7 | template = "docs/page.html" 8 | 9 | [extra] 10 | lead = "Supported flavors are Linux and Mac (aarch64 and x86_64)." 11 | toc = true 12 | top = false 13 | +++ 14 | 15 | ## Nix 16 | 17 | ```bash 18 | nix profile install "github:ck3mp3r/laio-cli" 19 | ``` 20 | 21 | ## Homebrew 22 | 23 | ```bash 24 | brew tap ck3mp3r/laio-cli https://github.com/ck3mp3r/laio-cli/ 25 | 26 | brew install laio 27 | ``` 28 | 29 | ## Download 30 | 31 | Download the binary suitable for your system from the [Release Page](https://github.com/ck3mp3r/laio-cli/releases) 32 | and place it in your `PATH`. 33 | -------------------------------------------------------------------------------- /src/common/config/util.rs: -------------------------------------------------------------------------------- 1 | pub(crate) fn gcd(a: usize, b: usize) -> usize { 2 | if b == 0 { 3 | a 4 | } else { 5 | gcd(b, a % b) 6 | } 7 | } 8 | 9 | pub(crate) fn gcd_vec(numbers: &[usize]) -> usize { 10 | if numbers.is_empty() || numbers.iter().all(|&x| x == 0) { 11 | return 1; // Return 1 if vector is empty or all zeros 12 | } 13 | numbers.iter().fold(0, |acc, &x| gcd(acc, x)) 14 | } 15 | 16 | // Function to round a number to the nearest multiple of base 17 | pub(crate) fn round(number: usize) -> usize { 18 | let base = 5; 19 | let remainder = number % base; 20 | if remainder >= base / 2 { 21 | number + base - remainder 22 | } else { 23 | number - remainder 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Overview 11 | Brief summary of the bug. 12 | 13 | ## Steps to Reproduce 14 | 1. Step one 15 | 2. Step two 16 | 3. Step three 17 | 18 | ## Expected Behavior 19 | Describe what you expected to happen. 20 | 21 | ## Actual Behavior 22 | Describe what actually happened. 23 | 24 | ## Screenshots/Logs 25 | If applicable, add screenshots or paste log messages here. 26 | 27 | ## Environment 28 | - OS: [e.g., Ubuntu 24.04, macOS Big Sur] 29 | - laio-cli Version: [e.g., 0.10.2] 30 | - Other relevant details 31 | 32 | ## Additional Context 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /src/common/config/test/to_yaml.kdl: -------------------------------------------------------------------------------- 1 | layout { 2 | cwd "/tmp" 3 | tab name="code" split_direction="vertical" { 4 | pane size="100%" cwd="." command="$EDITOR" { 5 | args "foo.yaml" 6 | } 7 | } 8 | tab name="misc" split_direction="vertical" { 9 | pane size="50%"{ 10 | pane size="33%" split_direction="vertical" { 11 | pane size="33%" cwd="one" command="foo" 12 | pane size="33%" cwd="two" 13 | pane size="33%" cwd="three" 14 | } 15 | pane size="67%" 16 | } 17 | pane size="50%" { 18 | pane size="50%" cwd="four" 19 | pane size="25%" cwd="five" 20 | pane size="25%" cwd="six" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yaml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == 'ck3mp3r/laio-cli' 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v2.4.0 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | - name: Enable auto-merge for Dependabot PRs 19 | if: steps.metadata.outputs.update-type == 'version-update:semver-patch' 20 | run: gh pr merge --auto --squash "$PR_URL" 21 | env: 22 | PR_URL: ${{github.event.pull_request.html_url}} 23 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 24 | -------------------------------------------------------------------------------- /src/common/config/test/to_yaml.yaml: -------------------------------------------------------------------------------- 1 | name: valid 2 | path: /tmp 3 | windows: 4 | - name: code 5 | panes: 6 | - flex: 1 7 | commands: 8 | - command: $EDITOR 9 | args: 10 | - foo.yaml 11 | - name: misc 12 | panes: 13 | - flex_direction: column 14 | flex: 1 15 | panes: 16 | - flex: 1 17 | panes: 18 | - flex: 1 19 | path: one 20 | commands: 21 | - command: foo 22 | - flex: 1 23 | path: two 24 | - flex: 1 25 | path: three 26 | - flex: 2 27 | - flex_direction: column 28 | flex: 1 29 | panes: 30 | - flex: 2 31 | path: four 32 | - flex: 1 33 | path: five 34 | - flex: 1 35 | path: six 36 | -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | # push: 4 | # paths: 5 | # - docs/* 6 | 7 | name: Build and deploy GH Pages 8 | jobs: 9 | check: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | with: 14 | submodules: "true" 15 | - uses: DeterminateSystems/nix-installer-action@main 16 | - run: | 17 | cd docs 18 | nix develop --no-pure-eval --command zola check 19 | 20 | build: 21 | runs-on: ubuntu-latest 22 | needs: check 23 | if: github.ref == 'refs/heads/main' 24 | steps: 25 | - uses: actions/checkout@v6 26 | with: 27 | submodules: "true" 28 | - name: build_and_deploy 29 | uses: shalzz/zola-deploy-action@v0.21.0 30 | env: 31 | BUILD_DIR: docs 32 | PAGES_BRANCH: gh_pages 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /.github/workflows/flakehub-publish-tagged.yml: -------------------------------------------------------------------------------- 1 | name: "Publish tags to FlakeHub" 2 | on: 3 | # push: 4 | # tags: 5 | # - "v?[0-9]+.[0-9]+.[0-9]+" 6 | # - "v?[0-9]+.[0-9]+" 7 | workflow_dispatch: 8 | inputs: 9 | tag: 10 | description: "The existing tag to publish to FlakeHub" 11 | type: "string" 12 | required: true 13 | jobs: 14 | flakehub-publish: 15 | runs-on: "ubuntu-latest" 16 | permissions: 17 | id-token: "write" 18 | contents: "read" 19 | steps: 20 | - uses: "actions/checkout@v6" 21 | with: 22 | ref: "${{ (inputs.tag != null) && format('refs/tags/{0}', inputs.tag) || '' }}" 23 | - uses: "DeterminateSystems/nix-installer-action@main" 24 | - uses: "DeterminateSystems/flakehub-push@main" 25 | with: 26 | visibility: "public" 27 | name: "ck3mp3r/laio-cli" 28 | tag: "${{ inputs.tag }}" 29 | -------------------------------------------------------------------------------- /.github/workflows/update-flake-lock.yaml: -------------------------------------------------------------------------------- 1 | name: update-flake-lock 2 | on: 3 | workflow_dispatch: # allows manual triggering 4 | schedule: 5 | - cron: "0 0 * * 0" # runs weekly on Sunday at 00:00 6 | - cron: "30 0 * * 0" # runs on Sunday at 00:30 7 | 8 | jobs: 9 | lockfile: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.event_name != 'pull_request' }} 12 | steps: 13 | - uses: actions/create-github-app-token@v2 14 | id: token 15 | with: 16 | app-id: ${{ secrets.BOT_ID }} 17 | private-key: ${{ secrets.BOT }} 18 | 19 | - name: Checkout repository 20 | uses: actions/checkout@v6 21 | with: 22 | token: ${{ steps.token.outputs.token }} 23 | 24 | - name: update flake lock 25 | uses: ck3mp3r/actions/update-flake-lock@main 26 | with: 27 | github-token: ${{ steps.token.outputs.token }} 28 | checks-required: "Test" 29 | -------------------------------------------------------------------------------- /src/common/config/model/window.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use serde_valid::Validate; 3 | 4 | use super::{flex_direction::FlexDirection, pane::Pane}; 5 | 6 | #[derive(Debug, Deserialize, Serialize, Validate)] 7 | pub(crate) struct Window { 8 | #[validate( 9 | min_length = 3, 10 | message = "Window names should have at least 3 characters." 11 | )] 12 | pub(crate) name: String, 13 | #[serde(default, skip_serializing_if = "FlexDirection::is_default")] 14 | pub(crate) flex_direction: FlexDirection, 15 | #[serde(default, skip_serializing_if = "Vec::is_empty")] 16 | #[validate] 17 | pub(crate) panes: Vec, 18 | } 19 | 20 | impl Window { 21 | pub(crate) fn first_leaf_path(&self) -> Option<&String> { 22 | for pane in &self.panes { 23 | if let Some(path) = pane.first_leaf_path() { 24 | return Some(path); 25 | } 26 | } 27 | None 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/common/config/test/no_panes.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: no_panes 3 | 4 | path: /tmp 5 | 6 | startup: 7 | - command: date 8 | - command: echo 9 | args: 10 | - Hi 11 | 12 | env: 13 | FOO: "BAR" 14 | 15 | shutdown: 16 | - command: date 17 | - command: echo 18 | args: 19 | - Bye 20 | 21 | windows: 22 | - name: code 23 | flex_direction: column 24 | 25 | - name: infrastructure 26 | path: . 27 | flex_direction: column 28 | panes: 29 | - flex: 1 30 | path: one 31 | commands: 32 | - command: echo 33 | args: 34 | - "hello again 1" 35 | - flex: 2 36 | path: two 37 | commands: 38 | - command: echo 39 | args: 40 | - "hello again 2" 41 | - flex: 1 42 | path: three 43 | commands: 44 | - command: clear 45 | - command: echo 46 | args: 47 | - "hello again 3" 48 | -------------------------------------------------------------------------------- /src/common/muxer/test.rs: -------------------------------------------------------------------------------- 1 | use crate::common::{config::Session, session_info::SessionInfo}; 2 | use miette::Result; 3 | use mockall::mock; 4 | 5 | use super::Multiplexer; 6 | 7 | mock! { 8 | pub Multiplexer {} 9 | 10 | impl Multiplexer for Multiplexer { 11 | fn start( 12 | &self, 13 | session: &Session, 14 | config: &str, 15 | skip_attach: bool, 16 | skip_cmds: bool, 17 | ) -> Result<()>; 18 | 19 | fn stop( 20 | &self, 21 | name: &Option, 22 | skip_cmds: bool, 23 | stop_all: bool, 24 | stop_other: bool, 25 | ) -> Result<()>; 26 | 27 | fn list_sessions(&self) -> Result>; 28 | 29 | fn switch( 30 | &self, 31 | name: &str, 32 | skip_attach: bool, 33 | ) -> Result; 34 | 35 | fn get_session(&self) -> Result; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/manager/config/tmpl.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: { name } 3 | 4 | startup: 5 | - command: echo 6 | args: 7 | - "starting { name } session" # add commands here to run on session startup 8 | 9 | shutdown: 10 | - command: echo 11 | args: 12 | - "stopping { name } session" # add commands here to run on session shutdown 13 | 14 | path: { path } # change this to suit 15 | 16 | windows: 17 | - name: code 18 | flex_direction: column # panes are above one another, if ommited defaults to row where panes are side by side 19 | panes: 20 | - name: Editor 21 | flex: 5 22 | commands: 23 | - command: $EDITOR 24 | focus: true #ensure editor pane is selected 25 | - flex: 2 26 | 27 | - name: misc 28 | flex_direction: row 29 | panes: 30 | - flex: 1 31 | - flex: 1 32 | path: . # optional relative or absolute path 33 | flex_direction: column 34 | panes: 35 | - flex: 1 36 | - flex: 5 37 | -------------------------------------------------------------------------------- /Formula/laio.rb: -------------------------------------------------------------------------------- 1 | class Laio < Formula 2 | desc "laio - a simple, flexbox-inspired, layout & session manager for tmux." 3 | homepage "https://laio.sh" 4 | version "0.14.3" 5 | 6 | depends_on "tmux" 7 | 8 | on_macos do 9 | if Hardware::CPU.arm? 10 | url "https://github.com/ck3mp3r/laio-cli/releases/download/v0.14.3/laio-0.14.3-aarch64-darwin.tgz" 11 | sha256 "33643d682abd23761c84b6c3bc9ab4a1f026a401c68c6f3db1a812b74750d977" 12 | else 13 | odie "Intel Macs are no longer supported. Please use an Apple Silicon Mac." 14 | end 15 | end 16 | 17 | on_linux do 18 | if Hardware::CPU.intel? 19 | url "https://github.com/ck3mp3r/laio-cli/releases/download/v0.14.3/laio-0.14.3-x86_64-linux.tgz" 20 | sha256 "b7302dd5c42250474923230771239276471d47888516e40e1320650befcb69a9" 21 | elsif Hardware::CPU.arm? 22 | url "https://github.com/ck3mp3r/laio-cli/releases/download/v0.14.3/laio-0.14.3-aarch64-linux.tgz" 23 | sha256 "50706fc5a04fb8ff85c8bc0d2f9c0d21d6233a8b9a051a01a61e25b6708313d6" 24 | end 25 | end 26 | 27 | def install 28 | bin.install "laio" 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /src/muxer/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::common::muxer::Multiplexer; 2 | use clap::ValueEnum; 3 | use miette::{bail, Result}; 4 | use std::env; 5 | pub(crate) mod tmux; 6 | pub(crate) mod zellij; 7 | pub(crate) use tmux::Tmux; 8 | pub(crate) use zellij::Zellij; 9 | 10 | #[derive(Debug, Clone, ValueEnum)] 11 | pub(crate) enum Muxer { 12 | Tmux, 13 | Zellij, 14 | } 15 | 16 | pub(crate) fn create_muxer(muxer: &Option) -> Result> { 17 | let muxer = match muxer { 18 | Some(m) => m.clone(), 19 | None => match env::var("LAIO_MUXER") { 20 | Ok(env_value) => match env_value.to_lowercase().as_str() { 21 | "tmux" => Muxer::Tmux, 22 | "zellij" => Muxer::Zellij, 23 | _ => bail!(format!( 24 | "Unsupported muxer specified in LAIO_MUXER: '{}'", 25 | env_value 26 | )), 27 | }, 28 | Err(_) => Muxer::Tmux, 29 | }, 30 | }; 31 | 32 | match muxer { 33 | Muxer::Tmux => Ok(Box::new(Tmux::new())), 34 | Muxer::Zellij => Ok(Box::new(Zellij::new())), 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/common/config/test/multi_focus.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: multi_zoom 3 | 4 | path: /tmp 5 | 6 | windows: 7 | - name: code 8 | flex_direction: column 9 | panes: 10 | - flex: 1 11 | commands: 12 | - command: echo 13 | args: 14 | - "hello" 15 | flex_direction: row 16 | panes: 17 | - flex: 1 18 | style: bg=red,fg=default 19 | - flex: 2 20 | focus: true 21 | - flex: 1 22 | path: src 23 | commands: 24 | - command: echo 25 | args: 26 | - "hello again" 27 | focus: true 28 | 29 | - name: infrastructure 30 | path: . 31 | flex_direction: column 32 | panes: 33 | - flex: 1 34 | path: one 35 | commands: 36 | - command: echo 37 | args: 38 | - "hello again 1" 39 | - flex: 2 40 | path: two 41 | commands: 42 | - command: echo 43 | args: 44 | - "hello again 2" 45 | - flex: 1 46 | path: three 47 | commands: 48 | - command: clear 49 | - command: echo 50 | args: 51 | - "hello again 3" 52 | -------------------------------------------------------------------------------- /src/common/config/test/multi_zoom.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: multi_zoom 3 | 4 | path: /tmp 5 | 6 | windows: 7 | - name: code 8 | flex_direction: column 9 | panes: 10 | - flex: 1 11 | commands: 12 | - command: echo 13 | args: 14 | - "hello" 15 | flex_direction: row 16 | panes: 17 | - flex: 1 18 | style: bg=red,fg=default 19 | - flex: 2 20 | zoom: true 21 | - flex: 1 22 | path: src 23 | commands: 24 | - command: echo 25 | args: 26 | - "hello again" 27 | zoom: true 28 | 29 | - name: infrastructure 30 | path: . 31 | flex_direction: column 32 | panes: 33 | - flex: 1 34 | path: one 35 | commands: 36 | - command: echo 37 | args: 38 | - "hello again 1" 39 | - flex: 2 40 | path: two 41 | commands: 42 | - command: echo 43 | args: 44 | - "hello again 2" 45 | - flex: 1 46 | path: three 47 | commands: 48 | - command: clear 49 | - command: echo 50 | args: 51 | - "hello again 3" 52 | -------------------------------------------------------------------------------- /docs/content/docs/getting-started/quick-start.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "Quick Start" 3 | description = "Get up and running with laio in minutes." 4 | draft = false 5 | weight = 15 6 | sort_by = "weight" 7 | template = "docs/page.html" 8 | 9 | [extra] 10 | toc = true 11 | top = false 12 | +++ 13 | 14 | ## Create Your First Session 15 | 16 | The fastest way to get started with laio is to create a configuration and start it: 17 | 18 | ```bash 19 | # Create a new configuration 20 | laio config create myproject 21 | 22 | # Start the session 23 | laio start myproject 24 | ``` 25 | 26 | This creates a default configuration with: 27 | - An editor window with your `$EDITOR` 28 | - A terminal window with two vertically split panes 29 | 30 | ## View Available Sessions 31 | 32 | List all sessions and configurations: 33 | 34 | ```bash 35 | laio list 36 | ``` 37 | 38 | Active sessions are marked with `*`. 39 | 40 | ## Stop a Session 41 | 42 | Stop the session when you're done: 43 | 44 | ```bash 45 | laio stop myproject 46 | ``` 47 | 48 | ## What's Next? 49 | 50 | - Learn more about [creating configurations](/docs/getting-started/your-first-session) 51 | - Explore [configuration options](/docs/configuration/yaml-reference) 52 | - Understand [workflow patterns](/docs/workflow/managing-configs) 53 | -------------------------------------------------------------------------------- /nix/devenv/developer.nix: -------------------------------------------------------------------------------- 1 | { 2 | pkgs, 3 | inputs, 4 | ... 5 | }: { 6 | # Directly add fenix toolchain - no language modules 7 | packages = [ 8 | inputs.fenix.packages.${pkgs.system}.stable.toolchain 9 | pkgs.cargo-tarpaulin 10 | pkgs.zola 11 | pkgs.act 12 | ]; 13 | 14 | scripts = { 15 | checks.exec = "nix flake check"; 16 | tests.exec = "cargo test"; 17 | clippy.exec = "cargo clippy $@"; 18 | clean.exec = "cargo clean"; 19 | coverage.exec = "cargo tarpaulin --out Html"; 20 | }; 21 | 22 | git-hooks.hooks = { 23 | fix-whitespace = { 24 | enable = true; 25 | name = "Fix trailing whitespace"; 26 | entry = "${pkgs.writeShellScript "fix-whitespace" '' 27 | # Fix trailing whitespace in staged files 28 | git diff --cached --name-only --diff-filter=ACM | while read file; do 29 | if [ -f "$file" ]; then 30 | ${pkgs.gnused}/bin/sed -i 's/[[:space:]]*$//' "$file" 31 | git add "$file" 32 | fi 33 | done 34 | ''}"; 35 | language = "system"; 36 | stages = ["pre-commit"]; 37 | pass_filenames = false; 38 | }; 39 | 40 | pre-push = { 41 | enable = true; 42 | entry = "cargo test -- --include-ignored"; 43 | stages = ["pre-push"]; 44 | }; 45 | }; 46 | 47 | enterShell = '' 48 | echo "laio devshell" 49 | ''; 50 | } 51 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "laio" 3 | version = "0.15.0" 4 | edition = "2021" 5 | description = "A simple flexbox-like layout manager for tmux." 6 | homepage = "https://github.com/ck3mp3r/laio-cli" 7 | 8 | [lib] 9 | path = "src/lib.rs" 10 | 11 | [profile.release] 12 | opt-level = "z" 13 | lto = true 14 | codegen-units = 1 15 | debug = false 16 | panic = "abort" 17 | strip = "symbols" 18 | 19 | [dependencies] 20 | clap-verbosity-flag = "3.0.4" 21 | clap_complete = "4.5.61" 22 | clap_complete_nushell = "4.5.10" 23 | crossterm = "0.29.0" 24 | env_logger = "0.11.8" 25 | inquire = "0.9.1" 26 | lazy_static = "1.5.0" 27 | log = "0.4.29" 28 | regex = "1.12.2" 29 | serde_json = "1.0.145" 30 | serde_yaml = "0.9.34" 31 | sha2 = "0.10.9" 32 | sysinfo = "0.37.2" 33 | tabled = "0.20.0" 34 | 35 | [dependencies.tokio] 36 | version = "1.48.0" 37 | features = [ 38 | "rt-multi-thread", 39 | "macros", 40 | "time", 41 | ] 42 | 43 | [dependencies.clap] 44 | version = "4.5.53" 45 | features = ["derive"] 46 | 47 | [dependencies.kdl] 48 | version = "6.5.0" 49 | features = ["v1"] 50 | 51 | [dependencies.miette] 52 | version = "7.6.0" 53 | features = ["fancy"] 54 | 55 | [dependencies.serde] 56 | version = "1.0.228" 57 | features = ["derive"] 58 | 59 | [dependencies.serde_valid] 60 | version = "2.0.0" 61 | features = ["yaml"] 62 | 63 | [dev-dependencies] 64 | lazy_static = "1.5.0" 65 | mockall = "0.14.0" 66 | -------------------------------------------------------------------------------- /src/common/cmd/test.rs: -------------------------------------------------------------------------------- 1 | use super::{Cmd, Runner, Type}; 2 | use miette::Result; 3 | use mockall::mock; 4 | 5 | mock! { 6 | pub CmdUnitMock {} 7 | 8 | impl Cmd<()> for CmdUnitMock { 9 | fn run(&self, cmd: &Type) -> Result<()>; 10 | } 11 | } 12 | 13 | mock! { 14 | pub CmdStringMock {} 15 | 16 | impl Cmd for CmdStringMock { 17 | fn run(&self, cmd: &Type) -> Result; 18 | } 19 | } 20 | 21 | mock! { 22 | pub CmdBoolMock {} 23 | 24 | impl Cmd for CmdBoolMock { 25 | fn run(&self, cmd: &Type) -> Result; 26 | } 27 | } 28 | 29 | pub struct RunnerMock { 30 | pub cmd_unit: MockCmdUnitMock, 31 | pub cmd_string: MockCmdStringMock, 32 | pub cmd_bool: MockCmdBoolMock, 33 | } 34 | 35 | impl Clone for RunnerMock { 36 | fn clone(&self) -> Self { 37 | Self { 38 | cmd_unit: MockCmdUnitMock::new(), 39 | cmd_string: MockCmdStringMock::new(), 40 | cmd_bool: MockCmdBoolMock::new(), 41 | } 42 | } 43 | } 44 | 45 | impl Runner for RunnerMock {} 46 | 47 | impl Cmd<()> for RunnerMock { 48 | fn run(&self, cmd: &Type) -> Result<()> { 49 | self.cmd_unit.run(cmd) 50 | } 51 | } 52 | 53 | impl Cmd for RunnerMock { 54 | fn run(&self, cmd: &Type) -> Result { 55 | self.cmd_string.run(cmd) 56 | } 57 | } 58 | 59 | impl Cmd for RunnerMock { 60 | fn run(&self, cmd: &Type) -> Result { 61 | self.cmd_bool.run(cmd) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/common/muxer/client.rs: -------------------------------------------------------------------------------- 1 | use std::env::{self, current_dir}; 2 | 3 | use miette::Result; 4 | use miette::{miette, IntoDiagnostic}; 5 | 6 | use crate::common::cmd::Runner; 7 | use crate::common::cmd::Type; 8 | use crate::common::config::Command as ConfigCommand; 9 | use crate::common::path::to_absolute_path; 10 | 11 | pub(crate) trait Client { 12 | fn get_runner(&self) -> &R; 13 | 14 | fn run_commands(&self, commands: &[ConfigCommand], cwd: &str) -> Result<()> { 15 | if commands.is_empty() { 16 | log::info!("No commands to run..."); 17 | return Ok(()); 18 | } 19 | 20 | log::info!("Running commands..."); 21 | 22 | let current_dir = current_dir().into_diagnostic()?; 23 | 24 | log::trace!("Current directory: {current_dir:?}"); 25 | log::trace!("Changing to: {cwd:?}"); 26 | 27 | env::set_current_dir(to_absolute_path(cwd)?) 28 | .map_err(|_| miette!("Unable to change to directory: {:?}", &cwd))?; 29 | 30 | for cmd in commands { 31 | let _res: String = self 32 | .get_runner() 33 | .run(&Type::Verbose(cmd.to_process_command())) 34 | .map_err(|_| miette!("Failed to run command: {}", cmd.to_string()))?; 35 | } 36 | 37 | env::set_current_dir(¤t_dir) 38 | .map_err(|_| miette!("Failed to restore original directory {:?}", current_dir))?; 39 | 40 | log::info!("Completed commands."); 41 | 42 | Ok(()) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /docs/content/_index.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "laio - a simple, flexbox-inspired, layout & session manager for tmux." 3 | description = "Welcome to laio." 4 | sort_by = "weight" 5 | weight = 1 6 | template = "index.html" 7 | 8 | # The homepage contents 9 | [extra] 10 | lead = '' 11 | url = "/docs/getting-started/installing" 12 | url_button = "Get started" 13 | repo_version = "GitHub v0.15.0" 14 | repo_license = "Apache License." 15 | repo_url = "https://github.com/ck3mp3r/laio-cli" 16 | 17 | # Menu items 18 | [[extra.menu.main]] 19 | name = "Docs" 20 | section = "docs" 21 | url = "/docs/getting-started/installing" 22 | weight = 10 23 | 24 | [[extra.list]] 25 | title = "Flexbox-Inspired Layouts" 26 | content = 'Define complex multi-pane layouts with intuitive row/column flex directions and proportional sizing.' 27 | 28 | [[extra.list]] 29 | title = "Session Lifecycle" 30 | content = 'Manage startup/shutdown hooks with commands and embedded scripts for complete session control.' 31 | 32 | [[extra.list]] 33 | title = "Dual Config Modes" 34 | content = 'Use global configs (~/.config/laio) or project-local .laio.yaml files for flexible workflows.' 35 | 36 | [[extra.list]] 37 | title = "Session Export" 38 | content = 'Serialize existing tmux sessions to YAML format for sharing and templating.' 39 | 40 | [[extra.list]] 41 | title = "Supported Platforms" 42 | content = 'Mac & Linux, x86 & arm64' 43 | 44 | [[extra.list]] 45 | title = "Built with Nix" 46 | content = '' 47 | 48 | +++ 49 | -------------------------------------------------------------------------------- /src/app/cli/completion.rs: -------------------------------------------------------------------------------- 1 | use clap::{Args, CommandFactory, ValueEnum}; 2 | use clap_complete::{generate, Shell}; 3 | use clap_complete_nushell::Nushell; 4 | use miette::Result; 5 | use std::io; 6 | 7 | use crate::app::cli::Cli as RootCli; 8 | 9 | #[derive(Args, Debug)] 10 | #[command()] 11 | pub struct Cli { 12 | #[arg(value_enum)] 13 | shell: ShellWrapper, 14 | } 15 | 16 | #[derive(Debug, Clone)] 17 | enum ShellWrapper { 18 | Builtin(Shell), 19 | Nushell, 20 | } 21 | 22 | impl ValueEnum for ShellWrapper { 23 | fn value_variants<'a>() -> &'a [Self] { 24 | &[ 25 | Self::Builtin(Shell::Bash), 26 | Self::Builtin(Shell::Elvish), 27 | Self::Builtin(Shell::Fish), 28 | Self::Builtin(Shell::PowerShell), 29 | Self::Builtin(Shell::Zsh), 30 | Self::Nushell, 31 | ] 32 | } 33 | 34 | fn to_possible_value(&self) -> Option { 35 | match self { 36 | Self::Builtin(shell) => shell.to_possible_value(), 37 | Self::Nushell => Some(clap::builder::PossibleValue::new("nushell")), 38 | } 39 | } 40 | } 41 | 42 | impl Cli { 43 | pub fn run(&self) -> Result<()> { 44 | let mut cmd = RootCli::command(); 45 | let bin_name = cmd.get_name().to_string(); 46 | 47 | match &self.shell { 48 | ShellWrapper::Nushell => generate(Nushell, &mut cmd, bin_name, &mut io::stdout()), 49 | ShellWrapper::Builtin(shell) => generate(*shell, &mut cmd, bin_name, &mut io::stdout()), 50 | } 51 | Ok(()) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/common/config/test/valid.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: valid 3 | 4 | path: /tmp 5 | 6 | startup: 7 | - command: date 8 | - command: echo 9 | args: 10 | - Hi 11 | 12 | startup_script: | 13 | #!/usr/bin/env bash 14 | 15 | echo "Hello from startup_script" 16 | env: 17 | FOO: bar 18 | 19 | shutdown: 20 | - command: date 21 | - command: echo 22 | args: 23 | - Bye 24 | 25 | windows: 26 | - name: code 27 | flex_direction: column 28 | panes: 29 | - flex: 1 30 | env: 31 | BAR: baz 32 | commands: 33 | - command: echo 34 | args: 35 | - hello again 36 | script: | 37 | #!/usr/bin/env bash 38 | echo "Hello from pane script" 39 | 40 | flex_direction: row 41 | name: foo 42 | panes: 43 | - flex: 1 44 | focus: true 45 | style: bg=red,fg=default 46 | - flex: 2 47 | env: 48 | FOO: baz 49 | - flex: 1 50 | path: src 51 | commands: 52 | - command: echo 53 | args: 54 | - hello again 55 | zoom: true 56 | 57 | - name: infrastructure 58 | path: . 59 | flex_direction: column 60 | panes: 61 | - flex: 1 62 | path: one 63 | commands: 64 | - command: echo 65 | args: 66 | - hello again 1 67 | - flex: 2 68 | path: two 69 | commands: 70 | - command: echo 71 | args: 72 | - hello again 2 73 | - flex: 1 74 | path: three 75 | commands: 76 | - command: clear 77 | - command: echo 78 | args: 79 | - hello again 3 80 | -------------------------------------------------------------------------------- /src/muxer/tmux/target.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 4 | pub(crate) struct Target { 5 | pub session: String, 6 | pub window: Option, 7 | pub pane: Option, 8 | } 9 | 10 | #[macro_export] 11 | macro_rules! tmux_target { 12 | ($session:expr) => { 13 | Target::new($session) 14 | }; 15 | ($session:expr, $window:expr) => { 16 | Target::new($session).window($window) 17 | }; 18 | ($session:expr, $window:expr, $pane:expr) => { 19 | Target::new($session).window($window).pane($pane) 20 | }; 21 | } 22 | 23 | impl fmt::Display for Target { 24 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 25 | let mut target = String::new(); 26 | 27 | target.push_str(self.session.to_string().as_str()); 28 | 29 | if let Some(window) = &self.window { 30 | if !target.is_empty() { 31 | target.push(':'); 32 | } 33 | target.push_str(window); 34 | } 35 | 36 | if let Some(pane) = &self.pane { 37 | if !target.is_empty() { 38 | target.push('.'); 39 | } 40 | target.push_str(pane); 41 | } 42 | 43 | write!(f, "{target}") 44 | } 45 | } 46 | 47 | impl Target { 48 | pub fn new(session: &str) -> Self { 49 | Target { 50 | session: session.to_string(), 51 | window: None, 52 | pane: None, 53 | } 54 | } 55 | 56 | pub fn window(mut self, window: &str) -> Self { 57 | self.window = Some(window.to_string()); 58 | self 59 | } 60 | 61 | pub fn pane(mut self, pane: &str) -> Self { 62 | self.pane = Some(pane.to_string()); 63 | self 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/common/session_info.rs: -------------------------------------------------------------------------------- 1 | use serde::{Serialize, Serializer}; 2 | use std::fmt; 3 | 4 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 5 | pub enum SessionStatus { 6 | Attached, 7 | Active, 8 | Inactive, 9 | } 10 | 11 | impl Serialize for SessionStatus { 12 | fn serialize(&self, serializer: S) -> Result 13 | where 14 | S: Serializer, 15 | { 16 | let status_str = match self { 17 | SessionStatus::Attached => "attached", 18 | SessionStatus::Active => "active", 19 | SessionStatus::Inactive => "inactive", 20 | }; 21 | serializer.serialize_str(status_str) 22 | } 23 | } 24 | 25 | impl SessionStatus { 26 | pub fn icon(&self) -> &str { 27 | match self { 28 | SessionStatus::Attached => "●", 29 | SessionStatus::Active => "○", 30 | SessionStatus::Inactive => "·", 31 | } 32 | } 33 | } 34 | 35 | impl fmt::Display for SessionStatus { 36 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 37 | write!(f, "{}", self.icon()) 38 | } 39 | } 40 | 41 | #[derive(Debug, Clone, Serialize)] 42 | pub struct SessionInfo { 43 | pub status: SessionStatus, 44 | pub name: String, 45 | } 46 | 47 | impl SessionInfo { 48 | pub fn active(name: String, is_attached: bool) -> Self { 49 | Self { 50 | status: if is_attached { 51 | SessionStatus::Attached 52 | } else { 53 | SessionStatus::Active 54 | }, 55 | name, 56 | } 57 | } 58 | 59 | pub fn inactive(name: String) -> Self { 60 | Self { 61 | status: SessionStatus::Inactive, 62 | name, 63 | } 64 | } 65 | } 66 | 67 | impl fmt::Display for SessionInfo { 68 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 69 | write!(f, "{} {}", self.status.icon(), self.name) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/common/config/validation.rs: -------------------------------------------------------------------------------- 1 | use std::slice::from_ref; 2 | 3 | use miette::Report; 4 | use serde_valid::validation::{Error, Errors}; 5 | 6 | pub(crate) fn generate_report(errors: Option<&Errors>) -> Report { 7 | build_error_tree(errors, "session") 8 | } 9 | 10 | fn build_error_tree(errors: Option<&Errors>, prefix: &str) -> Report { 11 | match errors { 12 | Some(Errors::Array(array_errors)) => { 13 | let mut messages = vec![]; 14 | 15 | for (field, (_, err)) in array_errors.items.iter().enumerate() { 16 | let nested_report = build_error_tree(Some(err), &format!("{prefix}[{field}]")); 17 | messages.push(format!("{nested_report}")); 18 | } 19 | 20 | for (field, err) in array_errors.errors.iter().enumerate() { 21 | let single_error_report = 22 | build_single_error_report(from_ref(err), &format!("{prefix}[{field}]")); 23 | messages.push(format!("{single_error_report}")); 24 | } 25 | 26 | Report::msg(messages.join("\n")) 27 | } 28 | 29 | Some(Errors::Object(object_errors)) => { 30 | let messages = object_errors 31 | .properties 32 | .iter() 33 | .map(|(field, err)| build_error_tree(Some(err), &format!("{prefix}.{field}"))) 34 | .map(|r| format!("{r}")) 35 | .collect::>(); 36 | 37 | Report::msg(messages.join("\n")) 38 | } 39 | 40 | Some(Errors::NewType(new_type_error)) => build_single_error_report(new_type_error, prefix), 41 | 42 | None => Report::msg("No errors found."), 43 | } 44 | } 45 | 46 | fn build_single_error_report(errors: &[Error], prefix: &str) -> Report { 47 | let messages = errors 48 | .iter() 49 | .map(|err| format!("{prefix}: {err}")) 50 | .collect::>() 51 | .join("\n"); 52 | 53 | Report::msg(messages) 54 | } 55 | -------------------------------------------------------------------------------- /docs/content/docs/getting-started/your-first-session.md: -------------------------------------------------------------------------------- 1 | +++ 2 | title = "Your First Session" 3 | description = "Create a custom session configuration from scratch." 4 | draft = false 5 | weight = 16 6 | sort_by = "weight" 7 | template = "docs/page.html" 8 | 9 | [extra] 10 | toc = true 11 | top = false 12 | +++ 13 | 14 | ## Understanding Sessions 15 | 16 | A laio session is defined by a YAML configuration file that describes: 17 | - The session name 18 | - Root working directory 19 | - Windows and their layouts 20 | - Panes within each window 21 | - Commands to run in each pane 22 | - Session lifecycle hooks 23 | 24 | ## Creating a Configuration 25 | 26 | Create a new configuration: 27 | 28 | ```bash 29 | laio config create myproject 30 | ``` 31 | 32 | This creates `~/.config/laio/myproject.yaml` with a default template. 33 | 34 | ## Editing the Configuration 35 | 36 | Open the configuration in your editor: 37 | 38 | ```bash 39 | laio config edit myproject 40 | ``` 41 | 42 | Or edit it directly: 43 | 44 | ```bash 45 | $EDITOR ~/.config/laio/myproject.yaml 46 | ``` 47 | 48 | ## Basic Configuration Structure 49 | 50 | Here's a simple two-window setup: 51 | 52 | ```yaml 53 | name: myproject 54 | path: /path/to/myproject 55 | 56 | windows: 57 | - name: editor 58 | panes: 59 | - commands: 60 | - command: nvim 61 | 62 | - name: terminal 63 | flex_direction: row # vertical split: side-by-side 64 | panes: 65 | - flex: 1 66 | - flex: 1 67 | ``` 68 | 69 | This creates: 70 | - Window 1: Single pane running `nvim` 71 | - Window 2: Two side-by-side panes with vertical split (50/50) 72 | 73 | ## Starting Your Session 74 | 75 | Start the configured session: 76 | 77 | ```bash 78 | laio start myproject 79 | ``` 80 | 81 | You'll be automatically attached to the session with your configured layout. 82 | 83 | ## Validating Configuration 84 | 85 | Before starting, validate your YAML: 86 | 87 | ```bash 88 | laio config validate myproject 89 | ``` 90 | 91 | This checks for syntax errors and schema violations. 92 | 93 | ## Next Steps 94 | 95 | - Learn about [layout options](/docs/configuration/layouts) 96 | - Add [lifecycle hooks](/docs/configuration/lifecycle) 97 | - Explore [YAML reference](/docs/configuration/yaml-reference) 98 | -------------------------------------------------------------------------------- /src/common/config/model/command.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | use serde::{Deserialize, Serialize}; 3 | use serde_valid::Validate; 4 | use serde_yaml::Value; 5 | use std::{fmt::Display, process::Command as ProcessCommand}; 6 | 7 | #[derive(Debug, Deserialize, Serialize, Clone, Validate, PartialEq)] 8 | pub(crate) struct Command { 9 | #[serde(default)] 10 | pub(crate) command: String, 11 | #[serde(default, skip_serializing_if = "Vec::is_empty")] 12 | pub(crate) args: Vec, 13 | } 14 | 15 | impl Command { 16 | pub fn from_string(input: &str) -> Self { 17 | let mut parts = input.split_whitespace(); 18 | let command = parts.next().unwrap_or_default().to_string(); 19 | let args = parts.map(|s| Value::String(s.to_string())).collect(); 20 | Command { command, args } 21 | } 22 | 23 | pub fn to_process_command(&self) -> ProcessCommand { 24 | let mut process_command = ProcessCommand::new(&self.command); 25 | 26 | process_command.args( 27 | self.args 28 | .iter() 29 | .map(|v| serde_yaml::to_string(v).unwrap().trim().to_string()) 30 | .collect::>(), 31 | ); 32 | process_command 33 | } 34 | } 35 | 36 | impl Display for Command { 37 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 38 | let mut cmd = self.command.clone(); 39 | 40 | if !self.args.is_empty() { 41 | cmd.push(' '); 42 | 43 | let formatted_args: Vec = self 44 | .args 45 | .iter() 46 | .map(|v| match v { 47 | Value::String(s) => { 48 | if s.contains(' ') || s.starts_with('"') || s.starts_with('\'') { 49 | format!("\"{s}\"") 50 | } else { 51 | s.clone() 52 | } 53 | } 54 | Value::Number(n) => n.to_string(), 55 | Value::Bool(b) => b.to_string(), 56 | _ => String::new(), 57 | }) 58 | .collect(); 59 | 60 | cmd.push_str(&formatted_args.join(" ")); 61 | } 62 | 63 | write!(f, "{cmd}") 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/common/config/model/script.rs: -------------------------------------------------------------------------------- 1 | use crate::common::config::Command; 2 | use miette::IntoDiagnostic; 3 | use miette::Result; 4 | use serde::{Deserialize, Serialize}; 5 | use sha2::Digest; 6 | use sha2::Sha256; 7 | use std::{ 8 | fmt::{self, Display}, 9 | fs::{set_permissions, File}, 10 | io::{Error, Read, Write}, 11 | os::unix::fs::PermissionsExt, 12 | path::PathBuf, 13 | }; 14 | 15 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 16 | pub(crate) struct Script(String); 17 | impl Script { 18 | fn checksum(&self) -> String { 19 | let mut hasher = Sha256::new(); 20 | hasher.update(self.0.as_bytes()); 21 | format!("{:x}", hasher.finalize()) 22 | } 23 | 24 | pub(crate) fn script_to_path(&self) -> Result { 25 | let checksum = self.checksum(); 26 | let mut path = std::env::temp_dir(); 27 | path.push(format!("laio-{checksum}")); 28 | 29 | if path.exists() { 30 | let mut file = File::open(&path).into_diagnostic()?; 31 | let mut existing = Vec::new(); 32 | file.read_to_end(&mut existing).into_diagnostic()?; 33 | 34 | let mut hasher = Sha256::new(); 35 | hasher.update(&existing); 36 | let existing_checksum = format!("{:x}", hasher.finalize()); 37 | 38 | (existing_checksum == checksum) 39 | .then_some(()) 40 | .ok_or_else(|| { 41 | Error::other(format!( 42 | "Checksum mismatch for cached script at {}", 43 | path.display() 44 | )) 45 | }) 46 | .into_diagnostic()?; 47 | } else { 48 | let mut file = File::create(&path).into_diagnostic()?; 49 | file.write_all(self.0.as_bytes()).into_diagnostic()?; 50 | set_permissions(&path, PermissionsExt::from_mode(0o700)).into_diagnostic()?; 51 | } 52 | 53 | Ok(path) 54 | } 55 | 56 | pub(crate) fn to_cmd(&self) -> Result { 57 | Ok(Command { 58 | command: self.script_to_path()?.to_string_lossy().to_string(), 59 | args: vec![], 60 | }) 61 | } 62 | } 63 | 64 | impl Display for Script { 65 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 66 | write!(f, "{}", self.0) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/app/cli/session/cli.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::SessionManager, 3 | muxer::{create_muxer, Muxer}, 4 | }; 5 | 6 | use clap::{Args, Subcommand}; 7 | use miette::{Context, IntoDiagnostic, Result}; 8 | use tabled::{builder::Builder, settings::Style}; 9 | 10 | #[derive(Debug, Subcommand, Clone)] 11 | pub(crate) enum Commands { 12 | /// List all active sessions. 13 | #[clap(alias = "ls")] 14 | List { 15 | /// Specify the multiplexer to use. 16 | #[clap(short, long)] 17 | muxer: Option, 18 | 19 | /// Output as JSON. 20 | #[clap(short, long)] 21 | json: bool, 22 | }, 23 | 24 | /// Shows current session layout as yaml. 25 | #[clap()] 26 | Yaml { 27 | /// Specify the multiplexer to use. 28 | #[clap(short, long)] 29 | muxer: Option, 30 | }, 31 | } 32 | 33 | /// Manage Sessions 34 | #[derive(Args, Debug)] 35 | pub struct Cli { 36 | #[clap(subcommand)] 37 | commands: Commands, 38 | } 39 | 40 | impl Cli { 41 | pub fn run(&self, config_path: &str) -> Result<()> { 42 | match &self.commands { 43 | Commands::List { muxer, json } => { 44 | let muxer = 45 | create_muxer(muxer).wrap_err("Could not create desired multiplexer.")?; 46 | let session = SessionManager::new(config_path, muxer); 47 | 48 | let list = session.list()?; 49 | 50 | if *json { 51 | let json_output = serde_json::to_string_pretty(&list).into_diagnostic()?; 52 | println!("{}", json_output); 53 | } else { 54 | let records: Vec<_> = list 55 | .iter() 56 | .map(|item| [item.status.icon(), item.name.as_str()]) 57 | .collect(); 58 | let builder = Builder::from_iter(records); 59 | let mut table = builder.build(); 60 | table.with(Style::rounded().remove_horizontals()); 61 | println!("{}", table); 62 | } 63 | Ok(()) 64 | } 65 | Commands::Yaml { muxer } => { 66 | let muxer = 67 | create_muxer(muxer).wrap_err("Could not create desired multiplexer.")?; 68 | let session = SessionManager::new(config_path, muxer); 69 | 70 | let yaml = session.to_yaml()?; 71 | println!("{yaml}"); 72 | Ok(()) 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/common/config/model/pane.rs: -------------------------------------------------------------------------------- 1 | use crate::common::config::FlexDirection; 2 | use crate::common::config::Script; 3 | use miette::bail; 4 | use miette::Result; 5 | use serde::{Deserialize, Serialize}; 6 | use serde_valid::Validate; 7 | 8 | use super::command::Command; 9 | use super::common::default_path; 10 | 11 | #[derive(Debug, Deserialize, Serialize, Clone, Validate)] 12 | pub(crate) struct Pane { 13 | #[serde(default, skip_serializing_if = "FlexDirection::is_default")] 14 | pub(crate) flex_direction: FlexDirection, 15 | #[validate(minimum = 1, message = "Flex has to be >= 0")] 16 | #[serde(default = "flex")] 17 | pub(crate) flex: usize, 18 | #[serde(default, skip_serializing_if = "Option::is_none")] 19 | pub(crate) name: Option, 20 | #[serde(default = "default_path", skip_serializing_if = "if_is_default_path")] 21 | pub(crate) path: String, 22 | #[serde(default, skip_serializing_if = "Option::is_none")] 23 | pub(crate) style: Option, 24 | #[serde(default, skip_serializing_if = "Vec::is_empty")] 25 | pub(crate) commands: Vec, 26 | #[serde(default, skip_serializing_if = "Option::is_none")] 27 | pub(crate) script: Option