Native web mobile UI (#5441)

* initial chrome

* mobile state

* feat(mobile-web): implement browser→server actions (fit toggle, single-pane fullscreen, pane/tab focus) with fullscreen-truth reconciliation

* panning

* remove mobile plugin

* fix first load race

* improve web handshake

* rustfmt

* cleanups

* moar cleanups

* add bundle asset check to ci

* fix(mobile-web): tab-scope co-presence, yield single-pane to desktop clients, fix render-mode desync

* make mobile use no-ui fullscreen

* client side welcome screen for mobile

* attach web clients to first tab on tiles surface

* fix: opening new pane in mobile single mode no longer exits mobile single mode

* mobile chrome light theme support

* fix server races and flaky tests

* rustfmt

* fix flakes

* add pr
This commit is contained in:
Aram Drevekenin
2026-08-05 15:32:53 +02:00
committed by GitHub
parent a7259350a8
commit 3239d37b40
136 changed files with 9591 additions and 10206 deletions
+18
View File
@@ -164,6 +164,24 @@ jobs:
- name: Test without web support
run: cargo xtask test --no-web
assets:
name: Check Bundled Assets
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
rustflags: ""
- name: Test xtask
run: cargo test -p xtask
- name: Check bundled assets
run: cargo xtask assets --check
format:
name: Check Formatting
runs-on: ubuntu-latest
+1 -1
View File
@@ -7,7 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
## [Unreleased]
* feat: allow tabs to have different sizes if clients aren't focused on the same one (https://github.com/zellij-org/zellij/pull/5133)
* feat: PWA support for the web client (manifest + icons + iOS meta tags) so the page can be installed as a standalone app (https://github.com/zellij-org/zellij/pull/5184)
* feat: mobile UI (https://github.com/zellij-org/zellij/pull/5241)
* feat: mobile UI (https://github.com/zellij-org/zellij/pull/5241 and https://github.com/zellij-org/zellij/pull/5441)
* chore: new integration testing infra (https://github.com/zellij-org/zellij/pull/5269)
* feat: FocusLastPane bindable action (https://github.com/zellij-org/zellij/pull/4241 and https://github.com/zellij-org/zellij/pull/5344)
* fix: live reload on theme dir changes (https://github.com/zellij-org/zellij/pull/5135)
Generated
+1 -9
View File
@@ -2310,15 +2310,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "mobile"
version = "0.1.0"
dependencies = [
"fuzzy-matcher",
"unicode-width 0.2.2",
"zellij-tile",
]
[[package]]
name = "multimap"
version = "0.8.3"
@@ -4955,6 +4946,7 @@ dependencies = [
"anyhow",
"prost-build",
"serde_json",
"sha2",
"toml 1.1.4+spec-1.1.0",
"which",
"xflags",
-1
View File
@@ -55,7 +55,6 @@ members = [
"default-plugins/multiple-select",
"default-plugins/layout-manager",
"default-plugins/link",
"default-plugins/mobile",
"zellij-client",
"zellij-integration-tests",
"zellij-server",
@@ -1,2 +0,0 @@
[build]
target = "wasm32-wasip1"
-1
View File
@@ -1 +0,0 @@
/target
-10
View File
@@ -1,10 +0,0 @@
[package]
name = "mobile"
version = "0.1.0"
authors = ["Aram Drevekenin <[email protected]>"]
edition.workspace = true
[dependencies]
zellij-tile = { path = "../../zellij-tile" }
fuzzy-matcher = { workspace = true }
unicode-width = { workspace = true }
-235
View File
@@ -1,235 +0,0 @@
use unicode_width::UnicodeWidthStr;
pub(crate) const RESET: &str = "\x1b[0m";
pub(crate) fn move_to(row: usize, col: usize) -> String {
format!("\x1b[{};{}H", row + 1, col + 1)
}
pub(crate) fn format_time_ago(then_unix_secs: Option<u64>, now_unix_secs: u64) -> String {
let Some(then) = then_unix_secs else {
return "—".to_string();
};
let diff = now_unix_secs.saturating_sub(then);
let body = if diff < 5 {
"just now".to_string()
} else if diff < 60 {
format!("{}s ago", diff)
} else if diff < 3600 {
format!("{}m ago", diff / 60)
} else if diff < 86400 {
format!("{}h ago", diff / 3600)
} else {
format!("{}d ago", diff / 86400)
};
format!("Active {}", body)
}
pub(crate) fn pad_or_truncate(text: &str, width: usize) -> String {
let text_w = UnicodeWidthStr::width(text);
if width == 0 {
return String::new();
}
if text_w == width {
return text.to_string();
}
if text_w < width {
return format!("{}{}", text, " ".repeat(width - text_w));
}
if width == 1 {
let first = text.chars().find(|c| char_width(*c) <= 1);
return first.map(String::from).unwrap_or_else(|| " ".to_string());
}
let mut out = String::new();
let mut taken = 0;
let target = width - 1;
for ch in text.chars() {
let w = char_width(ch);
if taken + w > target {
break;
}
out.push(ch);
taken += w;
}
out.push('…');
let out_w = UnicodeWidthStr::width(out.as_str());
out.push_str(&" ".repeat(width.saturating_sub(out_w)));
out
}
fn char_width(ch: char) -> usize {
let mut buf = [0u8; 4];
UnicodeWidthStr::width(ch.encode_utf8(&mut buf) as &str)
}
pub(crate) fn visible_width(text: &str) -> usize {
let bytes = text.as_bytes();
let mut width = 0;
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b {
i = skip_escape(bytes, i);
continue;
}
let ch_len = utf8_char_len(bytes[i]).max(1);
if let Some(s) = bytes
.get(i..i + ch_len)
.and_then(|b| std::str::from_utf8(b).ok())
{
width += UnicodeWidthStr::width(s);
}
i += ch_len;
}
width
}
pub(crate) fn slice_ansi_visible(line: &str, h_offset: usize, max_cols: usize) -> String {
if max_cols == 0 {
return String::new();
}
let bytes = line.as_bytes();
let right_edge = h_offset.saturating_add(max_cols);
let mut out = String::new();
let mut cell = 0usize;
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b {
let end = skip_escape(bytes, i);
if let Ok(esc) = std::str::from_utf8(&bytes[i..end]) {
out.push_str(esc);
}
i = end;
continue;
}
let ch_len = utf8_char_len(bytes[i]).max(1);
let end = (i + ch_len).min(bytes.len());
let Ok(ch) = std::str::from_utf8(&bytes[i..end]) else {
i = end;
continue;
};
let w = UnicodeWidthStr::width(ch);
if w == 0 {
if cell > h_offset && cell <= right_edge && !out.is_empty() {
out.push_str(ch);
}
} else if cell + w <= h_offset {
} else if cell < h_offset {
out.push(' ');
} else if cell >= right_edge || cell + w > right_edge {
break;
} else {
out.push_str(ch);
}
cell += w;
i = end;
}
out.push_str(RESET);
out
}
fn skip_escape(bytes: &[u8], start: usize) -> usize {
let mut i = start + 1;
match bytes.get(i) {
Some(b'[') => {
i += 1;
while i < bytes.len() && !(0x40..=0x7e).contains(&bytes[i]) {
i += 1;
}
(i + 1).min(bytes.len())
},
Some(b']') => {
i += 1;
while i < bytes.len()
&& bytes[i] != 0x07
&& !(bytes[i] == 0x1b && bytes.get(i + 1) == Some(&b'\\'))
{
i += 1;
}
match bytes.get(i) {
Some(0x07) => i + 1,
Some(0x1b) => (i + 2).min(bytes.len()),
_ => i,
}
},
Some(_) => i + 1,
None => i,
}
}
fn utf8_char_len(byte: u8) -> usize {
match byte {
b if b < 0xc0 => 1,
b if b < 0xe0 => 2,
b if b < 0xf0 => 3,
_ => 4,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn visible(s: &str) -> &str {
s.strip_suffix(RESET).unwrap_or(s)
}
#[test]
fn ascii_slice_inside_line() {
assert_eq!(visible(&slice_ansi_visible("abcdefghij", 2, 4)), "cdef");
}
#[test]
fn ascii_slice_at_left_edge() {
assert_eq!(visible(&slice_ansi_visible("abcdefghij", 0, 4)), "abcd");
}
#[test]
fn ascii_slice_past_right_edge() {
assert_eq!(visible(&slice_ansi_visible("abcd", 1, 10)), "bcd");
}
#[test]
fn empty_when_offset_past_line_width() {
let sliced = slice_ansi_visible("abcd", 10, 4);
assert_eq!(visible(&sliced), "");
assert!(sliced.ends_with(RESET));
}
#[test]
fn max_cols_zero_returns_empty() {
assert_eq!(slice_ansi_visible("abcd", 0, 0), "");
}
#[test]
fn ansi_escape_preserved_when_in_window() {
let sliced = slice_ansi_visible("\x1b[31mred\x1b[0m end", 0, 7);
assert!(sliced.contains("\x1b[31m"));
assert!(sliced.contains("\x1b[0m"));
assert!(sliced.contains("red"));
assert!(sliced.contains("end"));
}
#[test]
fn ansi_escape_replayed_when_offset_skips_text() {
let sliced = slice_ansi_visible("\x1b[31maaaa\x1b[32mbbbb", 4, 4);
assert!(sliced.contains("\x1b[31m"));
assert!(sliced.contains("\x1b[32m"));
assert!(sliced.contains("bbbb"));
assert!(!sliced.contains("aaaa"));
}
#[test]
fn wide_char_straddling_left_boundary_becomes_space() {
assert_eq!(visible(&slice_ansi_visible("中abc", 1, 3)), " ab");
}
#[test]
fn wide_char_straddling_right_boundary_dropped() {
assert_eq!(visible(&slice_ansi_visible("ab中cd", 0, 3)), "ab");
}
#[test]
fn wide_char_entirely_inside_window() {
assert_eq!(visible(&slice_ansi_visible("ab中cd", 0, 4)), "ab中");
}
}
-131
View File
@@ -1,131 +0,0 @@
use zellij_tile::prelude::*;
use crate::state::State;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClickAction {
ExpandSessions,
ExpandPanes,
CollapseSelector,
SelectSession(String),
SelectPane {
tab_position: usize,
pane_id: PaneId,
},
ToggleFit,
Keyboard(crate::components::modifier_bar::CellId),
ToggleMenu,
NewPaneInTab {
tab_position: usize,
},
NewTab,
OpenNewSessionPrompt,
CancelNewSessionPrompt,
AcceptNewSessionPrompt,
ExitMobileMode,
}
#[derive(Debug, Clone)]
pub struct ClickRegion {
pub row_start: usize,
pub row_end: usize,
pub col_start: usize,
pub col_end: usize,
pub action: ClickAction,
pub priority: u8,
pub center: Option<(usize, usize)>,
}
impl ClickRegion {
pub fn tight(row: usize, col_start: usize, col_end: usize, action: ClickAction) -> Self {
Self::tight_range(row, row + 1, col_start, col_end, action)
}
pub fn tight_range(
row_start: usize,
row_end: usize,
col_start: usize,
col_end: usize,
action: ClickAction,
) -> Self {
Self {
row_start,
row_end,
col_start,
col_end,
action,
priority: 0,
center: None,
}
}
pub fn slop(
row: usize,
col_start: usize,
col_end: usize,
action: ClickAction,
center: (usize, usize),
) -> Self {
Self::slop_range(row, row + 1, col_start, col_end, action, center)
}
pub fn slop_range(
row_start: usize,
row_end: usize,
col_start: usize,
col_end: usize,
action: ClickAction,
center: (usize, usize),
) -> Self {
Self {
row_start,
row_end,
col_start,
col_end,
action,
priority: 1,
center: Some(center),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct ViewportRegion {
pub row_start: usize,
pub row_end: usize,
pub cols: usize,
pub skip: usize,
pub h_offset: usize,
}
pub fn slop_key(r: &ClickRegion) -> (usize, usize) {
match r.center {
Some((cx, cy)) => (cy, cx),
None => (r.row_start, r.col_start),
}
}
pub fn dispatch(state: &mut State, action: ClickAction) -> bool {
match action {
ClickAction::ExpandSessions => state.open_sessions(),
ClickAction::ExpandPanes => state.open_panes(),
ClickAction::ToggleMenu => state.menu.toggle(&mut state.active),
ClickAction::CollapseSelector => state.collapse_selector(),
ClickAction::SelectSession(name) => state.sessions.select_session(&mut state.active, &name),
ClickAction::OpenNewSessionPrompt => state.open_new_session_prompt(),
ClickAction::CancelNewSessionPrompt => state.cancel_new_session_prompt(),
ClickAction::AcceptNewSessionPrompt => state.accept_new_session_prompt(),
ClickAction::SelectPane {
tab_position,
pane_id,
} => state.select_pane(tab_position, pane_id),
ClickAction::ToggleFit => state.toggle_fit(),
ClickAction::NewPaneInTab { tab_position } => state.new_pane_in_tab(tab_position),
ClickAction::NewTab => state.new_tab(),
ClickAction::ExitMobileMode => {
exit_mobile_mode();
true
},
ClickAction::Keyboard(cell) => state.keyboard_tap(cell),
}
}
@@ -1,2 +0,0 @@
pub mod modifier_bar;
pub mod top_bar;
@@ -1,681 +0,0 @@
use std::collections::BTreeSet;
use unicode_width::UnicodeWidthStr;
use zellij_tile::prelude::*;
use crate::click::{ClickAction, ClickRegion};
use crate::keys;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Modifier {
Ctrl,
Alt,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct KeyboardModifiers {
pub ctrl_armed: bool,
pub alt_armed: bool,
}
impl KeyboardModifiers {
pub fn consume_one_shots(&mut self) {
self.ctrl_armed = false;
self.alt_armed = false;
}
pub fn is_armed(&self, m: Modifier) -> bool {
match m {
Modifier::Ctrl => self.ctrl_armed,
Modifier::Alt => self.alt_armed,
}
}
pub fn toggle(&mut self, m: Modifier) {
match m {
Modifier::Ctrl => self.ctrl_armed = !self.ctrl_armed,
Modifier::Alt => self.alt_armed = !self.alt_armed,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CellId(pub u16);
#[derive(Debug, Clone)]
pub enum KeyAction {
SendKey(KeyWithModifier),
ToggleModifier(Modifier),
NoOp,
}
pub const CELL_ESC: CellId = CellId(0);
pub const CELL_TAB: CellId = CellId(1);
pub const CELL_CTRL: CellId = CellId(2);
pub const CELL_ALT: CellId = CellId(3);
pub const CELL_LEFT: CellId = CellId(4);
pub const CELL_DOWN: CellId = CellId(5);
pub const CELL_UP: CellId = CellId(6);
pub const CELL_RIGHT: CellId = CellId(7);
pub const CELL_MINUS: CellId = CellId(8);
pub const BAR_CELL_COUNT: usize = 9;
pub enum TapOutcome {
SendBytes(Vec<u8>),
Toggled,
NoOp,
}
#[derive(Default)]
pub struct ModifierBarController {
pub modifiers: KeyboardModifiers,
}
impl ModifierBarController {
pub fn new() -> Self {
Self::default()
}
pub fn handle_tap(
&mut self,
cell: CellId,
ctrl_held: &mut bool,
alt_held: &mut bool,
) -> TapOutcome {
self.modifiers.ctrl_armed = *ctrl_held;
self.modifiers.alt_armed = *alt_held;
let action = cell_action(cell);
match action {
KeyAction::ToggleModifier(m) => {
self.modifiers.toggle(m);
match m {
Modifier::Ctrl => *ctrl_held = self.modifiers.ctrl_armed,
Modifier::Alt => *alt_held = self.modifiers.alt_armed,
}
TapOutcome::Toggled
},
KeyAction::SendKey(mut kwm) => {
if self.modifiers.ctrl_armed {
kwm.key_modifiers.insert(KeyModifier::Ctrl);
}
if self.modifiers.alt_armed {
kwm.key_modifiers.insert(KeyModifier::Alt);
}
let bytes = keys::serialize_key(&kwm);
self.modifiers.consume_one_shots();
*ctrl_held = false;
*alt_held = false;
TapOutcome::SendBytes(bytes)
},
KeyAction::NoOp => TapOutcome::NoOp,
}
}
}
fn bare(k: BareKey) -> KeyWithModifier {
KeyWithModifier {
bare_key: k,
key_modifiers: BTreeSet::new(),
}
}
fn cell_action(cell: CellId) -> KeyAction {
match cell {
CELL_ESC => KeyAction::SendKey(bare(BareKey::Esc)),
CELL_TAB => KeyAction::SendKey(bare(BareKey::Tab)),
CELL_CTRL => KeyAction::ToggleModifier(Modifier::Ctrl),
CELL_ALT => KeyAction::ToggleModifier(Modifier::Alt),
CELL_LEFT => KeyAction::SendKey(bare(BareKey::Left)),
CELL_DOWN => KeyAction::SendKey(bare(BareKey::Down)),
CELL_UP => KeyAction::SendKey(bare(BareKey::Up)),
CELL_RIGHT => KeyAction::SendKey(bare(BareKey::Right)),
CELL_MINUS => KeyAction::SendKey(bare(BareKey::Char('-'))),
_ => KeyAction::NoOp,
}
}
struct BarCell {
id: CellId,
label: &'static str,
short_label: &'static str,
modifier: Option<Modifier>,
}
const BAR: [BarCell; BAR_CELL_COUNT] = [
BarCell {
id: CELL_ESC,
label: "ESC",
short_label: "ES",
modifier: None,
},
BarCell {
id: CELL_TAB,
label: "TAB",
short_label: "TB",
modifier: None,
},
BarCell {
id: CELL_CTRL,
label: "CTRL",
short_label: "CTL",
modifier: Some(Modifier::Ctrl),
},
BarCell {
id: CELL_ALT,
label: "ALT",
short_label: "AL",
modifier: Some(Modifier::Alt),
},
BarCell {
id: CELL_LEFT,
label: "\u{2190}",
short_label: "\u{2190}",
modifier: None,
},
BarCell {
id: CELL_DOWN,
label: "\u{2193}",
short_label: "\u{2193}",
modifier: None,
},
BarCell {
id: CELL_UP,
label: "\u{2191}",
short_label: "\u{2191}",
modifier: None,
},
BarCell {
id: CELL_RIGHT,
label: "\u{2192}",
short_label: "\u{2192}",
modifier: None,
},
BarCell {
id: CELL_MINUS,
label: "-",
short_label: "-",
modifier: None,
},
];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SepMode {
Wide,
Compact,
}
impl SepMode {
fn width(self) -> usize {
match self {
Self::Wide => 3,
Self::Compact => 1,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum LabelMode {
Full,
Short,
}
impl LabelMode {
fn label_for(self, cell: &BarCell) -> &'static str {
match self {
Self::Full => cell.label,
Self::Short => cell.short_label,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DropMode {
None,
DropMinus,
DropMinusAndTab,
}
impl DropMode {
fn cell_indices(self) -> &'static [usize] {
match self {
Self::None => &[0, 1, 2, 3, 4, 5, 6, 7, 8],
Self::DropMinus => &[0, 1, 2, 3, 4, 5, 6, 7],
Self::DropMinusAndTab => &[0, 2, 3, 4, 5, 6, 7],
}
}
}
fn required_width(drop: DropMode, labels: LabelMode, sep: SepMode) -> usize {
let indices = drop.cell_indices();
let n = indices.len();
let labels_sum: usize = indices
.iter()
.map(|&i| UnicodeWidthStr::width(labels.label_for(&BAR[i])))
.sum();
labels_sum + n.saturating_sub(1) * sep.width()
}
fn choose_config(cols: usize) -> Option<(DropMode, LabelMode, SepMode)> {
const CONFIGS: [(DropMode, LabelMode, SepMode); 12] = [
(DropMode::None, LabelMode::Full, SepMode::Wide),
(DropMode::None, LabelMode::Full, SepMode::Compact),
(DropMode::None, LabelMode::Short, SepMode::Wide),
(DropMode::None, LabelMode::Short, SepMode::Compact),
(DropMode::DropMinus, LabelMode::Full, SepMode::Wide),
(DropMode::DropMinus, LabelMode::Full, SepMode::Compact),
(DropMode::DropMinus, LabelMode::Short, SepMode::Wide),
(DropMode::DropMinus, LabelMode::Short, SepMode::Compact),
(DropMode::DropMinusAndTab, LabelMode::Full, SepMode::Wide),
(DropMode::DropMinusAndTab, LabelMode::Full, SepMode::Compact),
(DropMode::DropMinusAndTab, LabelMode::Short, SepMode::Wide),
(
DropMode::DropMinusAndTab,
LabelMode::Short,
SepMode::Compact,
),
];
CONFIGS
.iter()
.copied()
.find(|&(d, l, s)| cols >= required_width(d, l, s))
}
pub fn render_modifier_bar(
modifiers: &KeyboardModifiers,
row: usize,
cols: usize,
click_regions: &mut Vec<ClickRegion>,
) {
let (drop_mode, label_mode, sep_mode) = match choose_config(cols) {
Some(c) => c,
None => return,
};
let indices = drop_mode.cell_indices();
let n = indices.len();
let sep_w = sep_mode.width();
let sep_total = n.saturating_sub(1) * sep_w;
let content_cols = cols - sep_total;
let naturals: Vec<usize> = indices
.iter()
.map(|&i| UnicodeWidthStr::width(label_mode.label_for(&BAR[i])))
.collect();
let layout = compute_bar_layout(content_cols, &naturals);
let widths = &layout.widths;
let mut bar = String::with_capacity(cols);
let mut label_ranges: Vec<(std::ops::Range<usize>, bool)> = Vec::with_capacity(n);
let mut cell_boundaries: Vec<usize> = Vec::with_capacity(n + 1);
let mut chars_cursor: usize = 0;
let mut cells_cursor: usize = 0;
cell_boundaries.push(0);
for _ in 0..layout.left_margin {
bar.push(' ');
}
chars_cursor += layout.left_margin;
cells_cursor += layout.left_margin;
for (slot, &cell_idx) in indices.iter().enumerate() {
let cell = &BAR[cell_idx];
let label = label_mode.label_for(cell);
let width = widths[slot];
let label_w = UnicodeWidthStr::width(label);
let visible = if label_w <= width {
label.to_string()
} else {
truncate_to_width(label, width)
};
let visible_w = UnicodeWidthStr::width(visible.as_str());
let left_pad = (width - visible_w) / 2;
let right_pad = width - visible_w - left_pad;
for _ in 0..left_pad {
bar.push(' ');
}
chars_cursor += left_pad;
let label_chars_start = chars_cursor;
bar.push_str(&visible);
chars_cursor += visible.chars().count();
let label_chars_end = chars_cursor;
for _ in 0..right_pad {
bar.push(' ');
}
chars_cursor += right_pad;
let armed = cell
.modifier
.map(|m| modifiers.is_armed(m))
.unwrap_or(false);
label_ranges.push((label_chars_start..label_chars_end, armed));
cells_cursor += width;
if slot + 1 < n {
// Separators are blank cells on the shared selected background; the
// gap is represented by spaces rather than a drawn glyph.
for _ in 0..sep_w {
bar.push(' ');
}
chars_cursor += sep_w;
cells_cursor += sep_w;
cell_boundaries.push(cells_cursor);
}
}
for _ in 0..layout.right_margin {
bar.push(' ');
}
chars_cursor += layout.right_margin;
cells_cursor += layout.right_margin;
cell_boundaries.push(cells_cursor);
let _ = chars_cursor;
let mut text = Text::new(&bar).selected();
for (range, armed) in &label_ranges {
let level = if *armed { 2 } else { 3 };
text = text.color_range(level, range.clone());
}
print_text_with_coordinates(text, 0, row, Some(cols), None);
for (slot, &cell_idx) in indices.iter().enumerate() {
click_regions.push(ClickRegion::tight(
row,
cell_boundaries[slot],
cell_boundaries[slot + 1],
ClickAction::Keyboard(BAR[cell_idx].id),
));
}
}
struct BarLayout {
widths: Vec<usize>,
left_margin: usize,
right_margin: usize,
}
fn compute_bar_layout(cols: usize, naturals: &[usize]) -> BarLayout {
let n = naturals.len();
if n == 0 {
return BarLayout {
widths: Vec::new(),
left_margin: cols,
right_margin: 0,
};
}
let natural_sum: usize = naturals.iter().sum();
let slack = cols.saturating_sub(natural_sum);
let pad = slack / (2 * n);
let widths: Vec<usize> = naturals.iter().map(|&nat| nat + 2 * pad).collect();
let used: usize = widths.iter().sum();
let outer = cols.saturating_sub(used);
let left_margin = outer / 2;
let right_margin = outer - left_margin;
BarLayout {
widths,
left_margin,
right_margin,
}
}
fn truncate_to_width(label: &str, max_w: usize) -> String {
let mut out = String::new();
let mut w = 0usize;
for ch in label.chars() {
let mut buf = [0u8; 4];
let ch_str = ch.encode_utf8(&mut buf);
let ch_w = UnicodeWidthStr::width(ch_str as &str);
if w + ch_w > max_w {
break;
}
out.push(ch);
w += ch_w;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn consume_one_shots_clears_modifiers() {
let mut m = KeyboardModifiers {
ctrl_armed: true,
alt_armed: true,
};
m.consume_one_shots();
assert!(!m.ctrl_armed);
assert!(!m.alt_armed);
}
#[test]
fn toggle_flips_modifier_state() {
let mut m = KeyboardModifiers::default();
assert!(!m.is_armed(Modifier::Ctrl));
m.toggle(Modifier::Ctrl);
assert!(m.is_armed(Modifier::Ctrl));
m.toggle(Modifier::Ctrl);
assert!(!m.is_armed(Modifier::Ctrl));
}
#[test]
fn bar_layout_totals_to_cols() {
let naturals = vec![1usize; BAR_CELL_COUNT];
for cols in [9, 10, 20, 80, 137] {
let layout = compute_bar_layout(cols, &naturals);
let total: usize =
layout.widths.iter().sum::<usize>() + layout.left_margin + layout.right_margin;
assert_eq!(total, cols, "cols={}", cols);
assert_eq!(layout.widths.len(), BAR_CELL_COUNT);
}
}
#[test]
fn bar_layout_every_cell_has_identical_symmetric_padding() {
let naturals = vec![3, 3, 4, 3, 1, 1, 1, 1, 1];
for cols in [18, 19, 20, 26, 27, 50, 80, 137] {
let layout = compute_bar_layout(cols, &naturals);
let pads: Vec<usize> = layout
.widths
.iter()
.zip(naturals.iter())
.map(|(&w, &nat)| {
assert!(w >= nat, "cols={} cell width {} < natural {}", cols, w, nat);
let p2 = w - nat;
assert_eq!(p2 % 2, 0, "cols={} cell padding {} not even", cols, p2);
p2 / 2
})
.collect();
let first = pads[0];
for (i, &p) in pads.iter().enumerate() {
assert_eq!(
p, first,
"cols={} cell {} pad={} (expected {})",
cols, i, p, first
);
}
assert!(
layout.left_margin + layout.right_margin < 2 * naturals.len(),
"cols={} outer margin overflow",
cols
);
let diff = layout.left_margin.abs_diff(layout.right_margin);
assert!(
diff <= 1,
"cols={} margins {}/{} not centred",
cols,
layout.left_margin,
layout.right_margin
);
}
}
#[test]
fn bar_layout_no_padding_when_slack_too_small() {
let naturals = vec![3, 3, 4, 3, 1, 1, 1, 1, 1];
let layout = compute_bar_layout(19, &naturals);
assert_eq!(layout.widths, naturals);
assert_eq!(layout.left_margin + layout.right_margin, 1);
}
#[test]
fn bar_layout_uses_padding_when_slack_permits() {
let naturals = vec![3, 3, 4, 3, 1, 1, 1, 1, 1];
let layout = compute_bar_layout(36, &naturals);
assert_eq!(layout.widths, vec![5, 5, 6, 5, 3, 3, 3, 3, 3]);
assert_eq!(layout.left_margin, 0);
assert_eq!(layout.right_margin, 0);
}
#[test]
fn truncate_to_width_drops_overflow() {
assert_eq!(truncate_to_width("CTRL", 2), "CT");
assert_eq!(truncate_to_width("ESC", 3), "ESC");
assert_eq!(truncate_to_width("ESC", 0), "");
}
#[test]
fn choose_config_picks_full_layout_when_wide() {
assert_eq!(
choose_config(42),
Some((DropMode::None, LabelMode::Full, SepMode::Wide))
);
assert_eq!(
choose_config(80),
Some((DropMode::None, LabelMode::Full, SepMode::Wide))
);
}
#[test]
fn choose_config_degrades_separator_before_labels() {
assert_eq!(
choose_config(41),
Some((DropMode::None, LabelMode::Full, SepMode::Compact))
);
assert_eq!(
choose_config(26),
Some((DropMode::None, LabelMode::Full, SepMode::Compact))
);
}
#[test]
fn choose_config_degrades_labels_before_dropping_cells() {
assert_eq!(
choose_config(25),
Some((DropMode::None, LabelMode::Short, SepMode::Compact))
);
assert_eq!(
choose_config(22),
Some((DropMode::None, LabelMode::Short, SepMode::Compact))
);
}
#[test]
fn choose_config_drops_minus_when_short_compact_falls_short() {
assert_eq!(
choose_config(21),
Some((DropMode::DropMinus, LabelMode::Short, SepMode::Compact))
);
assert_eq!(
choose_config(20),
Some((DropMode::DropMinus, LabelMode::Short, SepMode::Compact))
);
}
#[test]
fn choose_config_drops_minus_and_tab_at_minimum() {
assert_eq!(
choose_config(19),
Some((
DropMode::DropMinusAndTab,
LabelMode::Short,
SepMode::Compact
))
);
assert_eq!(
choose_config(17),
Some((
DropMode::DropMinusAndTab,
LabelMode::Short,
SepMode::Compact
))
);
}
#[test]
fn choose_config_none_when_too_narrow() {
assert_eq!(choose_config(16), None);
assert_eq!(choose_config(0), None);
}
#[test]
fn render_modifier_bar_pushes_one_region_per_cell() {
let mods = KeyboardModifiers::default();
let mut regions = Vec::new();
render_modifier_bar(&mods, 5, 90, &mut regions);
assert_eq!(regions.len(), BAR_CELL_COUNT);
regions.sort_by_key(|r| r.col_start);
let mut cursor = 0usize;
for r in &regions {
assert_eq!(r.row_start, 5);
assert_eq!(r.row_end, 6);
assert_eq!(r.col_start, cursor);
cursor = r.col_end;
}
assert_eq!(cursor, 90);
}
#[test]
fn render_modifier_bar_tiles_at_compact_width() {
let mods = KeyboardModifiers::default();
let mut regions = Vec::new();
render_modifier_bar(&mods, 0, 30, &mut regions);
assert_eq!(regions.len(), BAR_CELL_COUNT);
regions.sort_by_key(|r| r.col_start);
let mut cursor = 0usize;
for r in &regions {
assert_eq!(r.col_start, cursor);
cursor = r.col_end;
}
assert_eq!(cursor, 30);
}
#[test]
fn render_modifier_bar_drops_minus_at_narrow() {
let mods = KeyboardModifiers::default();
let mut regions = Vec::new();
render_modifier_bar(&mods, 0, 21, &mut regions);
assert_eq!(regions.len(), 8);
for r in &regions {
if let ClickAction::Keyboard(id) = r.action {
assert_ne!(id, CELL_MINUS);
}
}
}
#[test]
fn render_modifier_bar_drops_minus_and_tab_at_minimum() {
let mods = KeyboardModifiers::default();
let mut regions = Vec::new();
render_modifier_bar(&mods, 0, 17, &mut regions);
assert_eq!(regions.len(), 7);
for r in &regions {
if let ClickAction::Keyboard(id) = r.action {
assert_ne!(id, CELL_MINUS);
assert_ne!(id, CELL_TAB);
}
}
}
#[test]
fn render_modifier_bar_noop_when_too_narrow() {
let mods = KeyboardModifiers::default();
let mut regions = Vec::new();
render_modifier_bar(&mods, 0, 16, &mut regions);
assert!(regions.is_empty());
}
}
@@ -1,412 +0,0 @@
use std::ops::Range;
use unicode_width::UnicodeWidthStr;
use zellij_tile::prelude::*;
use crate::ansi::pad_or_truncate;
use crate::click::{ClickAction, ClickRegion};
use crate::frame::Frame;
use crate::screens::ActiveScreen;
use crate::workspace::Workspace;
const PREFIX: &str = "Zellij ";
const HAMBURGER: &str = "\u{2630}";
const SESSION_PANE_SEP: &str = " ";
const HAMBURGER_SLOP_CELLS: usize = 3;
pub(crate) fn render(
ws: &Workspace,
frame: &mut Frame,
active: ActiveScreen,
row: usize,
cols: usize,
) {
let pane_name = current_pane_name(ws);
let session_name = ws.session_name.clone();
let content_max = cols.saturating_sub(HAMBURGER_SLOP_CELLS + width(HAMBURGER));
let plan = Plan::compute(
session_name.as_deref().map(width),
width(&pane_name),
width(PREFIX),
width(SESSION_PANE_SEP),
content_max,
);
let mut bar = BarBuilder::default();
if plan.show_prefix {
bar.push(PREFIX);
}
let session_seg = session_name
.as_ref()
.filter(|_| plan.session_target > 0)
.map(|session| {
let seg = bar.push(&pad_or_truncate(session, plan.session_target));
bar.push(SESSION_PANE_SEP);
seg
});
let pane_seg = bar.push(&pad_or_truncate(&pane_name, plan.pane_target));
let pane_end = bar.cells;
let pad = cols
.saturating_sub(bar.cells + width(HAMBURGER))
.max(HAMBURGER_SLOP_CELLS);
bar.push(&" ".repeat(pad));
let hamburger_start = bar.cells;
let hamburger_seg = bar.push(HAMBURGER);
paint(
&bar.text,
row,
cols,
&pane_seg,
&hamburger_seg,
session_seg.as_ref(),
);
let (pane_action, session_action) = actions_for(active);
let session_cells = session_seg.map(|s| (s.cells.start, s.cells.end));
for region in click_regions(
row,
cols,
pane_end,
hamburger_start,
pane_action,
session_cells,
session_action,
) {
frame.click_regions.push(region);
}
}
fn paint(
bar: &str,
row: usize,
cols: usize,
pane_seg: &Segment,
hamburger_seg: &Segment,
session_seg: Option<&Segment>,
) {
let mut text = Text::new(bar)
.selected()
.color_range(2, pane_seg.chars.clone())
.color_range(3, hamburger_seg.chars.clone());
if let Some(seg) = session_seg {
text = text.color_range(0, seg.chars.clone());
}
print_text_with_coordinates(text, 0, row, Some(cols), None);
}
fn actions_for(active: ActiveScreen) -> (ClickAction, ClickAction) {
if active == ActiveScreen::Viewport {
(ClickAction::ExpandPanes, ClickAction::ExpandSessions)
} else {
(ClickAction::CollapseSelector, ClickAction::CollapseSelector)
}
}
fn current_pane_name(ws: &Workspace) -> String {
ws.current_pane()
.map(|p| {
if p.title.is_empty() {
format!("#{}", p.id)
} else {
p.title.clone()
}
})
.unwrap_or_else(|| "—".to_string())
}
fn width(s: &str) -> usize {
UnicodeWidthStr::width(s)
}
#[derive(Default)]
struct BarBuilder {
text: String,
chars: usize,
cells: usize,
}
struct Segment {
chars: Range<usize>,
cells: Range<usize>,
}
impl BarBuilder {
fn push(&mut self, s: &str) -> Segment {
let chars = self.chars..self.chars + s.chars().count();
let cells = self.cells..self.cells + width(s);
self.text.push_str(s);
self.chars = chars.end;
self.cells = cells.end;
Segment { chars, cells }
}
}
struct Plan {
show_prefix: bool,
session_target: usize,
pane_target: usize,
}
impl Plan {
fn compute(
session_w: Option<usize>,
pane_w: usize,
prefix_w: usize,
sep_w: usize,
content_max: usize,
) -> Self {
let Some(session_w) = session_w else {
return Plan {
show_prefix: true,
session_target: 0,
pane_target: pane_w.min(content_max.saturating_sub(prefix_w)),
};
};
if prefix_w + session_w + sep_w + pane_w <= content_max {
return Plan {
show_prefix: true,
session_target: session_w,
pane_target: pane_w,
};
}
if session_w + sep_w + pane_w <= content_max {
return Plan {
show_prefix: false,
session_target: session_w,
pane_target: pane_w,
};
}
let available = content_max.saturating_sub(sep_w);
let half = available / 2;
let (session_target, pane_target) = if session_w <= half {
(session_w, available.saturating_sub(session_w))
} else if pane_w <= half {
(available.saturating_sub(pane_w), pane_w)
} else {
(half, available.saturating_sub(half))
};
Plan {
show_prefix: false,
session_target,
pane_target,
}
}
}
pub fn click_regions(
row: usize,
cols: usize,
pane_end: usize,
hamburger_start: usize,
pane_action: ClickAction,
session_cells: Option<(usize, usize)>,
session_action: ClickAction,
) -> Vec<ClickRegion> {
let mut regions = Vec::with_capacity(5);
push_content_regions(
&mut regions,
row,
pane_end,
session_cells,
pane_action,
session_action,
);
regions.push(ClickRegion::tight(
row,
hamburger_start,
cols,
ClickAction::ToggleMenu,
));
let hamburger_center = (hamburger_start.min(cols.saturating_sub(1)), row);
regions.push(ClickRegion::slop(
row,
pane_end,
cols,
ClickAction::ToggleMenu,
hamburger_center,
));
regions
}
fn push_content_regions(
regions: &mut Vec<ClickRegion>,
row: usize,
pane_end: usize,
session_cells: Option<(usize, usize)>,
pane_action: ClickAction,
session_action: ClickAction,
) {
let Some((session_start, session_end)) = clamp_session(session_cells, pane_end) else {
if pane_end > 0 {
regions.push(ClickRegion::tight(row, 0, pane_end, pane_action));
}
return;
};
if session_start > 0 {
regions.push(ClickRegion::tight(
row,
0,
session_start,
pane_action.clone(),
));
}
regions.push(ClickRegion::tight(
row,
session_start,
session_end,
session_action,
));
if session_end < pane_end {
regions.push(ClickRegion::tight(row, session_end, pane_end, pane_action));
}
}
fn clamp_session(session_cells: Option<(usize, usize)>, pane_end: usize) -> Option<(usize, usize)> {
let (start, end) = session_cells?;
let (start, end) = (start.min(pane_end), end.min(pane_end));
(start < end).then_some((start, end))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::State;
#[test]
fn partition_with_slop() {
let cols = 80;
let pane_end = 40;
let hamburger_start = 79;
let regions = click_regions(
0,
cols,
pane_end,
hamburger_start,
ClickAction::ExpandPanes,
None,
ClickAction::ExpandSessions,
);
assert_eq!(regions.len(), 3);
assert!(matches!(regions[0].action, ClickAction::ExpandPanes));
assert_eq!(regions[0].priority, 0);
assert_eq!(regions[0].col_start, 0);
assert_eq!(regions[0].col_end, pane_end);
assert!(matches!(regions[1].action, ClickAction::ToggleMenu));
assert_eq!(regions[1].priority, 0);
assert_eq!(regions[1].col_start, hamburger_start);
assert_eq!(regions[1].col_end, cols);
assert!(matches!(regions[2].action, ClickAction::ToggleMenu));
assert_eq!(regions[2].priority, 1);
assert_eq!(regions[2].col_start, pane_end);
assert_eq!(regions[2].col_end, cols);
let mut state = State::default();
state.frame.click_regions = regions.clone();
assert_eq!(
state.frame.click_to_action(0, 0),
Some(ClickAction::ExpandPanes)
);
assert_eq!(
state.frame.click_to_action(0, pane_end + 5),
Some(ClickAction::ToggleMenu),
);
assert_eq!(
state.frame.click_to_action(0, hamburger_start),
Some(ClickAction::ToggleMenu)
);
}
#[test]
fn pane_action_collapses_in_selector_mode() {
let regions = click_regions(
0,
80,
40,
79,
ClickAction::CollapseSelector,
None,
ClickAction::CollapseSelector,
);
assert!(matches!(regions[0].action, ClickAction::CollapseSelector));
let mut state = State::default();
state.frame.click_regions = regions;
assert_eq!(
state.frame.click_to_action(0, 0),
Some(ClickAction::CollapseSelector)
);
}
#[test]
fn session_sub_region_dispatches_expand_sessions() {
let regions = click_regions(
0,
80,
17,
79,
ClickAction::ExpandPanes,
Some((7, 11)),
ClickAction::ExpandSessions,
);
assert_eq!(regions.len(), 5);
assert_eq!((regions[0].col_start, regions[0].col_end), (0, 7));
assert!(matches!(regions[0].action, ClickAction::ExpandPanes));
assert_eq!((regions[1].col_start, regions[1].col_end), (7, 11));
assert!(matches!(regions[1].action, ClickAction::ExpandSessions));
assert_eq!((regions[2].col_start, regions[2].col_end), (11, 17));
assert!(matches!(regions[2].action, ClickAction::ExpandPanes));
let mut state = State::default();
state.frame.click_regions = regions;
assert_eq!(
state.frame.click_to_action(0, 3),
Some(ClickAction::ExpandPanes)
);
assert_eq!(
state.frame.click_to_action(0, 9),
Some(ClickAction::ExpandSessions)
);
assert_eq!(
state.frame.click_to_action(0, 11),
Some(ClickAction::ExpandPanes)
);
assert_eq!(
state.frame.click_to_action(0, 14),
Some(ClickAction::ExpandPanes)
);
assert_eq!(
state.frame.click_to_action(0, 30),
Some(ClickAction::ToggleMenu)
);
assert_eq!(
state.frame.click_to_action(0, 79),
Some(ClickAction::ToggleMenu)
);
}
#[test]
fn session_at_left_edge_skips_empty_prefix_region() {
let regions = click_regions(
0,
40,
11,
39,
ClickAction::ExpandPanes,
Some((0, 4)),
ClickAction::ExpandSessions,
);
assert_eq!(regions.len(), 4);
assert_eq!((regions[0].col_start, regions[0].col_end), (0, 4));
assert!(matches!(regions[0].action, ClickAction::ExpandSessions));
assert_eq!((regions[1].col_start, regions[1].col_end), (4, 11));
assert!(matches!(regions[1].action, ClickAction::ExpandPanes));
}
}
-146
View File
@@ -1,146 +0,0 @@
use zellij_tile::prelude::*;
use crate::frame::{chrome_offsets, Frame};
use crate::workspace::{pane_id_of, Workspace};
#[derive(Default)]
pub struct Fit {
pub active: bool,
pub tab_id: Option<usize>,
pub last_sent_size: Option<Size>,
}
impl Fit {
pub fn toggle(&mut self, ws: &Workspace, frame: &Frame, suppress_top_bar: bool) -> bool {
if self.active {
let tab_id = self.tab_id.unwrap_or_default();
self.active = false;
self.tab_id = None;
self.last_sent_size = None;
set_tab_fit(tab_id, None);
true
} else {
let Some(pane) = ws.current_pane() else {
return false;
};
let Some(tab) = ws.current_tab().cloned() else {
return false;
};
self.active = true;
self.tab_id = Some(tab.tab_id);
let size = embedded_size(frame, suppress_top_bar);
set_tab_fit(tab.tab_id, Some((pane_id_of(&pane), size)));
self.last_sent_size = Some(size);
true
}
}
pub fn clear_if_active(&mut self) {
if self.active {
let tab_id = self.tab_id.unwrap_or_default();
self.active = false;
self.tab_id = None;
self.last_sent_size = None;
set_tab_fit(tab_id, None);
}
}
pub fn reset_local(&mut self) {
self.active = false;
self.tab_id = None;
self.last_sent_size = None;
}
pub fn notify_size(&mut self, ws: &Workspace, frame: &Frame, suppress_top_bar: bool) {
if !self.active {
return;
}
let Some(tab_id) = self.tab_id else {
return;
};
let Some(pane) = ws.current_pane() else {
return;
};
let size = embedded_size(frame, suppress_top_bar);
if self.last_sent_size == Some(size) {
return;
}
set_tab_fit(tab_id, Some((pane_id_of(&pane), size)));
self.last_sent_size = Some(size);
}
}
pub fn embedded_size(frame: &Frame, suppress_top_bar: bool) -> Size {
let (body_top, bar_height) = chrome_offsets(
frame.last_render_rows,
suppress_top_bar,
frame.soft_keyboard_visible,
);
Size {
rows: frame
.last_render_rows
.saturating_sub(bar_height)
.saturating_sub(body_top),
cols: frame.last_render_cols,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn frame_with_dims(rows: usize, cols: usize, soft_keyboard: bool) -> Frame {
let mut frame = Frame::default();
frame.last_render_rows = rows;
frame.last_render_cols = cols;
frame.soft_keyboard_visible = soft_keyboard;
frame
}
#[test]
fn embedded_size_default_top_bar_only() {
let frame = frame_with_dims(20, 80, false);
assert_eq!(embedded_size(&frame, false), Size { rows: 19, cols: 80 });
}
#[test]
fn embedded_size_soft_keyboard_adds_bottom() {
let frame = frame_with_dims(20, 80, true);
assert_eq!(embedded_size(&frame, false), Size { rows: 18, cols: 80 });
}
#[test]
fn embedded_size_suppressed_top_bar() {
let frame = frame_with_dims(20, 80, false);
assert_eq!(embedded_size(&frame, true), Size { rows: 20, cols: 80 });
}
#[test]
fn notify_size_gated_off() {
let ws = Workspace::default();
let frame = frame_with_dims(20, 80, false);
let mut fit = Fit::default();
fit.active = false;
fit.tab_id = Some(7);
fit.notify_size(&ws, &frame, false);
assert_eq!(fit.last_sent_size, None);
fit.active = true;
fit.tab_id = None;
fit.notify_size(&ws, &frame, false);
assert!(fit.active);
assert_eq!(fit.last_sent_size, None);
}
#[test]
fn clear_if_active_round_trip() {
let mut fit = Fit::default();
fit.active = true;
fit.tab_id = Some(7);
fit.clear_if_active();
assert!(!fit.active);
assert_eq!(fit.tab_id, None);
fit.clear_if_active();
assert!(!fit.active);
assert_eq!(fit.tab_id, None);
}
}
-142
View File
@@ -1,142 +0,0 @@
use zellij_tile::prelude::*;
use crate::click::{slop_key, ClickAction, ClickRegion};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum LastEmittedCursor {
#[default]
Unknown,
Sent(Option<(usize, usize)>),
}
#[derive(Default)]
pub struct Frame {
pub click_regions: Vec<ClickRegion>,
pub last_render_rows: usize,
pub last_render_cols: usize,
pub last_emitted_cursor: LastEmittedCursor,
pub soft_keyboard_visible: bool,
}
impl Frame {
pub fn emit_cursor(&mut self, new_pos: Option<(usize, usize)>) {
let needs_emit = match self.last_emitted_cursor {
LastEmittedCursor::Unknown => true,
LastEmittedCursor::Sent(prev) => prev != new_pos,
};
if needs_emit {
show_cursor(new_pos);
self.last_emitted_cursor = LastEmittedCursor::Sent(new_pos);
}
}
pub fn click_to_action(&self, row: usize, col: usize) -> Option<ClickAction> {
for region in &self.click_regions {
if region.priority == 0
&& region.row_start <= row
&& row < region.row_end
&& col >= region.col_start
&& col < region.col_end
{
return Some(region.action.clone());
}
}
let mut best: Option<(&ClickRegion, u64)> = None;
for region in &self.click_regions {
if region.priority == 0 {
continue;
}
if row < region.row_start || row >= region.row_end {
continue;
}
if col < region.col_start || col >= region.col_end {
continue;
}
let Some((cx, cy)) = region.center else {
continue;
};
let dx = (cx as i64 - col as i64).unsigned_abs();
let dy = (cy as i64 - row as i64).unsigned_abs();
let dist_sq = dx * dx + dy * dy;
best = Some(match best {
None => (region, dist_sq),
Some((cur, cur_d)) if dist_sq < cur_d => (region, dist_sq),
Some((cur, cur_d)) if dist_sq == cur_d => {
let cur_key = slop_key(cur);
let new_key = slop_key(region);
if new_key < cur_key {
(region, dist_sq)
} else {
(cur, cur_d)
}
},
Some(prev) => prev,
});
}
best.map(|(r, _)| r.action.clone())
}
}
pub fn chrome_offsets(
rows: usize,
suppress_top_bar: bool,
soft_keyboard_visible: bool,
) -> (usize, usize) {
let body_top = if suppress_top_bar { 0 } else { 1 };
let bar_height = if soft_keyboard_visible && rows.saturating_sub(body_top) >= 2 {
1
} else {
0
};
(body_top, bar_height)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::modifier_bar::CellId;
fn kb(id: u16) -> ClickAction {
ClickAction::Keyboard(CellId(id))
}
#[test]
fn tight_wins_over_overlapping_slop() {
let mut f = Frame::default();
f.click_regions.push(ClickRegion::tight(5, 10, 13, kb(1)));
f.click_regions
.push(ClickRegion::slop(5, 9, 14, kb(1), (11, 5)));
f.click_regions.push(ClickRegion::tight(5, 13, 16, kb(2)));
f.click_regions
.push(ClickRegion::slop(5, 12, 17, kb(2), (14, 5)));
assert_eq!(f.click_to_action(5, 12), Some(kb(1)));
assert_eq!(f.click_to_action(5, 13), Some(kb(2)));
}
#[test]
fn slop_resolves_by_nearest_center() {
let mut f = Frame::default();
f.click_regions.push(ClickRegion::tight(5, 10, 13, kb(1)));
for r in 4..=6 {
f.click_regions
.push(ClickRegion::slop(r, 9, 14, kb(1), (11, 5)));
}
f.click_regions.push(ClickRegion::tight(7, 10, 13, kb(2)));
for r in 6..=8 {
f.click_regions
.push(ClickRegion::slop(r, 9, 14, kb(2), (11, 7)));
}
assert_eq!(f.click_to_action(6, 11), Some(kb(1)));
assert_eq!(f.click_to_action(8, 11), Some(kb(2)));
}
#[test]
fn miss_returns_none() {
let mut f = Frame::default();
f.click_regions.push(ClickRegion::tight(5, 10, 13, kb(1)));
f.click_regions
.push(ClickRegion::slop(5, 9, 14, kb(1), (11, 5)));
assert!(f.click_to_action(0, 0).is_none());
assert!(f.click_to_action(5, 20).is_none());
}
}
-28
View File
@@ -1,28 +0,0 @@
use zellij_tile::prelude::*;
use crate::components::modifier_bar::{CellId, ModifierBarController, TapOutcome};
#[derive(Default)]
pub struct Input {
pub modifier_bar: ModifierBarController,
pub ctrl_held: bool,
pub alt_held: bool,
}
impl Input {
pub fn merge_held_modifiers(&self, key: &KeyWithModifier) -> KeyWithModifier {
let mut merged = key.clone();
if self.ctrl_held {
merged.key_modifiers.insert(KeyModifier::Ctrl);
}
if self.alt_held {
merged.key_modifiers.insert(KeyModifier::Alt);
}
merged
}
pub fn handle_tap(&mut self, cell: CellId) -> TapOutcome {
self.modifier_bar
.handle_tap(cell, &mut self.ctrl_held, &mut self.alt_held)
}
}
-195
View File
@@ -1,195 +0,0 @@
use zellij_tile::prelude::*;
pub fn serialize_key(key: &KeyWithModifier) -> Vec<u8> {
let ctrl = key.key_modifiers.contains(&KeyModifier::Ctrl);
let alt = key.key_modifiers.contains(&KeyModifier::Alt);
let shift = key.key_modifiers.contains(&KeyModifier::Shift);
match key.bare_key {
BareKey::Char(c) => serialize_char(c, ctrl, alt, shift),
BareKey::Enter => prefix_alt(alt, b"\r"),
BareKey::Tab => {
if shift {
let back_tab = b"\x1b[Z";
prefix_alt(alt, back_tab)
} else {
prefix_alt(alt, b"\t")
}
},
BareKey::Backspace => {
if ctrl {
prefix_alt(alt, b"\x08")
} else {
prefix_alt(alt, b"\x7f")
}
},
BareKey::Esc => prefix_alt(alt, b"\x1b"),
BareKey::Up => arrow_or_modified(b'A', ctrl, alt, shift),
BareKey::Down => arrow_or_modified(b'B', ctrl, alt, shift),
BareKey::Right => arrow_or_modified(b'C', ctrl, alt, shift),
BareKey::Left => arrow_or_modified(b'D', ctrl, alt, shift),
BareKey::Home => arrow_or_modified(b'H', ctrl, alt, shift),
BareKey::End => arrow_or_modified(b'F', ctrl, alt, shift),
BareKey::Insert => tilde_or_modified(2, ctrl, alt, shift),
BareKey::Delete => tilde_or_modified(3, ctrl, alt, shift),
BareKey::PageUp => tilde_or_modified(5, ctrl, alt, shift),
BareKey::PageDown => tilde_or_modified(6, ctrl, alt, shift),
BareKey::F(n) => serialize_function_key(n, ctrl, alt, shift),
BareKey::CapsLock
| BareKey::ScrollLock
| BareKey::NumLock
| BareKey::PrintScreen
| BareKey::Pause
| BareKey::Menu => Vec::new(),
}
}
fn serialize_char(c: char, ctrl: bool, alt: bool, _shift: bool) -> Vec<u8> {
if ctrl {
if let Some(byte) = ctrl_byte(c) {
return prefix_alt(alt, &[byte]);
}
}
let mut buf = [0u8; 4];
let encoded = c.encode_utf8(&mut buf).as_bytes();
prefix_alt(alt, encoded)
}
fn ctrl_byte(c: char) -> Option<u8> {
let lower = c.to_ascii_lowercase();
match lower {
'@' | ' ' => Some(0x00),
c if ('a'..='z').contains(&c) => Some((c as u8) - b'`'),
'[' => Some(0x1b),
'\\' => Some(0x1c),
']' => Some(0x1d),
'^' => Some(0x1e),
'_' | '?' => Some(0x1f),
_ => None,
}
}
fn arrow_or_modified(letter: u8, ctrl: bool, alt: bool, shift: bool) -> Vec<u8> {
if ctrl || shift {
let modifier = encode_modifiers(ctrl, alt, shift);
format!("\x1b[1;{}{}", modifier, letter as char).into_bytes()
} else {
prefix_alt(alt, &[0x1b, b'[', letter])
}
}
fn tilde_or_modified(n: u8, ctrl: bool, alt: bool, shift: bool) -> Vec<u8> {
if ctrl || shift {
let modifier = encode_modifiers(ctrl, alt, shift);
format!("\x1b[{};{}~", n, modifier).into_bytes()
} else {
prefix_alt(alt, format!("\x1b[{}~", n).as_bytes())
}
}
fn serialize_function_key(n: u8, ctrl: bool, alt: bool, shift: bool) -> Vec<u8> {
let any_mod = ctrl || shift;
match n {
1..=4 => {
let letter = match n {
1 => 'P',
2 => 'Q',
3 => 'R',
4 => 'S',
_ => unreachable!(),
};
if any_mod {
let modifier = encode_modifiers(ctrl, alt, shift);
format!("\x1b[1;{}{}", modifier, letter).into_bytes()
} else {
prefix_alt(alt, format!("\x1bO{}", letter).as_bytes())
}
},
5 => format_tilde_fkey(15, ctrl, alt, shift),
6 => format_tilde_fkey(17, ctrl, alt, shift),
7 => format_tilde_fkey(18, ctrl, alt, shift),
8 => format_tilde_fkey(19, ctrl, alt, shift),
9 => format_tilde_fkey(20, ctrl, alt, shift),
10 => format_tilde_fkey(21, ctrl, alt, shift),
11 => format_tilde_fkey(23, ctrl, alt, shift),
12 => format_tilde_fkey(24, ctrl, alt, shift),
_ => Vec::new(),
}
}
fn format_tilde_fkey(n: u8, ctrl: bool, alt: bool, shift: bool) -> Vec<u8> {
if ctrl || shift {
let modifier = encode_modifiers(ctrl, alt, shift);
format!("\x1b[{};{}~", n, modifier).into_bytes()
} else {
prefix_alt(alt, format!("\x1b[{}~", n).as_bytes())
}
}
fn encode_modifiers(ctrl: bool, alt: bool, shift: bool) -> u8 {
let mut m = 1u8;
if shift {
m += 1;
}
if alt {
m += 2;
}
if ctrl {
m += 4;
}
m
}
fn prefix_alt(alt: bool, body: &[u8]) -> Vec<u8> {
if alt {
let mut out = Vec::with_capacity(body.len() + 1);
out.push(0x1b);
out.extend_from_slice(body);
out
} else {
body.to_vec()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
fn key_with(bare: BareKey, mods: &[KeyModifier]) -> KeyWithModifier {
let mut set = BTreeSet::new();
for m in mods {
set.insert(*m);
}
KeyWithModifier {
bare_key: bare,
key_modifiers: set,
}
}
#[test]
fn f1_unmodified() {
assert_eq!(serialize_key(&key_with(BareKey::F(1), &[])), b"\x1bOP");
}
#[test]
fn f12_unmodified() {
assert_eq!(serialize_key(&key_with(BareKey::F(12), &[])), b"\x1b[24~");
}
#[test]
fn ctrl_c() {
assert_eq!(
serialize_key(&key_with(BareKey::Char('c'), &[KeyModifier::Ctrl])),
vec![0x03]
);
}
#[test]
fn alt_x() {
assert_eq!(
serialize_key(&key_with(BareKey::Char('x'), &[KeyModifier::Alt])),
vec![0x1b, b'x']
);
}
}
-577
View File
@@ -1,577 +0,0 @@
mod ansi;
mod click;
mod components;
mod fit;
mod frame;
mod input;
mod keys;
mod mouse;
mod navigation;
mod pane_sync;
mod render;
mod screens;
mod state;
mod workspace;
use std::collections::BTreeMap;
use zellij_tile::prelude::*;
use crate::components::modifier_bar::{CellId, TapOutcome};
use crate::screens::ActiveScreen;
use crate::state::State;
use crate::workspace::pane_id_of;
register_plugin!(State);
impl ZellijPlugin for State {
fn load(&mut self, _configuration: BTreeMap<String, String>) {
let ids = get_plugin_ids();
self.workspace.own_plugin_pane_id = Some(PaneId::Plugin(ids.plugin_id));
subscribe(&[
EventType::ModeUpdate,
EventType::TabUpdate,
EventType::PaneUpdate,
EventType::Key,
EventType::Mouse,
EventType::PaneRenderReportWithAnsi,
EventType::SessionUpdate,
EventType::SoftKeyboardVisibilityChanged,
EventType::Timer,
]);
}
fn update(&mut self, event: Event) -> bool {
let should_render = match event {
Event::ModeUpdate(mode_info) => {
self.workspace.mode_info = Some(mode_info);
true
},
Event::TabUpdate(tabs) => {
self.workspace.tabs = tabs;
if self.workspace.selected_tab_position.is_none() {
self.workspace.selected_tab_position =
self.workspace.tabs_in_order().first().map(|t| t.position);
}
if let Some(pos) = self.workspace.selected_tab_position {
let still_visible = self
.workspace
.tabs_in_order()
.iter()
.any(|t| t.position == pos);
if !still_visible {
self.workspace.selected_tab_position =
self.workspace.tabs_in_order().first().map(|t| t.position);
}
}
sync_shadow_focus(self);
true
},
Event::PaneUpdate(manifest) => {
pane_sync::refresh_pane_manifest(self, manifest);
pane_sync::reconcile_selected_tab(self);
pane_sync::resolve_pending_new_tab(self);
pane_sync::ensure_pane_selected(self);
sync_shadow_focus(self);
pane_sync::maybe_take_over_welcome(self);
true
},
Event::SessionUpdate(sessions, _) => {
if let Some(current) = sessions.iter().find(|s| s.is_current_session) {
self.workspace.session_name = Some(current.name.clone());
}
let filtered = filter_sessions_for_client(sessions, self);
self.sessions.sessions = filtered;
true
},
Event::Timer(_) => {
self.empty_welcome_list_grace_elapsed = true;
true
},
Event::PaneRenderReportWithAnsi(map) => {
let now = unix_now();
for id in map.keys() {
self.workspace.pane_last_activity.insert(*id, now);
}
self.workspace.latest_pane_contents.extend(map);
if self.active == ActiveScreen::Panes {
refresh_pane_titles(self);
}
true
},
Event::Mouse(mouse) => {
match mouse {
Mouse::ScrollUp(lines) => {
let up = true;
return mouse::scroll_or_pan(self, lines, up);
},
Mouse::ScrollDown(lines) => {
let up = false;
return mouse::scroll_or_pan(self, lines, up);
},
Mouse::ScrollRight(cols) => {
let right = true;
return mouse::pan_horizontally(self, cols, right);
},
Mouse::ScrollLeft(cols) => {
let right = false;
return mouse::pan_horizontally(self, cols, right);
},
Mouse::LeftClick(..) => {
if let Some((line, col)) = mouse.position() {
return mouse::handle_left_click(self, line, col);
}
},
_ => {},
}
false
},
Event::Key(key) => match self.active {
ActiveScreen::NewSessionPrompt => {
self.new_session.handle_key(&mut self.active, key)
},
ActiveScreen::Sessions => {
self.sessions
.handle_key(&mut self.active, &mut self.navigation, key)
},
ActiveScreen::Panes => {
if let Some((tab_position, pane_id)) = self.panes.handle_key(
&mut self.active,
&mut self.navigation,
&self.workspace,
key,
) {
self.select_pane(tab_position, pane_id);
}
true
},
ActiveScreen::Viewport => {
if key.bare_key == BareKey::Esc && self.menu.open {
self.menu.open = false;
true
} else {
self.viewport
.handle_key(&self.workspace, &mut self.input, key)
}
},
},
Event::SoftKeyboardVisibilityChanged(visible) => {
if self.frame.soft_keyboard_visible == visible {
return false;
}
self.frame.soft_keyboard_visible = visible;
true
},
_ => false,
};
if self.fit.active {
let suppress_top_bar =
self.sessions.is_welcome_screen || self.active == ActiveScreen::Sessions;
self.fit
.notify_size(&self.workspace, &self.frame, suppress_top_bar);
}
should_render && self.is_ready_to_render()
}
fn render(&mut self, rows: usize, cols: usize) {
if rows == 0 || cols == 0 || !self.is_ready_to_render() {
return;
}
render::render(self, rows, cols);
}
}
impl State {
fn is_ready_to_render(&self) -> bool {
if self.workspace.tabs.is_empty() && self.workspace.panes_by_tab_position.is_empty() {
return false;
}
if self.showing_empty_welcome_list_within_grace() {
return false;
}
if self.active == ActiveScreen::Viewport {
return self.viewport_pane_has_content();
}
true
}
fn showing_empty_welcome_list_within_grace(&self) -> bool {
self.sessions.is_welcome_screen
&& self.sessions.sessions.is_empty()
&& !self.empty_welcome_list_grace_elapsed
}
fn viewport_pane_has_content(&self) -> bool {
let Some(pane) = self.workspace.current_pane() else {
return false;
};
self.workspace
.latest_pane_contents
.get(&pane_id_of(&pane))
.map(|contents| !contents.viewport.is_empty())
.unwrap_or(false)
}
pub fn open_sessions(&mut self) -> bool {
self.menu.open = false;
self.navigation.selector_scroll_offset = 0;
self.sessions.welcome_search.clear();
if let Ok(snapshot) = get_session_list() {
let filtered = filter_sessions_for_client(snapshot.live_sessions, self);
self.sessions.sessions = filtered;
}
self.active = ActiveScreen::Sessions;
true
}
pub fn open_panes(&mut self) -> bool {
self.menu.open = false;
self.navigation.selector_scroll_offset = 0;
self.panes.panes_search.clear();
refresh_pane_titles(self);
self.active = ActiveScreen::Panes;
true
}
pub fn collapse_selector(&mut self) -> bool {
self.active = ActiveScreen::Viewport;
self.sessions.welcome_search.clear();
self.panes.panes_search.clear();
self.navigation.selector_scroll_offset = 0;
true
}
pub fn select_pane(&mut self, tab_position: usize, pane_id: PaneId) -> bool {
self.fit.clear_if_active();
self.workspace.selected_tab_position = Some(tab_position);
self.workspace.selected_pane_id = Some(pane_id);
self.viewport.reset_pan();
self.active = ActiveScreen::Viewport;
sync_shadow_focus(self);
true
}
pub fn toggle_fit(&mut self) -> bool {
let suppress_top_bar =
self.sessions.is_welcome_screen || self.active == ActiveScreen::Sessions;
self.fit
.toggle(&self.workspace, &self.frame, suppress_top_bar)
}
pub fn new_pane_in_tab(&mut self, tab_position: usize) -> bool {
self.fit.clear_if_active();
if let Some(new_id) = new_tiled_pane_in_tab(tab_position) {
self.workspace.selected_tab_position = Some(tab_position);
self.workspace.selected_pane_id = Some(new_id);
self.viewport.reset_pan();
self.active = ActiveScreen::Viewport;
sync_shadow_focus(self);
}
true
}
pub fn new_tab(&mut self) -> bool {
self.fit.clear_if_active();
if let Some(tab_position) = new_tab_unfocused::<&str>(None, None) {
self.workspace.pending_new_tab_position = Some(tab_position);
}
true
}
pub fn open_new_session_prompt(&mut self) -> bool {
self.new_session.open(&mut self.active)
}
pub fn cancel_new_session_prompt(&mut self) -> bool {
self.new_session.cancel(&mut self.active)
}
pub fn accept_new_session_prompt(&mut self) -> bool {
self.new_session.accept(&mut self.active)
}
pub fn keyboard_tap(&mut self, cell: CellId) -> bool {
let outcome = self.input.handle_tap(cell);
match outcome {
TapOutcome::SendBytes(bytes) => {
if let Some(pane) = self.workspace.current_pane() {
if !bytes.is_empty() {
write_to_pane_id(bytes, pane_id_of(&pane));
}
}
},
TapOutcome::Toggled | TapOutcome::NoOp => {},
}
true
}
}
pub fn filter_sessions_for_client(sessions: Vec<SessionInfo>, state: &State) -> Vec<SessionInfo> {
let is_web_client = state
.workspace
.mode_info
.as_ref()
.and_then(|m| m.is_web_client)
.unwrap_or(false);
sessions
.into_iter()
.filter(|s| !is_welcome_session(s))
.filter(|s| !is_web_client || s.web_clients_allowed)
.collect()
}
fn is_welcome_session(session: &SessionInfo) -> bool {
session
.panes
.panes
.values()
.flatten()
.any(|p| p.is_plugin && p.plugin_url.as_deref() == Some("welcome-screen"))
}
pub fn sync_shadow_focus(state: &State) {
if let Some(pane) = state.workspace.current_pane() {
set_shadow_focus(pane_id_of(&pane));
}
}
pub fn unix_now() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn refresh_pane_titles(state: &mut State) {
let pane_ids: Vec<PaneId> = state
.workspace
.panes_by_tab_position
.values()
.flat_map(|panes| panes.iter().map(pane_id_of))
.collect();
for id in pane_ids {
let Some(fresh) = get_pane_info(id) else {
continue;
};
for panes in state.workspace.panes_by_tab_position.values_mut() {
for p in panes.iter_mut() {
if pane_id_of(p) == id {
p.title = fresh.title.clone();
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::click::{self, ClickAction};
use zellij_tile::prelude::{PaneInfo, SessionInfo, TabInfo};
fn fit_ready_state() -> State {
let mut state = State::default();
let tab = TabInfo {
position: 0,
name: "shell".to_string(),
tab_id: 7,
display_area_rows: 24,
display_area_columns: 80,
viewport_rows: 22,
viewport_columns: 80,
..TabInfo::default()
};
state.workspace.tabs.push(tab);
state.workspace.selected_tab_position = Some(0);
let pane = PaneInfo {
id: 3,
is_plugin: false,
is_selectable: true,
pane_rows: 22,
pane_columns: 80,
pane_content_rows: 20,
pane_content_columns: 78,
..PaneInfo::default()
};
state.workspace.panes_by_tab_position.insert(0, vec![pane]);
state.workspace.selected_pane_id = Some(PaneId::Terminal(3));
state
}
#[test]
fn dispatch_toggle_fit_on_path_seeds_fields() {
let mut state = fit_ready_state();
assert!(!state.fit.active, "Pre-condition: fit is off");
let consumed = click::dispatch(&mut state, ClickAction::ToggleFit);
assert!(consumed);
assert!(state.fit.active);
assert_eq!(state.fit.tab_id, Some(7));
}
#[test]
fn dispatch_toggle_fit_off_path_clears_fields() {
let mut state = fit_ready_state();
state.fit.active = true;
state.fit.tab_id = Some(7);
let consumed = click::dispatch(&mut state, ClickAction::ToggleFit);
assert!(consumed);
assert!(!state.fit.active);
assert_eq!(state.fit.tab_id, None);
}
#[test]
fn pane_update_clears_fit_when_selected_pane_disappears() {
let mut state = fit_ready_state();
state.fit.active = true;
state.fit.tab_id = Some(7);
let replacement_pane = PaneInfo {
id: 99,
is_plugin: false,
is_selectable: true,
..PaneInfo::default()
};
let mut panes = std::collections::HashMap::new();
panes.insert(0_usize, vec![replacement_pane]);
let manifest = PaneManifest { panes };
state.update(Event::PaneUpdate(manifest));
assert!(!state.fit.active, "Local fit mirror cleared");
assert_eq!(state.fit.tab_id, None);
}
#[test]
fn pane_update_resolves_pending_new_tab() {
let mut state = State::default();
let mut tab0 = TabInfo::default();
tab0.position = 0;
state.workspace.tabs.push(tab0);
let mut pane0 = PaneInfo::default();
pane0.id = 1;
pane0.is_plugin = false;
pane0.is_selectable = true;
state.workspace.panes_by_tab_position.insert(0, vec![pane0]);
state.workspace.selected_tab_position = Some(0);
state.workspace.selected_pane_id = Some(PaneId::Terminal(1));
state.active = ActiveScreen::Panes;
state.workspace.pending_new_tab_position = Some(1);
let mut new_tab = TabInfo::default();
new_tab.position = 1;
state.workspace.tabs.push(new_tab);
let mut new_pane = PaneInfo::default();
new_pane.id = 7;
new_pane.is_plugin = false;
new_pane.is_selectable = true;
let mut panes_map = std::collections::HashMap::new();
panes_map.insert(
0_usize,
vec![PaneInfo {
id: 1,
is_plugin: false,
is_selectable: true,
..PaneInfo::default()
}],
);
panes_map.insert(1_usize, vec![new_pane]);
let manifest = PaneManifest { panes: panes_map };
state.update(Event::PaneUpdate(manifest));
assert_eq!(state.workspace.selected_tab_position, Some(1));
assert_eq!(state.workspace.selected_pane_id, Some(PaneId::Terminal(7)));
assert_eq!(state.active, ActiveScreen::Viewport);
assert_eq!(state.workspace.pending_new_tab_position, None);
}
#[test]
fn pane_update_keeps_pending_when_target_tab_empty() {
let mut state = State::default();
let mut tab0 = TabInfo::default();
tab0.position = 0;
state.workspace.tabs.push(tab0);
let mut pane0 = PaneInfo::default();
pane0.id = 1;
pane0.is_plugin = false;
pane0.is_selectable = true;
state.workspace.panes_by_tab_position.insert(0, vec![pane0]);
state.workspace.selected_tab_position = Some(0);
state.workspace.selected_pane_id = Some(PaneId::Terminal(1));
state.workspace.pending_new_tab_position = Some(5);
let mut panes_map = std::collections::HashMap::new();
panes_map.insert(
0_usize,
vec![PaneInfo {
id: 1,
is_plugin: false,
is_selectable: true,
..PaneInfo::default()
}],
);
let manifest = PaneManifest { panes: panes_map };
state.update(Event::PaneUpdate(manifest));
assert_eq!(state.workspace.pending_new_tab_position, Some(5));
assert_eq!(state.workspace.selected_tab_position, Some(0));
}
fn welcome_state(session_names: &[&str]) -> State {
let mut state = fit_ready_state();
state.active = ActiveScreen::Sessions;
state.sessions.is_welcome_screen = true;
state.sessions.sessions = session_names
.iter()
.map(|name| SessionInfo {
name: name.to_string(),
..SessionInfo::default()
})
.collect();
state
}
#[test]
fn welcome_empty_list_is_not_ready_within_grace() {
let mut state = welcome_state(&[]);
state.empty_welcome_list_grace_elapsed = false;
assert!(!state.is_ready_to_render());
}
#[test]
fn welcome_empty_list_is_ready_after_grace() {
let mut state = welcome_state(&[]);
state.empty_welcome_list_grace_elapsed = true;
assert!(state.is_ready_to_render());
}
#[test]
fn welcome_non_empty_list_is_ready_within_grace() {
let mut state = welcome_state(&["alpha"]);
state.empty_welcome_list_grace_elapsed = false;
assert!(state.is_ready_to_render());
}
#[test]
fn timer_event_elapses_welcome_grace() {
let mut state = welcome_state(&[]);
assert!(!state.empty_welcome_list_grace_elapsed);
state.update(Event::Timer(0.0));
assert!(state.empty_welcome_list_grace_elapsed);
assert!(state.is_ready_to_render());
}
#[test]
fn viewport_without_a_pane_is_not_ready() {
let mut state = State::default();
state.workspace.tabs.push(TabInfo {
position: 0,
..TabInfo::default()
});
state.workspace.selected_tab_position = Some(0);
state.active = ActiveScreen::Viewport;
assert!(state.workspace.current_pane().is_none());
assert!(!state.is_ready_to_render());
}
}
-180
View File
@@ -1,180 +0,0 @@
use std::collections::BTreeMap;
use zellij_tile::prelude::*;
use crate::click;
use crate::screens::ActiveScreen;
use crate::state::State;
use crate::workspace::pane_id_of;
pub(crate) fn scroll_or_pan(state: &mut State, lines: usize, up: bool) -> bool {
if state.active != ActiveScreen::Viewport {
handle_selector_scroll(state, lines, up)
} else {
handle_scroll_pan(state, lines, up)
}
}
pub(crate) fn pan_horizontally(state: &mut State, cols: usize, right: bool) -> bool {
if !pan_is_allowed(state) {
return false;
}
state.viewport.viewport_h_pan = if right {
state.viewport.viewport_h_pan.saturating_add(cols)
} else {
state.viewport.viewport_h_pan.saturating_sub(cols)
};
true
}
pub(crate) fn handle_left_click(state: &mut State, line: usize, col: usize) -> bool {
if let Some(action) = state.frame.click_to_action(line, col) {
return click::dispatch(state, action);
}
if state.menu.open {
state.menu.open = false;
return true;
}
forward_click_to_pane(state, line, col)
}
fn forward_click_to_pane(state: &mut State, line: usize, col: usize) -> bool {
let Some((pane_row, pane_col)) = state.viewport.click_in_viewport(line, col) else {
return false;
};
let Some(pane) = state.workspace.current_pane() else {
return false;
};
if pane.is_plugin {
let mut args = BTreeMap::new();
args.insert("row".to_string(), pane_row.to_string());
args.insert("col".to_string(), pane_col.to_string());
let message = MessageToPlugin::new("mobile_viewport_click")
.with_destination_plugin_id(pane.id)
.with_args(args);
pipe_message_to_plugin(message);
} else {
let bytes = sgr_left_click(pane_row, pane_col);
write_to_pane_id(bytes, pane_id_of(&pane));
}
false
}
fn apply_v_pan(old_pan: usize, max_pan: usize, lines: usize, up: bool) -> (usize, usize) {
if up {
let desired = old_pan.saturating_add(lines);
let new_pan = desired.min(max_pan);
let absorbed = new_pan - old_pan;
(new_pan, lines - absorbed)
} else {
let new_pan = old_pan.saturating_sub(lines);
let absorbed = old_pan - new_pan;
(new_pan, lines - absorbed)
}
}
fn handle_selector_scroll(state: &mut State, lines: usize, up: bool) -> bool {
let step = lines.min(1);
let old = state.navigation.selector_scroll_offset;
state.navigation.selector_scroll_offset = if up {
old.saturating_sub(step)
} else {
old.saturating_add(step)
};
state.navigation.selector_scroll_offset != old
}
fn handle_scroll_pan(state: &mut State, lines: usize, up: bool) -> bool {
if !pan_is_allowed(state) {
return false;
}
let Some(max_v_pan) = state.viewport.max_viewport_v_pan(&state.workspace) else {
if up {
state.viewport.viewport_v_pan = state.viewport.viewport_v_pan.saturating_add(lines);
} else {
state.viewport.viewport_v_pan = state.viewport.viewport_v_pan.saturating_sub(lines);
}
return true;
};
let old_pan = state.viewport.viewport_v_pan;
let (new_pan, overflow) = apply_v_pan(old_pan, max_v_pan, lines, up);
let pan_moved = new_pan != old_pan;
state.viewport.viewport_v_pan = new_pan;
if overflow > 0 {
if let Some(pane) = state.workspace.current_pane() {
let pane_id = pane_id_of(&pane);
for _ in 0..overflow {
if up {
scroll_up_in_pane_id(pane_id);
} else {
scroll_down_in_pane_id(pane_id);
}
}
}
}
pan_moved
}
fn pan_is_allowed(state: &State) -> bool {
if state.active != ActiveScreen::Viewport {
return false;
}
if state.workspace.current_pane().is_none() {
return false;
}
let len = state.workspace.current_pane_viewport_len();
if len == 0 {
return false;
}
true
}
fn sgr_left_click(pane_row: usize, pane_col: usize) -> Vec<u8> {
let col = pane_col + 1;
let row = pane_row + 1;
format!("\x1b[<0;{};{}M\x1b[<0;{};{}m", col, row, col, row).into_bytes()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apply_v_pan_up_fully_absorbed() {
assert_eq!(apply_v_pan(0, 100, 3, true), (3, 0));
assert_eq!(apply_v_pan(50, 100, 3, true), (53, 0));
}
#[test]
fn apply_v_pan_up_partial_overflow() {
assert_eq!(apply_v_pan(99, 100, 3, true), (100, 2));
assert_eq!(apply_v_pan(98, 100, 5, true), (100, 3));
}
#[test]
fn apply_v_pan_up_fully_overflowed() {
assert_eq!(apply_v_pan(100, 100, 3, true), (100, 3));
}
#[test]
fn apply_v_pan_up_zero_max() {
assert_eq!(apply_v_pan(0, 0, 3, true), (0, 3));
assert_eq!(apply_v_pan(0, 0, 0, true), (0, 0));
}
#[test]
fn apply_v_pan_down_partial_overflow() {
assert_eq!(apply_v_pan(2, 100, 3, false), (0, 1));
assert_eq!(apply_v_pan(5, 100, 3, false), (2, 0));
}
#[test]
fn apply_v_pan_down_fully_overflowed() {
assert_eq!(apply_v_pan(0, 100, 3, false), (0, 3));
}
#[test]
fn apply_v_pan_zero_lines() {
assert_eq!(apply_v_pan(5, 100, 0, true), (5, 0));
assert_eq!(apply_v_pan(5, 100, 0, false), (5, 0));
}
}
-14
View File
@@ -1,14 +0,0 @@
use fuzzy_matcher::skim::SkimMatcherV2;
#[derive(Default)]
pub struct Navigation {
pub selector_scroll_offset: usize,
pub fuzzy_matcher: Option<SkimMatcherV2>,
}
impl Navigation {
pub fn matcher(&mut self) -> &mut SkimMatcherV2 {
self.fuzzy_matcher
.get_or_insert_with(|| SkimMatcherV2::default().use_cache(true))
}
}
-99
View File
@@ -1,99 +0,0 @@
use zellij_tile::prelude::*;
use crate::filter_sessions_for_client;
use crate::screens::ActiveScreen;
use crate::state::State;
use crate::workspace::pane_id_of;
const EMPTY_WELCOME_LIST_GRACE_SECS: f64 = 0.4;
pub fn refresh_pane_manifest(state: &mut State, manifest: PaneManifest) {
state.workspace.panes_by_tab_position = manifest.panes;
let live_pane_ids: std::collections::HashSet<PaneId> = state
.workspace
.panes_by_tab_position
.values()
.flat_map(|panes| panes.iter().map(pane_id_of))
.collect();
state
.workspace
.latest_pane_contents
.retain(|id, _| live_pane_ids.contains(id));
state
.workspace
.pane_last_activity
.retain(|id, _| live_pane_ids.contains(id));
if let Some(selected) = state.workspace.selected_pane_id {
if !live_pane_ids.contains(&selected) {
state.fit.reset_local();
}
}
}
pub fn reconcile_selected_tab(state: &mut State) {
let Some(pos) = state.workspace.selected_tab_position else {
state.workspace.selected_tab_position =
state.workspace.tabs_in_order().first().map(|t| t.position);
return;
};
let still_visible = state
.workspace
.tabs_in_order()
.iter()
.any(|t| t.position == pos);
if !still_visible {
state.fit.clear_if_active();
state.workspace.selected_tab_position =
state.workspace.tabs_in_order().first().map(|t| t.position);
state.workspace.selected_pane_id = None;
}
}
pub fn resolve_pending_new_tab(state: &mut State) {
let Some(target) = state.workspace.pending_new_tab_position else {
return;
};
let Some(id) = state
.workspace
.panes_for_tab(target)
.into_iter()
.next()
.map(pane_id_of)
else {
return;
};
state.workspace.selected_tab_position = Some(target);
state.workspace.selected_pane_id = Some(id);
state.viewport.reset_pan();
state.active = ActiveScreen::Viewport;
state.workspace.pending_new_tab_position = None;
}
pub fn ensure_pane_selected(state: &mut State) {
if state.workspace.selected_pane_id.is_none() {
if let Some(pane) = state.workspace.current_tab_panes().into_iter().next() {
state.workspace.selected_pane_id = Some(pane_id_of(pane));
}
}
}
pub fn maybe_take_over_welcome(state: &mut State) {
if state.sessions.is_welcome_screen
|| state.active != ActiveScreen::Viewport
|| !state.workspace.current_pane_is_welcome()
{
return;
}
if let Some(pane) = state.workspace.current_pane() {
close_plugin_pane(pane.id);
}
state.active = ActiveScreen::Sessions;
state.navigation.selector_scroll_offset = 0;
state.menu.open = false;
state.sessions.is_welcome_screen = true;
if let Ok(snapshot) = get_session_list() {
let filtered = filter_sessions_for_client(snapshot.live_sessions, state);
state.sessions.sessions = filtered;
}
set_timeout(EMPTY_WELCOME_LIST_GRACE_SECS);
}
-125
View File
@@ -1,125 +0,0 @@
use crate::ansi::{move_to, RESET};
use crate::components::{modifier_bar, top_bar};
use crate::frame::chrome_offsets;
use crate::screens::ActiveScreen;
use crate::state::State;
pub fn render(state: &mut State, rows: usize, cols: usize) {
reset_frame(state, rows, cols);
if rows < 4 || cols < 8 {
render_too_small(state, rows, cols);
return;
}
let suppress_top_bar = top_bar_suppressed(state);
let (body_top, bar_height) =
chrome_offsets(rows, suppress_top_bar, state.frame.soft_keyboard_visible);
let body_bottom = rows.saturating_sub(bar_height);
let viewport_height = body_bottom.saturating_sub(body_top);
state
.frame
.emit_cursor(viewport_cursor(state, body_top, viewport_height, cols));
disable_autowrap();
if !suppress_top_bar && cols > 0 {
top_bar::render(&state.workspace, &mut state.frame, state.active, 0, cols);
}
if body_bottom > body_top {
render_active_screen(state, body_top, body_bottom, cols);
render_menu_overlay(state, body_top, body_bottom, cols);
}
if bar_height > 0 {
render_modifier_bar(state, body_bottom, cols);
}
enable_autowrap();
}
fn reset_frame(state: &mut State, rows: usize, cols: usize) {
state.frame.click_regions.clear();
state.viewport.viewport_region = None;
state.frame.last_render_rows = rows;
state.frame.last_render_cols = cols;
}
fn render_too_small(state: &mut State, rows: usize, cols: usize) {
state.frame.emit_cursor(None);
print!("{}\x1b[2J{}mobile {}x{}", RESET, move_to(0, 0), rows, cols);
}
fn top_bar_suppressed(state: &State) -> bool {
state.sessions.is_welcome_screen || state.active == ActiveScreen::Sessions
}
fn viewport_cursor(
state: &State,
body_top: usize,
viewport_height: usize,
cols: usize,
) -> Option<(usize, usize)> {
if state.active != ActiveScreen::Viewport {
return None;
}
let lines = state.workspace.current_pane_viewport_len();
let max_v_pan = lines.saturating_sub(viewport_height);
let skip = max_v_pan - state.viewport.viewport_v_pan.min(max_v_pan);
state.viewport.compute_cursor_position(
&state.workspace,
body_top,
viewport_height,
cols,
skip,
state.viewport.viewport_h_pan,
)
}
fn render_active_screen(state: &mut State, body_top: usize, body_bottom: usize, cols: usize) {
let frame = &mut state.frame;
match state.active {
ActiveScreen::Viewport => {
state
.viewport
.render(&state.workspace, frame, body_top, body_bottom, cols)
},
ActiveScreen::Sessions => {
state
.sessions
.render(&mut state.navigation, frame, body_top, body_bottom, cols)
},
ActiveScreen::Panes => state.panes.render(
&state.workspace,
&mut state.navigation,
frame,
body_top,
body_bottom,
cols,
),
ActiveScreen::NewSessionPrompt => {
state.new_session.render(frame, body_top, body_bottom, cols)
},
}
}
fn render_menu_overlay(state: &mut State, body_top: usize, body_bottom: usize, cols: usize) {
if state.menu.open && state.active == ActiveScreen::Viewport {
state
.menu
.render(&state.fit, &mut state.frame, body_top, body_bottom, cols);
}
}
fn render_modifier_bar(state: &mut State, body_bottom: usize, cols: usize) {
let armed = modifier_bar::KeyboardModifiers {
ctrl_armed: state.input.ctrl_held,
alt_armed: state.input.alt_held,
};
modifier_bar::render_modifier_bar(&armed, body_bottom, cols, &mut state.frame.click_regions);
}
fn disable_autowrap() {
print!("\x1b[?7l");
}
fn enable_autowrap() {
print!("\x1b[?7h");
}
-217
View File
@@ -1,217 +0,0 @@
use unicode_width::UnicodeWidthStr;
use zellij_tile::prelude::*;
use crate::click::{ClickAction, ClickRegion};
use crate::fit::Fit;
use crate::frame::Frame;
use crate::screens::ActiveScreen;
const H_PAD: usize = 1;
const SEPARATOR_CHAR: char = '\u{2500}';
const ENTRIES: [HamburgerEntry; 5] = [
HamburgerEntry::Item(HamburgerItem::Fit),
HamburgerEntry::Item(HamburgerItem::ChangePane),
HamburgerEntry::Item(HamburgerItem::ChangeSession),
HamburgerEntry::Separator,
HamburgerEntry::Item(HamburgerItem::SwitchToDesktop),
];
#[derive(Default)]
pub struct MenuScreen {
pub open: bool,
}
impl MenuScreen {
pub fn toggle(&mut self, active: &mut ActiveScreen) -> bool {
if self.open {
self.open = false;
} else {
*active = ActiveScreen::Viewport;
self.open = true;
}
true
}
pub fn render(
&self,
fit: &Fit,
frame: &mut Frame,
row_start: usize,
row_end: usize,
cols: usize,
) {
let Some(layout) = MenuLayout::compute(&ENTRIES, cols) else {
return;
};
let max_visible = row_end.saturating_sub(row_start);
for (i, entry) in ENTRIES.iter().take(max_visible).enumerate() {
let row = row_start + i;
match entry {
HamburgerEntry::Item(item) => draw_item_row(frame, fit, item, &layout, row),
HamburgerEntry::Separator => draw_separator_row(&layout, row),
}
}
}
}
enum HamburgerItem {
Fit,
ChangePane,
ChangeSession,
SwitchToDesktop,
}
impl HamburgerItem {
fn label(&self) -> &'static str {
match self {
HamburgerItem::Fit => "Fit to Screen",
HamburgerItem::ChangePane => "Change Pane",
HamburgerItem::ChangeSession => "Change Session",
HamburgerItem::SwitchToDesktop => "Switch to Desktop",
}
}
fn action(&self) -> ClickAction {
match self {
HamburgerItem::Fit => ClickAction::ToggleFit,
HamburgerItem::ChangePane => ClickAction::ExpandPanes,
HamburgerItem::ChangeSession => ClickAction::ExpandSessions,
HamburgerItem::SwitchToDesktop => ClickAction::ExitMobileMode,
}
}
fn is_armed(&self, fit: &Fit) -> bool {
matches!(self, HamburgerItem::Fit) && fit.active
}
}
enum HamburgerEntry {
Item(HamburgerItem),
Separator,
}
impl HamburgerEntry {
fn label_width(&self) -> Option<usize> {
match self {
HamburgerEntry::Item(item) => Some(UnicodeWidthStr::width(item.label())),
HamburgerEntry::Separator => None,
}
}
}
struct MenuLayout {
menu_x: usize,
menu_w: usize,
label_max: usize,
}
impl MenuLayout {
fn compute(entries: &[HamburgerEntry], cols: usize) -> Option<Self> {
let label_max = entries
.iter()
.filter_map(HamburgerEntry::label_width)
.max()
.unwrap_or(0);
let menu_w = label_max + 2 * H_PAD;
if label_max == 0 || menu_w > cols {
return None;
}
Some(MenuLayout {
menu_x: cols - menu_w,
menu_w,
label_max,
})
}
fn padded_row(&self, content: &str, content_w: usize) -> String {
let pad = " ".repeat(H_PAD);
let trailing = " ".repeat(self.label_max.saturating_sub(content_w));
format!("{pad}{content}{trailing}{pad}")
}
}
fn draw_item_row(
frame: &mut Frame,
fit: &Fit,
item: &HamburgerItem,
layout: &MenuLayout,
row: usize,
) {
let label = item.label();
let text = layout.padded_row(label, UnicodeWidthStr::width(label));
let label_chars = H_PAD..H_PAD + label.chars().count();
let mut t = Text::new(&text).selected();
t = if item.is_armed(fit) {
t.success_color_range(label_chars)
} else {
t.color_range(3, label_chars)
};
print_text_with_coordinates(t, layout.menu_x, row, Some(layout.menu_w), None);
frame.click_regions.push(ClickRegion::tight(
row,
layout.menu_x,
layout.menu_x + layout.menu_w,
item.action(),
));
}
fn draw_separator_row(layout: &MenuLayout, row: usize) {
let rule: String = std::iter::repeat(SEPARATOR_CHAR)
.take(layout.label_max)
.collect();
let text = layout.padded_row(&rule, layout.label_max);
print_text_with_coordinates(
Text::new(&text).selected(),
layout.menu_x,
row,
Some(layout.menu_w),
None,
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::State;
#[test]
fn hamburger_menu_emits_four_click_regions_with_separator_above_exit() {
let mut state = State::default();
let cols = 40;
state.menu.render(&state.fit, &mut state.frame, 0, 20, cols);
assert_eq!(state.frame.click_regions.len(), 4);
let actions: Vec<ClickAction> = state
.frame
.click_regions
.iter()
.map(|r| r.action.clone())
.collect();
assert!(matches!(actions[0], ClickAction::ToggleFit));
assert!(matches!(actions[1], ClickAction::ExpandPanes));
assert!(matches!(actions[2], ClickAction::ExpandSessions));
assert!(matches!(actions[3], ClickAction::ExitMobileMode));
let rows: Vec<usize> = state
.frame
.click_regions
.iter()
.map(|r| r.row_start)
.collect();
assert_eq!(rows, vec![0, 1, 2, 4]);
let menu_x = state.frame.click_regions[0].col_start;
let menu_end = state.frame.click_regions[0].col_end;
for c in menu_x..menu_end {
assert_eq!(
state.frame.click_to_action(3, c),
None,
"separator row should be non-interactive at col {c}",
);
}
}
}
-20
View File
@@ -1,20 +0,0 @@
pub mod menu;
pub mod new_session;
pub mod panes;
pub mod sessions;
pub mod viewport;
pub use menu::MenuScreen;
pub use new_session::NewSessionPromptScreen;
pub use panes::PanesScreen;
pub use sessions::SessionsScreen;
pub use viewport::ViewportScreen;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ActiveScreen {
#[default]
Viewport,
Sessions,
Panes,
NewSessionPrompt,
}
@@ -1,233 +0,0 @@
use unicode_width::UnicodeWidthStr;
use zellij_tile::prelude::*;
use crate::click::{ClickAction, ClickRegion};
use crate::frame::Frame;
use crate::screens::ActiveScreen;
const H_PAD: usize = 1;
const RESERVED_INPUT_CHARS: usize = 20;
const ELLIPSIS: &str = "\u{2026}";
const DEFAULT_BUTTON_GAP: usize = 6;
const BLOCK_ROWS: usize = 5;
const TITLE: &str = "New Session";
const INPUT_LABEL: &str = "Name: ";
const CANCEL_LABEL: &str = "[Cancel]";
const ACCEPT_LABEL: &str = "[Accept]";
#[derive(Default)]
pub struct NewSessionPromptScreen {
pub pending_session_name: String,
pub new_session_view_offset: usize,
pub new_session_content_w: usize,
}
impl NewSessionPromptScreen {
fn reset(&mut self) {
self.pending_session_name.clear();
self.new_session_view_offset = 0;
self.new_session_content_w = 0;
}
pub fn open(&mut self, active: &mut ActiveScreen) -> bool {
self.reset();
*active = ActiveScreen::NewSessionPrompt;
true
}
pub fn cancel(&mut self, active: &mut ActiveScreen) -> bool {
self.reset();
*active = ActiveScreen::Sessions;
true
}
pub fn accept(&mut self, active: &mut ActiveScreen) -> bool {
let name = std::mem::take(&mut self.pending_session_name);
let arg = if name.is_empty() {
None
} else {
Some(name.as_str())
};
switch_session(arg);
self.new_session_view_offset = 0;
self.new_session_content_w = 0;
*active = ActiveScreen::Viewport;
true
}
pub fn handle_key(&mut self, active: &mut ActiveScreen, key: KeyWithModifier) -> bool {
match key.bare_key {
BareKey::Esc => {
self.cancel(active);
},
BareKey::Enter => {
self.accept(active);
},
BareKey::Backspace => {
self.pending_session_name.pop();
},
BareKey::Char(c) => {
self.pending_session_name.push(c);
},
_ => {},
}
true
}
pub fn render(&mut self, frame: &mut Frame, row_start: usize, row_end: usize, cols: usize) {
let body_height = row_end.saturating_sub(row_start);
if body_height == 0 || cols == 0 {
return;
}
let input = self.visible_input_row(cols);
let layout = self.layout(&input, cols, row_start, row_end, body_height);
draw_title(&layout);
draw_input(&input, &layout);
draw_buttons(frame, &layout);
}
fn visible_input_row(&mut self, cols: usize) -> String {
const CURSOR_CELL: usize = 1;
let max_input_total_w = cols.saturating_sub(2 * H_PAD);
let max_chars_no_ellipsis = max_input_total_w
.saturating_sub(INPUT_LABEL.len())
.saturating_sub(CURSOR_CELL);
let max_chars_with_ellipsis =
max_chars_no_ellipsis.saturating_sub(UnicodeWidthStr::width(ELLIPSIS));
let buffer_chars = self.pending_session_name.chars().count();
let view_offset = if buffer_chars > max_chars_no_ellipsis {
let min_offset = buffer_chars.saturating_sub(max_chars_with_ellipsis);
self.new_session_view_offset
.max(min_offset)
.min(buffer_chars)
} else {
0
};
self.new_session_view_offset = view_offset;
let visible: String = self
.pending_session_name
.chars()
.skip(view_offset)
.collect();
if view_offset > 0 {
format!("{}{}{}_", INPUT_LABEL, ELLIPSIS, visible)
} else {
format!("{}{}_", INPUT_LABEL, visible)
}
}
fn layout(
&mut self,
input: &str,
cols: usize,
row_start: usize,
row_end: usize,
body_height: usize,
) -> PromptLayout {
let default_buttons_w = UnicodeWidthStr::width(CANCEL_LABEL)
+ DEFAULT_BUTTON_GAP
+ UnicodeWidthStr::width(ACCEPT_LABEL);
let default_input_w = INPUT_LABEL.len() + RESERVED_INPUT_CHARS + 1;
let default_content_w = UnicodeWidthStr::width(TITLE)
.max(default_input_w)
.max(default_buttons_w);
let target_content_w = default_content_w.max(UnicodeWidthStr::width(input));
let content_w = self.new_session_content_w.max(target_content_w);
self.new_session_content_w = content_w;
let box_w = (content_w + 2 * H_PAD).min(cols);
let content_x = cols.saturating_sub(box_w) / 2 + H_PAD;
let top = if body_height >= BLOCK_ROWS {
row_start + (body_height - BLOCK_ROWS) / 2
} else {
row_start
};
PromptLayout {
content_x,
content_w: box_w.saturating_sub(2 * H_PAD),
row_title: top,
row_input: top + 2,
row_buttons: top + 4,
row_end,
}
}
}
struct PromptLayout {
content_x: usize,
content_w: usize,
row_title: usize,
row_input: usize,
row_buttons: usize,
row_end: usize,
}
impl PromptLayout {
fn centered_x(&self, label_w: usize) -> usize {
self.content_x + self.content_w.saturating_sub(label_w) / 2
}
fn right_x(&self, label_w: usize) -> usize {
self.content_x + self.content_w.saturating_sub(label_w)
}
}
fn draw_title(layout: &PromptLayout) {
if layout.row_title < layout.row_end {
let x = layout.centered_x(UnicodeWidthStr::width(TITLE));
print_text_with_coordinates(
Text::new(TITLE).color_range(3, ..),
x,
layout.row_title,
None,
None,
);
}
}
fn draw_input(input: &str, layout: &PromptLayout) {
if layout.row_input < layout.row_end {
print_text_with_coordinates(
Text::new(input),
layout.content_x,
layout.row_input,
None,
None,
);
}
}
fn draw_buttons(frame: &mut Frame, layout: &PromptLayout) {
if layout.row_buttons >= layout.row_end {
return;
}
let cancel_w = UnicodeWidthStr::width(CANCEL_LABEL);
let accept_w = UnicodeWidthStr::width(ACCEPT_LABEL);
let cancel_x = layout.content_x;
let accept_x = layout.right_x(accept_w);
let gap = " ".repeat(accept_x.saturating_sub(cancel_x + cancel_w));
let buttons = format!("{}{}{}", CANCEL_LABEL, gap, ACCEPT_LABEL);
let buttons_text = Text::new(&buttons)
.error_color_substring(CANCEL_LABEL)
.success_color_substring(ACCEPT_LABEL);
print_text_with_coordinates(buttons_text, cancel_x, layout.row_buttons, None, None);
frame.click_regions.push(ClickRegion::tight(
layout.row_buttons,
cancel_x,
cancel_x + cancel_w,
ClickAction::CancelNewSessionPrompt,
));
frame.click_regions.push(ClickRegion::tight(
layout.row_buttons,
accept_x,
accept_x + accept_w,
ClickAction::AcceptNewSessionPrompt,
));
}
-879
View File
@@ -1,879 +0,0 @@
use fuzzy_matcher::FuzzyMatcher;
use unicode_width::UnicodeWidthStr;
use zellij_tile::prelude::*;
use crate::click::{ClickAction, ClickRegion};
use crate::frame::Frame;
use crate::navigation::Navigation;
use crate::screens::ActiveScreen;
use crate::workspace::{pane_id_of, Workspace};
const CARD_BULLET: &str = "- ";
const CARD_INDENT_W: usize = 2;
const META_SEPARATOR: &str = ", ";
const TITLE: &str = "Switch Pane";
const PROMPT_LABEL: &str = "Pane: ";
const CURRENT_PANE_LABEL: &str = "[CURRENT PANE]";
const NEW_TAB_LABEL: &str = "+ New Tab";
const NEW_PANE_LABEL: &str = "+ New Pane";
const FOOTER_GAP: usize = 4;
#[derive(Default)]
pub struct PanesScreen {
pub panes_search: String,
}
impl PanesScreen {
pub fn handle_key(
&mut self,
active: &mut ActiveScreen,
nav: &mut Navigation,
ws: &Workspace,
key: KeyWithModifier,
) -> Option<(usize, PaneId)> {
match key.bare_key {
BareKey::Esc => {
if !self.panes_search.is_empty() {
self.panes_search.clear();
nav.selector_scroll_offset = 0;
} else {
*active = ActiveScreen::Viewport;
}
None
},
BareKey::Enter => {
if let Some((tab_position, pane_id)) = self.panes_top_match(ws, nav) {
self.panes_search.clear();
nav.selector_scroll_offset = 0;
Some((tab_position, pane_id))
} else {
None
}
},
BareKey::Backspace => {
self.panes_search.pop();
nav.selector_scroll_offset = 0;
None
},
BareKey::Char(c) => {
self.panes_search.push(c);
nav.selector_scroll_offset = 0;
None
},
_ => None,
}
}
pub fn panes_top_match(
&mut self,
ws: &Workspace,
nav: &mut Navigation,
) -> Option<(usize, PaneId)> {
let tabs: Vec<TabInfo> = ws.tabs_in_order().into_iter().cloned().collect();
let mut entries: Vec<(String, usize, PaneId)> = Vec::new();
for tab in &tabs {
for pane in ws.panes_for_tab(tab.position) {
entries.push((pane_title(pane), tab.position, pane_id_of(pane)));
}
}
if entries.is_empty() {
return None;
}
let search = self.panes_search.clone();
if search.is_empty() {
let first = entries.into_iter().next()?;
return Some((first.1, first.2));
}
let matcher = nav.matcher();
let mut best: Option<(i64, String, usize, PaneId)> = None;
for (title, tab_pos, pane_id) in entries.into_iter() {
if let Some((score, _)) = matcher.fuzzy_indices(&title, &search) {
let take = match &best {
None => true,
Some((bs, bn, _, _)) => score > *bs || (score == *bs && &title < bn),
};
if take {
best = Some((score, title, tab_pos, pane_id));
}
}
}
best.map(|(_, _, tab_pos, pane_id)| (tab_pos, pane_id))
}
pub fn render(
&mut self,
ws: &Workspace,
nav: &mut Navigation,
frame: &mut Frame,
row_start: usize,
row_end: usize,
cols: usize,
) {
if row_start < row_end {
draw_back_button(frame, row_start);
}
let body_start = row_start.saturating_add(1);
let body_end = row_end.saturating_sub(1);
let body_height = body_end.saturating_sub(body_start);
if body_height == 0 || cols == 0 {
return;
}
let new_pane_target_tab = ws.current_tab().map(|t| t.position);
let search = self.panes_search.clone();
let cards = self.ordered_cards(ws, nav, &search);
let layout = PickerLayout::compute(
nav,
&cards,
body_start,
body_end,
body_height,
cols,
new_pane_target_tab.is_some(),
);
let visible: Vec<&PaneCard> = cards
.iter()
.skip(layout.offset)
.take(layout.visible_count)
.collect();
draw_title(&layout);
draw_prompt(&search, &layout);
draw_scroll_indicators(&layout);
draw_cards(frame, &visible, &layout);
draw_footer(frame, new_pane_target_tab, &layout);
}
fn ordered_cards(&self, ws: &Workspace, nav: &mut Navigation, search: &str) -> Vec<PaneCard> {
let cards = self.collect_cards(ws);
if search.is_empty() {
return cards;
}
let matcher = nav.matcher();
let mut scored: Vec<(i64, PaneCard)> = cards
.into_iter()
.filter_map(|mut card| {
matcher
.fuzzy_indices(&card.title_label, search)
.map(|(score, indices)| {
card.title_indices = indices;
(score, card)
})
})
.collect();
scored.sort_by(|a, b| {
b.0.cmp(&a.0)
.then_with(|| a.1.title_label.cmp(&b.1.title_label))
});
scored.into_iter().map(|(_, card)| card).collect()
}
fn collect_cards(&self, ws: &Workspace) -> Vec<PaneCard> {
let now = crate::unix_now();
let current_tab_position = ws.current_tab().map(|t| t.position);
let current_pane_id = ws.current_pane().as_ref().map(pane_id_of);
let tabs: Vec<TabInfo> = ws.tabs_in_order().into_iter().cloned().collect();
let mut cards = Vec::new();
for tab in &tabs {
for pane in ws.panes_for_tab(tab.position) {
let id = pane_id_of(pane);
let is_current =
current_tab_position == Some(tab.position) && current_pane_id == Some(id);
cards.push(PaneCard {
title_label: pane_title(pane),
tab_label: tab.name.clone(),
activity_label: pane_activity_label(ws, id, is_current, now),
action: ClickAction::SelectPane {
tab_position: tab.position,
pane_id: id,
},
title_indices: Vec::new(),
is_current,
});
}
}
cards
}
}
fn pane_title(pane: &PaneInfo) -> String {
if pane.title.is_empty() {
format!("#{}", pane.id)
} else {
pane.title.clone()
}
}
fn pane_activity_label(ws: &Workspace, id: PaneId, is_current: bool, now: u64) -> String {
if is_current {
CURRENT_PANE_LABEL.to_string()
} else {
crate::ansi::format_time_ago(ws.pane_last_activity.get(&id).copied(), now)
}
}
struct PaneCard {
title_label: String,
tab_label: String,
activity_label: String,
action: ClickAction,
title_indices: Vec<usize>,
is_current: bool,
}
impl PaneCard {
fn meta_width(&self) -> usize {
UnicodeWidthStr::width(self.tab_label.as_str())
+ META_SEPARATOR.len()
+ UnicodeWidthStr::width(self.activity_label.as_str())
}
fn content_width(&self) -> usize {
UnicodeWidthStr::width(self.title_label.as_str()).max(self.meta_width())
}
fn draw(
&self,
frame: &mut Frame,
card_x: usize,
content_x: usize,
row_title: usize,
row_meta: usize,
row_end: usize,
) {
print_text_with_coordinates(Text::new(CARD_BULLET), card_x, row_title, None, None);
let mut title_text = Text::new(&self.title_label).color_range(2, ..);
if !self.title_indices.is_empty() {
title_text = title_text.color_indices(3, self.title_indices.clone());
}
print_text_with_coordinates(title_text, content_x, row_title, None, None);
if row_meta < row_end {
self.draw_meta_row(content_x, row_meta);
}
frame.click_regions.push(ClickRegion::tight_range(
row_title,
row_meta + 1,
card_x,
content_x + self.content_width(),
self.action.clone(),
));
}
fn draw_meta_row(&self, content_x: usize, row: usize) {
// The host reapplies bold per color-range without composing state, so a
// single Text would bleed the tab segment's bold onto the activity
// segment; emit each segment as its own Text.
let tab_w = UnicodeWidthStr::width(self.tab_label.as_str());
print_text_with_coordinates(
Text::new(&self.tab_label).color_range(1, ..),
content_x,
row,
None,
None,
);
let sep_x = content_x + tab_w;
print_text_with_coordinates(Text::new(META_SEPARATOR), sep_x, row, None, None);
let activity_x = sep_x + META_SEPARATOR.len();
let activity_text = if self.is_current {
Text::new(&self.activity_label).color_range(0, ..)
} else {
Text::new(&self.activity_label).unbold_all()
};
print_text_with_coordinates(activity_text, activity_x, row, None, None);
}
}
struct PickerLayout {
cols: usize,
row_end: usize,
top_y: usize,
block_height: usize,
card_x: usize,
content_x: usize,
offset: usize,
visible_count: usize,
hidden_above: usize,
hidden_below: usize,
new_tab_x: usize,
new_tab_w: usize,
new_pane_x: usize,
new_pane_w: usize,
}
impl PickerLayout {
fn compute(
nav: &mut Navigation,
cards: &[PaneCard],
body_start: usize,
body_end: usize,
body_height: usize,
cols: usize,
show_new_pane: bool,
) -> Self {
let total_cards = cards.len();
const CHROME_ROWS: usize = 6;
const ROWS_PER_CARD: usize = 2;
let max_visible_cards =
(body_height.saturating_sub(CHROME_ROWS) / ROWS_PER_CARD).min(total_cards);
let max_offset = total_cards.saturating_sub(max_visible_cards);
let offset = nav.selector_scroll_offset.min(max_offset);
nav.selector_scroll_offset = offset;
let visible_count = total_cards.saturating_sub(offset).min(max_visible_cards);
let block_height = if visible_count == 0 {
5.min(body_height)
} else {
(6 + 2 * visible_count).min(body_height)
};
let top_y = body_start + body_height.saturating_sub(block_height) / 2;
let content_w = cards
.iter()
.skip(offset)
.take(visible_count)
.map(PaneCard::content_width)
.max()
.unwrap_or(0);
let card_x = cols.saturating_sub(CARD_INDENT_W + content_w) / 2;
let content_x = card_x + CARD_INDENT_W;
let new_tab_w = UnicodeWidthStr::width(NEW_TAB_LABEL);
let new_pane_w = UnicodeWidthStr::width(NEW_PANE_LABEL);
let (new_tab_x, new_pane_x) = if show_new_pane {
let total = new_tab_w + FOOTER_GAP + new_pane_w;
let block_x = cols.saturating_sub(total) / 2;
(block_x, block_x + new_tab_w + FOOTER_GAP)
} else {
(cols.saturating_sub(new_tab_w) / 2, 0)
};
PickerLayout {
cols,
row_end: body_end,
top_y,
block_height,
card_x,
content_x,
offset,
visible_count,
hidden_above: offset,
hidden_below: total_cards.saturating_sub(offset + visible_count),
new_tab_x,
new_tab_w,
new_pane_x,
new_pane_w,
}
}
fn centered_x(&self, label_w: usize) -> usize {
self.cols.saturating_sub(label_w) / 2
}
fn prompt_x(&self) -> usize {
if self.visible_count > 0 {
self.content_x
} else {
self.new_tab_x
}
}
}
fn draw_back_button(frame: &mut Frame, row: usize) {
let back_label = "[← BACK]";
let back_w = UnicodeWidthStr::width(back_label);
print_text_with_coordinates(Text::new(back_label).color_range(3, ..), 0, row, None, None);
frame.click_regions.push(ClickRegion::tight(
row,
0,
back_w,
ClickAction::CollapseSelector,
));
}
fn draw_title(layout: &PickerLayout) {
let title_y = layout.top_y;
if title_y < layout.row_end {
let title_x = layout.centered_x(UnicodeWidthStr::width(TITLE));
print_text_with_coordinates(Text::new(TITLE), title_x, title_y, None, None);
}
}
fn draw_prompt(search: &str, layout: &PickerLayout) {
let prompt_y = layout.top_y + 2;
if prompt_y >= layout.row_end {
return;
}
let prompt_full = format!("{}{}_", PROMPT_LABEL, search);
let label_chars = PROMPT_LABEL.chars().count();
let total_chars = prompt_full.chars().count();
let prompt_text = Text::new(&prompt_full).color_range(3, label_chars..total_chars);
print_text_with_coordinates(prompt_text, layout.prompt_x(), prompt_y, None, None);
}
fn draw_scroll_indicators(layout: &PickerLayout) {
if layout.visible_count == 0 {
return;
}
if layout.hidden_above > 0 {
let y = layout.top_y + 3;
if y < layout.row_end {
let label = format!("\u{2191} [+{}]", layout.hidden_above);
let w = UnicodeWidthStr::width(label.as_str());
print_text_with_coordinates(
Text::new(&label).color_range(1, ..),
layout.centered_x(w),
y,
None,
None,
);
}
}
if layout.hidden_below > 0 {
let y = layout.top_y + 4 + 2 * layout.visible_count;
if y < layout.row_end {
let label = format!("\u{2193} [+{}]", layout.hidden_below);
let w = UnicodeWidthStr::width(label.as_str());
print_text_with_coordinates(
Text::new(&label).color_range(1, ..),
layout.centered_x(w),
y,
None,
None,
);
}
}
}
fn draw_cards(frame: &mut Frame, visible: &[&PaneCard], layout: &PickerLayout) {
let first_card_y = layout.top_y + 4;
for (i, card) in visible.iter().enumerate() {
let row_title = first_card_y + i * 2;
if row_title >= layout.row_end {
break;
}
card.draw(
frame,
layout.card_x,
layout.content_x,
row_title,
row_title + 1,
layout.row_end,
);
}
}
fn draw_footer(frame: &mut Frame, new_pane_target_tab: Option<usize>, layout: &PickerLayout) {
let footer_y = layout.top_y + layout.block_height.saturating_sub(1);
if footer_y >= layout.row_end {
return;
}
print_text_with_coordinates(
Text::new(NEW_TAB_LABEL).color_range(3, ..),
layout.new_tab_x,
footer_y,
None,
None,
);
frame.click_regions.push(ClickRegion::tight(
footer_y,
layout.new_tab_x,
layout.new_tab_x + layout.new_tab_w,
ClickAction::NewTab,
));
if let Some(tab_position) = new_pane_target_tab {
print_text_with_coordinates(
Text::new(NEW_PANE_LABEL).color_range(3, ..),
layout.new_pane_x,
footer_y,
None,
None,
);
frame.click_regions.push(ClickRegion::tight(
footer_y,
layout.new_pane_x,
layout.new_pane_x + layout.new_pane_w,
ClickAction::NewPaneInTab { tab_position },
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::State;
fn state_with_tabs_and_panes(tab_count: usize) -> State {
use zellij_tile::prelude::TabInfo;
let mut state = State::default();
for i in 0..tab_count {
let mut tab = TabInfo::default();
tab.position = i;
tab.name = format!("Tab {}", i);
state.workspace.tabs.push(tab);
let mut pane = PaneInfo::default();
pane.id = (100 + i) as u32;
pane.is_plugin = false;
pane.is_selectable = true;
pane.is_suppressed = false;
state.workspace.panes_by_tab_position.insert(i, vec![pane]);
}
state.workspace.selected_tab_position = Some(0);
state.workspace.selected_pane_id = Some(PaneId::Terminal(100));
state
}
#[test]
fn panes_menu_one_tab_emits_four_click_regions() {
let mut state = state_with_tabs_and_panes(1);
let cols = 40;
state.panes.render(
&state.workspace,
&mut state.navigation,
&mut state.frame,
0,
20,
cols,
);
assert_eq!(state.frame.click_regions.len(), 4);
let actions: Vec<ClickAction> = state
.frame
.click_regions
.iter()
.map(|r| r.action.clone())
.collect();
assert!(matches!(actions[0], ClickAction::CollapseSelector));
assert!(matches!(
actions[1],
ClickAction::SelectPane {
tab_position: 0,
pane_id: PaneId::Terminal(100)
}
));
assert!(matches!(actions[2], ClickAction::NewTab));
assert!(matches!(
actions[3],
ClickAction::NewPaneInTab { tab_position: 0 }
));
}
#[test]
fn panes_menu_two_tabs_emits_single_footer_new_pane() {
let mut state = state_with_tabs_and_panes(2);
let cols = 40;
state.panes.render(
&state.workspace,
&mut state.navigation,
&mut state.frame,
0,
20,
cols,
);
assert_eq!(state.frame.click_regions.len(), 5);
let new_panes: Vec<usize> = state
.frame
.click_regions
.iter()
.filter_map(|r| match &r.action {
ClickAction::NewPaneInTab { tab_position } => Some(*tab_position),
_ => None,
})
.collect();
assert_eq!(new_panes, vec![0]);
let new_tabs = state
.frame
.click_regions
.iter()
.filter(|r| matches!(r.action, ClickAction::NewTab))
.count();
assert_eq!(new_tabs, 1);
}
#[test]
fn footer_affordances_share_a_row_and_order_left_to_right() {
let mut state = state_with_tabs_and_panes(1);
state.panes.render(
&state.workspace,
&mut state.navigation,
&mut state.frame,
0,
20,
40,
);
let new_tab = state
.frame
.click_regions
.iter()
.find(|r| matches!(r.action, ClickAction::NewTab))
.expect("expected a NewTab region")
.clone();
let new_pane = state
.frame
.click_regions
.iter()
.find(|r| matches!(r.action, ClickAction::NewPaneInTab { .. }))
.expect("expected a NewPaneInTab region")
.clone();
assert_eq!(
new_tab.row_start, new_pane.row_start,
"footer affordances must share a row",
);
assert!(
new_tab.col_end <= new_pane.col_start,
"+ New Tab ({}..{}) must sit left of + New Pane ({}..)",
new_tab.col_start,
new_tab.col_end,
new_pane.col_start,
);
}
#[test]
fn click_on_new_pane_row_resolves_to_action() {
let mut state = state_with_tabs_and_panes(2);
state.workspace.selected_tab_position = Some(1);
state.workspace.selected_pane_id = Some(PaneId::Terminal(101));
state.panes.render(
&state.workspace,
&mut state.navigation,
&mut state.frame,
0,
20,
40,
);
let new_pane_region = state
.frame
.click_regions
.iter()
.find(|r| matches!(r.action, ClickAction::NewPaneInTab { tab_position: 1 }))
.expect("expected NewPaneInTab targeting the selected tab")
.clone();
assert_eq!(
state
.frame
.click_to_action(new_pane_region.row_start, new_pane_region.col_start,),
Some(ClickAction::NewPaneInTab { tab_position: 1 })
);
}
#[test]
fn panes_menu_back_button_at_top_left_collapses_selector() {
let mut state = state_with_tabs_and_panes(1);
state.panes.render(
&state.workspace,
&mut state.navigation,
&mut state.frame,
0,
20,
40,
);
let first = state
.frame
.click_regions
.first()
.expect("expected at least one click region");
assert_eq!(first.row_start, 0);
assert_eq!(first.col_start, 0);
assert!(matches!(first.action, ClickAction::CollapseSelector));
assert_eq!(
state.frame.click_to_action(0, 0),
Some(ClickAction::CollapseSelector)
);
}
#[test]
fn pane_card_click_region_spans_two_rows() {
let mut state = state_with_tabs_and_panes(1);
state.panes.render(
&state.workspace,
&mut state.navigation,
&mut state.frame,
0,
20,
40,
);
let pane_region = state
.frame
.click_regions
.iter()
.find(|r| {
matches!(
r.action,
ClickAction::SelectPane {
tab_position: 0,
pane_id: PaneId::Terminal(100),
}
)
})
.expect("expected a SelectPane region")
.clone();
assert_eq!(
pane_region.row_end - pane_region.row_start,
2,
"pane card click region should cover two rows",
);
let expected = Some(ClickAction::SelectPane {
tab_position: 0,
pane_id: PaneId::Terminal(100),
});
assert_eq!(
state
.frame
.click_to_action(pane_region.row_start, pane_region.col_start),
expected,
"tap on title row should select the pane",
);
assert_eq!(
state
.frame
.click_to_action(pane_region.row_start + 1, pane_region.col_start,),
expected,
"tap on activity row should select the pane",
);
}
#[test]
fn panes_menu_fuzzy_filter_keeps_footer_visible() {
let mut state = state_with_tabs_and_panes(2);
for (i, panes) in state.workspace.panes_by_tab_position.iter_mut() {
for pane in panes.iter_mut() {
pane.title = format!("alpha-{}", i);
}
}
state.panes.panes_search = "alpha".to_string();
state.panes.render(
&state.workspace,
&mut state.navigation,
&mut state.frame,
0,
20,
60,
);
let new_tab_count = state
.frame
.click_regions
.iter()
.filter(|r| matches!(r.action, ClickAction::NewTab))
.count();
assert_eq!(
new_tab_count, 1,
"+ New Tab must stay visible during search"
);
let new_pane_count = state
.frame
.click_regions
.iter()
.filter(|r| matches!(r.action, ClickAction::NewPaneInTab { .. }))
.count();
assert_eq!(
new_pane_count, 1,
"+ New Pane must stay visible during search",
);
assert_eq!(state.frame.click_regions.len(), 5);
}
#[test]
fn current_pane_card_span_includes_current_pane_label() {
let mut state = state_with_tabs_and_panes(2);
state.workspace.selected_tab_position = Some(0);
state.workspace.selected_pane_id = Some(PaneId::Terminal(100));
state.panes.render(
&state.workspace,
&mut state.navigation,
&mut state.frame,
0,
20,
60,
);
let select_regions: Vec<&ClickRegion> = state
.frame
.click_regions
.iter()
.filter(|r| matches!(r.action, ClickAction::SelectPane { .. }))
.collect();
assert_eq!(select_regions.len(), 2);
let (current, other): (&&ClickRegion, &&ClickRegion) = {
let current = select_regions
.iter()
.find(|r| {
matches!(
r.action,
ClickAction::SelectPane {
pane_id: PaneId::Terminal(100),
..
}
)
})
.expect("expected a SelectPane region for pane 100");
let other = select_regions
.iter()
.find(|r| {
matches!(
r.action,
ClickAction::SelectPane {
pane_id: PaneId::Terminal(101),
..
}
)
})
.expect("expected a SelectPane region for pane 101");
(current, other)
};
let current_span = current.col_end.saturating_sub(current.col_start);
assert_eq!(current_span, 23);
let other_span = other.col_end.saturating_sub(other.col_start);
assert_eq!(other_span, 10);
assert_eq!(
current.col_start, other.col_start,
"both pane cards must anchor at the card column's left edge",
);
}
#[test]
fn panes_menu_fuzzy_filter_narrows_to_matching_pane() {
let mut state = state_with_tabs_and_panes(2);
state.workspace.panes_by_tab_position.get_mut(&0).unwrap()[0].title = "alpha".to_string();
state.workspace.panes_by_tab_position.get_mut(&1).unwrap()[0].title = "bravo".to_string();
state.panes.panes_search = "brv".to_string();
state.panes.render(
&state.workspace,
&mut state.navigation,
&mut state.frame,
0,
20,
60,
);
let select_panes: Vec<ClickAction> = state
.frame
.click_regions
.iter()
.filter(|r| matches!(r.action, ClickAction::SelectPane { .. }))
.map(|r| r.action.clone())
.collect();
assert_eq!(
select_panes.len(),
1,
"only the 'bravo' pane should survive the 'brv' filter",
);
assert!(matches!(
select_panes[0],
ClickAction::SelectPane {
tab_position: 1,
pane_id: PaneId::Terminal(101),
}
));
}
}
@@ -1,458 +0,0 @@
use fuzzy_matcher::FuzzyMatcher;
use unicode_width::UnicodeWidthStr;
use zellij_tile::prelude::*;
use crate::click::{ClickAction, ClickRegion};
use crate::frame::Frame;
use crate::navigation::Navigation;
use crate::screens::ActiveScreen;
const CARD_BULLET: &str = "- ";
const CARD_INDENT_W: usize = 2;
const NEW_SESSION_LABEL: &str = "+ New Session";
#[derive(Default)]
pub struct SessionsScreen {
pub sessions: Vec<SessionInfo>,
pub welcome_search: String,
pub is_welcome_screen: bool,
}
impl SessionsScreen {
pub fn select_session(&self, active: &mut ActiveScreen, name: &str) -> bool {
switch_session(Some(name));
*active = ActiveScreen::Viewport;
true
}
pub fn handle_key(
&mut self,
active: &mut ActiveScreen,
nav: &mut Navigation,
key: KeyWithModifier,
) -> bool {
match key.bare_key {
BareKey::Esc => {
if !self.welcome_search.is_empty() {
self.welcome_search.clear();
nav.selector_scroll_offset = 0;
} else if !self.is_welcome_screen {
*active = ActiveScreen::Viewport;
}
},
BareKey::Enter => {
if let Some(name) = self.top_match_name(nav) {
switch_session(Some(&name));
*active = ActiveScreen::Viewport;
}
},
BareKey::Backspace => {
self.welcome_search.pop();
nav.selector_scroll_offset = 0;
},
BareKey::Char(c) => {
self.welcome_search.push(c);
nav.selector_scroll_offset = 0;
},
_ => {},
}
true
}
pub fn top_match_name(&mut self, nav: &mut Navigation) -> Option<String> {
let search = self.welcome_search.clone();
if search.is_empty() {
return self.selectable_sessions().map(|s| s.name.clone()).min();
}
let matcher = nav.matcher();
let mut best: Option<(i64, String)> = None;
for s in self.selectable_sessions() {
if let Some((score, _)) = matcher.fuzzy_indices(&s.name, &search) {
let take = match &best {
None => true,
Some((bs, bn)) => score > *bs || (score == *bs && &s.name < bn),
};
if take {
best = Some((score, s.name.clone()));
}
}
}
best.map(|(_, name)| name)
}
fn selectable_sessions(&self) -> impl Iterator<Item = &SessionInfo> {
self.sessions.iter().filter(|s| !s.is_current_session)
}
fn header(&self) -> (&'static str, bool) {
if self.is_welcome_screen {
("Hi from Zellij!", false)
} else {
("Switch Session", true)
}
}
pub fn render(
&mut self,
nav: &mut Navigation,
frame: &mut Frame,
row_start: usize,
row_end: usize,
cols: usize,
) {
let (title, show_back_button) = self.header();
if show_back_button && row_start < row_end {
draw_back_button(frame, row_start);
}
let body_start = if show_back_button {
row_start.saturating_add(1)
} else {
row_start
};
let body_end = row_end.saturating_sub(1);
let body_height = body_end.saturating_sub(body_start);
if body_height == 0 || cols == 0 {
return;
}
let search = self.welcome_search.clone();
let cards = self.ordered_cards(nav, &search);
let layout = PickerLayout::compute(nav, &cards, body_start, body_end, body_height, cols);
let visible: Vec<&Card> = cards
.iter()
.skip(layout.offset)
.take(layout.visible_count)
.collect();
draw_title(title, &layout);
draw_prompt(&search, &layout);
draw_scroll_indicators(&layout);
draw_cards(frame, &visible, &layout);
draw_new_session_button(frame, &layout);
}
fn ordered_cards(&self, nav: &mut Navigation, search: &str) -> Vec<Card> {
self.scored_session_order(nav, search)
.into_iter()
.map(|(idx, name_indices)| Card::from_session(&self.sessions[idx], name_indices))
.collect()
}
fn scored_session_order(&self, nav: &mut Navigation, search: &str) -> Vec<(usize, Vec<usize>)> {
if search.is_empty() {
let mut indexed: Vec<(usize, &str)> = self
.sessions
.iter()
.enumerate()
.filter(|(_, s)| !s.is_current_session)
.map(|(i, s)| (i, s.name.as_str()))
.collect();
indexed.sort_by(|a, b| a.1.cmp(b.1));
return indexed.into_iter().map(|(i, _)| (i, Vec::new())).collect();
}
let matcher = nav.matcher();
let mut scored: Vec<(usize, i64, Vec<usize>)> = self
.sessions
.iter()
.enumerate()
.filter(|(_, s)| !s.is_current_session)
.filter_map(|(i, s)| {
matcher
.fuzzy_indices(&s.name, search)
.map(|(score, indices)| (i, score, indices))
})
.collect();
scored.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| self.sessions[a.0].name.cmp(&self.sessions[b.0].name))
});
scored
.into_iter()
.map(|(i, _, indices)| (i, indices))
.collect()
}
}
struct Card {
name_label: String,
counts_label: String,
action: ClickAction,
tab_range: std::ops::Range<usize>,
pane_range: std::ops::Range<usize>,
client_range: std::ops::Range<usize>,
name_indices: Vec<usize>,
}
impl Card {
fn from_session(s: &SessionInfo, name_indices: Vec<usize>) -> Self {
let pane_count: usize = s
.panes
.panes
.values()
.map(|panes| {
panes
.iter()
.filter(|p| p.is_selectable && !p.is_suppressed)
.count()
})
.sum();
let name = s.name.clone();
let tab_str = format!("{}", s.tabs.len());
let pane_str = format!("{}", pane_count);
let conn_str = format!("{}", s.connected_clients);
let client_word = if s.connected_clients == 1 {
"client"
} else {
"clients"
};
let counts_label = format!(
"{} tabs, {} panes, {} {}",
tab_str, pane_str, conn_str, client_word
);
// Color ranges mirror the session-manager welcome screen
// (UnifiedResultsRenderCache::rebuild): tab count color 1, pane and
// client counts color 2.
let tab_end = tab_str.len();
let pane_offset = tab_str.len() + " tabs, ".len();
let pane_end = pane_offset + pane_str.len();
let conn_offset = pane_end + " panes, ".len();
let conn_end = conn_offset + conn_str.len();
Card {
name_label: name.clone(),
counts_label,
action: ClickAction::SelectSession(name),
tab_range: 0..tab_end,
pane_range: pane_offset..pane_end,
client_range: conn_offset..conn_end,
name_indices,
}
}
fn content_width(&self) -> usize {
UnicodeWidthStr::width(self.name_label.as_str())
.max(UnicodeWidthStr::width(self.counts_label.as_str()))
}
fn draw(
&self,
frame: &mut Frame,
card_x: usize,
content_x: usize,
row_name: usize,
row_counts: usize,
row_end: usize,
) {
print_text_with_coordinates(Text::new(CARD_BULLET), card_x, row_name, None, None);
let mut name_text = Text::new(&self.name_label).color_range(0, ..);
if !self.name_indices.is_empty() {
name_text = name_text.color_indices(3, self.name_indices.clone());
}
print_text_with_coordinates(name_text, content_x, row_name, None, None);
if row_counts < row_end {
let counts_text = Text::new(&self.counts_label)
.color_range(1, self.tab_range.clone())
.color_range(2, self.pane_range.clone())
.color_range(2, self.client_range.clone());
print_text_with_coordinates(counts_text, content_x, row_counts, None, None);
}
frame.click_regions.push(ClickRegion::tight_range(
row_name,
row_counts + 1,
card_x,
content_x + self.content_width(),
self.action.clone(),
));
}
}
struct PickerLayout {
cols: usize,
row_end: usize,
top_y: usize,
block_height: usize,
card_x: usize,
content_x: usize,
new_session_x: usize,
new_session_w: usize,
offset: usize,
visible_count: usize,
hidden_above: usize,
hidden_below: usize,
}
impl PickerLayout {
fn compute(
nav: &mut Navigation,
cards: &[Card],
body_start: usize,
body_end: usize,
body_height: usize,
cols: usize,
) -> Self {
let total_cards = cards.len();
const CHROME_ROWS: usize = 6;
const ROWS_PER_CARD: usize = 2;
let max_visible_cards =
(body_height.saturating_sub(CHROME_ROWS) / ROWS_PER_CARD).min(total_cards);
let max_offset = total_cards.saturating_sub(max_visible_cards);
let offset = nav.selector_scroll_offset.min(max_offset);
nav.selector_scroll_offset = offset;
let visible_count = total_cards.saturating_sub(offset).min(max_visible_cards);
let block_height = if visible_count == 0 {
5.min(body_height)
} else {
(6 + 2 * visible_count).min(body_height)
};
let top_y = body_start + body_height.saturating_sub(block_height) / 2;
let content_w = cards
.iter()
.skip(offset)
.take(visible_count)
.map(Card::content_width)
.max()
.unwrap_or(0);
let card_w = CARD_INDENT_W + content_w;
let card_x = cols.saturating_sub(card_w) / 2;
let content_x = card_x + CARD_INDENT_W;
let new_session_w = UnicodeWidthStr::width(NEW_SESSION_LABEL);
let new_session_x = cols.saturating_sub(new_session_w) / 2;
PickerLayout {
cols,
row_end: body_end,
top_y,
block_height,
card_x,
content_x,
new_session_x,
new_session_w,
offset,
visible_count,
hidden_above: offset,
hidden_below: total_cards.saturating_sub(offset + visible_count),
}
}
fn centered_x(&self, label_w: usize) -> usize {
self.cols.saturating_sub(label_w) / 2
}
}
fn draw_back_button(frame: &mut Frame, row: usize) {
let back_label = "[← BACK]";
let back_w = UnicodeWidthStr::width(back_label);
print_text_with_coordinates(Text::new(back_label).color_range(3, ..), 0, row, None, None);
frame.click_regions.push(ClickRegion::tight(
row,
0,
back_w,
ClickAction::CollapseSelector,
));
}
fn draw_title(title: &str, layout: &PickerLayout) {
let title_y = layout.top_y;
if title_y < layout.row_end {
let title_x = layout.centered_x(UnicodeWidthStr::width(title));
print_text_with_coordinates(Text::new(title), title_x, title_y, None, None);
}
}
fn draw_prompt(search: &str, layout: &PickerLayout) {
let prompt_y = layout.top_y + 2;
if prompt_y >= layout.row_end {
return;
}
let prompt_label = "Session: ";
let prompt_full = format!("{}{}_", prompt_label, search);
let prompt_x = if layout.visible_count > 0 {
layout.content_x
} else {
layout.new_session_x
};
let label_chars = prompt_label.chars().count();
let total_chars = prompt_full.chars().count();
let prompt_text = Text::new(&prompt_full).color_range(3, label_chars..total_chars);
print_text_with_coordinates(prompt_text, prompt_x, prompt_y, None, None);
}
fn draw_scroll_indicators(layout: &PickerLayout) {
if layout.visible_count == 0 {
return;
}
if layout.hidden_above > 0 {
let y = layout.top_y + 3;
if y < layout.row_end {
let label = format!("\u{2191} [+{}]", layout.hidden_above);
let w = UnicodeWidthStr::width(label.as_str());
print_text_with_coordinates(
Text::new(&label).color_range(1, ..),
layout.centered_x(w),
y,
None,
None,
);
}
}
if layout.hidden_below > 0 {
let y = layout.top_y + 4 + 2 * layout.visible_count;
if y < layout.row_end {
let label = format!("\u{2193} [+{}]", layout.hidden_below);
let w = UnicodeWidthStr::width(label.as_str());
print_text_with_coordinates(
Text::new(&label).color_range(1, ..),
layout.centered_x(w),
y,
None,
None,
);
}
}
}
fn draw_cards(frame: &mut Frame, visible: &[&Card], layout: &PickerLayout) {
let first_card_y = layout.top_y + 4;
for (i, card) in visible.iter().enumerate() {
let row_name = first_card_y + i * 2;
if row_name >= layout.row_end {
break;
}
card.draw(
frame,
layout.card_x,
layout.content_x,
row_name,
row_name + 1,
layout.row_end,
);
}
}
fn draw_new_session_button(frame: &mut Frame, layout: &PickerLayout) {
let new_session_y = layout.top_y + layout.block_height.saturating_sub(1);
if new_session_y >= layout.row_end {
return;
}
print_text_with_coordinates(
Text::new(NEW_SESSION_LABEL).color_range(3, ..),
layout.new_session_x,
new_session_y,
None,
None,
);
frame.click_regions.push(ClickRegion::tight(
new_session_y,
layout.new_session_x,
layout.new_session_x + layout.new_session_w,
ClickAction::OpenNewSessionPrompt,
));
}
@@ -1,239 +0,0 @@
use zellij_tile::prelude::*;
use crate::ansi::{move_to, slice_ansi_visible, visible_width, RESET};
use crate::click::ViewportRegion;
use crate::frame::Frame;
use crate::input::Input;
use crate::keys;
use crate::workspace::{pane_id_of, Workspace};
const DISABLE_AUTOWRAP: &str = "\x1b[?7l";
const ENABLE_AUTOWRAP: &str = "\x1b[?7h";
#[derive(Default)]
pub struct ViewportScreen {
pub viewport_v_pan: usize,
pub viewport_h_pan: usize,
pub viewport_region: Option<ViewportRegion>,
}
impl ViewportScreen {
pub fn reset_pan(&mut self) {
self.viewport_v_pan = 0;
self.viewport_h_pan = 0;
}
pub fn handle_key(&self, ws: &Workspace, input: &mut Input, key: KeyWithModifier) -> bool {
if let Some(pane) = ws.current_pane() {
let key = if input.ctrl_held || input.alt_held {
input.merge_held_modifiers(&key)
} else {
key
};
let bytes = keys::serialize_key(&key);
if !bytes.is_empty() {
write_to_pane_id(bytes, pane_id_of(&pane));
}
}
let consumed = input.ctrl_held || input.alt_held;
input.ctrl_held = false;
input.alt_held = false;
consumed
}
pub fn max_viewport_v_pan(&self, ws: &Workspace) -> Option<usize> {
let region = self.viewport_region?;
let embed_height = region.row_end.saturating_sub(region.row_start);
Some(ws.current_pane_viewport_len().saturating_sub(embed_height))
}
pub fn click_in_viewport(&self, row: usize, col: usize) -> Option<(usize, usize)> {
let region = self.viewport_region?;
if row < region.row_start || row >= region.row_end {
return None;
}
if col >= region.cols {
return None;
}
let pane_row = region.skip + (row - region.row_start);
let pane_col = region.h_offset + col;
Some((pane_row, pane_col))
}
pub fn compute_cursor_position(
&self,
ws: &Workspace,
viewport_top: usize,
viewport_height: usize,
cols: usize,
skip: usize,
h_offset: usize,
) -> Option<(usize, usize)> {
if viewport_height == 0 {
return None;
}
let pane = ws.current_pane()?;
let pane_id = pane_id_of(&pane);
let (cursor_x, cursor_y) = ws.latest_pane_contents.get(&pane_id)?.cursor?;
if cursor_y < skip {
return None;
}
let row_in_slice = cursor_y - skip;
if row_in_slice >= viewport_height {
return None;
}
if cursor_x < h_offset {
return None;
}
let plugin_x = cursor_x - h_offset;
if plugin_x >= cols {
return None;
}
let plugin_y = viewport_top + row_in_slice;
Some((plugin_x, plugin_y))
}
pub fn render(
&mut self,
ws: &Workspace,
_frame: &mut Frame,
row_start: usize,
row_end: usize,
cols: usize,
) {
let height = row_end - row_start;
if height == 0 {
return;
}
let pane = ws.current_pane();
let pane_id = pane.as_ref().map(pane_id_of);
let viewport_lines: Vec<String> = pane_id
.and_then(|id| ws.latest_pane_contents.get(&id))
.map(|c| c.viewport.clone())
.unwrap_or_default();
let max_v_pan = self
.max_viewport_v_pan(ws)
.unwrap_or_else(|| viewport_lines.len().saturating_sub(height));
self.viewport_v_pan = self.viewport_v_pan.min(max_v_pan);
let skip = max_v_pan.saturating_sub(self.viewport_v_pan);
let pane_width = pane
.as_ref()
.map(|p| p.pane_content_columns)
.filter(|&w| w > 0)
.unwrap_or_else(|| {
viewport_lines
.iter()
.map(|l| visible_width(l))
.max()
.unwrap_or(0)
});
let max_h_pan = pane_width.saturating_sub(cols);
self.viewport_h_pan = self.viewport_h_pan.min(max_h_pan);
let h_offset = self.viewport_h_pan;
self.viewport_region = Some(ViewportRegion {
row_start,
row_end,
cols,
skip,
h_offset,
});
print!("{}", DISABLE_AUTOWRAP);
for i in 0..height {
let row = row_start + i;
print!("{}{}", RESET, move_to(row, 0));
if let Some(line) = viewport_lines.get(skip + i) {
if h_offset == 0 {
print!("{}", line);
} else {
let sliced = slice_ansi_visible(line, h_offset, cols);
print!("{}", sliced);
}
} else if i == 0 && pane_id.is_none() {
print!("{}(no pane selected)", RESET);
} else if i == 0 && viewport_lines.is_empty() {
print!("{}(awaiting first render…)", RESET);
}
let raw_width = viewport_lines
.get(skip + i)
.map(|l| visible_width(l))
.unwrap_or(0);
let printed_width = raw_width.saturating_sub(h_offset).min(cols);
if printed_width < cols {
print!("{}\x1b[K", RESET);
}
}
print!("{}", ENABLE_AUTOWRAP);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::click::ViewportRegion;
fn state_with_viewport(
viewport_len: usize,
embed_height: Option<usize>,
) -> (Workspace, ViewportScreen) {
let mut ws = Workspace::default();
let mut tab = TabInfo::default();
tab.position = 0;
ws.tabs.push(tab);
ws.selected_tab_position = Some(0);
let mut pane = PaneInfo::default();
pane.id = 42;
pane.is_plugin = false;
pane.is_selectable = true;
pane.is_suppressed = false;
ws.panes_by_tab_position.insert(0, vec![pane]);
ws.selected_pane_id = Some(PaneId::Terminal(42));
let mut contents = PaneContents::default();
contents.viewport = vec![String::new(); viewport_len];
ws.latest_pane_contents
.insert(PaneId::Terminal(42), contents);
let mut vp = ViewportScreen::default();
if let Some(h) = embed_height {
vp.viewport_region = Some(ViewportRegion {
row_start: 0,
row_end: h,
cols: 80,
skip: 0,
h_offset: 0,
});
}
(ws, vp)
}
#[test]
fn max_viewport_v_pan_none_without_region() {
let (ws, vp) = state_with_viewport(100, None);
assert_eq!(vp.max_viewport_v_pan(&ws), None);
}
#[test]
fn max_viewport_v_pan_some_typical() {
let (ws, vp) = state_with_viewport(100, Some(20));
assert_eq!(vp.max_viewport_v_pan(&ws), Some(80));
}
#[test]
fn max_viewport_v_pan_saturates_when_embed_larger() {
let (ws, vp) = state_with_viewport(10, Some(20));
assert_eq!(vp.max_viewport_v_pan(&ws), Some(0));
}
#[test]
fn max_viewport_v_pan_empty_cache() {
let (ws, vp) = state_with_viewport(0, Some(20));
assert_eq!(vp.max_viewport_v_pan(&ws), Some(0));
}
}
-24
View File
@@ -1,24 +0,0 @@
use crate::fit::Fit;
use crate::frame::Frame;
use crate::input::Input;
use crate::navigation::Navigation;
use crate::screens::{
ActiveScreen, MenuScreen, NewSessionPromptScreen, PanesScreen, SessionsScreen, ViewportScreen,
};
use crate::workspace::Workspace;
#[derive(Default)]
pub struct State {
pub workspace: Workspace,
pub fit: Fit,
pub frame: Frame,
pub input: Input,
pub navigation: Navigation,
pub active: ActiveScreen,
pub viewport: ViewportScreen,
pub sessions: SessionsScreen,
pub panes: PanesScreen,
pub new_session: NewSessionPromptScreen,
pub menu: MenuScreen,
pub empty_welcome_list_grace_elapsed: bool,
}
-122
View File
@@ -1,122 +0,0 @@
use std::collections::HashMap;
use zellij_tile::prelude::*;
const WELCOME_SCREEN_PLUGIN_ALIAS: &str = "welcome-screen";
#[derive(Default)]
pub struct Workspace {
pub own_plugin_pane_id: Option<PaneId>,
pub tabs: Vec<TabInfo>,
pub panes_by_tab_position: HashMap<usize, Vec<PaneInfo>>,
pub selected_tab_position: Option<usize>,
pub selected_pane_id: Option<PaneId>,
pub latest_pane_contents: HashMap<PaneId, PaneContents>,
pub mode_info: Option<ModeInfo>,
pub session_name: Option<String>,
pub pane_last_activity: HashMap<PaneId, u64>,
pub pending_new_tab_position: Option<usize>,
}
impl Workspace {
pub fn tabs_in_order(&self) -> Vec<&TabInfo> {
let own = self.own_plugin_pane_id;
let mut tabs: Vec<&TabInfo> = self
.tabs
.iter()
.filter(|t| !self.tab_is_self_only(t.position, own))
.collect();
tabs.sort_by_key(|t| t.position);
tabs
}
fn tab_is_self_only(&self, tab_position: usize, own: Option<PaneId>) -> bool {
let Some(panes) = self.panes_by_tab_position.get(&tab_position) else {
return false;
};
let Some(own) = own else {
return false;
};
let visible: Vec<&PaneInfo> = panes.iter().filter(|p| !p.is_suppressed).collect();
if visible.is_empty() {
return false;
}
visible.iter().all(|p| pane_info_matches(p, own))
}
pub fn current_tab(&self) -> Option<&TabInfo> {
let visible = self.tabs_in_order();
if let Some(pos) = self.selected_tab_position {
if let Some(t) = visible.iter().find(|t| t.position == pos) {
return Some(*t);
}
}
visible.first().copied()
}
pub fn current_tab_panes(&self) -> Vec<&PaneInfo> {
let Some(tab) = self.current_tab() else {
return vec![];
};
self.panes_for_tab(tab.position)
}
pub fn panes_for_tab(&self, tab_position: usize) -> Vec<&PaneInfo> {
let own = self.own_plugin_pane_id;
let mut panes: Vec<&PaneInfo> = self
.panes_by_tab_position
.get(&tab_position)
.map(|v| v.iter().collect())
.unwrap_or_default();
panes.retain(|p| {
!p.is_suppressed
&& p.is_selectable
&& !own.map(|id| pane_info_matches(p, id)).unwrap_or(false)
});
panes.sort_by_key(|p| (p.is_floating, p.pane_y, p.pane_x, p.id));
panes
}
pub fn current_pane_viewport_len(&self) -> usize {
self.current_pane()
.as_ref()
.map(pane_id_of)
.and_then(|id| self.latest_pane_contents.get(&id))
.map(|c| c.viewport.len())
.unwrap_or(0)
}
pub fn current_pane_is_welcome(&self) -> bool {
self.current_pane()
.map(|p| p.is_plugin && p.plugin_url.as_deref() == Some(WELCOME_SCREEN_PLUGIN_ALIAS))
.unwrap_or(false)
}
// `is_focused` is server-global (true if any client focuses the pane), so
// selecting by it would make this client's viewport follow another
// connected client's focus; fall back to the first pane instead.
pub fn current_pane(&self) -> Option<PaneInfo> {
if let Some(selected) = self.selected_pane_id {
for pane in self.current_tab_panes() {
if pane_info_matches(pane, selected) {
return Some(pane.clone());
}
}
}
self.current_tab_panes().into_iter().next().cloned()
}
}
pub fn pane_info_matches(info: &PaneInfo, id: PaneId) -> bool {
match id {
PaneId::Terminal(tid) => !info.is_plugin && info.id == tid,
PaneId::Plugin(pid) => info.is_plugin && info.id == pid,
}
}
pub fn pane_id_of(info: &PaneInfo) -> PaneId {
if info.is_plugin {
PaneId::Plugin(info.id)
} else {
PaneId::Terminal(info.id)
}
}
-1
View File
@@ -245,7 +245,6 @@ plugins {
configuration location="zellij:configuration"
plugin-manager location="zellij:plugin-manager"
about location="zellij:about"
mobile location="zellij:mobile"
}
// Plugins to load in the background when a new session starts
+1
View File
@@ -8,6 +8,7 @@ edition.workspace = true
anyhow = { workspace = true }
serde_json = { workspace = true }
prost-build = { version = "0.12.6", default-features = false }
sha2 = { workspace = true }
toml = { version = "1.1.2", default-features = false, features = ["parse", "serde", "std"] }
which = "8"
xflags = { version = "0.3.2", default-features = false }
+452
View File
@@ -0,0 +1,452 @@
//! Deterministic bundling of the web client frontend assets.
use crate::flags;
use anyhow::{anyhow, Context};
use sha2::{Digest, Sha384};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use xshell::Shell;
const MODULE_ORDER: &[&str] = &[
"utils",
"connection",
"auth",
"keyboard",
"links",
"terminal",
"ime-bypass",
"soft-keyboard",
"key-handler",
"mouse",
"pinch",
"mobile-pan",
"touch",
"input",
"mobile-ui",
"websockets",
"index",
];
const HASHED_ASSETS: &[&str] = &[
"app.js",
"xterm.js",
"addon-fit.js",
"addon-clipboard.js",
"addon-web-links.js",
"addon-webgl.js",
"modals.js",
"xterm.css",
"style.css",
];
const BUNDLE_FILE: &str = "app.js";
const INTEGRITY_FILE: &str = "integrity.json";
const INDEX_FILE: &str = "index.html";
const DECLARATION_PREFIXES: &[&str] = &[
"async function ",
"function ",
"const ",
"let ",
"var ",
"class ",
];
pub fn assets(_sh: &Shell, flags: flags::Assets) -> anyhow::Result<()> {
let msg = if flags.check {
">> Checking bundled web client assets"
} else {
">> Bundling web client assets"
};
crate::status(msg);
println!("{}", msg);
let assets_dir = web_assets_dir();
let generated = generate(&assets_dir)?;
if flags.check {
for (name, contents) in &generated {
let path = assets_dir.join(name);
let on_disk = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read '{}'", path.display()))?;
if &on_disk != contents {
return Err(anyhow!(
"'{}' is out of date, run `cargo xtask assets`",
path.display()
));
}
}
return Ok(());
}
for (name, contents) in &generated {
let path = assets_dir.join(name);
std::fs::write(&path, contents)
.with_context(|| format!("failed to write '{}'", path.display()))?;
}
Ok(())
}
fn web_assets_dir() -> PathBuf {
crate::project_root().join("zellij-client").join("assets")
}
fn generate(assets_dir: &Path) -> anyhow::Result<Vec<(String, String)>> {
let bundle = build_bundle(assets_dir)?;
let mut digests: BTreeMap<String, String> = BTreeMap::new();
for asset in HASHED_ASSETS {
let bytes = if *asset == BUNDLE_FILE {
bundle.as_bytes().to_vec()
} else {
let path = assets_dir.join(asset);
std::fs::read(&path).with_context(|| format!("failed to read '{}'", path.display()))?
};
digests.insert((*asset).to_string(), subresource_integrity(&bytes));
}
let integrity = format!("{}\n", serde_json::to_string_pretty(&digests)?);
let index_path = assets_dir.join(INDEX_FILE);
let index_source = std::fs::read_to_string(&index_path)
.with_context(|| format!("failed to read '{}'", index_path.display()))?;
let index = rewrite_integrity_attributes(&index_source, &digests)?;
Ok(vec![
(BUNDLE_FILE.to_string(), bundle),
(INTEGRITY_FILE.to_string(), integrity),
(INDEX_FILE.to_string(), index),
])
}
fn build_bundle(assets_dir: &Path) -> anyhow::Result<String> {
let mut bundle = String::new();
let mut declarations: BTreeMap<String, String> = BTreeMap::new();
for module in MODULE_ORDER {
let path = assets_dir.join(format!("{}.js", module));
let source = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read '{}'", path.display()))?;
let chunk = flatten_module(module, &source)?;
for name in top_level_declarations(&chunk) {
if let Some(previous) = declarations.insert(name.clone(), (*module).to_string()) {
return Err(anyhow!(
"duplicate top-level identifier '{}' declared in both '{}.js' and '{}.js'",
name,
previous,
module
));
}
}
bundle.push_str(&chunk);
if !bundle.ends_with('\n') {
bundle.push('\n');
}
}
Ok(bundle)
}
fn flatten_module(module: &str, source: &str) -> anyhow::Result<String> {
let mut out = String::new();
let mut lines = source.lines().enumerate().peekable();
while let Some((index, line)) = lines.next() {
let location = || format!("{}.js:{}", module, index + 1);
if line.contains("import(") {
return Err(anyhow!("dynamic import is not supported at {}", location()));
}
if let Some(rest) = line.strip_prefix("import ") {
if is_terminated_import(rest) {
validate_module_specifier(rest, &location())?;
continue;
}
let mut terminated = false;
for (_, continuation) in lines.by_ref() {
let trimmed = continuation.trim_start();
if trimmed.starts_with("} from ") && trimmed.ends_with(';') {
validate_module_specifier(trimmed, &location())?;
terminated = true;
break;
}
if !is_import_binding_line(trimmed) {
return Err(anyhow!("unrecognised import syntax at {}", location()));
}
}
if !terminated {
return Err(anyhow!("unterminated import statement at {}", location()));
}
continue;
}
if line.starts_with("export ") || line.starts_with("export{") {
if line.starts_with("export {") {
if line.contains(" from ") {
validate_module_specifier(line, &location())?;
continue;
}
return Err(anyhow!("unrecognised export syntax at {}", location()));
}
if line.starts_with("export default") || line.starts_with("export *") {
return Err(anyhow!("unsupported export form at {}", location()));
}
let stripped = &line["export ".len()..];
if !DECLARATION_PREFIXES
.iter()
.any(|prefix| stripped.starts_with(prefix))
{
return Err(anyhow!("unrecognised export syntax at {}", location()));
}
out.push_str(stripped);
out.push('\n');
continue;
}
out.push_str(line);
out.push('\n');
}
Ok(out)
}
fn is_terminated_import(rest: &str) -> bool {
rest.ends_with(';') && rest.contains(" from ")
}
fn is_import_binding_line(trimmed: &str) -> bool {
if trimmed.is_empty() || trimmed == "{" {
return true;
}
trimmed
.trim_end_matches(',')
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == ' ')
}
fn validate_module_specifier(line: &str, location: &str) -> anyhow::Result<()> {
let specifier = line
.rsplit_once(" from ")
.map(|(_, specifier)| specifier)
.ok_or_else(|| anyhow!("missing module specifier at {}", location))?
.trim()
.trim_end_matches(';')
.trim_matches(|c| c == '"' || c == '\'');
let name = specifier
.strip_prefix("./")
.and_then(|name| name.strip_suffix(".js"))
.ok_or_else(|| {
anyhow!(
"only relative sibling module specifiers are supported, found '{}' at {}",
specifier,
location
)
})?;
if !MODULE_ORDER.contains(&name) {
return Err(anyhow!(
"module '{}' referenced at {} is not part of the bundle",
name,
location
));
}
Ok(())
}
fn top_level_declarations(chunk: &str) -> Vec<String> {
let mut names = Vec::new();
for line in chunk.lines() {
if line.starts_with(char::is_whitespace) {
continue;
}
for prefix in DECLARATION_PREFIXES {
let Some(rest) = line.strip_prefix(prefix) else {
continue;
};
let name: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
.collect();
if !name.is_empty() {
names.push(name);
}
break;
}
}
names
}
fn subresource_integrity(bytes: &[u8]) -> String {
let mut hasher = Sha384::new();
hasher.update(bytes);
format!("sha384-{}", base64_encode(&hasher.finalize()))
}
fn base64_encode(input: &[u8]) -> String {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
for chunk in input.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = *chunk.get(1).unwrap_or(&0) as u32;
let b2 = *chunk.get(2).unwrap_or(&0) as u32;
let triple = (b0 << 16) | (b1 << 8) | b2;
out.push(TABLE[((triple >> 18) & 0x3f) as usize] as char);
out.push(TABLE[((triple >> 12) & 0x3f) as usize] as char);
if chunk.len() > 1 {
out.push(TABLE[((triple >> 6) & 0x3f) as usize] as char);
} else {
out.push('=');
}
if chunk.len() > 2 {
out.push(TABLE[(triple & 0x3f) as usize] as char);
} else {
out.push('=');
}
}
out
}
fn rewrite_integrity_attributes(
source: &str,
digests: &BTreeMap<String, String>,
) -> anyhow::Result<String> {
let mut out = String::with_capacity(source.len());
let trailing_newline = source.ends_with('\n');
for line in source.lines() {
let mut rewritten = line.to_string();
if let Some(asset) = referenced_asset(line, digests) {
let digest = &digests[&asset];
rewritten = replace_integrity_value(&rewritten, digest).ok_or_else(|| {
anyhow!(
"tag referencing '{}' is missing an integrity attribute",
asset
)
})?;
}
out.push_str(&rewritten);
out.push('\n');
}
if !trailing_newline {
out.pop();
}
Ok(out)
}
fn referenced_asset(line: &str, digests: &BTreeMap<String, String>) -> Option<String> {
for asset in digests.keys() {
for attribute in ["src=\"assets/", "href=\"assets/"] {
let needle = format!("{}{}\"", attribute, asset);
if line.contains(&needle) {
return Some(asset.clone());
}
}
}
None
}
fn replace_integrity_value(line: &str, digest: &str) -> Option<String> {
let start = line.find("integrity=\"")? + "integrity=\"".len();
let end = start + line[start..].find('"')?;
let mut out = String::with_capacity(line.len() + digest.len());
out.push_str(&line[..start]);
out.push_str(digest);
out.push_str(&line[end..]);
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn import_statements_are_dropped() {
let source = "import { a } from \"./utils.js\";\nexport function b() {}\n";
let flattened = flatten_module("terminal", source).expect("flatten");
assert_eq!(flattened, "function b() {}\n");
}
#[test]
fn multiline_imports_are_dropped() {
let source = "import {\n a,\n b,\n} from \"./utils.js\";\nconst c = 1;\n";
let flattened = flatten_module("terminal", source).expect("flatten");
assert_eq!(flattened, "const c = 1;\n");
}
#[test]
fn re_exports_are_dropped() {
let source = "export { setSoftKeyboard } from \"./soft-keyboard.js\";\n";
let flattened = flatten_module("input", source).expect("flatten");
assert_eq!(flattened, "");
}
#[test]
fn unsupported_export_forms_are_rejected() {
assert!(flatten_module("utils", "export default function a() {}\n").is_err());
assert!(flatten_module("utils", "export * from \"./links.js\";\n").is_err());
assert!(flatten_module("utils", "export unexpected;\n").is_err());
}
#[test]
fn unknown_module_specifiers_are_rejected() {
assert!(flatten_module("utils", "import { a } from \"nowhere\";\n").is_err());
assert!(flatten_module("utils", "import { a } from \"./nowhere.js\";\n").is_err());
}
#[test]
fn dynamic_imports_are_rejected() {
assert!(flatten_module("utils", "const m = await import(\"./utils.js\");\n").is_err());
}
#[test]
fn duplicate_top_level_identifiers_are_detected() {
let dir = std::env::temp_dir().join("zellij-xtask-assets-duplicate-test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp dir");
for module in MODULE_ORDER {
let contents = if *module == "utils" || *module == "connection" {
"function collide() {}\n"
} else {
""
};
std::fs::write(dir.join(format!("{}.js", module)), contents).expect("write module");
}
let error = build_bundle(&dir).expect_err("expected duplicate detection to fail");
assert!(
error.to_string().contains("duplicate top-level identifier"),
"unexpected error: {}",
error
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn top_level_declarations_ignores_nested_scopes() {
let chunk = "function outer() {\n const inner = 1;\n}\nconst top = 2;\n";
assert_eq!(top_level_declarations(chunk), vec!["outer", "top"]);
}
#[test]
fn integrity_attributes_are_rewritten() {
let mut digests = BTreeMap::new();
digests.insert("app.js".to_string(), "sha384-abc".to_string());
let source = "<script type=\"module\" src=\"assets/app.js\" integrity=\"\"></script>\n";
let rewritten = rewrite_integrity_attributes(source, &digests).expect("rewrite");
assert_eq!(
rewritten,
"<script type=\"module\" src=\"assets/app.js\" integrity=\"sha384-abc\"></script>\n"
);
}
#[test]
fn base64_matches_known_vectors() {
assert_eq!(base64_encode(b""), "");
assert_eq!(base64_encode(b"f"), "Zg==");
assert_eq!(base64_encode(b"fo"), "Zm8=");
assert_eq!(base64_encode(b"foo"), "Zm9v");
assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
}
}
+3 -1
View File
@@ -1,6 +1,6 @@
//! Tasks related to zellij CI
use crate::{
build,
assets, build,
flags::{self, BuildRelease, CiCmd, Cross, E2e},
metadata,
};
@@ -146,6 +146,8 @@ fn e2e_build(sh: &Shell) -> anyhow::Result<()> {
fn build_release(sh: &Shell, no_web: bool) -> anyhow::Result<()> {
let err_context = "failed to perform native release build";
assets::assets(sh, flags::Assets { check: true }).context(err_context)?;
// Build plugins and generate protobufs
build::build(
sh,
+12
View File
@@ -41,6 +41,12 @@ xflags::xflags! {
cmd proto {}
/// Bundle the web client frontend assets
cmd assets {
/// Verify the checked-in assets are up to date instead of writing them
optional --check
}
/// Publish zellij and all the sub-crates
cmd publish {
/// Perform a dry-run (don't push/publish anything)
@@ -143,6 +149,7 @@ pub enum XtaskCmd {
Deprecated(Deprecated),
Ci(Ci),
Proto(Proto),
Assets(Assets),
Publish(Publish),
Make(Make),
Install(Install),
@@ -193,6 +200,11 @@ pub struct BuildRelease {
#[derive(Debug)]
pub struct Proto;
#[derive(Debug)]
pub struct Assets {
pub check: bool,
}
#[derive(Debug)]
pub struct Publish {
pub dry_run: bool,
+2 -4
View File
@@ -6,6 +6,7 @@
//!
//! This binary is integrated into the `cargo` command line by using an alias in `.cargo/config`.
mod assets;
mod build;
mod ci;
mod flags;
@@ -85,10 +86,6 @@ fn workspace_members() -> &'static Vec<WorkspaceMember> {
crate_name: "default-plugins/link",
build: true,
},
WorkspaceMember {
crate_name: "default-plugins/mobile",
build: true,
},
WorkspaceMember {
crate_name: "zellij-utils",
build: false,
@@ -130,6 +127,7 @@ fn main() -> anyhow::Result<()> {
flags::XtaskCmd::Test(flags) => test::test(shell, flags),
flags::XtaskCmd::IntegrationTest(flags) => integration_test::integration_test(shell, flags),
flags::XtaskCmd::Proto(_flags) => build::proto(shell),
flags::XtaskCmd::Assets(flags) => assets::assets(shell, flags),
// Pipelines
// These are composite commands, made up of multiple "stages" defined above.
flags::XtaskCmd::Make(flags) => pipelines::make(shell, flags),
File diff suppressed because it is too large Load Diff
+76 -70
View File
@@ -29,96 +29,102 @@ async function waitForSecurityToken() {
}
/**
* Get client ID from server after authentication
* Perform the login exchange with the server
* @param {string} token - Authentication token
* @param {boolean} rememberMe - Remember login preference
* @param {boolean} hasAuthenticationCookie - Whether auth cookie exists
* @returns {Promise<string|null>} Client ID or null on failure
* @returns {Promise<boolean>} true when the login succeeded
*/
export async function getClientId(token, rememberMe, hasAuthenticationCookie) {
async function login(token, rememberMe) {
const baseUrl = getBaseUrl();
if (!hasAuthenticationCookie) {
let login_res = await fetch(`${baseUrl}/command/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
auth_token: token,
remember_me: rememberMe ? true : false,
}),
credentials: "include",
});
if (login_res.status === 401) {
await showErrorModal(
"Error",
"Unauthorized or revoked login token."
);
return null;
} else if (!login_res.ok) {
await showErrorModal(
"Error",
`Error ${login_res.status} connecting to server.`
);
return null;
}
}
let data = await fetch(`${baseUrl}/session`, {
let login_res = await fetch(`${baseUrl}/command/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
body: JSON.stringify({
auth_token: token,
remember_me: rememberMe ? true : false,
}),
credentials: "include",
});
if (data.status === 401) {
if (login_res.status === 401) {
await showErrorModal("Error", "Unauthorized or revoked login token.");
return null;
} else if (!data.ok) {
return false;
} else if (!login_res.ok) {
await showErrorModal(
"Error",
`Error ${data.status} connecting to server.`
`Error ${login_res.status} connecting to server.`
);
return null;
} else {
let body = await data.json();
return body.web_client_id;
return false;
}
return true;
}
/**
* Perform a request, authenticating and retrying for as long as it is rejected.
* @param {string} url - Absolute URL to request
* @param {Object} options - fetch options, merged over the credentialed defaults
* @returns {Promise<Response>} The successful response
*/
export async function authorizedFetch(url, options = {}) {
while (true) {
const response = await fetch(url, {
credentials: "include",
...options,
});
if (response.ok) {
return response;
}
if (response.status !== 401) {
await showErrorModal(
"Error",
`Error ${response.status} connecting to server.`
);
}
const { token, remember } = await waitForSecurityToken();
await login(token, remember);
}
}
/**
* Initialize authentication flow and return client ID
* @returns {Promise<string>} Client ID
* Request a session from the server, authenticating first if required.
* @param {string} sessionFromPath - Session name taken from the URL path
* @param {Object} options - `{ welcome: boolean }`, welcome defaulting to true
* @returns {Promise<Object>} The full session bootstrap payload
*/
export async function initAuthentication() {
let token = null;
let remember = null;
let hasAuthenticationCookie = document.body.dataset.authenticated === "true";
if (!hasAuthenticationCookie) {
const tokenResult = await waitForSecurityToken();
token = tokenResult.token;
remember = tokenResult.remember;
export async function fetchSession(sessionFromPath, options = {}) {
const baseUrl = getBaseUrl();
const params = new URLSearchParams();
if (sessionFromPath) {
params.set("session", sessionFromPath);
}
let webClientId;
while (!webClientId) {
webClientId = await getClientId(
token,
remember,
hasAuthenticationCookie
);
if (!webClientId) {
hasAuthenticationCookie = false;
const tokenResult = await waitForSecurityToken();
token = tokenResult.token;
remember = tokenResult.remember;
}
if (options.welcome === false) {
params.set("welcome", "false");
}
const query = params.toString() ? `?${params.toString()}` : "";
return webClientId;
const response = await authorizedFetch(`${baseUrl}/session${query}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
return await response.json();
}
/**
* Fetch the list of live sessions without attaching to any of them.
* @returns {Promise<Array<Object>>} The session descriptors
*/
export async function fetchSessionList() {
const baseUrl = getBaseUrl();
const response = await authorizedFetch(`${baseUrl}/session-list`, {
method: "GET",
});
const payload = await response.json();
return payload.sessions || [];
}
+14 -36
View File
@@ -1,46 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<base href="BASE_URL" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<title>Zellij Web Client</title>
<link rel="icon" href="assets/favicon.ico" />
<link rel="manifest" href="assets/manifest.webmanifest" />
<link rel="apple-touch-icon" href="assets/icon-192.png" />
<meta name="theme-color" content="#000000" />
<!-- preload the font to avoid rendering issue on first load
not 100% perfect but much better than without
check this for some additional context: https://github.com/xtermjs/xterm.js/issues/5164
-->
<link href="assets/style.css" rel="stylesheet" />
<!-- xterm.css -->
<link rel="stylesheet" href="assets/xterm.css" />
<!-- xterm.js -->
<script src="assets/xterm.js"></script>
<script src="assets/addon-fit.js"></script>
<script src="assets/addon-clipboard.js"></script>
<script src="assets/addon-web-links.js"></script>
<script src="assets/addon-webgl.js"></script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Zellij Web Client</title>
<link rel="stylesheet" href="assets/style.css" integrity="sha384-qvSJURWrtlva+87171/c2KHjzg2kzTYgjlEkCLTCdadhZtJA5MyGgt7VgOj9dE0D" />
<link rel="stylesheet" href="assets/xterm.css" integrity="sha384-Mz9RDc16Iua5vrzALPqUZn39WVfelFzZvwpqCNkMKji5gpYeP3kNCMPGypMFf0AD" />
<script src="assets/xterm.js" integrity="sha384-9qDi2ngVq54Xf5EPnRuadR1QvddqkiXlIm8UFlQQc58yvC6XKRDPraE8W8PEkjQu"></script>
<script src="assets/addon-fit.js" integrity="sha384-zZmjmUWgxteKU0acxOtNmpWCMwSnn1/pSIhFBXVx6ARJ4pD/eD3E0dDCccITHWWD"></script>
<script src="assets/addon-clipboard.js" integrity="sha384-u4EBOaE07GHmKsUXC4q1Q+tB3DztCkgpT0AzFavi1gCO/qQryHvcVVI05jk/B8hG"></script>
<script src="assets/addon-web-links.js" integrity="sha384-Zb/L38m4THfRbsxxv8gWAi8cQ6xcWQnv0BgR0qwbTDwtBDaPi6pkyfgfBIH6C8ZG"></script>
<script src="assets/addon-webgl.js" integrity="sha384-AkzdQDeATxpW8V7I2EPcwg17qAoLQMjEFHb//1QaAAIyIlbDAHGKno5+huH7RKxq"></script>
</head>
<body data-authenticated="IS_AUTHENTICATED">
<body>
<div id="terminal" tabindex="0"></div>
<script src="assets/modals.js"></script>
<!-- Module files - order matters -->
<script type="module" src="assets/utils.js"></script>
<script type="module" src="assets/connection.js"></script>
<script type="module" src="assets/auth.js"></script>
<script type="module" src="assets/keyboard.js"></script>
<script type="module" src="assets/links.js"></script>
<script type="module" src="assets/terminal.js"></script>
<script type="module" src="assets/ime-bypass.js"></script>
<script type="module" src="assets/soft-keyboard.js"></script>
<script type="module" src="assets/key-handler.js"></script>
<script type="module" src="assets/mouse.js"></script>
<script type="module" src="assets/pinch.js"></script>
<script type="module" src="assets/touch.js"></script>
<script type="module" src="assets/input.js"></script>
<script type="module" src="assets/websockets.js"></script>
<script type="module" src="assets/index.js"></script>
<script src="assets/modals.js" integrity="sha384-iDRet9veierC1HWbStHC4LLKCSzjeIZTrPRePuKxiUxr7UvdmJ5epb+PYqgHYqte"></script>
<script type="module" src="assets/app.js" integrity="sha384-ltsszp8xHOJbHDX+dpNDmfCym1Er/n7QxGBoDF10vsccseNqSK7lDkkAz1jPVg9U"></script>
</body>
</html>
+32 -11
View File
@@ -1,25 +1,46 @@
import { initConnectionHandlers } from './connection.js';
import { initAuthentication } from './auth.js';
import { initTerminal } from './terminal.js';
import { fetchSession, fetchSessionList } from './auth.js';
import { initTerminal, settleFontSize } from './terminal.js';
import { setupInputHandlers } from './input.js';
import { initWebSockets } from './websockets.js';
import {
initMobileUi,
shouldUseStandaloneMenu,
showStandaloneSessionMenu,
} from './mobile-ui.js';
document.addEventListener("DOMContentLoaded", async (event) => {
initConnectionHandlers();
const webClientId = await initAuthentication();
const sessionFromPath = location.pathname.split("/").pop();
const { term, fitAddon } = initTerminal();
const sessionName = location.pathname.split("/").pop();
// A session name in the path is an explicit request for that session; only the bare root,
// which would otherwise boot a welcome session, is replaced by the session menu.
let welcome = true;
if (!sessionFromPath && shouldUseStandaloneMenu()) {
document.title = "Zellij";
await showStandaloneSessionMenu({ fetchSessions: fetchSessionList });
welcome = false;
}
let sendAnsiKey = (ansiKey) => {};
const boot = await fetchSession(sessionFromPath, { welcome });
setupInputHandlers(term, fitAddon, sendAnsiKey);
if (!location.pathname.endsWith(`/${boot.session_name}`)) {
history.replaceState(null, "", boot.session_name);
}
document.title = sessionName;
const websockets = initWebSockets(webClientId, sessionName, term, fitAddon, sendAnsiKey);
const { term, fitAddon } = initTerminal(boot.config);
settleFontSize(term, fitAddon, boot.config);
sendAnsiKey = websockets.sendAnsiKey;
let websockets = null;
initMobileUi({
term,
fitAddon,
getSendAnsiKey: () => (websockets ? websockets.sendAnsiKey : () => {}),
});
setupInputHandlers(term, fitAddon, sendAnsiKey);
document.title = boot.session_name;
websockets = initWebSockets(boot, term, fitAddon);
setupInputHandlers(term, fitAddon, websockets.sendAnsiKey);
});
+2
View File
@@ -7,6 +7,7 @@ import { installCustomKeyHandler } from "./key-handler.js";
import { installMouseHandlers } from "./mouse.js";
import { createPinchController } from "./pinch.js";
import { installTouchGestures } from "./touch.js";
import { initMobilePan } from "./mobile-pan.js";
export { setSoftKeyboard } from "./soft-keyboard.js";
@@ -17,6 +18,7 @@ export function setupInputHandlers(term, fitAddon, sendFunction) {
installCustomKeyHandler(term, sendFunction);
const terminalElement = document.getElementById("terminal");
initMobilePan({ term });
installMouseHandlers(term, terminalElement, sendFunction);
const pinch = createPinchController(term);
+11
View File
@@ -0,0 +1,11 @@
{
"addon-clipboard.js": "sha384-u4EBOaE07GHmKsUXC4q1Q+tB3DztCkgpT0AzFavi1gCO/qQryHvcVVI05jk/B8hG",
"addon-fit.js": "sha384-zZmjmUWgxteKU0acxOtNmpWCMwSnn1/pSIhFBXVx6ARJ4pD/eD3E0dDCccITHWWD",
"addon-web-links.js": "sha384-Zb/L38m4THfRbsxxv8gWAi8cQ6xcWQnv0BgR0qwbTDwtBDaPi6pkyfgfBIH6C8ZG",
"addon-webgl.js": "sha384-AkzdQDeATxpW8V7I2EPcwg17qAoLQMjEFHb//1QaAAIyIlbDAHGKno5+huH7RKxq",
"app.js": "sha384-ltsszp8xHOJbHDX+dpNDmfCym1Er/n7QxGBoDF10vsccseNqSK7lDkkAz1jPVg9U",
"modals.js": "sha384-iDRet9veierC1HWbStHC4LLKCSzjeIZTrPRePuKxiUxr7UvdmJ5epb+PYqgHYqte",
"style.css": "sha384-qvSJURWrtlva+87171/c2KHjzg2kzTYgjlEkCLTCdadhZtJA5MyGgt7VgOj9dE0D",
"xterm.css": "sha384-Mz9RDc16Iua5vrzALPqUZn39WVfelFzZvwpqCNkMKji5gpYeP3kNCMPGypMFf0AD",
"xterm.js": "sha384-9qDi2ngVq54Xf5EPnRuadR1QvddqkiXlIm8UFlQQc58yvC6XKRDPraE8W8PEkjQu"
}
+140
View File
@@ -0,0 +1,140 @@
let term = null;
let containerEl = null;
let panX = 0;
let panY = 0;
let active = false;
function getScreenEl() {
if (!term || !term.element) {
return null;
}
return term.element;
}
function cellDims() {
try {
const cell =
term && term._core && term._core._renderService &&
term._core._renderService.dimensions &&
term._core._renderService.dimensions.css &&
term._core._renderService.dimensions.css.cell;
if (cell && cell.width && cell.height) {
return { width: cell.width, height: cell.height };
}
} catch (_) {}
return null;
}
function contentSize() {
const cell = cellDims();
if (!cell) {
return null;
}
return {
width: cell.width * term.cols,
height: cell.height * term.rows,
};
}
function containerSize() {
if (!containerEl) {
return { width: 0, height: 0 };
}
const rect = containerEl.getBoundingClientRect();
return { width: rect.width, height: rect.height };
}
function bounds() {
const content = contentSize();
if (!content) {
return { maxX: 0, maxY: 0 };
}
const view = containerSize();
return {
maxX: Math.max(0, content.width - view.width),
maxY: Math.max(0, content.height - view.height),
};
}
function clamp() {
const b = bounds();
if (panX > b.maxX) panX = b.maxX;
if (panY > b.maxY) panY = b.maxY;
if (panX < 0) panX = 0;
if (panY < 0) panY = 0;
}
function applyTransform() {
const el = getScreenEl();
if (!el) {
return;
}
if (active) {
el.style.transform = `translate3d(${-panX}px, ${-panY}px, 0)`;
el.style.transformOrigin = "top left";
el.style.willChange = "transform";
} else {
el.style.transform = "";
el.style.willChange = "";
}
}
function setActive(on) {
const next = !!on;
if (next === active) {
if (active) {
clamp();
applyTransform();
}
return;
}
active = next;
if (!active) {
panX = 0;
panY = 0;
}
document.body.classList.toggle("zj-mobile-panning", active);
clamp();
applyTransform();
}
function panBy(dx, dy) {
if (!active) {
return;
}
panX += dx;
panY += dy;
clamp();
applyTransform();
}
function recompute() {
if (!active) {
return;
}
clamp();
applyTransform();
}
export function initMobilePan(context) {
term = context.term;
containerEl = document.getElementById("terminal");
if (window.__zjMobilePan) {
return window.__zjMobilePan;
}
window.__zjMobilePan = {
setActive,
panBy,
recompute,
isActive: () => active,
getOffset: () => ({ x: panX, y: panY }),
};
window.addEventListener("resize", recompute);
if (window.visualViewport) {
window.visualViewport.addEventListener("resize", recompute);
}
window.addEventListener("zellij:rendering-resize", () => {
requestAnimationFrame(recompute);
});
return window.__zjMobilePan;
}
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -5,8 +5,16 @@ export function installMouseHandlers(term, terminalElement, sendFunction) {
// xterm.js doesn't emit mousemove (xtermjs/xterm.js#1062); synthesize SGR motion reports.
terminalElement.addEventListener("mousemove", function (event) {
if (event.buttons == 0) {
let coordEvent = event;
if (window.__zjMobilePan && window.__zjMobilePan.isActive()) {
const off = window.__zjMobilePan.getOffset();
coordEvent = {
clientX: event.clientX + off.x,
clientY: event.clientY + off.y,
};
}
const { col, row } = term._core._mouseService.getMouseReportCoords(
event,
coordEvent,
terminalElement
);
if (prev_col != col || prev_row != row) {
+24 -3
View File
@@ -95,8 +95,21 @@ export function installSoftKeyboardCapture(term, sendFunction) {
captureShadow.appendChild(div);
state.element = div;
div.addEventListener("focus", () => { state.isFocused = true; });
div.addEventListener("blur", () => { state.isFocused = false; });
const emitVisibility = (visible) => {
window.dispatchEvent(
new CustomEvent("zellij:soft-keyboard-visibility", {
detail: { visible },
})
);
};
div.addEventListener("focus", () => {
state.isFocused = true;
emitVisibility(true);
});
div.addEventListener("blur", () => {
state.isFocused = false;
emitVisibility(false);
});
const setCaretToMiddle = () => {
try {
@@ -211,10 +224,18 @@ export function installSoftKeyboardCapture(term, sendFunction) {
// Mobile browsers honor programmatic focus() only inside a user gesture, so
// re-focus the capture on every gesture to keep the OS keyboard summoned.
const ensureCaptureFocused = () => {
const ensureCaptureFocused = (ev) => {
if (!window.__zjSoftKbdEnabled) {
return;
}
const target = ev && ev.target;
if (
target &&
typeof target.closest === "function" &&
target.closest(".zj-mobile-chrome")
) {
return;
}
if (state.isFocused) {
div.blur();
}
+75 -7
View File
@@ -1,4 +1,8 @@
import { build_link_handler } from "./links.js";
import { isMobileViewport } from "./utils.js";
export const NATURAL_MIN_TOTAL_ROWS = 25;
export const MOBILE_LEGIBLE_FLOOR_PX = 16;
// drawImage on a composited WebGL canvas returns blank pixels unless
// preserveDrawingBuffer is set (needed by the pinch overlay snapshot); this must
@@ -17,13 +21,78 @@ function ensurePreserveDrawingBuffer() {
};
}
export function initTerminal() {
export function terminalConfigOptions(config) {
const options = {};
if (!config) {
return options;
}
if (typeof config.font === "string" && config.font !== "") {
options.fontFamily = config.font;
}
if (config.theme) {
options.theme = config.theme;
}
if (typeof config.cursor_blink !== "undefined") {
options.cursorBlink = config.cursor_blink;
}
if (typeof config.mac_option_is_meta !== "undefined") {
options.macOptionIsMeta = config.mac_option_is_meta;
}
if (typeof config.cursor_style !== "undefined") {
options.cursorStyle = config.cursor_style;
}
if (typeof config.cursor_inactive_style !== "undefined") {
options.cursorInactiveStyle = config.cursor_inactive_style;
}
return options;
}
export function applyTerminalBackground(config) {
const background = (config && config.theme && config.theme.background) || null;
const body = document.querySelector("body");
if (body) {
body.style.background = background || "black";
}
const terminal = document.getElementById("terminal");
if (terminal) {
terminal.style.background = background;
}
}
export function settleFontSize(term, fitAddon, config) {
const fontSize = config ? config.font_size : undefined;
const mobileViewport = isMobileViewport();
const hasExplicitFontSize = typeof fontSize === "number" && fontSize > 0;
const baseFontPx = hasExplicitFontSize ? fontSize : mobileViewport ? 24 : 12;
applyFontSize(term, fitAddon, baseFontPx);
const needsMobileDownscale =
!hasExplicitFontSize &&
mobileViewport &&
term.rows < NATURAL_MIN_TOTAL_ROWS;
if (needsMobileDownscale) {
const downscaledPx = Math.max(
Math.floor((baseFontPx * term.rows) / NATURAL_MIN_TOTAL_ROWS),
MOBILE_LEGIBLE_FLOOR_PX
);
if (downscaledPx < baseFontPx) {
applyFontSize(term, fitAddon, downscaledPx);
}
}
applyTerminalBackground(config);
}
export function initTerminal(config) {
ensurePreserveDrawingBuffer();
const term = new Terminal({
fontFamily: "Monospace",
allowProposedApi: true,
scrollback: 0,
});
const term = new Terminal(
Object.assign(
{
fontFamily: "Monospace",
allowProposedApi: true,
scrollback: 0,
},
terminalConfigOptions(config)
)
);
window.term = term;
const fitAddon = new FitAddon.FitAddon();
const clipboardAddon = new ClipboardAddon.ClipboardAddon();
@@ -44,7 +113,6 @@ export function initTerminal() {
});
term.loadAddon(webglAddon);
term.open(document.getElementById("terminal"));
fitAddon.fit();
term.focus();
return { term, fitAddon };
}
+19 -3
View File
@@ -2,6 +2,8 @@ import { clampFontSize } from "./terminal.js";
import { toggleSoftKeyboard } from "./soft-keyboard.js";
const SGR_COORD_BASE = 1;
const panActive = () =>
!!(window.__zjMobilePan && window.__zjMobilePan.isActive());
export function installTouchGestures({ term, terminalElement, sendFunction, pinch }) {
let last_touch_y = null;
let last_touch_x = null;
@@ -22,11 +24,19 @@ export function installTouchGestures({ term, terminalElement, sendFunction, pinc
const two_finger_tap_max_ms = 600;
const pinch_activation_threshold = 18;
const reportCoords = (clientX, clientY) =>
term._core._mouseService.getMouseReportCoords(
{ clientX, clientY },
const reportCoords = (clientX, clientY) => {
let x = clientX;
let y = clientY;
if (panActive()) {
const off = window.__zjMobilePan.getOffset();
x += off.x;
y += off.y;
}
return term._core._mouseService.getMouseReportCoords(
{ clientX: x, clientY: y },
terminalElement
);
};
const cancelLongPress = () => {
if (long_press_timer !== null) {
@@ -188,6 +198,12 @@ export function installTouchGestures({ term, terminalElement, sendFunction, pinc
last_touch_x === null ? 0 : touch.clientX - last_touch_x;
last_touch_y = touch.clientY;
last_touch_x = touch.clientX;
if (panActive()) {
window.__zjMobilePan.panBy(-delta_x, -delta_y);
return;
}
pending_scroll += delta_y;
pending_h_scroll += delta_x;
while (pending_scroll <= -touch_scroll_threshold) {
+45 -16
View File
@@ -18,29 +18,58 @@ export function isMac() {
}
/**
* Get the base URL from the base href tag
* @returns {string} Base URL
* Get the application base URL, derived from the location of this module.
* Modules are always served from `<base>/assets/<file>.js`, so stripping the
* trailing `/assets/<file>.js` yields the mount point of the web client.
* @returns {string} Base URL without a trailing slash
*/
export function getBaseUrl() {
const baseElement = document.querySelector("base");
if (baseElement && baseElement.href) {
return baseElement.href.replace(/\/$/, ""); // Remove trailing slash
try {
const moduleUrl = new URL(import.meta.url);
const path = moduleUrl.pathname.replace(/\/assets\/[^/]*$/, "");
return `${moduleUrl.origin}${path}`.replace(/\/$/, "");
} catch (_) {
return window.location.origin;
}
// Fallback to current origin if no base href
return window.location.origin;
}
/**
* Get the base URL from the base href tag and convert to WebSocket URL
* Check whether a target URL points at the page already being displayed,
* ignoring the query string and fragment.
* @param {string} target absolute or relative URL
* @returns {boolean} true if navigating there would only reload the page
*/
export function isCurrentLocation(target) {
try {
const targetUrl = new URL(target, window.location.href);
const stripTrailingSlash = (path) => path.replace(/\/$/, "");
return (
targetUrl.origin === window.location.origin &&
stripTrailingSlash(targetUrl.pathname) ===
stripTrailingSlash(window.location.pathname)
);
} catch (_) {
return false;
}
}
/**
* Detect a mobile viewport (coarse pointer + small width, or a mobile UA).
* @returns {boolean} true if the current viewport is considered mobile
*/
export function isMobileViewport() {
return (
(window.matchMedia &&
window.matchMedia("(pointer: coarse)").matches &&
window.innerWidth < 600) ||
/Mobi|Android|iPhone|iPad/i.test(navigator.userAgent)
);
}
/**
* Get the application base URL converted to a WebSocket URL
* @returns {string} WebSocket base URL
*/
export function getWebSocketBaseUrl() {
const baseElement = document.querySelector("base");
if (baseElement && baseElement.href) {
const baseUrl = baseElement.href.replace(/\/$/, ""); // Remove trailing slash
// Convert http/https to ws/wss for WebSocket
return baseUrl.replace(/^https?/, is_https() ? "wss" : "ws");
}
// Fallback to current origin if no base href
return window.location.origin.replace(/^https?/, is_https() ? "wss" : "ws");
return getBaseUrl().replace(/^https?/, is_https() ? "wss" : "ws");
}
+153 -139
View File
@@ -1,10 +1,16 @@
import { handleReconnection, handleDisconnected, markConnectionEstablished } from "./connection.js";
import { getBaseUrl, getWebSocketBaseUrl } from "./utils.js";
import {
getBaseUrl,
getWebSocketBaseUrl,
isCurrentLocation,
} from "./utils.js";
import { setSoftKeyboard } from "./input.js";
import { applyFontSize } from "./terminal.js";
import {
applyTerminalBackground,
terminalConfigOptions,
} from "./terminal.js";
const NATURAL_MIN_TOTAL_ROWS = 25;
const MOBILE_LEGIBLE_FLOOR_PX = 16;
let lastSentCellDimensions = null;
function getCellPixelDimensions(term) {
try {
@@ -28,21 +34,21 @@ function getCellPixelDimensions(term) {
return null;
}
function sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols, cause) {
function getMobileRenderSizing() {
return window.__zjMobileUi && window.__zjMobileUi.getRenderSizing
? window.__zjMobileUi.getRenderSizing()
: { pinned: false };
}
function sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols) {
if (!wsControl || !ownWebClientId) {
return;
}
const resizeType =
cause === "RenderingPreference"
? "TerminalResizeRendering"
: cause === "Settled"
? "TerminalSizeSettled"
: "TerminalResize";
wsControl.send(
JSON.stringify({
web_client_id: ownWebClientId,
payload: {
type: resizeType,
type: "TerminalResize",
rows,
cols,
},
@@ -52,13 +58,23 @@ function sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols, cause) {
if (!cell) {
return;
}
const cellWidth = Math.round(cell.width);
const cellHeight = Math.round(cell.height);
if (
lastSentCellDimensions &&
lastSentCellDimensions.width === cellWidth &&
lastSentCellDimensions.height === cellHeight
) {
return;
}
lastSentCellDimensions = { width: cellWidth, height: cellHeight };
wsControl.send(
JSON.stringify({
web_client_id: ownWebClientId,
payload: {
type: "TerminalMetrics",
cell_pixel_width: Math.round(cell.width),
cell_pixel_height: Math.round(cell.height),
cell_pixel_width: cellWidth,
cell_pixel_height: cellHeight,
text_area_pixel_width: Math.round(cols * cell.width),
text_area_pixel_height: Math.round(rows * cell.height),
},
@@ -66,41 +82,69 @@ function sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols, cause) {
);
}
export function initWebSockets(
webClientId,
sessionName,
term,
fitAddon,
sendAnsiKey
) {
let ownWebClientId = "";
export function initWebSockets(boot, term, fitAddon) {
const ownWebClientId = boot.web_client_id;
const sessionName = boot.session_name;
let wsTerminal;
let wsControl;
const userConfig = { blink: false, style: false };
if (boot.config) {
if (typeof boot.config.cursor_blink !== "undefined") {
userConfig.blink = true;
}
if (typeof boot.config.cursor_style !== "undefined") {
userConfig.style = true;
}
}
const wsBaseUrl = getWebSocketBaseUrl();
const url =
sessionName === ""
!sessionName || sessionName === ""
? `${wsBaseUrl}/ws/terminal`
: `${wsBaseUrl}/ws/terminal/${sessionName}`;
: `${wsBaseUrl}/ws/terminal/${encodeURIComponent(sessionName)}`;
const queryString = `?web_client_id=${encodeURIComponent(webClientId)}`;
const fitDimensions = fitAddon.proposeDimensions() || {
rows: term.rows,
cols: term.cols,
};
if (
fitDimensions.rows !== term.rows ||
fitDimensions.cols !== term.cols
) {
term.resize(fitDimensions.cols, fitDimensions.rows);
}
const cell = getCellPixelDimensions(term);
let queryString = `?web_client_id=${encodeURIComponent(ownWebClientId)}&rows=${term.rows}&cols=${term.cols}`;
if (cell) {
const cellWidth = Math.round(cell.width);
const cellHeight = Math.round(cell.height);
queryString += `&cell_width=${cellWidth}&cell_height=${cellHeight}`;
lastSentCellDimensions = { width: cellWidth, height: cellHeight };
}
const wsTerminalUrl = `${url}${queryString}`;
wsTerminal = new WebSocket(wsTerminalUrl);
wsControl = new WebSocket(
`${wsBaseUrl}/ws/control?web_client_id=${encodeURIComponent(ownWebClientId)}`
);
startWsControl(wsControl, term, fitAddon, ownWebClientId, userConfig);
window.__zjSendControl = function (payload) {
if (wsControl && wsControl.readyState === WebSocket.OPEN) {
wsControl.send(
JSON.stringify({
web_client_id: ownWebClientId,
payload,
})
);
}
};
wsTerminal.onopen = function () {
markConnectionEstablished();
};
wsTerminal.onmessage = function (event) {
if (ownWebClientId == "") {
ownWebClientId = webClientId;
const wsControlUrl = `${wsBaseUrl}/ws/control`;
wsControl = new WebSocket(wsControlUrl);
startWsControl(wsControl, term, fitAddon, ownWebClientId, userConfig);
}
let data = event.data;
if (typeof data === "string") {
@@ -155,11 +199,12 @@ export function initWebSockets(
}
};
const originalSendAnsiKey = sendAnsiKey;
sendAnsiKey = (ansiKey) => {
if (ownWebClientId !== "") {
wsTerminal.send(ansiKey);
const sendAnsiKey = (ansiKey) => {
let payload = ansiKey;
if (typeof window.__zjMobileMergeKey === "function") {
payload = window.__zjMobileMergeKey(payload);
}
wsTerminal.send(payload);
};
setupResizeHandler(
@@ -186,92 +231,44 @@ export function initWebSockets(
}
function startWsControl(wsControl, term, fitAddon, ownWebClientId, userConfig) {
wsControl.onopen = function (event) {
const fitDimensions = fitAddon.proposeDimensions();
const { rows, cols } = fitDimensions;
sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols);
};
wsControl.onmessage = function (event) {
const msg = JSON.parse(event.data);
if (msg.type === "SetConfig") {
const {
font,
theme,
cursor_blink,
mac_option_is_meta,
cursor_style,
cursor_inactive_style,
font_size,
} = msg;
term.options.fontFamily = font;
term.options.theme = theme;
if (cursor_blink !== "undefined") {
term.options.cursorBlink = cursor_blink;
const options = terminalConfigOptions(msg);
for (const key of Object.keys(options)) {
term.options[key] = options[key];
}
if (typeof msg.cursor_blink !== "undefined") {
userConfig.blink = true;
}
if (mac_option_is_meta !== "undefined") {
term.options.macOptionIsMeta = mac_option_is_meta;
}
if (cursor_style !== "undefined") {
term.options.cursorStyle = cursor_style;
if (typeof msg.cursor_style !== "undefined") {
userConfig.style = true;
}
if (cursor_inactive_style !== "undefined") {
term.options.cursorInactiveStyle = cursor_inactive_style;
}
if (typeof window.__zjSyncInactiveCursorStyle === "function") {
window.__zjSyncInactiveCursorStyle();
}
const isMobileViewport =
(window.matchMedia &&
window.matchMedia("(pointer: coarse)").matches &&
window.innerWidth < 600) ||
/Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);
const hasExplicitFontSize =
typeof font_size === "number" && font_size > 0;
const baseFontPx = hasExplicitFontSize
? font_size
: isMobileViewport
? 24
: 12;
applyFontSize(term, fitAddon, baseFontPx);
const needsMobileDownscale =
!hasExplicitFontSize &&
isMobileViewport &&
term.rows < NATURAL_MIN_TOTAL_ROWS;
if (needsMobileDownscale) {
const downscaledPx = Math.max(
Math.floor(
(baseFontPx * term.rows) / NATURAL_MIN_TOTAL_ROWS
),
MOBILE_LEGIBLE_FLOOR_PX
);
if (downscaledPx < baseFontPx) {
applyFontSize(term, fitAddon, downscaledPx);
}
}
const body = document.querySelector("body");
body.style.background = theme.background || "black";
const terminal = document.getElementById("terminal");
terminal.style.background = theme.background;
sendSizeUpdate(
wsControl,
ownWebClientId,
term,
term.rows,
term.cols,
"Settled"
);
applyTerminalBackground(msg);
} else if (msg.type === "QueryTerminalSize") {
const fitDimensions = fitAddon.proposeDimensions();
const { rows, cols } = fitDimensions;
if (rows !== term.rows || cols !== term.cols) {
term.resize(cols, rows);
const sizing = getMobileRenderSizing();
if (sizing.pinned) {
if (sizing.rows !== term.rows || sizing.cols !== term.cols) {
term.resize(sizing.cols, sizing.rows);
}
sendSizeUpdate(
wsControl,
ownWebClientId,
term,
sizing.rows,
sizing.cols
);
} else {
const fitDimensions = fitAddon.proposeDimensions();
const { rows, cols } = fitDimensions;
if (rows !== term.rows || cols !== term.cols) {
term.resize(cols, rows);
}
sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols);
}
sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols);
} else if (msg.type === "Log") {
const { lines } = msg;
for (const line in lines) {
@@ -285,10 +282,22 @@ function startWsControl(wsControl, term, fitAddon, ownWebClientId, userConfig) {
} else if (msg.type === "SwitchedSession") {
const { new_session_name } = msg;
const baseUrl = getBaseUrl();
window.location.href = `${baseUrl}/${encodeURIComponent(new_session_name)}`;
const target = `${baseUrl}/${encodeURIComponent(new_session_name)}`;
if (!isCurrentLocation(target)) {
history.pushState(null, "", target);
document.title = new_session_name;
}
} else if (msg.type === "SetSoftKeyboard") {
const { on } = msg;
setSoftKeyboard(term, !!on);
} else if (msg.type === "MobileState") {
const { payload } = msg;
if (payload) {
window.__zjLastMobileState = payload;
if (window.__zjMobileUi) {
window.__zjMobileUi.setData(payload);
}
}
}
};
@@ -308,8 +317,7 @@ export function setupResizeHandler(
getOwnWebClientId
) {
let resizeScheduled = false;
let pendingViewportSignal = false;
let pendingRenderingSignal = false;
let pendingResizeSignal = false;
const updateViewportVars = () => {
const root = document.documentElement;
@@ -320,12 +328,24 @@ export function setupResizeHandler(
root.style.setProperty("--dynamic-vw", `${width}px`);
};
const resizeTerminal = (cause) => {
const resizeTerminal = () => {
const ownWebClientId = getOwnWebClientId();
if (ownWebClientId === "") {
return;
}
const sizing = getMobileRenderSizing();
if (sizing.pinned) {
if (sizing.rows !== term.rows || sizing.cols !== term.cols) {
term.resize(sizing.cols, sizing.rows);
}
if (window.__zjMobilePan) {
window.__zjMobilePan.recompute();
}
return;
}
const fitDimensions = fitAddon.proposeDimensions();
if (fitDimensions === undefined) {
console.warn("failed to get new fit dimensions");
@@ -340,48 +360,36 @@ export function setupResizeHandler(
const wsControl = getWsControl();
term.resize(cols, rows);
sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols, cause);
sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols);
};
const handleViewportChange = (cause) => {
const handleViewportChange = () => {
updateViewportVars();
resizeTerminal(cause);
resizeTerminal();
};
const scheduleResize = (cause) => {
if (cause === "RenderingPreference") {
pendingRenderingSignal = true;
} else {
pendingViewportSignal = true;
}
const scheduleResize = () => {
pendingResizeSignal = true;
if (resizeScheduled) {
return;
}
resizeScheduled = true;
requestAnimationFrame(() => {
const tickCause =
pendingRenderingSignal && !pendingViewportSignal
? "RenderingPreference"
: "Viewport";
pendingViewportSignal = false;
pendingRenderingSignal = false;
resizeScheduled = false;
handleViewportChange(tickCause);
if (!pendingResizeSignal) {
return;
}
pendingResizeSignal = false;
handleViewportChange();
});
};
const scheduleViewportResize = () => scheduleResize("Viewport");
const scheduleRenderingResize = () => scheduleResize("RenderingPreference");
updateViewportVars();
addEventListener("resize", scheduleViewportResize);
addEventListener("resize", scheduleResize);
if (window.visualViewport) {
window.visualViewport.addEventListener(
"resize",
scheduleViewportResize
);
window.visualViewport.addEventListener("resize", scheduleResize);
}
addEventListener("zellij:rendering-resize", scheduleRenderingResize);
addEventListener("zellij:rendering-resize", scheduleResize);
setupSoftKeyboardVisibilityTracker(getWsControl, getOwnWebClientId);
}
@@ -409,6 +417,12 @@ function setupSoftKeyboardVisibilityTracker(getWsControl, getOwnWebClientId) {
}
kbdVisible = newKbdVisible;
window.dispatchEvent(
new CustomEvent("zellij:soft-keyboard-visibility", {
detail: { visible: kbdVisible },
})
);
if (!kbdVisible) {
const capture =
window.__zjSoftKbdCapture &&
+31 -5
View File
@@ -156,7 +156,7 @@ use zellij_utils::{
envs,
errors::{ClientContext, ContextType, ErrorInstruction},
input::{cli_assets::CliAssets, config::Config, options::Options},
ipc::{ClientToServerMsg, ExitReason, ResizeCause, ServerToClientMsg},
ipc::{ClientToServerMsg, ExitReason, ServerToClientMsg},
nested_session,
pane_size::Size,
vendored::termwiz::input::InputEvent,
@@ -220,6 +220,7 @@ impl From<ServerToClientMsg> for ClientInstruction {
ServerToClientMsg::PaneRenderUpdate { .. } => ClientInstruction::UnblockInputThread,
ServerToClientMsg::SubscribedPaneClosed { .. } => ClientInstruction::UnblockInputThread,
ServerToClientMsg::SetSoftKeyboard { .. } => ClientInstruction::UnblockInputThread,
ServerToClientMsg::MobileState { .. } => ClientInstruction::UnblockInputThread,
}
}
}
@@ -751,8 +752,11 @@ pub async fn run_remote_client_terminal_loop(
Ok(WebServerToWebClientControlMessage::SetSoftKeyboard{ .. }) => {
// no-op
}
Ok(WebServerToWebClientControlMessage::MobileState{ .. }) => {
// no-op
}
Err(e) => {
log::error!("Failed to deserialize control message: {}", e);
log::debug!("Ignoring unrecognized control message: {}", e);
}
}
@@ -1264,7 +1268,6 @@ pub fn start_client(
move || {
os_api.send_to_server(ClientToServerMsg::TerminalResize {
new_size: os_api.get_terminal_size(),
cause: ResizeCause::Viewport,
});
#[cfg(not(windows))]
let _ = os_api
@@ -1410,7 +1413,6 @@ pub fn start_client(
ClientInstruction::QueryTerminalSize => {
os_input.send_to_server(ClientToServerMsg::TerminalResize {
new_size: os_input.get_terminal_size(),
cause: ResizeCause::Viewport,
});
},
ClientInstruction::StartWebServer => {
@@ -1436,7 +1438,31 @@ pub fn start_client(
ClientInstruction::ForwardQueryToHost { token, query_bytes } => {
// 1. Open a forwarding window on the parser so any reply
// events that arrive before the barrier are captured.
stdin_ansi_parser.lock().unwrap().open_forward(token);
// A slot may still be open here: the server's backstop
// timeout can give up on the previous token and dispatch
// this one before this client's per-slot timer got to
// run. Hand that slot over instead of clobbering it.
let stale_forward = {
let mut stdin_ansi_parser = stdin_ansi_parser.lock().unwrap();
let stale_forward = stdin_ansi_parser.take_active_forward();
stdin_ansi_parser.open_forward(token);
stale_forward
};
if let Some((stale_token, stale_reply_bytes)) = stale_forward {
log::warn!(
"forward slot for token {} was still open when token {} was dispatched \
({} accumulated bytes); closing it out",
stale_token,
token,
stale_reply_bytes.len(),
);
let _ = send_input_instructions.send(
InputInstruction::ForwardedReplyFromHostComplete {
token: stale_token,
reply_bytes: stale_reply_bytes,
},
);
}
// 2. Spawn a per-forward timer on the dedicated async
// runtime. When the deadline fires, the task closes
// the slot (if it's still open for this token) and
+17 -2
View File
@@ -15,10 +15,24 @@ pub struct SessionResponse {
pub web_client_id: String,
}
fn session_url(server_base_url: &str, session_name: &str) -> String {
if session_name.is_empty() {
format!("{}{}", server_base_url, SESSION_ENDPOINT)
} else {
format!(
"{}{}?session={}",
server_base_url,
SESSION_ENDPOINT,
urlencoding::encode(session_name)
)
}
}
pub async fn authenticate(
server_base_url: &str,
auth_token: &str,
remember_me: bool,
session_name: &str,
ca_cert: Option<&std::path::Path>,
insecure: bool,
) -> Result<(String, HttpClientWithCookies, Option<String>), RemoteClientError> {
@@ -61,7 +75,7 @@ pub async fn authenticate(
}
// Step 2: Get session/client ID
let session_url = format!("{}{}", server_base_url, SESSION_ENDPOINT);
let session_url = session_url(server_base_url, session_name);
let mut session_response = http_client
.send_with_cookies(
@@ -107,6 +121,7 @@ pub async fn authenticate(
pub async fn validate_session_token(
server_base_url: &str,
session_token: &str,
session_name: &str,
ca_cert: Option<&std::path::Path>,
insecure: bool,
) -> Result<(String, HttpClientWithCookies), RemoteClientError> {
@@ -117,7 +132,7 @@ pub async fn validate_session_token(
http_client.set_cookie("session_token".to_string(), session_token.to_string());
// Skip /login, go directly to /session endpoint
let session_url = format!("{}{}", server_base_url, SESSION_ENDPOINT);
let session_url = session_url(server_base_url, session_name);
let mut session_response = http_client
.send_with_cookies(
+17 -4
View File
@@ -208,8 +208,15 @@ async fn remote_attach(
) -> Result<(websockets::WebSocketConnections, Option<String>), RemoteClientError> {
let server_base_url = extract_server_url(server_url)?;
let session_name = extract_session_name(server_url)?;
let (web_client_id, http_client, session_token) =
auth::authenticate(&server_base_url, auth_token, remember_me, ca_cert, insecure).await?;
let (web_client_id, http_client, session_token) = auth::authenticate(
&server_base_url,
auth_token,
remember_me,
&session_name,
ca_cert,
insecure,
)
.await?;
let connections = websockets::establish_websocket_connections(
&web_client_id,
&http_client,
@@ -231,8 +238,14 @@ async fn remote_attach_with_session_token(
) -> Result<websockets::WebSocketConnections, RemoteClientError> {
let server_base_url = extract_server_url(server_url)?;
let session_name = extract_session_name(server_url)?;
let (web_client_id, http_client) =
auth::validate_session_token(&server_base_url, session_token, ca_cert, insecure).await?;
let (web_client_id, http_client) = auth::validate_session_token(
&server_base_url,
session_token,
&session_name,
ca_cert,
insecure,
)
.await?;
let connections = websockets::establish_websocket_connections(
&web_client_id,
&http_client,
@@ -28,6 +28,7 @@ mod mock_server {
pub valid_auth_tokens: Arc<Mutex<HashMap<String, ()>>>,
pub session_tokens: Arc<Mutex<HashMap<String, String>>>, // token -> web_client_id
pub endpoints_called: Arc<Mutex<Vec<String>>>,
pub query_strings: Arc<Mutex<HashMap<String, String>>>,
}
impl MockRemoteServerState {
@@ -36,9 +37,21 @@ mod mock_server {
valid_auth_tokens: Arc::new(Mutex::new(HashMap::new())),
session_tokens: Arc::new(Mutex::new(HashMap::new())),
endpoints_called: Arc::new(Mutex::new(Vec::new())),
query_strings: Arc::new(Mutex::new(HashMap::new())),
}
}
fn record_query(&self, endpoint: &str, query: Option<&str>) {
self.query_strings
.lock()
.unwrap()
.insert(endpoint.to_string(), query.unwrap_or("").to_string());
}
pub fn get_query_string(&self, endpoint: &str) -> Option<String> {
self.query_strings.lock().unwrap().get(endpoint).cloned()
}
pub fn add_valid_token(&self, token: &str) {
self.valid_auth_tokens
.lock()
@@ -103,9 +116,11 @@ mod mock_server {
pub async fn handle_session(
State(state): State<MockRemoteServerState>,
uri: axum::http::Uri,
jar: CookieJar,
) -> Result<Json<serde_json::Value>, StatusCode> {
state.record_endpoint("/session");
state.record_query("/session", uri.query());
let session_token = jar
.get("session_token")
@@ -120,16 +135,26 @@ mod mock_server {
drop(session_tokens);
Ok(Json(json!({
"web_client_id": web_client_id
"web_client_id": web_client_id,
"is_read_only": false,
"session_name": "session-name",
"config": {
"font": "Monospace",
"theme": {},
"cursor_blink": false,
"mac_option_is_meta": false
}
})))
}
pub async fn handle_ws_terminal(
ws: axum::extract::ws::WebSocketUpgrade,
State(state): State<MockRemoteServerState>,
uri: axum::http::Uri,
jar: CookieJar,
) -> Result<Response, StatusCode> {
state.record_endpoint("/ws/terminal");
state.record_query("/ws/terminal", uri.query());
// Validate session token
let session_token = jar
@@ -160,9 +185,11 @@ mod mock_server {
pub async fn handle_ws_control(
ws: axum::extract::ws::WebSocketUpgrade,
State(state): State<MockRemoteServerState>,
uri: axum::http::Uri,
jar: CookieJar,
) -> Result<Response, StatusCode> {
state.record_endpoint("/ws/control");
state.record_query("/ws/control", uri.query());
// Validate session token
let session_token = jar
@@ -477,6 +504,34 @@ mod tests {
"Should establish control WebSocket"
);
let session_query = server_state
.get_query_string("/session")
.expect("session endpoint query recorded");
assert_eq!(
session_query, "session=session-name",
"session endpoint must carry the requested session name"
);
let terminal_query = server_state
.get_query_string("/ws/terminal")
.expect("terminal socket query recorded");
assert!(
terminal_query.contains("web_client_id=")
&& terminal_query.contains("rows=")
&& terminal_query.contains("cols="),
"terminal socket must carry the local terminal size, got: {}",
terminal_query
);
let control_query = server_state
.get_query_string("/ws/control")
.expect("control socket query recorded");
assert!(
control_query.contains("web_client_id="),
"control socket must carry the web_client_id, got: {}",
control_query
);
server_handle.abort();
cleanup_test_db(&format!("http://127.0.0.1:{}", port));
}
+15 -5
View File
@@ -205,24 +205,34 @@ pub async fn establish_websocket_connections(
let ws_protocol = if is_tls { "wss" } else { "ws" };
let base_host = format!("{}:{}", host, port);
let terminal_size = crate::os_input_output::get_terminal_size();
let size_query = format!("&rows={}&cols={}", terminal_size.rows, terminal_size.cols);
let terminal_url = if session_name.is_empty() {
format!(
"{}://{}{WS_TERMINAL_ENDPOINT}?web_client_id={}",
"{}://{}{WS_TERMINAL_ENDPOINT}?web_client_id={}{}",
ws_protocol,
base_host,
urlencoding::encode(web_client_id)
urlencoding::encode(web_client_id),
size_query
)
} else {
format!(
"{}://{}{WS_TERMINAL_ENDPOINT}/{}?web_client_id={}",
"{}://{}{WS_TERMINAL_ENDPOINT}/{}?web_client_id={}{}",
ws_protocol,
base_host,
urlencoding::encode(session_name),
urlencoding::encode(web_client_id)
urlencoding::encode(web_client_id),
size_query
)
};
let control_url = format!("{}://{}{WS_CONTROL_ENDPOINT}", ws_protocol, base_host);
let control_url = format!(
"{}://{}{WS_CONTROL_ENDPOINT}?web_client_id={}",
ws_protocol,
base_host,
urlencoding::encode(web_client_id)
);
log::info!("Connecting to terminal WebSocket: {}", terminal_url);
log::info!("Connecting to control WebSocket: {}", control_url);
+17 -5
View File
@@ -332,11 +332,13 @@ impl StdinAnsiParser {
/// classified `HostReply` events.
///
/// The server serializes forwarded queries globally (`forward_in_flight`
/// on `Screen`), so in a well-behaved session this is only ever called
/// when the slot is empty. The guards below catch a misbehaving server
/// or a race that reached through: debug builds panic so bugs surface
/// during testing, release builds log and clobber the previous slot
/// (whose accumulated bytes would otherwise silently leak).
/// on `Screen`), but its own backstop timeout can release that slot and
/// dispatch the next query before this client's per-slot timer has run,
/// so callers hand a still-open slot over with `take_active_forward`
/// first. The guards below catch a caller that skipped that handover:
/// debug builds panic so bugs surface during testing, release builds
/// log and clobber the previous slot (whose accumulated bytes would
/// otherwise silently leak).
pub fn open_forward(&mut self, token: u32) {
debug_assert!(
self.active_forward.is_none(),
@@ -371,6 +373,16 @@ impl StdinAnsiParser {
}
}
/// Close whatever forwarding window is currently open, whichever token
/// it belongs to, and return its token together with the bytes it
/// accumulated. Used to hand a slot the server has already given up on
/// over to the forward that replaced it, instead of clobbering it.
pub fn take_active_forward(&mut self) -> Option<(u32, Vec<u8>)> {
self.active_forward
.take()
.map(|slot| (slot.token, slot.reply_bytes))
}
/// Currently-open slot's token, if any. Test-only inspector;
/// production code drives slot lifecycle through `open_forward`,
/// `close_forward_on_timeout`, and `feed()` directly.
@@ -745,7 +745,6 @@ async fn test_control_message_handling() {
serde_json::from_str(&text).expect("Failed to parse");
let size = match parsed.payload {
WebClientToWebServerControlMessagePayload::TerminalResize(size) => size,
WebClientToWebServerControlMessagePayload::TerminalResizeRendering(size) => size,
other => panic!("Expected TerminalResize, got: {:?}", other),
};
assert_eq!(size, terminal_size);
@@ -46,6 +46,12 @@ impl ConnectionTable {
.map(|c| c.add_control_tx(control_channel_tx));
}
pub fn queue_client_control_message(&mut self, client_id: &str, message: Message) {
self.client_id_to_channels
.get_mut(client_id)
.map(|c| c.queue_control_message(message));
}
pub fn add_client_terminal_tx(
&mut self,
client_id: &str,
@@ -125,7 +131,10 @@ impl ClientConnectionBus {
if let Some(control_channel_tx) = self.control_channel_tx.as_ref() {
let _ = control_channel_tx.send(message);
} else {
log::error!("Failed to send control message to client");
self.connection_table
.lock()
.unwrap()
.queue_client_control_message(&self.web_client_id, message);
}
},
}
@@ -201,3 +210,149 @@ impl ClientConnectionBus {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::os_input_output::ClientOsApi;
use std::io::{BufRead, Cursor, Write};
use std::path::Path;
use std::sync::Mutex;
use zellij_utils::{
data::Palette,
errors::ErrorContext,
ipc::{ClientToServerMsg, ServerToClientMsg},
pane_size::Size,
};
#[derive(Clone, Debug, Default)]
struct StubOsInput;
impl ClientOsApi for StubOsInput {
fn get_terminal_size(&self) -> Size {
Size::default()
}
fn set_raw_mode(&mut self) {}
fn unset_raw_mode(&self) -> Result<(), std::io::Error> {
Ok(())
}
fn get_stdout_writer(&self) -> Box<dyn Write> {
Box::new(std::io::sink())
}
fn get_stdin_reader(&self) -> Box<dyn BufRead> {
Box::new(Cursor::new(Vec::<u8>::new()))
}
fn update_session_name(&mut self, _new_session_name: String) {}
fn read_from_stdin(&mut self) -> Result<Vec<u8>, &'static str> {
Ok(vec![])
}
fn box_clone(&self) -> Box<dyn ClientOsApi> {
Box::new(self.clone())
}
fn send_to_server(&self, _msg: ClientToServerMsg) {}
fn recv_from_server(&self) -> Option<(ServerToClientMsg, ErrorContext)> {
None
}
fn handle_signals(
&self,
_sigwinch_cb: Box<dyn Fn()>,
_quit_cb: Box<dyn Fn()>,
_resize_receiver: Option<std::sync::mpsc::Receiver<()>>,
) {
}
fn connect_to_server(&self, _path: &Path) {}
fn load_palette(&self) -> Palette {
Palette::default()
}
fn enable_mouse(&self) -> anyhow::Result<()> {
Ok(())
}
fn disable_mouse(&self) -> anyhow::Result<()> {
Ok(())
}
}
fn connection_table_with_client(client_id: &str) -> Arc<Mutex<ConnectionTable>> {
let connection_table = Arc::new(Mutex::new(ConnectionTable::default()));
connection_table.lock().unwrap().add_new_client(
client_id.to_owned(),
Box::new(StubOsInput::default()),
false,
"token".to_owned(),
);
connection_table
}
fn message_text(message: &Message) -> String {
match message {
Message::Text(text) => text.to_string(),
other => panic!("expected a text control message, got {other:?}"),
}
}
#[test]
fn control_messages_sent_before_registration_are_buffered_and_flushed_in_order() {
let connection_table = connection_table_with_client("client");
let mut bus = ClientConnectionBus::new("client", &connection_table);
bus.send_control(WebServerToWebClientControlMessage::QueryTerminalSize);
bus.send_control(WebServerToWebClientControlMessage::SetSoftKeyboard { on: true });
let (control_channel_tx, mut control_channel_rx) = tokio::sync::mpsc::unbounded_channel();
connection_table
.lock()
.unwrap()
.add_client_control_tx("client", control_channel_tx);
let first = control_channel_rx
.try_recv()
.expect("the first buffered control message must be flushed on registration");
let second = control_channel_rx
.try_recv()
.expect("the second buffered control message must be flushed on registration");
assert!(message_text(&first).contains("QueryTerminalSize"));
assert!(message_text(&second).contains("SetSoftKeyboard"));
assert!(control_channel_rx.try_recv().is_err());
}
#[test]
fn control_messages_are_delivered_directly_once_registered() {
let connection_table = connection_table_with_client("client");
let (control_channel_tx, mut control_channel_rx) = tokio::sync::mpsc::unbounded_channel();
connection_table
.lock()
.unwrap()
.add_client_control_tx("client", control_channel_tx);
let mut bus = ClientConnectionBus::new("client", &connection_table);
bus.send_control(WebServerToWebClientControlMessage::QueryTerminalSize);
let message = control_channel_rx
.try_recv()
.expect("a registered control channel must receive messages directly");
assert!(message_text(&message).contains("QueryTerminalSize"));
}
#[test]
fn buffered_control_messages_are_bounded() {
let connection_table = connection_table_with_client("client");
let mut bus = ClientConnectionBus::new("client", &connection_table);
for _ in 0..200 {
bus.send_control(WebServerToWebClientControlMessage::QueryTerminalSize);
}
let (control_channel_tx, mut control_channel_rx) = tokio::sync::mpsc::unbounded_channel();
connection_table
.lock()
.unwrap()
.add_client_control_tx("client", control_channel_tx);
let mut flushed = 0;
while control_channel_rx.try_recv().is_ok() {
flushed += 1;
}
assert_eq!(flushed, 64);
}
}
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use zellij_utils::{input::config::Config, pane_size::Size};
use zellij_utils::{input::config::Config, ipc::MobileStatePayload, pane_size::Size};
#[derive(Serialize, Deserialize, Debug, Clone)]
@@ -12,11 +12,28 @@ pub struct WebClientToWebServerControlMessage {
#[serde(tag = "type")]
pub enum WebClientToWebServerControlMessagePayload {
TerminalResize(Size),
TerminalResizeRendering(Size),
TerminalSizeSettled(Size),
TerminalMetrics(TerminalMetricsPayload),
SoftKeyboardVisibilityChanged { visible: bool },
NestedSessionFrameFromHost { payload_bytes: Vec<u8> },
SoftKeyboardVisibilityChanged {
visible: bool,
},
NestedSessionFrameFromHost {
payload_bytes: Vec<u8>,
},
RequestSessionList,
FocusPane {
pane_id: u32,
is_plugin: bool,
},
NewPaneInTab {
tab_id: usize,
},
NewTab,
SetMobileRenderPreferences {
single_pane: bool,
fit: bool,
},
#[serde(other)]
Unknown,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
@@ -36,6 +53,7 @@ pub enum WebServerToWebClientControlMessage {
LogError { lines: Vec<String> },
SwitchedSession { new_session_name: String },
SetSoftKeyboard { on: bool },
MobileState { payload: MobileStatePayload },
}
#[derive(Serialize, Deserialize, Debug, Clone)]
+50 -40
View File
@@ -1,54 +1,39 @@
use crate::web_client::authentication::{IsReadOnly, SessionTokenHash};
use crate::web_client::types::{AppState, CreateClientIdResponse, LoginRequest, LoginResponse};
use crate::web_client::utils::{get_mime_type, parse_cookies};
use crate::web_client::control_message::SetConfigPayload;
use crate::web_client::types::{
record_pending_welcome_session, AppState, CreateClientIdResponse, LoginRequest, LoginResponse,
SessionListResponse, SessionQuery,
};
use crate::web_client::utils::get_mime_type;
use axum::{
extract::{Path as AxumPath, Request, State},
extract::{Path as AxumPath, Query, State},
http::{header, StatusCode},
response::{Html, IntoResponse},
response::IntoResponse,
Json,
};
use axum_extra::extract::cookie::{Cookie, SameSite};
use include_dir;
use uuid::Uuid;
use zellij_utils::{consts::VERSION, web_authentication_tokens::create_session_token};
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#x27;")
}
const WEB_CLIENT_PAGE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/",
"assets/index.html"
));
use zellij_utils::{
consts::VERSION, sessions::generate_unique_session_name,
web_authentication_tokens::create_session_token,
};
const ASSETS_DIR: include_dir::Dir<'_> = include_dir::include_dir!("$CARGO_MANIFEST_DIR/assets");
pub async fn serve_html(State(state): State<AppState>, request: Request) -> Html<String> {
let cookies = parse_cookies(&request);
let is_authenticated = cookies.get("session_token").is_some();
let auth_value = if is_authenticated { "true" } else { "false" };
let base_url = html_escape(
&state
.config
.lock()
.unwrap()
.web_client
.base_url
.clone()
.unwrap_or("/".to_string()),
);
let html = Html(
WEB_CLIENT_PAGE
.replace("IS_AUTHENTICATED", &format!("{}", auth_value))
.replace("BASE_URL", &base_url),
);
html
pub async fn serve_html() -> impl IntoResponse {
match ASSETS_DIR.get_file("index.html") {
Some(file) => (
StatusCode::OK,
[(header::CONTENT_TYPE, "text/html")],
file.contents(),
),
None => (
StatusCode::INTERNAL_SERVER_ERROR,
[(header::CONTENT_TYPE, "text/plain")],
"index.html missing".as_bytes(),
),
}
}
pub async fn login_handler(
@@ -105,6 +90,7 @@ pub async fn login_handler(
pub async fn create_new_client(
State(state): State<AppState>,
Query(params): Query<SessionQuery>,
request: axum::extract::Request,
) -> Result<Json<CreateClientIdResponse>, (StatusCode, impl IntoResponse)> {
// Extract is_read_only from request extensions (set by auth middleware)
@@ -123,6 +109,20 @@ pub async fn create_new_client(
Json("Missing session info".to_string()),
))?;
let session_name = match params.session.filter(|name| !name.is_empty()) {
Some(session_name) => session_name,
None => {
let generated = generate_unique_session_name().ok_or((
StatusCode::INTERNAL_SERVER_ERROR,
Json("Failed to generate unique session name".to_string()),
))?;
if params.welcome.unwrap_or(true) {
record_pending_welcome_session(&state.pending_welcome_sessions, &generated);
}
generated
},
};
let web_client_id = String::from(Uuid::new_v4());
let os_input = state
.client_os_api_factory
@@ -136,12 +136,22 @@ pub async fn create_new_client(
session_token_hash.0,
);
let config = SetConfigPayload::from(&*state.config.lock().unwrap());
Ok(Json(CreateClientIdResponse {
web_client_id,
is_read_only,
session_name,
config,
}))
}
pub async fn list_sessions_handler(State(state): State<AppState>) -> Json<SessionListResponse> {
let mut sessions = state.session_manager.list_sessions();
sessions.sort_by(|a, b| a.name.cmp(&b.name));
Json(SessionListResponse { sessions })
}
pub async fn get_static_asset(AxumPath(path): AxumPath<String>) -> impl IntoResponse {
let path = path.trim_start_matches('/');
+4 -1
View File
@@ -44,7 +44,8 @@ use zellij_utils::input::{config::Config, options::Options};
use authentication::auth_middleware;
use http_handlers::{
create_new_client, get_static_asset, login_handler, serve_html, version_handler,
create_new_client, get_static_asset, list_sessions_handler, login_handler, serve_html,
version_handler,
};
use ipc_listener::listen_to_web_server_instructions;
@@ -216,6 +217,7 @@ pub async fn serve_web_client(
session_manager,
client_os_api_factory,
is_https,
pending_welcome_sessions: Arc::new(Mutex::new(std::collections::VecDeque::new())),
};
tokio::spawn({
@@ -237,6 +239,7 @@ pub async fn serve_web_client(
.route("/ws/terminal", any(ws_handler_terminal))
.route("/ws/terminal/{session}", any(ws_handler_terminal))
.route("/session", post(create_new_client))
.route("/session-list", get(list_sessions_handler))
.route_layer(middleware::from_fn(auth_middleware))
.route("/", get(serve_html))
.route("/{session}", get(serve_html))
+51 -52
View File
@@ -4,7 +4,10 @@ use crate::web_client::host_query_seed::build_host_query_seed_msgs;
use crate::web_client::session_management::{
build_initial_connection, create_first_message, create_ipc_pipe,
};
use crate::web_client::types::{ClientConnectionBus, ConnectionTable, SessionManager};
use crate::web_client::types::{
take_pending_welcome_session, ClientConnectionBus, ConnectionTable, PendingWelcomeSessions,
SessionManager,
};
use crate::web_client::utils::terminal_init_messages;
use std::{
@@ -15,7 +18,8 @@ use zellij_utils::{
cli::CliArgs,
data::Style,
input::{config::Config, options::Options},
ipc::{ClientToServerMsg, ExitReason, ServerToClientMsg},
ipc::{ClientToServerMsg, ExitReason, PixelDimensions, ServerToClientMsg},
pane_size::{Size, SizeInPixels},
sessions::generate_unique_session_name,
setup::Setup,
};
@@ -30,6 +34,9 @@ pub fn zellij_server_listener(
web_client_id: String,
session_manager: Arc<dyn SessionManager>,
attachment_complete_tx: Option<tokio::sync::oneshot::Sender<()>>,
client_size: Option<Size>,
client_pixel_dims: Option<SizeInPixels>,
pending_welcome_sessions: PendingWelcomeSessions,
) {
let _server_listener_thread = std::thread::Builder::new()
.name("server_listener".to_string())
@@ -37,8 +44,12 @@ pub fn zellij_server_listener(
move || {
let mut client_connection_bus =
ClientConnectionBus::new(&web_client_id, &connection_table);
let is_welcome_session = session_name
.as_ref()
.map(|name| take_pending_welcome_session(&pending_welcome_sessions, name))
.unwrap_or(true);
let mut reconnect_to_session =
match build_initial_connection(session_name, &config) {
match build_initial_connection(session_name, is_welcome_session, &config) {
Ok(initial_session_connection) => initial_session_connection,
Err(e) => {
log::error!("{}", e);
@@ -46,6 +57,8 @@ pub fn zellij_server_listener(
},
};
let mut attachment_complete_tx = attachment_complete_tx;
let mut host_query_seed_msgs: Option<Vec<ClientToServerMsg>> = None;
let mut switched_from_previous_session = false;
'reconnect_loop: loop {
let reconnect_info = reconnect_to_session.take();
let initial_layout = reconnect_info.as_ref().and_then(|r| r.layout.clone());
@@ -71,7 +84,8 @@ pub fn zellij_server_listener(
reload_config_from_disk(&mut config, &mut config_options, &config_file_path);
let full_screen_ws = os_input.get_terminal_size();
let full_screen_ws =
client_size.unwrap_or_else(|| os_input.get_terminal_size());
let mut sent_init_messages = false;
let palette = config
@@ -120,6 +134,18 @@ pub fn zellij_server_listener(
first_message,
);
if let Some(pixel_dims) = client_pixel_dims {
os_input.send_to_server(ClientToServerMsg::TerminalPixelDimensions {
pixel_dimensions: PixelDimensions {
text_area_size: client_size.map(|size| SizeInPixels {
width: size.cols * pixel_dims.width,
height: size.rows * pixel_dims.height,
}),
character_cell_size: Some(pixel_dims),
},
});
}
// Seed the server's host-terminal-query cache with web
// client state derived from Config (fg/bg/palette).
// Without this, OSC 10/11/4 queries from apps
@@ -127,7 +153,9 @@ pub fn zellij_server_listener(
// back empty. Pixel dimensions are seeded separately
// via the TerminalMetrics control message once the
// browser has reported them.
for seed in build_host_query_seed_msgs(&config, &config_options) {
let seeds = host_query_seed_msgs
.get_or_insert_with(|| build_host_query_seed_msgs(&config, &config_options));
for seed in seeds.iter().cloned() {
os_input.send_to_server(seed);
}
@@ -135,11 +163,13 @@ pub fn zellij_server_listener(
let _ = tx.send(());
}
client_connection_bus.send_control(
WebServerToWebClientControlMessage::SwitchedSession {
new_session_name: session_name.clone(),
},
);
if switched_from_previous_session {
client_connection_bus.send_control(
WebServerToWebClientControlMessage::SwitchedSession {
new_session_name: session_name.clone(),
},
);
}
let mut unknown_message_count = 0;
loop {
@@ -162,15 +192,15 @@ pub fn zellij_server_listener(
},
Some(ServerToClientMsg::Render{content: bytes}) => {
if !sent_init_messages {
for message in terminal_init_messages() {
client_connection_bus.send_stdout(message.to_owned())
}
client_connection_bus
.send_stdout(terminal_init_messages().concat());
sent_init_messages = true;
}
client_connection_bus.send_stdout(bytes);
},
Some(ServerToClientMsg::SwitchSession{connect_to_session}) => {
reconnect_to_session = Some(connect_to_session);
switched_from_previous_session = true;
continue 'reconnect_loop;
},
Some(ServerToClientMsg::QueryTerminalSize) => {
@@ -183,6 +213,11 @@ pub fn zellij_server_listener(
WebServerToWebClientControlMessage::SetSoftKeyboard { on },
);
},
Some(ServerToClientMsg::MobileState{payload}) => {
client_connection_bus.send_control(
WebServerToWebClientControlMessage::MobileState { payload },
);
},
Some(ServerToClientMsg::Log{lines}) => {
client_connection_bus.send_control(
WebServerToWebClientControlMessage::Log { lines },
@@ -210,45 +245,9 @@ pub fn zellij_server_listener(
os_input.send_to_server(seed);
}
let set_config_payload = SetConfigPayload::from(&new_config);
let client_ids: Vec<String> = {
let connection_table_lock = connection_table.lock().unwrap();
connection_table_lock
.client_id_to_channels
.keys()
.cloned()
.collect()
};
let config_message =
WebServerToWebClientControlMessage::SetConfig(set_config_payload);
let config_msg_json = match serde_json::to_string(&config_message) {
Ok(json) => json,
Err(e) => {
log::error!("Failed to serialize config message: {}", e);
continue;
},
};
for client_id in client_ids {
if let Some(control_tx) = connection_table
.lock()
.unwrap()
.get_client_control_tx(&client_id)
{
let ws_message = config_msg_json.clone();
match control_tx.send(ws_message.into()) {
Ok(_) => {}, // no-op
Err(e) => {
log::error!(
"Failed to send config update to client {}: {}",
client_id,
e
);
},
}
}
}
client_connection_bus.send_control(
WebServerToWebClientControlMessage::SetConfig(set_config_payload),
);
}
}
},
@@ -13,12 +13,12 @@ use zellij_utils::{
pub fn build_initial_connection(
session_name: Option<String>,
is_welcome_session: bool,
config: &Config,
) -> Result<Option<ConnectToSession>, &'static str> {
let should_start_with_welcome_screen = session_name.is_none();
let default_layout_from_config =
LayoutInfo::from_config(&config.options.layout_dir, &config.options.default_layout);
if should_start_with_welcome_screen {
if is_welcome_session {
let Some(initial_session_name) = session_name.clone().or_else(generate_unique_session_name)
else {
return Err("Failed to generate unique session name, bailing.");
+93 -1
View File
@@ -1,11 +1,13 @@
use axum::extract::ws::Message;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::{HashMap, VecDeque};
use std::sync::{atomic::AtomicBool, Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::sync::mpsc::UnboundedSender;
use tokio_util::sync::CancellationToken;
use crate::os_input_output::ClientOsApi;
use crate::web_client::control_message::SetConfigPayload;
use crate::web_client::session_management::spawn_new_session;
use std::path::PathBuf;
use zellij_utils::{
@@ -30,6 +32,7 @@ impl ClientOsApiFactory for RealClientOsApiFactory {
pub trait SessionManager: Send + Sync + std::fmt::Debug {
fn session_exists(&self, session_name: &str) -> Result<bool, Box<dyn std::error::Error>>;
fn list_sessions(&self) -> Vec<WebSessionInfo>;
fn get_resurrection_layout(
&self,
session_name: &str,
@@ -53,6 +56,20 @@ impl SessionManager for RealSessionManager {
.map_err(|e| format!("Session check failed: {:?}", e).into())
}
fn list_sessions(&self) -> Vec<WebSessionInfo> {
zellij_utils::sessions::read_live_session_states_default_dirs("")
.into_values()
.map(|info| WebSessionInfo {
name: info.name,
web_clients_allowed: info.web_clients_allowed,
tab_count: info.tabs.len(),
pane_count: info.panes.panes.values().map(|panes| panes.len()).sum(),
connected_clients: info.connected_clients,
creation_secs_ago: info.creation_time.as_secs(),
})
.collect()
}
fn get_resurrection_layout(
&self,
session_name: &str,
@@ -85,6 +102,8 @@ pub struct ConnectionTable {
pub client_session_token_hash: HashMap<String, String>,
}
const MAX_PENDING_CONTROL_MESSAGES: usize = 64;
#[derive(Debug, Clone)]
pub struct ClientChannels {
pub os_api: Box<dyn ClientOsApi>,
@@ -92,6 +111,7 @@ pub struct ClientChannels {
pub terminal_channel_tx: Option<UnboundedSender<String>>,
terminal_channel_cancellation_token: Option<CancellationToken>,
pub should_not_reconnect: Arc<AtomicBool>,
pending_control_messages: Vec<Message>,
}
impl ClientChannels {
@@ -102,13 +122,24 @@ impl ClientChannels {
terminal_channel_tx: None,
terminal_channel_cancellation_token: None,
should_not_reconnect: Arc::new(AtomicBool::new(false)),
pending_control_messages: Vec::new(),
}
}
pub fn add_control_tx(&mut self, control_channel_tx: UnboundedSender<Message>) {
for message in self.pending_control_messages.drain(..) {
let _ = control_channel_tx.send(message);
}
self.control_channel_tx = Some(control_channel_tx);
}
pub fn queue_control_message(&mut self, message: Message) {
if self.pending_control_messages.len() >= MAX_PENDING_CONTROL_MESSAGES {
self.pending_control_messages.remove(0);
}
self.pending_control_messages.push(message);
}
pub fn add_terminal_tx(&mut self, terminal_channel_tx: UnboundedSender<String>) {
self.terminal_channel_tx = Some(terminal_channel_tx);
}
@@ -156,6 +187,34 @@ impl ClientConnectionBus {
}
}
pub const MAX_PENDING_WELCOME_SESSIONS: usize = 256;
pub const PENDING_WELCOME_SESSION_TTL: Duration = Duration::from_secs(300);
pub type PendingWelcomeSessions = Arc<Mutex<VecDeque<(String, Instant)>>>;
pub fn record_pending_welcome_session(pending: &PendingWelcomeSessions, session_name: &str) {
let mut pending = pending.lock().unwrap();
let now = Instant::now();
pending.retain(|(_, created_at)| now.duration_since(*created_at) < PENDING_WELCOME_SESSION_TTL);
while pending.len() >= MAX_PENDING_WELCOME_SESSIONS {
pending.pop_front();
}
pending.push_back((session_name.to_owned(), now));
}
pub fn take_pending_welcome_session(pending: &PendingWelcomeSessions, session_name: &str) -> bool {
let mut pending = pending.lock().unwrap();
let now = Instant::now();
pending.retain(|(_, created_at)| now.duration_since(*created_at) < PENDING_WELCOME_SESSION_TTL);
match pending.iter().position(|(name, _)| name == session_name) {
Some(index) => {
pending.remove(index);
true
},
None => false,
}
}
#[derive(Clone)]
pub struct AppState {
pub connection_table: Arc<Mutex<ConnectionTable>>,
@@ -165,17 +224,50 @@ pub struct AppState {
pub session_manager: Arc<dyn SessionManager>,
pub client_os_api_factory: Arc<dyn ClientOsApiFactory>,
pub is_https: bool,
pub pending_welcome_sessions: PendingWelcomeSessions,
}
#[derive(Serialize)]
pub struct CreateClientIdResponse {
pub web_client_id: String,
pub is_read_only: bool,
pub session_name: String,
pub config: SetConfigPayload,
}
#[derive(Deserialize)]
pub struct SessionQuery {
pub session: Option<String>,
pub welcome: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct WebSessionInfo {
pub name: String,
pub web_clients_allowed: bool,
pub tab_count: usize,
pub pane_count: usize,
pub connected_clients: usize,
pub creation_secs_ago: u64,
}
#[derive(Serialize, Deserialize)]
pub struct SessionListResponse {
pub sessions: Vec<WebSessionInfo>,
}
#[derive(Deserialize)]
pub struct TerminalParams {
pub web_client_id: String,
pub rows: Option<u16>,
pub cols: Option<u16>,
pub cell_width: Option<u16>,
pub cell_height: Option<u16>,
}
#[derive(Deserialize)]
pub struct ControlParams {
pub web_client_id: String,
}
#[derive(Deserialize)]
@@ -14,11 +14,11 @@ use zellij_utils::{consts::VERSION, input::config::Config, input::options::Optio
use crate::os_input_output::ClientOsApi;
use crate::web_client::control_message::{
WebClientToWebServerControlMessage, WebClientToWebServerControlMessagePayload,
WebServerToWebClientControlMessage,
};
use crate::web_client::types::{SessionListResponse, WebSessionInfo};
use crate::web_client::ClientOsApiFactory;
use zellij_utils::{
data::Palette,
data::{LayoutInfo, Palette},
errors::ErrorContext,
ipc::{ClientToServerMsg, ServerToClientMsg},
pane_size::Size,
@@ -179,6 +179,19 @@ mod web_client_tests {
assert!(response.status().is_success());
let remember_cookie = response
.headers()
.get("set-cookie")
.expect("remember_me login must set a cookie")
.to_str()
.expect("cookie header must be valid utf8")
.to_string();
assert!(
remember_cookie.contains("Max-Age="),
"remember_me:true must produce a persistent cookie carrying Max-Age, got: {}",
remember_cookie
);
let response_text = response.text().expect("Failed to read response body");
let response_json: serde_json::Value =
serde_json::from_str(&response_text).expect("Failed to parse JSON");
@@ -186,6 +199,42 @@ mod web_client_tests {
assert_eq!(response_json["success"], true);
assert_eq!(response_json["message"], "Login successful");
let login_url = format!("http://127.0.0.1:{}/command/login", port);
let login_payload = serde_json::json!({
"auth_token": auth_token,
"remember_me": false
});
let session_response = timeout(
Duration::from_secs(5),
tokio::task::spawn_blocking(move || {
isahc::Request::post(&login_url)
.header("Content-Type", "application/json")
.body(login_payload.to_string())
.unwrap()
.send()
}),
)
.await
.expect("Login request timed out")
.expect("Spawn blocking failed")
.expect("Login request failed");
assert!(session_response.status().is_success());
let session_cookie = session_response
.headers()
.get("set-cookie")
.expect("login must set a cookie")
.to_str()
.expect("cookie header must be valid utf8")
.to_string();
assert!(
!session_cookie.contains("Max-Age="),
"remember_me:false must produce a session cookie without Max-Age, got: {}",
session_cookie
);
server_handle.abort();
revoke_token(test_token_name).expect("Failed to revoke test token");
tokio::time::sleep(Duration::from_millis(100)).await;
@@ -354,7 +403,10 @@ mod web_client_tests {
serde_json::from_str(&client_response.text().unwrap()).unwrap();
let web_client_id = client_data["web_client_id"].as_str().unwrap().to_string();
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let control_ws_url = format!(
"ws://127.0.0.1:{}/ws/control?web_client_id={}",
port, web_client_id
);
let (control_ws, _) = timeout(
Duration::from_secs(5),
connect_async_with_cookie(&control_ws_url, session_token),
@@ -365,23 +417,12 @@ mod web_client_tests {
let (mut control_sink, mut control_stream) = control_ws.split();
let control_message = timeout(Duration::from_secs(2), control_stream.next())
.await
.expect("Timeout waiting for control message")
.expect("Control stream ended")
.expect("Error receiving control message");
if let Message::Text(text) = control_message {
let parsed: WebServerToWebClientControlMessage =
serde_json::from_str(&text).expect("Failed to parse control message");
match parsed {
WebServerToWebClientControlMessage::SetConfig(_) => {},
_ => panic!("Expected SetConfig message, got: {:?}", parsed),
}
} else {
panic!("Expected text message, got: {:?}", control_message);
}
let unsolicited = timeout(Duration::from_millis(500), control_stream.next()).await;
assert!(
unsolicited.is_err(),
"no control message may be pushed before the first render, got: {:?}",
unsolicited
);
let resize_msg = WebClientToWebServerControlMessage {
web_client_id: web_client_id.clone(),
@@ -556,7 +597,10 @@ mod web_client_tests {
serde_json::from_str(&client_response.text().unwrap()).unwrap();
let web_client_id = client_data["web_client_id"].as_str().unwrap().to_string();
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let control_ws_url = format!(
"ws://127.0.0.1:{}/ws/control?web_client_id={}",
port, web_client_id
);
let (control_ws, _) = timeout(
Duration::from_secs(5),
connect_async_with_cookie(&control_ws_url, session_token),
@@ -564,10 +608,7 @@ mod web_client_tests {
.await
.expect("Control WebSocket connection timed out")
.expect("Failed to connect to control WebSocket");
let (mut control_sink, mut control_stream) = control_ws.split();
let _ = timeout(Duration::from_secs(2), control_stream.next())
.await
.expect("Timeout waiting for initial control message");
let (mut control_sink, _control_stream) = control_ws.split();
let resize_msg = WebClientToWebServerControlMessage {
web_client_id: web_client_id.clone(),
@@ -787,7 +828,10 @@ mod web_client_tests {
let web_client_id = create_client_session(port, &session_token).await;
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let control_ws_url = format!(
"ws://127.0.0.1:{}/ws/control?web_client_id={}",
port, web_client_id
);
let (control_ws, _) = timeout(
Duration::from_secs(5),
connect_async_with_cookie(&control_ws_url, &session_token),
@@ -798,10 +842,6 @@ mod web_client_tests {
let (mut control_sink, mut control_stream) = control_ws.split();
let _initial_msg = timeout(Duration::from_secs(2), control_stream.next())
.await
.expect("Timeout waiting for initial control message");
let resize_msg = WebClientToWebServerControlMessage {
web_client_id: web_client_id.clone(),
payload: WebClientToWebServerControlMessagePayload::TerminalResize(Size {
@@ -927,7 +967,10 @@ mod web_client_tests {
let client_id_1 = create_client_session(port, &session_token).await;
let client_id_2 = create_client_session(port, &session_token).await;
let control_ws_url_1 = format!("ws://127.0.0.1:{}/ws/control", port);
let control_ws_url_1 = format!(
"ws://127.0.0.1:{}/ws/control?web_client_id={}",
port, client_id_1
);
let (control_ws_1, _) = timeout(
Duration::from_secs(5),
connect_async_with_cookie(&control_ws_url_1, &session_token),
@@ -936,9 +979,12 @@ mod web_client_tests {
.expect("Client 1 control WebSocket connection timed out")
.expect("Failed to connect client 1 to control WebSocket");
let (mut control_sink_1, mut control_stream_1) = control_ws_1.split();
let (mut control_sink_1, _control_stream_1) = control_ws_1.split();
let control_ws_url_2 = format!("ws://127.0.0.1:{}/ws/control", port);
let control_ws_url_2 = format!(
"ws://127.0.0.1:{}/ws/control?web_client_id={}",
port, client_id_2
);
let (control_ws_2, _) = timeout(
Duration::from_secs(5),
connect_async_with_cookie(&control_ws_url_2, &session_token),
@@ -947,10 +993,7 @@ mod web_client_tests {
.expect("Client 2 control WebSocket connection timed out")
.expect("Failed to connect client 2 to control WebSocket");
let (mut control_sink_2, mut control_stream_2) = control_ws_2.split();
let _initial_msg_1 = timeout(Duration::from_secs(2), control_stream_1.next()).await;
let _initial_msg_2 = timeout(Duration::from_secs(2), control_stream_2.next()).await;
let (mut control_sink_2, _control_stream_2) = control_ws_2.split();
let resize_msg_1 = WebClientToWebServerControlMessage {
web_client_id: client_id_1.clone(),
@@ -1370,10 +1413,14 @@ mod web_client_tests {
let client_data: serde_json::Value =
serde_json::from_str(&client_response.text().unwrap()).unwrap();
let is_read_only = client_data["is_read_only"].as_bool().unwrap();
let web_client_id = client_data["web_client_id"].as_str().unwrap().to_string();
assert_eq!(is_read_only, true, "Client should be marked as read-only");
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let control_ws_url = format!(
"ws://127.0.0.1:{}/ws/control?web_client_id={}",
port, web_client_id
);
let _ws_result = timeout(
Duration::from_secs(3),
@@ -1437,7 +1484,10 @@ mod web_client_tests {
let regular_session_token = login_and_get_session_token(port, &regular_token).await;
let regular_web_client_id = create_client_session(port, &regular_session_token).await;
let regular_control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let regular_control_ws_url = format!(
"ws://127.0.0.1:{}/ws/control?web_client_id={}",
port, regular_web_client_id
);
let (regular_control_ws, _) = timeout(
Duration::from_secs(5),
connect_async_with_cookie(&regular_control_ws_url, &regular_session_token),
@@ -1490,7 +1540,10 @@ mod web_client_tests {
let readonly_session_token = login_and_get_session_token(port, &readonly_token).await;
let readonly_web_client_id = create_client_session(port, &readonly_session_token).await;
let readonly_control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let readonly_control_ws_url = format!(
"ws://127.0.0.1:{}/ws/control?web_client_id={}",
port, readonly_web_client_id
);
let (readonly_control_ws, _) = timeout(
Duration::from_secs(5),
connect_async_with_cookie(&readonly_control_ws_url, &readonly_session_token),
@@ -1596,7 +1649,10 @@ mod web_client_tests {
let session_token = login_and_get_session_token(port, &regular_token).await;
let web_client_id = create_client_session(port, &session_token).await;
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let control_ws_url = format!(
"ws://127.0.0.1:{}/ws/control?web_client_id={}",
port, web_client_id
);
let (control_ws, _) = timeout(
Duration::from_secs(5),
connect_async_with_cookie(&control_ws_url, &session_token),
@@ -1820,6 +1876,14 @@ mod web_client_tests {
let client_data: serde_json::Value =
serde_json::from_str(&client_response.text().unwrap()).unwrap();
assert!(
client_data["session_name"].as_str().is_some(),
"session response should carry a session_name"
);
assert!(
client_data["config"].is_object(),
"session response should carry the client config"
);
client_data["web_client_id"].as_str().unwrap().to_string()
}
@@ -1881,7 +1945,10 @@ mod web_client_tests {
let (_terminal_sink, mut terminal_stream) = terminal_ws.split();
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let control_ws_url = format!(
"ws://127.0.0.1:{}/ws/control?web_client_id={}",
port, web_client_id
);
let (control_ws, _) = timeout(
Duration::from_secs(5),
connect_async_with_cookie(&control_ws_url, &session_token),
@@ -1892,7 +1959,6 @@ mod web_client_tests {
let (mut control_sink, mut control_stream) = control_ws.split();
let _initial_msg = timeout(Duration::from_secs(2), control_stream.next()).await;
let resize_msg = WebClientToWebServerControlMessage {
web_client_id: web_client_id.clone(),
payload: WebClientToWebServerControlMessagePayload::TerminalResize(Size {
@@ -2056,7 +2122,10 @@ mod web_client_tests {
let (_terminal_sink, mut terminal_stream) = terminal_ws.split();
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let control_ws_url = format!(
"ws://127.0.0.1:{}/ws/control?web_client_id={}",
port, web_client_id
);
let (control_ws, _) = timeout(
Duration::from_secs(5),
connect_async_with_cookie(&control_ws_url, &session_token),
@@ -2065,9 +2134,8 @@ mod web_client_tests {
.expect("Control WebSocket connection timed out")
.expect("Failed to connect to control WebSocket");
let (mut control_sink, mut control_stream) = control_ws.split();
let (mut control_sink, _control_stream) = control_ws.split();
let _initial_msg = timeout(Duration::from_secs(2), control_stream.next()).await;
let resize_msg = WebClientToWebServerControlMessage {
web_client_id: web_client_id.clone(),
payload: WebClientToWebServerControlMessagePayload::TerminalResize(Size {
@@ -2386,49 +2454,21 @@ mod web_client_tests {
let ro_session_token = login_and_get_session_token(port, &ro_token).await;
let _ro_web_client_id = create_client_session(port, &ro_session_token).await;
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let (control_ws, _) = timeout(
let control_ws_url = format!(
"ws://127.0.0.1:{}/ws/control?web_client_id={}",
port, rw_web_client_id
);
let connect_result = timeout(
Duration::from_secs(5),
connect_async_with_cookie(&control_ws_url, &ro_session_token),
)
.await
.expect("Control WebSocket connection timed out")
.expect("Failed to connect to control WebSocket");
.expect("Control WebSocket connection timed out");
let (mut control_sink, mut control_stream) = control_ws.split();
let _initial_msg = timeout(Duration::from_secs(2), control_stream.next()).await;
let resize_msg = WebClientToWebServerControlMessage {
web_client_id: rw_web_client_id.clone(),
payload: WebClientToWebServerControlMessagePayload::TerminalResize(Size {
rows: 1,
cols: 1,
}),
};
control_sink
.send(Message::Text(
serde_json::to_string(&resize_msg).unwrap().into(),
))
.await
.expect("Failed to send resize message");
let close_result = timeout(Duration::from_secs(3), control_stream.next()).await;
match close_result {
Ok(Some(Ok(Message::Close(_)))) | Ok(Some(Err(_))) | Ok(None) => {},
Err(_) => {},
Ok(Some(Ok(_))) => {
let second_result = timeout(Duration::from_secs(2), control_stream.next()).await;
assert!(
matches!(
second_result,
Ok(Some(Ok(Message::Close(_)))) | Ok(Some(Err(_))) | Ok(None) | Err(_)
),
"WebSocket should have been closed after sending foreign web_client_id"
);
},
}
assert!(
connect_result.is_err(),
"control WebSocket upgrade should be rejected for a foreign web_client_id"
);
tokio::time::sleep(Duration::from_millis(200)).await;
let mock_apis = factory_for_verification.mock_apis.lock().unwrap();
@@ -2442,8 +2482,8 @@ mod web_client_tests {
}
}
}
drop(mock_apis);
let _ = control_sink.close().await;
server_handle.abort();
let _ = delete_db();
tokio::time::sleep(Duration::from_millis(100)).await;
@@ -2491,7 +2531,10 @@ mod web_client_tests {
let session_token = login_and_get_session_token(port, &rw_token).await;
let web_client_id = create_client_session(port, &session_token).await;
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let control_ws_url = format!(
"ws://127.0.0.1:{}/ws/control?web_client_id={}",
port, web_client_id
);
let (control_ws, _) = timeout(
Duration::from_secs(5),
connect_async_with_cookie(&control_ws_url, &session_token),
@@ -2500,9 +2543,7 @@ mod web_client_tests {
.expect("Control WebSocket connection timed out")
.expect("Failed to connect to control WebSocket");
let (mut control_sink, mut control_stream) = control_ws.split();
let _initial_msg = timeout(Duration::from_secs(2), control_stream.next()).await;
let (mut control_sink, _control_stream) = control_ws.split();
let resize_msg = WebClientToWebServerControlMessage {
web_client_id: web_client_id.clone(),
@@ -2615,66 +2656,6 @@ mod web_client_tests {
tokio::time::sleep(Duration::from_millis(100)).await;
}
#[tokio::test]
#[serial]
async fn test_base_url_with_special_characters_is_escaped() {
let _ = delete_db();
let session_manager = Arc::new(MockSessionManager::new());
let client_os_api_factory = Arc::new(MockClientOsApiFactory::new());
let mut config = Config::default();
config.web_client.base_url = Some("\"><script>alert(1)</script>".to_string());
let options = Options::default();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let port = addr.port();
let temp_config_path = std::env::temp_dir().join("test_config.kdl");
let server_handle = tokio::spawn(async move {
serve_web_client(
config,
options,
Some(temp_config_path),
listener,
None,
Some(session_manager),
Some(client_os_api_factory),
addr.ip(),
port,
)
.await;
});
wait_for_server(port, Duration::from_secs(5))
.await
.expect("Server failed to start");
let url = format!("http://127.0.0.1:{}/", port);
let mut response = timeout(
Duration::from_secs(5),
tokio::task::spawn_blocking(move || isahc::get(&url)),
)
.await
.expect("Request timed out")
.expect("Spawn blocking failed")
.expect("Request failed");
let body = response.text().expect("Failed to read response body");
assert!(
!body.contains("<script>alert(1)</script>"),
"Response body should NOT contain unescaped script tag"
);
assert!(
body.contains("&lt;script&gt;"),
"Response body should contain HTML-escaped script tag"
);
server_handle.abort();
tokio::time::sleep(Duration::from_millis(100)).await;
}
// ========== PWA manifest and icon tests ==========
async fn spawn_test_server_with_config(config: Config) -> (u16, tokio::task::JoinHandle<()>) {
@@ -2885,6 +2866,64 @@ mod web_client_tests {
body.contains("theme-color"),
"index.html must declare a theme-color meta tag"
);
assert!(
!body.contains("<base "),
"index.html must not declare a <base href>"
);
assert!(
!body.contains("data-authenticated"),
"index.html must not carry a data-authenticated hint"
);
assert!(
body.contains("src=\"assets/app.js\""),
"index.html must load the bundled app.js module"
);
let integrity_url = format!("http://127.0.0.1:{}/assets/integrity.json", port);
let mut integrity_response = fetch_url(integrity_url).await;
let integrity: serde_json::Value =
serde_json::from_str(&integrity_response.text().expect("Failed to read integrity"))
.expect("integrity.json must be valid JSON");
let integrity = integrity
.as_object()
.expect("integrity.json must be an object");
let mut checked = 0;
for line in body.lines() {
let Some(attribute_start) = line
.find("src=\"assets/")
.map(|index| index + "src=\"assets/".len())
.or_else(|| {
line.find("href=\"assets/")
.map(|index| index + "href=\"assets/".len())
})
else {
continue;
};
let asset = &line
[attribute_start..attribute_start + line[attribute_start..].find('"').unwrap()];
let Some(expected) = integrity.get(asset) else {
continue;
};
let expected = expected.as_str().expect("digest must be a string");
let digest_start = line
.find("integrity=\"")
.map(|index| index + "integrity=\"".len())
.unwrap_or_else(|| panic!("missing integrity attribute for {}", asset));
let digest =
&line[digest_start..digest_start + line[digest_start..].find('"').unwrap()];
assert_eq!(
digest, expected,
"integrity attribute for {} does not match integrity.json",
asset
);
checked += 1;
}
assert_eq!(
checked,
integrity.len(),
"every hashed asset must be referenced with an integrity attribute"
);
server_handle.abort();
tokio::time::sleep(Duration::from_millis(100)).await;
@@ -2929,6 +2968,265 @@ mod web_client_tests {
"manifest body must be identical regardless of base_url config"
);
}
async fn spawn_test_server_with_session_manager(
session_manager: Arc<MockSessionManager>,
) -> (u16, tokio::task::JoinHandle<()>) {
let client_os_api_factory = Arc::new(MockClientOsApiFactory::new());
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let port = addr.port();
let temp_config_path = std::env::temp_dir().join("test_config.kdl");
let server_handle = tokio::spawn(async move {
serve_web_client(
Config::default(),
Options::default(),
Some(temp_config_path),
listener,
None,
Some(session_manager),
Some(client_os_api_factory),
addr.ip(),
port,
)
.await;
});
wait_for_server(port, Duration::from_secs(5))
.await
.expect("Server failed to start");
(port, server_handle)
}
#[tokio::test]
#[serial]
async fn test_session_list_requires_authentication() {
let _ = delete_db();
let (port, server_handle) =
spawn_test_server_with_session_manager(Arc::new(MockSessionManager::new())).await;
let url = format!("http://127.0.0.1:{}/session-list", port);
let response = timeout(
Duration::from_secs(5),
tokio::task::spawn_blocking(move || isahc::get(&url)),
)
.await
.expect("Request timed out")
.expect("Spawn blocking failed")
.expect("Request failed");
assert_eq!(
response.status(),
401,
"the session list must not be readable without authentication"
);
server_handle.abort();
tokio::time::sleep(Duration::from_millis(100)).await;
}
#[tokio::test]
#[serial]
async fn test_session_list_returns_live_sessions_without_attaching() {
let _ = delete_db();
let (regular_token, _) = create_token(Some("regular".to_string()), false).unwrap();
let session_manager = Arc::new(MockSessionManager::with_listed_sessions(vec![
WebSessionInfo {
name: "zebra".to_owned(),
web_clients_allowed: false,
tab_count: 1,
pane_count: 2,
connected_clients: 0,
creation_secs_ago: 120,
},
WebSessionInfo {
name: "alpha".to_owned(),
web_clients_allowed: true,
tab_count: 3,
pane_count: 7,
connected_clients: 1,
creation_secs_ago: 5,
},
]));
let session_manager_for_verification = session_manager.clone();
let (port, server_handle) = spawn_test_server_with_session_manager(session_manager).await;
let session_token = login_and_get_session_token(port, &regular_token).await;
let url = format!("http://127.0.0.1:{}/session-list", port);
let mut response = timeout(
Duration::from_secs(5),
tokio::task::spawn_blocking(move || {
isahc::Request::get(&url)
.header("Cookie", format!("session_token={}", session_token))
.body(())
.unwrap()
.send()
}),
)
.await
.expect("Request timed out")
.expect("Spawn blocking failed")
.expect("Request failed");
assert!(response.status().is_success());
let body: SessionListResponse =
serde_json::from_str(&response.text().expect("Failed to read body"))
.expect("Failed to parse the session list response");
assert_eq!(
body.sessions
.iter()
.map(|s| s.name.as_str())
.collect::<Vec<_>>(),
vec!["alpha", "zebra"],
"sessions must be reported sorted by name"
);
assert_eq!(body.sessions[0].tab_count, 3);
assert_eq!(body.sessions[0].pane_count, 7);
assert_eq!(body.sessions[0].connected_clients, 1);
assert_eq!(body.sessions[0].creation_secs_ago, 5);
assert!(!body.sessions[1].web_clients_allowed);
assert!(
session_manager_for_verification
.sessions_created
.lock()
.unwrap()
.is_empty(),
"listing sessions must not create or attach to any session"
);
assert!(
session_manager_for_verification
.first_messages_sent
.lock()
.unwrap()
.is_empty(),
"listing sessions must not send any message to a zellij server"
);
server_handle.abort();
let _ = delete_db();
tokio::time::sleep(Duration::from_millis(100)).await;
}
async fn create_client_session_with_query(
port: u16,
session_token: &str,
query: &str,
) -> (String, String) {
let session_url = format!("http://127.0.0.1:{}/session{}", port, query);
let mut client_response = timeout(
Duration::from_secs(5),
tokio::task::spawn_blocking({
let session_token = session_token.to_string();
move || {
isahc::Request::post(&session_url)
.header("Cookie", format!("session_token={}", session_token))
.header("Content-Type", "application/json")
.body("{}")
.unwrap()
.send()
}
}),
)
.await
.unwrap()
.unwrap()
.unwrap();
assert!(client_response.status().is_success());
let client_data: serde_json::Value =
serde_json::from_str(&client_response.text().unwrap()).unwrap();
(
client_data["web_client_id"].as_str().unwrap().to_string(),
client_data["session_name"].as_str().unwrap().to_string(),
)
}
async fn layout_of_first_message_for_query(query: &str) -> Option<LayoutInfo> {
let (regular_token, _) = create_token(Some("regular".to_string()), false).unwrap();
let session_manager = Arc::new(MockSessionManager::new());
let session_manager_for_verification = session_manager.clone();
let (port, server_handle) = spawn_test_server_with_session_manager(session_manager).await;
let session_token = login_and_get_session_token(port, &regular_token).await;
let (web_client_id, session_name) =
create_client_session_with_query(port, &session_token, query).await;
let terminal_ws_url = format!(
"ws://127.0.0.1:{}/ws/terminal/{}?web_client_id={}",
port, session_name, web_client_id
);
let (terminal_ws, _) = timeout(
Duration::from_secs(5),
connect_async_with_cookie(&terminal_ws_url, &session_token),
)
.await
.expect("Terminal WebSocket connection timed out")
.expect("Failed to connect to terminal WebSocket");
let (mut terminal_sink, _terminal_stream) = terminal_ws.split();
tokio::time::sleep(Duration::from_millis(500)).await;
let layout = {
let all_messages = session_manager_for_verification
.first_messages_sent
.lock()
.unwrap();
let (_, msg) = all_messages.first().expect("Should have a first message");
match msg {
ClientToServerMsg::FirstClientConnected { cli_assets, .. } => {
cli_assets.layout.clone()
},
other => panic!("Expected FirstClientConnected, got {:?}", other),
}
};
let _ = terminal_sink.close().await;
server_handle.abort();
let _ = delete_db();
tokio::time::sleep(Duration::from_millis(100)).await;
layout
}
#[tokio::test]
#[serial]
async fn test_unnamed_session_defaults_to_the_welcome_layout() {
let _ = delete_db();
let layout = layout_of_first_message_for_query("").await;
assert_eq!(
layout,
Some(LayoutInfo::BuiltIn("welcome".to_owned())),
"an unnamed session must boot the welcome screen by default"
);
}
#[tokio::test]
#[serial]
async fn test_unnamed_session_with_welcome_false_skips_the_welcome_layout() {
let _ = delete_db();
let layout = layout_of_first_message_for_query("?welcome=false").await;
assert_ne!(
layout,
Some(LayoutInfo::BuiltIn("welcome".to_owned())),
"welcome=false must not record a pending welcome session"
);
}
}
#[derive(Debug, Clone)]
@@ -2938,6 +3236,7 @@ pub struct MockSessionManager {
pub sessions_created: Arc<Mutex<HashSet<String>>>,
pub first_messages_sent: Arc<Mutex<Vec<(String, ClientToServerMsg)>>>,
pub all_sessions_exist: bool,
pub listed_sessions: Vec<WebSessionInfo>,
}
impl MockSessionManager {
@@ -2948,6 +3247,7 @@ impl MockSessionManager {
sessions_created: Arc::new(Mutex::new(HashSet::new())),
first_messages_sent: Arc::new(Mutex::new(Vec::new())),
all_sessions_exist: false,
listed_sessions: Vec::new(),
}
}
@@ -2958,6 +3258,14 @@ impl MockSessionManager {
sessions_created: Arc::new(Mutex::new(HashSet::new())),
first_messages_sent: Arc::new(Mutex::new(Vec::new())),
all_sessions_exist: true,
listed_sessions: Vec::new(),
}
}
pub fn with_listed_sessions(listed_sessions: Vec<WebSessionInfo>) -> Self {
Self {
listed_sessions,
..Self::new()
}
}
@@ -2990,6 +3298,10 @@ impl SessionManager for MockSessionManager {
}
}
fn list_sessions(&self) -> Vec<WebSessionInfo> {
self.listed_sessions.clone()
}
fn get_resurrection_layout(&self, session_name: &str) -> Option<Layout> {
self.mock_layouts.get(session_name).cloned()
}
@@ -1,37 +1,53 @@
use crate::web_client::authentication::SessionTokenHash;
use crate::web_client::control_message::{
SetConfigPayload, TerminalMetricsPayload, WebClientToWebServerControlMessage,
WebClientToWebServerControlMessagePayload, WebServerToWebClientControlMessage,
TerminalMetricsPayload, WebClientToWebServerControlMessage,
WebClientToWebServerControlMessagePayload,
};
use crate::web_client::message_handlers::{
parse_stdin, render_to_client, send_control_messages_to_client, StdinSession,
};
use crate::web_client::server_listener::zellij_server_listener;
use crate::web_client::types::{AppState, TerminalParams};
use crate::web_client::types::{AppState, ControlParams, TerminalParams};
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
Path as AxumPath, Query, State,
},
response::IntoResponse,
http::StatusCode,
response::{IntoResponse, Response},
};
use futures::StreamExt;
use std::sync::{atomic::AtomicBool, Arc};
use tokio_util::sync::CancellationToken;
use zellij_utils::{
data::PaneId,
input::actions::Action,
input::mouse::MouseEvent,
ipc::{ClientToServerMsg, PixelDimensions, ResizeCause},
pane_size::SizeInPixels,
ipc::{ClientToServerMsg, PixelDimensions},
pane_size::{Size, SizeInPixels},
};
pub async fn ws_handler_control(
ws: WebSocketUpgrade,
_path: Option<AxumPath<String>>,
Query(params): Query<ControlParams>,
State(state): State<AppState>,
axum::Extension(session_token_hash): axum::Extension<SessionTokenHash>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_ws_control(socket, state, session_token_hash))
) -> Response {
if !state
.connection_table
.lock()
.unwrap()
.verify_client_ownership(&params.web_client_id, &session_token_hash.0)
{
log::error!(
"Control WebSocket: client does not own web_client_id {}",
params.web_client_id
);
return StatusCode::FORBIDDEN.into_response();
}
ws.on_upgrade(move |socket| handle_ws_control(socket, params, state))
}
pub async fn ws_handler_terminal(
@@ -46,22 +62,19 @@ pub async fn ws_handler_terminal(
})
}
async fn handle_ws_control(
socket: WebSocket,
state: AppState,
session_token_hash: SessionTokenHash,
) {
let payload = SetConfigPayload::from(&*state.config.lock().unwrap());
let set_config_msg = WebServerToWebClientControlMessage::SetConfig(payload);
async fn handle_ws_control(socket: WebSocket, params: ControlParams, state: AppState) {
let web_client_id = params.web_client_id;
let (control_socket_tx, mut control_socket_rx) = socket.split();
let (control_channel_tx, control_channel_rx) = tokio::sync::mpsc::unbounded_channel();
send_control_messages_to_client(control_channel_rx, control_socket_tx);
let _ = control_channel_tx.send(Message::Text(
serde_json::to_string(&set_config_msg).unwrap().into(),
));
state
.connection_table
.lock()
.unwrap()
.add_client_control_tx(&web_client_id, control_channel_tx);
let send_message_to_server = |deserialized_msg: WebClientToWebServerControlMessage| {
let Some(client_connection) = state
@@ -74,41 +87,13 @@ async fn handle_ws_control(
log::error!("Unknown web_client_id: {}", deserialized_msg.web_client_id);
return;
};
let client_msg = match deserialized_msg.payload {
WebClientToWebServerControlMessagePayload::TerminalResize(size) => {
ClientToServerMsg::TerminalResize {
new_size: size,
cause: ResizeCause::Viewport,
}
},
WebClientToWebServerControlMessagePayload::TerminalResizeRendering(size) => {
ClientToServerMsg::TerminalResize {
new_size: size,
cause: ResizeCause::RenderingPreference,
}
},
WebClientToWebServerControlMessagePayload::TerminalSizeSettled(size) => {
ClientToServerMsg::TerminalResize {
new_size: size,
cause: ResizeCause::SizeSettled,
}
},
WebClientToWebServerControlMessagePayload::TerminalMetrics(metrics) => {
terminal_metrics_to_ipc(metrics)
},
WebClientToWebServerControlMessagePayload::SoftKeyboardVisibilityChanged {
visible,
} => ClientToServerMsg::SoftKeyboardVisibilityChanged { visible },
WebClientToWebServerControlMessagePayload::NestedSessionFrameFromHost {
payload_bytes,
} => ClientToServerMsg::NestedSessionFrameFromHost { payload_bytes },
let Some(client_msg) = control_payload_to_server_msg(deserialized_msg.payload) else {
return;
};
let _ = client_connection.send_to_server(client_msg);
};
let mut set_client_control_channel = false;
while let Some(Ok(msg)) = control_socket_rx.next().await {
match msg {
Message::Text(msg) => {
@@ -116,32 +101,13 @@ async fn handle_ws_control(
serde_json::from_str(&msg);
match deserialized_msg {
Ok(deserialized_msg) => {
if !state
.connection_table
.lock()
.unwrap()
.verify_client_ownership(
&deserialized_msg.web_client_id,
&session_token_hash.0,
)
{
if deserialized_msg.web_client_id != web_client_id {
log::error!(
"Client attempted to use web_client_id {} that does not belong to their session",
"Client attempted to use web_client_id {} that does not belong to their connection",
deserialized_msg.web_client_id
);
return;
}
if !set_client_control_channel {
set_client_control_channel = true;
state
.connection_table
.lock()
.unwrap()
.add_client_control_tx(
&deserialized_msg.web_client_id,
control_channel_tx.clone(),
);
}
send_message_to_server(deserialized_msg);
},
Err(e) => {
@@ -166,6 +132,20 @@ async fn handle_ws_terminal(
state: AppState,
session_token_hash: SessionTokenHash,
) {
let client_size = match (params.rows, params.cols) {
(Some(rows), Some(cols)) if rows > 0 && cols > 0 => Some(Size {
rows: rows as usize,
cols: cols as usize,
}),
_ => None,
};
let client_pixel_dims = match (params.cell_width, params.cell_height) {
(Some(width), Some(height)) if width > 0 && height > 0 => Some(SizeInPixels {
width: width as usize,
height: height as usize,
}),
_ => None,
};
let web_client_id = params.web_client_id;
// Verify the session token owns this web_client_id
@@ -213,6 +193,9 @@ async fn handle_ws_terminal(
web_client_id.clone(),
state.session_manager.clone(),
Some(attachment_complete_tx),
client_size,
client_pixel_dims,
state.pending_welcome_sessions.clone(),
);
let terminal_channel_cancellation_token = CancellationToken::new();
@@ -343,6 +326,82 @@ async fn handle_ws_terminal(
os_input.send_to_server(ClientToServerMsg::ClientExited);
}
fn control_payload_to_server_msg(
payload: WebClientToWebServerControlMessagePayload,
) -> Option<ClientToServerMsg> {
let client_msg = match payload {
WebClientToWebServerControlMessagePayload::TerminalResize(size) => {
ClientToServerMsg::TerminalResize { new_size: size }
},
WebClientToWebServerControlMessagePayload::TerminalMetrics(metrics) => {
terminal_metrics_to_ipc(metrics)
},
WebClientToWebServerControlMessagePayload::SoftKeyboardVisibilityChanged { visible } => {
ClientToServerMsg::SoftKeyboardVisibilityChanged { visible }
},
WebClientToWebServerControlMessagePayload::NestedSessionFrameFromHost { payload_bytes } => {
ClientToServerMsg::NestedSessionFrameFromHost { payload_bytes }
},
WebClientToWebServerControlMessagePayload::RequestSessionList => {
ClientToServerMsg::RequestSessionList
},
WebClientToWebServerControlMessagePayload::FocusPane { pane_id, is_plugin } => {
let pane_id = if is_plugin {
PaneId::Plugin(pane_id)
} else {
PaneId::Terminal(pane_id)
};
ClientToServerMsg::Action {
action: Action::FocusPaneByPaneId { pane_id },
terminal_id: None,
client_id: None,
is_cli_client: false,
}
},
WebClientToWebServerControlMessagePayload::NewPaneInTab { .. } => {
ClientToServerMsg::Action {
action: Action::NewTiledPane {
direction: None,
command: None,
pane_name: None,
near_current_pane: false,
no_focus: false,
borderless: None,
tab_id: None,
},
terminal_id: None,
client_id: None,
is_cli_client: false,
}
},
WebClientToWebServerControlMessagePayload::NewTab => ClientToServerMsg::Action {
action: Action::NewTab {
tiled_layout: None,
floating_layouts: vec![],
swap_tiled_layouts: None,
swap_floating_layouts: None,
tab_name: None,
should_change_focus_to_new_tab: true,
cwd: None,
initial_panes: None,
first_pane_unblock_condition: None,
},
terminal_id: None,
client_id: None,
is_cli_client: false,
},
WebClientToWebServerControlMessagePayload::SetMobileRenderPreferences {
single_pane,
fit,
} => ClientToServerMsg::SetMobileRenderPreferences { single_pane, fit },
WebClientToWebServerControlMessagePayload::Unknown => {
log::warn!("Ignoring unknown control message type from web client");
return None;
},
};
Some(client_msg)
}
fn terminal_metrics_to_ipc(metrics: TerminalMetricsPayload) -> ClientToServerMsg {
ClientToServerMsg::TerminalPixelDimensions {
pixel_dimensions: PixelDimensions {
@@ -438,4 +497,115 @@ mod tests {
other => panic!("expected TerminalResize, got {:?}", other),
}
}
#[test]
fn focus_pane_payload_deserializes() {
let raw = serde_json::json!({
"web_client_id": "abc",
"payload": {
"type": "FocusPane",
"pane_id": 7,
"is_plugin": true,
}
});
let parsed: WebClientToWebServerControlMessage =
serde_json::from_value(raw).expect("parse");
match parsed.payload {
WebClientToWebServerControlMessagePayload::FocusPane { pane_id, is_plugin } => {
assert_eq!(pane_id, 7);
assert!(is_plugin);
},
other => panic!("expected FocusPane, got {:?}", other),
}
}
#[test]
fn new_pane_in_tab_payload_deserializes() {
let raw = serde_json::json!({
"web_client_id": "abc",
"payload": {
"type": "NewPaneInTab",
"tab_id": 2,
}
});
let parsed: WebClientToWebServerControlMessage =
serde_json::from_value(raw).expect("parse");
match parsed.payload {
WebClientToWebServerControlMessagePayload::NewPaneInTab { tab_id } => {
assert_eq!(tab_id, 2);
},
other => panic!("expected NewPaneInTab, got {:?}", other),
}
}
#[test]
fn new_tab_payload_deserializes() {
let raw = serde_json::json!({
"web_client_id": "abc",
"payload": { "type": "NewTab" }
});
let parsed: WebClientToWebServerControlMessage =
serde_json::from_value(raw).expect("parse");
assert!(matches!(
parsed.payload,
WebClientToWebServerControlMessagePayload::NewTab
));
}
#[test]
fn set_mobile_render_preferences_payload_deserializes() {
let raw = serde_json::json!({
"web_client_id": "abc",
"payload": {
"type": "SetMobileRenderPreferences",
"single_pane": false,
"fit": true,
}
});
let parsed: WebClientToWebServerControlMessage =
serde_json::from_value(raw).expect("parse");
match parsed.payload {
WebClientToWebServerControlMessagePayload::SetMobileRenderPreferences {
single_pane,
fit,
} => {
assert!(!single_pane);
assert!(fit);
},
other => panic!("expected SetMobileRenderPreferences, got {:?}", other),
}
}
#[test]
fn new_pane_in_tab_is_routed_to_the_requesting_client() {
let client_msg = control_payload_to_server_msg(
WebClientToWebServerControlMessagePayload::NewPaneInTab { tab_id: 2 },
)
.expect("message dropped");
match client_msg {
ClientToServerMsg::Action {
action:
Action::NewTiledPane {
tab_id, no_focus, ..
},
..
} => {
assert_eq!(
tab_id, None,
"The pane is opened in the client's own tab so that it is focused for it, \
keeping single-pane mode attached to the new pane"
);
assert!(!no_focus, "The new pane takes focus");
},
other => panic!("expected a NewTiledPane action, got {:?}", other),
}
}
#[test]
fn unknown_control_message_is_dropped() {
assert!(
control_payload_to_server_msg(WebClientToWebServerControlMessagePayload::Unknown)
.is_none()
);
}
}
@@ -15,6 +15,31 @@ use crate::client_screen::ClientScreen;
type ServerSpawner = Box<dyn FnOnce(PathBuf) + Send>;
fn server_message_name(msg: &ServerToClientMsg) -> String {
match msg {
ServerToClientMsg::Render { .. } => "Render",
ServerToClientMsg::UnblockInputThread => "UnblockInputThread",
ServerToClientMsg::Exit { .. } => "Exit",
ServerToClientMsg::Connected => "Connected",
ServerToClientMsg::Log { .. } => "Log",
ServerToClientMsg::LogError { .. } => "LogError",
ServerToClientMsg::SwitchSession { .. } => "SwitchSession",
ServerToClientMsg::UnblockCliPipeInput { .. } => "UnblockCliPipeInput",
ServerToClientMsg::CliPipeOutput { .. } => "CliPipeOutput",
ServerToClientMsg::QueryTerminalSize => "QueryTerminalSize",
ServerToClientMsg::SetSoftKeyboard { .. } => "SetSoftKeyboard",
ServerToClientMsg::StartWebServer => "StartWebServer",
ServerToClientMsg::RenamedSession { .. } => "RenamedSession",
ServerToClientMsg::ConfigFileUpdated => "ConfigFileUpdated",
ServerToClientMsg::PaneRenderUpdate { .. } => "PaneRenderUpdate",
ServerToClientMsg::SubscribedPaneClosed { .. } => "SubscribedPaneClosed",
ServerToClientMsg::ForwardQueryToHost { .. } => "ForwardQueryToHost",
ServerToClientMsg::MobileState { .. } => "MobileState",
ServerToClientMsg::EmitNestedSessionFrame { .. } => "EmitNestedSessionFrame",
}
.to_string()
}
pub struct FakeClientOsApi {
client_screen: ClientScreen,
size: Arc<Mutex<Size>>,
@@ -25,6 +50,7 @@ pub struct FakeClientOsApi {
session_name: Arc<Mutex<Option<String>>>,
server_spawner: Arc<Mutex<Option<ServerSpawner>>>,
env: Arc<Mutex<HashMap<String, String>>>,
received_server_messages: Arc<Mutex<Vec<String>>>,
}
impl Clone for FakeClientOsApi {
@@ -39,6 +65,7 @@ impl Clone for FakeClientOsApi {
session_name: self.session_name.clone(),
server_spawner: self.server_spawner.clone(),
env: self.env.clone(),
received_server_messages: self.received_server_messages.clone(),
}
}
}
@@ -54,6 +81,13 @@ pub struct FakeClientHandle {
pub size: Arc<Mutex<Size>>,
pub stdin_tx: crossbeam::channel::Sender<Vec<u8>>,
pub signal_tx: crossbeam::channel::Sender<SignalEvent>,
pub received_server_messages: Arc<Mutex<Vec<String>>>,
}
impl FakeClientHandle {
pub fn received_server_messages(&self) -> Vec<String> {
self.received_server_messages.lock().unwrap().clone()
}
}
impl FakeClientOsApi {
@@ -73,6 +107,7 @@ impl FakeClientOsApi {
let client_screen = ClientScreen::new(size.clone());
let (stdin_tx, stdin_rx) = crossbeam::channel::unbounded();
let (signal_tx, signal_rx) = crossbeam::channel::unbounded();
let received_server_messages = Arc::new(Mutex::new(Vec::new()));
let fake_client_os_api = FakeClientOsApi {
client_screen: client_screen.clone(),
size: size.clone(),
@@ -83,12 +118,14 @@ impl FakeClientOsApi {
session_name: Arc::new(Mutex::new(None)),
server_spawner: Arc::new(Mutex::new(server_spawner)),
env: Arc::new(Mutex::new(env)),
received_server_messages: received_server_messages.clone(),
};
let fake_client_handle = FakeClientHandle {
client_screen,
size,
stdin_tx,
signal_tx,
received_server_messages,
};
(fake_client_os_api, fake_client_handle)
}
@@ -128,12 +165,20 @@ impl ClientOsApi for FakeClientOsApi {
}
}
fn recv_from_server(&self) -> Option<(ServerToClientMsg, ErrorContext)> {
self.receive_instructions_from_server
let received = self
.receive_instructions_from_server
.lock()
.unwrap()
.as_mut()
.unwrap()
.recv_server_msg()
.recv_server_msg();
if let Some((msg, _)) = received.as_ref() {
self.received_server_messages
.lock()
.unwrap()
.push(server_message_name(msg));
}
received
}
fn handle_signals(
&self,
+11 -1
View File
@@ -19,6 +19,8 @@ use crate::fake_pty::FakePtyHandle;
use crate::fake_server_os_api::FakeServerOsApi;
use crate::{keys, test_env};
const GUEST_MODAL_TITLE: &str = "Nested Zellij session detected";
pub struct TestRunner {
size: Size,
extra_config_kdl: String,
@@ -275,6 +277,10 @@ impl TestClient {
self.fake_client_handle.client_screen.raw_bytes()
}
pub fn received_server_messages(&self) -> Vec<String> {
self.fake_client_handle.received_server_messages()
}
pub fn quit(mut self) {
self.send_stdin(&keys::ctrl('q'));
self.join();
@@ -401,11 +407,15 @@ impl TestSession {
self.main_client.raw_bytes()
}
pub fn received_server_messages(&self) -> Vec<String> {
self.main_client.received_server_messages()
}
pub fn wait_for_app_load(&self) -> GridSnapshot {
self.main_client.wait_until("app to load", |grid_snapshot| {
(grid_snapshot.status_bar_appears() || grid_snapshot.contains("Descend:"))
&& grid_snapshot.tab_bar_appears()
&& grid_snapshot.cursor.is_some()
&& (grid_snapshot.cursor.is_some() || grid_snapshot.contains(GUEST_MODAL_TITLE))
})
}
@@ -0,0 +1,63 @@
#![cfg(unix)]
use zellij_integration_tests::{
claim_first_terminal_and_wait_for_prompt, start_zellij, FakePtyHandle, Size, TERMINAL_SIZE,
};
const ATTACHING_CLIENT_SIZE: Size = Size { cols: 80, rows: 30 };
fn pane_size(terminal: &FakePtyHandle, what: &str) -> (u16, u16) {
terminal.wait_for_size(what, |_, _| true)
}
#[test]
fn cold_attach_lays_out_at_the_client_reported_size_without_querying_it() {
let mut zellij = start_zellij();
let terminal = claim_first_terminal_and_wait_for_prompt(&zellij);
let (initial_cols, initial_rows) = pane_size(&terminal, "initial pane size");
let attaching_client = zellij.attach_client(ATTACHING_CLIENT_SIZE);
attaching_client.wait_until("attaching client loaded", |grid_snapshot| {
grid_snapshot.tab_bar_appears()
&& grid_snapshot.contains("Ctrl +")
&& grid_snapshot.cursor.is_some()
});
let (attached_cols, attached_rows) = terminal.wait_for_size(
"pane resized to the attaching client's size",
|cols, rows| (cols, rows) != (initial_cols, initial_rows),
);
let chrome_rows = TERMINAL_SIZE.rows as u16 - initial_rows;
let expected_rows = ATTACHING_CLIENT_SIZE.rows as u16 - chrome_rows;
assert_eq!(
attached_cols, ATTACHING_CLIENT_SIZE.cols as u16,
"layout must be applied at the size the attaching client reported"
);
assert_eq!(
attached_rows, expected_rows,
"layout must be applied at the size the attaching client reported"
);
let attaching_client_messages = attaching_client.received_server_messages();
assert!(
!attaching_client_messages
.iter()
.any(|name| name == "QueryTerminalSize"),
"a cold attach must not be asked for its terminal size, got: {:?}",
attaching_client_messages
);
let main_client_messages = zellij.received_server_messages();
assert!(
!main_client_messages
.iter()
.any(|name| name == "QueryTerminalSize"),
"an existing client must not be asked for its terminal size when a peer attaches, got: {:?}",
main_client_messages
);
attaching_client.quit();
zellij.quit();
}
@@ -150,6 +150,7 @@ fn a_layout_stack_renders_as_a_title_list() {
&& grid_snapshot.contains(&selected_entry("Pane #1"))
&& grid_snapshot.contains("Pane #2")
&& grid_snapshot.contains("Pane #3")
&& grid_snapshot.cursor_is_at(col(2).row(5))
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
@@ -445,6 +446,7 @@ fn entries_share_a_uniform_width_sized_by_the_widest_title() {
grid_snapshot.status_bar_appears()
&& grid_snapshot.contains("member-command")
&& grid_snapshot.contains(&selected_entry("Pane #1"))
&& grid_snapshot.cursor_is_at(col(2).row(4))
});
let lines = grid_snapshot.lines();
@@ -669,6 +671,7 @@ fn splitting_a_new_pane_reforms_the_list_around_it() {
&& grid_snapshot.contains("Pane #2")
&& grid_snapshot.contains(&selected_entry("Pane #3"))
&& grid_snapshot.contains("Pane #4")
&& grid_snapshot.cursor_is_at(col(2).row(13))
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
@@ -760,6 +763,7 @@ fn a_stack_list_survives_session_resurrection() {
&& grid_snapshot.contains("Pane #1")
&& grid_snapshot.contains("Pane #2")
&& grid_snapshot.contains(&selected_entry("Pane #3"))
&& grid_snapshot.cursor_is_at(col(2).row(5))
});
assert_snapshot!(normalized(&grid_snapshot));
zellij.quit();
+5 -61
View File
@@ -29,7 +29,6 @@ use std::sync::{
Arc, Mutex,
};
use std::time::{Duration, Instant};
use zellij_utils::consts::is_ipc_socket;
use crate::panes::PaneId;
use crate::plugins::{PluginId, PluginInstruction};
@@ -74,7 +73,6 @@ pub enum BackgroundJob {
StopFlashPaneBell(Vec<PaneId>),
FlashTabBell(usize), // usize = tab_id
StopFlashTabBell(usize), // usize = tab_id
MobileGateTimeout(ClientId),
StartNestedGuestPing(PaneId),
StopNestedGuestPing(PaneId),
Exit,
@@ -105,7 +103,6 @@ impl From<&BackgroundJob> for BackgroundJobContext {
BackgroundJob::StopFlashPaneBell(..) => BackgroundJobContext::StopFlashPaneBell,
BackgroundJob::FlashTabBell(..) => BackgroundJobContext::FlashTabBell,
BackgroundJob::StopFlashTabBell(..) => BackgroundJobContext::StopFlashTabBell,
BackgroundJob::MobileGateTimeout(..) => BackgroundJobContext::MobileGateTimeout,
BackgroundJob::StartNestedGuestPing(..) => BackgroundJobContext::StartNestedGuestPing,
BackgroundJob::StopNestedGuestPing(..) => BackgroundJobContext::StopNestedGuestPing,
BackgroundJob::Exit => BackgroundJobContext::Exit,
@@ -114,7 +111,6 @@ impl From<&BackgroundJob> for BackgroundJobContext {
}
static LONG_FLASH_DURATION_MS: u64 = 1000;
static MOBILE_GATE_FALLBACK_LIFT_TIMEOUT_MS: u64 = 5000;
static FLASH_DURATION_MS: u64 = 400; // Doherty threshold
static PLUGIN_ANIMATION_OFFSET_DURATION_MD: u64 = 500;
static SESSION_METADATA_WRITE_INTERVAL_MS: u64 = 1000;
@@ -653,19 +649,6 @@ pub(crate) fn background_jobs_main(
flag.store(false, Ordering::SeqCst);
}
},
BackgroundJob::MobileGateTimeout(client_id) => {
runtime.spawn({
let senders = bus.senders.clone();
async move {
tokio::time::sleep(Duration::from_millis(
MOBILE_GATE_FALLBACK_LIFT_TIMEOUT_MS,
))
.await;
let _ =
senders.send_to_screen(ScreenInstruction::ForceMobileUngate(client_id));
}
});
},
BackgroundJob::Exit => {
for loading_plugin in loading_plugins.values() {
loading_plugin.store(false, Ordering::SeqCst);
@@ -758,8 +741,11 @@ pub fn scan_session_list(
sock_dir: &Path,
session_info_cache_dir: &Path,
) -> (BTreeMap<String, SessionInfo>, BTreeMap<String, Duration>) {
let mut session_infos_on_machine =
read_other_live_session_states(current_session_name, sock_dir, session_info_cache_dir);
let mut session_infos_on_machine = zellij_utils::sessions::read_live_session_states(
current_session_name,
sock_dir,
session_info_cache_dir,
);
for (name, info) in session_infos_on_machine.iter_mut() {
if name == current_session_name {
info.populate_plugin_list(current_session_plugin_list.clone());
@@ -785,48 +771,6 @@ pub fn scan_session_list_default_dirs(
)
}
fn read_other_live_session_states(
current_session_name: &str,
sock_dir: &Path,
session_info_cache_dir: &Path,
) -> BTreeMap<String, SessionInfo> {
let mut other_session_names: Vec<(String, Duration)> = vec![];
let mut session_infos_on_machine = BTreeMap::new();
// we do this so that the session infos will be actual and we're
// reasonably sure their session is running
if let Ok(files) = fs::read_dir(sock_dir) {
files.for_each(|file| {
if let Ok(file) = file {
if let Ok(file_name) = file.file_name().into_string() {
if is_ipc_socket(&file.file_type().unwrap()) {
let creation_time = std::fs::metadata(&file.path())
.ok()
.and_then(|f| f.created().ok().or_else(|| f.modified().ok()))
.and_then(|d| d.elapsed().ok())
.unwrap_or_default();
other_session_names.push((file_name, creation_time));
}
}
}
});
}
for (session_name, creation_time) in other_session_names {
let session_cache_file_name = session_info_cache_dir
.join(&session_name)
.join("session-metadata.kdl");
if let Ok(raw_session_info) = fs::read_to_string(&session_cache_file_name) {
if let Ok(mut session_info) =
SessionInfo::from_string(&raw_session_info, &current_session_name)
{
session_info.creation_time = creation_time;
session_infos_on_machine.insert(session_name, session_info);
}
}
}
session_infos_on_machine
}
fn find_resurrectable_sessions(
session_infos_on_machine: &BTreeMap<String, SessionInfo>,
session_info_cache_dir: &Path,
+1 -62
View File
@@ -14,7 +14,7 @@ pub mod tab;
pub mod background_jobs;
mod global_async_runtime;
mod logging_pipe;
mod mobile_mode;
mod mobile_web;
pub mod nested_guest;
mod pane_groups;
mod plugins;
@@ -1036,22 +1036,6 @@ pub fn start_server_impl(
is_web_client,
);
let should_enter_mobile = should_enter_mobile_on_connect(
&runtime_config_options,
is_web_client,
client_attributes.size,
);
if should_enter_mobile {
session_data
.read()
.unwrap()
.as_ref()
.unwrap()
.senders
.send_to_screen(ScreenInstruction::SuppressRenderUntilMobile(client_id))
.unwrap();
}
let default_shell = runtime_config_options.default_shell.map(|shell| {
TerminalAction::RunCommand(RunCommand {
command: shell,
@@ -1148,17 +1132,6 @@ pub fn start_server_impl(
.senders
.send_to_plugin(PluginInstruction::AddClient(client_id))
.unwrap();
if should_enter_mobile {
session_data
.read()
.unwrap()
.as_ref()
.unwrap()
.senders
.send_to_screen(ScreenInstruction::EnterMobileMode(client_id, None))
.unwrap();
}
},
ServerInstruction::AttachClient(
cli_assets,
@@ -1203,18 +1176,6 @@ pub fn start_server_impl(
is_web_client,
);
let should_enter_mobile = should_enter_mobile_on_connect(
&runtime_config_options,
is_web_client,
client_attributes.size,
);
if should_enter_mobile {
session_data
.senders
.send_to_screen(ScreenInstruction::SuppressRenderUntilMobile(client_id))
.unwrap();
}
session_data
.senders
.send_to_screen(ScreenInstruction::AddClient(
@@ -1241,13 +1202,6 @@ pub fn start_server_impl(
None,
))
.unwrap();
if should_enter_mobile {
session_data
.senders
.send_to_screen(ScreenInstruction::EnterMobileMode(client_id, None))
.unwrap();
}
},
ServerInstruction::AttachWatcherClient(client_id, terminal_size, is_web_client) => {
// the client_id was inserted into clients upon ipc tunnel initialization
@@ -2394,21 +2348,6 @@ fn should_show_startup_tip(
}
}
fn should_enter_mobile_on_connect(options: &Options, is_web_client: bool, viewport: Size) -> bool {
let mobile_layout = options.mobile_layout.unwrap_or_default();
if is_web_client {
mobile_layout.may_route_web_client_to_mobile()
} else {
mobile_layout.should_route_to_mobile(
is_web_client,
viewport.cols,
viewport.rows,
options.mobile_threshold_cols.unwrap_or(60),
options.mobile_threshold_rows.unwrap_or(30),
)
}
}
fn report_changes_in_config_file(
config_file_path: PathBuf,
config_dir: Option<&Path>,
-733
View File
@@ -1,733 +0,0 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use zellij_utils::errors::prelude::*;
use zellij_utils::input::layout::{Run, RunPluginOrAlias, TiledPaneLayout};
use zellij_utils::pane_size::Size;
use crate::panes::PaneId;
use crate::tab::Tab;
use crate::ClientId;
pub(crate) const FIT_RESIZE_MAX_ITERS: usize = 3;
const MOBILE_PLUGIN_URL: &str = "zellij:mobile";
#[derive(Debug, Default)]
pub(crate) struct MobileState {
mobile_tab_for_client: HashMap<ClientId, usize>,
tab_before_mobile_for_client: HashMap<ClientId, usize>,
auto_entered_clients: HashSet<ClientId>,
fit_override_for_tab: HashMap<usize, FitOverride>,
}
#[derive(Debug, Clone, Copy)]
struct FitOverride {
owning_client: ClientId,
fullscreened_pane: PaneId,
embedded_content_size: Size,
pane_was_fullscreen_before_fit: bool,
}
const CLEAR_SCREEN: &str = "\u{1b}[2J\u{1b}[H";
#[derive(Debug, Default)]
pub(crate) struct MobileRenderGate {
awaiting_first_render: HashSet<ClientId>,
clients_with_settled_size: HashSet<ClientId>,
last_paint_size: HashMap<ClientId, Size>,
}
impl MobileRenderGate {
pub(crate) fn gate(&mut self, client_id: ClientId) {
self.awaiting_first_render.insert(client_id);
}
pub(crate) fn is_gated(&self, client_id: ClientId) -> bool {
self.awaiting_first_render.contains(&client_id)
}
pub(crate) fn is_empty(&self) -> bool {
self.awaiting_first_render.is_empty()
}
pub(crate) fn gated_clients(&self) -> Vec<ClientId> {
self.awaiting_first_render.iter().copied().collect()
}
pub(crate) fn ungate(&mut self, client_id: ClientId) {
self.awaiting_first_render.remove(&client_id);
self.clients_with_settled_size.remove(&client_id);
self.last_paint_size.remove(&client_id);
}
pub(crate) fn record_settled_size(&mut self, client_id: ClientId) {
self.clients_with_settled_size.insert(client_id);
}
pub(crate) fn record_paint_size(&mut self, client_id: ClientId, size: Size) {
self.last_paint_size.insert(client_id, size);
}
pub(crate) fn try_reveal(
&mut self,
client_id: ClientId,
is_web_client: bool,
reported_size: Option<Size>,
) -> bool {
if !self.is_gated(client_id) {
return false;
}
let paint_size = self.last_paint_size.get(&client_id).copied();
let size_settled = self.clients_with_settled_size.contains(&client_id);
let ready_to_reveal = match paint_size {
None => false,
Some(paint_size) if is_web_client => size_settled && reported_size == Some(paint_size),
Some(_) => true,
};
if ready_to_reveal {
self.ungate(client_id);
}
ready_to_reveal
}
pub(crate) fn blank_gated_clients(&self, serialized_output: &mut HashMap<ClientId, String>) {
for (client_id, bytes) in serialized_output.iter_mut() {
if self.is_gated(*client_id) {
*bytes = CLEAR_SCREEN.to_string();
}
}
}
}
impl MobileState {
pub(crate) fn is_in_mobile_mode(&self, client_id: ClientId) -> bool {
self.mobile_tab_for_client.contains_key(&client_id)
}
pub(crate) fn mobile_tab_id(&self, client_id: ClientId) -> Option<usize> {
self.mobile_tab_for_client.get(&client_id).copied()
}
pub(crate) fn mobile_tab_ids(&self) -> HashSet<usize> {
self.mobile_tab_for_client.values().copied().collect()
}
pub(crate) fn mobile_tab_count(&self) -> usize {
self.mobile_tab_for_client.len()
}
pub(crate) fn set_previous_tab(&mut self, client_id: ClientId, previous_tab: Option<usize>) {
if let Some(previous_tab) = previous_tab {
self.tab_before_mobile_for_client
.insert(client_id, previous_tab);
}
}
pub(crate) fn register_tab(&mut self, client_id: ClientId, mobile_tab_id: usize) {
self.mobile_tab_for_client.insert(client_id, mobile_tab_id);
}
pub(crate) fn begin_exit(&mut self, client_id: ClientId) -> Option<(usize, Option<usize>)> {
let mobile_tab_id = self.mobile_tab_for_client.remove(&client_id)?;
self.auto_entered_clients.remove(&client_id);
let previous_tab = self.tab_before_mobile_for_client.remove(&client_id);
Some((mobile_tab_id, previous_tab))
}
pub(crate) fn forget_client(&mut self, client_id: ClientId) {
self.mobile_tab_for_client.remove(&client_id);
self.tab_before_mobile_for_client.remove(&client_id);
self.auto_entered_clients.remove(&client_id);
}
pub(crate) fn was_auto_entered(&self, client_id: ClientId) -> bool {
self.auto_entered_clients.contains(&client_id)
}
pub(crate) fn mark_auto_entered(&mut self, client_id: ClientId) {
self.auto_entered_clients.insert(client_id);
}
pub(crate) fn mobile_tab_layout() -> Result<TiledPaneLayout> {
let mut mobile_tab_layout = TiledPaneLayout::default();
let mobile_plugin = RunPluginOrAlias::from_url(MOBILE_PLUGIN_URL, &None, None, None)
.map_err(|e| anyhow!("invalid mobile plugin url: {e}"))?;
mobile_tab_layout.run = Some(Run::Plugin(mobile_plugin));
mobile_tab_layout.borderless = Some(true);
Ok(mobile_tab_layout)
}
pub(crate) fn clear_shadow_focus(&self, client_id: ClientId, tabs: &mut BTreeMap<usize, Tab>) {
let mobile_tab_id = self.mobile_tab_id(client_id);
for tab in tabs.values_mut() {
if Some(tab.id) == mobile_tab_id {
continue;
}
tab.clear_shadow_focus(client_id);
}
}
pub(crate) fn apply_shadow_focus(
&self,
client_id: ClientId,
pane_id: PaneId,
tabs: &mut BTreeMap<usize, Tab>,
) -> ShadowFocusOutcome {
let mobile_tab_id = self.mobile_tab_id(client_id);
for tab in tabs.values() {
if Some(tab.id) == mobile_tab_id {
continue;
}
if tab.has_shadow_focus_on(client_id, pane_id) {
return ShadowFocusOutcome::AlreadyApplied;
}
}
self.clear_shadow_focus(client_id, tabs);
for tab in tabs.values_mut() {
if Some(tab.id) == mobile_tab_id {
continue;
}
if tab.set_shadow_focus(client_id, pane_id) {
break;
}
}
ShadowFocusOutcome::NewlyApplied
}
pub(crate) fn has_fit(&self, tab_id: usize) -> bool {
self.fit_override_for_tab.contains_key(&tab_id)
}
pub(crate) fn compute_fit_size(
&self,
tab_id: usize,
tabs: &BTreeMap<usize, Tab>,
) -> Option<Size> {
let fit = self.fit_override_for_tab.get(&tab_id)?;
let target_tab = tabs.get(&tab_id)?;
let viewport = target_tab.get_viewport();
let display_area = target_tab.get_display_area();
let tab_bar_rows = display_area.rows.saturating_sub(viewport.rows);
let tab_bar_cols = display_area.cols.saturating_sub(viewport.cols);
let (frame_rows, frame_cols) = target_tab
.get_pane_with_id(fit.fullscreened_pane)
.map(|pane| {
(
pane.rows().saturating_sub(pane.get_content_rows()),
pane.cols().saturating_sub(pane.get_content_columns()),
)
})
.unwrap_or((0, 0));
Some(Size {
rows: fit.embedded_content_size.rows + tab_bar_rows + frame_rows,
cols: fit.embedded_content_size.cols + tab_bar_cols + frame_cols,
})
}
pub(crate) fn set_fit(
&mut self,
client_id: ClientId,
tab_id: usize,
pane_id: PaneId,
embedded_content_size: Size,
tabs: &mut BTreeMap<usize, Tab>,
) {
if let Some(existing_fit) = self.fit_override_for_tab.get_mut(&tab_id) {
existing_fit.embedded_content_size = embedded_content_size;
existing_fit.owning_client = client_id;
existing_fit.fullscreened_pane = pane_id;
return;
}
let pane_was_fullscreen_before_fit = tabs
.get(&tab_id)
.map(|tab| tab.is_fullscreen_active() && tab.fullscreen_pane_id() == Some(pane_id))
.unwrap_or(false);
if !pane_was_fullscreen_before_fit {
Self::toggle_fullscreen(tabs, tab_id, pane_id);
}
self.fit_override_for_tab.insert(
tab_id,
FitOverride {
owning_client: client_id,
fullscreened_pane: pane_id,
embedded_content_size,
pane_was_fullscreen_before_fit,
},
);
}
pub(crate) fn clear_fit_owned_by(
&mut self,
client_id: ClientId,
tabs: &mut BTreeMap<usize, Tab>,
) -> Option<usize> {
let tab_id = self
.fit_override_for_tab
.iter()
.find(|(_, fit)| fit.owning_client == client_id)
.map(|(&tab_id, _)| tab_id)?;
let fit = self.fit_override_for_tab.remove(&tab_id)?;
if !fit.pane_was_fullscreen_before_fit {
Self::toggle_fullscreen(tabs, tab_id, fit.fullscreened_pane);
}
Some(tab_id)
}
pub(crate) fn clear_fit_for_pane(&mut self, pane_id: PaneId) -> Option<usize> {
let tab_id = self
.fit_override_for_tab
.iter()
.find(|(_, fit)| fit.fullscreened_pane == pane_id)
.map(|(&tab_id, _)| tab_id)?;
self.fit_override_for_tab.remove(&tab_id);
Some(tab_id)
}
pub(crate) fn remove_fit_for_tab(&mut self, tab_id: usize) {
self.fit_override_for_tab.remove(&tab_id);
}
pub(crate) fn clear_fits_owned_by(
&mut self,
client_id: ClientId,
tabs: &mut BTreeMap<usize, Tab>,
) -> Vec<usize> {
let owned_fits: Vec<(usize, FitOverride)> = self
.fit_override_for_tab
.iter()
.filter(|(_, fit)| fit.owning_client == client_id)
.map(|(&tab_id, fit)| (tab_id, *fit))
.collect();
let mut tabs_to_recompute = Vec::with_capacity(owned_fits.len());
for (tab_id, fit) in owned_fits {
self.fit_override_for_tab.remove(&tab_id);
if !fit.pane_was_fullscreen_before_fit {
Self::toggle_fullscreen(tabs, tab_id, fit.fullscreened_pane);
}
tabs_to_recompute.push(tab_id);
}
tabs_to_recompute
}
fn toggle_fullscreen(tabs: &mut BTreeMap<usize, Tab>, tab_id: usize, pane_id: PaneId) {
if let Some(tab) = tabs.get_mut(&tab_id) {
if tab.has_pane_with_pid(&pane_id) {
tab.toggle_pane_fullscreen(pane_id);
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ShadowFocusOutcome {
NewlyApplied,
AlreadyApplied,
}
#[cfg(test)]
impl MobileState {
pub(crate) fn previous_tab(&self, client_id: ClientId) -> Option<usize> {
self.tab_before_mobile_for_client.get(&client_id).copied()
}
pub(crate) fn fit_count(&self) -> usize {
self.fit_override_for_tab.len()
}
pub(crate) fn fit_owner(&self, tab_id: usize) -> Option<ClientId> {
self.fit_override_for_tab
.get(&tab_id)
.map(|fit| fit.owning_client)
}
pub(crate) fn fit_pane(&self, tab_id: usize) -> Option<PaneId> {
self.fit_override_for_tab
.get(&tab_id)
.map(|fit| fit.fullscreened_pane)
}
pub(crate) fn fit_embedded_size(&self, tab_id: usize) -> Option<Size> {
self.fit_override_for_tab
.get(&tab_id)
.map(|fit| fit.embedded_content_size)
}
pub(crate) fn fit_pane_was_fullscreen_before(&self, tab_id: usize) -> Option<bool> {
self.fit_override_for_tab
.get(&tab_id)
.map(|fit| fit.pane_was_fullscreen_before_fit)
}
}
#[cfg(test)]
mod tests {
use super::*;
const CLIENT_A: ClientId = 1;
const CLIENT_B: ClientId = 2;
fn embedded_size() -> Size {
Size { rows: 24, cols: 80 }
}
fn no_tabs() -> BTreeMap<usize, Tab> {
BTreeMap::new()
}
fn settled_size() -> Size {
Size { rows: 25, cols: 31 }
}
#[test]
fn try_reveal_is_false_for_an_ungated_client() {
let mut gate = MobileRenderGate::default();
assert!(!gate.try_reveal(CLIENT_A, true, Some(settled_size())));
}
#[test]
fn web_client_is_not_revealed_before_size_settles() {
let mut gate = MobileRenderGate::default();
gate.gate(CLIENT_A);
gate.record_paint_size(CLIENT_A, settled_size());
// Paint arrived but no settled size has been reported yet.
assert!(!gate.try_reveal(CLIENT_A, true, Some(settled_size())));
assert!(gate.is_gated(CLIENT_A));
}
#[test]
fn web_client_is_not_revealed_without_a_paint() {
let mut gate = MobileRenderGate::default();
gate.gate(CLIENT_A);
gate.record_settled_size(CLIENT_A);
assert!(!gate.try_reveal(CLIENT_A, true, Some(settled_size())));
assert!(gate.is_gated(CLIENT_A));
}
#[test]
fn web_client_is_not_revealed_when_reported_size_differs_from_paint() {
let mut gate = MobileRenderGate::default();
gate.gate(CLIENT_A);
gate.record_settled_size(CLIENT_A);
gate.record_paint_size(CLIENT_A, settled_size());
let stale = Size { rows: 38, cols: 46 };
assert!(!gate.try_reveal(CLIENT_A, true, Some(stale)));
assert!(gate.is_gated(CLIENT_A));
}
#[test]
fn web_client_is_revealed_on_settled_matching_paint() {
let mut gate = MobileRenderGate::default();
gate.gate(CLIENT_A);
gate.record_settled_size(CLIENT_A);
gate.record_paint_size(CLIENT_A, settled_size());
assert!(gate.try_reveal(CLIENT_A, true, Some(settled_size())));
assert!(!gate.is_gated(CLIENT_A));
}
#[test]
fn non_web_client_is_revealed_on_any_paint() {
let mut gate = MobileRenderGate::default();
gate.gate(CLIENT_A);
gate.record_paint_size(CLIENT_A, embedded_size());
// No settled size, mismatched reported size — still revealed for non-web.
assert!(gate.try_reveal(CLIENT_A, false, None));
assert!(!gate.is_gated(CLIENT_A));
}
#[test]
fn blank_gated_clients_replaces_only_gated_output() {
let mut gate = MobileRenderGate::default();
gate.gate(CLIENT_A);
let mut output = HashMap::new();
output.insert(CLIENT_A, "real-a".to_string());
output.insert(CLIENT_B, "real-b".to_string());
gate.blank_gated_clients(&mut output);
assert_eq!(output.get(&CLIENT_A).unwrap(), CLEAR_SCREEN);
assert_eq!(output.get(&CLIENT_B).unwrap(), "real-b");
}
#[test]
fn ungate_clears_settled_and_paint_state() {
let mut gate = MobileRenderGate::default();
gate.gate(CLIENT_A);
gate.record_settled_size(CLIENT_A);
gate.record_paint_size(CLIENT_A, settled_size());
gate.ungate(CLIENT_A);
assert!(!gate.is_gated(CLIENT_A));
// A fresh gate must not inherit the prior settled/paint state.
gate.gate(CLIENT_A);
assert!(!gate.try_reveal(CLIENT_A, true, Some(settled_size())));
}
#[test]
fn client_is_not_in_mobile_mode_by_default() {
let state = MobileState::default();
assert!(!state.is_in_mobile_mode(CLIENT_A));
assert_eq!(state.mobile_tab_count(), 0);
assert!(state.mobile_tab_ids().is_empty());
}
#[test]
fn registering_a_tab_puts_the_client_in_mobile_mode() {
let mut state = MobileState::default();
state.register_tab(CLIENT_A, 7);
assert!(state.is_in_mobile_mode(CLIENT_A));
assert_eq!(state.mobile_tab_count(), 1);
assert!(state.mobile_tab_ids().contains(&7));
}
#[test]
fn mobile_tab_ids_collects_every_clients_tab() {
let mut state = MobileState::default();
state.register_tab(CLIENT_A, 7);
state.register_tab(CLIENT_B, 9);
let ids = state.mobile_tab_ids();
assert!(ids.contains(&7));
assert!(ids.contains(&9));
assert_eq!(ids.len(), 2);
}
#[test]
fn begin_exit_returns_the_mobile_and_previous_tabs() {
let mut state = MobileState::default();
state.register_tab(CLIENT_A, 7);
state.set_previous_tab(CLIENT_A, Some(3));
assert_eq!(state.begin_exit(CLIENT_A), Some((7, Some(3))));
}
#[test]
fn set_previous_tab_ignores_none() {
let mut state = MobileState::default();
state.register_tab(CLIENT_A, 7);
state.set_previous_tab(CLIENT_A, None);
assert_eq!(state.begin_exit(CLIENT_A), Some((7, None)));
}
#[test]
fn begin_exit_returns_none_when_not_in_mobile_mode() {
let mut state = MobileState::default();
assert_eq!(state.begin_exit(CLIENT_A), None);
}
#[test]
fn begin_exit_clears_membership_and_auto_entry() {
let mut state = MobileState::default();
state.register_tab(CLIENT_A, 7);
state.mark_auto_entered(CLIENT_A);
state.set_previous_tab(CLIENT_A, Some(3));
state.begin_exit(CLIENT_A);
assert!(!state.is_in_mobile_mode(CLIENT_A));
assert!(!state.was_auto_entered(CLIENT_A));
assert_eq!(state.begin_exit(CLIENT_A), None);
}
#[test]
fn forget_client_drops_all_per_client_bookkeeping() {
let mut state = MobileState::default();
state.register_tab(CLIENT_A, 7);
state.set_previous_tab(CLIENT_A, Some(3));
state.mark_auto_entered(CLIENT_A);
state.forget_client(CLIENT_A);
assert!(!state.is_in_mobile_mode(CLIENT_A));
assert!(!state.was_auto_entered(CLIENT_A));
}
#[test]
fn auto_entered_marker_round_trips() {
let mut state = MobileState::default();
assert!(!state.was_auto_entered(CLIENT_A));
state.mark_auto_entered(CLIENT_A);
assert!(state.was_auto_entered(CLIENT_A));
}
#[test]
fn mobile_tab_layout_is_a_borderless_plugin() {
let layout = MobileState::mobile_tab_layout().expect("layout builds");
assert_eq!(layout.borderless, Some(true));
assert!(matches!(layout.run, Some(Run::Plugin(_))));
}
#[test]
fn no_fit_override_by_default() {
let state = MobileState::default();
assert!(!state.has_fit(5));
}
#[test]
fn set_fit_records_an_override() {
let mut state = MobileState::default();
let mut tabs = no_tabs();
state.set_fit(
CLIENT_A,
5,
PaneId::Terminal(11),
embedded_size(),
&mut tabs,
);
assert!(state.has_fit(5));
}
#[test]
fn refitting_a_tab_reassigns_ownership() {
let mut state = MobileState::default();
let mut tabs = no_tabs();
state.set_fit(
CLIENT_A,
5,
PaneId::Terminal(11),
embedded_size(),
&mut tabs,
);
state.set_fit(
CLIENT_B,
5,
PaneId::Terminal(11),
embedded_size(),
&mut tabs,
);
assert_eq!(state.clear_fit_owned_by(CLIENT_A, &mut tabs), None);
assert_eq!(state.clear_fit_owned_by(CLIENT_B, &mut tabs), Some(5));
}
#[test]
fn compute_fit_size_is_none_without_a_target_tab() {
let mut state = MobileState::default();
let mut tabs = no_tabs();
state.set_fit(
CLIENT_A,
5,
PaneId::Terminal(11),
embedded_size(),
&mut tabs,
);
assert_eq!(state.compute_fit_size(5, &tabs), None);
}
#[test]
fn compute_fit_size_is_none_without_a_fit() {
let state = MobileState::default();
let tabs = no_tabs();
assert_eq!(state.compute_fit_size(5, &tabs), None);
}
#[test]
fn clear_fit_owned_by_removes_and_returns_the_tab() {
let mut state = MobileState::default();
let mut tabs = no_tabs();
state.set_fit(
CLIENT_A,
5,
PaneId::Terminal(11),
embedded_size(),
&mut tabs,
);
assert_eq!(state.clear_fit_owned_by(CLIENT_A, &mut tabs), Some(5));
assert!(!state.has_fit(5));
}
#[test]
fn clear_fit_owned_by_is_none_when_client_owns_nothing() {
let mut state = MobileState::default();
let mut tabs = no_tabs();
state.set_fit(
CLIENT_A,
5,
PaneId::Terminal(11),
embedded_size(),
&mut tabs,
);
assert_eq!(state.clear_fit_owned_by(CLIENT_B, &mut tabs), None);
assert!(state.has_fit(5));
}
#[test]
fn clear_fit_for_pane_matches_by_fullscreened_pane() {
let mut state = MobileState::default();
let mut tabs = no_tabs();
state.set_fit(
CLIENT_A,
5,
PaneId::Terminal(11),
embedded_size(),
&mut tabs,
);
assert_eq!(state.clear_fit_for_pane(PaneId::Terminal(11)), Some(5));
assert!(!state.has_fit(5));
}
#[test]
fn clear_fit_for_pane_is_none_when_no_fit_targets_it() {
let mut state = MobileState::default();
let mut tabs = no_tabs();
state.set_fit(
CLIENT_A,
5,
PaneId::Terminal(11),
embedded_size(),
&mut tabs,
);
assert_eq!(state.clear_fit_for_pane(PaneId::Terminal(99)), None);
assert!(state.has_fit(5));
}
#[test]
fn remove_fit_for_tab_drops_the_override() {
let mut state = MobileState::default();
let mut tabs = no_tabs();
state.set_fit(
CLIENT_A,
5,
PaneId::Terminal(11),
embedded_size(),
&mut tabs,
);
state.remove_fit_for_tab(5);
assert!(!state.has_fit(5));
}
#[test]
fn clear_fits_owned_by_removes_only_the_clients_fits() {
let mut state = MobileState::default();
let mut tabs = no_tabs();
state.set_fit(
CLIENT_A,
1,
PaneId::Terminal(11),
embedded_size(),
&mut tabs,
);
state.set_fit(
CLIENT_A,
2,
PaneId::Terminal(12),
embedded_size(),
&mut tabs,
);
state.set_fit(
CLIENT_B,
3,
PaneId::Terminal(13),
embedded_size(),
&mut tabs,
);
let mut cleared = state.clear_fits_owned_by(CLIENT_A, &mut tabs);
cleared.sort_unstable();
assert_eq!(cleared, vec![1, 2]);
assert!(!state.has_fit(1));
assert!(!state.has_fit(2));
assert!(state.has_fit(3));
}
}
+18
View File
@@ -0,0 +1,18 @@
use crate::panes::PaneId;
#[derive(Debug, Clone, Copy)]
pub(crate) struct MobileWebPrefs {
pub single_pane: bool,
pub fit: bool,
pub fullscreened_pane: Option<PaneId>,
}
impl Default for MobileWebPrefs {
fn default() -> Self {
MobileWebPrefs {
single_pane: true,
fit: true,
fullscreened_pane: None,
}
}
}
+1 -24
View File
@@ -1,12 +1,11 @@
use crate::tab::Pane;
use crate::{os_input_output::ServerOsApi, panes::PaneId, ClientId};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::collections::{BTreeMap, HashMap};
#[derive(Clone)]
pub struct ActivePanes {
active_panes: HashMap<ClientId, PaneId>,
shadow_clients: HashSet<ClientId>,
last_panes: HashMap<ClientId, PaneId>,
os_api: Box<dyn ServerOsApi>,
}
@@ -22,21 +21,10 @@ impl ActivePanes {
let os_api = os_api.clone();
ActivePanes {
active_panes: HashMap::new(),
shadow_clients: HashSet::new(),
last_panes: HashMap::new(),
os_api,
}
}
pub fn is_shadow_client(&self, client_id: &ClientId) -> bool {
self.shadow_clients.contains(client_id)
}
pub fn iter_shadow_clients(&self) -> impl Iterator<Item = &ClientId> {
self.shadow_clients.iter()
}
pub fn has_shadow_focus_on(&self, client_id: ClientId, pane_id: PaneId) -> bool {
self.shadow_clients.contains(&client_id)
&& self.active_panes.get(&client_id) == Some(&pane_id)
}
pub fn get(&self, client_id: &ClientId) -> Option<&PaneId> {
self.active_panes.get(client_id)
}
@@ -49,19 +37,10 @@ impl ActivePanes {
pane_id: PaneId,
panes: &mut BTreeMap<PaneId, Box<dyn Pane>>,
) {
self.shadow_clients.remove(&client_id);
self.unfocus_pane_for_client(client_id, panes);
self.active_panes.insert(client_id, pane_id);
self.focus_pane(pane_id, panes);
}
pub fn insert_silent(&mut self, client_id: ClientId, pane_id: PaneId) {
self.active_panes.insert(client_id, pane_id);
self.shadow_clients.insert(client_id);
}
pub fn remove_silent(&mut self, client_id: &ClientId) -> Option<PaneId> {
self.shadow_clients.remove(client_id);
self.active_panes.remove(client_id)
}
pub fn set_last_pane(&mut self, client_id: ClientId, pane_id: PaneId) {
self.last_panes.insert(client_id, pane_id);
}
@@ -70,7 +49,6 @@ impl ActivePanes {
self.unfocus_pane(*pane_id, panes);
}
self.active_panes.clear();
self.shadow_clients.clear();
}
pub fn is_empty(&self) -> bool {
self.active_panes.is_empty()
@@ -89,7 +67,6 @@ impl ActivePanes {
if let Some(pane_id_to_unfocus) = self.active_panes.get(&client_id) {
self.unfocus_pane(*pane_id_to_unfocus, panes);
}
self.shadow_clients.remove(client_id);
self.active_panes.remove(client_id)
}
pub fn unfocus_all_panes(&self, panes: &mut BTreeMap<PaneId, Box<dyn Pane>>) {
+8 -36
View File
@@ -308,21 +308,6 @@ impl FloatingPanes {
pub fn panes_contain(&self, pane_id: &PaneId) -> bool {
self.panes.contains_key(pane_id)
}
pub fn set_shadow_focus(&mut self, client_id: ClientId, pane_id: PaneId) {
self.active_panes.insert_silent(client_id, pane_id);
}
pub fn clear_shadow_focus(&mut self, client_id: ClientId) -> Option<PaneId> {
self.active_panes.remove_silent(&client_id)
}
pub fn is_shadow_focus_client(&self, client_id: &ClientId) -> bool {
self.active_panes.is_shadow_client(client_id)
}
pub fn shadow_focus_clients(&self) -> Vec<ClientId> {
self.active_panes.iter_shadow_clients().copied().collect()
}
pub fn has_shadow_focus_on(&self, client_id: ClientId, pane_id: PaneId) -> bool {
self.active_panes.has_shadow_focus_on(client_id, pane_id)
}
pub fn find_room_for_new_pane(&mut self) -> Option<PaneGeom> {
let display_area = *self.display_area.borrow();
let viewport = *self.viewport.borrow();
@@ -571,11 +556,7 @@ impl FloatingPanes {
let mut active_panes = active_panes.clone();
let multiple_users_exist_in_session =
{ self.connected_clients_in_app.borrow().len() > 1 };
let connected = self.connected_clients.borrow();
active_panes.retain(|c_id, _| {
connected.contains(c_id) || self.active_panes.is_shadow_client(c_id)
});
drop(connected);
active_panes.retain(|c_id, _| self.connected_clients.borrow().contains(c_id));
let pane_is_selectable = pane.selectable();
let show_help_text = active_panes.iter().any(|(client_id, pane_id)| {
pane_id == &pane.pid() && help_text_visible.get(client_id).copied().unwrap_or(false)
@@ -1171,14 +1152,8 @@ impl FloatingPanes {
.nth(1)
.map(|(pane_id, _pane)| **pane_id);
let connected_clients: HashSet<ClientId> =
self.connected_clients.borrow().iter().copied().collect();
for (client_id, active_pane_id) in active_panes {
if active_pane_id == pane_id {
if !connected_clients.contains(&client_id) {
self.active_panes.remove_silent(&client_id);
continue;
}
match next_active_pane_id {
Some(next_active_pane_id) => {
self.active_panes
@@ -1303,7 +1278,11 @@ impl FloatingPanes {
}
pub fn toggle_pane_fullscreen(&mut self, pane_id: PaneId) {
if self.fullscreen_pane_id.is_some() {
let was_no_ui = self.floating_fullscreen_covers_ui;
self.unset_fullscreen();
if was_no_ui {
self.set_fullscreen(pane_id, false);
}
} else {
self.set_fullscreen(pane_id, false);
}
@@ -1597,17 +1576,10 @@ impl FloatingPanes {
.filter(|(_cid, pid)| **pid == from_pane_id)
.map(|(cid, _pid)| *cid)
.collect();
let connected_clients: HashSet<ClientId> =
self.connected_clients.borrow().iter().copied().collect();
for client_id in clients_in_pane {
if connected_clients.contains(&client_id) {
self.active_panes.remove(&client_id, &mut self.panes);
self.active_panes
.insert(client_id, to_pane_id, &mut self.panes);
} else {
self.active_panes.remove_silent(&client_id);
self.active_panes.insert_silent(client_id, to_pane_id);
}
self.active_panes.remove(&client_id, &mut self.panes);
self.active_panes
.insert(client_id, to_pane_id, &mut self.panes);
}
}
pub fn reapply_pane_focus(&mut self) {
+13 -43
View File
@@ -1154,10 +1154,7 @@ impl TiledPanes {
} else {
self.active_panes
.iter()
.filter(|(client_id, _pane_id)| {
connected_clients.contains(client_id)
|| self.active_panes.is_shadow_client(client_id)
})
.filter(|(client_id, _pane_id)| connected_clients.contains(client_id))
.map(|(client_id, pane_id)| (*client_id, *pane_id))
.collect()
};
@@ -2709,22 +2706,13 @@ impl TiledPanes {
{
self.expand_pane_in_stack(next_active_pane_id);
}
let connected_clients: HashSet<ClientId> =
self.connected_clients.borrow().iter().copied().collect();
for (client_id, active_pane_id) in active_panes {
if active_pane_id == pane_id {
if connected_clients.contains(&client_id) {
self.active_panes.insert(
client_id,
next_active_pane_id,
&mut self.panes,
);
if let Some(last_active_pane_id) = last_active_pane_id {
self.active_panes
.set_last_pane(client_id, last_active_pane_id);
}
} else {
self.active_panes.remove_silent(&client_id);
self.active_panes
.insert(client_id, next_active_pane_id, &mut self.panes);
if let Some(last_active_pane_id) = last_active_pane_id {
self.active_panes
.set_last_pane(client_id, last_active_pane_id);
}
}
}
@@ -2830,7 +2818,11 @@ impl TiledPanes {
pub fn toggle_pane_fullscreen(&mut self, pane_id: PaneId) {
if self.fullscreen_is_active.is_some() {
let was_no_ui = *self.fullscreen_covers_ui.borrow();
self.unset_fullscreen();
if was_no_ui {
self.set_fullscreen(pane_id, false);
}
} else {
self.set_fullscreen(pane_id, false);
}
@@ -3029,21 +3021,6 @@ impl TiledPanes {
pub fn set_active_panes(&mut self, active_panes: ActivePanes) {
self.active_panes = active_panes;
}
pub fn set_shadow_focus(&mut self, client_id: ClientId, pane_id: PaneId) {
self.active_panes.insert_silent(client_id, pane_id);
}
pub fn clear_shadow_focus(&mut self, client_id: ClientId) -> Option<PaneId> {
self.active_panes.remove_silent(&client_id)
}
pub fn is_shadow_focus_client(&self, client_id: &ClientId) -> bool {
self.active_panes.is_shadow_client(client_id)
}
pub fn shadow_focus_clients(&self) -> Vec<ClientId> {
self.active_panes.iter_shadow_clients().copied().collect()
}
pub fn has_shadow_focus_on(&self, client_id: ClientId, pane_id: PaneId) -> bool {
self.active_panes.has_shadow_focus_on(client_id, pane_id)
}
pub fn move_client_focus_to_existing_panes(&mut self) {
let existing_pane_ids: Vec<PaneId> = self.panes.keys().copied().collect();
let nonexisting_panes_that_are_focused = self
@@ -3063,17 +3040,10 @@ impl TiledPanes {
.filter(|(_cid, pid)| **pid == from_pane_id)
.map(|(cid, _pid)| *cid)
.collect();
let connected_clients: HashSet<ClientId> =
self.connected_clients.borrow().iter().copied().collect();
for client_id in clients_in_pane {
if connected_clients.contains(&client_id) {
self.active_panes.remove(&client_id, &mut self.panes);
self.active_panes
.insert(client_id, to_pane_id, &mut self.panes);
} else {
self.active_panes.remove_silent(&client_id);
self.active_panes.insert_silent(client_id, to_pane_id);
}
self.active_panes.remove(&client_id, &mut self.panes);
self.active_panes
.insert(client_id, to_pane_id, &mut self.panes);
}
}
fn reset_boundaries(&mut self) {
-11
View File
@@ -113,8 +113,6 @@ pub enum PluginInstruction {
plugin_ids: Vec<PluginId>,
done_receiving_permissions: bool,
},
HoldMobileRender(ClientId),
ReleaseMobileRender(ClientId),
ApplyCachedWorkerMessages(PluginId),
PostMessagesToPluginWorker(
PluginId,
@@ -241,8 +239,6 @@ impl From<&PluginInstruction> for PluginContext {
PluginInstruction::NewTab(..) => PluginContext::NewTab,
PluginInstruction::OverrideLayout(..) => PluginContext::OverrideLayout,
PluginInstruction::ApplyCachedEvents { .. } => PluginContext::ApplyCachedEvents,
PluginInstruction::HoldMobileRender(..) => PluginContext::HoldMobileRender,
PluginInstruction::ReleaseMobileRender(..) => PluginContext::ReleaseMobileRender,
PluginInstruction::ApplyCachedWorkerMessages(..) => {
PluginContext::ApplyCachedWorkerMessages
},
@@ -736,12 +732,6 @@ pub(crate) fn plugin_thread_main(
PluginInstruction::ApplyCachedWorkerMessages(plugin_id) => {
wasm_bridge.apply_cached_worker_messages(plugin_id)?;
},
PluginInstruction::HoldMobileRender(client_id) => {
wasm_bridge.hold_mobile_render(client_id);
},
PluginInstruction::ReleaseMobileRender(client_id) => {
wasm_bridge.release_mobile_render(client_id, shutdown_send.clone())?;
},
PluginInstruction::PostMessagesToPluginWorker(
plugin_id,
client_id,
@@ -764,7 +754,6 @@ pub(crate) fn plugin_thread_main(
wasm_bridge.update_plugins(updates, shutdown_send.clone())?;
},
PluginInstruction::PluginSubscribedToEvents(plugin_id, client_id, events) => {
wasm_bridge.notify_screen_of_ansi_subscription_change();
wasm_bridge.notify_screen_of_background_plugin_subscriptions(
plugin_id,
client_id,
+1 -125
View File
@@ -177,7 +177,6 @@ pub struct WasmBridge {
plugin_ids_waiting_for_permission_request: HashSet<PluginId>,
cached_events_for_pending_plugins: HashMap<PluginId, Vec<EventOrPipeMessage>>,
cached_resizes_for_pending_plugins: HashMap<PluginId, (usize, usize)>, // (rows, columns)
clients_holding_mobile_render_until_size_settled: HashSet<ClientId>,
cached_worker_messages: HashMap<PluginId, Vec<(ClientId, String, String, String)>>, // Vec<clientid,
// worker_name,
// message,
@@ -243,7 +242,6 @@ impl WasmBridge {
path_to_default_shell,
watcher,
next_plugin_id: 0,
clients_holding_mobile_render_until_size_settled: HashSet::new(),
cached_events_for_pending_plugins: HashMap::new(),
plugin_ids_waiting_for_permission_request: HashSet::new(),
cached_resizes_for_pending_plugins: HashMap::new(),
@@ -501,17 +499,6 @@ impl WasmBridge {
plugin_map.remove_plugins(pid).into_iter().collect()
};
// Check if any removed plugin was subscribed to ANSI pane render
let was_subscribed_to_ansi =
plugins_to_cleanup
.iter()
.any(|((_, _), (_, subscriptions, _))| {
subscriptions
.lock()
.unwrap()
.contains(&EventType::PaneRenderReportWithAnsi)
});
// Schedule cleanup on each plugin's pinned thread
for ((plugin_id, client_id), (running_plugin, subscriptions, workers)) in plugins_to_cleanup
{
@@ -591,11 +578,6 @@ impl WasmBridge {
.senders
.send_to_background_jobs(BackgroundJob::ReportPluginList(plugin_list));
// If any unloaded plugin was subscribed to ANSI pane content, re-check remaining plugins
if was_subscribed_to_ansi {
self.notify_screen_of_ansi_subscription_change();
}
Ok(())
}
pub fn reload_plugin_with_id(&mut self, plugin_id: u32) -> Result<()> {
@@ -1216,69 +1198,6 @@ impl WasmBridge {
}
Ok(())
}
fn plugin_render_is_held_until_size_settled(&self, plugin_id: PluginId) -> bool {
let clients_of_plugin: Vec<ClientId> = self
.plugin_map
.lock()
.unwrap()
.running_plugins()
.iter()
.filter(|(pid, _client_id, _running_plugin)| *pid == plugin_id)
.map(|(_pid, client_id, _running_plugin)| *client_id)
.collect();
!clients_of_plugin.is_empty()
&& clients_of_plugin.iter().all(|client_id| {
self.clients_holding_mobile_render_until_size_settled
.contains(client_id)
})
}
pub fn hold_mobile_render(&mut self, client_id: ClientId) {
self.clients_holding_mobile_render_until_size_settled
.insert(client_id);
}
pub fn release_mobile_render(
&mut self,
client_id: ClientId,
shutdown_sender: Sender<()>,
) -> Result<()> {
if !self
.clients_holding_mobile_render_until_size_settled
.remove(&client_id)
{
return Ok(());
}
let plugins_held_for_client: Vec<PluginId> = self
.plugin_map
.lock()
.unwrap()
.running_plugins()
.iter()
.filter(|(pid, cid, _)| {
*cid == client_id
&& (self.cached_events_for_pending_plugins.contains_key(pid)
|| self.cached_resizes_for_pending_plugins.contains_key(pid))
})
.map(|(pid, _cid, _)| *pid)
.collect();
for plugin_id in &plugins_held_for_client {
self.drain_pending_plugin_resize_before_events(*plugin_id, shutdown_sender.clone())?;
}
Ok(())
}
fn drain_pending_plugin_resize_before_events(
&mut self,
plugin_id: PluginId,
shutdown_sender: Sender<()>,
) -> Result<()> {
if let Some((rows, columns)) = self.cached_resizes_for_pending_plugins.remove(&plugin_id) {
self.resize_plugin(plugin_id, columns, rows, shutdown_sender.clone())?;
}
self.apply_cached_events(vec![plugin_id], true, shutdown_sender)
}
pub fn apply_cached_events(
&mut self,
plugin_ids: Vec<PluginId>,
@@ -1287,9 +1206,6 @@ impl WasmBridge {
) -> Result<()> {
let mut applied_plugin_paths = HashSet::new();
for plugin_id in plugin_ids {
if self.plugin_render_is_held_until_size_settled(plugin_id) {
continue;
}
if !done_receiving_permissions
&& self
.plugin_ids_waiting_for_permission_request
@@ -1315,8 +1231,6 @@ impl WasmBridge {
Ok(())
}
pub fn remove_client(&mut self, client_id: ClientId) {
self.clients_holding_mobile_render_until_size_settled
.remove(&client_id);
self.connected_clients
.lock()
.unwrap()
@@ -1387,46 +1301,10 @@ impl WasmBridge {
self.update_plugins(updates, shutdown_sender.clone())?;
}
if !pane_render_report.all_pane_contents_with_ansi.is_empty() {
let changed_ansi_panes_per_client = self.get_changed_panes_per_client(
&pane_render_report.all_pane_contents_with_ansi,
self.previous_pane_render_report
.as_ref()
.map(|r| &r.all_pane_contents_with_ansi),
);
for (client_id, client_panes) in changed_ansi_panes_per_client {
let updates = vec![(
None,
Some(client_id),
Event::PaneRenderReportWithAnsi(client_panes),
)];
self.update_plugins(updates, shutdown_sender.clone())?;
}
}
self.previous_pane_render_report = Some(pane_render_report);
Ok(())
}
pub fn notify_screen_of_ansi_subscription_change(&self) {
let any_plugin_needs_ansi = {
let mut plugin_map = self.plugin_map.lock().unwrap();
plugin_map
.running_plugins_and_subscriptions()
.iter()
.any(|(_, _, _, subs)| {
subs.lock()
.unwrap()
.contains(&EventType::PaneRenderReportWithAnsi)
})
};
let _ = self
.senders
.send_to_screen(ScreenInstruction::PluginSubscribedToAnsiPaneContents(
any_plugin_needs_ansi,
));
}
pub fn notify_screen_of_background_plugin_subscriptions(
&self,
plugin_id: PluginId,
@@ -2174,9 +2052,7 @@ fn check_event_permission(
| Event::ActivePaneScroll(..)
| Event::InputReceived => PermissionType::ReadApplicationState,
Event::WebServerStatus(..) => PermissionType::StartWebServer,
Event::PaneRenderReport(..) | Event::PaneRenderReportWithAnsi(..) => {
PermissionType::ReadPaneContents
},
Event::PaneRenderReport(..) => PermissionType::ReadPaneContents,
Event::UserAction(..) => PermissionType::InterceptInput,
_ => return (PermissionStatus::Granted, None),
};
+1 -55
View File
@@ -53,7 +53,6 @@ use zellij_utils::web_server_commands::shutdown_all_webserver_instances;
use crate::{panes::PaneId, screen::ScreenInstruction};
use kdl::KdlDocument;
use zellij_utils::pane_size::Size;
use prost::Message;
use zellij_utils::{
@@ -466,13 +465,6 @@ fn host_run_plugin_command(mut caller: Caller<'_, PluginEnv>) {
PluginCommand::ListWindowsVolumes => list_windows_volumes(env),
PluginCommand::GetSessionList => get_session_list(env),
PluginCommand::SetSoftKeyboard(on) => set_soft_keyboard(env, on),
PluginCommand::SetTabFit { tab_id, fit } => set_tab_fit(
env,
tab_id,
fit.map(|(pane_id, size)| (pane_id.into(), size)),
),
PluginCommand::ExitMobileMode => exit_mobile_mode(env),
PluginCommand::SetShadowFocus(pane_id) => set_shadow_focus(env, pane_id.into()),
PluginCommand::DumpSessionLayout { tab_index } => {
dump_session_layout(env, tab_index)
},
@@ -868,15 +860,6 @@ fn unsubscribe(env: &PluginEnv, event_list: HashSet<EventType>) -> Result<()> {
.lock()
.to_anyhow()?
.retain(|k| !event_list.contains(k));
if event_list.contains(&EventType::PaneRenderReportWithAnsi) {
let _ = env
.senders
.send_to_plugin(PluginInstruction::PluginSubscribedToEvents(
env.plugin_id,
env.client_id,
HashSet::new(), // empty set signals a recheck, not a new subscription
));
}
Ok(())
}
@@ -4058,41 +4041,6 @@ fn set_soft_keyboard(env: &PluginEnv, on: bool) {
.non_fatal();
}
fn set_shadow_focus(env: &PluginEnv, pane_id: PaneId) {
env.senders
.send_to_screen(ScreenInstruction::SetShadowFocus(env.client_id, pane_id))
.with_context(|| {
format!(
"failed to dispatch SetShadowFocus for plugin {}",
env.plugin_id
)
})
.non_fatal();
}
fn set_tab_fit(env: &PluginEnv, tab_id: usize, fit: Option<(PaneId, Size)>) {
env.senders
.send_to_screen(ScreenInstruction::SetTabFit {
client_id: env.client_id,
tab_id,
fit,
})
.with_context(|| format!("failed to dispatch SetTabFit for plugin {}", env.plugin_id))
.non_fatal();
}
fn exit_mobile_mode(env: &PluginEnv) {
env.senders
.send_to_screen(ScreenInstruction::ExitMobileMode(env.client_id, None))
.with_context(|| {
format!(
"failed to dispatch ExitMobileMode for plugin {}",
env.plugin_id
)
})
.non_fatal();
}
#[cfg(windows)]
fn list_windows_volumes(env: &PluginEnv) {
let send_plugin_instructions = env.senders.to_plugin.clone();
@@ -5628,9 +5576,7 @@ fn check_command_permission(
| PluginCommand::HideFloatingPanes { .. }
| PluginCommand::SetPaneRegexHighlights(..)
| PluginCommand::ClearPaneHighlights(..)
| PluginCommand::SetSoftKeyboard(..)
| PluginCommand::SetTabFit { .. }
| PluginCommand::ExitMobileMode => PermissionType::ChangeApplicationState,
| PluginCommand::SetSoftKeyboard(..) => PermissionType::ChangeApplicationState,
PluginCommand::UnblockCliPipeInput(..)
| PluginCommand::BlockCliPipeInput(..)
| PluginCommand::CliPipeOutput(..) => PermissionType::ReadCliPipes,
+69 -66
View File
@@ -28,7 +28,7 @@ use zellij_utils::{
actions::{Action, SearchDirection, SearchOption},
command::TerminalAction,
},
ipc::{ClientToServerMsg, ExitReason, IpcReceiverWithContext, ResizeCause, ServerToClientMsg},
ipc::{ClientToServerMsg, ExitReason, IpcReceiverWithContext, ServerToClientMsg},
};
use crate::ClientId;
@@ -1287,14 +1287,6 @@ pub(crate) fn route_action(
.with_context(err_context)?;
},
Action::ToggleMouseMode => {}, // Handled client side
Action::ToggleMobileMode => {
senders
.send_to_screen(ScreenInstruction::ToggleMobileMode(
client_id,
Some(NotificationEnd::new(completion_tx)),
))
.with_context(err_context)?;
},
Action::PreviousSwapLayout => {
senders
.send_to_screen(ScreenInstruction::PreviousSwapLayout(
@@ -2278,7 +2270,7 @@ pub(crate) fn route_thread_main(
should_break = true;
}
},
ClientToServerMsg::TerminalResize { new_size, .. } => {
ClientToServerMsg::TerminalResize { new_size } => {
// For watchers: send size to Screen for rendering adjustments, but
// this does not affect the screen size
send_to_screen_or_retry_queue!(
@@ -2457,7 +2449,7 @@ pub(crate) fn route_thread_main(
}
}
},
ClientToServerMsg::TerminalResize { new_size, cause } => {
ClientToServerMsg::TerminalResize { new_size } => {
// Check if this is a watcher or regular client
if is_watcher {
// For watchers: send size to Screen for tracking, don't affect screen size
@@ -2484,51 +2476,6 @@ pub(crate) fn route_thread_main(
client_id, new_size,
))
});
if matches!(cause, ResizeCause::Viewport) {
let mobile_options =
session_data.read().ok().and_then(|guard| {
guard.as_ref().map(|s| {
let config = s
.session_configuration
.get_client_configuration(&client_id);
(
config
.options
.mobile_layout
.unwrap_or_default(),
config
.options
.mobile_threshold_cols
.unwrap_or(60),
config
.options
.mobile_threshold_rows
.unwrap_or(30),
)
})
});
if let Some((mobile_layout, threshold_cols, threshold_rows)) =
mobile_options
{
let _ = senders.as_ref().map(|s| {
s.send_to_screen(
ScreenInstruction::ReevaluateMobileMode {
client_id,
new_size,
mobile_layout,
threshold_cols,
threshold_rows,
},
)
});
}
} else if matches!(cause, ResizeCause::SizeSettled) {
let _ = senders.as_ref().map(|s| {
s.send_to_screen(ScreenInstruction::MobileSizeSettled(
client_id,
))
});
}
}
},
ClientToServerMsg::TerminalPixelDimensions { pixel_dimensions } => {
@@ -2843,25 +2790,81 @@ pub(crate) fn route_thread_main(
},
}
},
ClientToServerMsg::RequestSessionList => {
if let Some(senders) = senders.as_ref() {
if let Some(scan_state) =
crate::background_jobs::session_scan_state()
{
let (session_name, available_layouts, plugin_list) = {
let name =
scan_state.current_session_name.lock().unwrap().clone();
let info =
scan_state.current_session_info.lock().unwrap().clone();
let plugins = scan_state
.current_session_plugin_list
.lock()
.unwrap()
.clone();
(name, info.available_layouts, plugins)
};
let (live_sessions_map, resurrectable_sessions_map) =
crate::background_jobs::scan_session_list_default_dirs(
&session_name,
&available_layouts,
&plugin_list,
);
let _ = senders.send_to_screen(
ScreenInstruction::UpdateSessionInfos(
live_sessions_map,
resurrectable_sessions_map,
),
);
}
}
},
ClientToServerMsg::SetMobileRenderPreferences { single_pane, fit } => {
let _ = send_to_screen_or_retry_queue!(
senders,
ScreenInstruction::SetMobileRenderPreferences {
client_id,
single_pane,
fit,
},
instruction,
retry_queue
);
},
}
Ok(should_break)
};
let mut repeat_retries = VecDeque::new();
while let Some(instruction_to_retry) = retry_queue.pop_front() {
log::warn!("Server ready, retrying sending instruction.");
thread::sleep(Duration::from_millis(5));
// the parked instructions and the one just received form a single
// FIFO sequence: the server can become ready in the middle of the
// retry pass below, and handing it the fresh instruction while
// older ones are still parked would deliver them out of order (a
// stale nested AnnounceAck landing after the AncestryUpdate that
// supersedes it, for instance). Once one instruction has to be
// parked, every instruction behind it is parked too.
let retried_count = retry_queue.len();
let mut pending_instructions = std::mem::take(&mut retry_queue);
pending_instructions.push_back(instruction);
let mut deferred_instructions = VecDeque::new();
for (index, pending_instruction) in pending_instructions.into_iter().enumerate() {
if !deferred_instructions.is_empty() {
deferred_instructions.push_back(pending_instruction);
continue;
}
if index < retried_count {
log::warn!("Server ready, retrying sending instruction.");
thread::sleep(Duration::from_millis(5));
}
let should_break =
handle_instruction(instruction_to_retry, Some(&mut repeat_retries))?;
handle_instruction(pending_instruction, Some(&mut deferred_instructions))?;
if should_break {
break 'route_loop;
}
}
// retry on loop around
retry_queue.append(&mut repeat_retries);
let should_break = handle_instruction(instruction, Some(&mut retry_queue))?;
if should_break {
break 'route_loop;
}
retry_queue = deferred_instructions;
},
None => {
consecutive_unknown_messages_received += 1;
File diff suppressed because it is too large Load Diff
+31 -40
View File
@@ -191,7 +191,6 @@ pub(crate) struct Tab {
pub prev_name: String,
default_name: String,
pub size: Size,
pub visible_to: Option<HashSet<ClientId>>,
tiled_panes: TiledPanes,
floating_panes: FloatingPanes,
suppressed_panes: SuppressedPanes,
@@ -874,10 +873,9 @@ impl Tab {
mouse_click_through: bool,
web_server_ip: IpAddr,
web_server_port: u16,
mobile_tab_count: usize,
) -> Self {
let default_name = if name.is_empty() {
format!("Tab #{}", (id + 1).saturating_sub(mobile_tab_count))
format!("Tab #{}", id + 1)
} else {
String::new()
};
@@ -958,7 +956,6 @@ impl Tab {
prev_name: name,
default_name,
size: initial_size,
visible_to: None,
max_panes,
viewport,
display_area,
@@ -3415,7 +3412,7 @@ impl Tab {
self.close_down_to_max_terminals()
.with_context(err_context)?;
if self.tiled_panes.fullscreen_is_active() {
self.toggle_active_pane_fullscreen(client_id);
self.unset_fullscreen();
}
self.dissolve_stack_lists_for_classic_mutation();
if self.tiled_panes.can_split_pane_horizontally(client_id) {
@@ -3490,7 +3487,7 @@ impl Tab {
self.close_down_to_max_terminals()
.with_context(err_context)?;
if self.tiled_panes.fullscreen_is_active() {
self.toggle_active_pane_fullscreen(client_id);
self.unset_fullscreen();
}
self.dissolve_stack_lists_for_classic_mutation();
if self.tiled_panes.can_split_pane_vertically(client_id) {
@@ -3728,6 +3725,10 @@ impl Tab {
self.tiled_panes.get_active_pane_id(client_id)
}
}
pub fn get_active_pane_id_or_first_selectable(&self, client_id: ClientId) -> Option<PaneId> {
self.get_active_pane_id(client_id)
.or_else(|| self.tiled_panes.first_selectable_pane_id())
}
fn get_active_terminal_id(&self, client_id: ClientId) -> Option<u32> {
if let Some(PaneId::Terminal(pid)) = self.get_active_pane_id(client_id) {
Some(pid)
@@ -4044,36 +4045,6 @@ impl Tab {
pane.set_guest_modal_shortcuts(shortcuts);
}
}
pub fn set_shadow_focus(&mut self, client_id: ClientId, pane_id: PaneId) -> bool {
if self.tiled_panes.panes_contain(&pane_id) {
self.tiled_panes.set_shadow_focus(client_id, pane_id);
true
} else if self.floating_panes.panes_contain(&pane_id) {
self.floating_panes.set_shadow_focus(client_id, pane_id);
true
} else {
false
}
}
pub fn clear_shadow_focus(&mut self, client_id: ClientId) {
if self.tiled_panes.is_shadow_focus_client(&client_id) {
self.tiled_panes.clear_shadow_focus(client_id);
}
if self.floating_panes.is_shadow_focus_client(&client_id) {
self.floating_panes.clear_shadow_focus(client_id);
}
}
pub fn shadow_focus_clients(&self) -> Vec<ClientId> {
let mut out = self.tiled_panes.shadow_focus_clients();
out.extend(self.floating_panes.shadow_focus_clients());
out.sort_unstable();
out.dedup();
out
}
pub fn has_shadow_focus_on(&self, client_id: ClientId, pane_id: PaneId) -> bool {
self.tiled_panes.has_shadow_focus_on(client_id, pane_id)
|| self.floating_panes.has_shadow_focus_on(client_id, pane_id)
}
pub fn handle_pty_bytes(&mut self, pid: u32, bytes: VteBytes) -> Result<()> {
if self.is_pending {
self.pending_instructions
@@ -4684,6 +4655,19 @@ impl Tab {
log::error!("No tiled pane with id: {:?} found", pane_id);
}
}
pub fn unset_fullscreen(&mut self) {
if self.floating_panes.fullscreen_is_active() {
self.floating_panes.unset_fullscreen();
self.set_force_render();
self.set_should_clear_display_before_rendering();
return;
}
if self.floating_panes.panes_are_visible() {
return;
}
self.dissolve_stack_lists_for_classic_mutation();
self.tiled_panes.unset_fullscreen();
}
pub fn is_fullscreen_active(&self) -> bool {
self.tiled_panes.fullscreen_is_active() || self.floating_panes.fullscreen_is_active()
}
@@ -4759,10 +4743,6 @@ impl Tab {
self.floating_panes.set_force_render(); // we do this to make sure pinned panes are
// rendered even if their surface is not visible
}
#[cfg(test)]
pub fn should_clear_display_before_rendering(&self) -> bool {
self.should_clear_display_before_rendering
}
pub fn is_sync_panes_active(&self) -> bool {
self.synchronize_is_active
}
@@ -7128,6 +7108,17 @@ impl Tab {
}
}
pub fn pane_last_activity(&self) -> HashMap<PaneId, u64> {
let now = Instant::now();
let mut activity = HashMap::new();
for pane_id in self.get_all_pane_ids() {
if let Some(pane) = self.get_pane_with_id(pane_id) {
let secs_ago = now.saturating_duration_since(pane.active_at()).as_secs();
activity.insert(pane_id, secs_ago);
}
}
activity
}
pub fn pane_infos(&self) -> Vec<PaneInfo> {
let mut pane_info = vec![];
let current_pane_group = { self.current_pane_group.borrow().clone_inner() };
@@ -283,7 +283,6 @@ fn create_new_tab(size: Size, default_mode: ModeInfo) -> Tab {
false, // mouse_click_through
web_server_ip,
web_server_port,
0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -381,7 +380,6 @@ fn create_new_tab_with_stacked_pane_list(
false,
web_server_ip,
web_server_port,
0,
);
let (base_layout, new_terminal_ids) =
base_layout_and_ids.unwrap_or_else(|| (TiledPaneLayout::default(), vec![(1, None)]));
@@ -475,7 +473,6 @@ fn create_new_tab_without_pane_frames(size: Size, default_mode: ModeInfo) -> Tab
false, // mouse_click_through
web_server_ip,
web_server_port,
0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -586,7 +583,6 @@ fn create_new_tab_with_swap_layouts(
false, // mouse_click_through
web_server_ip,
web_server_port,
0, // mobile_tab_count
);
let (
base_layout,
@@ -694,7 +690,6 @@ fn create_new_tab_with_os_api(
false, // mouse_click_through
web_server_ip,
web_server_port,
0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -788,7 +783,6 @@ fn create_new_tab_with_layout(size: Size, default_mode: ModeInfo, layout: &str)
false, // mouse_click_through
web_server_ip,
web_server_port,
0, // mobile_tab_count
);
let pane_ids = tab_layout
.extract_run_instructions()
@@ -896,7 +890,6 @@ fn create_new_tab_with_mock_pty_writer(
false, // mouse_click_through
web_server_ip,
web_server_port,
0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -995,7 +988,6 @@ fn create_new_tab_with_sixel_support(
false, // mouse_click_through
web_server_ip,
web_server_port,
0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -13373,7 +13365,6 @@ fn create_new_tab_with_plugin_receiver(
false, // mouse_click_through
web_server_ip,
web_server_port,
0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -15161,7 +15152,6 @@ fn create_new_tab_with_server_receiver(
false, // mouse_click_through
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
8080,
0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
+110 -210
View File
@@ -225,7 +225,6 @@ fn create_new_tab(size: Size, stacked_resize: bool) -> Tab {
false, // mouse_click_through
web_server_ip,
web_server_port,
0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -316,7 +315,6 @@ fn create_new_tab_with_layout(size: Size, layout: TiledPaneLayout) -> Tab {
false, // mouse_click_through
web_server_ip,
web_server_port,
0, // mobile_tab_count
);
let mut new_terminal_ids = vec![];
for i in 0..layout.extract_run_instructions().len() {
@@ -413,7 +411,6 @@ fn create_new_tab_with_cell_size(
false, // mouse_click_through
web_server_ip,
web_server_port,
0, // mobile_tab_count
);
tab.apply_layout(
TiledPaneLayout::default(),
@@ -1325,6 +1322,71 @@ pub fn regular_fullscreen_switches_to_no_ui_fullscreen() {
);
}
#[test]
pub fn no_ui_fullscreen_downgrades_to_regular_fullscreen() {
let size = Size {
cols: 121,
rows: 20,
};
let stacked_resize = false;
let mut tab = create_new_tab(size, stacked_resize);
for i in 2..5 {
let new_pane_id = PaneId::Terminal(i);
tab.new_pane(
new_pane_id,
None,
None,
false,
true,
NewPanePlacement::default(),
Some(1),
None,
)
.unwrap();
}
let viewport = *tab.viewport.borrow();
tab.toggle_active_pane_no_ui_fullscreen(1);
assert!(
tab.is_fullscreen_active() && tab.fullscreen_covers_ui(),
"NoUi fullscreen is active"
);
tab.toggle_active_pane_fullscreen(1);
assert!(
tab.is_fullscreen_active(),
"Fullscreen stays active after the downgrade"
);
assert!(
!tab.fullscreen_covers_ui(),
"The downgrade restores the UI rows"
);
assert_eq!(
tab.fullscreen_pane_id(),
Some(PaneId::Terminal(4)),
"The same pane remains fullscreen"
);
let active_pane = tab.tiled_panes.panes.get(&PaneId::Terminal(4)).unwrap();
assert_eq!(active_pane.x(), viewport.x, "Pane x matches the viewport");
assert_eq!(active_pane.y(), viewport.y, "Pane y matches the viewport");
assert_eq!(
active_pane.cols(),
viewport.cols,
"Pane cols match the viewport cols"
);
assert_eq!(
active_pane.rows(),
viewport.rows,
"Pane rows match the viewport rows"
);
tab.toggle_active_pane_fullscreen(1);
assert!(
!tab.is_fullscreen_active(),
"The next regular toggle leaves fullscreen entirely"
);
}
#[test]
pub fn resize_whole_tab_while_no_ui_fullscreen_preserves_no_ui() {
let initial_size = Size {
@@ -1532,6 +1594,51 @@ pub fn regular_floating_fullscreen_switches_to_no_ui() {
);
}
#[test]
pub fn floating_no_ui_fullscreen_downgrades_to_regular_fullscreen() {
let mut tab = create_tab_with_two_floating_panes();
let viewport = *tab.viewport.borrow();
let active_pane_id = tab
.floating_panes
.active_pane_id(1)
.expect("a floating pane is focused");
tab.toggle_active_pane_no_ui_fullscreen(1);
assert!(
tab.is_fullscreen_active() && tab.fullscreen_covers_ui(),
"Floating no-ui fullscreen is active"
);
tab.toggle_active_pane_fullscreen(1);
assert!(
tab.is_fullscreen_active(),
"Fullscreen stays active after the downgrade"
);
assert!(
!tab.fullscreen_covers_ui(),
"The downgrade restores the UI rows"
);
let geom = floating_pane_geom(&tab, active_pane_id);
assert_eq!(geom.x, viewport.x, "Pane x matches the viewport");
assert_eq!(geom.y, viewport.y, "Pane y matches the viewport");
assert_eq!(
geom.cols.as_usize(),
viewport.cols,
"Pane cols match the viewport cols"
);
assert_eq!(
geom.rows.as_usize(),
viewport.rows,
"Pane rows match the viewport rows"
);
tab.toggle_active_pane_fullscreen(1);
assert!(
!tab.is_fullscreen_active(),
"The next regular toggle leaves fullscreen entirely"
);
}
#[test]
pub fn floating_fullscreen_on_lone_floating_pane() {
let size = Size {
@@ -17445,210 +17552,3 @@ pub fn scroll_terminal_down_nonexistent_pane_id_is_a_noop() {
let writes = drain_pty_writer(&rx);
assert!(writes.is_empty());
}
#[test]
pub fn set_shadow_focus_returns_true_when_pane_is_in_tab() {
let size = Size {
cols: 121,
rows: 20,
};
let mut tab = create_new_tab(size, true);
tab.vertical_split(PaneId::Terminal(2), None, 1, None, None)
.unwrap();
let placed = tab.set_shadow_focus(99, PaneId::Terminal(2));
assert!(
placed,
"pane is in the tab — should report a successful placement"
);
assert!(
tab.has_shadow_focus_on(99, PaneId::Terminal(2)),
"shadow focus must be recorded on the target pane",
);
}
#[test]
pub fn set_shadow_focus_returns_false_when_pane_not_in_tab() {
let size = Size {
cols: 121,
rows: 20,
};
let mut tab = create_new_tab(size, true);
let placed = tab.set_shadow_focus(99, PaneId::Terminal(42));
assert!(!placed, "no pane 42 in this tab — placement must fail");
assert!(
!tab.has_shadow_focus_on(99, PaneId::Terminal(42)),
"nothing should have been recorded",
);
assert!(
tab.shadow_focus_clients().is_empty(),
"no shadow markers should exist",
);
}
#[test]
pub fn clear_shadow_focus_removes_marker_and_entry() {
let size = Size {
cols: 121,
rows: 20,
};
let mut tab = create_new_tab(size, true);
tab.set_shadow_focus(99, PaneId::Terminal(1));
assert_eq!(tab.shadow_focus_clients(), vec![99]);
tab.clear_shadow_focus(99);
assert!(
tab.shadow_focus_clients().is_empty(),
"marker should be gone after clear",
);
assert!(
!tab.has_shadow_focus_on(99, PaneId::Terminal(1)),
"the active_panes entry should be gone too",
);
}
#[test]
pub fn clear_shadow_focus_is_a_noop_when_absent() {
let size = Size {
cols: 121,
rows: 20,
};
let mut tab = create_new_tab(size, true);
tab.clear_shadow_focus(99);
assert!(
tab.tiled_panes.pane_id_is_focused(&PaneId::Terminal(1)),
"real client's focus on pane 1 must survive an unrelated clear",
);
}
#[test]
pub fn has_shadow_focus_on_is_pane_specific() {
let size = Size {
cols: 121,
rows: 20,
};
let mut tab = create_new_tab(size, true);
tab.vertical_split(PaneId::Terminal(2), None, 1, None, None)
.unwrap();
tab.set_shadow_focus(99, PaneId::Terminal(2));
assert!(tab.has_shadow_focus_on(99, PaneId::Terminal(2)));
assert!(
!tab.has_shadow_focus_on(99, PaneId::Terminal(1)),
"must NOT match a different pane id",
);
assert!(
!tab.has_shadow_focus_on(7, PaneId::Terminal(2)),
"must NOT match a different client id",
);
}
#[test]
pub fn has_shadow_focus_on_does_not_match_real_focus() {
let size = Size {
cols: 121,
rows: 20,
};
let tab = create_new_tab(size, true);
assert!(
tab.tiled_panes.pane_id_is_focused(&PaneId::Terminal(1)),
"precondition: client 1 has real focus on pane 1",
);
assert!(
!tab.has_shadow_focus_on(1, PaneId::Terminal(1)),
"real focus must not register as shadow focus",
);
}
#[test]
pub fn shadow_focus_clients_lists_only_marked_clients() {
let size = Size {
cols: 121,
rows: 20,
};
let mut tab = create_new_tab(size, true);
tab.vertical_split(PaneId::Terminal(2), None, 1, None, None)
.unwrap();
tab.set_shadow_focus(99, PaneId::Terminal(1));
tab.set_shadow_focus(100, PaneId::Terminal(2));
let mut clients = tab.shadow_focus_clients();
clients.sort_unstable();
assert_eq!(clients, vec![99, 100]);
}
#[test]
pub fn real_focus_supersedes_shadow_marker() {
let size = Size {
cols: 121,
rows: 20,
};
let mut tab = create_new_tab(size, true);
tab.set_shadow_focus(99, PaneId::Terminal(1));
assert_eq!(tab.shadow_focus_clients(), vec![99]);
tab.tiled_panes.focus_pane(PaneId::Terminal(1), 99);
assert!(
tab.shadow_focus_clients().is_empty(),
"real focus must clear any prior shadow marker for the same client",
);
assert!(
tab.tiled_panes.pane_id_is_focused(&PaneId::Terminal(1)),
"real focus must still be recorded",
);
}
#[test]
pub fn moving_shadow_focus_updates_pane_target() {
let size = Size {
cols: 121,
rows: 20,
};
let mut tab = create_new_tab(size, true);
tab.vertical_split(PaneId::Terminal(2), None, 1, None, None)
.unwrap();
tab.set_shadow_focus(99, PaneId::Terminal(1));
tab.set_shadow_focus(99, PaneId::Terminal(2));
assert!(tab.has_shadow_focus_on(99, PaneId::Terminal(2)));
assert!(
!tab.has_shadow_focus_on(99, PaneId::Terminal(1)),
"previous shadow target must be overwritten",
);
assert_eq!(
tab.shadow_focus_clients(),
vec![99],
"still only one shadow client",
);
}
#[test]
pub fn closing_a_pane_silently_drops_shadow_clients() {
let size = Size {
cols: 121,
rows: 20,
};
let mut tab = create_new_tab(size, true);
tab.vertical_split(PaneId::Terminal(2), None, 1, None, None)
.unwrap();
tab.set_shadow_focus(99, PaneId::Terminal(2));
assert!(tab.has_shadow_focus_on(99, PaneId::Terminal(2)));
tab.close_pane(PaneId::Terminal(2), false, None);
assert!(
tab.shadow_focus_clients().is_empty(),
"shadow client must be dropped when its pane is closed",
);
}
File diff suppressed because it is too large Load Diff
-22
View File
@@ -7,7 +7,6 @@ use std::{
use zellij_utils::data::*;
use zellij_utils::errors::prelude::*;
use zellij_utils::input::actions::Action;
use zellij_utils::pane_size::Size;
pub use zellij_utils::plugin_api;
use zellij_utils::plugin_api::event::ProtobufPaneScrollbackResponse;
use zellij_utils::plugin_api::generated_api::api::plugin_command::{
@@ -1728,27 +1727,6 @@ pub fn set_soft_keyboard(on: bool) {
unsafe { host_run_plugin_command() };
}
pub fn set_tab_fit(tab_id: usize, fit: Option<(PaneId, Size)>) {
let plugin_command = PluginCommand::SetTabFit { tab_id, fit };
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
}
pub fn exit_mobile_mode() {
let plugin_command = PluginCommand::ExitMobileMode;
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
}
pub fn set_shadow_focus(pane_id: PaneId) {
let plugin_command = PluginCommand::SetShadowFocus(pane_id);
let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
object_to_stdout(&protobuf_plugin_command.encode_to_vec());
unsafe { host_run_plugin_command() };
}
/// Start watching the host folder for filesystem changes (Note: somewhat unstable at the time
/// being)
pub fn watch_filesystem() {
-1
View File
@@ -246,7 +246,6 @@ plugins {
configuration location="zellij:configuration"
plugin-manager location="zellij:plugin-manager"
about location="zellij:about"
mobile location="zellij:mobile"
}
// Plugins to load in the background when a new session starts
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More