zellij/default-plugins/tab-bar/src/line.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

469 lines
15 KiB
Rust

use ansi_term::ANSIStrings;
use unicode_width::UnicodeWidthStr;
use crate::{LinePart, ARROW_SEPARATOR};
use zellij_tile::prelude::actions::Action;
use zellij_tile::prelude::*;
use zellij_tile_utils::style;
fn get_current_title_len(current_title: &[LinePart]) -> usize {
current_title.iter().map(|p| p.len).sum()
}
// move elements from before_active and after_active into tabs_to_render while they fit in cols
// adds collapsed_tabs to the left and right if there's left over tabs that don't fit
fn populate_tabs_in_tab_line(
tabs_before_active: &mut Vec<LinePart>,
tabs_after_active: &mut Vec<LinePart>,
tabs_to_render: &mut Vec<LinePart>,
cols: usize,
palette: Styling,
capabilities: PluginCapabilities,
) {
let mut middle_size = get_current_title_len(tabs_to_render);
let mut total_left = 0;
let mut total_right = 0;
loop {
let left_count = tabs_before_active.len();
let right_count = tabs_after_active.len();
// left_more_tab_index is first tab to the left of the leftmost visible tab
let left_more_tab_index = left_count.saturating_sub(1);
let collapsed_left = left_more_message(
left_count,
palette,
tab_separator(capabilities),
left_more_tab_index,
);
// right_more_tab_index is the first tab to the right of the rightmost visible tab
let right_more_tab_index = left_count + tabs_to_render.len();
let collapsed_right = right_more_message(
right_count,
palette,
tab_separator(capabilities),
right_more_tab_index,
);
let total_size = collapsed_left.len + middle_size + collapsed_right.len;
if total_size > cols {
// break and dont add collapsed tabs to tabs_to_render, they will not fit
break;
}
let left = if let Some(tab) = tabs_before_active.last() {
tab.len
} else {
usize::MAX
};
let right = if let Some(tab) = tabs_after_active.first() {
tab.len
} else {
usize::MAX
};
// total size is shortened if the next tab to be added is the last one, as that will remove the collapsed tab
let size_by_adding_left =
left.saturating_add(total_size)
.saturating_sub(if left_count == 1 {
collapsed_left.len
} else {
0
});
let size_by_adding_right =
right
.saturating_add(total_size)
.saturating_sub(if right_count == 1 {
collapsed_right.len
} else {
0
});
let left_fits = size_by_adding_left <= cols;
let right_fits = size_by_adding_right <= cols;
// active tab is kept in the middle by adding to the side that
// has less width, or if the tab on the other side doesn't fit
if (total_left <= total_right || !right_fits) && left_fits {
// add left tab
let tab = tabs_before_active.pop().unwrap();
middle_size += tab.len;
total_left += tab.len;
tabs_to_render.insert(0, tab);
} else if right_fits {
// add right tab
let tab = tabs_after_active.remove(0);
middle_size += tab.len;
total_right += tab.len;
tabs_to_render.push(tab);
} else {
// there's either no space to add more tabs or no more tabs to add, so we're done
tabs_to_render.insert(0, collapsed_left);
tabs_to_render.push(collapsed_right);
break;
}
}
}
fn left_more_message(
tab_count_to_the_left: usize,
palette: Styling,
separator: &str,
tab_index: usize,
) -> LinePart {
if tab_count_to_the_left == 0 {
return LinePart::default();
}
let more_text = if tab_count_to_the_left < 10000 {
format!(" ← +{} ", tab_count_to_the_left)
} else {
" ← +many ".to_string()
};
// 238
// chars length plus separator length on both sides
let more_text_len = more_text.width() + 2 * separator.width();
let (text_color, sep_color) = (
palette.ribbon_unselected.base,
palette.text_unselected.background,
);
let left_separator = style!(sep_color, palette.ribbon_unselected.background).paint(separator);
let more_styled_text = style!(text_color, palette.ribbon_unselected.background)
.bold()
.paint(more_text);
let right_separator = style!(palette.ribbon_unselected.background, sep_color).paint(separator);
let more_styled_text =
ANSIStrings(&[left_separator, more_styled_text, right_separator]).to_string();
LinePart {
part: more_styled_text,
len: more_text_len,
tab_index: Some(tab_index),
}
}
fn right_more_message(
tab_count_to_the_right: usize,
palette: Styling,
separator: &str,
tab_index: usize,
) -> LinePart {
if tab_count_to_the_right == 0 {
return LinePart::default();
};
let more_text = if tab_count_to_the_right < 10000 {
format!(" +{}", tab_count_to_the_right)
} else {
" +many → ".to_string()
};
// chars length plus separator length on both sides
let more_text_len = more_text.width() + 2 * separator.width();
let (text_color, sep_color) = (
palette.ribbon_unselected.base,
palette.text_unselected.background,
);
let left_separator = style!(sep_color, palette.ribbon_unselected.background).paint(separator);
let more_styled_text = style!(text_color, palette.ribbon_unselected.background)
.bold()
.paint(more_text);
let right_separator = style!(palette.ribbon_unselected.background, sep_color).paint(separator);
let more_styled_text =
ANSIStrings(&[left_separator, more_styled_text, right_separator]).to_string();
LinePart {
part: more_styled_text,
len: more_text_len,
tab_index: Some(tab_index),
}
}
fn tab_line_prefix(session_name: Option<&str>, palette: Styling, cols: usize) -> Vec<LinePart> {
let prefix_text = " Zellij ".to_string();
let running_text_len = prefix_text.chars().count();
let text_color = palette.text_unselected.base;
let bg_color = palette.text_unselected.background;
let prefix_styled_text = style!(text_color, bg_color).bold().paint(prefix_text);
let mut parts = vec![LinePart {
part: prefix_styled_text.to_string(),
len: running_text_len,
tab_index: None,
}];
if let Some(name) = session_name {
let name_part = format!("({}) ", name);
let name_part_len = name_part.width();
let text_color = palette.text_unselected.base;
let name_part_styled_text = style!(text_color, bg_color).bold().paint(name_part);
if cols.saturating_sub(running_text_len) >= name_part_len {
parts.push(LinePart {
part: name_part_styled_text.to_string(),
len: name_part_len,
tab_index: None,
})
}
}
parts
}
pub fn tab_separator(capabilities: PluginCapabilities) -> &'static str {
if !capabilities.arrow_fonts {
ARROW_SEPARATOR
} else {
""
}
}
pub fn tab_line(
session_name: Option<&str>,
mut all_tabs: Vec<LinePart>,
active_tab_index: usize,
cols: usize,
palette: Styling,
capabilities: PluginCapabilities,
hide_session_name: bool,
tab_info: Option<&TabInfo>,
mode_info: &ModeInfo,
hide_swap_layout_indicator: bool,
background: &PaletteColor,
) -> Vec<LinePart> {
let mut tabs_after_active = all_tabs.split_off(active_tab_index);
let mut tabs_before_active = all_tabs;
let active_tab = if !tabs_after_active.is_empty() {
tabs_after_active.remove(0)
} else {
tabs_before_active.pop().unwrap()
};
let mut prefix = match hide_session_name {
true => tab_line_prefix(None, palette, cols),
false => tab_line_prefix(session_name, palette, cols),
};
let mut swap_layout_indicator = if hide_swap_layout_indicator {
None
} else {
tab_info.and_then(|tab_info| {
swap_layout_status(
&tab_info.active_swap_layout_name,
tab_info.is_swap_layout_dirty,
mode_info,
!capabilities.arrow_fonts,
)
})
};
let non_tab_len =
get_current_title_len(&prefix) + swap_layout_indicator.as_ref().map(|s| s.len).unwrap_or(0);
// if active tab alone won't fit in cols, don't draw any tabs
if non_tab_len + active_tab.len > cols {
return prefix;
}
let mut tabs_to_render = vec![active_tab];
populate_tabs_in_tab_line(
&mut tabs_before_active,
&mut tabs_after_active,
&mut tabs_to_render,
cols.saturating_sub(non_tab_len),
palette,
capabilities,
);
prefix.append(&mut tabs_to_render);
prefix.append(&mut vec![LinePart {
part: match background {
PaletteColor::Rgb((r, g, b)) => format!("\u{1b}[48;2;{};{};{}m\u{1b}[0K", r, g, b),
PaletteColor::EightBit(color) => format!("\u{1b}[48;5;{}m\u{1b}[0K", color),
},
len: 0,
tab_index: None,
}]);
if let Some(mut swap_layout_indicator) = swap_layout_indicator.take() {
let remaining_space = cols
.saturating_sub(prefix.iter().fold(0, |len, part| len + part.len))
.saturating_sub(swap_layout_indicator.len);
let mut padding = String::new();
let mut padding_len = 0;
for _ in 0..remaining_space {
padding.push_str(
&style!(
palette.text_unselected.background,
palette.text_unselected.background
)
.paint(" ")
.to_string(),
);
padding_len += 1;
}
swap_layout_indicator.part = format!("{}{}", padding, swap_layout_indicator.part);
swap_layout_indicator.len += padding_len;
prefix.push(swap_layout_indicator);
}
prefix
}
fn swap_layout_status(
swap_layout_name: &Option<String>,
is_swap_layout_dirty: bool,
mode_info: &ModeInfo,
supports_arrow_fonts: bool,
) -> Option<LinePart> {
match swap_layout_name {
Some(swap_layout_name) => {
let mode_keybinds = mode_info.get_mode_keybinds();
let prev_next_keys = action_key_group(
&mode_keybinds,
&[&[Action::PreviousSwapLayout], &[Action::NextSwapLayout]],
);
let mut text = style_key_with_modifier(&prev_next_keys, Some(0));
text.append(&ribbon_as_line_part(
&swap_layout_name.to_uppercase(),
!is_swap_layout_dirty,
supports_arrow_fonts,
));
Some(text)
},
None => None,
}
}
pub fn ribbon_as_line_part(text: &str, is_selected: bool, supports_arrow_fonts: bool) -> LinePart {
let ribbon_text = if is_selected {
Text::new(text).selected()
} else {
Text::new(text)
};
let part = serialize_ribbon(&ribbon_text);
let mut len = text.width() + 2;
if supports_arrow_fonts {
len += 2;
};
LinePart {
part,
len,
tab_index: None,
}
}
pub fn style_key_with_modifier(keyvec: &[KeyWithModifier], color_index: Option<usize>) -> LinePart {
if keyvec.is_empty() {
return LinePart::default();
}
let common_modifiers = get_common_modifiers(keyvec.iter().collect());
let no_common_modifier = common_modifiers.is_empty();
let modifier_str = common_modifiers
.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>()
.join("-");
// Prints the keys
let key = keyvec
.iter()
.map(|key| {
if no_common_modifier || keyvec.len() == 1 {
format!("{}", key)
} else {
format!("{}", key.strip_common_modifiers(&common_modifiers))
}
})
.collect::<Vec<String>>();
// Special handling of some pre-defined keygroups
let key_string = key.join("");
let key_separator = match &key_string[..] {
"HJKL" => "",
"hjkl" => "",
"←↓↑→" => "",
"←→" => "",
"↓↑" => "",
"[]" => "",
_ => "|",
};
if no_common_modifier || key.len() == 1 {
let key_string_text = format!(" {} ", key.join(key_separator));
let text = if let Some(color_index) = color_index {
Text::new(&key_string_text)
.color_range(color_index, ..)
.opaque()
} else {
Text::new(&key_string_text).opaque()
};
LinePart {
part: serialize_text(&text),
len: key_string_text.width(),
..Default::default()
}
} else {
let key_string_without_modifier = format!("{}", key.join(key_separator));
let key_string_text = format!(" {} <{}> ", modifier_str, key_string_without_modifier);
let text = if let Some(color_index) = color_index {
Text::new(&key_string_text)
.color_range(color_index, ..modifier_str.width() + 1)
.color_range(
color_index,
modifier_str.width() + 3
..modifier_str.width() + 3 + key_string_without_modifier.width(),
)
.opaque()
} else {
Text::new(&key_string_text).opaque()
};
LinePart {
part: serialize_text(&text),
len: key_string_text.width(),
..Default::default()
}
}
}
pub fn get_common_modifiers(mut keyvec: Vec<&KeyWithModifier>) -> Vec<KeyModifier> {
if keyvec.is_empty() {
return vec![];
}
let mut common_modifiers = keyvec.pop().unwrap().key_modifiers.clone();
for key in keyvec {
common_modifiers = common_modifiers
.intersection(&key.key_modifiers)
.cloned()
.collect();
}
common_modifiers.into_iter().collect()
}
pub fn action_key_group(
keymap: &[(KeyWithModifier, Vec<Action>)],
actions: &[&[Action]],
) -> Vec<KeyWithModifier> {
let mut ret = vec![];
for action in actions {
ret.extend(action_key(keymap, action));
}
ret
}
pub fn action_key(
keymap: &[(KeyWithModifier, Vec<Action>)],
action: &[Action],
) -> Vec<KeyWithModifier> {
keymap
.iter()
.filter_map(|(key, acvec)| {
let matching = acvec
.iter()
.zip(action)
.filter(|(a, b)| a.shallow_eq(b))
.count();
if matching == acvec.len() && matching == action.len() {
Some(key.clone())
} else {
None
}
})
.collect::<Vec<KeyWithModifier>>()
}