aboutsummaryrefslogtreecommitdiffstats
path: root/default-plugins/share
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2025-06-23 19:19:37 +0200
committerGitHub <noreply@github.com>2025-06-23 19:19:37 +0200
commitc5ac7968809ddb1033b625562eae22019cccc2f4 (patch)
tree932cf07d898dfbcc6ec6a9d4cf5353c06a47accc /default-plugins/share
parentfeat(ui): add optional tooltip with key hints to the compact bar (#4225) (diff)
downloadsl0ppij-c5ac7968809ddb1033b625562eae22019cccc2f4.tar.gz
sl0ppij-c5ac7968809ddb1033b625562eae22019cccc2f4.tar.xz
sl0ppij-c5ac7968809ddb1033b625562eae22019cccc2f4.zip
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>
Diffstat (limited to 'default-plugins/share')
-rw-r--r--default-plugins/share/.cargo/config.toml2
-rw-r--r--default-plugins/share/.gitignore1
-rw-r--r--default-plugins/share/Cargo.toml11
l---------default-plugins/share/LICENSE.md1
-rw-r--r--default-plugins/share/src/main.rs647
-rw-r--r--default-plugins/share/src/main_screen.rs301
-rw-r--r--default-plugins/share/src/token_management_screen.rs607
-rw-r--r--default-plugins/share/src/token_screen.rs192
-rw-r--r--default-plugins/share/src/ui_components.rs637
9 files changed, 2399 insertions, 0 deletions
diff --git a/default-plugins/share/.cargo/config.toml b/default-plugins/share/.cargo/config.toml
new file mode 100644
index 00000000..6b509f5b
--- /dev/null
+++ b/default-plugins/share/.cargo/config.toml
@@ -0,0 +1,2 @@
+[build]
+target = "wasm32-wasip1"
diff --git a/default-plugins/share/.gitignore b/default-plugins/share/.gitignore
new file mode 100644
index 00000000..ea8c4bf7
--- /dev/null
+++ b/default-plugins/share/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/default-plugins/share/Cargo.toml b/default-plugins/share/Cargo.toml
new file mode 100644
index 00000000..e89b623c
--- /dev/null
+++ b/default-plugins/share/Cargo.toml
@@ -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"
diff --git a/default-plugins/share/LICENSE.md b/default-plugins/share/LICENSE.md
new file mode 120000
index 00000000..f0608a63
--- /dev/null
+++ b/default-plugins/share/LICENSE.md
@@ -0,0 +1 @@
+../../LICENSE.md \ No newline at end of file
diff --git a/default-plugins/share/src/main.rs b/default-plugins/share/src/main.rs
new file mode 100644
index 00000000..1708e495
--- /dev/null
+++ b/default-plugins/share/src/main.rs
@@ -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(&current_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
+ }
+}
diff --git a/default-plugins/share/src/main_screen.rs b/default-plugins/share/src/main_screen.rs
new file mode 100644
index 00000000..b1222592
--- /dev/null
+++ b/default-plugins/share/src/main_screen.rs
@@ -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,
+}
diff --git a/default-plugins/share/src/token_management_screen.rs b/default-plugins/share/src/token_management_screen.rs
new file mode 100644
index 00000000..0eb3a873
--- /dev/null
+++ b/default-plugins/share/src/token_management_screen.rs
@@ -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);
+ }
+ }
+}
diff --git a/default-plugins/share/src/token_screen.rs b/default-plugins/share/src/token_screen.rs
new file mode 100644
index 00000000..461b199d
--- /dev/null
+++ b/default-plugins/share/src/token_screen.rs
@@ -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,
+}
diff --git a/default-plugins/share/src/ui_components.rs b/default-plugins/share/src/ui_components.rs
new file mode 100644
index 00000000..00b79dd5
--- /dev/null
+++ b/default-plugins/share/src/ui_components.rs
@@ -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,
+ );
+}