zellij/default-plugins/share/src/token_management_screen.rs
Aram Drevekenin c5ac796880
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>
2025-06-23 19:19:37 +02:00

607 lines
21 KiB
Rust

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);
}
}
}