Feature: web-client/server to share your sessions in the browser (#4242)
* work * moar work * notes * work * separate to terminal and control channels * stdin working * serve html web client initial * serve static assets loaded with include_dir * merge * enable_web_server config parameter * compile time flag to disable web server capability * rustfmt * add license to all xterm.js assets * mouse working except copy/paste * helpful comment * web client improvements - move script to js file - add favicon - add nerd font - change title TODO: investigate if font license embedded in otf is sufficient * get mouse to work properly * kitty keyboard support initial * fix wrong type in preload link * wip axum websocket handlers - upgrade axum to v0.8.1, enable ws feature - begin setup of websocket handlers - tidy up imports * replace control listener * handle terminal websocket with axum * cleanup Cargo.toml * kitty fixes and bracketed paste * fix(mouse): pane not found crash * initial session switching infra * add `web_client_font` option * session switching, creation and resurrection working through the session manager * move session module to zellij-utils and share logic with web-client * some cleanups * require restart for enable-web-server * use session name from router * write config to disk and watch for config changes * rename session name to ipc path * add basic panic handler, make render_to_client exit on channel close * use while let instead of loop * handle websocket close * add mouse motions * make clipboard work * add weblink handling and webgl rendering * add todo * fix: use session name instead of patch on session switch * use "default" layout for new sessions * ui indication for session being shared * share this session ui * plugin assets * Fix process crash on mac with notify watcher. Use poll watcher instead of recommended as a workaround. * make url session switching and creation work * start welcome screen on root url * scaffold control messages, set font from config * set dimensions on session start * bring back session name from url * send bytes on terminal websocket instead of json - create web client os input and id before websocket connection * draft ui * work * refactor ui * remove otf font, remove margins to avoid scrollbar * version query endpoint for server status * web session info query endpoint * refactor: move stuff around * add web client info to session metadata * make tests pass * populate real data in session list * remove unnecessary endpoint * add web_client node to config, add font option * remove web_client_font * allow disabling the web session through the config - WIP * formalize sharing/not-sharing configuration * fix tests * allow shutting down web server * display error when web clients are forbidden to attach * only show sessions that allow web clients if this is a web client * style(fmt): rustfmt * fix: query web server from Zellij rather than from each plugin * remove log spam * handle some error paths better in the web client * allow controlling the web server through the cli * allow configuring the web server's ip/port * fix tests and format code * use direct WebServerStatus event instead of piggy-backing on SessionInfo * plugin revamp initial * make plugin responsive * adjust plugin title * refactor: share plugin * refactor: share plugin * add cors middleware * some fixes for running without a compiled web server capability * display error when starting the share plugin without web server support * clarify config * add pipelines to compile zellij without web support * display error when unable to start web server * only query web server when share plugin is running * refactor(web-client): connection table * give zellij_server_listener access to the control channel * fixes and clarifications * refactor: consolidate generate_unique_session_name * give proper error when trying to attach to a forbidden session * change browser URL when switching sessions * add keyboard shortcut * enforce https when bound to non-loopback ip * initial authentication token implementation * background color from theme * initial web client theme config * basic token generation ui * refactor set config message creation * also set body background * allow editing scrollback for plugins too * set scrollback to 0 * properly parse colors in config * generate token from plugin * nice login modals * initial token management screen * implement token authentication * refactor(share): token management screen * style(fmt): rustfmt * fix(plugin): some minor bugs * refactor(share): main screen * refactor(share): token screen * refactor(share): main * refactor(share): ui components * fix(responsiveness): properly send usage_width to the render function * fix cli commands and add some verbosity * add support for settings ansi and selection colors * add cursor and cursor accent * basic web client tests * fix tests * refactor: web client * use session tokens for authentication * improve modals * move shutdown to ipc * refactor: ipc logic * serialize theme config for web client * update tests * refactor: move some stuff around to prepare for config hot reload * config live reloading for the web clients * change remember-me UI wording * improve xterm.js link handling * make sure terminal is focused on mousemove * remove deprecated sharing indication from compact-bar * gate deps and functionality behind the web_server_compatibility feature * feat(build): add --no-web flag in all the places * fix some other build flows * add new assets * update CI for no-web (untested) * make more dependencies optional * update axum-extra * add web client configuration options * gracefully close connections on server exit * tests for graceful connection closing * handle client-side reconnect when server is down * fix: make sure ipc bus folder exists before starting * add commands to manage login tokens from the cli * style(fmt): rustfmt * some cleanups * fix(ux): allow alt-right-click on the web client without opening the context menu * fix: prevent attaching to welcome screen * fix: reload config issues * fix long socket path on macos * normalize config conversion and fix color gap in browser * revoke session_token cookie if it is not valid * fix: visual bug with multiple clients in extremely small screen sizes * fix: only include rusqlite for the web server capability builds * update e2e snapshots * refactor(web): client side js * some cleanups * moar cleanups * fix(tests): wait for server instead of using a fixed timeout * debug CI * fix(tests): use spawn_blocking for running the test web server * fix(tests): wait for http rather than tcp port * fix(tests): properly pass config path - hopefully this is the issue... * success! bring back the rest of the tests * attempt to fix the macos CI issue * docs(changelog): add PR --------- Co-authored-by: Thomas Linford <linford.t@gmail.com>
This commit is contained in:
parent
7ef7cd5ecd
commit
c5ac796880
148 changed files with 13115 additions and 532 deletions
98
.github/workflows/release.yml
vendored
98
.github/workflows/release.yml
vendored
|
|
@ -66,54 +66,113 @@ jobs:
|
|||
cache: false
|
||||
rustflags: ""
|
||||
|
||||
- name: Build release binary
|
||||
- name: Build release binary (normal)
|
||||
run: cargo xtask ci cross ${{ matrix.target }}
|
||||
|
||||
- name: Preserve normal binary and build no-web variant
|
||||
run: |
|
||||
# Copy the normal binary to a safe location outside target/
|
||||
mkdir -p ./release-artifacts
|
||||
cp "target/${{ matrix.target }}/release/zellij" "./release-artifacts/zellij-normal"
|
||||
|
||||
# Clean and build the no-web version
|
||||
cargo clean
|
||||
cargo xtask ci cross ${{ matrix.target }} --no-web
|
||||
|
||||
# Copy the no-web binary to our safe location too
|
||||
cp "target/${{ matrix.target }}/release/zellij" "./release-artifacts/zellij-no-web"
|
||||
|
||||
# this breaks on aarch64 and this if conditional isn't working for some reason: TODO: investigate
|
||||
#- name: Strip release binary
|
||||
# if: runner.target != 'aarch64-unknown-linux-musl' && runner.target != 'aarch64-apple-darwin'
|
||||
# run: strip "target/${{ matrix.target }}/release/zellij"
|
||||
|
||||
- name: Create checksum
|
||||
id: make-checksum
|
||||
working-directory: ./target/${{ matrix.target }}/release
|
||||
- name: Create artifacts for both variants
|
||||
id: make-artifacts
|
||||
working-directory: ./release-artifacts
|
||||
run: |
|
||||
name="zellij-${{ matrix.target }}.sha256sum"
|
||||
# Create normal version artifact
|
||||
normal_name="zellij-${{ matrix.target }}.tar.gz"
|
||||
cp zellij-normal zellij
|
||||
tar cvzf "${normal_name}" zellij
|
||||
rm zellij
|
||||
echo "normal_name=${normal_name}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Create no-web version artifact
|
||||
noweb_name="zellij-no-web-${{ matrix.target }}.tar.gz"
|
||||
cp zellij-no-web zellij
|
||||
tar cvzf "${noweb_name}" zellij
|
||||
rm zellij
|
||||
echo "noweb_name=${noweb_name}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create checksums
|
||||
id: make-checksums
|
||||
working-directory: ./release-artifacts
|
||||
run: |
|
||||
normal_checksum="zellij-${{ matrix.target }}.sha256sum"
|
||||
noweb_checksum="zellij-no-web-${{ matrix.target }}.sha256sum"
|
||||
|
||||
# Create checksum for normal version
|
||||
cp zellij-normal zellij
|
||||
if [[ "$RUNNER_OS" != "macOS" ]]; then
|
||||
sha256sum "zellij" > "${name}"
|
||||
sha256sum zellij > "${normal_checksum}"
|
||||
else
|
||||
shasum -a 256 "zellij" > "${name}"
|
||||
shasum -a 256 zellij > "${normal_checksum}"
|
||||
fi
|
||||
echo "::set-output name=name::${name}"
|
||||
rm zellij
|
||||
|
||||
- name: Tar release
|
||||
id: make-artifact
|
||||
working-directory: ./target/${{ matrix.target }}/release
|
||||
run: |
|
||||
name="zellij-${{ matrix.target }}.tar.gz"
|
||||
tar cvzf "${name}" "zellij"
|
||||
echo "::set-output name=name::${name}"
|
||||
# Create checksum for no-web version
|
||||
cp zellij-no-web zellij
|
||||
if [[ "$RUNNER_OS" != "macOS" ]]; then
|
||||
sha256sum zellij > "${noweb_checksum}"
|
||||
else
|
||||
shasum -a 256 zellij > "${noweb_checksum}"
|
||||
fi
|
||||
rm zellij
|
||||
|
||||
- name: Upload release archive
|
||||
echo "normal_checksum=${normal_checksum}" >> "$GITHUB_OUTPUT"
|
||||
echo "noweb_checksum=${noweb_checksum}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload normal release archive
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
asset_path: ./target/${{ matrix.target }}/release/${{ steps.make-artifact.outputs.name }}
|
||||
asset_path: ./release-artifacts/${{ steps.make-artifacts.outputs.normal_name }}
|
||||
asset_name: zellij-${{matrix.target}}.tar.gz
|
||||
asset_content_type: application/octet-stream
|
||||
|
||||
- name: Upload checksum
|
||||
- name: Upload normal checksum
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
asset_path: ./target/${{ matrix.target }}/release/${{ steps.make-checksum.outputs.name }}
|
||||
asset_path: ./release-artifacts/${{ steps.make-checksums.outputs.normal_checksum }}
|
||||
asset_name: zellij-${{matrix.target}}.sha256sum
|
||||
asset_content_type: text/plain
|
||||
|
||||
- name: Upload no-web release archive
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
asset_path: ./release-artifacts/${{ steps.make-artifacts.outputs.noweb_name }}
|
||||
asset_name: zellij-no-web-${{matrix.target}}.tar.gz
|
||||
asset_content_type: application/octet-stream
|
||||
|
||||
- name: Upload no-web checksum
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
asset_path: ./release-artifacts/${{ steps.make-checksums.outputs.noweb_checksum }}
|
||||
asset_name: zellij-no-web-${{matrix.target}}.sha256sum
|
||||
asset_content_type: text/plain
|
||||
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
|
|
@ -129,4 +188,3 @@ jobs:
|
|||
release_name: Release ${{ github.event_name == 'workflow_dispatch' && 'main' || github.ref }}
|
||||
draft: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
prerelease: false
|
||||
|
||||
|
|
|
|||
20
.github/workflows/rust.yml
vendored
20
.github/workflows/rust.yml
vendored
|
|
@ -62,6 +62,26 @@ jobs:
|
|||
- name: Test
|
||||
run: cargo xtask test
|
||||
|
||||
test-no-web:
|
||||
name: Test (No Web)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Install Protoc
|
||||
uses: arduino/setup-protoc@v2
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
rustflags: ""
|
||||
|
||||
- name: Test without web support
|
||||
run: cargo xtask test --no-web
|
||||
|
||||
format:
|
||||
name: Check Formatting
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,5 +2,6 @@ target/
|
|||
*.new
|
||||
.vscode
|
||||
.vim
|
||||
.idea
|
||||
.DS_Store
|
||||
/assets/man/zellij.1
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
|||
## [Unreleased]
|
||||
* feat: multiple select and bulk pane actions (https://github.com/zellij-org/zellij/pull/4169 and https://github.com/zellij-org/zellij/pull/4171 and https://github.com/zellij-org/zellij/pull/4221)
|
||||
* feat: add an optional key tooltip to show the current keybindings for the compact bar (https://github.com/zellij-org/zellij/pull/4225)
|
||||
* feat: web-client, allowing users to share sessions in the browser (https://github.com/zellij-org/zellij/pull/4242)
|
||||
|
||||
## [0.42.2] - 2025-04-15
|
||||
* refactor(terminal): track scroll_region as tuple rather than Option (https://github.com/zellij-org/zellij/pull/4082)
|
||||
|
|
|
|||
1043
Cargo.lock
generated
1043
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
23
Cargo.toml
23
Cargo.toml
|
|
@ -20,14 +20,15 @@ zellij-utils = { workspace = true }
|
|||
anyhow = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
dialoguer = { version = "0.10.4", default-features = false }
|
||||
humantime = { version = "2.1.0", default-features = false }
|
||||
humantime = { workspace = true }
|
||||
interprocess = { workspace = true }
|
||||
log = { workspace = true }
|
||||
miette = { workspace = true }
|
||||
names = { version = "0.14.0", default-features = false }
|
||||
names = { workspace = true }
|
||||
nix = { workspace = true }
|
||||
suggest = { version = "0.4.0", default-features = false }
|
||||
suggest = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
isahc = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
insta = { version = "1.6.0", features = ["backtrace"] }
|
||||
|
|
@ -47,6 +48,7 @@ members = [
|
|||
"default-plugins/configuration",
|
||||
"default-plugins/plugin-manager",
|
||||
"default-plugins/about",
|
||||
"default-plugins/share",
|
||||
"default-plugins/multiple-select",
|
||||
"zellij-client",
|
||||
"zellij-server",
|
||||
|
|
@ -62,6 +64,8 @@ ansi_term = { version = "0.12.1", default-features = false }
|
|||
anyhow = { version = "1.0.70", default-features = false, features = ["backtrace", "std"] }
|
||||
async-std = { version = "1.3.0", default-features = false, features = ["attributes", "default", "std", "unstable"] }
|
||||
clap = { version = "3.2.2", default-features = false, features = ["env", "derive", "color", "std", "suggestions"] }
|
||||
daemonize = { version = "0.5", default-features = false }
|
||||
humantime = { version = "2.1.0", default-features = false }
|
||||
interprocess = { version = "1.2.1", default-features = false }
|
||||
isahc = { version = "1.7.2", default-features = false, features = ["http2", "text-decoding"] }
|
||||
lazy_static = { version = "1.4.0", default-features = false }
|
||||
|
|
@ -69,7 +73,8 @@ libc = { version = "0.2", default-features = false, features = ["std"] }
|
|||
log = { version = "0.4.17", default-features = false }
|
||||
miette = { version = "5.7.0", default-features = false, features = ["fancy"] }
|
||||
nix = { version = "0.23.1", default-features = false }
|
||||
notify-debouncer-full = { version = "0.1.0", default-features = false }
|
||||
notify-debouncer-full = { version = "0.3.1", default-features = false }
|
||||
notify = { version = "6.1.1", default-features = false, features = ["macos_kqueue"] }
|
||||
prost = { version = "0.11.9", default-features = false, features = ["std", "prost-derive"] }
|
||||
regex = { version = "1.5.5", default-features = false, features = ["perf", "std"] }
|
||||
serde = { version = "1.0", default-features = false, features = ["derive", "std"] }
|
||||
|
|
@ -77,13 +82,20 @@ serde_json = { version = "1.0", default-features = false, features = ["std"] }
|
|||
signal-hook = { version = "0.3", default-features = false, features = ["iterator"] }
|
||||
strum = { version = "0.20.0", default-features = false }
|
||||
strum_macros = { version = "0.20.0", default-features = false }
|
||||
suggest = { version = "0.4.0", default-features = false }
|
||||
tempfile = { version = "3.2.0", default-features = false }
|
||||
termwiz = { version = "0.23.2", default-features = false }
|
||||
thiserror = { version = "1.0.40", default-features = false }
|
||||
tokio = { version = "1.38.1", features = ["full"] }
|
||||
tokio-util = { version = "0.7.15" }
|
||||
unicode-width = { version = "0.1.8", default-features = false }
|
||||
url = { version = "2.2.2", default-features = false, features = ["serde"] }
|
||||
uuid = { version = "1.4.1", default-features = false, features = ["serde", "v4", "std"] }
|
||||
vte = { version = "0.11.0", default-features = false }
|
||||
names = { version = "0.14.0", default-features = false }
|
||||
include_dir = { version = "0.7.3", default-features = false }
|
||||
rmp-serde = { version = "1.1.0", default-features = false }
|
||||
sha2 = { version = "0.10", default-features = false }
|
||||
zellij-utils = { path = "zellij-utils/", version = "0.43.0" }
|
||||
|
||||
[profile.dev-opt]
|
||||
|
|
@ -118,12 +130,13 @@ pkg-fmt = "tgz"
|
|||
|
||||
[features]
|
||||
# See remarks in zellij_utils/Cargo.toml
|
||||
default = ["plugins_from_target", "vendored_curl"]
|
||||
default = ["plugins_from_target", "vendored_curl", "web_server_capability"]
|
||||
plugins_from_target = ["zellij-utils/plugins_from_target"]
|
||||
disable_automatic_asset_installation = ["zellij-utils/disable_automatic_asset_installation"]
|
||||
vendored_curl = ["zellij-utils/vendored_curl"]
|
||||
unstable = ["zellij-client/unstable", "zellij-utils/unstable"]
|
||||
singlepass = ["zellij-server/singlepass"]
|
||||
web_server_capability = ["zellij-client/web_server_capability", "zellij-server/web_server_capability", "zellij-utils/web_server_capability"]
|
||||
|
||||
# uncomment this when developing plugins in the Zellij UI to make plugin compilation faster
|
||||
# [profile.dev.package."*"]
|
||||
|
|
|
|||
|
|
@ -147,6 +147,13 @@ keybinds clear-defaults=true {{
|
|||
}};
|
||||
SwitchToMode "Locked"
|
||||
}}
|
||||
bind "s" {{
|
||||
LaunchOrFocusPlugin "zellij:share" {{
|
||||
floating true
|
||||
move_to_focused_tab true
|
||||
}};
|
||||
SwitchToMode "Locked"
|
||||
}}
|
||||
}}
|
||||
shared_except "locked" "renametab" "renamepane" {{
|
||||
bind "{primary_modifier} g" {{ SwitchToMode "Locked"; }}
|
||||
|
|
@ -355,6 +362,13 @@ keybinds clear-defaults=true {{
|
|||
}};
|
||||
SwitchToMode "Normal"
|
||||
}}
|
||||
bind "s" {{
|
||||
LaunchOrFocusPlugin "zellij:share" {{
|
||||
floating true
|
||||
move_to_focused_tab true
|
||||
}};
|
||||
SwitchToMode "Normal"
|
||||
}}
|
||||
}}
|
||||
tmux {{
|
||||
bind "[" {{ SwitchToMode "Scroll"; }}
|
||||
|
|
@ -563,6 +577,13 @@ keybinds clear-defaults=true {{
|
|||
}};
|
||||
SwitchToMode "Normal"
|
||||
}}
|
||||
bind "s" {{
|
||||
LaunchOrFocusPlugin "zellij:share" {{
|
||||
floating true
|
||||
move_to_focused_tab true
|
||||
}};
|
||||
SwitchToMode "Normal"
|
||||
}}
|
||||
}}
|
||||
tmux {{
|
||||
bind "[" {{ SwitchToMode "Scroll"; }}
|
||||
|
|
@ -752,6 +773,13 @@ keybinds clear-defaults=true {{
|
|||
}};
|
||||
SwitchToMode "Normal"
|
||||
}}
|
||||
bind "s" {{
|
||||
LaunchOrFocusPlugin "zellij:share" {{
|
||||
floating true
|
||||
move_to_focused_tab true
|
||||
}};
|
||||
SwitchToMode "Normal"
|
||||
}}
|
||||
}}
|
||||
tmux {{
|
||||
bind "[" {{ SwitchToMode "Scroll"; }}
|
||||
|
|
@ -941,6 +969,13 @@ keybinds clear-defaults=true {{
|
|||
}};
|
||||
SwitchToMode "Normal"
|
||||
}}
|
||||
bind "s" {{
|
||||
LaunchOrFocusPlugin "zellij:share" {{
|
||||
floating true
|
||||
move_to_focused_tab true
|
||||
}};
|
||||
SwitchToMode "Normal"
|
||||
}}
|
||||
}}
|
||||
tmux {{
|
||||
bind "[" {{ SwitchToMode "Scroll"; }}
|
||||
|
|
@ -1125,6 +1160,13 @@ keybinds clear-defaults=true {{
|
|||
}};
|
||||
SwitchToMode "Normal"
|
||||
}}
|
||||
bind "s" {{
|
||||
LaunchOrFocusPlugin "zellij:share" {{
|
||||
floating true
|
||||
move_to_focused_tab true
|
||||
}};
|
||||
SwitchToMode "Normal"
|
||||
}}
|
||||
}}
|
||||
tmux {{
|
||||
bind "[" {{ SwitchToMode "Scroll"; }}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ struct State {
|
|||
is_welcome_screen: bool,
|
||||
show_kill_all_sessions_warning: bool,
|
||||
request_ids: Vec<String>,
|
||||
is_web_client: bool,
|
||||
}
|
||||
|
||||
register_plugin!(State);
|
||||
|
|
@ -94,6 +95,7 @@ impl ZellijPlugin for State {
|
|||
match event {
|
||||
Event::ModeUpdate(mode_info) => {
|
||||
self.colors = Colors::new(mode_info.style.colors);
|
||||
self.is_web_client = mode_info.is_web_client.unwrap_or(false);
|
||||
should_render = true;
|
||||
},
|
||||
Event::Key(key) => {
|
||||
|
|
@ -462,6 +464,12 @@ impl State {
|
|||
} else if self.new_session_info.name().contains('/') {
|
||||
self.show_error("Session name cannot contain '/'");
|
||||
return;
|
||||
} else if self
|
||||
.sessions
|
||||
.has_forbidden_session(self.new_session_info.name())
|
||||
{
|
||||
self.show_error("This session exists and web clients cannot attach to it.");
|
||||
return;
|
||||
}
|
||||
self.new_session_info.handle_selection(&self.session_name);
|
||||
},
|
||||
|
|
@ -505,6 +513,8 @@ impl State {
|
|||
}
|
||||
} else if let Some(tab_position) = selected_tab {
|
||||
go_to_tab(tab_position as u32);
|
||||
} else {
|
||||
self.show_error("Already attached...");
|
||||
}
|
||||
} else {
|
||||
switch_session_with_focus(
|
||||
|
|
@ -518,7 +528,13 @@ impl State {
|
|||
self.search_term.clear();
|
||||
self.sessions
|
||||
.update_search_term(&self.search_term, &self.colors);
|
||||
if !self.is_welcome_screen {
|
||||
// we usually don't want to hide_self() if we're the welcome screen because
|
||||
// unless the user did something odd like opening an extra pane/tab in the
|
||||
// welcome screen, this will result in the current session closing, as this is
|
||||
// the last selectable pane...
|
||||
hide_self();
|
||||
}
|
||||
},
|
||||
ActiveScreen::ResurrectSession => {
|
||||
if let Some(session_name_to_resurrect) =
|
||||
|
|
@ -547,9 +563,32 @@ impl State {
|
|||
self.session_name = Some(new_name.to_owned());
|
||||
}
|
||||
fn update_session_infos(&mut self, session_infos: Vec<SessionInfo>) {
|
||||
let session_infos: Vec<SessionUiInfo> = session_infos
|
||||
let session_ui_infos: Vec<SessionUiInfo> = session_infos
|
||||
.iter()
|
||||
.map(|s| SessionUiInfo::from_session_info(s))
|
||||
.filter_map(|s| {
|
||||
if self.is_web_client && !s.web_clients_allowed {
|
||||
None
|
||||
} else if self.is_welcome_screen && s.is_current_session {
|
||||
// do not display current session if we're the welcome screen
|
||||
// because:
|
||||
// 1. attaching to the welcome screen from the welcome screen is not a thing
|
||||
// 2. it can cause issues on the web (since we're disconnecting and
|
||||
// reconnecting to a session we just closed by disconnecting...)
|
||||
None
|
||||
} else {
|
||||
Some(SessionUiInfo::from_session_info(s))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let forbidden_sessions: Vec<SessionUiInfo> = session_infos
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
if self.is_web_client && !s.web_clients_allowed {
|
||||
Some(SessionUiInfo::from_session_info(s))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let current_session_name = session_infos.iter().find_map(|s| {
|
||||
if s.is_current_session {
|
||||
|
|
@ -561,7 +600,8 @@ impl State {
|
|||
if let Some(current_session_name) = current_session_name {
|
||||
self.session_name = Some(current_session_name);
|
||||
}
|
||||
self.sessions.set_sessions(session_infos);
|
||||
self.sessions
|
||||
.set_sessions(session_ui_infos, forbidden_sessions);
|
||||
}
|
||||
fn main_menu_size(&self, rows: usize, cols: usize) -> (usize, usize, usize, usize) {
|
||||
// x, y, width, height
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use crate::ui::{
|
|||
#[derive(Debug, Default)]
|
||||
pub struct SessionList {
|
||||
pub session_ui_infos: Vec<SessionUiInfo>,
|
||||
pub forbidden_sessions: Vec<SessionUiInfo>,
|
||||
pub selected_index: SelectedIndex,
|
||||
pub selected_search_index: Option<usize>,
|
||||
pub search_results: Vec<SearchResult>,
|
||||
|
|
@ -16,7 +17,11 @@ pub struct SessionList {
|
|||
}
|
||||
|
||||
impl SessionList {
|
||||
pub fn set_sessions(&mut self, mut session_ui_infos: Vec<SessionUiInfo>) {
|
||||
pub fn set_sessions(
|
||||
&mut self,
|
||||
mut session_ui_infos: Vec<SessionUiInfo>,
|
||||
mut forbidden_sessions: Vec<SessionUiInfo>,
|
||||
) {
|
||||
session_ui_infos.sort_unstable_by(|a, b| {
|
||||
if a.is_current_session {
|
||||
std::cmp::Ordering::Less
|
||||
|
|
@ -26,7 +31,9 @@ impl SessionList {
|
|||
a.name.cmp(&b.name)
|
||||
}
|
||||
});
|
||||
forbidden_sessions.sort_unstable_by(|a, b| a.name.cmp(&b.name));
|
||||
self.session_ui_infos = session_ui_infos;
|
||||
self.forbidden_sessions = forbidden_sessions;
|
||||
}
|
||||
pub fn update_search_term(&mut self, search_term: &str, colors: &Colors) {
|
||||
let mut flattened_assets = self.flatten_assets(colors);
|
||||
|
|
@ -317,6 +324,11 @@ impl SessionList {
|
|||
pub fn has_session(&self, session_name: &str) -> bool {
|
||||
self.session_ui_infos.iter().any(|s| s.name == session_name)
|
||||
}
|
||||
pub fn has_forbidden_session(&self, session_name: &str) -> bool {
|
||||
self.forbidden_sessions
|
||||
.iter()
|
||||
.any(|s| s.name == session_name)
|
||||
}
|
||||
pub fn update_session_name(&mut self, old_name: &str, new_name: &str) {
|
||||
self.session_ui_infos
|
||||
.iter_mut()
|
||||
|
|
|
|||
2
default-plugins/share/.cargo/config.toml
Normal file
2
default-plugins/share/.cargo/config.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[build]
|
||||
target = "wasm32-wasip1"
|
||||
1
default-plugins/share/.gitignore
vendored
Normal file
1
default-plugins/share/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
11
default-plugins/share/Cargo.toml
Normal file
11
default-plugins/share/Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[package]
|
||||
name = "share"
|
||||
version = "0.1.0"
|
||||
authors = ["Aram Drevekenin <aram@poor.dev>"]
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
zellij-tile = { path = "../../zellij-tile" }
|
||||
url = "2.0"
|
||||
rand = "0.9.0"
|
||||
1
default-plugins/share/LICENSE.md
Symbolic link
1
default-plugins/share/LICENSE.md
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../LICENSE.md
|
||||
647
default-plugins/share/src/main.rs
Normal file
647
default-plugins/share/src/main.rs
Normal file
|
|
@ -0,0 +1,647 @@
|
|||
mod main_screen;
|
||||
mod token_management_screen;
|
||||
mod token_screen;
|
||||
mod ui_components;
|
||||
|
||||
use std::net::IpAddr;
|
||||
use zellij_tile::prelude::*;
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use main_screen::MainScreen;
|
||||
use token_management_screen::TokenManagementScreen;
|
||||
use token_screen::TokenScreen;
|
||||
|
||||
static WEB_SERVER_QUERY_DURATION: f64 = 0.4; // Doherty threshold
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct App {
|
||||
web_server: WebServerState,
|
||||
ui: UIState,
|
||||
tokens: TokenManager,
|
||||
state: AppState,
|
||||
}
|
||||
|
||||
register_plugin!(App);
|
||||
|
||||
impl ZellijPlugin for App {
|
||||
fn load(&mut self, _configuration: BTreeMap<String, String>) {
|
||||
self.initialize();
|
||||
}
|
||||
|
||||
fn update(&mut self, event: Event) -> bool {
|
||||
if !self.web_server.capability && !matches!(event, Event::ModeUpdate(_)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
match event {
|
||||
Event::Timer(_) => self.handle_timer(),
|
||||
Event::ModeUpdate(mode_info) => self.handle_mode_update(mode_info),
|
||||
Event::WebServerStatus(status) => self.handle_web_server_status(status),
|
||||
Event::Key(key) => self.handle_key_input(key),
|
||||
Event::Mouse(mouse_event) => self.handle_mouse_event(mouse_event),
|
||||
Event::RunCommandResult(exit_code, _stdout, _stderr, context) => {
|
||||
self.handle_command_result(exit_code, context)
|
||||
},
|
||||
Event::FailedToStartWebServer(error) => self.handle_web_server_error(error),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&mut self, rows: usize, cols: usize) {
|
||||
if !self.web_server.capability {
|
||||
self.render_no_capability_message(rows, cols);
|
||||
return;
|
||||
}
|
||||
|
||||
self.ui.reset_render_state();
|
||||
match &self.state.current_screen {
|
||||
Screen::Main => self.render_main_screen(rows, cols),
|
||||
Screen::Token(token) => self.render_token_screen(rows, cols, token),
|
||||
Screen::ManageTokens => self.render_manage_tokens_screen(rows, cols),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn initialize(&mut self) {
|
||||
self.subscribe_to_events();
|
||||
self.state.own_plugin_id = Some(get_plugin_ids().plugin_id);
|
||||
self.retrieve_token_list();
|
||||
self.query_link_executable();
|
||||
self.set_plugin_title();
|
||||
}
|
||||
|
||||
fn subscribe_to_events(&self) {
|
||||
subscribe(&[
|
||||
EventType::Key,
|
||||
EventType::ModeUpdate,
|
||||
EventType::WebServerStatus,
|
||||
EventType::Mouse,
|
||||
EventType::RunCommandResult,
|
||||
EventType::FailedToStartWebServer,
|
||||
EventType::Timer,
|
||||
]);
|
||||
}
|
||||
|
||||
fn set_plugin_title(&self) {
|
||||
if let Some(plugin_id) = self.state.own_plugin_id {
|
||||
rename_plugin_pane(plugin_id, "Share Session");
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_timer(&mut self) -> bool {
|
||||
query_web_server_status();
|
||||
self.retrieve_token_list();
|
||||
set_timeout(WEB_SERVER_QUERY_DURATION);
|
||||
false
|
||||
}
|
||||
|
||||
fn handle_mode_update(&mut self, mode_info: ModeInfo) -> bool {
|
||||
let mut should_render = false;
|
||||
|
||||
self.state.session_name = mode_info.session_name;
|
||||
|
||||
if let Some(web_clients_allowed) = mode_info.web_clients_allowed {
|
||||
self.web_server.clients_allowed = web_clients_allowed;
|
||||
should_render = true;
|
||||
}
|
||||
|
||||
if let Some(web_sharing) = mode_info.web_sharing {
|
||||
self.web_server.sharing = web_sharing;
|
||||
should_render = true;
|
||||
}
|
||||
|
||||
if let Some(web_server_ip) = mode_info.web_server_ip {
|
||||
self.web_server.ip = Some(web_server_ip);
|
||||
should_render = true;
|
||||
}
|
||||
|
||||
if let Some(web_server_port) = mode_info.web_server_port {
|
||||
self.web_server.port = Some(web_server_port);
|
||||
should_render = true;
|
||||
}
|
||||
|
||||
if let Some(web_server_capability) = mode_info.web_server_capability {
|
||||
self.web_server.capability = web_server_capability;
|
||||
if self.web_server.capability && !self.state.timer_running {
|
||||
self.state.timer_running = true;
|
||||
set_timeout(WEB_SERVER_QUERY_DURATION);
|
||||
}
|
||||
should_render = true;
|
||||
}
|
||||
|
||||
should_render
|
||||
}
|
||||
|
||||
fn handle_web_server_status(&mut self, status: WebServerStatus) -> bool {
|
||||
match status {
|
||||
WebServerStatus::Online(base_url) => {
|
||||
self.web_server.base_url = base_url;
|
||||
self.web_server.started = true;
|
||||
self.web_server.different_version_error = None;
|
||||
},
|
||||
WebServerStatus::Offline => {
|
||||
self.web_server.started = false;
|
||||
self.web_server.different_version_error = None;
|
||||
},
|
||||
WebServerStatus::DifferentVersion(version) => {
|
||||
self.web_server.started = false;
|
||||
self.web_server.different_version_error = Some(version);
|
||||
},
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn handle_key_input(&mut self, key: KeyWithModifier) -> bool {
|
||||
if self.clear_error_or_info() {
|
||||
return true;
|
||||
}
|
||||
|
||||
match self.state.current_screen {
|
||||
Screen::Main => self.handle_main_screen_keys(key),
|
||||
Screen::Token(_) => self.handle_token_screen_keys(key),
|
||||
Screen::ManageTokens => self.handle_manage_tokens_keys(key),
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_error_or_info(&mut self) -> bool {
|
||||
self.web_server.error.take().is_some() || self.state.info.take().is_some()
|
||||
}
|
||||
|
||||
fn handle_main_screen_keys(&mut self, key: KeyWithModifier) -> bool {
|
||||
match key.bare_key {
|
||||
BareKey::Enter if key.has_no_modifiers() && !self.web_server.started => {
|
||||
start_web_server();
|
||||
false
|
||||
},
|
||||
BareKey::Char('c') if key.has_modifiers(&[KeyModifier::Ctrl]) => {
|
||||
stop_web_server();
|
||||
false
|
||||
},
|
||||
BareKey::Char(' ') if key.has_no_modifiers() => {
|
||||
self.toggle_session_sharing();
|
||||
false
|
||||
},
|
||||
BareKey::Char('t') if key.has_no_modifiers() => {
|
||||
self.handle_token_action();
|
||||
true
|
||||
},
|
||||
BareKey::Esc if key.has_no_modifiers() => {
|
||||
close_self();
|
||||
false
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_session_sharing(&self) {
|
||||
match self.web_server.sharing {
|
||||
WebSharing::Disabled => {},
|
||||
WebSharing::On => stop_sharing_current_session(),
|
||||
WebSharing::Off => share_current_session(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_token_action(&mut self) {
|
||||
if self.tokens.list.is_empty() {
|
||||
self.generate_new_token(None);
|
||||
} else {
|
||||
self.change_to_manage_tokens_screen();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_token_screen_keys(&mut self, key: KeyWithModifier) -> bool {
|
||||
match key.bare_key {
|
||||
BareKey::Esc if key.has_no_modifiers() => {
|
||||
self.change_to_previous_screen();
|
||||
true
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_manage_tokens_keys(&mut self, key: KeyWithModifier) -> bool {
|
||||
if self.tokens.handle_text_input(&key) {
|
||||
return true;
|
||||
}
|
||||
|
||||
match key.bare_key {
|
||||
BareKey::Esc if key.has_no_modifiers() => self.handle_escape_key(),
|
||||
BareKey::Down if key.has_no_modifiers() => self.tokens.navigate_down(),
|
||||
BareKey::Up if key.has_no_modifiers() => self.tokens.navigate_up(),
|
||||
BareKey::Char('n') if key.has_no_modifiers() => {
|
||||
self.tokens.start_new_token_input();
|
||||
true
|
||||
},
|
||||
BareKey::Enter if key.has_no_modifiers() => self.handle_enter_key(),
|
||||
BareKey::Char('r') if key.has_no_modifiers() => {
|
||||
self.tokens.start_rename_input();
|
||||
true
|
||||
},
|
||||
BareKey::Char('x') if key.has_no_modifiers() => self.revoke_selected_token(),
|
||||
BareKey::Char('x') if key.has_modifiers(&[KeyModifier::Ctrl]) => {
|
||||
self.revoke_all_tokens();
|
||||
true
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_escape_key(&mut self) -> bool {
|
||||
let was_editing = self.tokens.cancel_input();
|
||||
|
||||
if !was_editing {
|
||||
self.change_to_main_screen();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn handle_enter_key(&mut self) -> bool {
|
||||
if let Some(token_name) = self.tokens.finish_new_token_input() {
|
||||
self.generate_new_token(token_name);
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(new_name) = self.tokens.finish_rename_input() {
|
||||
self.rename_current_token(new_name);
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn generate_new_token(&mut self, name: Option<String>) {
|
||||
match generate_web_login_token(name) {
|
||||
Ok(token) => self.change_to_token_screen(token),
|
||||
Err(e) => self.web_server.error = Some(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_current_token(&mut self, new_name: String) {
|
||||
if let Some(current_token) = self.tokens.get_selected_token() {
|
||||
match rename_web_token(¤t_token.0, &new_name) {
|
||||
Ok(_) => {
|
||||
self.retrieve_token_list();
|
||||
if self.tokens.adjust_selection_after_list_change() {
|
||||
self.change_to_main_screen();
|
||||
}
|
||||
},
|
||||
Err(e) => self.web_server.error = Some(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn revoke_selected_token(&mut self) -> bool {
|
||||
if let Some(token) = self.tokens.get_selected_token() {
|
||||
match revoke_web_login_token(&token.0) {
|
||||
Ok(_) => {
|
||||
self.retrieve_token_list();
|
||||
if self.tokens.adjust_selection_after_list_change() {
|
||||
self.change_to_main_screen();
|
||||
}
|
||||
self.state.info = Some("Revoked. Connected clients not affected.".to_owned());
|
||||
},
|
||||
Err(e) => self.web_server.error = Some(e),
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn revoke_all_tokens(&mut self) {
|
||||
match revoke_all_web_tokens() {
|
||||
Ok(_) => {
|
||||
self.retrieve_token_list();
|
||||
if self.tokens.adjust_selection_after_list_change() {
|
||||
self.change_to_main_screen();
|
||||
}
|
||||
self.state.info = Some("Revoked. Connected clients not affected.".to_owned());
|
||||
},
|
||||
Err(e) => self.web_server.error = Some(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_mouse_event(&mut self, event: Mouse) -> bool {
|
||||
match event {
|
||||
Mouse::LeftClick(line, column) => self.handle_link_click(line, column),
|
||||
Mouse::Hover(line, column) => {
|
||||
self.ui.hover_coordinates = Some((column, line as usize));
|
||||
true
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_link_click(&mut self, line: isize, column: usize) -> bool {
|
||||
for (coordinates, url) in &self.ui.clickable_urls {
|
||||
if coordinates.contains(column, line as usize) {
|
||||
if let Some(executable) = self.ui.link_executable {
|
||||
run_command(&[executable, url], Default::default());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn handle_command_result(
|
||||
&mut self,
|
||||
exit_code: Option<i32>,
|
||||
context: BTreeMap<String, String>,
|
||||
) -> bool {
|
||||
if context.contains_key("xdg_open_cli") && exit_code == Some(0) {
|
||||
self.ui.link_executable = Some("xdg-open");
|
||||
} else if context.contains_key("open_cli") && exit_code == Some(0) {
|
||||
self.ui.link_executable = Some("open");
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn handle_web_server_error(&mut self, error: String) -> bool {
|
||||
self.web_server.error = Some(error);
|
||||
true
|
||||
}
|
||||
|
||||
fn query_link_executable(&self) {
|
||||
let mut xdg_context = BTreeMap::new();
|
||||
xdg_context.insert("xdg_open_cli".to_owned(), String::new());
|
||||
run_command(&["xdg-open", "--help"], xdg_context);
|
||||
|
||||
let mut open_context = BTreeMap::new();
|
||||
open_context.insert("open_cli".to_owned(), String::new());
|
||||
run_command(&["open", "--help"], open_context);
|
||||
}
|
||||
|
||||
fn render_no_capability_message(&self, rows: usize, cols: usize) {
|
||||
let full_text = "This version of Zellij was compiled without web sharing capabilities";
|
||||
let short_text = "No web server capabilities";
|
||||
let text = if cols >= full_text.chars().count() {
|
||||
full_text
|
||||
} else {
|
||||
short_text
|
||||
};
|
||||
|
||||
let text_element = Text::new(text).color_range(3, ..);
|
||||
let text_x = cols.saturating_sub(text.chars().count()) / 2;
|
||||
let text_y = rows / 2;
|
||||
print_text_with_coordinates(text_element, text_x, text_y, None, None);
|
||||
}
|
||||
|
||||
fn change_to_token_screen(&mut self, token: String) {
|
||||
self.retrieve_token_list();
|
||||
set_self_mouse_selection_support(true);
|
||||
self.state.previous_screen = Some(self.state.current_screen.clone());
|
||||
self.state.current_screen = Screen::Token(token);
|
||||
}
|
||||
|
||||
fn change_to_manage_tokens_screen(&mut self) {
|
||||
self.retrieve_token_list();
|
||||
set_self_mouse_selection_support(false);
|
||||
self.tokens.selected_index = Some(0);
|
||||
self.state.previous_screen = None;
|
||||
self.state.current_screen = Screen::ManageTokens;
|
||||
}
|
||||
|
||||
fn change_to_main_screen(&mut self) {
|
||||
self.retrieve_token_list();
|
||||
set_self_mouse_selection_support(false);
|
||||
self.state.previous_screen = None;
|
||||
self.state.current_screen = Screen::Main;
|
||||
}
|
||||
|
||||
fn change_to_previous_screen(&mut self) {
|
||||
self.retrieve_token_list();
|
||||
match self.state.previous_screen.take() {
|
||||
Some(Screen::ManageTokens) => self.change_to_manage_tokens_screen(),
|
||||
_ => self.change_to_main_screen(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_main_screen(&mut self, rows: usize, cols: usize) {
|
||||
let state_changes = MainScreen::new(
|
||||
self.tokens.list.is_empty(),
|
||||
self.web_server.started,
|
||||
&self.web_server.error,
|
||||
&self.web_server.different_version_error,
|
||||
&self.web_server.base_url,
|
||||
self.web_server.ip,
|
||||
self.web_server.port,
|
||||
&self.state.session_name,
|
||||
self.web_server.sharing,
|
||||
self.ui.hover_coordinates,
|
||||
&self.state.info,
|
||||
&self.ui.link_executable,
|
||||
)
|
||||
.render(rows, cols);
|
||||
|
||||
self.ui.currently_hovering_over_link = state_changes.currently_hovering_over_link;
|
||||
self.ui.currently_hovering_over_unencrypted =
|
||||
state_changes.currently_hovering_over_unencrypted;
|
||||
self.ui.clickable_urls = state_changes.clickable_urls;
|
||||
}
|
||||
|
||||
fn render_token_screen(&self, rows: usize, cols: usize, token: &str) {
|
||||
let token_screen =
|
||||
TokenScreen::new(token.to_string(), self.web_server.error.clone(), rows, cols);
|
||||
token_screen.render();
|
||||
}
|
||||
|
||||
fn render_manage_tokens_screen(&self, rows: usize, cols: usize) {
|
||||
TokenManagementScreen::new(
|
||||
&self.tokens.list,
|
||||
self.tokens.selected_index,
|
||||
&self.tokens.renaming_token,
|
||||
&self.tokens.entering_new_name,
|
||||
&self.web_server.error,
|
||||
&self.state.info,
|
||||
rows,
|
||||
cols,
|
||||
)
|
||||
.render();
|
||||
}
|
||||
|
||||
fn retrieve_token_list(&mut self) {
|
||||
if let Err(e) = self.tokens.retrieve_list() {
|
||||
self.web_server.error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct WebServerState {
|
||||
started: bool,
|
||||
sharing: WebSharing,
|
||||
clients_allowed: bool,
|
||||
error: Option<String>,
|
||||
different_version_error: Option<String>,
|
||||
ip: Option<IpAddr>,
|
||||
port: Option<u16>,
|
||||
base_url: String,
|
||||
capability: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct UIState {
|
||||
hover_coordinates: Option<(usize, usize)>,
|
||||
clickable_urls: HashMap<CoordinatesInLine, String>,
|
||||
link_executable: Option<&'static str>,
|
||||
currently_hovering_over_link: bool,
|
||||
currently_hovering_over_unencrypted: bool,
|
||||
}
|
||||
|
||||
impl UIState {
|
||||
fn reset_render_state(&mut self) {
|
||||
self.currently_hovering_over_link = false;
|
||||
self.clickable_urls.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct TokenManager {
|
||||
list: Vec<(String, String)>,
|
||||
selected_index: Option<usize>,
|
||||
entering_new_name: Option<String>,
|
||||
renaming_token: Option<String>,
|
||||
}
|
||||
|
||||
impl TokenManager {
|
||||
fn retrieve_list(&mut self) -> Result<(), String> {
|
||||
match list_web_login_tokens() {
|
||||
Ok(tokens) => {
|
||||
self.list = tokens;
|
||||
Ok(())
|
||||
},
|
||||
Err(e) => Err(format!("Failed to retrieve login tokens: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_selected_token(&self) -> Option<&(String, String)> {
|
||||
self.selected_index.and_then(|i| self.list.get(i))
|
||||
}
|
||||
|
||||
fn adjust_selection_after_list_change(&mut self) -> bool {
|
||||
if self.list.is_empty() {
|
||||
self.selected_index = None;
|
||||
true // indicates should change to main screen
|
||||
} else if self.selected_index >= Some(self.list.len()) {
|
||||
self.selected_index = Some(self.list.len().saturating_sub(1));
|
||||
false
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn navigate_down(&mut self) -> bool {
|
||||
if let Some(ref mut index) = self.selected_index {
|
||||
*index = if *index < self.list.len().saturating_sub(1) {
|
||||
*index + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn navigate_up(&mut self) -> bool {
|
||||
if let Some(ref mut index) = self.selected_index {
|
||||
*index = if *index == 0 {
|
||||
self.list.len().saturating_sub(1)
|
||||
} else {
|
||||
*index - 1
|
||||
};
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn start_new_token_input(&mut self) {
|
||||
self.entering_new_name = Some(String::new());
|
||||
}
|
||||
|
||||
fn start_rename_input(&mut self) {
|
||||
self.renaming_token = Some(String::new());
|
||||
}
|
||||
|
||||
fn handle_text_input(&mut self, key: &KeyWithModifier) -> bool {
|
||||
match key.bare_key {
|
||||
BareKey::Char(c) if key.has_no_modifiers() => {
|
||||
if let Some(ref mut name) = self.entering_new_name {
|
||||
name.push(c);
|
||||
return true;
|
||||
}
|
||||
if let Some(ref mut name) = self.renaming_token {
|
||||
name.push(c);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
BareKey::Backspace if key.has_no_modifiers() => {
|
||||
if let Some(ref mut name) = self.entering_new_name {
|
||||
name.pop();
|
||||
return true;
|
||||
}
|
||||
if let Some(ref mut name) = self.renaming_token {
|
||||
name.pop();
|
||||
return true;
|
||||
}
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn finish_new_token_input(&mut self) -> Option<Option<String>> {
|
||||
self.entering_new_name
|
||||
.take()
|
||||
.map(|name| if name.is_empty() { None } else { Some(name) })
|
||||
}
|
||||
|
||||
fn finish_rename_input(&mut self) -> Option<String> {
|
||||
self.renaming_token.take()
|
||||
}
|
||||
|
||||
fn cancel_input(&mut self) -> bool {
|
||||
self.entering_new_name.take().is_some() || self.renaming_token.take().is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct AppState {
|
||||
session_name: Option<String>,
|
||||
own_plugin_id: Option<u32>,
|
||||
timer_running: bool,
|
||||
current_screen: Screen,
|
||||
previous_screen: Option<Screen>,
|
||||
info: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Screen {
|
||||
Main,
|
||||
Token(String),
|
||||
ManageTokens,
|
||||
}
|
||||
|
||||
impl Default for Screen {
|
||||
fn default() -> Self {
|
||||
Screen::Main
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Hash)]
|
||||
pub struct CoordinatesInLine {
|
||||
x: usize,
|
||||
y: usize,
|
||||
width: usize,
|
||||
}
|
||||
|
||||
impl CoordinatesInLine {
|
||||
pub fn new(x: usize, y: usize, width: usize) -> Self {
|
||||
CoordinatesInLine { x, y, width }
|
||||
}
|
||||
|
||||
pub fn contains(&self, x: usize, y: usize) -> bool {
|
||||
x >= self.x && x <= self.x + self.width && self.y == y
|
||||
}
|
||||
}
|
||||
301
default-plugins/share/src/main_screen.rs
Normal file
301
default-plugins/share/src/main_screen.rs
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
use super::CoordinatesInLine;
|
||||
use crate::ui_components::{
|
||||
hovering_on_line, render_text_with_underline, CurrentSessionSection, Usage,
|
||||
WebServerStatusSection,
|
||||
};
|
||||
use zellij_tile::prelude::*;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use url::Url;
|
||||
|
||||
pub struct MainScreenState {
|
||||
pub currently_hovering_over_link: bool,
|
||||
pub currently_hovering_over_unencrypted: bool,
|
||||
pub clickable_urls: HashMap<CoordinatesInLine, String>,
|
||||
}
|
||||
|
||||
pub struct MainScreen<'a> {
|
||||
token_list_is_empty: bool,
|
||||
web_server_started: bool,
|
||||
web_server_error: &'a Option<String>,
|
||||
web_server_different_version_error: &'a Option<String>,
|
||||
web_server_base_url: &'a String,
|
||||
web_server_ip: Option<std::net::IpAddr>,
|
||||
web_server_port: Option<u16>,
|
||||
session_name: &'a Option<String>,
|
||||
web_sharing: WebSharing,
|
||||
hover_coordinates: Option<(usize, usize)>,
|
||||
info: &'a Option<String>,
|
||||
link_executable: &'a Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> MainScreen<'a> {
|
||||
const TITLE_TEXT: &'static str = "Share Session Locally in the Browser";
|
||||
const WARNING_TEXT: &'static str =
|
||||
"[*] Connection unencrypted. Consider using an SSL certificate.";
|
||||
const MORE_INFO_TEXT: &'static str = "More info: ";
|
||||
const SSL_URL: &'static str = "https://zellij.dev/documentation/web-server-ssl";
|
||||
const HELP_TEXT_WITH_CLICK: &'static str = "Help: Click or Shift-Click to open in browser";
|
||||
const HELP_TEXT_SHIFT_ONLY: &'static str = "Help: Shift-Click to open in browser";
|
||||
pub fn new(
|
||||
token_list_is_empty: bool,
|
||||
web_server_started: bool,
|
||||
web_server_error: &'a Option<String>,
|
||||
web_server_different_version_error: &'a Option<String>,
|
||||
web_server_base_url: &'a String,
|
||||
web_server_ip: Option<std::net::IpAddr>,
|
||||
web_server_port: Option<u16>,
|
||||
session_name: &'a Option<String>,
|
||||
web_sharing: WebSharing,
|
||||
hover_coordinates: Option<(usize, usize)>,
|
||||
info: &'a Option<String>,
|
||||
link_executable: &'a Option<&'a str>,
|
||||
) -> Self {
|
||||
Self {
|
||||
token_list_is_empty,
|
||||
web_server_started,
|
||||
web_server_error,
|
||||
web_server_different_version_error,
|
||||
web_server_base_url,
|
||||
web_server_ip,
|
||||
web_server_port,
|
||||
session_name,
|
||||
web_sharing,
|
||||
hover_coordinates,
|
||||
info,
|
||||
link_executable,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(self, rows: usize, cols: usize) -> MainScreenState {
|
||||
let mut state = MainScreenState {
|
||||
currently_hovering_over_link: false,
|
||||
currently_hovering_over_unencrypted: false,
|
||||
clickable_urls: HashMap::new(),
|
||||
};
|
||||
|
||||
let layout = self.calculate_layout(rows, cols);
|
||||
self.render_content(&layout, &mut state);
|
||||
|
||||
state
|
||||
}
|
||||
|
||||
fn calculate_layout(&self, rows: usize, cols: usize) -> Layout {
|
||||
let usage = Usage::new(!self.token_list_is_empty);
|
||||
let web_server_status_section = WebServerStatusSection::new(
|
||||
self.web_server_started,
|
||||
self.web_server_error.clone(),
|
||||
self.web_server_different_version_error.clone(),
|
||||
self.web_server_base_url.clone(),
|
||||
self.connection_is_unencrypted(),
|
||||
);
|
||||
let current_session_section = CurrentSessionSection::new(
|
||||
self.web_server_started,
|
||||
self.web_server_ip,
|
||||
self.web_server_port,
|
||||
self.session_name.clone(),
|
||||
self.web_sharing,
|
||||
self.connection_is_unencrypted(),
|
||||
);
|
||||
|
||||
let title_width = Self::TITLE_TEXT.chars().count();
|
||||
|
||||
let (web_server_width, web_server_height) =
|
||||
web_server_status_section.web_server_status_width_and_height();
|
||||
let (current_session_width, current_session_height) =
|
||||
current_session_section.current_session_status_width_and_height();
|
||||
let (usage_width, usage_height) = usage.usage_width_and_height(cols);
|
||||
|
||||
let mut max_width = title_width
|
||||
.max(web_server_width)
|
||||
.max(current_session_width)
|
||||
.max(usage_width);
|
||||
|
||||
let mut total_height =
|
||||
2 + web_server_height + 1 + current_session_height + 1 + usage_height;
|
||||
|
||||
if self.connection_is_unencrypted() {
|
||||
let warning_width = self.unencrypted_warning_width();
|
||||
max_width = max_width.max(warning_width);
|
||||
total_height += 3;
|
||||
}
|
||||
|
||||
Layout {
|
||||
base_x: cols.saturating_sub(max_width) / 2,
|
||||
base_y: rows.saturating_sub(total_height) / 2,
|
||||
title_text: Self::TITLE_TEXT,
|
||||
web_server_height,
|
||||
usage_height,
|
||||
usage_width,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_content(&self, layout: &Layout, state: &mut MainScreenState) {
|
||||
let mut current_y = layout.base_y;
|
||||
|
||||
self.render_title(layout, current_y);
|
||||
current_y += 2;
|
||||
|
||||
current_y = self.render_web_server_section(layout, current_y, state);
|
||||
current_y = self.render_current_session_section(layout, current_y, state);
|
||||
current_y = self.render_usage_section(layout, current_y);
|
||||
current_y = self.render_warnings_and_help(layout, current_y, state);
|
||||
|
||||
self.render_info(layout, current_y);
|
||||
}
|
||||
|
||||
fn render_title(&self, layout: &Layout, y: usize) {
|
||||
let title = Text::new(layout.title_text).color_range(2, ..);
|
||||
print_text_with_coordinates(title, layout.base_x, y, None, None);
|
||||
}
|
||||
|
||||
fn render_web_server_section(
|
||||
&self,
|
||||
layout: &Layout,
|
||||
y: usize,
|
||||
state: &mut MainScreenState,
|
||||
) -> usize {
|
||||
let mut web_server_status_section = WebServerStatusSection::new(
|
||||
self.web_server_started,
|
||||
self.web_server_error.clone(),
|
||||
self.web_server_different_version_error.clone(),
|
||||
self.web_server_base_url.clone(),
|
||||
self.connection_is_unencrypted(),
|
||||
);
|
||||
|
||||
web_server_status_section.render_web_server_status(
|
||||
layout.base_x,
|
||||
y,
|
||||
self.hover_coordinates,
|
||||
);
|
||||
|
||||
state.currently_hovering_over_link |=
|
||||
web_server_status_section.currently_hovering_over_link;
|
||||
state.currently_hovering_over_unencrypted |=
|
||||
web_server_status_section.currently_hovering_over_unencrypted;
|
||||
|
||||
for (coordinates, url) in web_server_status_section.clickable_urls {
|
||||
state.clickable_urls.insert(coordinates, url);
|
||||
}
|
||||
|
||||
y + layout.web_server_height + 1
|
||||
}
|
||||
|
||||
fn render_current_session_section(
|
||||
&self,
|
||||
layout: &Layout,
|
||||
y: usize,
|
||||
state: &mut MainScreenState,
|
||||
) -> usize {
|
||||
let mut current_session_section = CurrentSessionSection::new(
|
||||
self.web_server_started,
|
||||
self.web_server_ip,
|
||||
self.web_server_port,
|
||||
self.session_name.clone(),
|
||||
self.web_sharing,
|
||||
self.connection_is_unencrypted(),
|
||||
);
|
||||
|
||||
current_session_section.render_current_session_status(
|
||||
layout.base_x,
|
||||
y,
|
||||
self.hover_coordinates,
|
||||
);
|
||||
|
||||
state.currently_hovering_over_link |= current_session_section.currently_hovering_over_link;
|
||||
|
||||
for (coordinates, url) in current_session_section.clickable_urls {
|
||||
state.clickable_urls.insert(coordinates, url);
|
||||
}
|
||||
|
||||
y + layout.web_server_height + 1
|
||||
}
|
||||
|
||||
fn render_usage_section(&self, layout: &Layout, y: usize) -> usize {
|
||||
let usage = Usage::new(!self.token_list_is_empty);
|
||||
usage.render_usage(layout.base_x, y, layout.usage_width);
|
||||
y + layout.usage_height + 1
|
||||
}
|
||||
|
||||
fn render_warnings_and_help(
|
||||
&self,
|
||||
layout: &Layout,
|
||||
mut y: usize,
|
||||
state: &mut MainScreenState,
|
||||
) -> usize {
|
||||
if self.connection_is_unencrypted() && self.web_server_started {
|
||||
self.render_unencrypted_warning(layout.base_x, y, state);
|
||||
y += 3;
|
||||
}
|
||||
|
||||
if state.currently_hovering_over_link {
|
||||
self.render_link_help(layout.base_x, y);
|
||||
y += 3;
|
||||
}
|
||||
|
||||
y
|
||||
}
|
||||
|
||||
fn render_info(&self, layout: &Layout, y: usize) {
|
||||
if let Some(info) = self.info {
|
||||
let info_text = Text::new(info).color_range(1, ..);
|
||||
print_text_with_coordinates(info_text, layout.base_x, y, None, None);
|
||||
}
|
||||
}
|
||||
|
||||
fn unencrypted_warning_width(&self) -> usize {
|
||||
let more_info_line = format!("{}{}", Self::MORE_INFO_TEXT, Self::SSL_URL);
|
||||
std::cmp::max(
|
||||
Self::WARNING_TEXT.chars().count(),
|
||||
more_info_line.chars().count(),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_unencrypted_warning(&self, x: usize, y: usize, state: &mut MainScreenState) {
|
||||
let warning_text = Text::new(Self::WARNING_TEXT).color_range(1, ..3);
|
||||
let more_info_line = Text::new(format!("{}{}", Self::MORE_INFO_TEXT, Self::SSL_URL));
|
||||
|
||||
let url_x = x + Self::MORE_INFO_TEXT.chars().count();
|
||||
let url_y = y + 1;
|
||||
let url_width = Self::SSL_URL.chars().count();
|
||||
|
||||
state.clickable_urls.insert(
|
||||
CoordinatesInLine::new(url_x, url_y, url_width),
|
||||
Self::SSL_URL.to_owned(),
|
||||
);
|
||||
|
||||
print_text_with_coordinates(warning_text, x, y, None, None);
|
||||
print_text_with_coordinates(more_info_line, x, y + 1, None, None);
|
||||
|
||||
if hovering_on_line(url_x, url_y, url_width, self.hover_coordinates) {
|
||||
state.currently_hovering_over_link = true;
|
||||
render_text_with_underline(url_x, url_y, Self::SSL_URL);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_link_help(&self, x: usize, y: usize) {
|
||||
let help_text = if self.link_executable.is_some() {
|
||||
Text::new(Self::HELP_TEXT_WITH_CLICK)
|
||||
.color_range(3, 6..=10)
|
||||
.color_range(3, 15..=25)
|
||||
} else {
|
||||
Text::new(Self::HELP_TEXT_SHIFT_ONLY).color_range(3, 6..=16)
|
||||
};
|
||||
print_text_with_coordinates(help_text, x, y, None, None);
|
||||
}
|
||||
pub fn connection_is_unencrypted(&self) -> bool {
|
||||
Url::parse(&self.web_server_base_url)
|
||||
.ok()
|
||||
.map(|b| b.scheme() == "http")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
struct Layout<'a> {
|
||||
base_x: usize,
|
||||
base_y: usize,
|
||||
title_text: &'a str,
|
||||
web_server_height: usize,
|
||||
usage_height: usize,
|
||||
usage_width: usize,
|
||||
}
|
||||
607
default-plugins/share/src/token_management_screen.rs
Normal file
607
default-plugins/share/src/token_management_screen.rs
Normal file
|
|
@ -0,0 +1,607 @@
|
|||
use zellij_tile::prelude::*;
|
||||
|
||||
struct ScreenContent {
|
||||
title: (String, Text),
|
||||
items: Vec<Vec<Text>>,
|
||||
help: (String, Text),
|
||||
status_message: Option<(String, Text)>,
|
||||
max_width: usize,
|
||||
new_token_item: Option<Vec<Text>>,
|
||||
}
|
||||
|
||||
struct Layout {
|
||||
base_x: usize,
|
||||
base_y: usize,
|
||||
title_x: usize,
|
||||
help_y: usize,
|
||||
status_y: usize,
|
||||
}
|
||||
|
||||
struct ScrollInfo {
|
||||
start_index: usize,
|
||||
end_index: usize,
|
||||
truncated_top: usize,
|
||||
truncated_bottom: usize,
|
||||
}
|
||||
|
||||
struct ColumnWidths {
|
||||
token: usize,
|
||||
date: usize,
|
||||
controls: usize,
|
||||
}
|
||||
|
||||
pub struct TokenManagementScreen<'a> {
|
||||
token_list: &'a Vec<(String, String)>,
|
||||
selected_list_index: Option<usize>,
|
||||
renaming_token: &'a Option<String>,
|
||||
entering_new_token_name: &'a Option<String>,
|
||||
error: &'a Option<String>,
|
||||
info: &'a Option<String>,
|
||||
rows: usize,
|
||||
cols: usize,
|
||||
}
|
||||
|
||||
impl<'a> TokenManagementScreen<'a> {
|
||||
pub fn new(
|
||||
token_list: &'a Vec<(String, String)>,
|
||||
selected_list_index: Option<usize>,
|
||||
renaming_token: &'a Option<String>,
|
||||
entering_new_token_name: &'a Option<String>,
|
||||
error: &'a Option<String>,
|
||||
info: &'a Option<String>,
|
||||
rows: usize,
|
||||
cols: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
token_list,
|
||||
selected_list_index,
|
||||
renaming_token,
|
||||
entering_new_token_name,
|
||||
error,
|
||||
info,
|
||||
rows,
|
||||
cols,
|
||||
}
|
||||
}
|
||||
pub fn render(&self) {
|
||||
let content = self.build_screen_content();
|
||||
let max_height = self.calculate_max_item_height();
|
||||
let scrolled_content = self.apply_scroll_truncation(content, max_height);
|
||||
let layout = self.calculate_layout(&scrolled_content);
|
||||
self.print_items_to_screen(scrolled_content, layout);
|
||||
}
|
||||
|
||||
fn calculate_column_widths(&self) -> ColumnWidths {
|
||||
let max_table_width = self.cols;
|
||||
|
||||
const MIN_TOKEN_WIDTH: usize = 10;
|
||||
const MIN_DATE_WIDTH: usize = 10; // Minimum for just date "YYYY-MM-DD"
|
||||
const MIN_CONTROLS_WIDTH: usize = 6; // Minimum for "(<x>, <r>)"
|
||||
const COLUMN_SPACING: usize = 2; // Space between columns
|
||||
|
||||
let min_total_width =
|
||||
MIN_TOKEN_WIDTH + MIN_DATE_WIDTH + MIN_CONTROLS_WIDTH + COLUMN_SPACING;
|
||||
|
||||
if max_table_width <= min_total_width {
|
||||
return ColumnWidths {
|
||||
token: MIN_TOKEN_WIDTH,
|
||||
date: MIN_DATE_WIDTH,
|
||||
controls: MIN_CONTROLS_WIDTH,
|
||||
};
|
||||
}
|
||||
|
||||
const PREFERRED_DATE_WIDTH: usize = 29; // "issued on YYYY-MM-DD HH:MM:SS"
|
||||
const PREFERRED_CONTROLS_WIDTH: usize = 24; // "(<x> revoke, <r> rename)"
|
||||
|
||||
let available_width = max_table_width.saturating_sub(COLUMN_SPACING);
|
||||
let preferred_fixed_width = PREFERRED_DATE_WIDTH + PREFERRED_CONTROLS_WIDTH;
|
||||
|
||||
if available_width >= preferred_fixed_width + MIN_TOKEN_WIDTH {
|
||||
// We can use preferred widths for date and controls
|
||||
ColumnWidths {
|
||||
token: available_width.saturating_sub(preferred_fixed_width),
|
||||
date: PREFERRED_DATE_WIDTH,
|
||||
controls: PREFERRED_CONTROLS_WIDTH,
|
||||
}
|
||||
} else {
|
||||
// Need to balance truncation across all columns
|
||||
let remaining_width = available_width
|
||||
.saturating_sub(MIN_TOKEN_WIDTH)
|
||||
.saturating_sub(MIN_DATE_WIDTH)
|
||||
.saturating_sub(MIN_CONTROLS_WIDTH);
|
||||
let extra_per_column = remaining_width / 3;
|
||||
|
||||
ColumnWidths {
|
||||
token: MIN_TOKEN_WIDTH + extra_per_column,
|
||||
date: MIN_DATE_WIDTH + extra_per_column,
|
||||
controls: MIN_CONTROLS_WIDTH + extra_per_column,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_token_name(&self, token: &str, max_width: usize) -> String {
|
||||
if token.chars().count() <= max_width {
|
||||
return token.to_string();
|
||||
}
|
||||
|
||||
if max_width <= 6 {
|
||||
// Too small to show anything meaningful
|
||||
return "[...]".to_string();
|
||||
}
|
||||
|
||||
let truncator = if max_width <= 10 { "[..]" } else { "[...]" };
|
||||
let truncator_len = truncator.chars().count();
|
||||
let remaining_chars = max_width.saturating_sub(truncator_len);
|
||||
let start_chars = remaining_chars / 2;
|
||||
let end_chars = remaining_chars.saturating_sub(start_chars);
|
||||
|
||||
let token_chars: Vec<char> = token.chars().collect();
|
||||
let start_part: String = token_chars.iter().take(start_chars).collect();
|
||||
let end_part: String = token_chars
|
||||
.iter()
|
||||
.rev()
|
||||
.take(end_chars)
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect();
|
||||
|
||||
format!("{}{}{}", start_part, truncator, end_part)
|
||||
}
|
||||
|
||||
fn format_date(
|
||||
&self,
|
||||
created_at: &str,
|
||||
max_width: usize,
|
||||
include_issued_prefix: bool,
|
||||
) -> String {
|
||||
let full_text = if include_issued_prefix {
|
||||
format!("issued on {}", created_at)
|
||||
} else {
|
||||
created_at.to_string()
|
||||
};
|
||||
|
||||
if full_text.chars().count() <= max_width {
|
||||
return full_text;
|
||||
}
|
||||
|
||||
// If we can't fit "issued on", use the date
|
||||
if !include_issued_prefix || created_at.chars().count() <= max_width {
|
||||
if created_at.chars().count() <= max_width {
|
||||
return created_at.to_string();
|
||||
}
|
||||
|
||||
// Truncate the date itself if needed
|
||||
let chars: Vec<char> = created_at.chars().collect();
|
||||
if max_width <= 3 {
|
||||
return "...".to_string();
|
||||
}
|
||||
let truncated: String = chars.iter().take(max_width - 3).collect();
|
||||
format!("{}...", truncated)
|
||||
} else {
|
||||
// Try without "issued on" prefix
|
||||
self.format_date(created_at, max_width, false)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_controls(&self, max_width: usize, is_selected: bool) -> String {
|
||||
if !is_selected {
|
||||
return " ".repeat(max_width);
|
||||
}
|
||||
|
||||
let full_controls = "(<x> revoke, <r> rename)";
|
||||
let short_controls = "(<x>, <r>)";
|
||||
|
||||
if full_controls.chars().count() <= max_width {
|
||||
full_controls.to_string()
|
||||
} else if short_controls.chars().count() <= max_width {
|
||||
// Pad the short controls to fill the available width
|
||||
let padding = max_width - short_controls.chars().count();
|
||||
format!("{}{}", short_controls, " ".repeat(padding))
|
||||
} else {
|
||||
// Very constrained space
|
||||
" ".repeat(max_width)
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_max_item_height(&self) -> usize {
|
||||
// Calculate fixed UI elements that are always present:
|
||||
// - 1 row for title
|
||||
// - 1 row for spacing after title (always preserved)
|
||||
// - 1 row for the "create new token" line (always visible)
|
||||
// - 1 row for spacing before help (always preserved)
|
||||
// - 1 row for help text (or status message - they're mutually exclusive)
|
||||
|
||||
let fixed_rows = 4; // title + spacing + help/status + spacing before help
|
||||
let create_new_token_rows = 1; // "create new token" line
|
||||
|
||||
let total_fixed_rows = fixed_rows + create_new_token_rows;
|
||||
|
||||
// Calculate available rows for token items
|
||||
let available_for_items = self.rows.saturating_sub(total_fixed_rows);
|
||||
|
||||
// Return at least 1 to avoid issues, but this will be the maximum height for token items only
|
||||
available_for_items.max(1)
|
||||
}
|
||||
|
||||
fn build_screen_content(&self) -> ScreenContent {
|
||||
let mut max_width = 0;
|
||||
let max_table_width = self.cols;
|
||||
let column_widths = self.calculate_column_widths();
|
||||
|
||||
let title_text = "List of Login Tokens";
|
||||
let title = Text::new(title_text).color_range(2, ..);
|
||||
max_width = std::cmp::max(max_width, title_text.len());
|
||||
|
||||
let mut items = vec![];
|
||||
for (i, (token, created_at)) in self.token_list.iter().enumerate() {
|
||||
let is_selected = Some(i) == self.selected_list_index;
|
||||
let (row_text, row_items) =
|
||||
self.create_token_item(token, created_at, is_selected, &column_widths);
|
||||
max_width = std::cmp::max(max_width, row_text.chars().count());
|
||||
items.push(row_items);
|
||||
}
|
||||
|
||||
let (new_token_text, new_token_row) = self.create_new_token_item(&column_widths);
|
||||
max_width = std::cmp::max(max_width, new_token_text.chars().count());
|
||||
|
||||
let (help_text, help_line) = self.create_help_line();
|
||||
max_width = std::cmp::max(max_width, help_text.chars().count());
|
||||
|
||||
let status_message = self.create_status_message();
|
||||
if let Some((ref text, _)) = status_message {
|
||||
max_width = std::cmp::max(max_width, text.chars().count());
|
||||
}
|
||||
|
||||
max_width = std::cmp::min(max_width, max_table_width);
|
||||
|
||||
ScreenContent {
|
||||
title: (title_text.to_string(), title),
|
||||
items,
|
||||
help: (help_text, help_line),
|
||||
status_message,
|
||||
max_width,
|
||||
new_token_item: Some(new_token_row),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_scroll_truncation(
|
||||
&self,
|
||||
mut content: ScreenContent,
|
||||
max_height: usize,
|
||||
) -> ScreenContent {
|
||||
let total_token_items = content.items.len(); // Only token items, not including "create new token"
|
||||
|
||||
// If all token items fit, no need to truncate
|
||||
if total_token_items <= max_height {
|
||||
return content;
|
||||
}
|
||||
|
||||
let scroll_info = self.calculate_scroll_info(total_token_items, max_height);
|
||||
|
||||
// Extract the visible range
|
||||
let mut visible_items: Vec<Vec<Text>> = content
|
||||
.items
|
||||
.into_iter()
|
||||
.skip(scroll_info.start_index)
|
||||
.take(
|
||||
scroll_info
|
||||
.end_index
|
||||
.saturating_sub(scroll_info.start_index),
|
||||
)
|
||||
.collect();
|
||||
|
||||
// Add truncation indicators
|
||||
if scroll_info.truncated_top > 0 {
|
||||
self.add_truncation_indicator(&mut visible_items[0], scroll_info.truncated_top);
|
||||
}
|
||||
|
||||
if scroll_info.truncated_bottom > 0 {
|
||||
let last_idx = visible_items.len().saturating_sub(1);
|
||||
self.add_truncation_indicator(
|
||||
&mut visible_items[last_idx],
|
||||
scroll_info.truncated_bottom,
|
||||
);
|
||||
}
|
||||
|
||||
content.items = visible_items;
|
||||
content
|
||||
}
|
||||
|
||||
fn calculate_scroll_info(&self, total_token_items: usize, max_height: usize) -> ScrollInfo {
|
||||
// Only consider token items for scrolling (not the "create new token" line)
|
||||
// The "create new token" line is always visible and handled separately
|
||||
|
||||
// Find the selected index within the token list only
|
||||
let selected_index = if let Some(idx) = self.selected_list_index {
|
||||
idx
|
||||
} else {
|
||||
// If "create new token" is selected or no selection,
|
||||
// we don't need to center anything in the token list
|
||||
0
|
||||
};
|
||||
|
||||
// Calculate how many items to show above and below the selected item
|
||||
let items_above = max_height / 2;
|
||||
let items_below = max_height.saturating_sub(items_above).saturating_sub(1); // -1 for the selected item itself
|
||||
|
||||
// Calculate the start and end indices
|
||||
let start_index = if selected_index < items_above {
|
||||
0
|
||||
} else if selected_index + items_below >= total_token_items {
|
||||
total_token_items.saturating_sub(max_height)
|
||||
} else {
|
||||
selected_index.saturating_sub(items_above)
|
||||
};
|
||||
|
||||
let end_index = std::cmp::min(start_index + max_height, total_token_items);
|
||||
|
||||
ScrollInfo {
|
||||
start_index,
|
||||
end_index,
|
||||
truncated_top: start_index,
|
||||
truncated_bottom: total_token_items.saturating_sub(end_index),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_truncation_indicator(&self, row: &mut Vec<Text>, count: usize) {
|
||||
let indicator = format!("+[{}]", count);
|
||||
|
||||
// Replace the last cell (controls column) with the truncation indicator
|
||||
if let Some(last_cell) = row.last_mut() {
|
||||
*last_cell = Text::new(&indicator).color_range(1, ..);
|
||||
}
|
||||
}
|
||||
|
||||
fn create_token_item(
|
||||
&self,
|
||||
token: &str,
|
||||
created_at: &str,
|
||||
is_selected: bool,
|
||||
column_widths: &ColumnWidths,
|
||||
) -> (String, Vec<Text>) {
|
||||
if is_selected {
|
||||
if let Some(new_name) = &self.renaming_token {
|
||||
self.create_renaming_item(new_name, created_at, column_widths)
|
||||
} else {
|
||||
self.create_selected_item(token, created_at, column_widths)
|
||||
}
|
||||
} else {
|
||||
self.create_regular_item(token, created_at, column_widths)
|
||||
}
|
||||
}
|
||||
|
||||
fn create_renaming_item(
|
||||
&self,
|
||||
new_name: &str,
|
||||
created_at: &str,
|
||||
column_widths: &ColumnWidths,
|
||||
) -> (String, Vec<Text>) {
|
||||
let truncated_name =
|
||||
self.truncate_token_name(new_name, column_widths.token.saturating_sub(1)); // -1 for cursor
|
||||
let item_text = format!("{}_", truncated_name);
|
||||
let date_text = self.format_date(created_at, column_widths.date, true);
|
||||
let controls_text = " ".repeat(column_widths.controls);
|
||||
|
||||
let token_end = truncated_name.chars().count();
|
||||
let items = vec![
|
||||
Text::new(&item_text)
|
||||
.color_range(0, ..token_end + 1)
|
||||
.selected(),
|
||||
Text::new(&date_text),
|
||||
Text::new(&controls_text),
|
||||
];
|
||||
(
|
||||
format!("{} {} {}", item_text, date_text, controls_text),
|
||||
items,
|
||||
)
|
||||
}
|
||||
|
||||
fn create_selected_item(
|
||||
&self,
|
||||
token: &str,
|
||||
created_at: &str,
|
||||
column_widths: &ColumnWidths,
|
||||
) -> (String, Vec<Text>) {
|
||||
let mut item_text = self.truncate_token_name(token, column_widths.token);
|
||||
if item_text.is_empty() {
|
||||
// otherwise the table gets messed up
|
||||
item_text.push(' ');
|
||||
};
|
||||
let date_text = self.format_date(created_at, column_widths.date, true);
|
||||
let controls_text = self.format_controls(column_widths.controls, true);
|
||||
|
||||
// Determine highlight ranges for controls based on the actual content
|
||||
let (x_range, r_range) = if controls_text.contains("revoke") {
|
||||
// Full controls: "(<x> revoke, <r> rename)"
|
||||
(1..=3, 13..=15)
|
||||
} else {
|
||||
// Short controls: "(<x>, <r>)"
|
||||
(1..=3, 6..=8)
|
||||
};
|
||||
|
||||
let controls_colored = if controls_text.trim().is_empty() {
|
||||
Text::new(&controls_text).selected()
|
||||
} else {
|
||||
Text::new(&controls_text)
|
||||
.color_range(3, x_range)
|
||||
.color_range(3, r_range)
|
||||
.selected()
|
||||
};
|
||||
|
||||
let items = vec![
|
||||
Text::new(&item_text).color_range(0, ..).selected(),
|
||||
Text::new(&date_text).selected(),
|
||||
controls_colored,
|
||||
];
|
||||
|
||||
(
|
||||
format!("{} {} {}", item_text, date_text, controls_text),
|
||||
items,
|
||||
)
|
||||
}
|
||||
|
||||
fn create_regular_item(
|
||||
&self,
|
||||
token: &str,
|
||||
created_at: &str,
|
||||
column_widths: &ColumnWidths,
|
||||
) -> (String, Vec<Text>) {
|
||||
let mut item_text = self.truncate_token_name(token, column_widths.token);
|
||||
if item_text.is_empty() {
|
||||
// otherwise the table gets messed up
|
||||
item_text.push(' ');
|
||||
};
|
||||
let date_text = self.format_date(created_at, column_widths.date, true);
|
||||
let controls_text = " ".repeat(column_widths.controls);
|
||||
|
||||
let items = vec![
|
||||
Text::new(&item_text).color_range(0, ..),
|
||||
Text::new(&date_text),
|
||||
Text::new(&controls_text),
|
||||
];
|
||||
(
|
||||
format!("{} {} {}", item_text, date_text, controls_text),
|
||||
items,
|
||||
)
|
||||
}
|
||||
|
||||
fn create_new_token_item(&self, column_widths: &ColumnWidths) -> (String, Vec<Text>) {
|
||||
let create_new_token_text = "<n> - create new token".to_string();
|
||||
let short_create_text = "<n> - new".to_string();
|
||||
let date_placeholder = " ".repeat(column_widths.date);
|
||||
let controls_placeholder = " ".repeat(column_widths.controls);
|
||||
|
||||
if let Some(name) = &self.entering_new_token_name {
|
||||
let truncated_name =
|
||||
self.truncate_token_name(name, column_widths.token.saturating_sub(1)); // -1 for cursor
|
||||
let text = format!("{}_", truncated_name);
|
||||
let item = vec![
|
||||
Text::new(&text).color_range(3, ..),
|
||||
Text::new(&date_placeholder),
|
||||
Text::new(&controls_placeholder),
|
||||
];
|
||||
(
|
||||
format!("{} {} {}", text, date_placeholder, controls_placeholder),
|
||||
item,
|
||||
)
|
||||
} else {
|
||||
// Check if the full text fits, otherwise use the short version
|
||||
let text_to_use = if create_new_token_text.chars().count() <= column_widths.token {
|
||||
&create_new_token_text
|
||||
} else {
|
||||
&short_create_text
|
||||
};
|
||||
|
||||
let item = vec![
|
||||
Text::new(text_to_use).color_range(3, 0..=2),
|
||||
Text::new(&date_placeholder),
|
||||
Text::new(&controls_placeholder),
|
||||
];
|
||||
(
|
||||
format!(
|
||||
"{} {} {}",
|
||||
text_to_use, date_placeholder, controls_placeholder
|
||||
),
|
||||
item,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn create_help_line(&self) -> (String, Text) {
|
||||
let (text, highlight_range) = if self.entering_new_token_name.is_some() {
|
||||
(
|
||||
"Help: Enter optional name for new token, <Enter> to submit",
|
||||
41..=47,
|
||||
)
|
||||
} else if self.renaming_token.is_some() {
|
||||
(
|
||||
"Help: Enter new name for this token, <Enter> to submit",
|
||||
39..=45,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"Help: <Ctrl x> - revoke all tokens, <Esc> - go back",
|
||||
6..=13,
|
||||
)
|
||||
};
|
||||
|
||||
let mut help_line = Text::new(text).color_range(3, highlight_range);
|
||||
|
||||
// Add second highlight for the back option
|
||||
if self.entering_new_token_name.is_none() && self.renaming_token.is_none() {
|
||||
help_line = help_line.color_range(3, 36..=40);
|
||||
}
|
||||
|
||||
(text.to_string(), help_line)
|
||||
}
|
||||
|
||||
fn create_status_message(&self) -> Option<(String, Text)> {
|
||||
if let Some(error) = &self.error {
|
||||
Some((error.clone(), Text::new(error).color_range(3, ..)))
|
||||
} else if let Some(info) = &self.info {
|
||||
Some((info.clone(), Text::new(info).color_range(1, ..)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_layout(&self, content: &ScreenContent) -> Layout {
|
||||
// Calculate fixed UI elements that must always be present:
|
||||
// - 1 row for title
|
||||
// - 1 row for spacing after title (always preserved)
|
||||
// - token items (variable, potentially truncated)
|
||||
// - 1 row for "create new token" line
|
||||
// - 1 row for spacing before help (always preserved)
|
||||
// - 1 row for help text OR status message (mutually exclusive now)
|
||||
|
||||
let fixed_ui_rows = 4; // title + spacing after title + spacing before help + help/status
|
||||
let create_new_token_rows = 1;
|
||||
let token_item_rows = content.items.len();
|
||||
|
||||
let total_content_rows = fixed_ui_rows + create_new_token_rows + token_item_rows;
|
||||
|
||||
// Only add top/bottom padding if we have extra space
|
||||
let base_y = if total_content_rows < self.rows {
|
||||
// We have room for padding - center the content
|
||||
(self.rows.saturating_sub(total_content_rows)) / 2
|
||||
} else {
|
||||
// No room for padding - start at the top
|
||||
0
|
||||
};
|
||||
|
||||
// Calculate positions relative to base_y
|
||||
let item_start_y = base_y + 2; // title + spacing after title
|
||||
let new_token_y = item_start_y + token_item_rows;
|
||||
let help_y = new_token_y + 1 + 1; // new token line + spacing before help
|
||||
|
||||
Layout {
|
||||
base_x: (self.cols.saturating_sub(content.max_width) as f64 / 2.0).floor() as usize,
|
||||
base_y,
|
||||
title_x: self.cols.saturating_sub(content.title.0.len()) / 2,
|
||||
help_y,
|
||||
status_y: help_y, // Status message uses the same position as help
|
||||
}
|
||||
}
|
||||
|
||||
fn print_items_to_screen(&self, content: ScreenContent, layout: Layout) {
|
||||
print_text_with_coordinates(content.title.1, layout.title_x, layout.base_y, None, None);
|
||||
|
||||
let mut table = Table::new().add_row(vec![" ", " ", " "]);
|
||||
for item in content.items.into_iter() {
|
||||
table = table.add_styled_row(item);
|
||||
}
|
||||
|
||||
if let Some(new_token_item) = content.new_token_item {
|
||||
table = table.add_styled_row(new_token_item);
|
||||
}
|
||||
|
||||
print_table_with_coordinates(table, layout.base_x, layout.base_y + 1, None, None);
|
||||
|
||||
if let Some((_, status_text)) = content.status_message {
|
||||
print_text_with_coordinates(status_text, layout.base_x, layout.status_y, None, None);
|
||||
} else {
|
||||
print_text_with_coordinates(content.help.1, layout.base_x, layout.help_y, None, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
192
default-plugins/share/src/token_screen.rs
Normal file
192
default-plugins/share/src/token_screen.rs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
use zellij_tile::prelude::*;
|
||||
|
||||
// Constants for text content
|
||||
const TOKEN_LABEL_LONG: &str = "New log-in token: ";
|
||||
const TOKEN_LABEL_SHORT: &str = "Token: ";
|
||||
const EXPLANATION_1_LONG: &str = "Use this token to log-in from the browser.";
|
||||
const EXPLANATION_1_SHORT: &str = "Use to log-in from the browser.";
|
||||
const EXPLANATION_2_LONG: &str =
|
||||
"Copy this token, because it will not be saved and can't be retrieved.";
|
||||
const EXPLANATION_2_SHORT: &str = "It will not be saved and can't be retrieved.";
|
||||
const EXPLANATION_3_LONG: &str = "If lost, it can always be revoked and a new one generated.";
|
||||
const EXPLANATION_3_SHORT: &str = "It can always be revoked and a regenerated.";
|
||||
const ESC_INSTRUCTION: &str = "<Esc> - go back";
|
||||
|
||||
// Screen layout constants
|
||||
const SCREEN_HEIGHT: usize = 7;
|
||||
const TOKEN_Y_OFFSET: usize = 0;
|
||||
const EXPLANATION_1_Y_OFFSET: usize = 2;
|
||||
const EXPLANATION_2_Y_OFFSET: usize = 4;
|
||||
const EXPLANATION_3_Y_OFFSET: usize = 5;
|
||||
const ESC_Y_OFFSET: usize = 7;
|
||||
const ERROR_Y_OFFSET: usize = 8;
|
||||
|
||||
struct TextVariant {
|
||||
long: &'static str,
|
||||
short: &'static str,
|
||||
}
|
||||
|
||||
impl TextVariant {
|
||||
fn select(&self, cols: usize) -> &'static str {
|
||||
if cols >= self.long.chars().count() {
|
||||
self.long
|
||||
} else {
|
||||
self.short
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TokenScreen {
|
||||
token: String,
|
||||
web_server_error: Option<String>,
|
||||
rows: usize,
|
||||
cols: usize,
|
||||
}
|
||||
|
||||
impl TokenScreen {
|
||||
pub fn new(token: String, web_server_error: Option<String>, rows: usize, cols: usize) -> Self {
|
||||
Self {
|
||||
token,
|
||||
web_server_error,
|
||||
rows,
|
||||
cols,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&self) {
|
||||
let elements = self.prepare_screen_elements();
|
||||
let width = self.calculate_max_width(&elements);
|
||||
let (base_x, base_y) = self.calculate_base_position(width);
|
||||
|
||||
self.render_elements(&elements, base_x, base_y);
|
||||
self.render_error_if_present(base_x, base_y);
|
||||
}
|
||||
|
||||
fn prepare_screen_elements(&self) -> ScreenElements {
|
||||
let token_variant = TextVariant {
|
||||
long: TOKEN_LABEL_LONG,
|
||||
short: TOKEN_LABEL_SHORT,
|
||||
};
|
||||
|
||||
let explanation_variants = [
|
||||
TextVariant {
|
||||
long: EXPLANATION_1_LONG,
|
||||
short: EXPLANATION_1_SHORT,
|
||||
},
|
||||
TextVariant {
|
||||
long: EXPLANATION_2_LONG,
|
||||
short: EXPLANATION_2_SHORT,
|
||||
},
|
||||
TextVariant {
|
||||
long: EXPLANATION_3_LONG,
|
||||
short: EXPLANATION_3_SHORT,
|
||||
},
|
||||
];
|
||||
|
||||
let token_label = token_variant.select(
|
||||
self.cols
|
||||
.saturating_sub(self.token.chars().count().saturating_sub(1)),
|
||||
);
|
||||
let token_text = format!("{}{}", token_label, self.token);
|
||||
let token_element = self.create_token_text_element(&token_text, token_label);
|
||||
|
||||
let explanation_texts: Vec<&str> = explanation_variants
|
||||
.iter()
|
||||
.map(|variant| variant.select(self.cols))
|
||||
.collect();
|
||||
|
||||
let explanation_elements: Vec<Text> = explanation_texts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &text)| {
|
||||
if i == 0 {
|
||||
Text::new(text).color_range(0, ..)
|
||||
} else {
|
||||
Text::new(text)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let esc_element = Text::new(ESC_INSTRUCTION).color_range(3, ..=4);
|
||||
|
||||
ScreenElements {
|
||||
token: token_element,
|
||||
token_text,
|
||||
explanation_texts,
|
||||
explanations: explanation_elements,
|
||||
esc: esc_element,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_token_text_element(&self, token_text: &str, token_label: &str) -> Text {
|
||||
Text::new(token_text).color_range(2, ..token_label.chars().count())
|
||||
}
|
||||
|
||||
fn calculate_max_width(&self, elements: &ScreenElements) -> usize {
|
||||
let token_width = elements.token_text.chars().count();
|
||||
let explanation_widths = elements
|
||||
.explanation_texts
|
||||
.iter()
|
||||
.map(|text| text.chars().count());
|
||||
let esc_width = ESC_INSTRUCTION.chars().count();
|
||||
|
||||
[token_width, esc_width]
|
||||
.into_iter()
|
||||
.chain(explanation_widths)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn calculate_base_position(&self, width: usize) -> (usize, usize) {
|
||||
let base_x = self.cols.saturating_sub(width) / 2;
|
||||
let base_y = self.rows.saturating_sub(SCREEN_HEIGHT) / 2;
|
||||
(base_x, base_y)
|
||||
}
|
||||
|
||||
fn render_elements(&self, elements: &ScreenElements, base_x: usize, base_y: usize) {
|
||||
print_text_with_coordinates(
|
||||
elements.token.clone(),
|
||||
base_x,
|
||||
base_y + TOKEN_Y_OFFSET,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let y_offsets = [
|
||||
EXPLANATION_1_Y_OFFSET,
|
||||
EXPLANATION_2_Y_OFFSET,
|
||||
EXPLANATION_3_Y_OFFSET,
|
||||
];
|
||||
for (explanation, &y_offset) in elements.explanations.iter().zip(y_offsets.iter()) {
|
||||
print_text_with_coordinates(explanation.clone(), base_x, base_y + y_offset, None, None);
|
||||
}
|
||||
|
||||
print_text_with_coordinates(
|
||||
elements.esc.clone(),
|
||||
base_x,
|
||||
base_y + ESC_Y_OFFSET,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_error_if_present(&self, base_x: usize, base_y: usize) {
|
||||
if let Some(error) = &self.web_server_error {
|
||||
print_text_with_coordinates(
|
||||
Text::new(error).color_range(3, ..),
|
||||
base_x,
|
||||
base_y + ERROR_Y_OFFSET,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ScreenElements {
|
||||
token: Text,
|
||||
token_text: String,
|
||||
explanation_texts: Vec<&'static str>,
|
||||
explanations: Vec<Text>,
|
||||
esc: Text,
|
||||
}
|
||||
637
default-plugins/share/src/ui_components.rs
Normal file
637
default-plugins/share/src/ui_components.rs
Normal file
|
|
@ -0,0 +1,637 @@
|
|||
use std::net::IpAddr;
|
||||
use zellij_tile::prelude::*;
|
||||
|
||||
use crate::CoordinatesInLine;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub const USAGE_TITLE: &str = "How it works:";
|
||||
pub const FIRST_TIME_USAGE_TITLE: &str = "Before logging in for the first time:";
|
||||
pub const FIRST_TIME_BULLETIN_1: &str = "- Press <t> to generate a login token";
|
||||
pub const BULLETIN_1_FULL: &str = "- Visit base URL to start a new session";
|
||||
pub const BULLETIN_1_SHORT: &str = "- Base URL: new session";
|
||||
pub const BULLETIN_2_FULL: &str = "- Follow base URL with a session name to attach to or create it";
|
||||
pub const BULLETIN_2_SHORT: &str = "- Base URL + session name: attach or create";
|
||||
pub const BULLETIN_3_FULL: &str =
|
||||
"- By default sessions not started from the web must be explicitly shared";
|
||||
pub const BULLETIN_3_SHORT: &str = "- Sessions not started from the web must be explicitly shared";
|
||||
pub const BULLETIN_4: &str = "- <t> manage login tokens";
|
||||
|
||||
pub const WEB_SERVER_TITLE: &str = "Web server: ";
|
||||
pub const WEB_SERVER_RUNNING: &str = "RUNNING ";
|
||||
pub const WEB_SERVER_NOT_RUNNING: &str = "NOT RUNNING";
|
||||
pub const WEB_SERVER_INCOMPATIBLE_PREFIX: &str = "RUNNING INCOMPATIBLE VERSION ";
|
||||
pub const CTRL_C_STOP: &str = "(<Ctrl c> - Stop)";
|
||||
pub const CTRL_C_STOP_OTHER: &str = "<Ctrl c> - Stop other server";
|
||||
pub const PRESS_ENTER_START: &str = "Press <ENTER> to start";
|
||||
pub const ERROR_PREFIX: &str = "ERROR: ";
|
||||
pub const URL_TITLE: &str = "URL: ";
|
||||
pub const UNENCRYPTED_MARKER: &str = " [*]";
|
||||
|
||||
pub const CURRENT_SESSION_TITLE: &str = "Current session: ";
|
||||
pub const SESSION_URL_TITLE: &str = "Session URL: ";
|
||||
pub const SHARING_STATUS: &str = "SHARING (<SPACE> - Stop Sharing)";
|
||||
pub const SHARING_DISABLED: &str = "SHARING IS DISABLED";
|
||||
pub const NOT_SHARING: &str = "NOT SHARING";
|
||||
pub const PRESS_SPACE_SHARE: &str = "Press <SPACE> to share";
|
||||
pub const WEB_SERVER_OFFLINE: &str = "...but web server is offline";
|
||||
|
||||
pub const COLOR_INDEX_0: usize = 0;
|
||||
pub const COLOR_INDEX_1: usize = 1;
|
||||
pub const COLOR_INDEX_2: usize = 2;
|
||||
pub const COLOR_HIGHLIGHT: usize = 3;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ColorRange {
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
pub color: usize,
|
||||
}
|
||||
|
||||
// TODO: move this API to zellij-tile
|
||||
#[derive(Debug)]
|
||||
pub struct ColoredTextBuilder {
|
||||
text: String,
|
||||
ranges: Vec<ColorRange>,
|
||||
}
|
||||
|
||||
impl ColoredTextBuilder {
|
||||
pub fn new(text: String) -> Self {
|
||||
Self {
|
||||
text,
|
||||
ranges: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn highlight_substring(mut self, substring: &str, color: usize) -> Self {
|
||||
if let Some(start) = self.text.find(substring) {
|
||||
let end = start + substring.chars().count();
|
||||
self.ranges.push(ColorRange { start, end, color });
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn highlight_range(mut self, start: usize, end: usize, color: usize) -> Self {
|
||||
self.ranges.push(ColorRange { start, end, color });
|
||||
self
|
||||
}
|
||||
|
||||
pub fn highlight_from_start(mut self, start: usize, color: usize) -> Self {
|
||||
let end = self.text.chars().count();
|
||||
self.ranges.push(ColorRange { start, end, color });
|
||||
self
|
||||
}
|
||||
|
||||
pub fn highlight_all(mut self, color: usize) -> Self {
|
||||
let end = self.text.chars().count();
|
||||
self.ranges.push(ColorRange {
|
||||
start: 0,
|
||||
end,
|
||||
color,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> (Text, usize) {
|
||||
let length = self.text.chars().count();
|
||||
let mut text_component = Text::new(self.text);
|
||||
|
||||
for range in self.ranges {
|
||||
text_component = text_component.color_range(range.color, range.start..range.end);
|
||||
}
|
||||
|
||||
(text_component, length)
|
||||
}
|
||||
}
|
||||
|
||||
// create titled text with different colors for title and value
|
||||
fn create_titled_text(
|
||||
title: &str,
|
||||
value: &str,
|
||||
title_color: usize,
|
||||
value_color: usize,
|
||||
) -> (Text, usize) {
|
||||
let full_text = format!("{}{}", title, value);
|
||||
ColoredTextBuilder::new(full_text)
|
||||
.highlight_range(0, title.chars().count(), title_color)
|
||||
.highlight_from_start(title.chars().count(), value_color)
|
||||
.build()
|
||||
}
|
||||
|
||||
// to create text with a highlighted shortcut key
|
||||
fn create_highlighted_shortcut(text: &str, shortcut: &str, color: usize) -> (Text, usize) {
|
||||
ColoredTextBuilder::new(text.to_string())
|
||||
.highlight_substring(shortcut, color)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn get_text_with_fallback(
|
||||
full_text: &'static str,
|
||||
short_text: &'static str,
|
||||
max_width: usize,
|
||||
) -> &'static str {
|
||||
if full_text.chars().count() <= max_width {
|
||||
full_text
|
||||
} else {
|
||||
short_text
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_max_length(texts: &[&str]) -> usize {
|
||||
texts
|
||||
.iter()
|
||||
.map(|text| text.chars().count())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn format_url_with_encryption_marker(base_url: &str, is_unencrypted: bool) -> String {
|
||||
if is_unencrypted {
|
||||
format!("{}{}", base_url, UNENCRYPTED_MARKER)
|
||||
} else {
|
||||
base_url.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Usage {
|
||||
has_login_tokens: bool,
|
||||
first_time_usage_title: &'static str,
|
||||
first_time_bulletin_1: &'static str,
|
||||
usage_title: &'static str,
|
||||
bulletin_1_full: &'static str,
|
||||
bulletin_1_short: &'static str,
|
||||
bulletin_2_full: &'static str,
|
||||
bulletin_2_short: &'static str,
|
||||
bulletin_3_full: &'static str,
|
||||
bulletin_3_short: &'static str,
|
||||
bulletin_4: &'static str,
|
||||
}
|
||||
|
||||
impl Usage {
|
||||
pub fn new(has_login_tokens: bool) -> Self {
|
||||
Usage {
|
||||
has_login_tokens,
|
||||
usage_title: USAGE_TITLE,
|
||||
bulletin_1_full: BULLETIN_1_FULL,
|
||||
bulletin_1_short: BULLETIN_1_SHORT,
|
||||
bulletin_2_full: BULLETIN_2_FULL,
|
||||
bulletin_2_short: BULLETIN_2_SHORT,
|
||||
bulletin_3_full: BULLETIN_3_FULL,
|
||||
bulletin_3_short: BULLETIN_3_SHORT,
|
||||
bulletin_4: BULLETIN_4,
|
||||
first_time_usage_title: FIRST_TIME_USAGE_TITLE,
|
||||
first_time_bulletin_1: FIRST_TIME_BULLETIN_1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn usage_width_and_height(&self, max_width: usize) -> (usize, usize) {
|
||||
if self.has_login_tokens {
|
||||
self.full_usage_width_and_height(max_width)
|
||||
} else {
|
||||
self.first_time_usage_width_and_height(max_width)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn full_usage_width_and_height(&self, max_width: usize) -> (usize, usize) {
|
||||
let bulletin_1 =
|
||||
get_text_with_fallback(self.bulletin_1_full, self.bulletin_1_short, max_width);
|
||||
let bulletin_2 =
|
||||
get_text_with_fallback(self.bulletin_2_full, self.bulletin_2_short, max_width);
|
||||
let bulletin_3 =
|
||||
get_text_with_fallback(self.bulletin_3_full, self.bulletin_3_short, max_width);
|
||||
|
||||
let texts = &[
|
||||
self.usage_title,
|
||||
bulletin_1,
|
||||
bulletin_2,
|
||||
bulletin_3,
|
||||
self.bulletin_4,
|
||||
];
|
||||
let width = calculate_max_length(texts);
|
||||
let height = 5;
|
||||
(width, height)
|
||||
}
|
||||
|
||||
pub fn first_time_usage_width_and_height(&self, _max_width: usize) -> (usize, usize) {
|
||||
let texts = &[self.first_time_usage_title, self.first_time_bulletin_1];
|
||||
let width = calculate_max_length(texts);
|
||||
let height = 2;
|
||||
(width, height)
|
||||
}
|
||||
|
||||
pub fn render_usage(&self, x: usize, y: usize, max_width: usize) {
|
||||
if self.has_login_tokens {
|
||||
self.render_full_usage(x, y, max_width)
|
||||
} else {
|
||||
self.render_first_time_usage(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_full_usage(&self, x: usize, y: usize, max_width: usize) {
|
||||
let bulletin_1 =
|
||||
get_text_with_fallback(self.bulletin_1_full, self.bulletin_1_short, max_width);
|
||||
let bulletin_2 =
|
||||
get_text_with_fallback(self.bulletin_2_full, self.bulletin_2_short, max_width);
|
||||
let bulletin_3 =
|
||||
get_text_with_fallback(self.bulletin_3_full, self.bulletin_3_short, max_width);
|
||||
|
||||
let usage_title = ColoredTextBuilder::new(self.usage_title.to_string())
|
||||
.highlight_all(COLOR_INDEX_2)
|
||||
.build()
|
||||
.0;
|
||||
|
||||
let bulletin_1_text = Text::new(bulletin_1);
|
||||
let bulletin_2_text = Text::new(bulletin_2);
|
||||
let bulletin_3_text = Text::new(bulletin_3);
|
||||
|
||||
let bulletin_4_text =
|
||||
create_highlighted_shortcut(self.bulletin_4, "<t>", COLOR_HIGHLIGHT).0;
|
||||
|
||||
let texts_and_positions = vec![
|
||||
(usage_title, y),
|
||||
(bulletin_1_text, y + 1),
|
||||
(bulletin_2_text, y + 2),
|
||||
(bulletin_3_text, y + 3),
|
||||
(bulletin_4_text, y + 4),
|
||||
];
|
||||
|
||||
for (text, y_pos) in texts_and_positions {
|
||||
print_text_with_coordinates(text, x, y_pos, None, None);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_first_time_usage(&self, x: usize, y: usize) {
|
||||
let usage_title = ColoredTextBuilder::new(self.first_time_usage_title.to_string())
|
||||
.highlight_all(COLOR_INDEX_1)
|
||||
.build()
|
||||
.0;
|
||||
|
||||
let bulletin_1 =
|
||||
create_highlighted_shortcut(self.first_time_bulletin_1, "<t>", COLOR_HIGHLIGHT).0;
|
||||
|
||||
print_text_with_coordinates(usage_title, x, y, None, None);
|
||||
print_text_with_coordinates(bulletin_1, x, y + 1, None, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct WebServerStatusSection {
|
||||
web_server_started: bool,
|
||||
web_server_base_url: String,
|
||||
web_server_error: Option<String>,
|
||||
web_server_different_version_error: Option<String>,
|
||||
connection_is_unencrypted: bool,
|
||||
pub clickable_urls: HashMap<CoordinatesInLine, String>,
|
||||
pub currently_hovering_over_link: bool,
|
||||
pub currently_hovering_over_unencrypted: bool,
|
||||
}
|
||||
|
||||
impl WebServerStatusSection {
|
||||
pub fn new(
|
||||
web_server_started: bool,
|
||||
web_server_error: Option<String>,
|
||||
web_server_different_version_error: Option<String>,
|
||||
web_server_base_url: String,
|
||||
connection_is_unencrypted: bool,
|
||||
) -> Self {
|
||||
WebServerStatusSection {
|
||||
web_server_started,
|
||||
clickable_urls: HashMap::new(),
|
||||
currently_hovering_over_link: false,
|
||||
currently_hovering_over_unencrypted: false,
|
||||
web_server_error,
|
||||
web_server_different_version_error,
|
||||
web_server_base_url,
|
||||
connection_is_unencrypted,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn web_server_status_width_and_height(&self) -> (usize, usize) {
|
||||
let mut max_len = self.web_server_status_line().1;
|
||||
|
||||
if let Some(error) = &self.web_server_error {
|
||||
max_len = std::cmp::max(max_len, self.web_server_error_component(error).1);
|
||||
} else if let Some(different_version) = &self.web_server_different_version_error {
|
||||
max_len = std::cmp::max(
|
||||
max_len,
|
||||
self.web_server_different_version_error_component(different_version)
|
||||
.1,
|
||||
);
|
||||
} else if self.web_server_started {
|
||||
let url_display = format_url_with_encryption_marker(
|
||||
&self.web_server_base_url,
|
||||
self.connection_is_unencrypted,
|
||||
);
|
||||
max_len = std::cmp::max(
|
||||
max_len,
|
||||
URL_TITLE.chars().count() + url_display.chars().count(),
|
||||
);
|
||||
} else {
|
||||
max_len = std::cmp::max(max_len, self.start_server_line().1);
|
||||
}
|
||||
|
||||
(max_len, 2)
|
||||
}
|
||||
|
||||
pub fn render_web_server_status(
|
||||
&mut self,
|
||||
x: usize,
|
||||
y: usize,
|
||||
hover_coordinates: Option<(usize, usize)>,
|
||||
) {
|
||||
let web_server_status_line = self.web_server_status_line().0;
|
||||
print_text_with_coordinates(web_server_status_line, x, y, None, None);
|
||||
|
||||
if let Some(error) = &self.web_server_error {
|
||||
let error_component = self.web_server_error_component(error).0;
|
||||
print_text_with_coordinates(error_component, x, y + 1, None, None);
|
||||
} else if let Some(different_version) = &self.web_server_different_version_error {
|
||||
let version_error_component = self
|
||||
.web_server_different_version_error_component(different_version)
|
||||
.0;
|
||||
print_text_with_coordinates(version_error_component, x, y + 1, None, None);
|
||||
} else if self.web_server_started {
|
||||
self.render_server_url(x, y, hover_coordinates);
|
||||
} else {
|
||||
let info_line = self.start_server_line().0;
|
||||
print_text_with_coordinates(info_line, x, y + 1, None, None);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_server_url(&mut self, x: usize, y: usize, hover_coordinates: Option<(usize, usize)>) {
|
||||
let server_url = &self.web_server_base_url;
|
||||
let url_x = x + URL_TITLE.chars().count();
|
||||
let url_width = server_url.chars().count();
|
||||
let url_y = y + 1;
|
||||
|
||||
self.clickable_urls.insert(
|
||||
CoordinatesInLine::new(url_x, url_y, url_width),
|
||||
server_url.clone(),
|
||||
);
|
||||
|
||||
let info_line = if self.connection_is_unencrypted {
|
||||
let full_text = format!("{}{}{}", URL_TITLE, server_url, UNENCRYPTED_MARKER);
|
||||
ColoredTextBuilder::new(full_text)
|
||||
.highlight_range(0, URL_TITLE.chars().count(), COLOR_INDEX_0)
|
||||
.highlight_substring(UNENCRYPTED_MARKER, COLOR_INDEX_1)
|
||||
.build()
|
||||
.0
|
||||
} else {
|
||||
create_titled_text(URL_TITLE, server_url, COLOR_INDEX_0, COLOR_INDEX_1).0
|
||||
};
|
||||
|
||||
print_text_with_coordinates(info_line, x, y + 1, None, None);
|
||||
|
||||
if hovering_on_line(url_x, url_y, url_width, hover_coordinates) {
|
||||
self.currently_hovering_over_link = true;
|
||||
render_text_with_underline(url_x, url_y, server_url);
|
||||
}
|
||||
}
|
||||
|
||||
fn web_server_status_line(&self) -> (Text, usize) {
|
||||
if self.web_server_started {
|
||||
self.create_running_status_line()
|
||||
} else if let Some(different_version) = &self.web_server_different_version_error {
|
||||
self.create_incompatible_version_line(different_version)
|
||||
} else {
|
||||
create_titled_text(
|
||||
WEB_SERVER_TITLE,
|
||||
WEB_SERVER_NOT_RUNNING,
|
||||
COLOR_INDEX_0,
|
||||
COLOR_HIGHLIGHT,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn create_running_status_line(&self) -> (Text, usize) {
|
||||
let full_text = format!("{}{}{}", WEB_SERVER_TITLE, WEB_SERVER_RUNNING, CTRL_C_STOP);
|
||||
ColoredTextBuilder::new(full_text)
|
||||
.highlight_range(0, WEB_SERVER_TITLE.chars().count(), COLOR_INDEX_0)
|
||||
.highlight_substring(WEB_SERVER_RUNNING.trim(), COLOR_HIGHLIGHT)
|
||||
.highlight_substring("<Ctrl c>", COLOR_HIGHLIGHT)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn create_incompatible_version_line(&self, different_version: &str) -> (Text, usize) {
|
||||
let value = format!("{}{}", WEB_SERVER_INCOMPATIBLE_PREFIX, different_version);
|
||||
create_titled_text(WEB_SERVER_TITLE, &value, COLOR_INDEX_0, COLOR_HIGHLIGHT)
|
||||
}
|
||||
|
||||
fn start_server_line(&self) -> (Text, usize) {
|
||||
create_highlighted_shortcut(PRESS_ENTER_START, "<ENTER>", COLOR_HIGHLIGHT)
|
||||
}
|
||||
|
||||
fn web_server_error_component(&self, error: &str) -> (Text, usize) {
|
||||
let text = format!("{}{}", ERROR_PREFIX, error);
|
||||
ColoredTextBuilder::new(text)
|
||||
.highlight_all(COLOR_HIGHLIGHT)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn web_server_different_version_error_component(&self, _version: &str) -> (Text, usize) {
|
||||
create_highlighted_shortcut(CTRL_C_STOP_OTHER, "<Ctrl c>", COLOR_HIGHLIGHT)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CurrentSessionSection {
|
||||
web_server_started: bool,
|
||||
web_server_ip: Option<IpAddr>,
|
||||
web_server_port: Option<u16>,
|
||||
web_sharing: WebSharing,
|
||||
session_name: Option<String>,
|
||||
connection_is_unencrypted: bool,
|
||||
pub clickable_urls: HashMap<CoordinatesInLine, String>,
|
||||
pub currently_hovering_over_link: bool,
|
||||
}
|
||||
|
||||
impl CurrentSessionSection {
|
||||
pub fn new(
|
||||
web_server_started: bool,
|
||||
web_server_ip: Option<IpAddr>,
|
||||
web_server_port: Option<u16>,
|
||||
session_name: Option<String>,
|
||||
web_sharing: WebSharing,
|
||||
connection_is_unencrypted: bool,
|
||||
) -> Self {
|
||||
CurrentSessionSection {
|
||||
web_server_started,
|
||||
web_server_ip,
|
||||
web_server_port,
|
||||
session_name,
|
||||
web_sharing,
|
||||
clickable_urls: HashMap::new(),
|
||||
currently_hovering_over_link: false,
|
||||
connection_is_unencrypted,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_session_status_width_and_height(&self) -> (usize, usize) {
|
||||
let mut max_len = self.get_session_status_line_length();
|
||||
|
||||
if self.web_sharing.web_clients_allowed() && self.web_server_started {
|
||||
let url_display = format_url_with_encryption_marker(
|
||||
&self.session_url(),
|
||||
self.connection_is_unencrypted,
|
||||
);
|
||||
max_len = std::cmp::max(
|
||||
max_len,
|
||||
SESSION_URL_TITLE.chars().count() + url_display.chars().count(),
|
||||
);
|
||||
} else if self.web_sharing.web_clients_allowed() {
|
||||
max_len = std::cmp::max(max_len, WEB_SERVER_OFFLINE.chars().count());
|
||||
} else {
|
||||
max_len = std::cmp::max(max_len, self.press_space_to_share().1);
|
||||
}
|
||||
|
||||
(max_len, 2)
|
||||
}
|
||||
|
||||
fn get_session_status_line_length(&self) -> usize {
|
||||
match self.web_sharing {
|
||||
WebSharing::On => self.render_current_session_sharing().1,
|
||||
WebSharing::Disabled => self.render_sharing_is_disabled().1,
|
||||
WebSharing::Off => self.render_not_sharing().1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_current_session_status(
|
||||
&mut self,
|
||||
x: usize,
|
||||
y: usize,
|
||||
hover_coordinates: Option<(usize, usize)>,
|
||||
) {
|
||||
let status_line = match self.web_sharing {
|
||||
WebSharing::On => self.render_current_session_sharing().0,
|
||||
WebSharing::Disabled => self.render_sharing_is_disabled().0,
|
||||
WebSharing::Off => self.render_not_sharing().0,
|
||||
};
|
||||
|
||||
print_text_with_coordinates(status_line, x, y, None, None);
|
||||
|
||||
if self.web_sharing.web_clients_allowed() && self.web_server_started {
|
||||
self.render_session_url(x, y, hover_coordinates);
|
||||
} else if self.web_sharing.web_clients_allowed() {
|
||||
let info_line = Text::new(WEB_SERVER_OFFLINE);
|
||||
print_text_with_coordinates(info_line, x, y + 1, None, None);
|
||||
} else if !self.web_sharing.sharing_is_disabled() {
|
||||
let info_line = self.press_space_to_share().0;
|
||||
print_text_with_coordinates(info_line, x, y + 1, None, None);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_session_url(
|
||||
&mut self,
|
||||
x: usize,
|
||||
y: usize,
|
||||
hover_coordinates: Option<(usize, usize)>,
|
||||
) {
|
||||
let session_url = self.session_url();
|
||||
let url_x = x + SESSION_URL_TITLE.chars().count();
|
||||
let url_width = session_url.chars().count();
|
||||
let url_y = y + 1;
|
||||
|
||||
self.clickable_urls.insert(
|
||||
CoordinatesInLine::new(url_x, url_y, url_width),
|
||||
session_url.clone(),
|
||||
);
|
||||
|
||||
let info_line = if self.connection_is_unencrypted {
|
||||
let full_text = format!("{}{}{}", SESSION_URL_TITLE, session_url, UNENCRYPTED_MARKER);
|
||||
ColoredTextBuilder::new(full_text)
|
||||
.highlight_range(0, SESSION_URL_TITLE.chars().count(), COLOR_INDEX_0)
|
||||
.highlight_substring(UNENCRYPTED_MARKER, COLOR_INDEX_1)
|
||||
.build()
|
||||
.0
|
||||
} else {
|
||||
create_titled_text(
|
||||
SESSION_URL_TITLE,
|
||||
&session_url,
|
||||
COLOR_INDEX_0,
|
||||
COLOR_INDEX_1,
|
||||
)
|
||||
.0
|
||||
};
|
||||
|
||||
print_text_with_coordinates(info_line, x, y + 1, None, None);
|
||||
|
||||
if hovering_on_line(url_x, url_y, url_width, hover_coordinates) {
|
||||
self.currently_hovering_over_link = true;
|
||||
render_text_with_underline(url_x, url_y, &session_url);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_current_session_sharing(&self) -> (Text, usize) {
|
||||
let full_text = format!("{}{}", CURRENT_SESSION_TITLE, SHARING_STATUS);
|
||||
ColoredTextBuilder::new(full_text)
|
||||
.highlight_range(0, CURRENT_SESSION_TITLE.chars().count(), COLOR_INDEX_0)
|
||||
.highlight_substring("SHARING", COLOR_HIGHLIGHT)
|
||||
.highlight_substring("<SPACE>", COLOR_HIGHLIGHT)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn render_sharing_is_disabled(&self) -> (Text, usize) {
|
||||
create_titled_text(
|
||||
CURRENT_SESSION_TITLE,
|
||||
SHARING_DISABLED,
|
||||
COLOR_INDEX_0,
|
||||
COLOR_HIGHLIGHT,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_not_sharing(&self) -> (Text, usize) {
|
||||
create_titled_text(
|
||||
CURRENT_SESSION_TITLE,
|
||||
NOT_SHARING,
|
||||
COLOR_INDEX_0,
|
||||
COLOR_HIGHLIGHT,
|
||||
)
|
||||
}
|
||||
|
||||
fn session_url(&self) -> String {
|
||||
let web_server_ip = self
|
||||
.web_server_ip
|
||||
.map(|i| i.to_string())
|
||||
.unwrap_or_else(|| "UNDEFINED".to_owned());
|
||||
let web_server_port = self
|
||||
.web_server_port
|
||||
.map(|p| p.to_string())
|
||||
.unwrap_or_else(|| "UNDEFINED".to_owned());
|
||||
let prefix = if self.connection_is_unencrypted {
|
||||
"http"
|
||||
} else {
|
||||
"https"
|
||||
};
|
||||
let session_name = self.session_name.as_deref().unwrap_or("");
|
||||
|
||||
format!(
|
||||
"{}://{}:{}/{}",
|
||||
prefix, web_server_ip, web_server_port, session_name
|
||||
)
|
||||
}
|
||||
|
||||
fn press_space_to_share(&self) -> (Text, usize) {
|
||||
create_highlighted_shortcut(PRESS_SPACE_SHARE, "<SPACE>", COLOR_HIGHLIGHT)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hovering_on_line(
|
||||
x: usize,
|
||||
y: usize,
|
||||
width: usize,
|
||||
hover_coordinates: Option<(usize, usize)>,
|
||||
) -> bool {
|
||||
match hover_coordinates {
|
||||
Some((hover_x, hover_y)) => hover_y == y && hover_x <= x + width && hover_x > x,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_text_with_underline(url_x: usize, url_y: usize, url_text: &str) {
|
||||
print!(
|
||||
"\u{1b}[{};{}H\u{1b}[m\u{1b}[1;4m{}",
|
||||
url_y + 1,
|
||||
url_x + 1,
|
||||
url_text,
|
||||
);
|
||||
}
|
||||
|
|
@ -1368,6 +1368,7 @@ fn get_keys_and_hints(mi: &ModeInfo) -> Vec<(String, String, Vec<KeyWithModifier
|
|||
]} else if mi.mode == IM::Session { vec![
|
||||
(s("Detach"), s("Detach"), action_key(&km, &[Action::Detach])),
|
||||
(s("Session Manager"), s("Manager"), session_manager_key(&km)),
|
||||
(s("Share"), s("Share"), share_key(&km)),
|
||||
(s("Configure"), s("Config"), configuration_key(&km)),
|
||||
(s("Plugin Manager"), s("Plugins"), plugin_manager_key(&km)),
|
||||
(s("About"), s("About"), about_key(&km)),
|
||||
|
|
@ -1461,6 +1462,25 @@ fn session_manager_key(keymap: &[(KeyWithModifier, Vec<Action>)]) -> Vec<KeyWith
|
|||
}
|
||||
}
|
||||
|
||||
fn share_key(keymap: &[(KeyWithModifier, Vec<Action>)]) -> Vec<KeyWithModifier> {
|
||||
let mut matching = keymap.iter().find_map(|(key, acvec)| {
|
||||
let has_match = acvec
|
||||
.iter()
|
||||
.find(|a| a.launches_plugin("zellij:share"))
|
||||
.is_some();
|
||||
if has_match {
|
||||
Some(key.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(matching) = matching.take() {
|
||||
vec![matching]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_manager_key(keymap: &[(KeyWithModifier, Vec<Action>)]) -> Vec<KeyWithModifier> {
|
||||
let mut matching = keymap.iter().find_map(|(key, acvec)| {
|
||||
let has_match = acvec
|
||||
|
|
|
|||
|
|
@ -179,13 +179,13 @@ fn right_more_message(
|
|||
fn tab_line_prefix(session_name: Option<&str>, palette: Styling, cols: usize) -> Vec<LinePart> {
|
||||
let prefix_text = " Zellij ".to_string();
|
||||
|
||||
let prefix_text_len = prefix_text.chars().count();
|
||||
let running_text_len = prefix_text.chars().count();
|
||||
let text_color = palette.text_unselected.base;
|
||||
let bg_color = palette.text_unselected.background;
|
||||
let prefix_styled_text = style!(text_color, bg_color).bold().paint(prefix_text);
|
||||
let mut parts = vec![LinePart {
|
||||
part: prefix_styled_text.to_string(),
|
||||
len: prefix_text_len,
|
||||
len: running_text_len,
|
||||
tab_index: None,
|
||||
}];
|
||||
if let Some(name) = session_name {
|
||||
|
|
@ -193,7 +193,7 @@ fn tab_line_prefix(session_name: Option<&str>, palette: Styling, cols: usize) ->
|
|||
let name_part_len = name_part.width();
|
||||
let text_color = palette.text_unselected.base;
|
||||
let name_part_styled_text = style!(text_color, bg_color).bold().paint(name_part);
|
||||
if cols.saturating_sub(prefix_text_len) >= name_part_len {
|
||||
if cols.saturating_sub(running_text_len) >= name_part_len {
|
||||
parts.push(LinePart {
|
||||
part: name_part_styled_text.to_string(),
|
||||
len: name_part_len,
|
||||
|
|
|
|||
|
|
@ -142,6 +142,13 @@ keybinds {
|
|||
};
|
||||
SwitchToMode "Normal"
|
||||
}
|
||||
bind "s" {
|
||||
LaunchOrFocusPlugin "zellij:share" {
|
||||
floating true
|
||||
move_to_focused_tab true
|
||||
};
|
||||
SwitchToMode "Normal"
|
||||
}
|
||||
}
|
||||
tmux {
|
||||
bind "[" { SwitchToMode "Scroll"; }
|
||||
|
|
|
|||
227
src/commands.rs
227
src/commands.rs
|
|
@ -1,15 +1,9 @@
|
|||
use dialoguer::Confirm;
|
||||
use std::{fs::File, io::prelude::*, path::PathBuf, process, time::Duration};
|
||||
|
||||
use crate::sessions::{
|
||||
assert_dead_session, assert_session, assert_session_ne, delete_session as delete_session_impl,
|
||||
get_active_session, get_name_generator, get_resurrectable_session_names,
|
||||
get_resurrectable_sessions, get_sessions, get_sessions_sorted_by_mtime,
|
||||
kill_session as kill_session_impl, match_session_name, print_sessions,
|
||||
print_sessions_with_index, resurrection_layout, session_exists, ActiveSession,
|
||||
SessionNameMatch,
|
||||
};
|
||||
use miette::{Report, Result};
|
||||
#[cfg(feature = "web_server_capability")]
|
||||
use isahc::{config::RedirectPolicy, prelude::*, HttpClient, Request};
|
||||
|
||||
use nix;
|
||||
use zellij_client::{
|
||||
old_config_converter::{
|
||||
|
|
@ -18,6 +12,26 @@ use zellij_client::{
|
|||
os_input_output::get_client_os_input,
|
||||
start_client as start_client_impl, ClientInfo,
|
||||
};
|
||||
use zellij_utils::sessions::{
|
||||
assert_dead_session, assert_session, assert_session_ne, delete_session as delete_session_impl,
|
||||
generate_unique_session_name, get_active_session, get_resurrectable_sessions, get_sessions,
|
||||
get_sessions_sorted_by_mtime, kill_session as kill_session_impl, match_session_name,
|
||||
print_sessions, print_sessions_with_index, resurrection_layout, session_exists, ActiveSession,
|
||||
SessionNameMatch,
|
||||
};
|
||||
|
||||
#[cfg(feature = "web_server_capability")]
|
||||
use zellij_client::web_client::start_web_client as start_web_client_impl;
|
||||
|
||||
#[cfg(feature = "web_server_capability")]
|
||||
use zellij_utils::web_server_commands::shutdown_all_webserver_instances;
|
||||
|
||||
#[cfg(feature = "web_server_capability")]
|
||||
use zellij_utils::web_authentication_tokens::{
|
||||
create_token, list_tokens, revoke_all_tokens, revoke_token,
|
||||
};
|
||||
|
||||
use miette::{Report, Result};
|
||||
use zellij_server::{os_input_output::get_server_os_input, start_server as start_server_impl};
|
||||
use zellij_utils::{
|
||||
cli::{CliArgs, Command, SessionCommand, Sessions},
|
||||
|
|
@ -32,7 +46,7 @@ use zellij_utils::{
|
|||
setup::{find_default_config_dir, get_layout_dir, Setup},
|
||||
};
|
||||
|
||||
pub(crate) use crate::sessions::list_sessions;
|
||||
pub(crate) use zellij_utils::sessions::list_sessions;
|
||||
|
||||
pub(crate) fn kill_all_sessions(yes: bool) {
|
||||
match get_sessions() {
|
||||
|
|
@ -144,8 +158,166 @@ pub(crate) fn start_server(path: PathBuf, debug: bool) {
|
|||
start_server_impl(Box::new(os_input), path);
|
||||
}
|
||||
|
||||
#[cfg(feature = "web_server_capability")]
|
||||
pub(crate) fn start_web_server(opts: CliArgs, run_daemonized: bool) {
|
||||
// TODO: move this outside of this function
|
||||
let (config, _layout, config_options, _config_without_layout, _config_options_without_layout) =
|
||||
match Setup::from_cli_args(&opts) {
|
||||
Ok(results) => results,
|
||||
Err(e) => {
|
||||
if let ConfigError::KdlError(error) = e {
|
||||
let report: Report = error.into();
|
||||
eprintln!("{:?}", report);
|
||||
} else {
|
||||
eprintln!("{}", e);
|
||||
}
|
||||
process::exit(1);
|
||||
},
|
||||
};
|
||||
|
||||
start_web_client_impl(config, config_options, opts.config, run_daemonized);
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "web_server_capability"))]
|
||||
pub(crate) fn start_web_server(_opts: CliArgs, _run_daemonized: bool) {
|
||||
log::error!(
|
||||
"This version of Zellij was compiled without web server support, cannot run web server!"
|
||||
);
|
||||
eprintln!(
|
||||
"This version of Zellij was compiled without web server support, cannot run web server!"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
fn create_new_client() -> ClientInfo {
|
||||
ClientInfo::New(generate_unique_session_name())
|
||||
ClientInfo::New(generate_unique_session_name_or_exit())
|
||||
}
|
||||
|
||||
#[cfg(feature = "web_server_capability")]
|
||||
pub(crate) fn stop_web_server() -> Result<(), String> {
|
||||
shutdown_all_webserver_instances().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "web_server_capability"))]
|
||||
pub(crate) fn stop_web_server() -> Result<(), String> {
|
||||
log::error!(
|
||||
"This version of Zellij was compiled without web server support, cannot stop web server!"
|
||||
);
|
||||
eprintln!(
|
||||
"This version of Zellij was compiled without web server support, cannot stop web server!"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
#[cfg(feature = "web_server_capability")]
|
||||
pub(crate) fn create_auth_token() -> Result<String, String> {
|
||||
// returns the token and it's name
|
||||
create_token(None)
|
||||
.map(|(token_name, token)| format!("{}: {}", token, token_name))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "web_server_capability"))]
|
||||
pub(crate) fn create_auth_token() -> Result<String, String> {
|
||||
log::error!(
|
||||
"This version of Zellij was compiled without web server support, cannot create auth token!"
|
||||
);
|
||||
eprintln!(
|
||||
"This version of Zellij was compiled without web server support, cannot create auth token!"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
#[cfg(feature = "web_server_capability")]
|
||||
pub(crate) fn revoke_auth_token(token_name: &str) -> Result<bool, String> {
|
||||
revoke_token(token_name).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "web_server_capability"))]
|
||||
pub(crate) fn revoke_auth_token(_token_name: &str) -> Result<bool, String> {
|
||||
log::error!(
|
||||
"This version of Zellij was compiled without web server support, cannot revoke auth token!"
|
||||
);
|
||||
eprintln!(
|
||||
"This version of Zellij was compiled without web server support, cannot revoke auth token!"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
#[cfg(feature = "web_server_capability")]
|
||||
pub(crate) fn revoke_all_auth_tokens() -> Result<usize, String> {
|
||||
// returns the revoked count
|
||||
revoke_all_tokens().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "web_server_capability"))]
|
||||
pub(crate) fn revoke_all_auth_tokens() -> Result<usize, String> {
|
||||
log::error!(
|
||||
"This version of Zellij was compiled without web server support, cannot revoke all tokens!"
|
||||
);
|
||||
eprintln!(
|
||||
"This version of Zellij was compiled without web server support, cannot revoke all tokens!"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
#[cfg(feature = "web_server_capability")]
|
||||
pub(crate) fn list_auth_tokens() -> Result<Vec<String>, String> {
|
||||
// returns the token list line by line
|
||||
list_tokens()
|
||||
.map(|tokens| {
|
||||
let mut res = vec![];
|
||||
for t in tokens {
|
||||
res.push(format!("{}: created at {}", t.name, t.created_at))
|
||||
}
|
||||
res
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "web_server_capability"))]
|
||||
pub(crate) fn list_auth_tokens() -> Result<Vec<String>, String> {
|
||||
log::error!(
|
||||
"This version of Zellij was compiled without web server support, cannot list tokens!"
|
||||
);
|
||||
eprintln!(
|
||||
"This version of Zellij was compiled without web server support, cannot list tokens!"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
#[cfg(feature = "web_server_capability")]
|
||||
pub(crate) fn web_server_status(web_server_base_url: &str) -> Result<String, String> {
|
||||
let http_client = HttpClient::builder()
|
||||
// TODO: timeout?
|
||||
.redirect_policy(RedirectPolicy::Follow)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let request = Request::get(format!("{}/info/version", web_server_base_url,));
|
||||
let req = request.body(()).map_err(|e| e.to_string())?;
|
||||
let mut res = http_client.send(req).map_err(|e| e.to_string())?;
|
||||
let status_code = res.status();
|
||||
if status_code == 200 {
|
||||
let body = res.bytes().map_err(|e| e.to_string())?;
|
||||
Ok(String::from_utf8_lossy(&body).to_string())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Failed to stop web server, got status code: {}",
|
||||
status_code
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "web_server_capability"))]
|
||||
pub(crate) fn web_server_status(_web_server_base_url: &str) -> Result<String, String> {
|
||||
log::error!(
|
||||
"This version of Zellij was compiled without web server support, cannot get web server status!"
|
||||
);
|
||||
eprintln!(
|
||||
"This version of Zellij was compiled without web server support, cannot get web server status!"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
fn find_indexed_session(
|
||||
|
|
@ -660,7 +832,7 @@ pub(crate) fn start_client(opts: CliArgs) {
|
|||
process::exit(0);
|
||||
}
|
||||
|
||||
let session_name = generate_unique_session_name();
|
||||
let session_name = generate_unique_session_name_or_exit();
|
||||
start_client_plan(session_name.clone());
|
||||
reconnect_to_session = start_client_impl(
|
||||
Box::new(os_input),
|
||||
|
|
@ -682,29 +854,12 @@ pub(crate) fn start_client(opts: CliArgs) {
|
|||
}
|
||||
}
|
||||
|
||||
fn generate_unique_session_name() -> String {
|
||||
let sessions = get_sessions().map(|sessions| {
|
||||
sessions
|
||||
.iter()
|
||||
.map(|s| s.0.clone())
|
||||
.collect::<Vec<String>>()
|
||||
});
|
||||
let dead_sessions = get_resurrectable_session_names();
|
||||
let Ok(sessions) = sessions else {
|
||||
eprintln!("Failed to list existing sessions: {:?}", sessions);
|
||||
process::exit(1);
|
||||
};
|
||||
|
||||
let name = get_name_generator()
|
||||
.take(1000)
|
||||
.find(|name| !sessions.contains(name) && !dead_sessions.contains(name));
|
||||
|
||||
if let Some(name) = name {
|
||||
return name;
|
||||
} else {
|
||||
fn generate_unique_session_name_or_exit() -> String {
|
||||
let Some(unique_session_name) = generate_unique_session_name() else {
|
||||
eprintln!("Failed to generate a unique session name, giving up");
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
unique_session_name
|
||||
}
|
||||
|
||||
pub(crate) fn list_aliases(opts: CliArgs) {
|
||||
|
|
@ -742,3 +897,9 @@ fn reload_config_from_disk(
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get_config_options_from_cli_args(opts: &CliArgs) -> Result<Options, String> {
|
||||
Setup::from_cli_args(&opts)
|
||||
.map(|(_, _, config_options, _, _)| config_options)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
|
|
|||
93
src/main.rs
93
src/main.rs
|
|
@ -1,16 +1,16 @@
|
|||
mod commands;
|
||||
mod sessions;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use clap::Parser;
|
||||
use zellij_utils::{
|
||||
cli::{CliAction, CliArgs, Command, Sessions},
|
||||
consts::create_config_and_cache_folders,
|
||||
consts::{create_config_and_cache_folders, VERSION},
|
||||
envs,
|
||||
input::config::Config,
|
||||
logging::*,
|
||||
setup::Setup,
|
||||
shared::web_server_base_url_from_config,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
|
|
@ -224,6 +224,95 @@ fn main() {
|
|||
opts.new_session_with_layout = None;
|
||||
opts.layout = Some(layout_for_new_session.clone());
|
||||
commands::start_client(opts);
|
||||
} else if let Some(Command::Web(web_opts)) = &opts.command {
|
||||
if web_opts.get_start() {
|
||||
let daemonize = web_opts.daemonize;
|
||||
commands::start_web_server(opts, daemonize);
|
||||
} else if web_opts.stop {
|
||||
match commands::stop_web_server() {
|
||||
Ok(()) => {
|
||||
println!("Stopped web server.");
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Failed to stop web server: {}", e);
|
||||
std::process::exit(2)
|
||||
},
|
||||
}
|
||||
} else if web_opts.status {
|
||||
let config_options = commands::get_config_options_from_cli_args(&opts)
|
||||
.expect("Can't find config options");
|
||||
let web_server_base_url = web_server_base_url_from_config(config_options);
|
||||
match commands::web_server_status(&web_server_base_url) {
|
||||
Ok(version) => {
|
||||
let version = version.trim();
|
||||
println!(
|
||||
"Web server online with version: {}. Checked: {}",
|
||||
version, web_server_base_url
|
||||
);
|
||||
if version != VERSION {
|
||||
println!("");
|
||||
println!(
|
||||
"Note: this version differs from the current Zellij version: {}.",
|
||||
VERSION
|
||||
);
|
||||
println!("Consider stopping the server with: zellij web --stop");
|
||||
println!("And then restarting it with: zellij web --start");
|
||||
}
|
||||
},
|
||||
Err(_e) => {
|
||||
println!("Web server is offline, checked: {}", web_server_base_url);
|
||||
},
|
||||
}
|
||||
} else if web_opts.create_token {
|
||||
match commands::create_auth_token() {
|
||||
Ok(token_and_name) => {
|
||||
println!("Created token successfully");
|
||||
println!("");
|
||||
println!("{}", token_and_name);
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Failed to create token: {}", e);
|
||||
std::process::exit(2)
|
||||
},
|
||||
}
|
||||
} else if let Some(token_name_to_revoke) = &web_opts.revoke_token {
|
||||
match commands::revoke_auth_token(token_name_to_revoke) {
|
||||
Ok(revoked) => {
|
||||
if revoked {
|
||||
println!("Successfully revoked token.");
|
||||
} else {
|
||||
eprintln!("Token by that name does not exist.");
|
||||
std::process::exit(2)
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Failed to revoke token: {}", e);
|
||||
std::process::exit(2)
|
||||
},
|
||||
}
|
||||
} else if web_opts.revoke_all_tokens {
|
||||
match commands::revoke_all_auth_tokens() {
|
||||
Ok(_) => {
|
||||
println!("Successfully revoked all auth tokens");
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Failed to revoke all auth tokens: {}", e);
|
||||
std::process::exit(2)
|
||||
},
|
||||
}
|
||||
} else if web_opts.list_tokens {
|
||||
match commands::list_auth_tokens() {
|
||||
Ok(token_list) => {
|
||||
for item in token_list {
|
||||
println!("{}", item);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Failed to list tokens: {}", e);
|
||||
std::process::exit(2)
|
||||
},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
commands::start_client(opts);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ edition = "2021"
|
|||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
prost-build = { version = "0.11.9", default-features = false }
|
||||
toml = { version = "0.5", default-features = false }
|
||||
which = { version = "4.2", default-features = false }
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
//!
|
||||
//! - [`build`]: Builds general cargo projects (i.e. zellij components) with `cargo build`
|
||||
//! - [`manpage`]: Builds the manpage with `mandown`
|
||||
use crate::{flags, WorkspaceMember};
|
||||
use crate::{flags, metadata, WorkspaceMember};
|
||||
use anyhow::Context;
|
||||
use std::path::{Path, PathBuf};
|
||||
use xshell::{cmd, Shell};
|
||||
|
|
@ -95,6 +95,23 @@ pub fn build(sh: &Shell, flags: flags::Build) -> anyhow::Result<()> {
|
|||
if flags.release {
|
||||
base_cmd = base_cmd.arg("--release");
|
||||
}
|
||||
if flags.no_web {
|
||||
// Check if this crate has web features that need modification
|
||||
match metadata::get_no_web_features(sh, crate_name)
|
||||
.context("Failed to check web features")?
|
||||
{
|
||||
Some(features) => {
|
||||
base_cmd = base_cmd.arg("--no-default-features");
|
||||
if !features.is_empty() {
|
||||
base_cmd = base_cmd.arg("--features");
|
||||
base_cmd = base_cmd.arg(features);
|
||||
}
|
||||
},
|
||||
None => {
|
||||
// Crate doesn't have web features, build normally
|
||||
},
|
||||
}
|
||||
}
|
||||
base_cmd.run().with_context(err_context)?;
|
||||
|
||||
if crate_name.contains("plugins") {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ fn e2e_build(sh: &Shell) -> anyhow::Result<()> {
|
|||
release: true,
|
||||
no_plugins: false,
|
||||
plugins_only: true,
|
||||
no_web: false,
|
||||
},
|
||||
)
|
||||
.context(err_context)?;
|
||||
|
|
@ -154,6 +155,7 @@ fn cross_compile(sh: &Shell, target: &OsString) -> anyhow::Result<()> {
|
|||
release: true,
|
||||
no_plugins: false,
|
||||
plugins_only: true,
|
||||
no_web: false,
|
||||
},
|
||||
)
|
||||
.and_then(|_| build::manpage(sh))
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ pub fn clippy(sh: &Shell, _flags: flags::Clippy) -> anyhow::Result<()> {
|
|||
release: false,
|
||||
no_plugins: false,
|
||||
plugins_only: true,
|
||||
no_web: false,
|
||||
},
|
||||
)
|
||||
.context("failed to run task 'clippy'")?;
|
||||
|
|
|
|||
|
|
@ -58,11 +58,15 @@ xflags::xflags! {
|
|||
optional -r, --release
|
||||
/// Clean project before building
|
||||
optional -c, --clean
|
||||
/// Compile without web server support
|
||||
optional --no-web
|
||||
}
|
||||
|
||||
/// Generate a runnable `zellij` executable with plugins bundled
|
||||
cmd install {
|
||||
required destination: PathBuf
|
||||
/// Compile without web server support
|
||||
optional --no-web
|
||||
}
|
||||
|
||||
/// Run debug version of zellij
|
||||
|
|
@ -75,6 +79,8 @@ xflags::xflags! {
|
|||
optional --singlepass
|
||||
/// Disable optimizing dependencies
|
||||
optional --disable-deps-optimize
|
||||
/// Compile without web server support
|
||||
optional --no-web
|
||||
/// Arguments to pass after `cargo run --`
|
||||
repeated args: OsString
|
||||
}
|
||||
|
|
@ -87,6 +93,8 @@ xflags::xflags! {
|
|||
|
||||
/// Run application tests
|
||||
cmd test {
|
||||
/// Compile without web server support
|
||||
optional --no-web
|
||||
/// Arguments to pass after `cargo test --`
|
||||
repeated args: OsString
|
||||
}
|
||||
|
|
@ -99,6 +107,8 @@ xflags::xflags! {
|
|||
optional -p, --plugins-only
|
||||
/// Build everything except the plugins
|
||||
optional --no-plugins
|
||||
/// Compile without web support
|
||||
optional --no-web
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -176,11 +186,13 @@ pub struct Clippy;
|
|||
pub struct Make {
|
||||
pub release: bool,
|
||||
pub clean: bool,
|
||||
pub no_web: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Install {
|
||||
pub destination: PathBuf,
|
||||
pub no_web: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -191,6 +203,7 @@ pub struct Run {
|
|||
pub data_dir: Option<PathBuf>,
|
||||
pub singlepass: bool,
|
||||
pub disable_deps_optimize: bool,
|
||||
pub no_web: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -201,6 +214,7 @@ pub struct Format {
|
|||
#[derive(Debug)]
|
||||
pub struct Test {
|
||||
pub args: Vec<OsString>,
|
||||
pub no_web: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -208,6 +222,7 @@ pub struct Build {
|
|||
pub release: bool,
|
||||
pub plugins_only: bool,
|
||||
pub no_plugins: bool,
|
||||
pub no_web: bool,
|
||||
}
|
||||
|
||||
impl Xtask {
|
||||
|
|
@ -226,4 +241,3 @@ impl Xtask {
|
|||
Self::from_vec_(args)
|
||||
}
|
||||
}
|
||||
// generated end
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ mod clippy;
|
|||
mod dist;
|
||||
mod flags;
|
||||
mod format;
|
||||
mod metadata;
|
||||
mod pipelines;
|
||||
mod test;
|
||||
|
||||
|
|
@ -73,6 +74,10 @@ fn workspace_members() -> &'static Vec<WorkspaceMember> {
|
|||
crate_name: "default-plugins/multiple-select",
|
||||
build: true,
|
||||
},
|
||||
WorkspaceMember {
|
||||
crate_name: "default-plugins/share",
|
||||
build: true,
|
||||
},
|
||||
WorkspaceMember {
|
||||
crate_name: "zellij-utils",
|
||||
build: false,
|
||||
|
|
|
|||
89
xtask/src/metadata.rs
Normal file
89
xtask/src/metadata.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
//! Helper functions for querying cargo metadata
|
||||
use anyhow::Context;
|
||||
use serde_json::Value;
|
||||
use xshell::{cmd, Shell};
|
||||
|
||||
/// Get cargo metadata for the workspace
|
||||
pub fn get_cargo_metadata(sh: &Shell) -> anyhow::Result<Value> {
|
||||
let cargo = crate::cargo().context("Failed to find cargo executable")?;
|
||||
let metadata_json = cmd!(sh, "{cargo} metadata --format-version 1 --no-deps")
|
||||
.read()
|
||||
.context("Failed to run cargo metadata")?;
|
||||
|
||||
serde_json::from_str(&metadata_json).context("Failed to parse cargo metadata JSON")
|
||||
}
|
||||
|
||||
/// Get the appropriate features string for a crate when --no-web is enabled
|
||||
/// Returns Some(features_string) if the crate has web_server_capability and should use --no-default-features
|
||||
/// Returns None if the crate doesn't have web_server_capability and should use normal build
|
||||
pub fn get_no_web_features(sh: &Shell, crate_name: &str) -> anyhow::Result<Option<String>> {
|
||||
let metadata = get_cargo_metadata(sh)?;
|
||||
|
||||
let packages = metadata["packages"]
|
||||
.as_array()
|
||||
.context("Expected packages array in metadata")?;
|
||||
|
||||
// First, find the main zellij crate to get the default features
|
||||
let mut main_default_features = Vec::new();
|
||||
for package in packages {
|
||||
let name = package["name"]
|
||||
.as_str()
|
||||
.context("Expected package name as string")?;
|
||||
|
||||
if name == "zellij" {
|
||||
let features = package["features"]
|
||||
.as_object()
|
||||
.context("Expected features object")?;
|
||||
|
||||
if let Some(default_features) = features.get("default").and_then(|v| v.as_array()) {
|
||||
for feature_value in default_features {
|
||||
if let Some(feature_name) = feature_value.as_str() {
|
||||
if feature_name != "web_server_capability" {
|
||||
main_default_features.push(feature_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Now check if the target crate has web_server_capability and filter features
|
||||
for package in packages {
|
||||
let name = package["name"]
|
||||
.as_str()
|
||||
.context("Expected package name as string")?;
|
||||
|
||||
// Handle the root crate case
|
||||
let matches_crate = if crate_name == "." {
|
||||
name == "zellij"
|
||||
} else {
|
||||
name == crate_name
|
||||
};
|
||||
|
||||
if matches_crate {
|
||||
let features = package["features"]
|
||||
.as_object()
|
||||
.context("Expected features object")?;
|
||||
|
||||
// Check if this crate has web_server_capability feature
|
||||
if !features.contains_key("web_server_capability") {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// This crate has web_server_capability, so we need to use --no-default-features
|
||||
// Only include features that this crate actually has
|
||||
let mut applicable_features = Vec::new();
|
||||
for feature_name in &main_default_features {
|
||||
if features.contains_key(*feature_name) {
|
||||
applicable_features.push(*feature_name);
|
||||
}
|
||||
}
|
||||
|
||||
// Return the feature string (even if empty) to indicate we should use --no-default-features
|
||||
return Ok(Some(applicable_features.join(" ")));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
//! Composite pipelines for the build system.
|
||||
//!
|
||||
//! Defines multiple "pipelines" that run specific individual steps in sequence.
|
||||
use crate::{build, clippy, format, test};
|
||||
use crate::{build, clippy, format, metadata, test};
|
||||
use crate::{flags, WorkspaceMember};
|
||||
use anyhow::Context;
|
||||
use xshell::{cmd, Shell};
|
||||
|
|
@ -31,10 +31,19 @@ pub fn make(sh: &Shell, flags: flags::Make) -> anyhow::Result<()> {
|
|||
release: flags.release,
|
||||
no_plugins: false,
|
||||
plugins_only: false,
|
||||
no_web: flags.no_web,
|
||||
},
|
||||
)
|
||||
})
|
||||
.and_then(|_| {
|
||||
test::test(
|
||||
sh,
|
||||
flags::Test {
|
||||
args: vec![],
|
||||
no_web: flags.no_web,
|
||||
},
|
||||
)
|
||||
})
|
||||
.and_then(|_| test::test(sh, flags::Test { args: vec![] }))
|
||||
.and_then(|_| clippy::clippy(sh, flags::Clippy {}))
|
||||
.with_context(err_context)
|
||||
}
|
||||
|
|
@ -57,6 +66,7 @@ pub fn install(sh: &Shell, flags: flags::Install) -> anyhow::Result<()> {
|
|||
release: true,
|
||||
no_plugins: false,
|
||||
plugins_only: true,
|
||||
no_web: flags.no_web,
|
||||
},
|
||||
)
|
||||
.and_then(|_| {
|
||||
|
|
@ -67,6 +77,7 @@ pub fn install(sh: &Shell, flags: flags::Install) -> anyhow::Result<()> {
|
|||
release: true,
|
||||
no_plugins: true,
|
||||
plugins_only: false,
|
||||
no_web: flags.no_web,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
|
@ -111,13 +122,18 @@ pub fn run(sh: &Shell, mut flags: flags::Run) -> anyhow::Result<()> {
|
|||
|
||||
if let Some(ref data_dir) = flags.data_dir {
|
||||
let data_dir = sh.current_dir().join(data_dir);
|
||||
let features = if flags.no_web {
|
||||
"disable_automatic_asset_installation"
|
||||
} else {
|
||||
"disable_automatic_asset_installation web_server_capability"
|
||||
};
|
||||
|
||||
crate::cargo()
|
||||
.and_then(|cargo| {
|
||||
cmd!(sh, "{cargo} run")
|
||||
.args(["--package", "zellij"])
|
||||
.arg("--no-default-features")
|
||||
.args(["--features", "disable_automatic_asset_installation"])
|
||||
.args(["--features", features])
|
||||
.args(singlepass.iter().flatten())
|
||||
.args(["--profile", profile])
|
||||
.args(["--", "--data-dir", &format!("{}", data_dir.display())])
|
||||
|
|
@ -133,10 +149,33 @@ pub fn run(sh: &Shell, mut flags: flags::Run) -> anyhow::Result<()> {
|
|||
release: false,
|
||||
no_plugins: false,
|
||||
plugins_only: true,
|
||||
no_web: flags.no_web,
|
||||
},
|
||||
)
|
||||
.and_then(|_| crate::cargo())
|
||||
.and_then(|cargo| {
|
||||
if flags.no_web {
|
||||
// Use dynamic metadata approach to get the correct features
|
||||
match metadata::get_no_web_features(sh, ".")
|
||||
.context("Failed to check web features for main crate")?
|
||||
{
|
||||
Some(features) => {
|
||||
let mut cmd = cmd!(sh, "{cargo} run")
|
||||
.args(singlepass.iter().flatten())
|
||||
.args(["--no-default-features"]);
|
||||
|
||||
if !features.is_empty() {
|
||||
cmd = cmd.args(["--features", &features]);
|
||||
}
|
||||
|
||||
cmd.args(["--profile", profile])
|
||||
.args(["--"])
|
||||
.args(&flags.args)
|
||||
.run()
|
||||
.map_err(anyhow::Error::new)
|
||||
},
|
||||
None => {
|
||||
// Main crate doesn't have web_server_capability, run normally
|
||||
cmd!(sh, "{cargo} run")
|
||||
.args(singlepass.iter().flatten())
|
||||
.args(["--profile", profile])
|
||||
|
|
@ -144,6 +183,17 @@ pub fn run(sh: &Shell, mut flags: flags::Run) -> anyhow::Result<()> {
|
|||
.args(&flags.args)
|
||||
.run()
|
||||
.map_err(anyhow::Error::new)
|
||||
},
|
||||
}
|
||||
} else {
|
||||
cmd!(sh, "{cargo} run")
|
||||
.args(singlepass.iter().flatten())
|
||||
.args(["--profile", profile])
|
||||
.args(["--"])
|
||||
.args(&flags.args)
|
||||
.run()
|
||||
.map_err(anyhow::Error::new)
|
||||
}
|
||||
})
|
||||
.with_context(|| err_context(&flags))
|
||||
}
|
||||
|
|
@ -167,6 +217,7 @@ pub fn dist(sh: &Shell, _flags: flags::Dist) -> anyhow::Result<()> {
|
|||
sh,
|
||||
flags::Install {
|
||||
destination: crate::project_root().join("./target/dist/zellij"),
|
||||
no_web: false,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
|
@ -275,6 +326,7 @@ pub fn publish(sh: &Shell, flags: flags::Publish) -> anyhow::Result<()> {
|
|||
release: true,
|
||||
no_plugins: false,
|
||||
plugins_only: true,
|
||||
no_web: false,
|
||||
},
|
||||
)
|
||||
.context(err_context)?;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::{build, flags, WorkspaceMember};
|
||||
use crate::{build, flags, metadata, WorkspaceMember};
|
||||
use anyhow::{anyhow, Context};
|
||||
use std::path::Path;
|
||||
use xshell::{cmd, Shell};
|
||||
|
|
@ -16,28 +16,42 @@ pub fn test(sh: &Shell, flags: flags::Test) -> anyhow::Result<()> {
|
|||
release: false,
|
||||
no_plugins: false,
|
||||
plugins_only: true,
|
||||
no_web: flags.no_web,
|
||||
},
|
||||
)
|
||||
.context(err_context)?;
|
||||
|
||||
for WorkspaceMember { crate_name, .. } in crate::workspace_members().iter() {
|
||||
// the workspace root only contains e2e tests, skip it
|
||||
if crate_name == &"." {
|
||||
continue;
|
||||
}
|
||||
|
||||
let _pd = sh.push_dir(Path::new(crate_name));
|
||||
// Tell the user where we are now
|
||||
println!();
|
||||
let msg = format!(">> Testing '{}'", crate_name);
|
||||
crate::status(&msg);
|
||||
println!("{}", msg);
|
||||
|
||||
// Override wasm32-wasip1 target for plugins only
|
||||
let cmd = if crate_name.contains("plugins") {
|
||||
cmd!(sh, "{cargo} test --target {host_triple} --")
|
||||
} else if flags.no_web {
|
||||
// Check if this crate has web features that need modification
|
||||
match metadata::get_no_web_features(sh, crate_name)
|
||||
.context("Failed to check web features")?
|
||||
{
|
||||
Some(features) => {
|
||||
if features.is_empty() {
|
||||
// Crate has web_server_capability but no other applicable features
|
||||
cmd!(sh, "{cargo} test --no-default-features --")
|
||||
} else {
|
||||
cmd!(sh, "{cargo} test --")
|
||||
cmd!(sh, "{cargo} test --no-default-features --features")
|
||||
.arg(features)
|
||||
.arg("--")
|
||||
}
|
||||
},
|
||||
None => {
|
||||
// Crate doesn't have web features, use normal test
|
||||
cmd!(sh, "{cargo} test --all-features --")
|
||||
},
|
||||
}
|
||||
} else {
|
||||
cmd!(sh, "{cargo} test --all-features --")
|
||||
};
|
||||
|
||||
cmd.args(&flags.args)
|
||||
|
|
@ -47,8 +61,6 @@ pub fn test(sh: &Shell, flags: flags::Test) -> anyhow::Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// Determine the target triple of the host. We explicitly run all tests against the host
|
||||
// architecture so we can test the plugins, too (they default to wasm32-wasip1 otherwise).
|
||||
pub fn host_target_triple(sh: &Shell) -> anyhow::Result<String> {
|
||||
let rustc_ver = cmd!(sh, "rustc -vV")
|
||||
.read()
|
||||
|
|
|
|||
|
|
@ -9,14 +9,26 @@ license = "MIT"
|
|||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
# TODO: we might want to move these to be workspace dependencies as well, but
|
||||
# work is currently being done on moving all our async stuff to tokio from async_std,
|
||||
# so let's see how that works out before rocking the boat
|
||||
futures = { version = "0.3.30", optional = true }
|
||||
axum = { version = "0.8.4", features = ["ws"], optional = true }
|
||||
axum-extra = { version = "0.10.1", features = ["cookie"], optional = true }
|
||||
axum-server = { version = "0.7", features = ["tls-rustls"], optional = true }
|
||||
time = { version = "0.3", optional = true }
|
||||
tower-http = { version = "0.6.4", features = ["cors"], optional = true }
|
||||
|
||||
anyhow = { workspace = true }
|
||||
daemonize = { workspace = true }
|
||||
interprocess = { workspace = true }
|
||||
lazy_static = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
log = { workspace = true }
|
||||
mio = { version = "0.7.11", default-features = false, features = ['os-ext'] }
|
||||
nix = { workspace = true }
|
||||
notify-debouncer-full = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
rmp-serde = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
|
@ -25,10 +37,28 @@ signal-hook = { workspace = true }
|
|||
termwiz = { workspace = true }
|
||||
url = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
include_dir = { workspace = true }
|
||||
zellij-utils = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
insta = "1.6.0"
|
||||
isahc = { workspace = true }
|
||||
tokio-tungstenite = "0.20"
|
||||
futures-util = "0.3"
|
||||
urlencoding = "2.1"
|
||||
serde_json = "1.0"
|
||||
serial_test = "3.0"
|
||||
|
||||
[features]
|
||||
unstable = [ ]
|
||||
unstable = []
|
||||
web_server_capability = [
|
||||
"dep:futures",
|
||||
"dep:axum",
|
||||
"dep:axum-extra",
|
||||
"dep:time",
|
||||
"dep:axum-server",
|
||||
"dep:tower-http",
|
||||
"zellij-utils/web_server_capability",
|
||||
]
|
||||
|
|
|
|||
2
zellij-client/assets/addon-clipboard.js
Normal file
2
zellij-client/assets/addon-clipboard.js
Normal file
File diff suppressed because one or more lines are too long
29
zellij-client/assets/addon-fit.js
Normal file
29
zellij-client/assets/addon-fit.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
|
||||
Taken from @xterm/addon-fit v0.10.0
|
||||
|
||||
The following license refers to this file and the functions
|
||||
within it only
|
||||
|
||||
Copyright (c) 2019, The xterm.js authors (https://github.com/xtermjs/xterm.js)
|
||||
|
||||
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.
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=addon-fit.js.map
|
||||
2
zellij-client/assets/addon-web-links.js
Normal file
2
zellij-client/assets/addon-web-links.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.WebLinksAddon=t():e.WebLinksAddon=t()}(self,(()=>(()=>{"use strict";var e={6:(e,t)=>{function n(e){try{const t=new URL(e),n=t.password&&t.username?`${t.protocol}//${t.username}:${t.password}@${t.host}`:t.username?`${t.protocol}//${t.username}@${t.host}`:`${t.protocol}//${t.host}`;return e.toLocaleLowerCase().startsWith(n.toLocaleLowerCase())}catch(e){return!1}}Object.defineProperty(t,"__esModule",{value:!0}),t.LinkComputer=t.WebLinkProvider=void 0,t.WebLinkProvider=class{constructor(e,t,n,o={}){this._terminal=e,this._regex=t,this._handler=n,this._options=o}provideLinks(e,t){const n=o.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(n))}_addCallbacks(e){return e.map((e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){const{range:o}=e;this._options.hover(t,n,o)}},e)))}};class o{static computeLink(e,t,r,i){const s=new RegExp(t.source,(t.flags||"")+"g"),[a,c]=o._getWindowedLineStrings(e-1,r),l=a.join("");let d;const p=[];for(;d=s.exec(l);){const e=d[0];if(!n(e))continue;const[t,s]=o._mapStrIdx(r,c,0,d.index),[a,l]=o._mapStrIdx(r,t,s,e.length);if(-1===t||-1===s||-1===a||-1===l)continue;const h={start:{x:s+1,y:t+1},end:{x:l,y:a+1}};p.push({range:h,text:e,activate:i})}return p}static _getWindowedLineStrings(e,t){let n,o=e,r=e,i=0,s="";const a=[];if(n=t.buffer.active.getLine(e)){const e=n.translateToString(!0);if(n.isWrapped&&" "!==e[0]){for(i=0;(n=t.buffer.active.getLine(--o))&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),n.isWrapped&&-1===s.indexOf(" ")););a.reverse()}for(a.push(e),i=0;(n=t.buffer.active.getLine(++r))&&n.isWrapped&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),-1===s.indexOf(" ")););}return[a,o]}static _mapStrIdx(e,t,n,o){const r=e.buffer.active,i=r.getNullCell();let s=n;for(;o;){const e=r.getLine(t);if(!e)return[-1,-1];for(let n=s;n<e.length;++n){e.getCell(n,i);const s=i.getChars();if(i.getWidth()&&(o-=s.length||1,n===e.length-1&&""===s)){const e=r.getLine(t+1);e&&e.isWrapped&&(e.getCell(0,i),2===i.getWidth()&&(o+=1))}if(o<0)return[t,n]}t++,s=0}return[t,s]}}t.LinkComputer=o}},t={};function n(o){var r=t[o];if(void 0!==r)return r.exports;var i=t[o]={exports:{}};return e[o](i,i.exports,n),i.exports}var o={};return(()=>{var e=o;Object.defineProperty(e,"__esModule",{value:!0}),e.WebLinksAddon=void 0;const t=n(6),r=/(https?|HTTPS?):[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function i(e,t){const n=window.open();if(n){try{n.opener=null}catch{}n.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}e.WebLinksAddon=class{constructor(e=i,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;const n=this._options,o=n.urlRegex||r;this._linkProvider=this._terminal.registerLinkProvider(new t.WebLinkProvider(this._terminal,o,this._handler,n))}dispose(){this._linkProvider?.dispose()}}})(),o})()));
|
||||
//# sourceMappingURL=addon-web-links.js.map
|
||||
2
zellij-client/assets/addon-webgl.js
Normal file
2
zellij-client/assets/addon-webgl.js
Normal file
File diff suppressed because one or more lines are too long
108
zellij-client/assets/auth.js
Normal file
108
zellij-client/assets/auth.js
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* Authentication logic and token management
|
||||
*/
|
||||
|
||||
import { is_https } from './utils.js';
|
||||
|
||||
/**
|
||||
* Wait for user to provide a security token
|
||||
* @returns {Promise<{token: string, remember: boolean}>}
|
||||
*/
|
||||
async function waitForSecurityToken() {
|
||||
let token = null;
|
||||
let remember = null;
|
||||
|
||||
while (!token) {
|
||||
let result = await getSecurityToken();
|
||||
if (result) {
|
||||
token = result.token;
|
||||
remember = result.remember;
|
||||
} else {
|
||||
await showErrorModal("Error", "Must provide security token in order to log in.");
|
||||
}
|
||||
}
|
||||
|
||||
return { token, remember };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client ID from server after authentication
|
||||
* @param {string} token - Authentication token
|
||||
* @param {boolean} rememberMe - Remember login preference
|
||||
* @param {boolean} hasAuthenticationCookie - Whether auth cookie exists
|
||||
* @returns {Promise<string|null>} Client ID or null on failure
|
||||
*/
|
||||
export async function getClientId(token, rememberMe, hasAuthenticationCookie) {
|
||||
let url_prefix = is_https() ? "https" : "http";
|
||||
|
||||
if (!hasAuthenticationCookie) {
|
||||
let login_res = await fetch(`${url_prefix}://${window.location.host}/command/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
auth_token: token,
|
||||
remember_me: rememberMe ? true : false
|
||||
}),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (login_res.status === 401) {
|
||||
await showErrorModal("Error", "Unauthorized or revoked login token.");
|
||||
return null;
|
||||
} else if (!login_res.ok) {
|
||||
await showErrorModal("Error", `Error ${login_res.status} connecting to server.`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let data = await fetch(`${url_prefix}://${window.location.host}/session`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (data.status === 401) {
|
||||
await showErrorModal("Error", "Unauthorized or revoked login token.");
|
||||
return null;
|
||||
} else if (!data.ok) {
|
||||
await showErrorModal("Error", `Error ${data.status} connecting to server.`);
|
||||
return null;
|
||||
} else {
|
||||
let body = await data.json();
|
||||
return body.web_client_id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize authentication flow and return client ID
|
||||
* @returns {Promise<string>} Client ID
|
||||
*/
|
||||
export async function initAuthentication() {
|
||||
let token = null;
|
||||
let remember = null;
|
||||
let hasAuthenticationCookie = window.is_authenticated;
|
||||
|
||||
if (!hasAuthenticationCookie) {
|
||||
const tokenResult = await waitForSecurityToken();
|
||||
token = tokenResult.token;
|
||||
remember = tokenResult.remember;
|
||||
}
|
||||
|
||||
let webClientId;
|
||||
|
||||
while (!webClientId) {
|
||||
webClientId = await getClientId(token, remember, hasAuthenticationCookie);
|
||||
if (!webClientId) {
|
||||
hasAuthenticationCookie = false;
|
||||
const tokenResult = await waitForSecurityToken();
|
||||
token = tokenResult.token;
|
||||
remember = tokenResult.remember;
|
||||
}
|
||||
}
|
||||
|
||||
return webClientId;
|
||||
}
|
||||
113
zellij-client/assets/connection.js
Normal file
113
zellij-client/assets/connection.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* Connection-related utility functions and management
|
||||
*/
|
||||
|
||||
import { is_https } from './utils.js';
|
||||
|
||||
// Connection state
|
||||
let reconnectionAttempt = 0;
|
||||
let isReconnecting = false;
|
||||
let reconnectionTimeout = null;
|
||||
let hasConnectedBefore = false;
|
||||
let isPageUnloading = false;
|
||||
|
||||
/**
|
||||
* Get the delay for reconnection attempts using exponential backoff
|
||||
* @param {number} attempt - The current attempt number (1-based)
|
||||
* @returns {number} The delay in seconds
|
||||
*/
|
||||
export function getReconnectionDelay(attempt) {
|
||||
const delays = [1, 2, 4, 8, 16];
|
||||
return delays[Math.min(attempt - 1, delays.length - 1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the server connection is available
|
||||
* @returns {Promise<boolean>} true if connection is OK, false otherwise
|
||||
*/
|
||||
export async function checkConnection() {
|
||||
try {
|
||||
let url_prefix = is_https() ? "https" : "http";
|
||||
const response = await fetch(`${url_prefix}://${window.location.host}/info/version`, {
|
||||
method: 'GET',
|
||||
timeout: 5000
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle reconnection attempts with exponential backoff
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function handleReconnection() {
|
||||
if (isReconnecting || !hasConnectedBefore || isPageUnloading) {
|
||||
return;
|
||||
}
|
||||
|
||||
isReconnecting = true;
|
||||
let currentModal = null;
|
||||
|
||||
while (isReconnecting) {
|
||||
reconnectionAttempt++;
|
||||
const delaySeconds = getReconnectionDelay(reconnectionAttempt);
|
||||
|
||||
const result = await showReconnectionModal(reconnectionAttempt, delaySeconds);
|
||||
|
||||
if (result.action === 'cancel') {
|
||||
if (result.cleanup) result.cleanup();
|
||||
isReconnecting = false;
|
||||
reconnectionAttempt = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.action === 'reconnect') {
|
||||
currentModal = result.modal;
|
||||
const connectionOk = await checkConnection();
|
||||
|
||||
if (connectionOk) {
|
||||
if (result.cleanup) result.cleanup();
|
||||
isReconnecting = false;
|
||||
reconnectionAttempt = 0;
|
||||
window.location.reload();
|
||||
return;
|
||||
} else {
|
||||
if (result.cleanup) result.cleanup();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize connection handlers and event listeners
|
||||
*/
|
||||
export function initConnectionHandlers() {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
isPageUnloading = true;
|
||||
});
|
||||
|
||||
window.addEventListener('pagehide', () => {
|
||||
isPageUnloading = true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark that a connection has been established
|
||||
*/
|
||||
export function markConnectionEstablished() {
|
||||
hasConnectedBefore = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset connection state
|
||||
*/
|
||||
export function resetConnectionState() {
|
||||
reconnectionAttempt = 0;
|
||||
isReconnecting = false;
|
||||
reconnectionTimeout = null;
|
||||
hasConnectedBefore = false;
|
||||
isPageUnloading = false;
|
||||
}
|
||||
BIN
zellij-client/assets/favicon.ico
Normal file
BIN
zellij-client/assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
39
zellij-client/assets/index.html
Normal file
39
zellij-client/assets/index.html
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" style="height: 100%">
|
||||
<head>
|
||||
<link rel="icon" href="/assets/favicon.ico" />
|
||||
<!-- preload the font to avoid rendering issue on first load
|
||||
not 100% perfect but much better than without
|
||||
check this for some additional context: https://github.com/xtermjs/xterm.js/issues/5164
|
||||
-->
|
||||
<link href="/assets/style.css" rel="stylesheet" />
|
||||
<!-- xterm.css -->
|
||||
<link rel="stylesheet" href="/assets/xterm.css" />
|
||||
<!-- xterm.js -->
|
||||
<script src="/assets/xterm.js"></script>
|
||||
<script src="/assets/addon-fit.js"></script>
|
||||
<script src="/assets/addon-clipboard.js"></script>
|
||||
<script src="/assets/addon-web-links.js"></script>
|
||||
<script src="/assets/addon-webgl.js"></script>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Zellij Web Client</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="terminal" tabindex=0></div>
|
||||
<script>
|
||||
window.is_authenticated = IS_AUTHENTICATED;
|
||||
</script>
|
||||
<script src="/assets/modals.js"></script>
|
||||
<!-- Module files - order matters -->
|
||||
<script type="module" src="/assets/utils.js"></script>
|
||||
<script type="module" src="/assets/connection.js"></script>
|
||||
<script type="module" src="/assets/auth.js"></script>
|
||||
<script type="module" src="/assets/keyboard.js"></script>
|
||||
<script type="module" src="/assets/links.js"></script>
|
||||
<script type="module" src="/assets/terminal.js"></script>
|
||||
<script type="module" src="/assets/input.js"></script>
|
||||
<script type="module" src="/assets/websockets.js"></script>
|
||||
<script type="module" src="/assets/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
zellij-client/assets/index.js
Normal file
28
zellij-client/assets/index.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { initConnectionHandlers } from './connection.js';
|
||||
import { initAuthentication } from './auth.js';
|
||||
import { initTerminal } from './terminal.js';
|
||||
import { setupInputHandlers } from './input.js';
|
||||
import { initWebSockets } from './websockets.js';
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async (event) => {
|
||||
initConnectionHandlers();
|
||||
|
||||
const webClientId = await initAuthentication();
|
||||
|
||||
const { term, fitAddon } = initTerminal();
|
||||
const sessionName = location.pathname.split("/").pop();
|
||||
|
||||
let sendAnsiKey = (ansiKey) => {
|
||||
// This will be replaced by the WebSocket module
|
||||
};
|
||||
|
||||
setupInputHandlers(term, sendAnsiKey);
|
||||
|
||||
const websockets = initWebSockets(webClientId, sessionName, term, fitAddon, sendAnsiKey);
|
||||
|
||||
// Update sendAnsiKey to use the actual WebSocket function returned by initWebSockets
|
||||
sendAnsiKey = websockets.sendAnsiKey;
|
||||
|
||||
// Update the input handlers with the correct sendAnsiKey function
|
||||
setupInputHandlers(term, sendAnsiKey);
|
||||
});
|
||||
125
zellij-client/assets/input.js
Normal file
125
zellij-client/assets/input.js
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
/**
|
||||
* Input handling for terminal events
|
||||
*/
|
||||
|
||||
import { encode_kitty_key } from './keyboard.js';
|
||||
|
||||
/**
|
||||
* Set up all input handlers for the terminal
|
||||
* @param {Terminal} term - The terminal instance
|
||||
* @param {function} sendFunction - Function to send data through WebSocket
|
||||
*/
|
||||
export function setupInputHandlers(term, sendFunction) {
|
||||
// Mouse tracking state
|
||||
let prev_col = 0;
|
||||
let prev_row = 0;
|
||||
|
||||
// Custom key event handler
|
||||
term.attachCustomKeyEventHandler((ev) => {
|
||||
if (ev.type === "keydown") {
|
||||
let modifiers_count = 0;
|
||||
let shift_keycode = 16;
|
||||
let alt_keycode = 17;
|
||||
let ctrl_keycode = 18;
|
||||
if (ev.altKey) {
|
||||
modifiers_count += 1;
|
||||
}
|
||||
if (ev.ctrlKey) {
|
||||
modifiers_count += 1;
|
||||
}
|
||||
if (ev.shiftKey) {
|
||||
modifiers_count += 1;
|
||||
}
|
||||
if (ev.metaKey) {
|
||||
modifiers_count += 1;
|
||||
}
|
||||
if (
|
||||
(modifiers_count > 1 || ev.metaKey) &&
|
||||
ev.keyCode != shift_keycode &&
|
||||
ev.keyCode != alt_keycode &&
|
||||
ev.keyCode != ctrl_keycode
|
||||
) {
|
||||
ev.preventDefault();
|
||||
encode_kitty_key(ev, sendFunction);
|
||||
return false;
|
||||
}
|
||||
// workarounds for https://github.com/xtermjs/xterm.js/blob/41e8ae395937011d6bf6c7cb618b851791aed395/src/common/input/Keyboard.ts#L158
|
||||
if (ev.key == "ArrowLeft" && ev.altKey) {
|
||||
ev.preventDefault();
|
||||
sendFunction("\x1b[1;3D");
|
||||
return false;
|
||||
}
|
||||
if (ev.key == "ArrowRight" && ev.altKey) {
|
||||
ev.preventDefault();
|
||||
sendFunction("\x1b[1;3C");
|
||||
return false;
|
||||
}
|
||||
if (ev.key == "ArrowUp" && ev.altKey) {
|
||||
ev.preventDefault();
|
||||
sendFunction("\x1b[1;3A");
|
||||
return false;
|
||||
}
|
||||
if (ev.key == "ArrowDown" && ev.altKey) {
|
||||
ev.preventDefault();
|
||||
sendFunction("\x1b[1;3B");
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
(ev.key == "=" && ev.altKey) ||
|
||||
(ev.key == "+" && ev.altKey) ||
|
||||
(ev.key == "-" && ev.altKey)
|
||||
) {
|
||||
// these are not properly handled by xterm.js, so we bypass it and encode them as kitty to make things easier
|
||||
ev.preventDefault();
|
||||
encode_kitty_key(ev, sendFunction);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Mouse movement handler
|
||||
let terminal_element = document.getElementById("terminal");
|
||||
terminal_element.addEventListener("mousemove", function (event) {
|
||||
window.term.focus();
|
||||
// this is a hack around: https://github.com/xtermjs/xterm.js/issues/1062
|
||||
// in short, xterm.js doesn't listen to mousemove at all and so even though
|
||||
// we send it a request for AnyEvent mouse handling, we don't get motion events in return
|
||||
// here we use some internal functions in a hopefully non-destructive way to calculate the
|
||||
// columns/rows to send from the x/y coordinates - it's safe to always send these because Zellij
|
||||
// always requests mouse AnyEvent handling
|
||||
if (event.buttons == 0) {
|
||||
// this means no mouse buttons are pressed and this is just a mouse movement
|
||||
let { col, row } = term._core._mouseService.getMouseReportCoords(
|
||||
event,
|
||||
terminal_element
|
||||
);
|
||||
if (prev_col != col || prev_row != row) {
|
||||
sendFunction(`\x1b[<35;${col + 1};${row + 1}M`);
|
||||
}
|
||||
prev_col = col;
|
||||
prev_row = row;
|
||||
}
|
||||
});
|
||||
|
||||
// Context menu handler
|
||||
document.addEventListener('contextmenu', function(event) {
|
||||
if (event.altKey) {
|
||||
// this is so that when the user does an alt-right-click to ungroup panes, the context menu will not appear
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Terminal data handlers
|
||||
term.onData((data) => {
|
||||
sendFunction(data);
|
||||
});
|
||||
|
||||
term.onBinary((data) => {
|
||||
const buffer = new Uint8Array(data.length);
|
||||
for (let i = 0; i < data.length; ++i) {
|
||||
buffer[i] = data.charCodeAt(i) & 255;
|
||||
}
|
||||
sendFunction(buffer);
|
||||
});
|
||||
}
|
||||
30
zellij-client/assets/keyboard.js
Normal file
30
zellij-client/assets/keyboard.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Keyboard handling functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Encode a keyboard event into kitty protocol ANSI escape sequence
|
||||
* @param {KeyboardEvent} ev - The keyboard event to encode
|
||||
* @param {function} send_ansi_key - Function to send the ANSI key sequence
|
||||
*/
|
||||
export function encode_kitty_key(ev, send_ansi_key) {
|
||||
let shift_value = 1;
|
||||
let alt_value = 2;
|
||||
let ctrl_value = 4;
|
||||
let super_value = 8;
|
||||
let modifier_string = 1;
|
||||
if (ev.shiftKey) {
|
||||
modifier_string += shift_value;
|
||||
}
|
||||
if (ev.altKey) {
|
||||
modifier_string += alt_value;
|
||||
}
|
||||
if (ev.ctrlKey) {
|
||||
modifier_string += ctrl_value;
|
||||
}
|
||||
if (ev.metaKey) {
|
||||
modifier_string += super_value;
|
||||
}
|
||||
let key_code = ev.key.charCodeAt(0);
|
||||
send_ansi_key(`\x1b[${key_code};${modifier_string}u`);
|
||||
}
|
||||
51
zellij-client/assets/links.js
Normal file
51
zellij-client/assets/links.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Link handling functions for terminal
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build a link handler object for terminal links
|
||||
* @returns {object} Object containing linkHandler and activateLink function
|
||||
*/
|
||||
export function build_link_handler() {
|
||||
let _linkPopup;
|
||||
|
||||
function removeLinkPopup(event, text, range) {
|
||||
if (_linkPopup) {
|
||||
_linkPopup.remove();
|
||||
_linkPopup = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function showLinkPopup(event, text, range) {
|
||||
let popup = document.createElement('div');
|
||||
popup.classList.add('xterm-link-popup');
|
||||
popup.style.position = 'absolute';
|
||||
popup.style.top = (event.clientY + 25) + 'px';
|
||||
popup.style.left = (event.clientX + 25) + 'px';
|
||||
popup.style.fontSize = 'small';
|
||||
popup.style.lineBreak = 'normal';
|
||||
popup.style.padding = '4px';
|
||||
popup.style.minWidth = '15em';
|
||||
popup.style.maxWidth = '80%';
|
||||
popup.style.border = 'thin solid';
|
||||
popup.style.borderRadius = '6px';
|
||||
popup.style.background = '#6c4c4c';
|
||||
popup.style.borderColor = '#150262';
|
||||
popup.innerText = "Shift-Click: " + text;
|
||||
const topElement = event.target.parentNode;
|
||||
topElement.appendChild(popup);
|
||||
const popupHeight = popup.offsetHeight;
|
||||
_linkPopup = popup;
|
||||
}
|
||||
|
||||
function activateLink(event, uri) {
|
||||
const newWindow = window.open(uri, '_blank');
|
||||
if (newWindow) newWindow.opener = null; // prevent the opened link from gaining access to the terminal instance
|
||||
}
|
||||
|
||||
let linkHandler = {};
|
||||
linkHandler.hover = showLinkPopup;
|
||||
linkHandler.leave = removeLinkPopup;
|
||||
linkHandler.activate = activateLink;
|
||||
return { linkHandler, activateLink };
|
||||
}
|
||||
556
zellij-client/assets/modals.js
Normal file
556
zellij-client/assets/modals.js
Normal file
|
|
@ -0,0 +1,556 @@
|
|||
function createModalStyles() {
|
||||
if (document.querySelector('#modal-styles')) return;
|
||||
|
||||
const zellijGreen = '#A3BD8D';
|
||||
const zellijGreenDark = '#7A9B6A';
|
||||
const zellijBlue = '#7E9FBE';
|
||||
const zellijBlueDark = '#5A7EA0';
|
||||
const zellijYellow = '#EACB8B';
|
||||
const errorRed = '#BE616B';
|
||||
const errorRedDark = '#A04E57';
|
||||
|
||||
const terminalDark = '#000000';
|
||||
const terminalMedium = '#1C1C1C';
|
||||
const terminalLight = '#3A3A3A';
|
||||
const terminalText = '#FFFFFF';
|
||||
const terminalTextDim = '#CCCCCC';
|
||||
|
||||
const terminalLightBg = '#FFFFFF';
|
||||
const terminalLightMedium = '#F0F0F0';
|
||||
const terminalLightText = '#000000';
|
||||
const terminalLightTextDim = '#666666';
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'modal-styles';
|
||||
style.textContent = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
.security-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(28, 28, 28, 0.95);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
font-family: 'JetBrains Mono', 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.security-modal-content {
|
||||
background: ${terminalDark};
|
||||
color: ${terminalText};
|
||||
padding: 24px;
|
||||
border-radius: 0;
|
||||
border: 2px solid ${zellijGreen};
|
||||
box-shadow: 0 0 20px rgba(127, 176, 105, 0.3);
|
||||
max-width: 420px;
|
||||
width: 90%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.security-modal-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
background: ${zellijGreen};
|
||||
border-radius: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.security-modal h3 {
|
||||
margin: 0 0 20px 0;
|
||||
color: ${zellijBlue};
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
border-bottom: 1px solid ${terminalLight};
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.security-modal.error .security-modal-content {
|
||||
border-color: ${errorRed};
|
||||
box-shadow: 0 0 20px rgba(190, 97, 107, 0.3);
|
||||
}
|
||||
|
||||
.security-modal.error .security-modal-content::before {
|
||||
background: ${errorRed};
|
||||
}
|
||||
|
||||
.security-modal.error h3 {
|
||||
color: ${errorRed};
|
||||
}
|
||||
|
||||
.security-modal input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid ${terminalLight};
|
||||
border-radius: 0;
|
||||
box-sizing: border-box;
|
||||
background: ${terminalMedium};
|
||||
color: ${terminalText};
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.security-modal input[type="password"]:focus {
|
||||
outline: none;
|
||||
border-color: ${zellijBlue};
|
||||
box-shadow: 0 0 0 1px ${zellijBlue};
|
||||
background: ${terminalLight};
|
||||
}
|
||||
|
||||
.security-modal label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
cursor: pointer;
|
||||
color: ${terminalTextDim};
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.security-modal input[type="checkbox"] {
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid ${terminalLight};
|
||||
margin-right: 10px;
|
||||
background: ${terminalMedium};
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.security-modal input[type="checkbox"]:checked {
|
||||
background: ${zellijGreen};
|
||||
border-color: ${zellijGreen};
|
||||
}
|
||||
|
||||
.security-modal input[type="checkbox"]:checked::after {
|
||||
content: '✓';
|
||||
color: ${terminalDark};
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.security-modal .button-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.security-modal button {
|
||||
padding: 10px 20px;
|
||||
border: 1px solid;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.security-modal .cancel-btn {
|
||||
background: transparent;
|
||||
color: ${terminalTextDim};
|
||||
border-color: ${terminalLight};
|
||||
}
|
||||
|
||||
.security-modal .cancel-btn:hover {
|
||||
background: ${terminalLight};
|
||||
color: ${terminalText};
|
||||
}
|
||||
|
||||
.security-modal .submit-btn {
|
||||
background: ${zellijGreen};
|
||||
color: ${terminalDark};
|
||||
border-color: ${zellijGreen};
|
||||
}
|
||||
|
||||
.security-modal .submit-btn:hover {
|
||||
background: ${zellijGreenDark};
|
||||
border-color: ${zellijGreenDark};
|
||||
color: white;
|
||||
}
|
||||
|
||||
.security-modal .dismiss-btn {
|
||||
background: transparent;
|
||||
color: ${terminalText};
|
||||
border-color: ${terminalLight};
|
||||
}
|
||||
|
||||
.security-modal .dismiss-btn:hover {
|
||||
background: ${terminalLight};
|
||||
}
|
||||
|
||||
.security-modal.error .dismiss-btn {
|
||||
border-color: ${errorRed};
|
||||
color: ${errorRed};
|
||||
}
|
||||
|
||||
.security-modal.error .dismiss-btn:hover {
|
||||
background: rgba(190, 97, 107, 0.2);
|
||||
}
|
||||
|
||||
.security-modal .error-description {
|
||||
margin: 16px 0 20px 0;
|
||||
color: ${terminalTextDim};
|
||||
line-height: 1.5;
|
||||
font-size: 14px;
|
||||
padding: 12px;
|
||||
background: rgba(190, 97, 107, 0.1);
|
||||
border-left: 3px solid ${errorRed};
|
||||
}
|
||||
|
||||
.security-modal .status-bar {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
height: 3px;
|
||||
background: ${zellijGreen};
|
||||
}
|
||||
|
||||
.security-modal.error .status-bar {
|
||||
background: ${errorRed};
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.security-modal {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.security-modal-content {
|
||||
background: ${terminalLightBg};
|
||||
color: ${terminalLightText};
|
||||
border-color: ${zellijBlueDark};
|
||||
box-shadow: 0 0 20px rgba(90, 126, 160, 0.3);
|
||||
}
|
||||
|
||||
.security-modal-content::before {
|
||||
background: ${zellijBlueDark};
|
||||
}
|
||||
|
||||
.security-modal h3 {
|
||||
color: ${zellijBlueDark};
|
||||
border-bottom-color: ${terminalLightMedium};
|
||||
}
|
||||
|
||||
.security-modal input[type="password"] {
|
||||
background: white;
|
||||
border-color: ${zellijBlueDark};
|
||||
color: ${terminalLightText};
|
||||
}
|
||||
|
||||
.security-modal input[type="password"]:focus {
|
||||
border-color: ${zellijBlueDark};
|
||||
box-shadow: 0 0 0 1px ${zellijBlueDark};
|
||||
background: ${terminalLightBg};
|
||||
}
|
||||
|
||||
.security-modal label {
|
||||
color: ${terminalLightTextDim};
|
||||
}
|
||||
|
||||
.security-modal input[type="checkbox"] {
|
||||
background: white;
|
||||
border-color: ${zellijBlueDark};
|
||||
}
|
||||
|
||||
.security-modal input[type="checkbox"]:checked {
|
||||
background: ${zellijGreenDark};
|
||||
border-color: ${zellijGreenDark};
|
||||
}
|
||||
|
||||
.security-modal input[type="checkbox"]:checked::after {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.security-modal .cancel-btn {
|
||||
background: ${terminalLightBg};
|
||||
color: ${terminalLightTextDim};
|
||||
border-color: ${zellijBlueDark};
|
||||
}
|
||||
|
||||
.security-modal .cancel-btn:hover {
|
||||
background: ${terminalLightMedium};
|
||||
color: ${terminalLightText};
|
||||
}
|
||||
|
||||
.security-modal .submit-btn {
|
||||
background: ${zellijGreenDark};
|
||||
border-color: ${zellijGreenDark};
|
||||
color: white;
|
||||
}
|
||||
|
||||
.security-modal .submit-btn:hover {
|
||||
background: ${zellijGreen};
|
||||
border-color: ${zellijGreen};
|
||||
color: ${terminalDark};
|
||||
}
|
||||
|
||||
.security-modal .dismiss-btn {
|
||||
background: ${terminalLightBg};
|
||||
color: ${terminalLightText};
|
||||
border-color: ${terminalLightMedium};
|
||||
}
|
||||
|
||||
.security-modal .dismiss-btn:hover {
|
||||
background: ${terminalLightMedium};
|
||||
}
|
||||
|
||||
.security-modal.error .dismiss-btn {
|
||||
border-color: ${errorRedDark};
|
||||
color: ${errorRedDark};
|
||||
background: ${terminalLightBg};
|
||||
}
|
||||
|
||||
.security-modal.error .dismiss-btn:hover {
|
||||
background: rgba(160, 78, 87, 0.2);
|
||||
color: ${errorRedDark};
|
||||
border-color: ${errorRedDark};
|
||||
}
|
||||
|
||||
.security-modal .error-description {
|
||||
color: ${terminalLightTextDim};
|
||||
background: rgba(160, 78, 87, 0.05);
|
||||
}
|
||||
|
||||
.security-modal .status-bar {
|
||||
background: ${zellijBlueDark};
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function getSecurityToken() {
|
||||
return new Promise((resolve) => {
|
||||
createModalStyles();
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'security-modal';
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="security-modal-content">
|
||||
<h3>Security Token Required</h3>
|
||||
<input type="password" id="token" placeholder="Enter your security token">
|
||||
<label>
|
||||
<input type="checkbox" id="remember">
|
||||
Remember me
|
||||
</label>
|
||||
<div class="button-row">
|
||||
<button id="cancel" class="cancel-btn">Cancel</button>
|
||||
<button id="submit" class="submit-btn">Authenticate</button>
|
||||
</div>
|
||||
<div class="status-bar"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
modal.querySelector('#token').focus();
|
||||
|
||||
const handleKeydown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
modal.addEventListener('keydown', handleKeydown);
|
||||
|
||||
const cleanup = () => {
|
||||
modal.removeEventListener('keydown', handleKeydown);
|
||||
document.body.removeChild(modal);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const token = modal.querySelector('#token').value;
|
||||
const remember = modal.querySelector('#remember').checked;
|
||||
cleanup();
|
||||
resolve({ token, remember });
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
modal.querySelector('#submit').onclick = handleSubmit;
|
||||
modal.querySelector('#cancel').onclick = handleCancel;
|
||||
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) {
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function showErrorModal(title, description) {
|
||||
return new Promise((resolve) => {
|
||||
createModalStyles();
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'security-modal error';
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="security-modal-content">
|
||||
<h3>${title}</h3>
|
||||
<div class="error-description">${description}</div>
|
||||
<div class="button-row">
|
||||
<button id="dismiss" class="dismiss-btn">Acknowledge</button>
|
||||
</div>
|
||||
<div class="status-bar"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
modal.querySelector('#dismiss').focus();
|
||||
|
||||
const handleKeydown = (e) => {
|
||||
if (e.key === 'Enter' || e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
modal.addEventListener('keydown', handleKeydown);
|
||||
|
||||
const cleanup = () => {
|
||||
modal.removeEventListener('keydown', handleKeydown);
|
||||
document.body.removeChild(modal);
|
||||
resolve();
|
||||
};
|
||||
|
||||
modal.querySelector('#dismiss').onclick = cleanup;
|
||||
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function showReconnectionModal(attemptNumber, delaySeconds) {
|
||||
return new Promise((resolve) => {
|
||||
createModalStyles();
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'security-modal';
|
||||
modal.style.background = 'rgba(28, 28, 28, 0.85)'; // More transparent to show terminal
|
||||
|
||||
const isFirstAttempt = attemptNumber === 1;
|
||||
const title = isFirstAttempt ? 'Connection Lost' : 'Reconnection Failed';
|
||||
const message = isFirstAttempt
|
||||
? `Reconnecting in <span id="countdown">${delaySeconds}</span> second${delaySeconds > 1 ? 's' : ''}...`
|
||||
: `Retrying in <span id="countdown">${delaySeconds}</span> second${delaySeconds > 1 ? 's' : ''}... (Attempt ${attemptNumber})`;
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="security-modal-content">
|
||||
<h3 id="modal-title">${title}</h3>
|
||||
<div class="error-description" id="modal-message">${message}</div>
|
||||
<div class="button-row" id="button-row">
|
||||
<button id="cancel" class="cancel-btn">Cancel</button>
|
||||
<button id="reconnect" class="submit-btn">Reconnect Now</button>
|
||||
</div>
|
||||
<div class="status-bar"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
modal.querySelector('#reconnect').focus();
|
||||
|
||||
let countdownInterval;
|
||||
let remainingSeconds = delaySeconds;
|
||||
let isCheckingConnection = false;
|
||||
|
||||
const updateCountdown = () => {
|
||||
const countdownElement = modal.querySelector('#countdown');
|
||||
if (countdownElement && !isCheckingConnection) {
|
||||
countdownElement.textContent = remainingSeconds;
|
||||
}
|
||||
remainingSeconds--;
|
||||
|
||||
if (remainingSeconds < 0 && !isCheckingConnection) {
|
||||
clearInterval(countdownInterval);
|
||||
handleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
const showConnectionCheck = () => {
|
||||
isCheckingConnection = true;
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
}
|
||||
|
||||
const messageElement = modal.querySelector('#modal-message');
|
||||
messageElement.innerHTML = 'Connecting...';
|
||||
};
|
||||
|
||||
countdownInterval = setInterval(updateCountdown, 1000);
|
||||
|
||||
const handleKeydown = (e) => {
|
||||
if (isCheckingConnection) return;
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleReconnect();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
modal.addEventListener('keydown', handleKeydown);
|
||||
|
||||
const cleanup = () => {
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
}
|
||||
modal.removeEventListener('keydown', handleKeydown);
|
||||
if (document.body.contains(modal)) {
|
||||
document.body.removeChild(modal);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReconnect = () => {
|
||||
showConnectionCheck();
|
||||
// Don't cleanup here - let the parent handle it
|
||||
resolve({ action: 'reconnect', cleanup, modal });
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (isCheckingConnection) return;
|
||||
cleanup();
|
||||
resolve({ action: 'cancel' });
|
||||
};
|
||||
|
||||
modal.querySelector('#reconnect').onclick = handleReconnect;
|
||||
modal.querySelector('#cancel').onclick = handleCancel;
|
||||
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal && !isCheckingConnection) {
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
5
zellij-client/assets/style.css
Normal file
5
zellij-client/assets/style.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
body,
|
||||
#terminal {
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
38
zellij-client/assets/terminal.js
Normal file
38
zellij-client/assets/terminal.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Terminal initialization and management
|
||||
*/
|
||||
|
||||
import { build_link_handler } from './links.js';
|
||||
|
||||
/**
|
||||
* Initialize the terminal with all required addons and configuration
|
||||
* @returns {object} Object containing term and fitAddon instances
|
||||
*/
|
||||
export function initTerminal() {
|
||||
const term = new Terminal({
|
||||
fontFamily: "Monospace",
|
||||
allowProposedApi: true,
|
||||
scrollback: 0,
|
||||
});
|
||||
// for debugging
|
||||
window.term = term;
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
const clipboardAddon = new ClipboardAddon.ClipboardAddon();
|
||||
|
||||
const { linkHandler, activateLink } = build_link_handler();
|
||||
const webLinksAddon = new WebLinksAddon.WebLinksAddon(activateLink, linkHandler);
|
||||
term.options.linkHandler = linkHandler;
|
||||
|
||||
const webglAddon = new WebglAddon.WebglAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(clipboardAddon);
|
||||
term.loadAddon(webLinksAddon);
|
||||
webglAddon.onContextLoss((e) => {
|
||||
// TODO: reload, or?
|
||||
webglAddon.dispose();
|
||||
});
|
||||
term.loadAddon(webglAddon);
|
||||
term.open(document.getElementById("terminal"));
|
||||
fitAddon.fit();
|
||||
return { term, fitAddon };
|
||||
}
|
||||
11
zellij-client/assets/utils.js
Normal file
11
zellij-client/assets/utils.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Utility functions for the terminal web client
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if the current page is served over HTTPS
|
||||
* @returns {boolean} true if protocol is https:, false otherwise
|
||||
*/
|
||||
export function is_https() {
|
||||
return document.location.protocol === "https:";
|
||||
}
|
||||
253
zellij-client/assets/websockets.js
Normal file
253
zellij-client/assets/websockets.js
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
/**
|
||||
* WebSocket management for terminal and control connections
|
||||
*/
|
||||
|
||||
import { is_https } from './utils.js';
|
||||
import { handleReconnection, markConnectionEstablished } from './connection.js';
|
||||
|
||||
/**
|
||||
* Initialize both terminal and control WebSocket connections
|
||||
* @param {string} webClientId - Client ID from authentication
|
||||
* @param {string} sessionName - Session name from URL
|
||||
* @param {Terminal} term - Terminal instance
|
||||
* @param {FitAddon} fitAddon - Terminal fit addon
|
||||
* @param {function} sendAnsiKey - Function to send ANSI key sequences
|
||||
* @returns {object} Object containing WebSocket instances and cleanup function
|
||||
*/
|
||||
export function initWebSockets(webClientId, sessionName, term, fitAddon, sendAnsiKey) {
|
||||
let ownWebClientId = "";
|
||||
let wsTerminal;
|
||||
let wsControl;
|
||||
|
||||
const wsUrlPrefix = is_https() ? "wss" : "ws";
|
||||
const url = sessionName === ""
|
||||
? `${wsUrlPrefix}://${window.location.host}/ws/terminal`
|
||||
: `${wsUrlPrefix}://${window.location.host}/ws/terminal/${sessionName}`;
|
||||
|
||||
const queryString = `?web_client_id=${encodeURIComponent(webClientId)}`;
|
||||
const wsTerminalUrl = `${url}${queryString}`;
|
||||
|
||||
wsTerminal = new WebSocket(wsTerminalUrl);
|
||||
|
||||
wsTerminal.onopen = function () {
|
||||
markConnectionEstablished();
|
||||
};
|
||||
|
||||
wsTerminal.onmessage = function (event) {
|
||||
if (ownWebClientId == "") {
|
||||
ownWebClientId = webClientId;
|
||||
const wsControlUrl = `${wsUrlPrefix}://${window.location.host}/ws/control`;
|
||||
wsControl = new WebSocket(wsControlUrl);
|
||||
startWsControl(wsControl, term, fitAddon, ownWebClientId);
|
||||
}
|
||||
|
||||
let data = event.data;
|
||||
if (typeof data === 'string' && data.includes('\x1b[0 q')) {
|
||||
const shouldBlink = term.options.cursorBlink;
|
||||
const cursorStyle = term.options.cursorStyle;
|
||||
let replacement;
|
||||
switch (cursorStyle) {
|
||||
case 'block':
|
||||
replacement = shouldBlink ? '\x1b[1 q' : '\x1b[2 q';
|
||||
break;
|
||||
case 'underline':
|
||||
replacement = shouldBlink ? '\x1b[3 q' : '\x1b[4 q';
|
||||
break;
|
||||
case 'bar':
|
||||
replacement = shouldBlink ? '\x1b[5 q' : '\x1b[6 q';
|
||||
break;
|
||||
default:
|
||||
replacement = '\x1b[2 q';
|
||||
break;
|
||||
}
|
||||
data = data.replace(/\x1b\[0 q/g, replacement);
|
||||
}
|
||||
term.write(data);
|
||||
};
|
||||
|
||||
wsTerminal.onclose = function () {
|
||||
handleReconnection();
|
||||
};
|
||||
|
||||
// Update sendAnsiKey to use the actual WebSocket
|
||||
const originalSendAnsiKey = sendAnsiKey;
|
||||
sendAnsiKey = (ansiKey) => {
|
||||
if (ownWebClientId !== "") {
|
||||
wsTerminal.send(ansiKey);
|
||||
}
|
||||
};
|
||||
|
||||
// Setup resize handler
|
||||
setupResizeHandler(term, fitAddon, () => wsControl, () => ownWebClientId);
|
||||
|
||||
return {
|
||||
wsTerminal,
|
||||
getWsControl: () => wsControl,
|
||||
getOwnWebClientId: () => ownWebClientId,
|
||||
sendAnsiKey,
|
||||
cleanup: () => {
|
||||
if (wsTerminal) {
|
||||
wsTerminal.close();
|
||||
}
|
||||
if (wsControl) {
|
||||
wsControl.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the control WebSocket and set up its handlers
|
||||
* @param {WebSocket} wsControl - Control WebSocket instance
|
||||
* @param {Terminal} term - Terminal instance
|
||||
* @param {FitAddon} fitAddon - Terminal fit addon
|
||||
* @param {string} ownWebClientId - Own web client ID
|
||||
*/
|
||||
function startWsControl(wsControl, term, fitAddon, ownWebClientId) {
|
||||
wsControl.onopen = function (event) {
|
||||
const fitDimensions = fitAddon.proposeDimensions();
|
||||
const { rows, cols } = fitDimensions;
|
||||
wsControl.send(
|
||||
JSON.stringify({
|
||||
web_client_id: ownWebClientId,
|
||||
payload: {
|
||||
type: "TerminalResize",
|
||||
rows,
|
||||
cols,
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
wsControl.onmessage = function (event) {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "SetConfig") {
|
||||
const {
|
||||
font,
|
||||
theme,
|
||||
cursor_blink,
|
||||
mac_option_is_meta,
|
||||
cursor_style,
|
||||
cursor_inactive_style
|
||||
} = msg;
|
||||
term.options.fontFamily = font;
|
||||
term.options.theme = theme;
|
||||
if (cursor_blink !== 'undefined') {
|
||||
term.options.cursorBlink = cursor_blink;
|
||||
}
|
||||
if (mac_option_is_meta !== 'undefined') {
|
||||
term.options.macOptionIsMeta = mac_option_is_meta;
|
||||
}
|
||||
if (cursor_style !== 'undefined') {
|
||||
term.options.cursorStyle = cursor_style;
|
||||
}
|
||||
if (cursor_inactive_style !== 'undefined') {
|
||||
term.options.cursorInactiveStyle = cursor_inactive_style;
|
||||
}
|
||||
const body = document.querySelector("body");
|
||||
body.style.background = theme.background;
|
||||
|
||||
const terminal = document.getElementById("terminal");
|
||||
terminal.style.background = theme.background;
|
||||
|
||||
const fitDimensions = fitAddon.proposeDimensions();
|
||||
if (fitDimensions === undefined) {
|
||||
console.warn("failed to get new fit dimensions");
|
||||
return;
|
||||
}
|
||||
|
||||
const { rows, cols } = fitDimensions;
|
||||
if (rows === term.rows && cols === term.cols) {
|
||||
return;
|
||||
}
|
||||
term.resize(cols, rows);
|
||||
|
||||
wsControl.send(
|
||||
JSON.stringify({
|
||||
web_client_id: ownWebClientId,
|
||||
payload: {
|
||||
type: "TerminalResize",
|
||||
rows,
|
||||
cols,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else if (msg.type === "QueryTerminalSize") {
|
||||
const fitDimensions = fitAddon.proposeDimensions();
|
||||
const { rows, cols } = fitDimensions;
|
||||
if (rows !== term.rows || cols !== term.cols) {
|
||||
term.resize(cols, rows);
|
||||
}
|
||||
wsControl.send(
|
||||
JSON.stringify({
|
||||
web_client_id: ownWebClientId,
|
||||
payload: {
|
||||
type: "TerminalResize",
|
||||
rows,
|
||||
cols,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else if (msg.type === "Log") {
|
||||
const { lines } = msg;
|
||||
for (const line in lines) {
|
||||
console.log(line);
|
||||
}
|
||||
} else if (msg.type === "LogError") {
|
||||
const { lines } = msg;
|
||||
for (const line in lines) {
|
||||
console.error(line);
|
||||
}
|
||||
} else if (msg.type === "SwitchedSession") {
|
||||
const { new_session_name } = msg;
|
||||
window.location.pathname = `/${new_session_name}`;
|
||||
}
|
||||
};
|
||||
|
||||
wsControl.onclose = function () {
|
||||
handleReconnection();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up window resize event handler
|
||||
* @param {Terminal} term - Terminal instance
|
||||
* @param {FitAddon} fitAddon - Terminal fit addon
|
||||
* @param {function} getWsControl - Function that returns control WebSocket
|
||||
* @param {function} getOwnWebClientId - Function that returns own web client ID
|
||||
*/
|
||||
export function setupResizeHandler(term, fitAddon, getWsControl, getOwnWebClientId) {
|
||||
addEventListener("resize", (event) => {
|
||||
const ownWebClientId = getOwnWebClientId();
|
||||
if (ownWebClientId === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
const fitDimensions = fitAddon.proposeDimensions();
|
||||
if (fitDimensions === undefined) {
|
||||
console.warn("failed to get new fit dimensions");
|
||||
return;
|
||||
}
|
||||
|
||||
const { rows, cols } = fitDimensions;
|
||||
if (rows === term.rows && cols === term.cols) {
|
||||
return;
|
||||
}
|
||||
|
||||
term.resize(cols, rows);
|
||||
|
||||
const wsControl = getWsControl();
|
||||
if (wsControl) {
|
||||
wsControl.send(
|
||||
JSON.stringify({
|
||||
web_client_id: ownWebClientId,
|
||||
payload: {
|
||||
type: "TerminalResize",
|
||||
rows,
|
||||
cols,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
223
zellij-client/assets/xterm.css
Normal file
223
zellij-client/assets/xterm.css
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
/**
|
||||
* Taken from @xterm/xterm v5.5.0
|
||||
*
|
||||
* The following license refers to this file and the functions
|
||||
* within it only
|
||||
*
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility:not(.debug),
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree {
|
||||
user-select: text;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||
* explicitly in the generated class and reset to 1 here */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xterm-underline-1 { text-decoration: underline; }
|
||||
.xterm-underline-2 { text-decoration: double underline; }
|
||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||
|
||||
.xterm-overline {
|
||||
text-decoration: overline;
|
||||
}
|
||||
|
||||
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
31
zellij-client/assets/xterm.js
Normal file
31
zellij-client/assets/xterm.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -51,7 +51,7 @@ fn termwiz_mouse_convert(original_event: &mut MouseEvent, event: &TermwizMouseEv
|
|||
original_event.ctrl = mods.contains(Modifiers::CTRL);
|
||||
}
|
||||
|
||||
fn from_termwiz(old_event: &mut MouseEvent, event: TermwizMouseEvent) -> MouseEvent {
|
||||
pub fn from_termwiz(old_event: &mut MouseEvent, event: TermwizMouseEvent) -> MouseEvent {
|
||||
// We use the state of old_event vs new_event to determine if this
|
||||
// event is a Press, Release, or Motion. This is an unfortunate
|
||||
// side effect of the pre-SGR-encoded X10 mouse protocol design in
|
||||
|
|
|
|||
|
|
@ -7,18 +7,19 @@ mod keyboard_parser;
|
|||
pub mod old_config_converter;
|
||||
mod stdin_ansi_parser;
|
||||
mod stdin_handler;
|
||||
#[cfg(feature = "web_server_capability")]
|
||||
pub mod web_client;
|
||||
|
||||
use log::info;
|
||||
use std::env::current_exe;
|
||||
use std::io::{self, Write};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use zellij_utils::errors::FatalError;
|
||||
|
||||
use notify_debouncer_full::notify::{self, Event, RecursiveMode, Watcher};
|
||||
use zellij_utils::setup::Setup;
|
||||
use zellij_utils::shared::web_server_base_url;
|
||||
|
||||
use crate::stdin_ansi_parser::{AnsiStdinInstruction, StdinAnsiParser, SyncOutput};
|
||||
use crate::{
|
||||
|
|
@ -32,7 +33,10 @@ use zellij_utils::{
|
|||
data::{ClientId, ConnectToSession, KeyWithModifier, Style},
|
||||
envs,
|
||||
errors::{ClientContext, ContextType, ErrorInstruction},
|
||||
input::{config::Config, options::Options},
|
||||
input::{
|
||||
config::{watch_config_file_changes, Config},
|
||||
options::Options,
|
||||
},
|
||||
ipc::{ClientAttributes, ClientToServerMsg, ExitReason, ServerToClientMsg},
|
||||
pane_size::Size,
|
||||
};
|
||||
|
|
@ -56,6 +60,7 @@ pub(crate) enum ClientInstruction {
|
|||
CliPipeOutput((), ()), // String -> pipe name, String -> output
|
||||
QueryTerminalSize,
|
||||
WriteConfigToDisk { config: String },
|
||||
StartWebServer,
|
||||
}
|
||||
|
||||
impl From<ServerToClientMsg> for ClientInstruction {
|
||||
|
|
@ -80,6 +85,7 @@ impl From<ServerToClientMsg> for ClientInstruction {
|
|||
ServerToClientMsg::WriteConfigToDisk { config } => {
|
||||
ClientInstruction::WriteConfigToDisk { config }
|
||||
},
|
||||
ServerToClientMsg::StartWebServer => ClientInstruction::StartWebServer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -102,6 +108,7 @@ impl From<&ClientInstruction> for ClientContext {
|
|||
ClientInstruction::CliPipeOutput(..) => ClientContext::CliPipeOutput,
|
||||
ClientInstruction::QueryTerminalSize => ClientContext::QueryTerminalSize,
|
||||
ClientInstruction::WriteConfigToDisk { .. } => ClientContext::WriteConfigToDisk,
|
||||
ClientInstruction::StartWebServer => ClientContext::StartWebServer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -112,7 +119,46 @@ impl ErrorInstruction for ClientInstruction {
|
|||
}
|
||||
}
|
||||
|
||||
fn spawn_server(socket_path: &Path, debug: bool) -> io::Result<()> {
|
||||
#[cfg(feature = "web_server_capability")]
|
||||
fn spawn_web_server(opts: &CliArgs) -> Result<String, String> {
|
||||
let mut cmd = Command::new(current_exe().map_err(|e| e.to_string())?);
|
||||
if let Some(config_file_path) = Config::config_file_path(opts) {
|
||||
let config_file_path_exists = Path::new(&config_file_path).exists();
|
||||
if !config_file_path_exists {
|
||||
return Err(format!(
|
||||
"Config file: {} does not exist",
|
||||
config_file_path.display()
|
||||
));
|
||||
}
|
||||
// this is so that if Zellij itself was started with a different config file, we'll use it
|
||||
// to start the webserver
|
||||
cmd.arg("--config");
|
||||
cmd.arg(format!("{}", config_file_path.display()));
|
||||
}
|
||||
cmd.arg("web");
|
||||
cmd.arg("-d");
|
||||
let output = cmd.output();
|
||||
match output {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
||||
}
|
||||
},
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "web_server_capability"))]
|
||||
fn spawn_web_server(_opts: &CliArgs) -> Result<String, String> {
|
||||
log::error!(
|
||||
"This version of Zellij was compiled without web server support, cannot run web server!"
|
||||
);
|
||||
Ok("".to_owned())
|
||||
}
|
||||
|
||||
pub fn spawn_server(socket_path: &Path, debug: bool) -> io::Result<()> {
|
||||
let mut cmd = Command::new(current_exe()?);
|
||||
cmd.arg("--server");
|
||||
cmd.arg(socket_path);
|
||||
|
|
@ -182,6 +228,7 @@ pub fn start_client(
|
|||
.support_kitty_keyboard_protocol
|
||||
.map(|e| !e)
|
||||
.unwrap_or(false);
|
||||
let should_start_web_server = config_options.web_server.map(|w| w).unwrap_or(false);
|
||||
let mut reconnect_to_session = None;
|
||||
let clear_client_terminal_attributes = "\u{1b}[?1l\u{1b}=\u{1b}[r\u{1b}[?1000l\u{1b}[?1002l\u{1b}[?1003l\u{1b}[?1005l\u{1b}[?1006l\u{1b}[?12l";
|
||||
let take_snapshot = "\u{1b}[?1049h";
|
||||
|
|
@ -224,6 +271,13 @@ pub fn start_client(
|
|||
hide_session_name: config.ui.pane_frames.hide_session_name,
|
||||
},
|
||||
};
|
||||
let web_server_ip = config_options
|
||||
.web_server_ip
|
||||
.unwrap_or_else(|| IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
|
||||
let web_server_port = config_options.web_server_port.unwrap_or_else(|| 8082);
|
||||
let has_certificate =
|
||||
config_options.web_server_cert.is_some() && config_options.web_server_key.is_some();
|
||||
let enforce_https_for_localhost = config_options.enforce_https_for_localhost.unwrap_or(false);
|
||||
|
||||
let create_ipc_pipe = || -> std::path::PathBuf {
|
||||
let mut sock_dir = ZELLIJ_SOCK_DIR.clone();
|
||||
|
|
@ -238,6 +292,7 @@ pub fn start_client(
|
|||
envs::set_session_name(name.clone());
|
||||
os_input.update_session_name(name);
|
||||
let ipc_pipe = create_ipc_pipe();
|
||||
let is_web_client = false;
|
||||
|
||||
(
|
||||
ClientToServerMsg::AttachClient(
|
||||
|
|
@ -246,6 +301,7 @@ pub fn start_client(
|
|||
config_options.clone(),
|
||||
tab_position_to_focus,
|
||||
pane_id_to_focus,
|
||||
is_web_client,
|
||||
),
|
||||
ipc_pipe,
|
||||
)
|
||||
|
|
@ -256,6 +312,12 @@ pub fn start_client(
|
|||
let ipc_pipe = create_ipc_pipe();
|
||||
|
||||
spawn_server(&*ipc_pipe, opts.debug).unwrap();
|
||||
if should_start_web_server {
|
||||
if let Err(e) = spawn_web_server(&opts) {
|
||||
log::error!("Failed to start web server: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let successfully_written_config =
|
||||
Config::write_config_to_disk_if_it_does_not_exist(config.to_string(true), &opts);
|
||||
// if we successfully wrote the config to disk, it means two things:
|
||||
|
|
@ -265,6 +327,7 @@ pub fn start_client(
|
|||
// If these two are true, we should launch the setup wizard, if even one of them is
|
||||
// false, we should never launch it.
|
||||
let should_launch_setup_wizard = successfully_written_config;
|
||||
let is_web_client = false;
|
||||
|
||||
(
|
||||
ClientToServerMsg::NewClient(
|
||||
|
|
@ -274,6 +337,7 @@ pub fn start_client(
|
|||
Box::new(config_options.clone()),
|
||||
Box::new(layout.unwrap()),
|
||||
Box::new(config.plugins.clone()),
|
||||
is_web_client,
|
||||
should_launch_setup_wizard,
|
||||
),
|
||||
ipc_pipe,
|
||||
|
|
@ -358,11 +422,7 @@ pub fn start_client(
|
|||
let os_input = os_input.clone();
|
||||
let opts = opts.clone();
|
||||
move || {
|
||||
// we keep the config_file_watcher here so that it is only dropped when this thread
|
||||
// exits (which is when the client disconnects/detaches), once it's dropped it
|
||||
// stops watching and we want it to keep watching the config file path for changes
|
||||
// as long as the client is alive
|
||||
let _config_file_watcher = report_changes_in_config_file(&opts, &os_input);
|
||||
report_changes_in_config_file(&opts, &os_input);
|
||||
os_input.handle_signals(
|
||||
Box::new({
|
||||
let os_api = os_input.clone();
|
||||
|
|
@ -561,6 +621,26 @@ pub fn start_client(
|
|||
},
|
||||
}
|
||||
},
|
||||
ClientInstruction::StartWebServer => {
|
||||
let web_server_base_url = web_server_base_url(
|
||||
web_server_ip,
|
||||
web_server_port,
|
||||
has_certificate,
|
||||
enforce_https_for_localhost,
|
||||
);
|
||||
match spawn_web_server(&opts) {
|
||||
Ok(_) => {
|
||||
let _ = os_input.send_to_server(ClientToServerMsg::WebServerStarted(
|
||||
web_server_base_url,
|
||||
));
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("Failed to start web_server: {}", e);
|
||||
let _ =
|
||||
os_input.send_to_server(ClientToServerMsg::FailedToStartWebServer(e));
|
||||
},
|
||||
}
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
|
|
@ -611,6 +691,8 @@ pub fn start_server_detached(
|
|||
envs::set_zellij("0".to_string());
|
||||
config.env.set_vars();
|
||||
|
||||
let should_start_web_server = config_options.web_server.map(|w| w).unwrap_or(false);
|
||||
|
||||
let palette = config
|
||||
.theme_config(config_options.theme.as_ref())
|
||||
.unwrap_or_else(|| os_input.load_palette().into());
|
||||
|
|
@ -640,8 +722,14 @@ pub fn start_server_detached(
|
|||
let ipc_pipe = create_ipc_pipe();
|
||||
|
||||
spawn_server(&*ipc_pipe, opts.debug).unwrap();
|
||||
if should_start_web_server {
|
||||
if let Err(e) = spawn_web_server(&opts) {
|
||||
log::error!("Failed to start web server: {}", e);
|
||||
}
|
||||
}
|
||||
let should_launch_setup_wizard = false; // no setup wizard when starting a detached
|
||||
// server
|
||||
let is_web_client = false;
|
||||
|
||||
(
|
||||
ClientToServerMsg::NewClient(
|
||||
|
|
@ -651,6 +739,7 @@ pub fn start_server_detached(
|
|||
Box::new(config_options.clone()),
|
||||
Box::new(layout.unwrap()),
|
||||
Box::new(config.plugins.clone()),
|
||||
is_web_client,
|
||||
should_launch_setup_wizard,
|
||||
),
|
||||
ipc_pipe,
|
||||
|
|
@ -666,59 +755,20 @@ pub fn start_server_detached(
|
|||
os_input.send_to_server(first_msg);
|
||||
}
|
||||
|
||||
fn report_changes_in_config_file(
|
||||
opts: &CliArgs,
|
||||
os_input: &Box<dyn ClientOsApi>,
|
||||
) -> Option<Box<dyn Watcher>> {
|
||||
match Config::config_file_path(&opts) {
|
||||
Some(config_file_path) => {
|
||||
let mut watcher = notify::recommended_watcher({
|
||||
pub fn report_changes_in_config_file(opts: &CliArgs, os_input: &Box<dyn ClientOsApi>) {
|
||||
if let Some(config_file_path) = Config::config_file_path(&opts) {
|
||||
let os_input = os_input.clone();
|
||||
let opts = opts.clone();
|
||||
let config_file_path = config_file_path.clone();
|
||||
move |res: Result<Event, _>| match res {
|
||||
Ok(event)
|
||||
if (event.kind.is_create() || event.kind.is_modify())
|
||||
&& event.paths.contains(&config_file_path) =>
|
||||
{
|
||||
match Setup::from_cli_args(&opts) {
|
||||
Ok((
|
||||
new_config,
|
||||
_layout,
|
||||
_config_options,
|
||||
_config_without_layout,
|
||||
_config_options_without_layout,
|
||||
)) => {
|
||||
os_input.send_to_server(ClientToServerMsg::ConfigWrittenToDisk(
|
||||
new_config,
|
||||
));
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("Failed to reload config: {}", e);
|
||||
},
|
||||
}
|
||||
},
|
||||
Err(e) => log::error!("watch error: {:?}", e),
|
||||
_ => {},
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async move {
|
||||
watch_config_file_changes(config_file_path, move |new_config| {
|
||||
let os_input = os_input.clone();
|
||||
async move {
|
||||
os_input.send_to_server(ClientToServerMsg::ConfigWrittenToDisk(new_config));
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
if let Some(config_file_parent_folder) = config_file_path.parent() {
|
||||
match watcher.watch(&config_file_parent_folder, RecursiveMode::Recursive) {
|
||||
Ok(_) => Some(Box::new(watcher)),
|
||||
Err(e) => {
|
||||
log::error!("Failed to watch config file folder: {}", e);
|
||||
None
|
||||
},
|
||||
}
|
||||
} else {
|
||||
log::error!("Could not find config parent folder");
|
||||
None
|
||||
}
|
||||
},
|
||||
None => {
|
||||
log::error!("Failed to find config path");
|
||||
None
|
||||
},
|
||||
.await;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,9 +88,15 @@ pub struct ClientOsInputOutput {
|
|||
session_name: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ClientOsInputOutput {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ClientOsInputOutput").finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ClientOsApi` trait represents an abstract interface to the features of an operating system that
|
||||
/// Zellij client requires.
|
||||
pub trait ClientOsApi: Send + Sync {
|
||||
pub trait ClientOsApi: Send + Sync + std::fmt::Debug {
|
||||
/// Returns the size of the terminal associated to file descriptor `fd`.
|
||||
fn get_terminal_size_using_fd(&self, fd: RawFd) -> Size;
|
||||
/// Set the terminal associated to file descriptor `fd` to
|
||||
|
|
@ -216,14 +222,14 @@ impl ClientOsApi for ClientOsInputOutput {
|
|||
}
|
||||
|
||||
fn send_to_server(&self, msg: ClientToServerMsg) {
|
||||
// TODO: handle the error here, right now we silently ignore it
|
||||
let _ = self
|
||||
.send_instructions_to_server
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.send(msg);
|
||||
match self.send_instructions_to_server.lock().unwrap().as_mut() {
|
||||
Some(sender) => {
|
||||
let _ = sender.send(msg);
|
||||
},
|
||||
None => {
|
||||
log::warn!("Server not ready, dropping message.");
|
||||
},
|
||||
}
|
||||
}
|
||||
fn recv_from_server(&self) -> Option<(ServerToClientMsg, ErrorContext)> {
|
||||
self.receive_instructions_from_server
|
||||
|
|
|
|||
43
zellij-client/src/web_client/authentication.rs
Normal file
43
zellij-client/src/web_client/authentication.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
use crate::web_client::utils::parse_cookies;
|
||||
use axum::body::Body;
|
||||
use axum::http::header::SET_COOKIE;
|
||||
use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response};
|
||||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||
use zellij_utils::web_authentication_tokens::validate_session_token;
|
||||
|
||||
pub async fn auth_middleware(request: Request, next: Next) -> Result<Response, StatusCode> {
|
||||
let cookies = parse_cookies(&request);
|
||||
|
||||
let session_token = match cookies.get("session_token") {
|
||||
Some(token) => token.clone(),
|
||||
None => return Err(StatusCode::UNAUTHORIZED),
|
||||
};
|
||||
|
||||
match validate_session_token(&session_token) {
|
||||
Ok(true) => {
|
||||
let response = next.run(request).await;
|
||||
Ok(response)
|
||||
},
|
||||
Ok(false) | Err(_) => {
|
||||
// revoke session_token as if it exists it's no longer valid
|
||||
let clear_cookie = Cookie::build(("session_token", ""))
|
||||
.http_only(true)
|
||||
.secure(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.path("/")
|
||||
.max_age(time::Duration::seconds(0))
|
||||
.build();
|
||||
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(SET_COOKIE, clear_cookie.to_string().parse().unwrap());
|
||||
|
||||
Ok(response)
|
||||
},
|
||||
}
|
||||
}
|
||||
146
zellij-client/src/web_client/connection_manager.rs
Normal file
146
zellij-client/src/web_client/connection_manager.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
use crate::os_input_output::ClientOsApi;
|
||||
use crate::web_client::control_message::WebServerToWebClientControlMessage;
|
||||
use crate::web_client::types::{ClientChannels, ClientConnectionBus, ConnectionTable};
|
||||
use axum::extract::ws::{CloseFrame, Message};
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
impl ConnectionTable {
|
||||
pub fn add_new_client(&mut self, client_id: String, client_os_api: Box<dyn ClientOsApi>) {
|
||||
self.client_id_to_channels
|
||||
.insert(client_id, ClientChannels::new(client_os_api));
|
||||
}
|
||||
|
||||
pub fn add_client_control_tx(
|
||||
&mut self,
|
||||
client_id: &str,
|
||||
control_channel_tx: UnboundedSender<Message>,
|
||||
) {
|
||||
self.client_id_to_channels
|
||||
.get_mut(client_id)
|
||||
.map(|c| c.add_control_tx(control_channel_tx));
|
||||
}
|
||||
|
||||
pub fn add_client_terminal_tx(
|
||||
&mut self,
|
||||
client_id: &str,
|
||||
terminal_channel_tx: UnboundedSender<String>,
|
||||
) {
|
||||
self.client_id_to_channels
|
||||
.get_mut(client_id)
|
||||
.map(|c| c.add_terminal_tx(terminal_channel_tx));
|
||||
}
|
||||
|
||||
pub fn add_client_terminal_channel_cancellation_token(
|
||||
&mut self,
|
||||
client_id: &str,
|
||||
terminal_channel_cancellation_token: CancellationToken,
|
||||
) {
|
||||
self.client_id_to_channels.get_mut(client_id).map(|c| {
|
||||
c.add_terminal_channel_cancellation_token(terminal_channel_cancellation_token)
|
||||
});
|
||||
}
|
||||
|
||||
pub fn get_client_os_api(&self, client_id: &str) -> Option<&Box<dyn ClientOsApi>> {
|
||||
self.client_id_to_channels.get(client_id).map(|c| &c.os_api)
|
||||
}
|
||||
|
||||
pub fn get_client_terminal_tx(&self, client_id: &str) -> Option<UnboundedSender<String>> {
|
||||
self.client_id_to_channels
|
||||
.get(client_id)
|
||||
.and_then(|c| c.terminal_channel_tx.clone())
|
||||
}
|
||||
|
||||
pub fn get_client_control_tx(&self, client_id: &str) -> Option<UnboundedSender<Message>> {
|
||||
self.client_id_to_channels
|
||||
.get(client_id)
|
||||
.and_then(|c| c.control_channel_tx.clone())
|
||||
}
|
||||
|
||||
pub fn remove_client(&mut self, client_id: &str) {
|
||||
if let Some(mut client_channels) = self.client_id_to_channels.remove(client_id).take() {
|
||||
client_channels.cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientConnectionBus {
|
||||
pub fn send_stdout(&mut self, stdout: String) {
|
||||
match self.stdout_channel_tx.as_ref() {
|
||||
Some(stdout_channel_tx) => {
|
||||
let _ = stdout_channel_tx.send(stdout);
|
||||
},
|
||||
None => {
|
||||
self.get_stdout_channel_tx();
|
||||
if let Some(stdout_channel_tx) = self.stdout_channel_tx.as_ref() {
|
||||
let _ = stdout_channel_tx.send(stdout);
|
||||
} else {
|
||||
log::error!("Failed to send STDOUT message to client");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_control(&mut self, message: WebServerToWebClientControlMessage) {
|
||||
let message = Message::Text(serde_json::to_string(&message).unwrap().into());
|
||||
match self.control_channel_tx.as_ref() {
|
||||
Some(control_channel_tx) => {
|
||||
let _ = control_channel_tx.send(message);
|
||||
},
|
||||
None => {
|
||||
self.get_control_channel_tx();
|
||||
if let Some(control_channel_tx) = self.control_channel_tx.as_ref() {
|
||||
let _ = control_channel_tx.send(message);
|
||||
} else {
|
||||
log::error!("Failed to send control message to client");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
pub fn close_connection(&mut self) {
|
||||
let close_frame = CloseFrame {
|
||||
code: axum::extract::ws::close_code::NORMAL,
|
||||
reason: "Connection closed".into(),
|
||||
};
|
||||
let close_message = Message::Close(Some(close_frame));
|
||||
match self.control_channel_tx.as_ref() {
|
||||
Some(control_channel_tx) => {
|
||||
let _ = control_channel_tx.send(close_message);
|
||||
},
|
||||
None => {
|
||||
self.get_control_channel_tx();
|
||||
if let Some(control_channel_tx) = self.control_channel_tx.as_ref() {
|
||||
let _ = control_channel_tx.send(close_message);
|
||||
} else {
|
||||
log::error!("Failed to send close message to client");
|
||||
}
|
||||
},
|
||||
}
|
||||
self.connection_table
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove_client(&self.web_client_id);
|
||||
}
|
||||
|
||||
fn get_control_channel_tx(&mut self) {
|
||||
if let Some(control_channel_tx) = self
|
||||
.connection_table
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_client_control_tx(&self.web_client_id)
|
||||
{
|
||||
self.control_channel_tx = Some(control_channel_tx);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_stdout_channel_tx(&mut self) {
|
||||
if let Some(stdout_channel_tx) = self
|
||||
.connection_table
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_client_terminal_tx(&self.web_client_id)
|
||||
{
|
||||
self.stdout_channel_tx = Some(stdout_channel_tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
162
zellij-client/src/web_client/control_message.rs
Normal file
162
zellij-client/src/web_client/control_message.rs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use zellij_utils::{input::config::Config, pane_size::Size};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
|
||||
pub(super) struct WebClientToWebServerControlMessage {
|
||||
pub web_client_id: String,
|
||||
pub payload: WebClientToWebServerControlMessagePayload,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[serde(tag = "type")]
|
||||
pub(super) enum WebClientToWebServerControlMessagePayload {
|
||||
TerminalResize(Size),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[serde(tag = "type")]
|
||||
pub(super) enum WebServerToWebClientControlMessage {
|
||||
SetConfig(SetConfigPayload),
|
||||
QueryTerminalSize,
|
||||
Log { lines: Vec<String> },
|
||||
LogError { lines: Vec<String> },
|
||||
SwitchedSession { new_session_name: String },
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub(super) struct SetConfigPayload {
|
||||
pub font: String,
|
||||
pub theme: SetConfigPayloadTheme,
|
||||
pub cursor_blink: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cursor_inactive_style: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cursor_style: Option<String>,
|
||||
pub mac_option_is_meta: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct SetConfigPayloadTheme {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub background: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub foreground: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub black: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blue: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bright_black: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bright_blue: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bright_cyan: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bright_green: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bright_magenta: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bright_red: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bright_white: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bright_yellow: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cursor: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cursor_accent: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cyan: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub green: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub magenta: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub red: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub selection_background: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub selection_foreground: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub selection_inactive_background: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub white: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub yellow: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&Config> for SetConfigPayload {
|
||||
fn from(config: &Config) -> Self {
|
||||
let font = config.web_client.font.clone();
|
||||
|
||||
let palette = config.theme_config(None);
|
||||
let web_client_theme_from_config = config.web_client.theme.as_ref();
|
||||
|
||||
let mut theme = SetConfigPayloadTheme::default();
|
||||
|
||||
theme.background = web_client_theme_from_config
|
||||
.and_then(|theme| theme.background.clone())
|
||||
.or_else(|| palette.map(|p| p.text_unselected.background.as_rgb_str()));
|
||||
theme.foreground = web_client_theme_from_config
|
||||
.and_then(|theme| theme.foreground.clone())
|
||||
.or_else(|| palette.map(|p| p.text_unselected.base.as_rgb_str()));
|
||||
theme.black = web_client_theme_from_config.and_then(|theme| theme.black.clone());
|
||||
theme.blue = web_client_theme_from_config.and_then(|theme| theme.blue.clone());
|
||||
theme.bright_black =
|
||||
web_client_theme_from_config.and_then(|theme| theme.bright_black.clone());
|
||||
theme.bright_blue =
|
||||
web_client_theme_from_config.and_then(|theme| theme.bright_blue.clone());
|
||||
theme.bright_cyan =
|
||||
web_client_theme_from_config.and_then(|theme| theme.bright_cyan.clone());
|
||||
theme.bright_green =
|
||||
web_client_theme_from_config.and_then(|theme| theme.bright_green.clone());
|
||||
theme.bright_magenta =
|
||||
web_client_theme_from_config.and_then(|theme| theme.bright_magenta.clone());
|
||||
theme.bright_red = web_client_theme_from_config.and_then(|theme| theme.bright_red.clone());
|
||||
theme.bright_white =
|
||||
web_client_theme_from_config.and_then(|theme| theme.bright_white.clone());
|
||||
theme.bright_yellow =
|
||||
web_client_theme_from_config.and_then(|theme| theme.bright_yellow.clone());
|
||||
theme.cursor = web_client_theme_from_config.and_then(|theme| theme.cursor.clone());
|
||||
theme.cursor_accent =
|
||||
web_client_theme_from_config.and_then(|theme| theme.cursor_accent.clone());
|
||||
theme.cyan = web_client_theme_from_config.and_then(|theme| theme.cyan.clone());
|
||||
theme.green = web_client_theme_from_config.and_then(|theme| theme.green.clone());
|
||||
theme.magenta = web_client_theme_from_config.and_then(|theme| theme.magenta.clone());
|
||||
theme.red = web_client_theme_from_config.and_then(|theme| theme.red.clone());
|
||||
theme.selection_background = web_client_theme_from_config
|
||||
.and_then(|theme| theme.selection_background.clone())
|
||||
.or_else(|| palette.map(|p| p.text_selected.background.as_rgb_str()));
|
||||
theme.selection_foreground = web_client_theme_from_config
|
||||
.and_then(|theme| theme.selection_foreground.clone())
|
||||
.or_else(|| palette.map(|p| p.text_selected.base.as_rgb_str()));
|
||||
theme.selection_inactive_background = web_client_theme_from_config
|
||||
.and_then(|theme| theme.selection_inactive_background.clone());
|
||||
theme.white = web_client_theme_from_config.and_then(|theme| theme.white.clone());
|
||||
theme.yellow = web_client_theme_from_config.and_then(|theme| theme.yellow.clone());
|
||||
|
||||
let cursor_blink = config.web_client.cursor_blink;
|
||||
let mac_option_is_meta = config.web_client.mac_option_is_meta;
|
||||
let cursor_style = config
|
||||
.web_client
|
||||
.cursor_style
|
||||
.as_ref()
|
||||
.map(|s| s.to_string());
|
||||
let cursor_inactive_style = config
|
||||
.web_client
|
||||
.cursor_inactive_style
|
||||
.as_ref()
|
||||
.map(|s| s.to_string());
|
||||
|
||||
SetConfigPayload {
|
||||
font,
|
||||
theme,
|
||||
cursor_blink,
|
||||
mac_option_is_meta,
|
||||
cursor_style,
|
||||
cursor_inactive_style,
|
||||
}
|
||||
}
|
||||
}
|
||||
114
zellij-client/src/web_client/http_handlers.rs
Normal file
114
zellij-client/src/web_client/http_handlers.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
use crate::web_client::types::{AppState, CreateClientIdResponse, LoginRequest, LoginResponse};
|
||||
use crate::web_client::utils::{get_mime_type, parse_cookies};
|
||||
use axum::{
|
||||
extract::{Path as AxumPath, Request, State},
|
||||
http::{header, StatusCode},
|
||||
response::{Html, IntoResponse},
|
||||
Json,
|
||||
};
|
||||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||
use include_dir;
|
||||
use uuid::Uuid;
|
||||
use zellij_utils::{consts::VERSION, web_authentication_tokens::create_session_token};
|
||||
|
||||
const WEB_CLIENT_PAGE: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/",
|
||||
"assets/index.html"
|
||||
));
|
||||
|
||||
const ASSETS_DIR: include_dir::Dir<'_> = include_dir::include_dir!("$CARGO_MANIFEST_DIR/assets");
|
||||
|
||||
pub async fn serve_html(request: Request) -> Html<String> {
|
||||
let cookies = parse_cookies(&request);
|
||||
let is_authenticated = cookies.get("session_token").is_some();
|
||||
let auth_value = if is_authenticated { "true" } else { "false" };
|
||||
let html = Html(WEB_CLIENT_PAGE.replace("IS_AUTHENTICATED", &format!("{}", auth_value)));
|
||||
html
|
||||
}
|
||||
|
||||
pub async fn login_handler(Json(login_request): Json<LoginRequest>) -> impl IntoResponse {
|
||||
match create_session_token(
|
||||
&login_request.auth_token,
|
||||
login_request.remember_me.unwrap_or(false),
|
||||
) {
|
||||
Ok(session_token) => {
|
||||
let cookie = if login_request.remember_me.unwrap_or(false) {
|
||||
// Persistent cookie for remember_me
|
||||
Cookie::build(("session_token", session_token))
|
||||
.http_only(true)
|
||||
.secure(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.path("/")
|
||||
.max_age(time::Duration::weeks(4))
|
||||
.build()
|
||||
} else {
|
||||
// Session cookie - NO max_age means it expires when browser closes/refreshes
|
||||
Cookie::build(("session_token", session_token))
|
||||
.http_only(true)
|
||||
.secure(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.path("/")
|
||||
.build()
|
||||
};
|
||||
|
||||
let mut response = Json(LoginResponse {
|
||||
success: true,
|
||||
message: "Login successful".to_string(),
|
||||
})
|
||||
.into_response();
|
||||
|
||||
if let Ok(cookie_header) = axum::http::HeaderValue::from_str(&cookie.to_string()) {
|
||||
response.headers_mut().insert("set-cookie", cookie_header);
|
||||
}
|
||||
|
||||
response
|
||||
},
|
||||
Err(_) => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(LoginResponse {
|
||||
success: false,
|
||||
message: "Invalid authentication token".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_new_client(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<CreateClientIdResponse>, (StatusCode, impl IntoResponse)> {
|
||||
let web_client_id = String::from(Uuid::new_v4());
|
||||
let os_input = state
|
||||
.client_os_api_factory
|
||||
.create_client_os_api()
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(e.to_string())))?;
|
||||
|
||||
state
|
||||
.connection_table
|
||||
.lock()
|
||||
.unwrap()
|
||||
.add_new_client(web_client_id.to_owned(), os_input);
|
||||
|
||||
Ok(Json(CreateClientIdResponse { web_client_id }))
|
||||
}
|
||||
|
||||
pub async fn get_static_asset(AxumPath(path): AxumPath<String>) -> impl IntoResponse {
|
||||
let path = path.trim_start_matches('/');
|
||||
|
||||
match ASSETS_DIR.get_file(path) {
|
||||
None => (
|
||||
[(header::CONTENT_TYPE, "text/html")],
|
||||
"Not Found".as_bytes(),
|
||||
),
|
||||
Some(file) => {
|
||||
let ext = file.path().extension().and_then(|ext| ext.to_str());
|
||||
let mime_type = get_mime_type(ext);
|
||||
([(header::CONTENT_TYPE, mime_type)], file.contents())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn version_handler() -> &'static str {
|
||||
VERSION
|
||||
}
|
||||
106
zellij-client/src/web_client/ipc_listener.rs
Normal file
106
zellij-client/src/web_client/ipc_listener.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
use super::control_message::{SetConfigPayload, WebServerToWebClientControlMessage};
|
||||
use super::types::ConnectionTable;
|
||||
use axum_server::Handle;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
use zellij_utils::consts::WEBSERVER_SOCKET_PATH;
|
||||
use zellij_utils::web_server_commands::InstructionForWebServer;
|
||||
|
||||
pub async fn create_webserver_receiver(
|
||||
id: &str,
|
||||
) -> Result<UnixStream, Box<dyn std::error::Error + Send + Sync>> {
|
||||
std::fs::create_dir_all(&WEBSERVER_SOCKET_PATH.as_path())?;
|
||||
let socket_path = WEBSERVER_SOCKET_PATH.join(format!("{}", id));
|
||||
|
||||
if socket_path.exists() {
|
||||
tokio::fs::remove_file(&socket_path).await?;
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(&socket_path)?;
|
||||
let (stream, _) = listener.accept().await?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
pub async fn receive_webserver_instruction(
|
||||
receiver: &mut UnixStream,
|
||||
) -> std::io::Result<InstructionForWebServer> {
|
||||
let mut buffer = Vec::new();
|
||||
receiver.read_to_end(&mut buffer).await?;
|
||||
let cursor = std::io::Cursor::new(buffer);
|
||||
rmp_serde::decode::from_read(cursor)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
|
||||
}
|
||||
|
||||
pub async fn listen_to_web_server_instructions(
|
||||
server_handle: Handle,
|
||||
connection_table: Arc<Mutex<ConnectionTable>>,
|
||||
id: &str,
|
||||
) {
|
||||
loop {
|
||||
let receiver = create_webserver_receiver(id).await;
|
||||
match receiver {
|
||||
Ok(mut receiver) => {
|
||||
match receive_webserver_instruction(&mut receiver).await {
|
||||
Ok(instruction) => match instruction {
|
||||
InstructionForWebServer::ShutdownWebServer => {
|
||||
server_handle.shutdown();
|
||||
break;
|
||||
},
|
||||
InstructionForWebServer::ConfigWrittenToDisk(new_config) => {
|
||||
let set_config_payload = SetConfigPayload::from(&new_config);
|
||||
|
||||
let client_ids: Vec<String> = {
|
||||
let connection_table_lock = connection_table.lock().unwrap();
|
||||
connection_table_lock
|
||||
.client_id_to_channels
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
|
||||
let config_message =
|
||||
WebServerToWebClientControlMessage::SetConfig(set_config_payload);
|
||||
let config_msg_json = match serde_json::to_string(&config_message) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
log::error!("Failed to serialize config message: {}", e);
|
||||
continue;
|
||||
},
|
||||
};
|
||||
|
||||
for client_id in client_ids {
|
||||
if let Some(control_tx) = connection_table
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_client_control_tx(&client_id)
|
||||
{
|
||||
let ws_message = config_msg_json.clone();
|
||||
match control_tx.send(ws_message.into()) {
|
||||
Ok(_) => {}, // no-op
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Failed to send config update to client {}: {}",
|
||||
client_id,
|
||||
e
|
||||
);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
// Continue loop to recreate receiver for next message
|
||||
},
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("Failed to process web server instruction: {}", e);
|
||||
// Continue loop to recreate receiver and try again
|
||||
},
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("Failed to listen to ipc channel: {}", e);
|
||||
break;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
137
zellij-client/src/web_client/message_handlers.rs
Normal file
137
zellij-client/src/web_client/message_handlers.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
use crate::input_handler::from_termwiz;
|
||||
use crate::keyboard_parser::KittyKeyboardParser;
|
||||
use crate::os_input_output::ClientOsApi;
|
||||
use crate::web_client::types::BRACKETED_PASTE_END;
|
||||
use crate::web_client::types::BRACKETED_PASTE_START;
|
||||
|
||||
use zellij_utils::{
|
||||
input::{actions::Action, cast_termwiz_key, mouse::MouseEvent},
|
||||
ipc::ClientToServerMsg,
|
||||
};
|
||||
|
||||
use axum::extract::ws::{CloseFrame, Message, WebSocket};
|
||||
use futures::{prelude::stream::SplitSink, SinkExt};
|
||||
use termwiz::input::{InputEvent, InputParser};
|
||||
use tokio::sync::mpsc::UnboundedReceiver;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub fn render_to_client(
|
||||
mut stdout_channel_rx: UnboundedReceiver<String>,
|
||||
mut client_channel_tx: SplitSink<WebSocket, Message>,
|
||||
cancellation_token: CancellationToken,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = stdout_channel_rx.recv() => {
|
||||
match result {
|
||||
Some(rendered_bytes) => {
|
||||
if client_channel_tx
|
||||
.send(Message::Text(rendered_bytes.into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
_ = cancellation_token.cancelled() => {
|
||||
let close_frame = CloseFrame {
|
||||
code: axum::extract::ws::close_code::NORMAL,
|
||||
reason: "Connection closed".into(),
|
||||
};
|
||||
let close_message = Message::Close(Some(close_frame));
|
||||
if client_channel_tx
|
||||
.send(close_message)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn send_control_messages_to_client(
|
||||
mut control_channel_rx: UnboundedReceiver<Message>,
|
||||
mut socket_channel_tx: SplitSink<WebSocket, Message>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
while let Some(message) = control_channel_rx.recv().await {
|
||||
if socket_channel_tx.send(message).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn parse_stdin(
|
||||
buf: &[u8],
|
||||
os_input: Box<dyn ClientOsApi>,
|
||||
mouse_old_event: &mut MouseEvent,
|
||||
explicitly_disable_kitty_keyboard_protocol: bool,
|
||||
) {
|
||||
if !explicitly_disable_kitty_keyboard_protocol {
|
||||
match KittyKeyboardParser::new().parse(&buf) {
|
||||
Some(key_with_modifier) => {
|
||||
os_input.send_to_server(ClientToServerMsg::Key(
|
||||
key_with_modifier.clone(),
|
||||
buf.to_vec(),
|
||||
true,
|
||||
));
|
||||
return;
|
||||
},
|
||||
None => {},
|
||||
}
|
||||
}
|
||||
|
||||
let mut input_parser = InputParser::new();
|
||||
let maybe_more = false;
|
||||
let mut events = vec![];
|
||||
input_parser.parse(
|
||||
&buf,
|
||||
|input_event: InputEvent| {
|
||||
events.push(input_event);
|
||||
},
|
||||
maybe_more,
|
||||
);
|
||||
|
||||
for (_i, input_event) in events.into_iter().enumerate() {
|
||||
match input_event {
|
||||
InputEvent::Key(key_event) => {
|
||||
let key = cast_termwiz_key(key_event.clone(), &buf, None);
|
||||
os_input.send_to_server(ClientToServerMsg::Key(key.clone(), buf.to_vec(), false));
|
||||
},
|
||||
InputEvent::Mouse(mouse_event) => {
|
||||
let mouse_event = from_termwiz(mouse_old_event, mouse_event);
|
||||
let action = Action::MouseEvent(mouse_event);
|
||||
os_input.send_to_server(ClientToServerMsg::Action(action, None, None));
|
||||
},
|
||||
InputEvent::Paste(pasted_text) => {
|
||||
os_input.send_to_server(ClientToServerMsg::Action(
|
||||
Action::Write(None, BRACKETED_PASTE_START.to_vec(), false),
|
||||
None,
|
||||
None,
|
||||
));
|
||||
os_input.send_to_server(ClientToServerMsg::Action(
|
||||
Action::Write(None, pasted_text.as_bytes().to_vec(), false),
|
||||
None,
|
||||
None,
|
||||
));
|
||||
os_input.send_to_server(ClientToServerMsg::Action(
|
||||
Action::Write(None, BRACKETED_PASTE_END.to_vec(), false),
|
||||
None,
|
||||
None,
|
||||
));
|
||||
},
|
||||
_ => {
|
||||
log::error!("Unsupported event: {:#?}", input_event);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
374
zellij-client/src/web_client/mod.rs
Normal file
374
zellij-client/src/web_client/mod.rs
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
mod control_message;
|
||||
|
||||
mod authentication;
|
||||
mod connection_manager;
|
||||
mod http_handlers;
|
||||
mod ipc_listener;
|
||||
mod message_handlers;
|
||||
mod server_listener;
|
||||
mod session_management;
|
||||
mod types;
|
||||
mod utils;
|
||||
mod websocket_handlers;
|
||||
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
thread,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
middleware,
|
||||
routing::{any, get, post},
|
||||
Router,
|
||||
};
|
||||
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
use axum_server::Handle;
|
||||
|
||||
use daemonize::{self, Outcome};
|
||||
use nix::sys::stat::{umask, Mode};
|
||||
|
||||
use interprocess::unnamed_pipe::pipe;
|
||||
use std::io::{prelude::*, BufRead, BufReader};
|
||||
use tokio::runtime::Runtime;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use zellij_utils::input::{
|
||||
config::{watch_config_file_changes, Config},
|
||||
options::Options,
|
||||
};
|
||||
|
||||
use authentication::auth_middleware;
|
||||
use http_handlers::{
|
||||
create_new_client, get_static_asset, login_handler, serve_html, version_handler,
|
||||
};
|
||||
use ipc_listener::listen_to_web_server_instructions;
|
||||
use types::{
|
||||
AppState, ClientOsApiFactory, ConnectionTable, RealClientOsApiFactory, RealSessionManager,
|
||||
SessionManager,
|
||||
};
|
||||
use utils::should_use_https;
|
||||
use uuid::Uuid;
|
||||
use websocket_handlers::{ws_handler_control, ws_handler_terminal};
|
||||
|
||||
use zellij_utils::{
|
||||
consts::WEBSERVER_SOCKET_PATH,
|
||||
web_server_commands::{
|
||||
create_webserver_sender, send_webserver_instruction, InstructionForWebServer,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn start_web_client(
|
||||
config: Config,
|
||||
config_options: Options,
|
||||
config_file_path: Option<PathBuf>,
|
||||
run_daemonized: bool,
|
||||
) {
|
||||
std::panic::set_hook({
|
||||
Box::new(move |info| {
|
||||
let thread = thread::current();
|
||||
let thread = thread.name().unwrap_or("unnamed");
|
||||
let msg = match info.payload().downcast_ref::<&'static str>() {
|
||||
Some(s) => Some(*s),
|
||||
None => info.payload().downcast_ref::<String>().map(|s| &**s),
|
||||
}
|
||||
.unwrap_or("An unexpected error occurred!");
|
||||
log::error!(
|
||||
"Thread {} panicked: {}, location {:?}",
|
||||
thread,
|
||||
msg,
|
||||
info.location()
|
||||
);
|
||||
eprintln!("{}", msg);
|
||||
std::process::exit(2);
|
||||
})
|
||||
});
|
||||
let web_server_ip = config_options
|
||||
.web_server_ip
|
||||
.unwrap_or_else(|| IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
|
||||
let web_server_port = config_options.web_server_port.unwrap_or_else(|| 8082);
|
||||
let web_server_cert = &config.options.web_server_cert;
|
||||
let web_server_key = &config.options.web_server_key;
|
||||
let has_https_certificate = web_server_cert.is_some() && web_server_key.is_some();
|
||||
|
||||
if let Err(e) = should_use_https(
|
||||
web_server_ip,
|
||||
has_https_certificate,
|
||||
config.options.enforce_https_for_localhost.unwrap_or(false),
|
||||
) {
|
||||
eprintln!("{}", e);
|
||||
std::process::exit(2);
|
||||
};
|
||||
let (runtime, listener, tls_config) = if run_daemonized {
|
||||
daemonize_web_server(
|
||||
web_server_ip,
|
||||
web_server_port,
|
||||
web_server_cert,
|
||||
web_server_key,
|
||||
)
|
||||
} else {
|
||||
let runtime = Runtime::new().unwrap();
|
||||
let listener = runtime.block_on(async move {
|
||||
std::net::TcpListener::bind(format!("{}:{}", web_server_ip, web_server_port))
|
||||
});
|
||||
let tls_config = match (web_server_cert, web_server_key) {
|
||||
(Some(web_server_cert), Some(web_server_key)) => {
|
||||
let tls_config = runtime.block_on(async move {
|
||||
RustlsConfig::from_pem_file(
|
||||
PathBuf::from(web_server_cert),
|
||||
PathBuf::from(web_server_key),
|
||||
)
|
||||
.await
|
||||
});
|
||||
let tls_config = match tls_config {
|
||||
Ok(tls_config) => tls_config,
|
||||
Err(e) => {
|
||||
eprintln!("{}", e);
|
||||
std::process::exit(2);
|
||||
},
|
||||
};
|
||||
Some(tls_config)
|
||||
},
|
||||
(None, None) => None,
|
||||
_ => {
|
||||
eprintln!("Must specify both web_server_cert and web_server_key");
|
||||
std::process::exit(2);
|
||||
},
|
||||
};
|
||||
|
||||
match listener {
|
||||
Ok(listener) => {
|
||||
println!(
|
||||
"Web Server started on {} port {}",
|
||||
web_server_ip, web_server_port
|
||||
);
|
||||
(runtime, listener, tls_config)
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("{}", e);
|
||||
std::process::exit(2);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
runtime.block_on(serve_web_client(
|
||||
config,
|
||||
config_options,
|
||||
config_file_path,
|
||||
listener,
|
||||
tls_config,
|
||||
None,
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
async fn listen_to_config_file_changes(config_file_path: PathBuf, instance_id: &str) {
|
||||
let socket_path = WEBSERVER_SOCKET_PATH.join(instance_id);
|
||||
|
||||
watch_config_file_changes(config_file_path, move |new_config| {
|
||||
let socket_path = socket_path.clone();
|
||||
async move {
|
||||
if let Ok(mut sender) = create_webserver_sender(&socket_path.to_string_lossy()) {
|
||||
let _ = send_webserver_instruction(
|
||||
&mut sender,
|
||||
InstructionForWebServer::ConfigWrittenToDisk(new_config),
|
||||
);
|
||||
drop(sender);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn serve_web_client(
|
||||
config: Config,
|
||||
config_options: Options,
|
||||
config_file_path: Option<PathBuf>,
|
||||
listener: std::net::TcpListener,
|
||||
rustls_config: Option<RustlsConfig>,
|
||||
session_manager: Option<Arc<dyn SessionManager>>,
|
||||
client_os_api_factory: Option<Arc<dyn ClientOsApiFactory>>,
|
||||
) {
|
||||
let Some(config_file_path) = config_file_path.or_else(|| Config::default_config_file_path())
|
||||
else {
|
||||
log::error!("Failed to find default config file path");
|
||||
return;
|
||||
};
|
||||
let connection_table = Arc::new(Mutex::new(ConnectionTable::default()));
|
||||
let server_handle = Handle::new();
|
||||
let session_manager = session_manager.unwrap_or_else(|| Arc::new(RealSessionManager));
|
||||
let client_os_api_factory =
|
||||
client_os_api_factory.unwrap_or_else(|| Arc::new(RealClientOsApiFactory));
|
||||
|
||||
// we use a short version here to bypass macos socket path length limitations
|
||||
// since there likely aren't going to be more than a handful of web instances on the same
|
||||
// machine listening to the same ipc socket path, the collision risk here is extremely low
|
||||
let id: String = Uuid::new_v4()
|
||||
.simple()
|
||||
.to_string()
|
||||
.chars()
|
||||
.take(5)
|
||||
.collect();
|
||||
|
||||
#[cfg(not(test))]
|
||||
tokio::spawn({
|
||||
let config_file_path = config_file_path.clone();
|
||||
let id_string = format!("{}", id);
|
||||
async move {
|
||||
listen_to_config_file_changes(config_file_path, &id_string).await;
|
||||
}
|
||||
});
|
||||
|
||||
tokio::spawn({
|
||||
let server_handle = server_handle.clone();
|
||||
let connection_table = connection_table.clone();
|
||||
async move {
|
||||
listen_to_web_server_instructions(server_handle, connection_table, &format!("{}", id))
|
||||
.await;
|
||||
}
|
||||
});
|
||||
|
||||
let state = AppState {
|
||||
connection_table,
|
||||
config,
|
||||
config_options,
|
||||
config_file_path,
|
||||
session_manager,
|
||||
client_os_api_factory,
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/ws/control", any(ws_handler_control))
|
||||
.route("/ws/terminal", any(ws_handler_terminal))
|
||||
.route("/ws/terminal/{session}", any(ws_handler_terminal))
|
||||
.route("/session", post(create_new_client))
|
||||
.route_layer(middleware::from_fn(auth_middleware))
|
||||
.route("/", get(serve_html))
|
||||
.route("/{session}", get(serve_html))
|
||||
.route("/assets/{*path}", get(get_static_asset))
|
||||
.route("/command/login", post(login_handler))
|
||||
.route("/info/version", get(version_handler))
|
||||
.layer(CorsLayer::permissive()) // TODO: configure properly
|
||||
.with_state(state);
|
||||
|
||||
match rustls_config {
|
||||
Some(rustls_config) => {
|
||||
let _ = axum_server::from_tcp_rustls(listener, rustls_config)
|
||||
.handle(server_handle)
|
||||
.serve(app.into_make_service())
|
||||
.await;
|
||||
},
|
||||
None => {
|
||||
let _ = axum_server::from_tcp(listener)
|
||||
.handle(server_handle)
|
||||
.serve(app.into_make_service())
|
||||
.await;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn daemonize_web_server(
|
||||
web_server_ip: IpAddr,
|
||||
web_server_port: u16,
|
||||
web_server_cert: &Option<PathBuf>,
|
||||
web_server_key: &Option<PathBuf>,
|
||||
) -> (Runtime, std::net::TcpListener, Option<RustlsConfig>) {
|
||||
let (mut exit_message_tx, exit_message_rx) = pipe().unwrap();
|
||||
let (mut exit_status_tx, mut exit_status_rx) = pipe().unwrap();
|
||||
let current_umask = umask(Mode::all());
|
||||
umask(current_umask);
|
||||
let web_server_key = web_server_key.clone();
|
||||
let web_server_cert = web_server_cert.clone();
|
||||
let daemonization_outcome = daemonize::Daemonize::new()
|
||||
.working_directory(std::env::current_dir().unwrap())
|
||||
.umask(current_umask.bits() as u32)
|
||||
.privileged_action(
|
||||
move || -> Result<(Runtime, std::net::TcpListener, Option<RustlsConfig>), String> {
|
||||
let runtime = Runtime::new().map_err(|e| e.to_string())?;
|
||||
let tls_config = match (web_server_cert, web_server_key) {
|
||||
(Some(web_server_cert), Some(web_server_key)) => {
|
||||
let tls_config = runtime.block_on(async move {
|
||||
RustlsConfig::from_pem_file(
|
||||
PathBuf::from(web_server_cert),
|
||||
PathBuf::from(web_server_key),
|
||||
)
|
||||
.await
|
||||
});
|
||||
let tls_config = match tls_config {
|
||||
Ok(tls_config) => tls_config,
|
||||
Err(e) => {
|
||||
return Err(e.to_string());
|
||||
},
|
||||
};
|
||||
Some(tls_config)
|
||||
},
|
||||
(None, None) => None,
|
||||
_ => {
|
||||
return Err(
|
||||
"Must specify both web_server_cert and web_server_key".to_owned()
|
||||
)
|
||||
},
|
||||
};
|
||||
|
||||
let listener = runtime.block_on(async move {
|
||||
std::net::TcpListener::bind(format!("{}:{}", web_server_ip, web_server_port))
|
||||
});
|
||||
listener
|
||||
.map(|listener| (runtime, listener, tls_config))
|
||||
.map_err(|e| e.to_string())
|
||||
},
|
||||
)
|
||||
.execute();
|
||||
match daemonization_outcome {
|
||||
Outcome::Parent(Ok(parent)) => {
|
||||
if parent.first_child_exit_code == 0 {
|
||||
let mut buf = [0; 1];
|
||||
match exit_status_rx.read_exact(&mut buf) {
|
||||
Ok(_) => {
|
||||
let exit_status = buf.iter().next().copied().unwrap_or(0) as i32;
|
||||
let mut message = String::new();
|
||||
let mut reader = BufReader::new(exit_message_rx);
|
||||
let _ = reader.read_line(&mut message);
|
||||
if exit_status == 0 {
|
||||
println!("{}", message.trim());
|
||||
} else {
|
||||
eprintln!("{}", message.trim());
|
||||
}
|
||||
std::process::exit(exit_status);
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("{}", e);
|
||||
std::process::exit(2);
|
||||
},
|
||||
}
|
||||
} else {
|
||||
std::process::exit(parent.first_child_exit_code);
|
||||
}
|
||||
},
|
||||
Outcome::Child(Ok(child)) => match child.privileged_action_result {
|
||||
Ok(listener_and_runtime) => {
|
||||
let _ = writeln!(
|
||||
exit_message_tx,
|
||||
"Web Server started on {} port {}",
|
||||
web_server_ip, web_server_port
|
||||
);
|
||||
let _ = exit_status_tx.write_all(&[0]);
|
||||
listener_and_runtime
|
||||
},
|
||||
Err(e) => {
|
||||
let _ = exit_status_tx.write_all(&[2]);
|
||||
let _ = writeln!(exit_message_tx, "{}", e);
|
||||
std::process::exit(2);
|
||||
},
|
||||
},
|
||||
_ => {
|
||||
eprintln!("Failed to start server");
|
||||
std::process::exit(2);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "./unit/web_client_tests.rs"]
|
||||
mod web_client_tests;
|
||||
216
zellij-client/src/web_client/server_listener.rs
Normal file
216
zellij-client/src/web_client/server_listener.rs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
use crate::os_input_output::ClientOsApi;
|
||||
use crate::report_changes_in_config_file;
|
||||
use crate::web_client::control_message::WebServerToWebClientControlMessage;
|
||||
use crate::web_client::session_management::build_initial_connection;
|
||||
use crate::web_client::types::{ClientConnectionBus, ConnectionTable, SessionManager};
|
||||
use crate::web_client::utils::terminal_init_messages;
|
||||
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use zellij_utils::{
|
||||
cli::CliArgs,
|
||||
data::Style,
|
||||
input::{config::Config, options::Options},
|
||||
ipc::{ClientToServerMsg, ExitReason, ServerToClientMsg},
|
||||
sessions::generate_unique_session_name,
|
||||
setup::Setup,
|
||||
};
|
||||
|
||||
pub fn zellij_server_listener(
|
||||
os_input: Box<dyn ClientOsApi>,
|
||||
connection_table: Arc<Mutex<ConnectionTable>>,
|
||||
session_name: Option<String>,
|
||||
mut config: Config,
|
||||
mut config_options: Options,
|
||||
config_file_path: Option<PathBuf>,
|
||||
web_client_id: String,
|
||||
session_manager: Arc<dyn SessionManager>,
|
||||
) {
|
||||
let _server_listener_thread = std::thread::Builder::new()
|
||||
.name("server_listener".to_string())
|
||||
.spawn({
|
||||
move || {
|
||||
let mut client_connection_bus =
|
||||
ClientConnectionBus::new(&web_client_id, &connection_table);
|
||||
let mut reconnect_to_session = match build_initial_connection(session_name) {
|
||||
Ok(initial_session_connection) => initial_session_connection,
|
||||
Err(e) => {
|
||||
log::error!("{}", e);
|
||||
return;
|
||||
},
|
||||
};
|
||||
'reconnect_loop: loop {
|
||||
let reconnect_info = reconnect_to_session.take();
|
||||
let path = {
|
||||
let Some(session_name) = reconnect_info
|
||||
.as_ref()
|
||||
.and_then(|r| r.name.clone())
|
||||
.or_else(generate_unique_session_name)
|
||||
else {
|
||||
log::error!("Failed to generate unique session name, bailing.");
|
||||
return;
|
||||
};
|
||||
let mut sock_dir = zellij_utils::consts::ZELLIJ_SOCK_DIR.clone();
|
||||
sock_dir.push(session_name.clone());
|
||||
sock_dir.to_str().unwrap().to_owned()
|
||||
};
|
||||
|
||||
reload_config_from_disk(&mut config, &mut config_options, &config_file_path);
|
||||
|
||||
let full_screen_ws = os_input.get_terminal_size_using_fd(0);
|
||||
let mut sent_init_messages = false;
|
||||
|
||||
let palette = config
|
||||
.theme_config(config_options.theme.as_ref())
|
||||
.unwrap_or_else(|| os_input.load_palette().into());
|
||||
let client_attributes = zellij_utils::ipc::ClientAttributes {
|
||||
size: full_screen_ws,
|
||||
style: Style {
|
||||
colors: palette,
|
||||
rounded_corners: config.ui.pane_frames.rounded_corners,
|
||||
hide_session_name: config.ui.pane_frames.hide_session_name,
|
||||
},
|
||||
};
|
||||
|
||||
let session_name = PathBuf::from(path.clone())
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
|
||||
let is_web_client = true;
|
||||
let (first_message, zellij_ipc_pipe) = session_manager.spawn_session_if_needed(
|
||||
&session_name,
|
||||
path,
|
||||
client_attributes,
|
||||
&config,
|
||||
&config_options,
|
||||
is_web_client,
|
||||
os_input.clone(),
|
||||
reconnect_info.as_ref().and_then(|r| r.layout.clone()),
|
||||
);
|
||||
|
||||
os_input.connect_to_server(&zellij_ipc_pipe);
|
||||
os_input.send_to_server(first_message);
|
||||
|
||||
let mut args_for_report = CliArgs::default();
|
||||
args_for_report.config = config_file_path.clone();
|
||||
report_changes_in_config_file(&args_for_report, &os_input);
|
||||
|
||||
client_connection_bus.send_control(
|
||||
WebServerToWebClientControlMessage::SwitchedSession {
|
||||
new_session_name: session_name.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
loop {
|
||||
match os_input.recv_from_server() {
|
||||
Some((ServerToClientMsg::UnblockInputThread, _)) => {},
|
||||
Some((ServerToClientMsg::Exit(exit_reason), _)) => {
|
||||
handle_exit_reason(&mut client_connection_bus, exit_reason);
|
||||
os_input.send_to_server(ClientToServerMsg::ClientExited);
|
||||
break;
|
||||
},
|
||||
Some((ServerToClientMsg::Render(bytes), _)) => {
|
||||
if !sent_init_messages {
|
||||
for message in terminal_init_messages() {
|
||||
client_connection_bus.send_stdout(message.to_owned())
|
||||
}
|
||||
sent_init_messages = true;
|
||||
}
|
||||
client_connection_bus.send_stdout(bytes);
|
||||
},
|
||||
Some((ServerToClientMsg::SwitchSession(connect_to_session), _)) => {
|
||||
reconnect_to_session = Some(connect_to_session);
|
||||
continue 'reconnect_loop;
|
||||
},
|
||||
Some((ServerToClientMsg::WriteConfigToDisk { config }, _)) => {
|
||||
handle_config_write(&os_input, config);
|
||||
},
|
||||
Some((ServerToClientMsg::QueryTerminalSize, _)) => {
|
||||
client_connection_bus.send_control(
|
||||
WebServerToWebClientControlMessage::QueryTerminalSize,
|
||||
);
|
||||
},
|
||||
Some((ServerToClientMsg::Log(lines), _)) => {
|
||||
client_connection_bus.send_control(
|
||||
WebServerToWebClientControlMessage::Log { lines },
|
||||
);
|
||||
},
|
||||
Some((ServerToClientMsg::LogError(lines), _)) => {
|
||||
client_connection_bus.send_control(
|
||||
WebServerToWebClientControlMessage::LogError { lines },
|
||||
);
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
if reconnect_to_session.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_exit_reason(client_connection_bus: &mut ClientConnectionBus, exit_reason: ExitReason) {
|
||||
match exit_reason {
|
||||
ExitReason::WebClientsForbidden => {
|
||||
client_connection_bus.send_stdout(format!(
|
||||
"\u{1b}[2J\n Web Clients are not allowed to attach to this session."
|
||||
));
|
||||
},
|
||||
ExitReason::Error(e) => {
|
||||
let goto_start_of_last_line = format!("\u{1b}[{};{}H", 1, 1);
|
||||
let clear_client_terminal_attributes = "\u{1b}[?1l\u{1b}=\u{1b}[r\u{1b}[?1000l\u{1b}[?1002l\u{1b}[?1003l\u{1b}[?1005l\u{1b}[?1006l\u{1b}[?12l";
|
||||
let disable_mouse = "\u{1b}[?1006l\u{1b}[?1015l\u{1b}[?1003l\u{1b}[?1002l\u{1b}[?1000l";
|
||||
let error = format!(
|
||||
"{}{}\n{}{}\n",
|
||||
disable_mouse,
|
||||
clear_client_terminal_attributes,
|
||||
goto_start_of_last_line,
|
||||
e.to_string().replace("\n", "\n\r")
|
||||
);
|
||||
client_connection_bus.send_stdout(format!("\u{1b}[2J\n{}", error));
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
client_connection_bus.close_connection();
|
||||
}
|
||||
|
||||
fn handle_config_write(os_input: &Box<dyn ClientOsApi>, config: String) {
|
||||
match Config::write_config_to_disk(config, &CliArgs::default()) {
|
||||
Ok(written_config) => {
|
||||
let _ = os_input.send_to_server(ClientToServerMsg::ConfigWrittenToDisk(written_config));
|
||||
},
|
||||
Err(e) => {
|
||||
let error_path = e
|
||||
.as_ref()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(String::new);
|
||||
log::error!("Failed to write config to disk: {}", error_path);
|
||||
let _ = os_input.send_to_server(ClientToServerMsg::FailedToWriteConfigToDisk(e));
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn reload_config_from_disk(
|
||||
config_without_layout: &mut Config,
|
||||
config_options_without_layout: &mut Options,
|
||||
config_file_path: &Option<PathBuf>,
|
||||
) {
|
||||
let mut cli_args = CliArgs::default();
|
||||
cli_args.config = config_file_path.clone();
|
||||
match Setup::from_cli_args(&cli_args) {
|
||||
Ok((_, _, _, reloaded_config_without_layout, reloaded_config_options_without_layout)) => {
|
||||
*config_without_layout = reloaded_config_without_layout;
|
||||
*config_options_without_layout = reloaded_config_options_without_layout;
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("Failed to reload config: {}", e);
|
||||
},
|
||||
};
|
||||
}
|
||||
198
zellij-client/src/web_client/session_management.rs
Normal file
198
zellij-client/src/web_client/session_management.rs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
use crate::os_input_output::ClientOsApi;
|
||||
use crate::spawn_server;
|
||||
|
||||
use std::{fs, path::PathBuf};
|
||||
use zellij_utils::{
|
||||
cli::CliArgs,
|
||||
data::{ConnectToSession, LayoutInfo, WebSharing},
|
||||
envs,
|
||||
input::{
|
||||
config::{Config, ConfigError},
|
||||
layout::Layout,
|
||||
options::Options,
|
||||
},
|
||||
ipc::{ClientAttributes, ClientToServerMsg},
|
||||
sessions::{generate_unique_session_name, resurrection_layout, session_exists},
|
||||
setup::{find_default_config_dir, get_layout_dir},
|
||||
};
|
||||
|
||||
pub fn build_initial_connection(
|
||||
session_name: Option<String>,
|
||||
) -> Result<Option<ConnectToSession>, &'static str> {
|
||||
let should_start_with_welcome_screen = session_name.is_none();
|
||||
if should_start_with_welcome_screen {
|
||||
let Some(initial_session_name) = session_name.clone().or_else(generate_unique_session_name)
|
||||
else {
|
||||
return Err("Failed to generate unique session name, bailing.");
|
||||
};
|
||||
Ok(Some(ConnectToSession {
|
||||
name: Some(initial_session_name.clone()),
|
||||
layout: Some(LayoutInfo::BuiltIn("welcome".to_owned())),
|
||||
..Default::default()
|
||||
}))
|
||||
} else if let Some(session_name) = session_name {
|
||||
Ok(Some(ConnectToSession {
|
||||
name: Some(session_name.clone()),
|
||||
..Default::default()
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_for_new_session(
|
||||
config: &Config,
|
||||
requested_layout: Option<LayoutInfo>,
|
||||
) -> Result<(Layout, Config), ConfigError> {
|
||||
let layout_dir = config
|
||||
.options
|
||||
.layout_dir
|
||||
.clone()
|
||||
.or_else(|| get_layout_dir(find_default_config_dir()));
|
||||
match requested_layout {
|
||||
Some(LayoutInfo::BuiltIn(layout_name)) => Layout::from_default_assets(
|
||||
&PathBuf::from(layout_name),
|
||||
layout_dir.clone(),
|
||||
config.clone(),
|
||||
),
|
||||
Some(LayoutInfo::File(layout_name)) => Layout::from_path_or_default(
|
||||
Some(&PathBuf::from(layout_name)),
|
||||
layout_dir.clone(),
|
||||
config.clone(),
|
||||
),
|
||||
Some(LayoutInfo::Url(url)) => Layout::from_url(&url, config.clone()),
|
||||
Some(LayoutInfo::Stringified(stringified_layout)) => {
|
||||
Layout::from_stringified_layout(&stringified_layout, config.clone())
|
||||
},
|
||||
None => Layout::from_default_assets(
|
||||
&PathBuf::from("default"),
|
||||
layout_dir.clone(),
|
||||
config.clone(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_session_if_needed(
|
||||
session_name: &str,
|
||||
path: String,
|
||||
client_attributes: ClientAttributes,
|
||||
config: &Config,
|
||||
config_options: &Options,
|
||||
is_web_client: bool,
|
||||
os_input: Box<dyn ClientOsApi>,
|
||||
requested_layout: Option<LayoutInfo>,
|
||||
) -> (ClientToServerMsg, PathBuf) {
|
||||
if session_exists(&session_name).unwrap_or(false) {
|
||||
ipc_pipe_and_first_message_for_existing_session(
|
||||
path,
|
||||
client_attributes,
|
||||
&config,
|
||||
&config_options,
|
||||
is_web_client,
|
||||
)
|
||||
} else {
|
||||
let force_run_commands = false;
|
||||
let resurrection_layout =
|
||||
resurrection_layout(&session_name).map(|mut resurrection_layout| {
|
||||
if force_run_commands {
|
||||
resurrection_layout.recursively_add_start_suspended(Some(false));
|
||||
}
|
||||
resurrection_layout
|
||||
});
|
||||
|
||||
match resurrection_layout {
|
||||
Some(resurrection_layout) => spawn_new_session(
|
||||
&session_name,
|
||||
os_input.clone(),
|
||||
config.clone(),
|
||||
config_options.clone(),
|
||||
Some(resurrection_layout),
|
||||
client_attributes,
|
||||
),
|
||||
None => {
|
||||
let new_session_layout = layout_for_new_session(&config, requested_layout);
|
||||
|
||||
spawn_new_session(
|
||||
&session_name,
|
||||
os_input.clone(),
|
||||
config.clone(),
|
||||
config_options.clone(),
|
||||
new_session_layout.ok().map(|(l, _c)| l),
|
||||
client_attributes,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_new_session(
|
||||
name: &str,
|
||||
mut os_input: Box<dyn ClientOsApi>,
|
||||
mut config: Config,
|
||||
config_opts: Options,
|
||||
layout: Option<Layout>,
|
||||
client_attributes: ClientAttributes,
|
||||
) -> (ClientToServerMsg, PathBuf) {
|
||||
let debug = false;
|
||||
envs::set_session_name(name.to_owned());
|
||||
os_input.update_session_name(name.to_owned());
|
||||
|
||||
let zellij_ipc_pipe: PathBuf = {
|
||||
let mut sock_dir = zellij_utils::consts::ZELLIJ_SOCK_DIR.clone();
|
||||
fs::create_dir_all(&sock_dir).unwrap();
|
||||
zellij_utils::shared::set_permissions(&sock_dir, 0o700).unwrap();
|
||||
sock_dir.push(name);
|
||||
sock_dir
|
||||
};
|
||||
|
||||
spawn_server(&*zellij_ipc_pipe, debug).unwrap();
|
||||
|
||||
let successfully_written_config = Config::write_config_to_disk_if_it_does_not_exist(
|
||||
config.to_string(true),
|
||||
&Default::default(),
|
||||
);
|
||||
let should_launch_setup_wizard = successfully_written_config;
|
||||
let cli_args = CliArgs::default();
|
||||
config.options.web_server = Some(true);
|
||||
config.options.web_sharing = Some(WebSharing::On);
|
||||
let is_web_client = true;
|
||||
|
||||
(
|
||||
ClientToServerMsg::NewClient(
|
||||
client_attributes,
|
||||
Box::new(cli_args),
|
||||
Box::new(config.clone()),
|
||||
Box::new(config_opts.clone()),
|
||||
Box::new(layout.unwrap()),
|
||||
Box::new(config.plugins.clone()),
|
||||
should_launch_setup_wizard,
|
||||
is_web_client,
|
||||
),
|
||||
zellij_ipc_pipe,
|
||||
)
|
||||
}
|
||||
|
||||
fn ipc_pipe_and_first_message_for_existing_session(
|
||||
session_name: String,
|
||||
client_attributes: ClientAttributes,
|
||||
config: &Config,
|
||||
config_options: &Options,
|
||||
is_web_client: bool,
|
||||
) -> (ClientToServerMsg, PathBuf) {
|
||||
let zellij_ipc_pipe: PathBuf = {
|
||||
let mut sock_dir = zellij_utils::consts::ZELLIJ_SOCK_DIR.clone();
|
||||
fs::create_dir_all(&sock_dir).unwrap();
|
||||
zellij_utils::shared::set_permissions(&sock_dir, 0o700).unwrap();
|
||||
sock_dir.push(session_name);
|
||||
sock_dir
|
||||
};
|
||||
let first_message = ClientToServerMsg::AttachClient(
|
||||
client_attributes,
|
||||
config.clone(),
|
||||
config_options.clone(),
|
||||
None,
|
||||
None,
|
||||
is_web_client,
|
||||
);
|
||||
(first_message, zellij_ipc_pipe)
|
||||
}
|
||||
213
zellij-client/src/web_client/types.rs
Normal file
213
zellij-client/src/web_client/types.rs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
use axum::extract::ws::Message;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::os_input_output::ClientOsApi;
|
||||
use std::path::PathBuf;
|
||||
use zellij_utils::{
|
||||
data::LayoutInfo,
|
||||
input::{config::Config, options::Options},
|
||||
ipc::{ClientAttributes, ClientToServerMsg},
|
||||
};
|
||||
|
||||
pub trait ClientOsApiFactory: Send + Sync + std::fmt::Debug {
|
||||
fn create_client_os_api(&self) -> Result<Box<dyn ClientOsApi>, Box<dyn std::error::Error>>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RealClientOsApiFactory;
|
||||
|
||||
impl ClientOsApiFactory for RealClientOsApiFactory {
|
||||
fn create_client_os_api(&self) -> Result<Box<dyn ClientOsApi>, Box<dyn std::error::Error>> {
|
||||
crate::os_input_output::get_client_os_input()
|
||||
.map(|os_input| Box::new(os_input) as Box<dyn ClientOsApi>)
|
||||
.map_err(|e| format!("Failed to create client OS API: {:?}", e).into())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait SessionManager: Send + Sync + std::fmt::Debug {
|
||||
fn session_exists(&self, session_name: &str) -> Result<bool, Box<dyn std::error::Error>>;
|
||||
fn get_resurrection_layout(
|
||||
&self,
|
||||
session_name: &str,
|
||||
) -> Option<zellij_utils::input::layout::Layout>;
|
||||
fn spawn_session_if_needed(
|
||||
&self,
|
||||
session_name: &str,
|
||||
path: String,
|
||||
client_attributes: ClientAttributes,
|
||||
config: &Config,
|
||||
config_options: &Options,
|
||||
is_web_client: bool,
|
||||
os_input: Box<dyn ClientOsApi>,
|
||||
requested_layout: Option<LayoutInfo>,
|
||||
) -> (ClientToServerMsg, PathBuf);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RealSessionManager;
|
||||
|
||||
impl SessionManager for RealSessionManager {
|
||||
fn session_exists(&self, session_name: &str) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
zellij_utils::sessions::session_exists(session_name)
|
||||
.map_err(|e| format!("Session check failed: {:?}", e).into())
|
||||
}
|
||||
|
||||
fn get_resurrection_layout(
|
||||
&self,
|
||||
session_name: &str,
|
||||
) -> Option<zellij_utils::input::layout::Layout> {
|
||||
zellij_utils::sessions::resurrection_layout(session_name)
|
||||
}
|
||||
|
||||
fn spawn_session_if_needed(
|
||||
&self,
|
||||
session_name: &str,
|
||||
path: String,
|
||||
client_attributes: ClientAttributes,
|
||||
config: &Config,
|
||||
config_options: &Options,
|
||||
is_web_client: bool,
|
||||
os_input: Box<dyn ClientOsApi>,
|
||||
requested_layout: Option<LayoutInfo>,
|
||||
) -> (ClientToServerMsg, PathBuf) {
|
||||
crate::web_client::session_management::spawn_session_if_needed(
|
||||
session_name,
|
||||
path,
|
||||
client_attributes,
|
||||
config,
|
||||
config_options,
|
||||
is_web_client,
|
||||
os_input,
|
||||
requested_layout,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct ConnectionTable {
|
||||
pub client_id_to_channels: HashMap<String, ClientChannels>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClientChannels {
|
||||
pub os_api: Box<dyn ClientOsApi>,
|
||||
pub control_channel_tx: Option<UnboundedSender<Message>>,
|
||||
pub terminal_channel_tx: Option<UnboundedSender<String>>,
|
||||
terminal_channel_cancellation_token: Option<CancellationToken>,
|
||||
}
|
||||
|
||||
impl ClientChannels {
|
||||
pub fn new(os_api: Box<dyn ClientOsApi>) -> Self {
|
||||
ClientChannels {
|
||||
os_api,
|
||||
control_channel_tx: None,
|
||||
terminal_channel_tx: None,
|
||||
terminal_channel_cancellation_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_control_tx(&mut self, control_channel_tx: UnboundedSender<Message>) {
|
||||
self.control_channel_tx = Some(control_channel_tx);
|
||||
}
|
||||
|
||||
pub fn add_terminal_tx(&mut self, terminal_channel_tx: UnboundedSender<String>) {
|
||||
self.terminal_channel_tx = Some(terminal_channel_tx);
|
||||
}
|
||||
|
||||
pub fn add_terminal_channel_cancellation_token(
|
||||
&mut self,
|
||||
terminal_channel_cancellation_token: CancellationToken,
|
||||
) {
|
||||
self.terminal_channel_cancellation_token = Some(terminal_channel_cancellation_token);
|
||||
}
|
||||
pub fn cleanup(&mut self) {
|
||||
if let Some(terminal_channel_cancellation_token) =
|
||||
self.terminal_channel_cancellation_token.take()
|
||||
{
|
||||
terminal_channel_cancellation_token.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ClientConnectionBus {
|
||||
pub connection_table: Arc<Mutex<ConnectionTable>>,
|
||||
pub stdout_channel_tx: Option<UnboundedSender<String>>,
|
||||
pub control_channel_tx: Option<UnboundedSender<Message>>,
|
||||
pub web_client_id: String,
|
||||
}
|
||||
|
||||
impl ClientConnectionBus {
|
||||
pub fn new(web_client_id: &str, connection_table: &Arc<Mutex<ConnectionTable>>) -> Self {
|
||||
let connection_table = connection_table.clone();
|
||||
let web_client_id = web_client_id.to_owned();
|
||||
let (stdout_channel_tx, control_channel_tx) = {
|
||||
let connection_table = connection_table.lock().unwrap();
|
||||
(
|
||||
connection_table.get_client_terminal_tx(&web_client_id),
|
||||
connection_table.get_client_control_tx(&web_client_id),
|
||||
)
|
||||
};
|
||||
ClientConnectionBus {
|
||||
connection_table,
|
||||
stdout_channel_tx,
|
||||
control_channel_tx,
|
||||
web_client_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub connection_table: Arc<Mutex<ConnectionTable>>,
|
||||
pub config: Config,
|
||||
pub config_options: Options,
|
||||
pub config_file_path: PathBuf,
|
||||
pub session_manager: Arc<dyn SessionManager>,
|
||||
pub client_os_api_factory: Arc<dyn ClientOsApiFactory>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StdinMessage {
|
||||
pub web_client_id: String,
|
||||
pub stdin: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ShutdownSignal {
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct SendShutdownSignalResponse {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreateClientIdResponse {
|
||||
pub web_client_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TerminalParams {
|
||||
pub web_client_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub auth_token: String,
|
||||
pub remember_me: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub const BRACKETED_PASTE_START: [u8; 6] = [27, 91, 50, 48, 48, 126]; // \u{1b}[200~
|
||||
pub const BRACKETED_PASTE_END: [u8; 6] = [27, 91, 50, 48, 49, 126]; // \u{1b}[201~
|
||||
1405
zellij-client/src/web_client/unit/web_client_tests.rs
Normal file
1405
zellij-client/src/web_client/unit/web_client_tests.rs
Normal file
File diff suppressed because it is too large
Load diff
75
zellij-client/src/web_client/utils.rs
Normal file
75
zellij-client/src/web_client/utils.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
use axum::http::Request;
|
||||
use axum_extra::extract::cookie::Cookie;
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
|
||||
pub fn get_mime_type(ext: Option<&str>) -> &str {
|
||||
match ext {
|
||||
None => "text/plain",
|
||||
Some(ext) => match ext {
|
||||
"html" => "text/html",
|
||||
"css" => "text/css",
|
||||
"js" => "application/javascript",
|
||||
"wasm" => "application/wasm",
|
||||
"png" => "image/png",
|
||||
"ico" => "image/x-icon",
|
||||
"svg" => "image/svg+xml",
|
||||
_ => "text/plain",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_use_https(
|
||||
ip: IpAddr,
|
||||
has_certificate: bool,
|
||||
enforce_https_for_localhost: bool,
|
||||
) -> Result<bool, String> {
|
||||
let is_loopback = match ip {
|
||||
IpAddr::V4(ipv4) => ipv4.is_loopback(),
|
||||
IpAddr::V6(ipv6) => ipv6.is_loopback(),
|
||||
};
|
||||
|
||||
if is_loopback && !enforce_https_for_localhost {
|
||||
Ok(has_certificate)
|
||||
} else if is_loopback {
|
||||
Err(format!("Cannot bind without an SSL certificate."))
|
||||
} else if has_certificate {
|
||||
Ok(true)
|
||||
} else {
|
||||
Err(format!(
|
||||
"Cannot bind to non-loopback IP: {} without an SSL certificate.",
|
||||
ip
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_cookies<T>(request: &Request<T>) -> HashMap<String, String> {
|
||||
let mut cookies = HashMap::new();
|
||||
|
||||
if let Some(cookie_header) = request.headers().get("cookie") {
|
||||
if let Ok(cookie_str) = cookie_header.to_str() {
|
||||
for cookie_part in cookie_str.split(';') {
|
||||
if let Ok(cookie) = Cookie::parse(cookie_part.trim()) {
|
||||
cookies.insert(cookie.name().to_string(), cookie.value().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cookies
|
||||
}
|
||||
|
||||
pub fn terminal_init_messages() -> Vec<&'static str> {
|
||||
let clear_client_terminal_attributes = "\u{1b}[?1l\u{1b}=\u{1b}[r\u{1b}[?1000l\u{1b}[?1002l\u{1b}[?1003l\u{1b}[?1005l\u{1b}[?1006l\u{1b}[?12l";
|
||||
let enter_alternate_screen = "\u{1b}[?1049h";
|
||||
let bracketed_paste = "\u{1b}[?2004h";
|
||||
let enter_kitty_keyboard_mode = "\u{1b}[>1u";
|
||||
let enable_mouse_mode = "\u{1b}[?1000h\u{1b}[?1002h\u{1b}[?1015h\u{1b}[?1006h";
|
||||
vec![
|
||||
clear_client_terminal_attributes,
|
||||
enter_alternate_screen,
|
||||
bracketed_paste,
|
||||
enter_kitty_keyboard_mode,
|
||||
enable_mouse_mode,
|
||||
]
|
||||
}
|
||||
202
zellij-client/src/web_client/websocket_handlers.rs
Normal file
202
zellij-client/src/web_client/websocket_handlers.rs
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
use crate::web_client::control_message::{
|
||||
SetConfigPayload, WebClientToWebServerControlMessage,
|
||||
WebClientToWebServerControlMessagePayload, WebServerToWebClientControlMessage,
|
||||
};
|
||||
use crate::web_client::message_handlers::{
|
||||
parse_stdin, render_to_client, send_control_messages_to_client,
|
||||
};
|
||||
use crate::web_client::server_listener::zellij_server_listener;
|
||||
use crate::web_client::types::{AppState, TerminalParams};
|
||||
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
Path as AxumPath, Query, State,
|
||||
},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use zellij_utils::{input::mouse::MouseEvent, ipc::ClientToServerMsg};
|
||||
|
||||
pub async fn ws_handler_control(
|
||||
ws: WebSocketUpgrade,
|
||||
_path: Option<AxumPath<String>>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| handle_ws_control(socket, state))
|
||||
}
|
||||
|
||||
pub async fn ws_handler_terminal(
|
||||
ws: WebSocketUpgrade,
|
||||
session_name: Option<AxumPath<String>>,
|
||||
Query(params): Query<TerminalParams>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| handle_ws_terminal(socket, session_name, params, state))
|
||||
}
|
||||
|
||||
async fn handle_ws_control(socket: WebSocket, state: AppState) {
|
||||
let config = SetConfigPayload::from(&state.config);
|
||||
let set_config_msg = WebServerToWebClientControlMessage::SetConfig(config);
|
||||
|
||||
let (control_socket_tx, mut control_socket_rx) = socket.split();
|
||||
|
||||
let (control_channel_tx, control_channel_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
send_control_messages_to_client(control_channel_rx, control_socket_tx);
|
||||
|
||||
let _ = control_channel_tx.send(Message::Text(
|
||||
serde_json::to_string(&set_config_msg).unwrap().into(),
|
||||
));
|
||||
|
||||
let send_message_to_server = |deserialized_msg: WebClientToWebServerControlMessage| {
|
||||
let Some(client_connection) = state
|
||||
.connection_table
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_client_os_api(&deserialized_msg.web_client_id)
|
||||
.cloned()
|
||||
else {
|
||||
log::error!("Unknown web_client_id: {}", deserialized_msg.web_client_id);
|
||||
return;
|
||||
};
|
||||
let client_msg = match deserialized_msg.payload {
|
||||
WebClientToWebServerControlMessagePayload::TerminalResize(size) => {
|
||||
ClientToServerMsg::TerminalResize(size)
|
||||
},
|
||||
};
|
||||
|
||||
let _ = client_connection.send_to_server(client_msg);
|
||||
};
|
||||
|
||||
let mut set_client_control_channel = false;
|
||||
|
||||
while let Some(Ok(msg)) = control_socket_rx.next().await {
|
||||
match msg {
|
||||
Message::Text(msg) => {
|
||||
let deserialized_msg: Result<WebClientToWebServerControlMessage, _> =
|
||||
serde_json::from_str(&msg);
|
||||
match deserialized_msg {
|
||||
Ok(deserialized_msg) => {
|
||||
if !set_client_control_channel {
|
||||
set_client_control_channel = true;
|
||||
state
|
||||
.connection_table
|
||||
.lock()
|
||||
.unwrap()
|
||||
.add_client_control_tx(
|
||||
&deserialized_msg.web_client_id,
|
||||
control_channel_tx.clone(),
|
||||
);
|
||||
}
|
||||
send_message_to_server(deserialized_msg);
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("Failed to deserialize client msg: {:?}", e);
|
||||
},
|
||||
}
|
||||
},
|
||||
Message::Close(_) => {
|
||||
return;
|
||||
},
|
||||
_ => {
|
||||
log::error!("Unsupported messagetype : {:?}", msg);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_ws_terminal(
|
||||
socket: WebSocket,
|
||||
session_name: Option<AxumPath<String>>,
|
||||
params: TerminalParams,
|
||||
state: AppState,
|
||||
) {
|
||||
let web_client_id = params.web_client_id;
|
||||
let Some(os_input) = state
|
||||
.connection_table
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_client_os_api(&web_client_id)
|
||||
.cloned()
|
||||
else {
|
||||
log::error!("Unknown web_client_id: {}", web_client_id);
|
||||
return;
|
||||
};
|
||||
|
||||
let (client_terminal_channel_tx, mut client_terminal_channel_rx) = socket.split();
|
||||
let (stdout_channel_tx, stdout_channel_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
state
|
||||
.connection_table
|
||||
.lock()
|
||||
.unwrap()
|
||||
.add_client_terminal_tx(&web_client_id, stdout_channel_tx);
|
||||
|
||||
zellij_server_listener(
|
||||
os_input.clone(),
|
||||
state.connection_table.clone(),
|
||||
session_name.map(|p| p.0),
|
||||
state.config.clone(),
|
||||
state.config_options.clone(),
|
||||
Some(state.config_file_path.clone()),
|
||||
web_client_id.clone(),
|
||||
state.session_manager.clone(),
|
||||
);
|
||||
|
||||
let terminal_channel_cancellation_token = CancellationToken::new();
|
||||
render_to_client(
|
||||
stdout_channel_rx,
|
||||
client_terminal_channel_tx,
|
||||
terminal_channel_cancellation_token.clone(),
|
||||
);
|
||||
state
|
||||
.connection_table
|
||||
.lock()
|
||||
.unwrap()
|
||||
.add_client_terminal_channel_cancellation_token(
|
||||
&web_client_id,
|
||||
terminal_channel_cancellation_token,
|
||||
);
|
||||
|
||||
let explicitly_disable_kitty_keyboard_protocol = state
|
||||
.config
|
||||
.options
|
||||
.support_kitty_keyboard_protocol
|
||||
.map(|e| !e)
|
||||
.unwrap_or(false);
|
||||
let mut mouse_old_event = MouseEvent::new();
|
||||
while let Some(Ok(msg)) = client_terminal_channel_rx.next().await {
|
||||
match msg {
|
||||
Message::Text(msg) => {
|
||||
let Some(client_connection) = state
|
||||
.connection_table
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_client_os_api(&web_client_id)
|
||||
.cloned()
|
||||
else {
|
||||
log::error!("Unknown web_client_id: {}", web_client_id);
|
||||
continue;
|
||||
};
|
||||
parse_stdin(
|
||||
msg.as_bytes(),
|
||||
client_connection.clone(),
|
||||
&mut mouse_old_event,
|
||||
explicitly_disable_kitty_keyboard_protocol,
|
||||
);
|
||||
},
|
||||
Message::Close(_) => {
|
||||
state
|
||||
.connection_table
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove_client(&web_client_id);
|
||||
break;
|
||||
},
|
||||
_ => {
|
||||
log::error!("Unsupported websocket msg type");
|
||||
},
|
||||
}
|
||||
}
|
||||
os_input.send_to_server(ClientToServerMsg::ClientExited);
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ bytes = { version = "1.6.0", default-features = false, features = ["std"] }
|
|||
cassowary = { version = "0.3.0", default-features = false }
|
||||
chrono = { version = "0.4.19", default-features = false, features = ["std", "clock"] }
|
||||
close_fds = { version = "0.3.2", default-features = false }
|
||||
daemonize = { version = "0.5", default-features = false }
|
||||
daemonize = { workspace = true }
|
||||
highway = { version = "0.6.4", default-features = false, features = ["std"] }
|
||||
interprocess = { workspace = true }
|
||||
isahc = { workspace = true }
|
||||
|
|
@ -69,3 +69,4 @@ wasmtime = { version = "29.0.1", features = ["winch"] } # Keep in sync with the
|
|||
|
||||
[features]
|
||||
singlepass = ["wasmtime/winch"]
|
||||
web_server_capability = ["zellij-utils/web_server_capability"]
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
use async_std::task;
|
||||
use zellij_utils::consts::{
|
||||
session_info_cache_file_name, session_info_folder_for_session, session_layout_cache_file_name,
|
||||
ZELLIJ_SESSION_INFO_CACHE_DIR, ZELLIJ_SOCK_DIR,
|
||||
VERSION, ZELLIJ_SESSION_INFO_CACHE_DIR, ZELLIJ_SOCK_DIR,
|
||||
};
|
||||
use zellij_utils::data::{Event, HttpVerb, SessionInfo};
|
||||
use zellij_utils::data::{Event, HttpVerb, SessionInfo, WebServerStatus};
|
||||
use zellij_utils::errors::{prelude::*, BackgroundJobContext, ContextType};
|
||||
use zellij_utils::input::layout::RunPlugin;
|
||||
|
||||
|
|
@ -57,6 +57,7 @@ pub enum BackgroundJob {
|
|||
),
|
||||
HighlightPanesWithMessage(Vec<PaneId>, String),
|
||||
RenderToClients,
|
||||
QueryZellijWebServerStatus,
|
||||
Exit,
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +81,9 @@ impl From<&BackgroundJob> for BackgroundJobContext {
|
|||
BackgroundJob::HighlightPanesWithMessage(..) => {
|
||||
BackgroundJobContext::HighlightPanesWithMessage
|
||||
},
|
||||
BackgroundJob::QueryZellijWebServerStatus => {
|
||||
BackgroundJobContext::QueryZellijWebServerStatus
|
||||
},
|
||||
BackgroundJob::Exit => BackgroundJobContext::Exit,
|
||||
}
|
||||
}
|
||||
|
|
@ -96,6 +100,7 @@ pub(crate) fn background_jobs_main(
|
|||
bus: Bus<BackgroundJob>,
|
||||
serialization_interval: Option<u64>,
|
||||
disable_session_metadata: bool,
|
||||
web_server_base_url: String,
|
||||
) -> Result<()> {
|
||||
let err_context = || "failed to write to pty".to_string();
|
||||
let mut running_jobs: HashMap<BackgroundJob, Instant> = HashMap::new();
|
||||
|
|
@ -369,6 +374,89 @@ pub(crate) fn background_jobs_main(
|
|||
}
|
||||
});
|
||||
},
|
||||
BackgroundJob::QueryZellijWebServerStatus => {
|
||||
if !cfg!(feature = "web_server_capability") {
|
||||
// no web server capability, no need to query
|
||||
continue;
|
||||
}
|
||||
|
||||
task::spawn({
|
||||
let http_client = http_client.clone();
|
||||
let senders = bus.senders.clone();
|
||||
let web_server_base_url = web_server_base_url.clone();
|
||||
async move {
|
||||
async fn web_request(
|
||||
http_client: HttpClient,
|
||||
web_server_base_url: &str,
|
||||
) -> Result<
|
||||
(u16, Vec<u8>), // status_code, body
|
||||
isahc::Error,
|
||||
> {
|
||||
let request =
|
||||
Request::get(format!("{}/info/version", web_server_base_url,));
|
||||
let req = request.body(())?;
|
||||
let mut res = http_client.send_async(req).await?;
|
||||
|
||||
let status_code = res.status();
|
||||
let body = res.bytes().await?;
|
||||
Ok((status_code.as_u16(), body))
|
||||
}
|
||||
let Some(http_client) = http_client else {
|
||||
log::error!("Cannot perform http request, likely due to a misconfigured http client");
|
||||
return;
|
||||
};
|
||||
|
||||
let http_client = http_client.clone();
|
||||
match web_request(http_client, &web_server_base_url).await {
|
||||
Ok((status, body)) => {
|
||||
if status == 200 && &body == VERSION.as_bytes() {
|
||||
// online
|
||||
let _ =
|
||||
senders.send_to_plugin(PluginInstruction::Update(vec![(
|
||||
None,
|
||||
None,
|
||||
Event::WebServerStatus(WebServerStatus::Online(
|
||||
web_server_base_url.clone(),
|
||||
)),
|
||||
)]));
|
||||
} else if status == 200 {
|
||||
let _ =
|
||||
senders.send_to_plugin(PluginInstruction::Update(vec![(
|
||||
None,
|
||||
None,
|
||||
Event::WebServerStatus(
|
||||
WebServerStatus::DifferentVersion(
|
||||
String::from_utf8_lossy(&body).to_string(),
|
||||
),
|
||||
),
|
||||
)]));
|
||||
} else {
|
||||
// offline/error
|
||||
let _ =
|
||||
senders.send_to_plugin(PluginInstruction::Update(vec![(
|
||||
None,
|
||||
None,
|
||||
Event::WebServerStatus(WebServerStatus::Offline),
|
||||
)]));
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
if e.kind() == isahc::error::ErrorKind::ConnectionFailed {
|
||||
let _ =
|
||||
senders.send_to_plugin(PluginInstruction::Update(vec![(
|
||||
None,
|
||||
None,
|
||||
Event::WebServerStatus(WebServerStatus::Offline),
|
||||
)]));
|
||||
} else {
|
||||
// no-op - otherwise we'll get errors if we were mid-request
|
||||
// (eg. when the server was shut down by a user action)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
BackgroundJob::RenderToClients => {
|
||||
// last_render_request being Some() represents a render request that is pending
|
||||
// last_render_request is only ever set to Some() if an async task is spawned to
|
||||
|
|
|
|||
|
|
@ -16,12 +16,15 @@ mod terminal_bytes;
|
|||
mod thread_bus;
|
||||
mod ui;
|
||||
|
||||
pub use daemonize;
|
||||
|
||||
use background_jobs::{background_jobs_main, BackgroundJob};
|
||||
use log::info;
|
||||
use nix::sys::stat::{umask, Mode};
|
||||
use pty_writer::{pty_writer_main, PtyWriteInstruction};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
path::PathBuf,
|
||||
sync::{Arc, RwLock},
|
||||
thread,
|
||||
|
|
@ -45,7 +48,7 @@ use zellij_utils::{
|
|||
consts::{
|
||||
DEFAULT_SCROLL_BUFFER_SIZE, SCROLL_BUFFER_SIZE, ZELLIJ_SEEN_RELEASE_NOTES_CACHE_FILE,
|
||||
},
|
||||
data::{ConnectToSession, Event, InputMode, KeyWithModifier, PluginCapabilities},
|
||||
data::{ConnectToSession, Event, InputMode, KeyWithModifier, PluginCapabilities, WebSharing},
|
||||
errors::{prelude::*, ContextType, ErrorInstruction, FatalError, ServerContext},
|
||||
home::{default_layout_dir, get_default_data_dir},
|
||||
input::{
|
||||
|
|
@ -59,7 +62,7 @@ use zellij_utils::{
|
|||
plugins::PluginAliases,
|
||||
},
|
||||
ipc::{ClientAttributes, ExitReason, ServerToClientMsg},
|
||||
shared::default_palette,
|
||||
shared::{default_palette, web_server_base_url},
|
||||
};
|
||||
|
||||
pub type ClientId = u16;
|
||||
|
|
@ -75,6 +78,7 @@ pub enum ServerInstruction {
|
|||
Box<Layout>,
|
||||
Box<PluginAliases>,
|
||||
bool, // should launch setup wizard
|
||||
bool, // is_web_client
|
||||
ClientId,
|
||||
),
|
||||
Render(Option<HashMap<ClientId, String>>),
|
||||
|
|
@ -90,6 +94,7 @@ pub enum ServerInstruction {
|
|||
Options, // represents the runtime configuration options
|
||||
Option<usize>, // tab position to focus
|
||||
Option<(u32, bool)>, // (pane_id, is_plugin) => pane_id to focus
|
||||
bool, // is_web_client
|
||||
ClientId,
|
||||
),
|
||||
ConnStatus(ClientId),
|
||||
|
|
@ -118,6 +123,12 @@ pub enum ServerInstruction {
|
|||
keys_to_unbind: Vec<(InputMode, KeyWithModifier)>,
|
||||
write_config_to_disk: bool,
|
||||
},
|
||||
StartWebServer(ClientId),
|
||||
ShareCurrentSession(ClientId),
|
||||
StopSharingCurrentSession(ClientId),
|
||||
SendWebClientsForbidden(ClientId),
|
||||
WebServerStarted(String), // String -> base_url
|
||||
FailedToStartWebServer(String),
|
||||
}
|
||||
|
||||
impl From<&ServerInstruction> for ServerContext {
|
||||
|
|
@ -154,6 +165,16 @@ impl From<&ServerInstruction> for ServerContext {
|
|||
ServerContext::FailedToWriteConfigToDisk
|
||||
},
|
||||
ServerInstruction::RebindKeys { .. } => ServerContext::RebindKeys,
|
||||
ServerInstruction::StartWebServer(..) => ServerContext::StartWebServer,
|
||||
ServerInstruction::ShareCurrentSession(..) => ServerContext::ShareCurrentSession,
|
||||
ServerInstruction::StopSharingCurrentSession(..) => {
|
||||
ServerContext::StopSharingCurrentSession
|
||||
},
|
||||
ServerInstruction::WebServerStarted(..) => ServerContext::WebServerStarted,
|
||||
ServerInstruction::FailedToStartWebServer(..) => ServerContext::FailedToStartWebServer,
|
||||
ServerInstruction::SendWebClientsForbidden(..) => {
|
||||
ServerContext::SendWebClientsForbidden
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -303,7 +324,10 @@ pub(crate) struct SessionMetaData {
|
|||
pub layout: Box<Layout>,
|
||||
pub current_input_modes: HashMap<ClientId, InputMode>,
|
||||
pub session_configuration: SessionConfiguration,
|
||||
|
||||
pub web_sharing: WebSharing, // this is a special attribute explicitly set on session
|
||||
// initialization because we don't want it to be overridden by
|
||||
// configuration changes, the only way it can be overwritten is by
|
||||
// explicit plugin action
|
||||
screen_thread: Option<thread::JoinHandle<()>>,
|
||||
pty_thread: Option<thread::JoinHandle<()>>,
|
||||
plugin_thread: Option<thread::JoinHandle<()>>,
|
||||
|
|
@ -453,7 +477,7 @@ macro_rules! send_to_client {
|
|||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct SessionState {
|
||||
clients: HashMap<ClientId, Option<Size>>,
|
||||
clients: HashMap<ClientId, Option<(Size, bool)>>, // bool -> is_web_client
|
||||
pipes: HashMap<String, ClientId>, // String => pipe_id
|
||||
}
|
||||
|
||||
|
|
@ -485,20 +509,31 @@ impl SessionState {
|
|||
self.pipes.retain(|_p_id, c_id| c_id != &client_id);
|
||||
}
|
||||
pub fn set_client_size(&mut self, client_id: ClientId, size: Size) {
|
||||
self.clients.insert(client_id, Some(size));
|
||||
self.clients
|
||||
.entry(client_id)
|
||||
.or_insert_with(Default::default)
|
||||
.as_mut()
|
||||
.map(|(s, _is_web_client)| *s = size);
|
||||
}
|
||||
pub fn set_client_data(&mut self, client_id: ClientId, size: Size, is_web_client: bool) {
|
||||
self.clients.insert(client_id, Some((size, is_web_client)));
|
||||
}
|
||||
pub fn min_client_terminal_size(&self) -> Option<Size> {
|
||||
// None if there are no client sizes
|
||||
let mut rows: Vec<usize> = self
|
||||
.clients
|
||||
.values()
|
||||
.filter_map(|size| size.map(|size| size.rows))
|
||||
.filter_map(|size_and_is_web_client| {
|
||||
size_and_is_web_client.map(|(size, _is_web_client)| size.rows)
|
||||
})
|
||||
.collect();
|
||||
rows.sort_unstable();
|
||||
let mut cols: Vec<usize> = self
|
||||
.clients
|
||||
.values()
|
||||
.filter_map(|size| size.map(|size| size.cols))
|
||||
.filter_map(|size_and_is_web_client| {
|
||||
size_and_is_web_client.map(|(size, _is_web_client)| size.cols)
|
||||
})
|
||||
.collect();
|
||||
cols.sort_unstable();
|
||||
let min_rows = rows.first();
|
||||
|
|
@ -514,6 +549,15 @@ impl SessionState {
|
|||
pub fn client_ids(&self) -> Vec<ClientId> {
|
||||
self.clients.keys().copied().collect()
|
||||
}
|
||||
pub fn web_client_ids(&self) -> Vec<ClientId> {
|
||||
self.clients
|
||||
.iter()
|
||||
.filter_map(|(c_id, size_and_is_web_client)| {
|
||||
size_and_is_web_client
|
||||
.and_then(|(_s, is_web_client)| if is_web_client { Some(*c_id) } else { None })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub fn get_pipe(&self, pipe_name: &str) -> Option<ClientId> {
|
||||
self.pipes.get(pipe_name).copied()
|
||||
}
|
||||
|
|
@ -621,6 +665,7 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
|
|||
layout,
|
||||
plugin_aliases,
|
||||
should_launch_setup_wizard,
|
||||
is_web_client,
|
||||
client_id,
|
||||
) => {
|
||||
let mut session = init_session(
|
||||
|
|
@ -650,10 +695,11 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
|
|||
.insert(client_id, default_input_mode);
|
||||
|
||||
*session_data.write().unwrap() = Some(session);
|
||||
session_state
|
||||
.write()
|
||||
.unwrap()
|
||||
.set_client_size(client_id, client_attributes.size);
|
||||
session_state.write().unwrap().set_client_data(
|
||||
client_id,
|
||||
client_attributes.size,
|
||||
is_web_client,
|
||||
);
|
||||
|
||||
let default_shell = runtime_config_options.default_shell.map(|shell| {
|
||||
TerminalAction::RunCommand(RunCommand {
|
||||
|
|
@ -683,7 +729,7 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
|
|||
tab_name,
|
||||
swap_layouts,
|
||||
should_focus_tab,
|
||||
client_id,
|
||||
(client_id, is_web_client),
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
|
|
@ -746,6 +792,7 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
|
|||
runtime_config_options,
|
||||
tab_position_to_focus,
|
||||
pane_id_to_focus,
|
||||
is_web_client,
|
||||
client_id,
|
||||
) => {
|
||||
let mut rlock = session_data.write().unwrap();
|
||||
|
|
@ -765,10 +812,11 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
|
|||
.current_input_modes
|
||||
.insert(client_id, default_input_mode);
|
||||
|
||||
session_state
|
||||
.write()
|
||||
.unwrap()
|
||||
.set_client_size(client_id, attrs.size);
|
||||
session_state.write().unwrap().set_client_data(
|
||||
client_id,
|
||||
attrs.size,
|
||||
is_web_client,
|
||||
);
|
||||
let min_size = session_state
|
||||
.read()
|
||||
.unwrap()
|
||||
|
|
@ -782,6 +830,7 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
|
|||
.senders
|
||||
.send_to_screen(ScreenInstruction::AddClient(
|
||||
client_id,
|
||||
is_web_client,
|
||||
tab_position_to_focus,
|
||||
pane_id_to_focus,
|
||||
))
|
||||
|
|
@ -949,6 +998,23 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
|
|||
.send_to_plugin(PluginInstruction::RemoveClient(client_id))
|
||||
.unwrap();
|
||||
},
|
||||
ServerInstruction::SendWebClientsForbidden(client_id) => {
|
||||
let _ = os_input.send_to_client(
|
||||
client_id,
|
||||
ServerToClientMsg::Exit(ExitReason::WebClientsForbidden),
|
||||
);
|
||||
remove_client!(client_id, os_input, session_state);
|
||||
if let Some(min_size) = session_state.read().unwrap().min_client_terminal_size() {
|
||||
session_data
|
||||
.write()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.senders
|
||||
.send_to_screen(ScreenInstruction::TerminalResize(min_size))
|
||||
.unwrap();
|
||||
}
|
||||
},
|
||||
ServerInstruction::KillSession => {
|
||||
let client_ids = session_state.read().unwrap().client_ids();
|
||||
for client_id in client_ids {
|
||||
|
|
@ -1246,6 +1312,98 @@ pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
|
|||
}
|
||||
}
|
||||
},
|
||||
ServerInstruction::StartWebServer(client_id) => {
|
||||
if cfg!(feature = "web_server_capability") {
|
||||
send_to_client!(
|
||||
client_id,
|
||||
os_input,
|
||||
ServerToClientMsg::StartWebServer,
|
||||
session_state
|
||||
);
|
||||
} else {
|
||||
// TODO: test this
|
||||
log::error!("Cannot start web server: this instance of Zellij was compiled without web_server_capability");
|
||||
}
|
||||
},
|
||||
ServerInstruction::ShareCurrentSession(_client_id) => {
|
||||
if cfg!(feature = "web_server_capability") {
|
||||
let successfully_changed = session_data
|
||||
.write()
|
||||
.ok()
|
||||
.and_then(|mut s| s.as_mut().map(|s| s.web_sharing.set_sharing()))
|
||||
.unwrap_or(false);
|
||||
if successfully_changed {
|
||||
session_data
|
||||
.write()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.senders
|
||||
.send_to_screen(ScreenInstruction::SessionSharingStatusChange(true))
|
||||
.unwrap();
|
||||
}
|
||||
} else {
|
||||
log::error!("Cannot share session: this instance of Zellij was compiled without web_server_capability");
|
||||
}
|
||||
},
|
||||
ServerInstruction::StopSharingCurrentSession(_client_id) => {
|
||||
if cfg!(feature = "web_server_capability") {
|
||||
let successfully_changed = session_data
|
||||
.write()
|
||||
.ok()
|
||||
.and_then(|mut s| s.as_mut().map(|s| s.web_sharing.set_not_sharing()))
|
||||
.unwrap_or(false);
|
||||
if successfully_changed {
|
||||
// disconnect existing web clients
|
||||
let web_client_ids: Vec<ClientId> = session_state
|
||||
.read()
|
||||
.unwrap()
|
||||
.web_client_ids()
|
||||
.iter()
|
||||
.copied()
|
||||
.collect();
|
||||
for client_id in web_client_ids {
|
||||
let _ = os_input.send_to_client(
|
||||
client_id,
|
||||
ServerToClientMsg::Exit(ExitReason::WebClientsForbidden),
|
||||
);
|
||||
remove_client!(client_id, os_input, session_state);
|
||||
}
|
||||
|
||||
session_data
|
||||
.write()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.senders
|
||||
.send_to_screen(ScreenInstruction::SessionSharingStatusChange(false))
|
||||
.unwrap();
|
||||
}
|
||||
} else {
|
||||
// TODO: test this
|
||||
log::error!("Cannot start web server: this instance of Zellij was compiled without web_server_capability");
|
||||
}
|
||||
},
|
||||
ServerInstruction::WebServerStarted(base_url) => {
|
||||
session_data
|
||||
.write()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.senders
|
||||
.send_to_plugin(PluginInstruction::WebServerStarted(base_url))
|
||||
.unwrap();
|
||||
},
|
||||
ServerInstruction::FailedToStartWebServer(error) => {
|
||||
session_data
|
||||
.write()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.senders
|
||||
.send_to_plugin(PluginInstruction::FailedToStartWebServer(error))
|
||||
.unwrap();
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1312,6 +1470,13 @@ fn init_session(
|
|||
|
||||
let serialization_interval = config_options.serialization_interval;
|
||||
let disable_session_metadata = config_options.disable_session_metadata.unwrap_or(false);
|
||||
let web_server_ip = config_options
|
||||
.web_server_ip
|
||||
.unwrap_or_else(|| IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
|
||||
let web_server_port = config_options.web_server_port.unwrap_or_else(|| 8082);
|
||||
let has_certificate =
|
||||
config_options.web_server_cert.is_some() && config_options.web_server_key.is_some();
|
||||
let enforce_https_for_localhost = config_options.enforce_https_for_localhost.unwrap_or(false);
|
||||
|
||||
let default_shell = config_options.default_shell.clone().map(|command| {
|
||||
TerminalAction::RunCommand(RunCommand {
|
||||
|
|
@ -1459,11 +1624,18 @@ fn init_session(
|
|||
None,
|
||||
Some(os_input.clone()),
|
||||
);
|
||||
let web_server_base_url = web_server_base_url(
|
||||
web_server_ip,
|
||||
web_server_port,
|
||||
has_certificate,
|
||||
enforce_https_for_localhost,
|
||||
);
|
||||
move || {
|
||||
background_jobs_main(
|
||||
background_jobs_bus,
|
||||
serialization_interval,
|
||||
disable_session_metadata,
|
||||
web_server_base_url,
|
||||
)
|
||||
.fatal()
|
||||
}
|
||||
|
|
@ -1491,6 +1663,10 @@ fn init_session(
|
|||
plugin_thread: Some(plugin_thread),
|
||||
pty_writer_thread: Some(pty_writer_thread),
|
||||
background_jobs_thread: Some(background_jobs_thread),
|
||||
#[cfg(feature = "web_server_capability")]
|
||||
web_sharing: config.options.web_sharing.unwrap_or(WebSharing::Off),
|
||||
#[cfg(not(feature = "web_server_capability"))]
|
||||
web_sharing: WebSharing::Disabled,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ pub struct FloatingPanes {
|
|||
display_area: Rc<RefCell<Size>>,
|
||||
viewport: Rc<RefCell<Viewport>>,
|
||||
connected_clients: Rc<RefCell<HashSet<ClientId>>>,
|
||||
connected_clients_in_app: Rc<RefCell<HashSet<ClientId>>>,
|
||||
connected_clients_in_app: Rc<RefCell<HashMap<ClientId, bool>>>, // bool -> is_web_client
|
||||
mode_info: Rc<RefCell<HashMap<ClientId, ModeInfo>>>,
|
||||
character_cell_size: Rc<RefCell<Option<SizeInPixels>>>,
|
||||
default_mode_info: ModeInfo,
|
||||
|
|
@ -58,7 +58,7 @@ impl FloatingPanes {
|
|||
display_area: Rc<RefCell<Size>>,
|
||||
viewport: Rc<RefCell<Viewport>>,
|
||||
connected_clients: Rc<RefCell<HashSet<ClientId>>>,
|
||||
connected_clients_in_app: Rc<RefCell<HashSet<ClientId>>>,
|
||||
connected_clients_in_app: Rc<RefCell<HashMap<ClientId, bool>>>, // bool -> is_web_client
|
||||
mode_info: Rc<RefCell<HashMap<ClientId, ModeInfo>>>,
|
||||
character_cell_size: Rc<RefCell<Option<SizeInPixels>>>,
|
||||
session_is_mirrored: bool,
|
||||
|
|
|
|||
|
|
@ -1413,14 +1413,14 @@ impl Grid {
|
|||
self.move_cursor_forward_until_edge(character_width);
|
||||
}
|
||||
pub fn get_character_under_cursor(&self) -> Option<TerminalCharacter> {
|
||||
let absolute_x_in_line = self.get_absolute_character_index(self.cursor.x, self.cursor.y);
|
||||
let absolute_x_in_line = self.get_absolute_character_index(self.cursor.x, self.cursor.y)?;
|
||||
self.viewport
|
||||
.get(self.cursor.y)
|
||||
.and_then(|current_line| current_line.columns.get(absolute_x_in_line))
|
||||
.cloned()
|
||||
}
|
||||
pub fn get_absolute_character_index(&self, x: usize, y: usize) -> usize {
|
||||
self.viewport.get(y).unwrap().absolute_character_index(x)
|
||||
pub fn get_absolute_character_index(&self, x: usize, y: usize) -> Option<usize> {
|
||||
Some(self.viewport.get(y)?.absolute_character_index(x))
|
||||
}
|
||||
pub fn move_cursor_forward_until_edge(&mut self, count: usize) {
|
||||
let count_to_move = std::cmp::min(count, self.width.saturating_sub(self.cursor.x));
|
||||
|
|
@ -2423,6 +2423,9 @@ impl Grid {
|
|||
pub fn update_arrow_fonts(&mut self, should_support_arrow_fonts: bool) {
|
||||
self.arrow_fonts = should_support_arrow_fonts;
|
||||
}
|
||||
pub fn has_selection(&self) -> bool {
|
||||
!self.selection.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Perform for Grid {
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ pub(crate) struct PluginPane {
|
|||
styled_underlines: bool,
|
||||
should_be_suppressed: bool,
|
||||
text_being_pasted: Option<Vec<u8>>,
|
||||
supports_mouse_selection: bool,
|
||||
}
|
||||
|
||||
impl PluginPane {
|
||||
|
|
@ -157,6 +158,7 @@ impl PluginPane {
|
|||
styled_underlines,
|
||||
should_be_suppressed: false,
|
||||
text_being_pasted: None,
|
||||
supports_mouse_selection: false,
|
||||
};
|
||||
for client_id in currently_connected_clients {
|
||||
plugin.handle_plugin_bytes(client_id, initial_loading_message.as_bytes().to_vec());
|
||||
|
|
@ -255,7 +257,14 @@ impl Pane for PluginPane {
|
|||
_raw_input_bytes_are_kitty: bool,
|
||||
client_id: Option<ClientId>,
|
||||
) -> Option<AdjustedInput> {
|
||||
if let Some(requesting_permissions) = &self.requesting_permissions {
|
||||
if client_id
|
||||
.and_then(|c| self.grids.get(&c))
|
||||
.map(|g| g.has_selection())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
self.reset_selection(client_id);
|
||||
None
|
||||
} else if let Some(requesting_permissions) = &self.requesting_permissions {
|
||||
let permissions = requesting_permissions.permissions.clone();
|
||||
if let Some(key_with_modifier) = key_with_modifier {
|
||||
match key_with_modifier.bare_key {
|
||||
|
|
@ -537,6 +546,12 @@ impl Pane for PluginPane {
|
|||
self.resize_grids();
|
||||
self.set_should_render(true);
|
||||
}
|
||||
fn dump_screen(&self, full: bool, client_id: Option<ClientId>) -> String {
|
||||
client_id
|
||||
.and_then(|c| self.grids.get(&c))
|
||||
.map(|g| g.dump_screen(full))
|
||||
.unwrap_or_else(|| "".to_owned())
|
||||
}
|
||||
fn scroll_up(&mut self, count: usize, client_id: ClientId) {
|
||||
self.send_plugin_instructions
|
||||
.send(PluginInstruction::Update(vec![(
|
||||
|
|
@ -562,6 +577,12 @@ impl Pane for PluginPane {
|
|||
// noop
|
||||
}
|
||||
fn start_selection(&mut self, start: &Position, client_id: ClientId) {
|
||||
if self.supports_mouse_selection {
|
||||
if let Some(grid) = self.grids.get_mut(&client_id) {
|
||||
grid.start_selection(start);
|
||||
self.set_should_render(true);
|
||||
}
|
||||
} else {
|
||||
self.send_plugin_instructions
|
||||
.send(PluginInstruction::Update(vec![(
|
||||
Some(self.pid),
|
||||
|
|
@ -570,7 +591,14 @@ impl Pane for PluginPane {
|
|||
)]))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
fn update_selection(&mut self, position: &Position, client_id: ClientId) {
|
||||
if self.supports_mouse_selection {
|
||||
if let Some(grid) = self.grids.get_mut(&client_id) {
|
||||
grid.update_selection(position);
|
||||
self.set_should_render(true); // TODO: no??
|
||||
}
|
||||
} else {
|
||||
self.send_plugin_instructions
|
||||
.send(PluginInstruction::Update(vec![(
|
||||
Some(self.pid),
|
||||
|
|
@ -579,7 +607,13 @@ impl Pane for PluginPane {
|
|||
)]))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
fn end_selection(&mut self, end: &Position, client_id: ClientId) {
|
||||
if self.supports_mouse_selection {
|
||||
if let Some(grid) = self.grids.get_mut(&client_id) {
|
||||
grid.end_selection(end);
|
||||
}
|
||||
} else {
|
||||
self.send_plugin_instructions
|
||||
.send(PluginInstruction::Update(vec![(
|
||||
Some(self.pid),
|
||||
|
|
@ -588,6 +622,24 @@ impl Pane for PluginPane {
|
|||
)]))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
fn reset_selection(&mut self, client_id: Option<ClientId>) {
|
||||
if let Some(grid) = client_id.and_then(|c| self.grids.get_mut(&c)) {
|
||||
grid.reset_selection();
|
||||
self.set_should_render(true);
|
||||
}
|
||||
}
|
||||
fn supports_mouse_selection(&self) -> bool {
|
||||
self.supports_mouse_selection
|
||||
}
|
||||
|
||||
fn get_selected_text(&self, client_id: ClientId) -> Option<String> {
|
||||
if let Some(grid) = self.grids.get(&client_id) {
|
||||
grid.get_selected_text()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn is_scrolled(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
|
@ -790,6 +842,15 @@ impl Pane for PluginPane {
|
|||
}
|
||||
None
|
||||
}
|
||||
fn set_mouse_selection_support(&mut self, selection_support: bool) {
|
||||
self.supports_mouse_selection = selection_support;
|
||||
if !selection_support {
|
||||
let client_ids_with_grids: Vec<ClientId> = self.grids.keys().copied().collect();
|
||||
for client_id in client_ids_with_grids {
|
||||
self.reset_selection(Some(client_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginPane {
|
||||
|
|
|
|||
|
|
@ -213,14 +213,14 @@ impl Pane for TerminalPane {
|
|||
key_with_modifier: &Option<KeyWithModifier>,
|
||||
raw_input_bytes: Vec<u8>,
|
||||
raw_input_bytes_are_kitty: bool,
|
||||
_client_id: Option<ClientId>,
|
||||
client_id: Option<ClientId>,
|
||||
) -> Option<AdjustedInput> {
|
||||
// there are some cases in which the terminal state means that input sent to it
|
||||
// needs to be adjusted.
|
||||
// here we match against those cases - if need be, we adjust the input and if not
|
||||
// we send back the original input
|
||||
|
||||
self.reset_selection();
|
||||
self.reset_selection(client_id);
|
||||
if !self.grid.bracketed_paste_mode {
|
||||
// Zellij itself operates in bracketed paste mode, so the terminal sends these
|
||||
// instructions (bracketed paste start and bracketed paste end respectively)
|
||||
|
|
@ -522,7 +522,7 @@ impl Pane for TerminalPane {
|
|||
self.geom.y -= count;
|
||||
self.reflow_lines();
|
||||
}
|
||||
fn dump_screen(&self, full: bool) -> String {
|
||||
fn dump_screen(&self, full: bool, _client_id: Option<ClientId>) -> String {
|
||||
self.grid.dump_screen(full)
|
||||
}
|
||||
fn clear_screen(&mut self) {
|
||||
|
|
@ -595,11 +595,11 @@ impl Pane for TerminalPane {
|
|||
self.set_should_render(true);
|
||||
}
|
||||
|
||||
fn reset_selection(&mut self) {
|
||||
fn reset_selection(&mut self, _client_id: Option<ClientId>) {
|
||||
self.grid.reset_selection();
|
||||
}
|
||||
|
||||
fn get_selected_text(&self) -> Option<String> {
|
||||
fn get_selected_text(&self, _client_id: ClientId) -> Option<String> {
|
||||
self.grid.get_selected_text()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ pub struct TiledPanes {
|
|||
display_area: Rc<RefCell<Size>>,
|
||||
viewport: Rc<RefCell<Viewport>>,
|
||||
connected_clients: Rc<RefCell<HashSet<ClientId>>>,
|
||||
connected_clients_in_app: Rc<RefCell<HashSet<ClientId>>>,
|
||||
connected_clients_in_app: Rc<RefCell<HashMap<ClientId, bool>>>, // bool -> is_web_client
|
||||
mode_info: Rc<RefCell<HashMap<ClientId, ModeInfo>>>,
|
||||
character_cell_size: Rc<RefCell<Option<SizeInPixels>>>,
|
||||
stacked_resize: Rc<RefCell<bool>>,
|
||||
|
|
@ -82,7 +82,7 @@ impl TiledPanes {
|
|||
display_area: Rc<RefCell<Size>>,
|
||||
viewport: Rc<RefCell<Viewport>>,
|
||||
connected_clients: Rc<RefCell<HashSet<ClientId>>>,
|
||||
connected_clients_in_app: Rc<RefCell<HashSet<ClientId>>>,
|
||||
connected_clients_in_app: Rc<RefCell<HashMap<ClientId, bool>>>, // bool -> is_web_client
|
||||
mode_info: Rc<RefCell<HashMap<ClientId, ModeInfo>>>,
|
||||
character_cell_size: Rc<RefCell<Option<SizeInPixels>>>,
|
||||
stacked_resize: Rc<RefCell<bool>>,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ use zellij_utils::{
|
|||
data::{
|
||||
ClientInfo, Event, EventType, FloatingPaneCoordinates, InputMode, MessageToPlugin,
|
||||
PermissionStatus, PermissionType, PipeMessage, PipeSource, PluginCapabilities,
|
||||
WebServerStatus,
|
||||
},
|
||||
errors::{prelude::*, ContextType, PluginContext},
|
||||
input::{
|
||||
|
|
@ -79,7 +80,7 @@ pub enum PluginInstruction {
|
|||
Vec<FloatingPaneLayout>,
|
||||
usize, // tab_index
|
||||
bool, // should change focus to new tab
|
||||
ClientId,
|
||||
(ClientId, bool), // bool -> is_web_client
|
||||
),
|
||||
ApplyCachedEvents {
|
||||
plugin_ids: Vec<PluginId>,
|
||||
|
|
@ -162,6 +163,8 @@ pub enum PluginInstruction {
|
|||
WatchFilesystem,
|
||||
ListClientsToPlugin(SessionLayoutMetadata, PluginId, ClientId),
|
||||
ChangePluginHostDir(PathBuf, PluginId, ClientId),
|
||||
WebServerStarted(String), // String -> the base url of the web server
|
||||
FailedToStartWebServer(String),
|
||||
Exit,
|
||||
}
|
||||
|
||||
|
|
@ -209,6 +212,8 @@ impl From<&PluginInstruction> for PluginContext {
|
|||
},
|
||||
PluginInstruction::ListClientsToPlugin(..) => PluginContext::ListClientsToPlugin,
|
||||
PluginInstruction::ChangePluginHostDir(..) => PluginContext::ChangePluginHostDir,
|
||||
PluginInstruction::WebServerStarted(..) => PluginContext::WebServerStarted,
|
||||
PluginInstruction::FailedToStartWebServer(..) => PluginContext::FailedToStartWebServer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -424,7 +429,7 @@ pub(crate) fn plugin_thread_main(
|
|||
mut floating_panes_layout,
|
||||
tab_index,
|
||||
should_change_focus_to_new_tab,
|
||||
client_id,
|
||||
(client_id, is_web_client),
|
||||
) => {
|
||||
// prefer connected clients so as to avoid opening plugins in the background for
|
||||
// CLI clients unless no-one else is connected
|
||||
|
|
@ -496,7 +501,7 @@ pub(crate) fn plugin_thread_main(
|
|||
tab_index,
|
||||
plugin_ids,
|
||||
should_change_focus_to_new_tab,
|
||||
client_id,
|
||||
(client_id, is_web_client),
|
||||
)));
|
||||
},
|
||||
PluginInstruction::ApplyCachedEvents {
|
||||
|
|
@ -915,6 +920,22 @@ pub(crate) fn plugin_thread_main(
|
|||
.change_plugin_host_dir(new_host_folder, plugin_id, client_id)
|
||||
.non_fatal();
|
||||
},
|
||||
PluginInstruction::WebServerStarted(base_url) => {
|
||||
let updates = vec![(
|
||||
None,
|
||||
None,
|
||||
Event::WebServerStatus(WebServerStatus::Online(base_url)),
|
||||
)];
|
||||
wasm_bridge
|
||||
.update_plugins(updates, shutdown_send.clone())
|
||||
.non_fatal();
|
||||
},
|
||||
PluginInstruction::FailedToStartWebServer(error) => {
|
||||
let updates = vec![(None, None, Event::FailedToStartWebServer(error))];
|
||||
wasm_bridge
|
||||
.update_plugins(updates, shutdown_send.clone())
|
||||
.non_fatal();
|
||||
},
|
||||
PluginInstruction::Exit => {
|
||||
break;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
---
|
||||
source: zellij-server/src/plugins/./unit/plugin_tests.rs
|
||||
assertion_line: 1373
|
||||
expression: "format!(\"{:#?}\", new_tab_event)"
|
||||
---
|
||||
Some(
|
||||
|
|
@ -15,6 +14,9 @@ Some(
|
|||
[],
|
||||
),
|
||||
true,
|
||||
(
|
||||
1,
|
||||
false,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
---
|
||||
source: zellij-server/src/plugins/./unit/plugin_tests.rs
|
||||
assertion_line: 1301
|
||||
expression: "format!(\"{:#?}\", second_new_tab_event)"
|
||||
---
|
||||
Some(
|
||||
|
|
@ -65,6 +64,9 @@ Some(
|
|||
[],
|
||||
),
|
||||
false,
|
||||
(
|
||||
1,
|
||||
false,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
---
|
||||
source: zellij-server/src/plugins/./unit/plugin_tests.rs
|
||||
assertion_line: 1300
|
||||
expression: "format!(\"{:#?}\", first_new_tab_event)"
|
||||
---
|
||||
Some(
|
||||
|
|
@ -65,6 +64,9 @@ Some(
|
|||
[],
|
||||
),
|
||||
true,
|
||||
(
|
||||
1,
|
||||
false,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -317,7 +317,13 @@ Some(
|
|||
),
|
||||
editor: None,
|
||||
shell: None,
|
||||
web_clients_allowed: None,
|
||||
web_sharing: None,
|
||||
currently_marking_pane_group: None,
|
||||
is_web_client: None,
|
||||
web_server_ip: None,
|
||||
web_server_port: None,
|
||||
web_server_capability: None,
|
||||
},
|
||||
1,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1588,6 +1588,7 @@ fn check_event_permission(
|
|||
| Event::FailedToWriteConfigToDisk(..)
|
||||
| Event::CommandPaneReRun(..)
|
||||
| Event::InputReceived => PermissionType::ReadApplicationState,
|
||||
Event::WebServerStatus(..) => PermissionType::StartWebServer,
|
||||
_ => return (PermissionStatus::Granted, None),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ use zellij_utils::data::{
|
|||
};
|
||||
use zellij_utils::input::permission::PermissionCache;
|
||||
use zellij_utils::ipc::{ClientToServerMsg, IpcSenderWithContext};
|
||||
#[cfg(feature = "web_server_capability")]
|
||||
use zellij_utils::web_authentication_tokens::{
|
||||
create_token, list_tokens, rename_token, revoke_all_tokens, revoke_token,
|
||||
};
|
||||
#[cfg(feature = "web_server_capability")]
|
||||
use zellij_utils::web_server_commands::shutdown_all_webserver_instances;
|
||||
|
||||
use crate::{panes::PaneId, screen::ScreenInstruction};
|
||||
|
||||
|
|
@ -47,6 +53,12 @@ use zellij_utils::{
|
|||
},
|
||||
};
|
||||
|
||||
#[cfg(feature = "web_server_capability")]
|
||||
use zellij_utils::plugin_api::plugin_command::{
|
||||
CreateTokenResponse, ListTokensResponse, RenameWebTokenResponse, RevokeAllWebTokensResponse,
|
||||
RevokeTokenResponse,
|
||||
};
|
||||
|
||||
macro_rules! apply_action {
|
||||
($action:ident, $error_message:ident, $env: ident) => {
|
||||
if let Err(e) = route_action(
|
||||
|
|
@ -456,6 +468,29 @@ fn host_run_plugin_command(mut caller: Caller<'_, PluginEnv>) {
|
|||
PluginCommand::EmbedMultiplePanes(pane_ids) => {
|
||||
embed_multiple_panes(env, pane_ids.into_iter().map(|p| p.into()).collect())
|
||||
},
|
||||
PluginCommand::StartWebServer => start_web_server(env),
|
||||
PluginCommand::StopWebServer => stop_web_server(env),
|
||||
PluginCommand::QueryWebServerStatus => query_web_server_status(env),
|
||||
PluginCommand::ShareCurrentSession => share_current_session(env),
|
||||
PluginCommand::StopSharingCurrentSession => stop_sharing_current_session(env),
|
||||
PluginCommand::SetSelfMouseSelectionSupport(selection_support) => {
|
||||
set_self_mouse_selection_support(env, selection_support);
|
||||
},
|
||||
PluginCommand::GenerateWebLoginToken(token_label) => {
|
||||
generate_web_login_token(env, token_label);
|
||||
},
|
||||
PluginCommand::RevokeWebLoginToken(label) => {
|
||||
revoke_web_login_token(env, label);
|
||||
},
|
||||
PluginCommand::ListWebLoginTokens => {
|
||||
list_web_login_tokens(env);
|
||||
},
|
||||
PluginCommand::RevokeAllWebLoginTokens => {
|
||||
revoke_all_web_login_tokens(env);
|
||||
},
|
||||
PluginCommand::RenameWebLoginToken(old_name, new_name) => {
|
||||
rename_web_login_token(env, old_name, new_name);
|
||||
},
|
||||
PluginCommand::InterceptKeyPresses => intercept_key_presses(&mut env),
|
||||
PluginCommand::ClearKeyPressesIntercepts => {
|
||||
clear_key_presses_intercepts(&mut env)
|
||||
|
|
@ -2179,6 +2214,37 @@ fn load_new_plugin(
|
|||
}
|
||||
}
|
||||
|
||||
fn start_web_server(env: &PluginEnv) {
|
||||
let _ = env
|
||||
.senders
|
||||
.send_to_server(ServerInstruction::StartWebServer(env.client_id));
|
||||
}
|
||||
|
||||
fn stop_web_server(_env: &PluginEnv) {
|
||||
#[cfg(feature = "web_server_capability")]
|
||||
let _ = shutdown_all_webserver_instances();
|
||||
#[cfg(not(feature = "web_server_capability"))]
|
||||
log::error!("This instance of Zellij was compiled without web server capabilities");
|
||||
}
|
||||
|
||||
fn query_web_server_status(env: &PluginEnv) {
|
||||
let _ = env
|
||||
.senders
|
||||
.send_to_background_jobs(BackgroundJob::QueryZellijWebServerStatus);
|
||||
}
|
||||
|
||||
fn share_current_session(env: &PluginEnv) {
|
||||
let _ = env
|
||||
.senders
|
||||
.send_to_server(ServerInstruction::ShareCurrentSession(env.client_id));
|
||||
}
|
||||
|
||||
fn stop_sharing_current_session(env: &PluginEnv) {
|
||||
let _ = env
|
||||
.senders
|
||||
.send_to_server(ServerInstruction::StopSharingCurrentSession(env.client_id));
|
||||
}
|
||||
|
||||
fn group_and_ungroup_panes(
|
||||
env: &PluginEnv,
|
||||
panes_to_group: Vec<PaneId>,
|
||||
|
|
@ -2238,6 +2304,140 @@ fn embed_multiple_panes(env: &PluginEnv, pane_ids: Vec<PaneId>) {
|
|||
));
|
||||
}
|
||||
|
||||
#[cfg(feature = "web_server_capability")]
|
||||
fn generate_web_login_token(env: &PluginEnv, token_label: Option<String>) {
|
||||
let serialized = match create_token(token_label) {
|
||||
Ok((token, token_label)) => CreateTokenResponse {
|
||||
token: Some(token),
|
||||
token_label: Some(token_label),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => CreateTokenResponse {
|
||||
token: None,
|
||||
token_label: None,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
};
|
||||
let _ = wasi_write_object(env, &serialized.encode_to_vec());
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "web_server_capability"))]
|
||||
fn generate_web_login_token(env: &PluginEnv, _token_label: Option<String>) {
|
||||
log::error!("This version of Zellij was compiled without the web server capabilities!");
|
||||
let empty_vec: Vec<&str> = vec![];
|
||||
let _ = wasi_write_object(env, &empty_vec);
|
||||
}
|
||||
|
||||
#[cfg(feature = "web_server_capability")]
|
||||
fn revoke_web_login_token(env: &PluginEnv, token_label: String) {
|
||||
let serialized = match revoke_token(&token_label) {
|
||||
Ok(true) => RevokeTokenResponse {
|
||||
successfully_revoked: true,
|
||||
error: None,
|
||||
},
|
||||
Ok(false) => RevokeTokenResponse {
|
||||
successfully_revoked: false,
|
||||
error: Some(format!("Token with label {} not found", token_label)),
|
||||
},
|
||||
Err(e) => RevokeTokenResponse {
|
||||
successfully_revoked: false,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
};
|
||||
let _ = wasi_write_object(env, &serialized.encode_to_vec());
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "web_server_capability"))]
|
||||
fn revoke_web_login_token(env: &PluginEnv, _token_label: String) {
|
||||
log::error!("This version of Zellij was compiled without the web server capabilities!");
|
||||
let empty_vec: Vec<&str> = vec![];
|
||||
let _ = wasi_write_object(env, &empty_vec);
|
||||
}
|
||||
|
||||
#[cfg(feature = "web_server_capability")]
|
||||
fn revoke_all_web_login_tokens(env: &PluginEnv) {
|
||||
let serialized = match revoke_all_tokens() {
|
||||
Ok(_) => RevokeAllWebTokensResponse {
|
||||
successfully_revoked: true,
|
||||
error: None,
|
||||
},
|
||||
Err(e) => RevokeAllWebTokensResponse {
|
||||
successfully_revoked: false,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
};
|
||||
let _ = wasi_write_object(env, &serialized.encode_to_vec());
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "web_server_capability"))]
|
||||
fn revoke_all_web_login_tokens(env: &PluginEnv) {
|
||||
log::error!("This version of Zellij was compiled without the web server capabilities!");
|
||||
let empty_vec: Vec<&str> = vec![];
|
||||
let _ = wasi_write_object(env, &empty_vec);
|
||||
}
|
||||
|
||||
#[cfg(feature = "web_server_capability")]
|
||||
fn rename_web_login_token(env: &PluginEnv, old_name: String, new_name: String) {
|
||||
let serialized = match rename_token(&old_name, &new_name) {
|
||||
Ok(_) => RenameWebTokenResponse {
|
||||
successfully_renamed: true,
|
||||
error: None,
|
||||
},
|
||||
Err(e) => RenameWebTokenResponse {
|
||||
successfully_renamed: false,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
};
|
||||
let _ = wasi_write_object(env, &serialized.encode_to_vec());
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "web_server_capability"))]
|
||||
fn rename_web_login_token(env: &PluginEnv, _old_name: String, _new_name: String) {
|
||||
log::error!("This version of Zellij was compiled without the web server capabilities!");
|
||||
let empty_vec: Vec<&str> = vec![];
|
||||
let _ = wasi_write_object(env, &empty_vec);
|
||||
}
|
||||
|
||||
#[cfg(feature = "web_server_capability")]
|
||||
fn list_web_login_tokens(env: &PluginEnv) {
|
||||
let serialized = match list_tokens() {
|
||||
Ok(token_list) => ListTokensResponse {
|
||||
tokens: token_list.iter().map(|t| t.name.clone()).collect(),
|
||||
creation_times: token_list.iter().map(|t| t.created_at.clone()).collect(),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => ListTokensResponse {
|
||||
tokens: vec![],
|
||||
creation_times: vec![],
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
};
|
||||
let _ = wasi_write_object(env, &serialized.encode_to_vec());
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "web_server_capability"))]
|
||||
fn list_web_login_tokens(env: &PluginEnv) {
|
||||
log::error!("This version of Zellij was compiled without the web server capabilities!");
|
||||
let empty_vec: Vec<&str> = vec![];
|
||||
let _ = wasi_write_object(env, &empty_vec);
|
||||
}
|
||||
|
||||
fn set_self_mouse_selection_support(env: &PluginEnv, selection_support: bool) {
|
||||
env.senders
|
||||
.send_to_screen(ScreenInstruction::SetMouseSelectionSupport(
|
||||
PaneId::Plugin(env.plugin_id),
|
||||
selection_support,
|
||||
))
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to set plugin {} selectable from plugin {}",
|
||||
selection_support,
|
||||
env.name()
|
||||
)
|
||||
})
|
||||
.non_fatal();
|
||||
}
|
||||
|
||||
fn intercept_key_presses(env: &mut PluginEnv) {
|
||||
env.intercepting_key_presses = true;
|
||||
let _ = env
|
||||
|
|
@ -2433,6 +2633,16 @@ fn check_command_permission(
|
|||
PermissionType::Reconfigure
|
||||
},
|
||||
PluginCommand::ChangeHostFolder(..) => PermissionType::FullHdAccess,
|
||||
PluginCommand::ShareCurrentSession
|
||||
| PluginCommand::StopSharingCurrentSession
|
||||
| PluginCommand::StopWebServer
|
||||
| PluginCommand::QueryWebServerStatus
|
||||
| PluginCommand::GenerateWebLoginToken(..)
|
||||
| PluginCommand::RevokeWebLoginToken(..)
|
||||
| PluginCommand::RevokeAllWebLoginTokens
|
||||
| PluginCommand::RenameWebLoginToken(..)
|
||||
| PluginCommand::ListWebLoginTokens
|
||||
| PluginCommand::StartWebServer => PermissionType::StartWebServer,
|
||||
PluginCommand::InterceptKeyPresses | PluginCommand::ClearKeyPressesIntercepts => {
|
||||
PermissionType::InterceptInput
|
||||
},
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ pub enum PtyInstruction {
|
|||
usize, // tab_index
|
||||
HashMap<RunPluginOrAlias, Vec<u32>>, // plugin_ids
|
||||
bool, // should change focus to new tab
|
||||
ClientId,
|
||||
(ClientId, bool), // bool -> is_web_client
|
||||
), // the String is the tab name
|
||||
ClosePane(PaneId),
|
||||
CloseTab(Vec<PaneId>),
|
||||
|
|
@ -547,9 +547,9 @@ pub(crate) fn pty_thread_main(mut pty: Pty, layout: Box<Layout>) -> Result<()> {
|
|||
tab_index,
|
||||
plugin_ids,
|
||||
should_change_focus_to_new_tab,
|
||||
client_id,
|
||||
client_id_and_is_web_client,
|
||||
) => {
|
||||
let err_context = || format!("failed to open new tab for client {}", client_id);
|
||||
let err_context = || "failed to open new tab";
|
||||
|
||||
let floating_panes_layout = if floating_panes_layout.is_empty() {
|
||||
layout.new_tab().1
|
||||
|
|
@ -564,7 +564,7 @@ pub(crate) fn pty_thread_main(mut pty: Pty, layout: Box<Layout>) -> Result<()> {
|
|||
plugin_ids,
|
||||
tab_index,
|
||||
should_change_focus_to_new_tab,
|
||||
client_id,
|
||||
client_id_and_is_web_client,
|
||||
)
|
||||
.with_context(err_context)?;
|
||||
},
|
||||
|
|
@ -1034,12 +1034,13 @@ impl Pty {
|
|||
plugin_ids: HashMap<RunPluginOrAlias, Vec<u32>>,
|
||||
tab_index: usize,
|
||||
should_change_focus_to_new_tab: bool,
|
||||
client_id: ClientId,
|
||||
client_id_and_is_web_client: (ClientId, bool),
|
||||
) -> Result<()> {
|
||||
let err_context = || format!("failed to spawn terminals for layout for client {client_id}");
|
||||
let err_context = || format!("failed to spawn terminals for layout for");
|
||||
|
||||
let mut default_shell =
|
||||
default_shell.unwrap_or_else(|| self.get_default_terminal(cwd, None));
|
||||
let (client_id, is_web_client) = client_id_and_is_web_client;
|
||||
self.fill_cwd(&mut default_shell, client_id);
|
||||
let extracted_run_instructions = layout.extract_run_instructions();
|
||||
let extracted_floating_run_instructions = floating_panes_layout
|
||||
|
|
@ -1099,7 +1100,7 @@ impl Pty {
|
|||
plugin_ids,
|
||||
tab_index,
|
||||
should_change_focus_to_new_tab,
|
||||
client_id,
|
||||
(client_id, is_web_client),
|
||||
))
|
||||
.with_context(err_context)?;
|
||||
let mut terminals_to_start = vec![];
|
||||
|
|
|
|||
|
|
@ -508,6 +508,7 @@ pub(crate) fn route_action(
|
|||
swap_tiled_layouts.unwrap_or_else(|| default_layout.swap_tiled_layouts.clone());
|
||||
let swap_floating_layouts = swap_floating_layouts
|
||||
.unwrap_or_else(|| default_layout.swap_floating_layouts.clone());
|
||||
let is_web_client = false; // actions cannot be initiated directly from the web
|
||||
senders
|
||||
.send_to_screen(ScreenInstruction::NewTab(
|
||||
None,
|
||||
|
|
@ -517,7 +518,7 @@ pub(crate) fn route_action(
|
|||
tab_name,
|
||||
(swap_tiled_layouts, swap_floating_layouts),
|
||||
should_change_focus_to_new_tab,
|
||||
client_id,
|
||||
(client_id, is_web_client),
|
||||
))
|
||||
.with_context(err_context)?;
|
||||
},
|
||||
|
|
@ -1146,6 +1147,7 @@ pub(crate) fn route_thread_main(
|
|||
layout,
|
||||
plugin_aliases,
|
||||
should_launch_setup_wizard,
|
||||
is_web_client,
|
||||
) => {
|
||||
let new_client_instruction = ServerInstruction::NewClient(
|
||||
client_attributes,
|
||||
|
|
@ -1155,6 +1157,7 @@ pub(crate) fn route_thread_main(
|
|||
layout,
|
||||
plugin_aliases,
|
||||
should_launch_setup_wizard,
|
||||
is_web_client,
|
||||
client_id,
|
||||
);
|
||||
to_server
|
||||
|
|
@ -1167,18 +1170,37 @@ pub(crate) fn route_thread_main(
|
|||
runtime_config_options,
|
||||
tab_position_to_focus,
|
||||
pane_id_to_focus,
|
||||
is_web_client,
|
||||
) => {
|
||||
let allow_web_connections = rlocked_sessions
|
||||
.as_ref()
|
||||
.map(|rlocked_sessions| {
|
||||
rlocked_sessions.web_sharing.web_clients_allowed()
|
||||
})
|
||||
.unwrap_or(false);
|
||||
let should_allow_connection = !is_web_client || allow_web_connections;
|
||||
if should_allow_connection {
|
||||
let attach_client_instruction = ServerInstruction::AttachClient(
|
||||
client_attributes,
|
||||
config,
|
||||
runtime_config_options,
|
||||
tab_position_to_focus,
|
||||
pane_id_to_focus,
|
||||
is_web_client,
|
||||
client_id,
|
||||
);
|
||||
to_server
|
||||
.send(attach_client_instruction)
|
||||
.with_context(err_context)?;
|
||||
} else {
|
||||
let error = "This session does not allow web connections.";
|
||||
let _ = to_server.send(ServerInstruction::LogError(
|
||||
vec![error.to_owned()],
|
||||
client_id,
|
||||
));
|
||||
let _ = to_server
|
||||
.send(ServerInstruction::SendWebClientsForbidden(client_id));
|
||||
}
|
||||
},
|
||||
ClientToServerMsg::ClientExited => {
|
||||
// we don't unwrap this because we don't really care if there's an error here (eg.
|
||||
|
|
@ -1209,6 +1231,13 @@ pub(crate) fn route_thread_main(
|
|||
failed_path,
|
||||
));
|
||||
},
|
||||
ClientToServerMsg::WebServerStarted(base_url) => {
|
||||
let _ = to_server.send(ServerInstruction::WebServerStarted(base_url));
|
||||
},
|
||||
ClientToServerMsg::FailedToStartWebServer(error) => {
|
||||
let _ =
|
||||
to_server.send(ServerInstruction::FailedToStartWebServer(error));
|
||||
},
|
||||
}
|
||||
Ok(should_break)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
use std::str;
|
||||
|
|
@ -10,7 +11,7 @@ use std::time::Duration;
|
|||
use log::{debug, warn};
|
||||
use zellij_utils::data::{
|
||||
Direction, FloatingPaneCoordinates, KeyWithModifier, PaneManifest, PluginPermission, Resize,
|
||||
ResizeStrategy, SessionInfo, Styling,
|
||||
ResizeStrategy, SessionInfo, Styling, WebSharing,
|
||||
};
|
||||
use zellij_utils::errors::prelude::*;
|
||||
use zellij_utils::input::command::RunCommand;
|
||||
|
|
@ -213,7 +214,7 @@ pub enum ScreenInstruction {
|
|||
Option<String>,
|
||||
(Vec<SwapTiledLayout>, Vec<SwapFloatingLayout>), // swap layouts
|
||||
bool, // should_change_focus_to_new_tab
|
||||
ClientId,
|
||||
(ClientId, bool), // bool -> is_web_client
|
||||
),
|
||||
ApplyLayout(
|
||||
TiledPaneLayout,
|
||||
|
|
@ -223,7 +224,7 @@ pub enum ScreenInstruction {
|
|||
HashMap<RunPluginOrAlias, Vec<u32>>,
|
||||
usize, // tab_index
|
||||
bool, // should change focus to new tab
|
||||
ClientId,
|
||||
(ClientId, bool), // bool -> is_web_client
|
||||
),
|
||||
SwitchTabNext(ClientId),
|
||||
SwitchTabPrev(ClientId),
|
||||
|
|
@ -253,6 +254,7 @@ pub enum ScreenInstruction {
|
|||
Copy(ClientId),
|
||||
AddClient(
|
||||
ClientId,
|
||||
bool, // is_web_client
|
||||
Option<usize>, // tab position to focus
|
||||
Option<(u32, bool)>, // (pane_id, is_plugin) => pane_id to focus
|
||||
),
|
||||
|
|
@ -415,6 +417,8 @@ pub enum ScreenInstruction {
|
|||
EmbedMultiplePanes(Vec<PaneId>, ClientId),
|
||||
TogglePaneInGroup(ClientId),
|
||||
ToggleGroupMarking(ClientId),
|
||||
SessionSharingStatusChange(bool),
|
||||
SetMouseSelectionSupport(PaneId, bool),
|
||||
InterceptKeyPresses(PluginId, ClientId),
|
||||
ClearKeyPressesIntercepts(ClientId),
|
||||
}
|
||||
|
|
@ -638,6 +642,12 @@ impl From<&ScreenInstruction> for ScreenContext {
|
|||
ScreenInstruction::EmbedMultiplePanes(..) => ScreenContext::EmbedMultiplePanes,
|
||||
ScreenInstruction::TogglePaneInGroup(..) => ScreenContext::TogglePaneInGroup,
|
||||
ScreenInstruction::ToggleGroupMarking(..) => ScreenContext::ToggleGroupMarking,
|
||||
ScreenInstruction::SessionSharingStatusChange(..) => {
|
||||
ScreenContext::SessionSharingStatusChange
|
||||
},
|
||||
ScreenInstruction::SetMouseSelectionSupport(..) => {
|
||||
ScreenContext::SetMouseSelectionSupport
|
||||
},
|
||||
ScreenInstruction::InterceptKeyPresses(..) => ScreenContext::InterceptKeyPresses,
|
||||
ScreenInstruction::ClearKeyPressesIntercepts(..) => {
|
||||
ScreenContext::ClearKeyPressesIntercepts
|
||||
|
|
@ -695,7 +705,7 @@ pub(crate) struct Screen {
|
|||
overlay: OverlayWindow,
|
||||
terminal_emulator_colors: Rc<RefCell<Palette>>,
|
||||
terminal_emulator_color_codes: Rc<RefCell<HashMap<usize, String>>>,
|
||||
connected_clients: Rc<RefCell<HashSet<ClientId>>>,
|
||||
connected_clients: Rc<RefCell<HashMap<ClientId, bool>>>, // bool -> is_web_client
|
||||
/// The indices of this [`Screen`]'s active [`Tab`]s.
|
||||
active_tab_indices: BTreeMap<ClientId, usize>,
|
||||
tab_history: BTreeMap<ClientId, Vec<usize>>,
|
||||
|
|
@ -723,9 +733,15 @@ pub(crate) struct Screen {
|
|||
default_layout_name: Option<String>,
|
||||
explicitly_disable_kitty_keyboard_protocol: bool,
|
||||
default_editor: Option<PathBuf>,
|
||||
web_clients_allowed: bool,
|
||||
web_sharing: WebSharing,
|
||||
current_pane_group: Rc<RefCell<PaneGroups>>,
|
||||
advanced_mouse_actions: bool,
|
||||
currently_marking_pane_group: Rc<RefCell<HashMap<ClientId, bool>>>,
|
||||
// the below are the configured values - the ones that will be set if and when the web server
|
||||
// is brought online
|
||||
web_server_ip: IpAddr,
|
||||
web_server_port: u16,
|
||||
}
|
||||
|
||||
impl Screen {
|
||||
|
|
@ -752,7 +768,11 @@ impl Screen {
|
|||
explicitly_disable_kitty_keyboard_protocol: bool,
|
||||
stacked_resize: bool,
|
||||
default_editor: Option<PathBuf>,
|
||||
web_clients_allowed: bool,
|
||||
web_sharing: WebSharing,
|
||||
advanced_mouse_actions: bool,
|
||||
web_server_ip: IpAddr,
|
||||
web_server_port: u16,
|
||||
) -> Self {
|
||||
let session_name = mode_info.session_name.clone().unwrap_or_default();
|
||||
let session_info = SessionInfo::new(session_name.clone());
|
||||
|
|
@ -769,7 +789,7 @@ impl Screen {
|
|||
stacked_resize: Rc::new(RefCell::new(stacked_resize)),
|
||||
sixel_image_store: Rc::new(RefCell::new(SixelImageStore::default())),
|
||||
style: client_attributes.style,
|
||||
connected_clients: Rc::new(RefCell::new(HashSet::new())),
|
||||
connected_clients: Rc::new(RefCell::new(HashMap::new())),
|
||||
active_tab_indices: BTreeMap::new(),
|
||||
tabs: BTreeMap::new(),
|
||||
overlay: OverlayWindow::default(),
|
||||
|
|
@ -797,9 +817,13 @@ impl Screen {
|
|||
layout_dir,
|
||||
explicitly_disable_kitty_keyboard_protocol,
|
||||
default_editor,
|
||||
web_clients_allowed,
|
||||
web_sharing,
|
||||
current_pane_group: Rc::new(RefCell::new(current_pane_group)),
|
||||
currently_marking_pane_group: Rc::new(RefCell::new(HashMap::new())),
|
||||
advanced_mouse_actions,
|
||||
web_server_ip,
|
||||
web_server_port,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -961,8 +985,12 @@ impl Screen {
|
|||
None,
|
||||
)
|
||||
.with_context(err_context)?;
|
||||
let all_connected_clients: Vec<ClientId> =
|
||||
self.connected_clients.borrow().iter().copied().collect();
|
||||
let all_connected_clients: Vec<ClientId> = self
|
||||
.connected_clients
|
||||
.borrow()
|
||||
.iter()
|
||||
.map(|(c, _i)| *c)
|
||||
.collect();
|
||||
for client_id in all_connected_clients {
|
||||
self.update_client_tab_focus(client_id, new_tab_index);
|
||||
match (
|
||||
|
|
@ -1404,9 +1432,13 @@ impl Screen {
|
|||
self.styled_underlines,
|
||||
self.explicitly_disable_kitty_keyboard_protocol,
|
||||
self.default_editor.clone(),
|
||||
self.web_clients_allowed,
|
||||
self.web_sharing,
|
||||
self.current_pane_group.clone(),
|
||||
self.currently_marking_pane_group.clone(),
|
||||
self.advanced_mouse_actions,
|
||||
self.web_server_ip,
|
||||
self.web_server_port,
|
||||
);
|
||||
for (client_id, mode_info) in &self.mode_info {
|
||||
tab.change_mode_info(mode_info.clone(), *client_id);
|
||||
|
|
@ -1423,7 +1455,7 @@ impl Screen {
|
|||
new_plugin_ids: HashMap<RunPluginOrAlias, Vec<u32>>,
|
||||
tab_index: usize,
|
||||
should_change_client_focus: bool,
|
||||
client_id: ClientId,
|
||||
client_id_and_is_web_client: (ClientId, bool),
|
||||
) -> Result<()> {
|
||||
if self.tabs.get(&tab_index).is_none() {
|
||||
// TODO: we should prevent this situation with a UI - eg. cannot close tabs with a
|
||||
|
|
@ -1431,9 +1463,20 @@ impl Screen {
|
|||
log::error!("Tab with index {tab_index} not found. Cannot apply layout!");
|
||||
return Ok(());
|
||||
}
|
||||
let (client_id, mut is_web_client) = client_id_and_is_web_client;
|
||||
let client_id = if self.get_active_tab(client_id).is_ok() {
|
||||
if let Some(connected_client_is_web_client) =
|
||||
self.connected_clients.borrow().get(&client_id)
|
||||
{
|
||||
is_web_client = *connected_client_is_web_client;
|
||||
}
|
||||
client_id
|
||||
} else if let Some(first_client_id) = self.get_first_client_id() {
|
||||
if let Some(first_client_is_web_client) =
|
||||
self.connected_clients.borrow().get(&first_client_id)
|
||||
{
|
||||
is_web_client = *first_client_is_web_client;
|
||||
}
|
||||
first_client_id
|
||||
} else {
|
||||
client_id
|
||||
|
|
@ -1457,8 +1500,12 @@ impl Screen {
|
|||
} else {
|
||||
None
|
||||
};
|
||||
let all_connected_clients: Vec<ClientId> =
|
||||
self.connected_clients.borrow().iter().copied().collect();
|
||||
let all_connected_clients: Vec<ClientId> = self
|
||||
.connected_clients
|
||||
.borrow()
|
||||
.iter()
|
||||
.map(|(c, _i)| *c)
|
||||
.collect();
|
||||
for client_id in all_connected_clients {
|
||||
self.update_client_tab_focus(client_id, tab_index);
|
||||
}
|
||||
|
|
@ -1508,7 +1555,8 @@ impl Screen {
|
|||
|
||||
if !self.active_tab_indices.contains_key(&client_id) {
|
||||
// this means this is a new client and we need to add it to our state properly
|
||||
self.add_client(client_id).with_context(err_context)?;
|
||||
self.add_client(client_id, is_web_client)
|
||||
.with_context(err_context)?;
|
||||
}
|
||||
|
||||
self.log_and_report_session_state()
|
||||
|
|
@ -1516,7 +1564,7 @@ impl Screen {
|
|||
.with_context(err_context)
|
||||
}
|
||||
|
||||
pub fn add_client(&mut self, client_id: ClientId) -> Result<()> {
|
||||
pub fn add_client(&mut self, client_id: ClientId, is_web_client: bool) -> Result<()> {
|
||||
let err_context = |tab_index| {
|
||||
format!("failed to attach client {client_id} to tab with index {tab_index}")
|
||||
};
|
||||
|
|
@ -1539,7 +1587,9 @@ impl Screen {
|
|||
};
|
||||
|
||||
self.active_tab_indices.insert(client_id, tab_index);
|
||||
self.connected_clients.borrow_mut().insert(client_id);
|
||||
self.connected_clients
|
||||
.borrow_mut()
|
||||
.insert(client_id, is_web_client);
|
||||
self.tab_history.insert(client_id, tab_history);
|
||||
self.tabs
|
||||
.get_mut(&tab_index)
|
||||
|
|
@ -1688,6 +1738,13 @@ impl Screen {
|
|||
connected_clients: self.active_tab_indices.keys().len(),
|
||||
is_current_session: true,
|
||||
available_layouts,
|
||||
web_clients_allowed: self.web_sharing.web_clients_allowed(),
|
||||
web_client_count: self
|
||||
.connected_clients
|
||||
.borrow()
|
||||
.iter()
|
||||
.filter(|(_client_id, is_web_client)| **is_web_client)
|
||||
.count(),
|
||||
plugins: Default::default(), // these are filled in by the wasm thread
|
||||
tab_history: self.tab_history.clone(),
|
||||
};
|
||||
|
|
@ -1703,6 +1760,12 @@ impl Screen {
|
|||
.senders
|
||||
.send_to_background_jobs(BackgroundJob::ReadAllSessionInfosOnMachine)
|
||||
.with_context(err_context)?;
|
||||
|
||||
// TODO: consider moving this elsewhere
|
||||
self.bus
|
||||
.senders
|
||||
.send_to_background_jobs(BackgroundJob::QueryZellijWebServerStatus)
|
||||
.with_context(err_context)?;
|
||||
Ok(())
|
||||
}
|
||||
fn dump_layout_to_hd(&mut self) -> Result<()> {
|
||||
|
|
@ -2245,6 +2308,12 @@ impl Screen {
|
|||
tiled_panes_layout.ignore_run_instruction(active_pane_run_instruction.clone());
|
||||
}
|
||||
let should_change_focus_to_new_tab = true;
|
||||
let is_web_client = self
|
||||
.connected_clients
|
||||
.borrow()
|
||||
.get(&client_id)
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
self.bus.senders.send_to_plugin(PluginInstruction::NewTab(
|
||||
None,
|
||||
default_shell,
|
||||
|
|
@ -2252,7 +2321,7 @@ impl Screen {
|
|||
floating_panes_layout,
|
||||
tab_index,
|
||||
should_change_focus_to_new_tab,
|
||||
client_id,
|
||||
(client_id, is_web_client),
|
||||
))?;
|
||||
} else {
|
||||
let active_pane_id = active_tab
|
||||
|
|
@ -2315,6 +2384,12 @@ impl Screen {
|
|||
tab.add_tiled_pane(pane, pane_id, None)?;
|
||||
tiled_panes_layout.ignore_run_instruction(run_instruction.clone());
|
||||
}
|
||||
let is_web_client = self
|
||||
.connected_clients
|
||||
.borrow()
|
||||
.get(&client_id)
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
self.bus.senders.send_to_plugin(PluginInstruction::NewTab(
|
||||
None,
|
||||
default_shell,
|
||||
|
|
@ -2322,7 +2397,7 @@ impl Screen {
|
|||
floating_panes_layout,
|
||||
tab_index,
|
||||
should_change_focus_to_new_tab,
|
||||
client_id,
|
||||
(client_id, is_web_client),
|
||||
))?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -2801,7 +2876,7 @@ impl Screen {
|
|||
.connected_clients
|
||||
.borrow()
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|(c, _i)| *c)
|
||||
.filter(|c| self.active_tab_indices.get(&c) == Some(&tab_index))
|
||||
.collect();
|
||||
|
||||
|
|
@ -2916,7 +2991,7 @@ impl Screen {
|
|||
found_plugin
|
||||
}
|
||||
fn connected_clients_contains(&self, client_id: &ClientId) -> bool {
|
||||
self.connected_clients.borrow().contains(client_id)
|
||||
self.connected_clients.borrow().contains_key(client_id)
|
||||
}
|
||||
fn get_client_pane_group(&self, client_id: &ClientId) -> HashSet<PaneId> {
|
||||
self.current_pane_group
|
||||
|
|
@ -3079,6 +3154,10 @@ pub(crate) fn screen_thread_main(
|
|||
config_options.copy_clipboard.unwrap_or_default(),
|
||||
config_options.copy_on_select.unwrap_or(true),
|
||||
);
|
||||
let web_server_ip = config_options
|
||||
.web_server_ip
|
||||
.unwrap_or_else(|| IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
|
||||
let web_server_port = config_options.web_server_port.unwrap_or(8082);
|
||||
let styled_underlines = config_options.styled_underlines.unwrap_or(true);
|
||||
let explicitly_disable_kitty_keyboard_protocol = config_options
|
||||
.support_kitty_keyboard_protocol
|
||||
|
|
@ -3088,6 +3167,11 @@ pub(crate) fn screen_thread_main(
|
|||
.unwrap_or(false); // by default, we try to support this if the terminal supports it and
|
||||
// the program running inside a pane requests it
|
||||
let stacked_resize = config_options.stacked_resize.unwrap_or(true);
|
||||
let web_clients_allowed = config_options
|
||||
.web_sharing
|
||||
.map(|s| s.web_clients_allowed())
|
||||
.unwrap_or(false);
|
||||
let web_sharing = config_options.web_sharing.unwrap_or_else(Default::default);
|
||||
let advanced_mouse_actions = config_options.advanced_mouse_actions.unwrap_or(true);
|
||||
|
||||
let thread_senders = bus.senders.clone();
|
||||
|
|
@ -3122,7 +3206,11 @@ pub(crate) fn screen_thread_main(
|
|||
explicitly_disable_kitty_keyboard_protocol,
|
||||
stacked_resize,
|
||||
default_editor,
|
||||
web_clients_allowed,
|
||||
web_sharing,
|
||||
advanced_mouse_actions,
|
||||
web_server_ip,
|
||||
web_server_port,
|
||||
);
|
||||
|
||||
let mut pending_tab_ids: HashSet<usize> = HashSet::new();
|
||||
|
|
@ -3801,6 +3889,24 @@ pub(crate) fn screen_thread_main(
|
|||
screen.render(None)?;
|
||||
screen.log_and_report_session_state()?;
|
||||
},
|
||||
ScreenInstruction::SetMouseSelectionSupport(pid, selection_support) => {
|
||||
let all_tabs = screen.get_tabs_mut();
|
||||
let mut found_plugin = false;
|
||||
for tab in all_tabs.values_mut() {
|
||||
if tab.has_pane_with_pid(&pid) {
|
||||
tab.set_mouse_selection_support(pid, selection_support);
|
||||
found_plugin = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found_plugin {
|
||||
pending_events_waiting_for_tab.push(
|
||||
ScreenInstruction::SetMouseSelectionSupport(pid, selection_support),
|
||||
);
|
||||
}
|
||||
screen.render(None)?;
|
||||
screen.log_and_report_session_state()?;
|
||||
},
|
||||
ScreenInstruction::ClosePane(id, client_id) => {
|
||||
match client_id {
|
||||
Some(client_id) => {
|
||||
|
|
@ -3894,7 +4000,7 @@ pub(crate) fn screen_thread_main(
|
|||
tab_name,
|
||||
swap_layouts,
|
||||
should_change_focus_to_new_tab,
|
||||
client_id,
|
||||
(client_id, is_web_client),
|
||||
) => {
|
||||
let tab_index = screen.get_new_tab_index();
|
||||
pending_tab_ids.insert(tab_index);
|
||||
|
|
@ -3919,7 +4025,7 @@ pub(crate) fn screen_thread_main(
|
|||
floating_panes_layout,
|
||||
tab_index,
|
||||
should_change_focus_to_new_tab,
|
||||
client_id,
|
||||
(client_id, is_web_client),
|
||||
))?;
|
||||
},
|
||||
ScreenInstruction::ApplyLayout(
|
||||
|
|
@ -3930,7 +4036,7 @@ pub(crate) fn screen_thread_main(
|
|||
new_plugin_ids,
|
||||
tab_index,
|
||||
should_change_focus_to_new_tab,
|
||||
client_id,
|
||||
(client_id, is_web_client),
|
||||
) => {
|
||||
screen.apply_layout(
|
||||
layout,
|
||||
|
|
@ -3940,7 +4046,7 @@ pub(crate) fn screen_thread_main(
|
|||
new_plugin_ids.clone(),
|
||||
tab_index,
|
||||
should_change_focus_to_new_tab,
|
||||
client_id,
|
||||
(client_id, is_web_client),
|
||||
)?;
|
||||
pending_tab_ids.remove(&tab_index);
|
||||
if pending_tab_ids.is_empty() {
|
||||
|
|
@ -3990,7 +4096,7 @@ pub(crate) fn screen_thread_main(
|
|||
// while this can affect other instances of a layout being applied, the query is
|
||||
// very short and cheap and shouldn't cause any trouble
|
||||
if let Some(os_input) = &mut screen.bus.os_input {
|
||||
for client_id in screen.connected_clients.borrow().iter() {
|
||||
for (client_id, _is_web_client) in screen.connected_clients.borrow().iter() {
|
||||
let _ = os_input
|
||||
.send_to_client(*client_id, ServerToClientMsg::QueryTerminalSize);
|
||||
}
|
||||
|
|
@ -4043,6 +4149,12 @@ pub(crate) fn screen_thread_main(
|
|||
screen.active_tab_indices.keys().next().copied()
|
||||
};
|
||||
if let Some(client_id) = client_id {
|
||||
let is_web_client = screen
|
||||
.connected_clients
|
||||
.borrow()
|
||||
.get(&client_id)
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
if let Ok(tab_exists) = screen.go_to_tab_name(tab_name.clone(), client_id) {
|
||||
screen.unblock_input()?;
|
||||
screen.render(None)?;
|
||||
|
|
@ -4065,7 +4177,7 @@ pub(crate) fn screen_thread_main(
|
|||
vec![],
|
||||
tab_index,
|
||||
should_change_focus_to_new_tab,
|
||||
client_id,
|
||||
(client_id, is_web_client),
|
||||
))?;
|
||||
}
|
||||
}
|
||||
|
|
@ -4152,8 +4264,13 @@ pub(crate) fn screen_thread_main(
|
|||
screen.unblock_input()?;
|
||||
screen.render(None)?;
|
||||
},
|
||||
ScreenInstruction::AddClient(client_id, tab_position_to_focus, pane_id_to_focus) => {
|
||||
screen.add_client(client_id)?;
|
||||
ScreenInstruction::AddClient(
|
||||
client_id,
|
||||
is_web_client,
|
||||
tab_position_to_focus,
|
||||
pane_id_to_focus,
|
||||
) => {
|
||||
screen.add_client(client_id, is_web_client)?;
|
||||
let pane_id = pane_id_to_focus.map(|(pane_id, is_plugin)| {
|
||||
if is_plugin {
|
||||
PaneId::Plugin(pane_id)
|
||||
|
|
@ -4170,6 +4287,21 @@ pub(crate) fn screen_thread_main(
|
|||
screen.bus.senders.send_to_screen(event).non_fatal();
|
||||
}
|
||||
screen.log_and_report_session_state()?;
|
||||
|
||||
if is_web_client {
|
||||
// we do this because
|
||||
// we need to query the client for its size, and we must do it only after we've
|
||||
// added it to our state.
|
||||
//
|
||||
// we have to do this specifically for web clients because the browser (as opposed
|
||||
// to a traditional terminal) can only figure out its dimensions after we sent it relevant
|
||||
// state (eg. font, which is controlled by our config and it needs to determine cell size)
|
||||
if let Some(os_input) = &mut screen.bus.os_input {
|
||||
let _ = os_input
|
||||
.send_to_client(client_id, ServerToClientMsg::QueryTerminalSize);
|
||||
}
|
||||
}
|
||||
|
||||
screen.render(None)?;
|
||||
},
|
||||
ScreenInstruction::RemoveClient(client_id) => {
|
||||
|
|
@ -5227,6 +5359,19 @@ pub(crate) fn screen_thread_main(
|
|||
ScreenInstruction::ToggleGroupMarking(client_id) => {
|
||||
screen.toggle_group_marking(client_id).non_fatal();
|
||||
},
|
||||
ScreenInstruction::SessionSharingStatusChange(web_sharing) => {
|
||||
if web_sharing {
|
||||
screen.web_sharing = WebSharing::On;
|
||||
} else {
|
||||
screen.web_sharing = WebSharing::Off;
|
||||
}
|
||||
|
||||
for tab in screen.tabs.values_mut() {
|
||||
tab.update_web_sharing(screen.web_sharing);
|
||||
}
|
||||
let _ = screen.log_and_report_session_state();
|
||||
let _ = screen.render(None);
|
||||
},
|
||||
ScreenInstruction::HighlightAndUnhighlightPanes(
|
||||
pane_ids_to_highlight,
|
||||
pane_ids_to_unhighlight,
|
||||
|
|
|
|||
|
|
@ -9,11 +9,12 @@ mod swap_layouts;
|
|||
use copy_command::CopyCommand;
|
||||
use serde;
|
||||
use std::env::temp_dir;
|
||||
use std::net::IpAddr;
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
use zellij_utils::data::{
|
||||
Direction, KeyWithModifier, PaneInfo, PermissionStatus, PermissionType, PluginPermission,
|
||||
ResizeStrategy,
|
||||
ResizeStrategy, WebSharing,
|
||||
};
|
||||
use zellij_utils::errors::prelude::*;
|
||||
use zellij_utils::input::command::RunCommand;
|
||||
|
|
@ -262,10 +263,17 @@ pub(crate) struct Tab {
|
|||
arrow_fonts: bool,
|
||||
styled_underlines: bool,
|
||||
explicitly_disable_kitty_keyboard_protocol: bool,
|
||||
web_clients_allowed: bool,
|
||||
web_sharing: WebSharing,
|
||||
mouse_hover_pane_id: HashMap<ClientId, PaneId>,
|
||||
current_pane_group: Rc<RefCell<PaneGroups>>,
|
||||
advanced_mouse_actions: bool,
|
||||
currently_marking_pane_group: Rc<RefCell<HashMap<ClientId, bool>>>,
|
||||
connected_clients_in_app: Rc<RefCell<HashMap<ClientId, bool>>>, // bool -> is_web_client
|
||||
// the below are the configured values - the ones that will be set if and when the web server
|
||||
// is brought online
|
||||
web_server_ip: IpAddr,
|
||||
web_server_port: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
|
|
@ -342,7 +350,7 @@ pub trait Pane {
|
|||
fn pull_left(&mut self, count: usize);
|
||||
fn pull_up(&mut self, count: usize);
|
||||
fn clear_screen(&mut self);
|
||||
fn dump_screen(&self, _full: bool) -> String {
|
||||
fn dump_screen(&self, _full: bool, _client_id: Option<ClientId>) -> String {
|
||||
"".to_owned()
|
||||
}
|
||||
fn scroll_up(&mut self, count: usize, client_id: ClientId);
|
||||
|
|
@ -368,8 +376,11 @@ pub trait Pane {
|
|||
fn start_selection(&mut self, _start: &Position, _client_id: ClientId) {}
|
||||
fn update_selection(&mut self, _position: &Position, _client_id: ClientId) {}
|
||||
fn end_selection(&mut self, _end: &Position, _client_id: ClientId) {}
|
||||
fn reset_selection(&mut self) {}
|
||||
fn get_selected_text(&self) -> Option<String> {
|
||||
fn reset_selection(&mut self, _client_id: Option<ClientId>) {}
|
||||
fn supports_mouse_selection(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn get_selected_text(&self, _client_id: ClientId) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
|
|
@ -613,6 +624,7 @@ pub trait Pane {
|
|||
fn toggle_pinned(&mut self) {}
|
||||
fn set_pinned(&mut self, _should_be_pinned: bool) {}
|
||||
fn reset_logical_position(&mut self) {}
|
||||
fn set_mouse_selection_support(&mut self, _selection_support: bool) {}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -663,7 +675,7 @@ impl Tab {
|
|||
default_mode_info: ModeInfo,
|
||||
draw_pane_frames: bool,
|
||||
auto_layout: bool,
|
||||
connected_clients_in_app: Rc<RefCell<HashSet<ClientId>>>,
|
||||
connected_clients_in_app: Rc<RefCell<HashMap<ClientId, bool>>>, // bool -> is_web_client
|
||||
session_is_mirrored: bool,
|
||||
client_id: Option<ClientId>,
|
||||
copy_options: CopyOptions,
|
||||
|
|
@ -676,9 +688,13 @@ impl Tab {
|
|||
styled_underlines: bool,
|
||||
explicitly_disable_kitty_keyboard_protocol: bool,
|
||||
default_editor: Option<PathBuf>,
|
||||
web_clients_allowed: bool,
|
||||
web_sharing: WebSharing,
|
||||
current_pane_group: Rc<RefCell<PaneGroups>>,
|
||||
currently_marking_pane_group: Rc<RefCell<HashMap<ClientId, bool>>>,
|
||||
advanced_mouse_actions: bool,
|
||||
web_server_ip: IpAddr,
|
||||
web_server_port: u16,
|
||||
) -> Self {
|
||||
let name = if name.is_empty() {
|
||||
format!("Tab #{}", index + 1)
|
||||
|
|
@ -715,7 +731,7 @@ impl Tab {
|
|||
display_area.clone(),
|
||||
viewport.clone(),
|
||||
connected_clients.clone(),
|
||||
connected_clients_in_app,
|
||||
connected_clients_in_app.clone(),
|
||||
mode_info.clone(),
|
||||
character_cell_size.clone(),
|
||||
session_is_mirrored,
|
||||
|
|
@ -773,10 +789,15 @@ impl Tab {
|
|||
styled_underlines,
|
||||
explicitly_disable_kitty_keyboard_protocol,
|
||||
default_editor,
|
||||
web_clients_allowed,
|
||||
web_sharing,
|
||||
mouse_hover_pane_id: HashMap::new(),
|
||||
current_pane_group,
|
||||
currently_marking_pane_group,
|
||||
advanced_mouse_actions,
|
||||
connected_clients_in_app,
|
||||
web_server_ip,
|
||||
web_server_port,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1001,8 +1022,22 @@ impl Tab {
|
|||
.clone();
|
||||
mode_info.shell = Some(self.default_shell.clone());
|
||||
mode_info.editor = self.default_editor.clone();
|
||||
mode_info.web_clients_allowed = Some(self.web_clients_allowed);
|
||||
mode_info.web_sharing = Some(self.web_sharing);
|
||||
mode_info.currently_marking_pane_group =
|
||||
currently_marking_pane_group.get(client_id).copied();
|
||||
mode_info.web_server_ip = Some(self.web_server_ip);
|
||||
mode_info.web_server_port = Some(self.web_server_port);
|
||||
mode_info.is_web_client = self
|
||||
.connected_clients_in_app
|
||||
.borrow()
|
||||
.get(&client_id)
|
||||
.copied();
|
||||
if cfg!(feature = "web_server_capability") {
|
||||
mode_info.web_server_capability = Some(true);
|
||||
} else {
|
||||
mode_info.web_server_capability = Some(false);
|
||||
}
|
||||
plugin_updates.push((None, Some(*client_id), Event::ModeUpdate(mode_info)));
|
||||
}
|
||||
self.senders
|
||||
|
|
@ -2882,6 +2917,11 @@ impl Tab {
|
|||
self.draw_pane_frames,
|
||||
);
|
||||
}
|
||||
pub fn set_mouse_selection_support(&mut self, pane_id: PaneId, selection_support: bool) {
|
||||
if let Some(pane) = self.get_pane_with_id_mut(pane_id) {
|
||||
pane.set_mouse_selection_support(selection_support);
|
||||
}
|
||||
}
|
||||
pub fn close_pane(&mut self, id: PaneId, ignore_suppressed_panes: bool) {
|
||||
// we need to ignore suppressed panes when we toggle a pane to be floating/embedded(tiled)
|
||||
// this is because in that case, while we do use this logic, we're not actually closing the
|
||||
|
|
@ -3122,7 +3162,7 @@ impl Tab {
|
|||
|| format!("failed to dump active terminal screen for client {client_id}");
|
||||
|
||||
if let Some(active_pane) = self.get_active_pane_or_floating_pane_mut(client_id) {
|
||||
let dump = active_pane.dump_screen(full);
|
||||
let dump = active_pane.dump_screen(full, Some(client_id));
|
||||
self.os_api
|
||||
.write_to_file(dump, file)
|
||||
.with_context(err_context)?;
|
||||
|
|
@ -3136,7 +3176,7 @@ impl Tab {
|
|||
full: bool,
|
||||
) -> Result<()> {
|
||||
if let Some(pane) = self.get_pane_with_id(pane_id) {
|
||||
let dump = pane.dump_screen(full);
|
||||
let dump = pane.dump_screen(full, None);
|
||||
self.os_api.write_to_file(dump, file).non_fatal()
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -3664,10 +3704,10 @@ impl Tab {
|
|||
// start selection for copy/paste
|
||||
let mut leave_clipboard_message = false;
|
||||
pane_at_position.start_selection(&relative_position, client_id);
|
||||
if pane_at_position.get_selected_text().is_some() {
|
||||
if pane_at_position.get_selected_text(client_id).is_some() {
|
||||
leave_clipboard_message = true;
|
||||
}
|
||||
if let PaneId::Terminal(_) = pane_at_position.pid() {
|
||||
if pane_at_position.supports_mouse_selection() {
|
||||
self.selecting_with_mouse_in_pane = Some(pane_at_position.pid());
|
||||
}
|
||||
if leave_clipboard_message {
|
||||
|
|
@ -3818,9 +3858,9 @@ impl Tab {
|
|||
} else {
|
||||
let relative_position = pane_with_selection.relative_position(&event.position);
|
||||
pane_with_selection.end_selection(&relative_position, client_id);
|
||||
if let PaneId::Terminal(_) = pane_with_selection.pid() {
|
||||
if pane_with_selection.supports_mouse_selection() {
|
||||
if copy_on_release {
|
||||
let selected_text = pane_with_selection.get_selected_text();
|
||||
let selected_text = pane_with_selection.get_selected_text(client_id);
|
||||
|
||||
if let Some(selected_text) = selected_text {
|
||||
leave_clipboard_message = true;
|
||||
|
|
@ -4087,7 +4127,7 @@ impl Tab {
|
|||
pub fn copy_selection(&self, client_id: ClientId) -> Result<()> {
|
||||
let selected_text = self
|
||||
.get_active_pane(client_id)
|
||||
.and_then(|p| p.get_selected_text());
|
||||
.and_then(|p| p.get_selected_text(client_id));
|
||||
if let Some(selected_text) = selected_text {
|
||||
self.write_selection_to_clipboard(&selected_text)
|
||||
.with_context(|| {
|
||||
|
|
@ -4746,6 +4786,13 @@ impl Tab {
|
|||
pub fn update_advanced_mouse_actions(&mut self, advanced_mouse_actions: bool) {
|
||||
self.advanced_mouse_actions = advanced_mouse_actions;
|
||||
}
|
||||
pub fn update_web_sharing(&mut self, web_sharing: WebSharing) {
|
||||
let old_value = self.web_sharing;
|
||||
self.web_sharing = web_sharing;
|
||||
if old_value != self.web_sharing {
|
||||
let _ = self.update_input_modes();
|
||||
}
|
||||
}
|
||||
pub fn extract_suppressed_panes(&mut self) -> SuppressedPanes {
|
||||
self.suppressed_panes.drain().collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use crate::{
|
|||
thread_bus::ThreadSenders,
|
||||
ClientId,
|
||||
};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ use zellij_utils::channels::Receiver;
|
|||
use zellij_utils::data::Direction;
|
||||
use zellij_utils::data::Resize;
|
||||
use zellij_utils::data::ResizeStrategy;
|
||||
use zellij_utils::data::WebSharing;
|
||||
use zellij_utils::envs::set_session_name;
|
||||
use zellij_utils::errors::{prelude::*, ErrorContext};
|
||||
use zellij_utils::input::layout::{
|
||||
|
|
@ -216,8 +218,8 @@ fn create_new_tab(size: Size, default_mode: ModeInfo) -> Tab {
|
|||
let auto_layout = true;
|
||||
let client_id = 1;
|
||||
let session_is_mirrored = true;
|
||||
let mut connected_clients = HashSet::new();
|
||||
connected_clients.insert(client_id);
|
||||
let mut connected_clients = HashMap::new();
|
||||
connected_clients.insert(client_id, false);
|
||||
let connected_clients = Rc::new(RefCell::new(connected_clients));
|
||||
let character_cell_info = Rc::new(RefCell::new(None));
|
||||
let stacked_resize = Rc::new(RefCell::new(true));
|
||||
|
|
@ -232,6 +234,9 @@ fn create_new_tab(size: Size, default_mode: ModeInfo) -> Tab {
|
|||
let styled_underlines = true;
|
||||
let explicitly_disable_kitty_keyboard_protocol = false;
|
||||
let advanced_mouse_actions = true;
|
||||
let web_sharing = WebSharing::Off;
|
||||
let web_server_ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
|
||||
let web_server_port = 8080;
|
||||
let mut tab = Tab::new(
|
||||
index,
|
||||
position,
|
||||
|
|
@ -260,9 +265,13 @@ fn create_new_tab(size: Size, default_mode: ModeInfo) -> Tab {
|
|||
styled_underlines,
|
||||
explicitly_disable_kitty_keyboard_protocol,
|
||||
None,
|
||||
false,
|
||||
web_sharing,
|
||||
current_group,
|
||||
currently_marking_pane_group,
|
||||
advanced_mouse_actions,
|
||||
web_server_ip,
|
||||
web_server_port,
|
||||
);
|
||||
tab.apply_layout(
|
||||
TiledPaneLayout::default(),
|
||||
|
|
@ -290,8 +299,8 @@ fn create_new_tab_without_pane_frames(size: Size, default_mode: ModeInfo) -> Tab
|
|||
let auto_layout = true;
|
||||
let client_id = 1;
|
||||
let session_is_mirrored = true;
|
||||
let mut connected_clients = HashSet::new();
|
||||
connected_clients.insert(client_id);
|
||||
let mut connected_clients = HashMap::new();
|
||||
connected_clients.insert(client_id, false);
|
||||
let connected_clients = Rc::new(RefCell::new(connected_clients));
|
||||
let character_cell_info = Rc::new(RefCell::new(None));
|
||||
let stacked_resize = Rc::new(RefCell::new(true));
|
||||
|
|
@ -306,6 +315,9 @@ fn create_new_tab_without_pane_frames(size: Size, default_mode: ModeInfo) -> Tab
|
|||
let styled_underlines = true;
|
||||
let explicitly_disable_kitty_keyboard_protocol = false;
|
||||
let advanced_mouse_actions = true;
|
||||
let web_sharing = WebSharing::Off;
|
||||
let web_server_ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
|
||||
let web_server_port = 8080;
|
||||
let mut tab = Tab::new(
|
||||
index,
|
||||
position,
|
||||
|
|
@ -334,9 +346,13 @@ fn create_new_tab_without_pane_frames(size: Size, default_mode: ModeInfo) -> Tab
|
|||
styled_underlines,
|
||||
explicitly_disable_kitty_keyboard_protocol,
|
||||
None,
|
||||
false,
|
||||
web_sharing,
|
||||
current_group,
|
||||
currently_marking_pane_group,
|
||||
advanced_mouse_actions,
|
||||
web_server_ip,
|
||||
web_server_port,
|
||||
);
|
||||
tab.apply_layout(
|
||||
TiledPaneLayout::default(),
|
||||
|
|
@ -379,8 +395,8 @@ fn create_new_tab_with_swap_layouts(
|
|||
let auto_layout = true;
|
||||
let client_id = 1;
|
||||
let session_is_mirrored = true;
|
||||
let mut connected_clients = HashSet::new();
|
||||
connected_clients.insert(client_id);
|
||||
let mut connected_clients = HashMap::new();
|
||||
connected_clients.insert(client_id, false);
|
||||
let connected_clients = Rc::new(RefCell::new(connected_clients));
|
||||
let character_cell_info = Rc::new(RefCell::new(None));
|
||||
let stacked_resize = Rc::new(RefCell::new(stacked_resize));
|
||||
|
|
@ -395,6 +411,9 @@ fn create_new_tab_with_swap_layouts(
|
|||
let styled_underlines = true;
|
||||
let explicitly_disable_kitty_keyboard_protocol = false;
|
||||
let advanced_mouse_actions = true;
|
||||
let web_sharing = WebSharing::Off;
|
||||
let web_server_ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
|
||||
let web_server_port = 8080;
|
||||
let mut tab = Tab::new(
|
||||
index,
|
||||
position,
|
||||
|
|
@ -423,9 +442,13 @@ fn create_new_tab_with_swap_layouts(
|
|||
styled_underlines,
|
||||
explicitly_disable_kitty_keyboard_protocol,
|
||||
None,
|
||||
false,
|
||||
web_sharing,
|
||||
current_group,
|
||||
currently_marking_pane_group,
|
||||
advanced_mouse_actions,
|
||||
web_server_ip,
|
||||
web_server_port,
|
||||
);
|
||||
let (
|
||||
base_layout,
|
||||
|
|
@ -469,8 +492,8 @@ fn create_new_tab_with_os_api(
|
|||
let auto_layout = true;
|
||||
let client_id = 1;
|
||||
let session_is_mirrored = true;
|
||||
let mut connected_clients = HashSet::new();
|
||||
connected_clients.insert(client_id);
|
||||
let mut connected_clients = HashMap::new();
|
||||
connected_clients.insert(client_id, false);
|
||||
let connected_clients = Rc::new(RefCell::new(connected_clients));
|
||||
let character_cell_info = Rc::new(RefCell::new(None));
|
||||
let stacked_resize = Rc::new(RefCell::new(true));
|
||||
|
|
@ -485,6 +508,9 @@ fn create_new_tab_with_os_api(
|
|||
let styled_underlines = true;
|
||||
let explicitly_disable_kitty_keyboard_protocol = false;
|
||||
let advanced_mouse_actions = true;
|
||||
let web_sharing = WebSharing::Off;
|
||||
let web_server_ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
|
||||
let web_server_port = 8080;
|
||||
let mut tab = Tab::new(
|
||||
index,
|
||||
position,
|
||||
|
|
@ -513,9 +539,13 @@ fn create_new_tab_with_os_api(
|
|||
styled_underlines,
|
||||
explicitly_disable_kitty_keyboard_protocol,
|
||||
None,
|
||||
false,
|
||||
web_sharing,
|
||||
current_group,
|
||||
currently_marking_pane_group,
|
||||
advanced_mouse_actions,
|
||||
web_server_ip,
|
||||
web_server_port,
|
||||
);
|
||||
tab.apply_layout(
|
||||
TiledPaneLayout::default(),
|
||||
|
|
@ -543,8 +573,8 @@ fn create_new_tab_with_layout(size: Size, default_mode: ModeInfo, layout: &str)
|
|||
let auto_layout = true;
|
||||
let client_id = 1;
|
||||
let session_is_mirrored = true;
|
||||
let mut connected_clients = HashSet::new();
|
||||
connected_clients.insert(client_id);
|
||||
let mut connected_clients = HashMap::new();
|
||||
connected_clients.insert(client_id, false);
|
||||
let connected_clients = Rc::new(RefCell::new(connected_clients));
|
||||
let character_cell_info = Rc::new(RefCell::new(None));
|
||||
let stacked_resize = Rc::new(RefCell::new(true));
|
||||
|
|
@ -561,6 +591,9 @@ fn create_new_tab_with_layout(size: Size, default_mode: ModeInfo, layout: &str)
|
|||
let styled_underlines = true;
|
||||
let explicitly_disable_kitty_keyboard_protocol = false;
|
||||
let advanced_mouse_actions = true;
|
||||
let web_sharing = WebSharing::Off;
|
||||
let web_server_ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
|
||||
let web_server_port = 8080;
|
||||
let mut tab = Tab::new(
|
||||
index,
|
||||
position,
|
||||
|
|
@ -589,9 +622,13 @@ fn create_new_tab_with_layout(size: Size, default_mode: ModeInfo, layout: &str)
|
|||
styled_underlines,
|
||||
explicitly_disable_kitty_keyboard_protocol,
|
||||
None,
|
||||
false,
|
||||
web_sharing,
|
||||
current_group,
|
||||
currently_marking_pane_group,
|
||||
advanced_mouse_actions,
|
||||
web_server_ip,
|
||||
web_server_port,
|
||||
);
|
||||
let pane_ids = tab_layout
|
||||
.extract_run_instructions()
|
||||
|
|
@ -635,8 +672,8 @@ fn create_new_tab_with_mock_pty_writer(
|
|||
let auto_layout = true;
|
||||
let client_id = 1;
|
||||
let session_is_mirrored = true;
|
||||
let mut connected_clients = HashSet::new();
|
||||
connected_clients.insert(client_id);
|
||||
let mut connected_clients = HashMap::new();
|
||||
connected_clients.insert(client_id, false);
|
||||
let connected_clients = Rc::new(RefCell::new(connected_clients));
|
||||
let character_cell_info = Rc::new(RefCell::new(None));
|
||||
let stacked_resize = Rc::new(RefCell::new(true));
|
||||
|
|
@ -651,6 +688,9 @@ fn create_new_tab_with_mock_pty_writer(
|
|||
let styled_underlines = true;
|
||||
let explicitly_disable_kitty_keyboard_protocol = false;
|
||||
let advanced_mouse_actions = true;
|
||||
let web_sharing = WebSharing::Off;
|
||||
let web_server_ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
|
||||
let web_server_port = 8080;
|
||||
let mut tab = Tab::new(
|
||||
index,
|
||||
position,
|
||||
|
|
@ -679,9 +719,13 @@ fn create_new_tab_with_mock_pty_writer(
|
|||
styled_underlines,
|
||||
explicitly_disable_kitty_keyboard_protocol,
|
||||
None,
|
||||
false,
|
||||
web_sharing,
|
||||
current_group,
|
||||
currently_marking_pane_group,
|
||||
advanced_mouse_actions,
|
||||
web_server_ip,
|
||||
web_server_port,
|
||||
);
|
||||
tab.apply_layout(
|
||||
TiledPaneLayout::default(),
|
||||
|
|
@ -714,8 +758,8 @@ fn create_new_tab_with_sixel_support(
|
|||
let auto_layout = true;
|
||||
let client_id = 1;
|
||||
let session_is_mirrored = true;
|
||||
let mut connected_clients = HashSet::new();
|
||||
connected_clients.insert(client_id);
|
||||
let mut connected_clients = HashMap::new();
|
||||
connected_clients.insert(client_id, false);
|
||||
let connected_clients = Rc::new(RefCell::new(connected_clients));
|
||||
let character_cell_size = Rc::new(RefCell::new(Some(SizeInPixels {
|
||||
width: 8,
|
||||
|
|
@ -732,6 +776,9 @@ fn create_new_tab_with_sixel_support(
|
|||
let styled_underlines = true;
|
||||
let explicitly_disable_kitty_keyboard_protocol = false;
|
||||
let advanced_mouse_actions = true;
|
||||
let web_sharing = WebSharing::Off;
|
||||
let web_server_ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
|
||||
let web_server_port = 8080;
|
||||
let mut tab = Tab::new(
|
||||
index,
|
||||
position,
|
||||
|
|
@ -760,9 +807,13 @@ fn create_new_tab_with_sixel_support(
|
|||
styled_underlines,
|
||||
explicitly_disable_kitty_keyboard_protocol,
|
||||
None,
|
||||
false,
|
||||
web_sharing,
|
||||
current_group,
|
||||
currently_marking_pane_group,
|
||||
advanced_mouse_actions,
|
||||
web_server_ip,
|
||||
web_server_port,
|
||||
);
|
||||
tab.apply_layout(
|
||||
TiledPaneLayout::default(),
|
||||
|
|
|
|||
|
|
@ -8,15 +8,16 @@ use crate::{
|
|||
thread_bus::ThreadSenders,
|
||||
ClientId,
|
||||
};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::path::PathBuf;
|
||||
use zellij_utils::data::{Direction, Resize, ResizeStrategy};
|
||||
use zellij_utils::data::{Direction, Resize, ResizeStrategy, WebSharing};
|
||||
use zellij_utils::errors::prelude::*;
|
||||
use zellij_utils::input::layout::{SplitDirection, SplitSize, TiledPaneLayout};
|
||||
use zellij_utils::ipc::IpcReceiverWithContext;
|
||||
use zellij_utils::pane_size::{Size, SizeInPixels};
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::os::unix::io::RawFd;
|
||||
use std::rc::Rc;
|
||||
|
||||
|
|
@ -157,10 +158,10 @@ fn create_new_tab(size: Size, stacked_resize: bool) -> Tab {
|
|||
let auto_layout = true;
|
||||
let client_id = 1;
|
||||
let session_is_mirrored = true;
|
||||
let mut connected_clients = HashSet::new();
|
||||
let mut connected_clients = HashMap::new();
|
||||
let character_cell_info = Rc::new(RefCell::new(None));
|
||||
let stacked_resize = Rc::new(RefCell::new(stacked_resize));
|
||||
connected_clients.insert(client_id);
|
||||
connected_clients.insert(client_id, false);
|
||||
let connected_clients = Rc::new(RefCell::new(connected_clients));
|
||||
let terminal_emulator_colors = Rc::new(RefCell::new(Palette::default()));
|
||||
let copy_options = CopyOptions::default();
|
||||
|
|
@ -173,6 +174,9 @@ fn create_new_tab(size: Size, stacked_resize: bool) -> Tab {
|
|||
let styled_underlines = true;
|
||||
let explicitly_disable_kitty_keyboard_protocol = false;
|
||||
let advanced_mouse_actions = true;
|
||||
let web_sharing = WebSharing::Off;
|
||||
let web_server_ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
|
||||
let web_server_port = 8080;
|
||||
let mut tab = Tab::new(
|
||||
index,
|
||||
position,
|
||||
|
|
@ -201,9 +205,13 @@ fn create_new_tab(size: Size, stacked_resize: bool) -> Tab {
|
|||
styled_underlines,
|
||||
explicitly_disable_kitty_keyboard_protocol,
|
||||
None,
|
||||
false,
|
||||
web_sharing,
|
||||
current_pane_group,
|
||||
currently_marking_pane_group,
|
||||
advanced_mouse_actions,
|
||||
web_server_ip,
|
||||
web_server_port,
|
||||
);
|
||||
tab.apply_layout(
|
||||
TiledPaneLayout::default(),
|
||||
|
|
@ -230,10 +238,10 @@ fn create_new_tab_with_layout(size: Size, layout: TiledPaneLayout) -> Tab {
|
|||
let auto_layout = true;
|
||||
let client_id = 1;
|
||||
let session_is_mirrored = true;
|
||||
let mut connected_clients = HashSet::new();
|
||||
let mut connected_clients = HashMap::new();
|
||||
let character_cell_info = Rc::new(RefCell::new(None));
|
||||
let stacked_resize = Rc::new(RefCell::new(true));
|
||||
connected_clients.insert(client_id);
|
||||
connected_clients.insert(client_id, false);
|
||||
let connected_clients = Rc::new(RefCell::new(connected_clients));
|
||||
let terminal_emulator_colors = Rc::new(RefCell::new(Palette::default()));
|
||||
let copy_options = CopyOptions::default();
|
||||
|
|
@ -246,6 +254,9 @@ fn create_new_tab_with_layout(size: Size, layout: TiledPaneLayout) -> Tab {
|
|||
let styled_underlines = true;
|
||||
let explicitly_disable_kitty_keyboard_protocol = false;
|
||||
let advanced_mouse_actions = true;
|
||||
let web_sharing = WebSharing::Off;
|
||||
let web_server_ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
|
||||
let web_server_port = 8080;
|
||||
let mut tab = Tab::new(
|
||||
index,
|
||||
position,
|
||||
|
|
@ -274,9 +285,13 @@ fn create_new_tab_with_layout(size: Size, layout: TiledPaneLayout) -> Tab {
|
|||
styled_underlines,
|
||||
explicitly_disable_kitty_keyboard_protocol,
|
||||
None,
|
||||
false,
|
||||
web_sharing,
|
||||
current_pane_group,
|
||||
currently_marking_pane_group,
|
||||
advanced_mouse_actions,
|
||||
web_server_ip,
|
||||
web_server_port,
|
||||
);
|
||||
let mut new_terminal_ids = vec![];
|
||||
for i in 0..layout.extract_run_instructions().len() {
|
||||
|
|
@ -310,8 +325,8 @@ fn create_new_tab_with_cell_size(
|
|||
let auto_layout = true;
|
||||
let client_id = 1;
|
||||
let session_is_mirrored = true;
|
||||
let mut connected_clients = HashSet::new();
|
||||
connected_clients.insert(client_id);
|
||||
let mut connected_clients = HashMap::new();
|
||||
connected_clients.insert(client_id, false);
|
||||
let connected_clients = Rc::new(RefCell::new(connected_clients));
|
||||
let terminal_emulator_colors = Rc::new(RefCell::new(Palette::default()));
|
||||
let copy_options = CopyOptions::default();
|
||||
|
|
@ -325,6 +340,9 @@ fn create_new_tab_with_cell_size(
|
|||
let styled_underlines = true;
|
||||
let explicitly_disable_kitty_keyboard_protocol = false;
|
||||
let advanced_mouse_actions = true;
|
||||
let web_sharing = WebSharing::Off;
|
||||
let web_server_ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
|
||||
let web_server_port = 8080;
|
||||
let mut tab = Tab::new(
|
||||
index,
|
||||
position,
|
||||
|
|
@ -353,9 +371,13 @@ fn create_new_tab_with_cell_size(
|
|||
styled_underlines,
|
||||
explicitly_disable_kitty_keyboard_protocol,
|
||||
None,
|
||||
false,
|
||||
web_sharing,
|
||||
current_pane_group,
|
||||
currently_marking_pane_group,
|
||||
advanced_mouse_actions,
|
||||
web_server_ip,
|
||||
web_server_port,
|
||||
);
|
||||
tab.apply_layout(
|
||||
TiledPaneLayout::default(),
|
||||
|
|
|
|||
|
|
@ -292,6 +292,7 @@ impl PaneFrame {
|
|||
full_indication.push(EMPTY_TERMINAL_CHARACTER);
|
||||
full_indication.append(&mut text.clone());
|
||||
short_indication_len += 2;
|
||||
short_indication.push(EMPTY_TERMINAL_CHARACTER);
|
||||
short_indication.append(&mut text);
|
||||
}
|
||||
if full_indication_len + 4 <= max_length {
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ use crate::{
|
|||
ClientId, ServerInstruction, SessionMetaData, ThreadSenders,
|
||||
};
|
||||
use insta::assert_snapshot;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::path::PathBuf;
|
||||
use zellij_utils::cli::CliAction;
|
||||
use zellij_utils::data::{Event, Resize, Style};
|
||||
use zellij_utils::data::{Event, Resize, Style, WebSharing};
|
||||
use zellij_utils::errors::{prelude::*, ErrorContext};
|
||||
use zellij_utils::input::actions::Action;
|
||||
use zellij_utils::input::command::{RunCommand, TerminalAction};
|
||||
|
|
@ -273,6 +274,9 @@ fn create_new_screen(size: Size, advanced_mouse_actions: bool) -> Screen {
|
|||
let arrow_fonts = true;
|
||||
let explicitly_disable_kitty_keyboard_protocol = false;
|
||||
let stacked_resize = true;
|
||||
let web_sharing = WebSharing::Off;
|
||||
let web_server_ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
|
||||
let web_server_port = 8080;
|
||||
let screen = Screen::new(
|
||||
bus,
|
||||
&client_attributes,
|
||||
|
|
@ -295,7 +299,11 @@ fn create_new_screen(size: Size, advanced_mouse_actions: bool) -> Screen {
|
|||
explicitly_disable_kitty_keyboard_protocol,
|
||||
stacked_resize,
|
||||
None,
|
||||
false,
|
||||
web_sharing,
|
||||
advanced_mouse_actions,
|
||||
web_server_ip,
|
||||
web_server_port,
|
||||
);
|
||||
screen
|
||||
}
|
||||
|
|
@ -387,7 +395,7 @@ impl MockScreen {
|
|||
tab_name,
|
||||
(vec![], vec![]), // swap layouts
|
||||
should_change_focus_to_new_tab,
|
||||
self.main_client_id,
|
||||
(self.main_client_id, false),
|
||||
));
|
||||
let _ = self.to_screen.send(ScreenInstruction::ApplyLayout(
|
||||
pane_layout,
|
||||
|
|
@ -397,7 +405,7 @@ impl MockScreen {
|
|||
plugin_ids,
|
||||
tab_index,
|
||||
true,
|
||||
self.main_client_id,
|
||||
(self.main_client_id, false),
|
||||
));
|
||||
self.last_opened_tab_index = Some(tab_index);
|
||||
screen_thread
|
||||
|
|
@ -474,7 +482,7 @@ impl MockScreen {
|
|||
tab_name,
|
||||
(vec![], vec![]), // swap layouts
|
||||
should_change_focus_to_new_tab,
|
||||
self.main_client_id,
|
||||
(self.main_client_id, false),
|
||||
));
|
||||
let _ = self.to_screen.send(ScreenInstruction::ApplyLayout(
|
||||
pane_layout,
|
||||
|
|
@ -484,7 +492,7 @@ impl MockScreen {
|
|||
plugin_ids,
|
||||
tab_index,
|
||||
true,
|
||||
self.main_client_id,
|
||||
(self.main_client_id, false),
|
||||
));
|
||||
self.last_opened_tab_index = Some(tab_index);
|
||||
screen_thread
|
||||
|
|
@ -508,7 +516,7 @@ impl MockScreen {
|
|||
tab_name,
|
||||
(vec![], vec![]), // swap layouts
|
||||
should_change_focus_to_new_tab,
|
||||
self.main_client_id,
|
||||
(self.main_client_id, false),
|
||||
));
|
||||
let _ = self.to_screen.send(ScreenInstruction::ApplyLayout(
|
||||
tab_layout,
|
||||
|
|
@ -518,7 +526,7 @@ impl MockScreen {
|
|||
plugin_ids,
|
||||
0,
|
||||
true,
|
||||
self.main_client_id,
|
||||
(self.main_client_id, false),
|
||||
));
|
||||
self.last_opened_tab_index = Some(tab_index);
|
||||
}
|
||||
|
|
@ -548,6 +556,7 @@ impl MockScreen {
|
|||
session_configuration: self.session_metadata.session_configuration.clone(),
|
||||
layout,
|
||||
current_input_modes: self.session_metadata.current_input_modes.clone(),
|
||||
web_sharing: WebSharing::Off,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -606,6 +615,7 @@ impl MockScreen {
|
|||
layout,
|
||||
session_configuration: Default::default(),
|
||||
current_input_modes: HashMap::new(),
|
||||
web_sharing: WebSharing::Off,
|
||||
};
|
||||
|
||||
let os_input = FakeInputOutput::default();
|
||||
|
|
@ -680,7 +690,7 @@ fn new_tab(screen: &mut Screen, pid: u32, tab_index: usize) {
|
|||
new_plugin_ids,
|
||||
tab_index,
|
||||
true,
|
||||
client_id,
|
||||
(client_id, false),
|
||||
)
|
||||
.expect("TEST");
|
||||
}
|
||||
|
|
@ -1335,7 +1345,7 @@ fn attach_after_first_tab_closed() {
|
|||
|
||||
screen.close_tab_at_index(0).expect("TEST");
|
||||
screen.remove_client(1).expect("TEST");
|
||||
screen.add_client(1).expect("TEST");
|
||||
screen.add_client(1, false).expect("TEST");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -3594,7 +3604,7 @@ pub fn screen_can_break_pane_to_a_new_tab() {
|
|||
Default::default(),
|
||||
1,
|
||||
true,
|
||||
1,
|
||||
(1, false),
|
||||
));
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
// move back to make sure the other pane is in the previous tab
|
||||
|
|
@ -3696,7 +3706,7 @@ pub fn screen_can_break_floating_pane_to_a_new_tab() {
|
|||
Default::default(),
|
||||
1,
|
||||
true,
|
||||
1,
|
||||
(1, false),
|
||||
));
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
// move back to make sure the other pane is in the previous tab
|
||||
|
|
@ -3766,7 +3776,7 @@ pub fn screen_can_break_plugin_pane_to_a_new_tab() {
|
|||
Default::default(),
|
||||
1,
|
||||
true,
|
||||
1,
|
||||
(1, false),
|
||||
));
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
// move back to make sure the other pane is in the previous tab
|
||||
|
|
@ -3840,7 +3850,7 @@ pub fn screen_can_break_floating_plugin_pane_to_a_new_tab() {
|
|||
Default::default(),
|
||||
1,
|
||||
true,
|
||||
1,
|
||||
(1, false),
|
||||
));
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
// move back to make sure the other pane is in the previous tab
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
---
|
||||
source: zellij-server/src/./unit/screen_tests.rs
|
||||
assertion_line: 2665
|
||||
expression: "format!(\"{:#?}\", new_tab_action)"
|
||||
---
|
||||
Some(
|
||||
|
|
@ -61,6 +60,9 @@ Some(
|
|||
[],
|
||||
0,
|
||||
true,
|
||||
(
|
||||
1,
|
||||
false,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
---
|
||||
source: zellij-server/src/./unit/screen_tests.rs
|
||||
assertion_line: 2711
|
||||
expression: "format!(\"{:#?}\", new_tab_instruction)"
|
||||
---
|
||||
NewTab(
|
||||
|
|
@ -88,5 +87,8 @@ NewTab(
|
|||
[],
|
||||
1,
|
||||
true,
|
||||
(
|
||||
10,
|
||||
false,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ use zellij_utils::data::*;
|
|||
use zellij_utils::errors::prelude::*;
|
||||
use zellij_utils::input::actions::Action;
|
||||
pub use zellij_utils::plugin_api;
|
||||
use zellij_utils::plugin_api::plugin_command::ProtobufPluginCommand;
|
||||
use zellij_utils::plugin_api::plugin_command::{
|
||||
CreateTokenResponse, ListTokensResponse, ProtobufPluginCommand, RenameWebTokenResponse,
|
||||
RevokeAllWebTokensResponse, RevokeTokenResponse,
|
||||
};
|
||||
use zellij_utils::plugin_api::plugin_ids::{ProtobufPluginIds, ProtobufZellijVersion};
|
||||
|
||||
pub use super::ui_components::*;
|
||||
|
|
@ -1273,6 +1276,41 @@ pub fn change_floating_panes_coordinates(
|
|||
unsafe { host_run_plugin_command() };
|
||||
}
|
||||
|
||||
pub fn start_web_server() {
|
||||
let plugin_command = PluginCommand::StartWebServer;
|
||||
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
|
||||
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
|
||||
unsafe { host_run_plugin_command() };
|
||||
}
|
||||
|
||||
pub fn stop_web_server() {
|
||||
let plugin_command = PluginCommand::StopWebServer;
|
||||
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
|
||||
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
|
||||
unsafe { host_run_plugin_command() };
|
||||
}
|
||||
|
||||
pub fn query_web_server_status() {
|
||||
let plugin_command = PluginCommand::QueryWebServerStatus;
|
||||
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
|
||||
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
|
||||
unsafe { host_run_plugin_command() };
|
||||
}
|
||||
|
||||
pub fn share_current_session() {
|
||||
let plugin_command = PluginCommand::ShareCurrentSession;
|
||||
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
|
||||
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
|
||||
unsafe { host_run_plugin_command() };
|
||||
}
|
||||
|
||||
pub fn stop_sharing_current_session() {
|
||||
let plugin_command = PluginCommand::StopSharingCurrentSession;
|
||||
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
|
||||
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
|
||||
unsafe { host_run_plugin_command() };
|
||||
}
|
||||
|
||||
pub fn group_and_ungroup_panes(pane_ids_to_group: Vec<PaneId>, pane_ids_to_ungroup: Vec<PaneId>) {
|
||||
let plugin_command =
|
||||
PluginCommand::GroupAndUngroupPanes(pane_ids_to_group, pane_ids_to_ungroup);
|
||||
|
|
@ -1313,6 +1351,92 @@ pub fn embed_multiple_panes(pane_ids: Vec<PaneId>) {
|
|||
unsafe { host_run_plugin_command() };
|
||||
}
|
||||
|
||||
pub fn set_self_mouse_selection_support(selection_support: bool) {
|
||||
let plugin_command = PluginCommand::SetSelfMouseSelectionSupport(selection_support);
|
||||
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
|
||||
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
|
||||
unsafe { host_run_plugin_command() };
|
||||
}
|
||||
|
||||
pub fn generate_web_login_token(token_label: Option<String>) -> Result<String, String> {
|
||||
let plugin_command = PluginCommand::GenerateWebLoginToken(token_label);
|
||||
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
|
||||
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
|
||||
unsafe { host_run_plugin_command() };
|
||||
let create_token_response =
|
||||
CreateTokenResponse::decode(bytes_from_stdin().unwrap().as_slice()).unwrap();
|
||||
if let Some(error) = create_token_response.error {
|
||||
Err(error)
|
||||
} else if let Some(token) = create_token_response.token {
|
||||
Ok(token)
|
||||
} else {
|
||||
Err("Received empty response".to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn revoke_web_login_token(token_label: &str) -> Result<(), String> {
|
||||
let plugin_command = PluginCommand::RevokeWebLoginToken(token_label.to_owned());
|
||||
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
|
||||
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
|
||||
unsafe { host_run_plugin_command() };
|
||||
let revoke_token_response =
|
||||
RevokeTokenResponse::decode(bytes_from_stdin().unwrap().as_slice()).unwrap();
|
||||
if let Some(error) = revoke_token_response.error {
|
||||
Err(error)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_web_login_tokens() -> Result<Vec<(String, String)>, String> {
|
||||
// (name, created_at)
|
||||
let plugin_command = PluginCommand::ListWebLoginTokens;
|
||||
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
|
||||
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
|
||||
unsafe { host_run_plugin_command() };
|
||||
let list_tokens_response =
|
||||
ListTokensResponse::decode(bytes_from_stdin().unwrap().as_slice()).unwrap();
|
||||
if let Some(error) = list_tokens_response.error {
|
||||
Err(error)
|
||||
} else {
|
||||
let tokens_and_creation_times = std::iter::zip(
|
||||
list_tokens_response.tokens,
|
||||
list_tokens_response.creation_times,
|
||||
)
|
||||
.collect();
|
||||
Ok(tokens_and_creation_times)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn revoke_all_web_tokens() -> Result<(), String> {
|
||||
let plugin_command = PluginCommand::RevokeAllWebLoginTokens;
|
||||
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
|
||||
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
|
||||
unsafe { host_run_plugin_command() };
|
||||
let revoke_all_web_tokens_response =
|
||||
RevokeAllWebTokensResponse::decode(bytes_from_stdin().unwrap().as_slice()).unwrap();
|
||||
if let Some(error) = revoke_all_web_tokens_response.error {
|
||||
Err(error)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rename_web_token(old_name: &str, new_name: &str) -> Result<(), String> {
|
||||
let plugin_command =
|
||||
PluginCommand::RenameWebLoginToken(old_name.to_owned(), new_name.to_owned());
|
||||
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
|
||||
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
|
||||
unsafe { host_run_plugin_command() };
|
||||
let rename_web_token_response =
|
||||
RenameWebTokenResponse::decode(bytes_from_stdin().unwrap().as_slice()).unwrap();
|
||||
if let Some(error) = rename_web_token_response.error {
|
||||
Err(error)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn intercept_key_presses() {
|
||||
let plugin_command = PluginCommand::InterceptKeyPresses;
|
||||
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ miette = { workspace = true }
|
|||
nix = { workspace = true }
|
||||
percent-encoding = { version = "2.1.0", default-features = false, features = ["std"] }
|
||||
prost = { workspace = true }
|
||||
rmp-serde = { version = "1.1.0", default-features = false }
|
||||
rmp-serde = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
shellexpand = { version = "3.0.0", default-features = false, features = ["base-0", "tilde"] }
|
||||
|
|
@ -39,6 +39,7 @@ thiserror = { workspace = true }
|
|||
unicode-width = { workspace = true }
|
||||
url = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_family = "wasm"))'.dependencies]
|
||||
async-std = { workspace = true }
|
||||
|
|
@ -48,6 +49,12 @@ interprocess = { workspace = true }
|
|||
openssl-sys = { version = "0.9.93", default-features = false, features = ["vendored"], optional = true }
|
||||
isahc = { workspace = true }
|
||||
curl-sys = { version = "0.4", default-features = false, features = ["force-system-lib-on-osx", "ssl"], optional = true }
|
||||
humantime = { workspace = true }
|
||||
suggest = { workspace = true }
|
||||
names = { workspace = true }
|
||||
rusqlite = { version = "0.30", default-features = false, features = ["bundled"], optional = true }
|
||||
notify = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
insta = { version = "1.6.0", features = ["backtrace"] }
|
||||
|
|
@ -64,4 +71,5 @@ prost-build = "0.11.9"
|
|||
disable_automatic_asset_installation = []
|
||||
unstable = []
|
||||
plugins_from_target = []
|
||||
web_server_capability = ["dep:rusqlite"]
|
||||
vendored_curl = ["isahc/static-curl", "dep:openssl-sys", "dep:curl-sys"]
|
||||
|
|
|
|||
|
|
@ -142,6 +142,13 @@ keybinds {
|
|||
};
|
||||
SwitchToMode "Normal"
|
||||
}
|
||||
bind "s" {
|
||||
LaunchOrFocusPlugin "zellij:share" {
|
||||
floating true
|
||||
move_to_focused_tab true
|
||||
};
|
||||
SwitchToMode "Normal"
|
||||
}
|
||||
}
|
||||
tmux {
|
||||
bind "[" { SwitchToMode "Scroll"; }
|
||||
|
|
@ -419,6 +426,70 @@ load_plugins {
|
|||
//
|
||||
// support_kitty_keyboard_protocol false
|
||||
|
||||
// Whether to make sure a local web server is running when a new Zellij session starts.
|
||||
// This web server will allow creating new sessions and attaching to existing ones that have
|
||||
// opted in to being shared in the browser.
|
||||
// When enabled, navigate to http://127.0.0.1:8082
|
||||
// (Requires restart)
|
||||
//
|
||||
// Note: a local web server can still be manually started from within a Zellij session or from the CLI.
|
||||
// If this is not desired, one can use a version of Zellij compiled without
|
||||
// `web_server_capability`
|
||||
//
|
||||
// Possible values:
|
||||
// - true
|
||||
// - false
|
||||
// Default: false
|
||||
//
|
||||
// web_server true
|
||||
|
||||
// Whether to allow sessions started in the terminal to be shared through a local web server, assuming one is
|
||||
// running (see the `web_server` option for more details).
|
||||
// (Requires restart)
|
||||
//
|
||||
// Note: This is an administrative separation and not intended as a security measure.
|
||||
//
|
||||
// Possible values:
|
||||
// - "on" (allow web sharing through the local web server if it
|
||||
// is online)
|
||||
// - "off" (do not allow web sharing unless sessions explicitly opt-in to it)
|
||||
// - "disabled" (do not allow web sharing and do not permit sessions started in the terminal to opt-in to it)
|
||||
// Default: "off"
|
||||
//
|
||||
// web_sharing "on"
|
||||
|
||||
// The ip address the web server should listen on when it starts
|
||||
// Default: "127.0.0.1"
|
||||
// (Requires restart)
|
||||
//
|
||||
// web_server_ip "127.0.0.1"
|
||||
|
||||
|
||||
// A path to a certificate file to be used when setting up the web client to serve the
|
||||
// connection over HTTPs
|
||||
//
|
||||
// web_server_cert "/path/to/my/cert.pem"
|
||||
|
||||
// A path to a key file to be used when setting up the web client to serve the
|
||||
// connection over HTTPs
|
||||
//
|
||||
// web_server_key "/path/to/my/key.pem"
|
||||
|
||||
// Whether to enforce https connections to the web server when it is bound to localhost
|
||||
// (127.0.0.0/8)
|
||||
//
|
||||
// Note: https is ALWAYS enforced when bound to non-local interfaces
|
||||
//
|
||||
// Default: false
|
||||
//
|
||||
// enforce_https_for_localhost true
|
||||
|
||||
// The port the web server should listen on when it starts
|
||||
// Default: 8082
|
||||
// (Requires restart)
|
||||
//
|
||||
// web_server_port 8082
|
||||
|
||||
// Whether to stack panes when resizing beyond a certain size
|
||||
// Default: true
|
||||
//
|
||||
|
|
@ -433,3 +504,7 @@ load_plugins {
|
|||
// Default: true
|
||||
//
|
||||
// advanced_mouse_actions false
|
||||
|
||||
web_client {
|
||||
font "monospace"
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue