Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions crates/shelldeck-ssh/src/workspace_helper/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,32 @@ mod tests {
use std::fs;
use std::io::Cursor;

// The helper deliberately changes process cwd through a retained directory
// descriptor. Each successful helper journey needs its own process: a test
// mutex cannot protect unrelated Rust test threads from process-wide cwd.
fn isolated_helper_test(test_name: &str) -> bool {
const CHILD: &str = "SHELLDECK_REMOTE_HELPER_TEST_CHILD";
if std::env::var(CHILD).as_deref() == Ok(test_name) {
return false;
}
let output = Command::new(std::env::current_exe().unwrap())
.args(["--exact", test_name, "--nocapture"])
.env(CHILD, test_name)
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("running 1 test"),
"child test not selected: {stdout}"
);
assert!(
output.status.success(),
"isolated helper test failed: {stdout}\n{}",
String::from_utf8_lossy(&output.stderr)
);
true
}

struct TestRepository {
root: PathBuf,
git: PathBuf,
Expand Down Expand Up @@ -518,6 +544,9 @@ mod tests {

#[test]
fn retained_descriptor_receipt_revalidates_clean_exact_lineage() {
if isolated_helper_test("workspace_helper::remote::tests::retained_descriptor_receipt_revalidates_clean_exact_lineage") {
return;
}
let repository = TestRepository::new();
let config = RemoteHelperConfig::for_test(repository.root.clone(), repository.git.clone());
let prepared = prepare_workspace(
Expand Down Expand Up @@ -577,6 +606,9 @@ mod tests {

#[test]
fn exchange_refuses_wrong_receipt_without_starting_shell() {
if isolated_helper_test("workspace_helper::remote::tests::exchange_refuses_wrong_receipt_without_starting_shell") {
return;
}
let repository = TestRepository::new();
let config = RemoteHelperConfig::for_test(repository.root.clone(), repository.git.clone());
let prepare = super::super::encode_request(&RequestFrame::Prepare(
Expand Down
13 changes: 12 additions & 1 deletion crates/shelldeck-terminal/src/grid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -662,7 +662,11 @@ impl TerminalGrid {
if width == 0 {
let row = self.cursor.row;
self.ensure_row(row);
let target_col = if self.cursor.col > 0 {
// At the right margin the cursor stays on the last written cell
// until a spacing character resolves the pending wrap.
let target_col = if self.pending_wrap {
self.cursor.col
} else if self.cursor.col > 0 {
self.cursor.col - 1
} else {
0
Expand Down Expand Up @@ -1678,7 +1682,14 @@ impl TerminalGrid {
let rows = self.rows;
let cols = self.cols;
let max_scrollback = self.max_scrollback;
// These belong to the session/host, rather than the resettable screen.
// Dropping the sender disconnects PTY replies; rewinding the generation
// makes existing command-completion observers miss subsequent events.
let response_tx = self.response_tx.take();
let prompt_mark_sequence = self.prompt_mark_sequence;
*self = Self::with_scrollback(rows, cols, max_scrollback);
self.response_tx = response_tx;
self.prompt_mark_sequence = prompt_mark_sequence;
}

/// Adjust the maximum number of scrollback lines retained, preserving the
Expand Down
94 changes: 93 additions & 1 deletion crates/shelldeck-terminal/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ fn parse_osc_color(spec: &str) -> Option<(u8, u8, u8)> {
}
} else if let Some(rest) = spec.strip_prefix('#') {
// Format: #RRGGBB
if rest.len() == 6 {
if rest.len() == 6 && rest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
let r = u8::from_str_radix(&rest[0..2], 16).ok()?;
let g = u8::from_str_radix(&rest[2..4], 16).ok()?;
let b = u8::from_str_radix(&rest[4..6], 16).ok()?;
Expand Down Expand Up @@ -1343,4 +1343,96 @@ mod tests {
// Response is 1-indexed: row 3, col 3.
assert_eq!(resp, b"\x1b[3;3R");
}

// SDTEST-1926
#[test]
fn malformed_unicode_palette_colors_preserve_state_and_parser_recovery() {
let grid = run(2, 10, b"\x1b]4;1;#123456\x07");
let mut processor = TerminalProcessor::new(grid.clone());
let mut parser = vte::Parser::new();
for color in ["#a€bc", "#abc€", "#é1234", "#xyzxyz"] {
let input = format!("\x1b]4;1;{color}\x07");
// PTY reads can split UTF-8 and escape sequences anywhere.
for chunk in input.as_bytes().chunks(3) {
processor.process_bytes(&mut parser, chunk);
}
assert_eq!(grid.lock().get_palette_color(1), Some((0x12, 0x34, 0x56)));
}
processor.process_bytes(&mut parser, b"\x1b]4;2;#abcdef\x07OK");
let grid = grid.lock();
assert_eq!(grid.get_palette_color(2), Some((0xab, 0xcd, 0xef)));
assert!(row_text(&grid, 0).starts_with("OK"));
}

// SDTEST-1927
#[test]
fn terminal_reset_keeps_pty_replies_connected() {
let grid = Arc::new(Mutex::new(TerminalGrid::new(3, 10)));
let (tx, rx) = std::sync::mpsc::channel();
grid.lock().set_response_tx(tx);
let mut processor = TerminalProcessor::new(grid.clone());
let mut parser = vte::Parser::new();
processor.process_bytes(&mut parser, b"old\r\noutput\r\nlast\r\n\x1b[1m");
assert!(grid.lock().scrollback_len() > 0);
processor.process_bytes(&mut parser, b"\x1bc");
{
let grid = grid.lock();
assert_eq!(grid.scrollback_len(), 0);
assert_eq!(row_text(&grid, 0), " ");
assert!(!grid.current_attrs.bold);
}
// Shells and TUIs still need device/status replies after `reset`.
processor.process_bytes(&mut parser, b"\x1b[2;4H\x1b[6n\x1b[c");
assert_eq!(rx.try_recv().expect("CPR after RIS"), b"\x1b[2;4R");
assert_eq!(rx.try_recv().expect("DA after RIS"), b"\x1b[?62;22c");
}

// SDTEST-1928
#[test]
fn combining_characters_at_right_margin_attach_before_wrapping() {
for (cols, text, base_col) in [(4, "abce", 3), (4, "ab界", 2), (1, "e", 0)] {
let grid = run(2, cols, text.as_bytes());
let mut processor = TerminalProcessor::new(grid.clone());
let mut parser = vte::Parser::new();
processor.process_bytes(&mut parser, "\u{0301}".as_bytes());
{
let grid = grid.lock();
assert_eq!(grid.cells[0][base_col].combining.as_slice(), &['\u{0301}']);
assert_eq!(grid.cursor.row, 0, "combining characters must not wrap");
for (col, cell) in grid.cells[0].iter().enumerate() {
if col != base_col {
assert!(cell.combining.is_empty(), "accent attached to column {col}");
}
}
}
processor.process_bytes(&mut parser, b"X");
let grid = grid.lock();
assert_eq!(grid.cells[1][0].c, 'X');
assert!(grid.line_flags[1].soft_wrapped);
}
}

// SDTEST-1929
#[test]
fn command_completion_generation_stays_monotonic_across_reset() {
let grid = run(2, 10, b"\x1b]133;C\x07before\x1b]133;D;0\x07");
let observed = grid.lock().prompt_mark_sequence;
let mut processor = TerminalProcessor::new(grid.clone());
let mut parser = vte::Parser::new();
processor.process_bytes(&mut parser, b"\x1bc");
{
let grid = grid.lock();
assert!(grid.prompt_mark.is_none());
assert!(grid.command_output_start.is_none());
}
processor.process_bytes(&mut parser, b"\x1b]133;C\x07after\x1b]133;D;7\x07");
let grid = grid.lock();
assert!(
grid.prompt_mark_sequence > observed,
"completion must reach existing observers"
);
assert_eq!(grid.prompt_mark, Some(PromptMark::CommandFinished(Some(7))));
assert!(grid.command_output(10, 100).contains("after"));
assert!(!grid.command_output(10, 100).contains("before"));
}
}
6 changes: 4 additions & 2 deletions crates/shelldeck-ui/src/agent_console_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1285,10 +1285,11 @@ impl AgentConsoleView {
.border_color(ShellDeckColors::border())
.child(lucide_icon("search", 11.0, ShellDeckColors::text_muted()))
.child(
div().flex_1().min_w(px(0.0)).child(
div().flex().flex_col().flex_1().min_w(px(0.0)).child(
Input::new(&self.navigator_search)
.variant(InputVariant::Bare)
.size(InputSize::Sm)
.w_full()
.placeholder(t!("fleet.sessions.search").to_string())
.on_change(move |_value, cx| {
parent.update(cx, |_this, cx| cx.notify());
Expand Down Expand Up @@ -2489,7 +2490,8 @@ impl Render for AgentConsoleView {
.flex()
.size_full()
.min_h(px(0.0))
.bg(ShellDeckColors::bg_primary());
.bg(ShellDeckColors::bg_primary())
.text_color(ShellDeckColors::text_primary());
if wide {
root = root.child(self.render_navigator(cx));
}
Expand Down
32 changes: 20 additions & 12 deletions crates/shelldeck-ui/src/fleet_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3416,18 +3416,18 @@ impl FleetView {
list
}

fn render_session_search(&self, cx: &mut Context<Self>) -> impl IntoElement {
Input::new(&self.search_state)
.size(InputSize::Sm)
fn render_session_search(&self, compact: bool, cx: &mut Context<Self>) -> impl IntoElement {
let mut input = Input::new(&self.search_state).size(InputSize::Sm);
// The desktop session column is 340 px with 10 px padding on each
// side. A definite input width prevents its text viewport from
// resolving a percentage against an intrinsic/min-content width.
if !compact {
input = input.w(px(320.0));
}
input
.placeholder(t!("fleet.sessions.search").to_string())
.clearable(true)
.prefix(
svg()
.path("icons/lucide/search.svg")
.size(px(12.0))
.flex_shrink_0()
.text_color(ShellDeckColors::text_muted()),
)
.prefix(lucide_icon("search", 12.0, ShellDeckColors::text_muted()))
.on_change({
let entity = cx.entity();
move |value, cx| {
Expand Down Expand Up @@ -4060,11 +4060,15 @@ impl Render for FleetView {
.flex_col()
.child(
div()
.flex()
.flex_col()
.w_full()
.min_w(px(0.0))
.px(px(10.0))
.py(px(8.0))
.border_b_1()
.border_color(ShellDeckColors::border())
.child(self.render_session_search(cx)),
.child(self.render_session_search(compact, cx)),
)
.child(self.render_pane_tabs(cx))
.child(self.render_selected_pane(cx))
Expand Down Expand Up @@ -4115,11 +4119,15 @@ impl Render for FleetView {
))
.child(
div()
.flex()
.flex_col()
.w_full()
.min_w(px(0.0))
.px(px(10.0))
.py(px(8.0))
.border_b_1()
.border_color(ShellDeckColors::border())
.child(self.render_session_search(cx)),
.child(self.render_session_search(compact, cx)),
)
.child(self.render_sessions(cx)),
)
Expand Down
4 changes: 2 additions & 2 deletions crates/shelldeck-ui/src/login_form.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use crate::scale::px;
use gpui::prelude::*;
use gpui::*;

use adabraka_ui::components::input::{Input, InputSize, InputState};
use adabraka_ui::components::input::{Input, InputSize, InputState, InputType};

use crate::overlay::{window_backdrop, InputEscape};
use crate::t;
Expand Down Expand Up @@ -84,7 +84,7 @@ impl LoginForm {
pub fn new(server: String, device: String, cx: &mut Context<Self>) -> Self {
Self {
email_state: cx.new(InputState::new),
password_state: cx.new(InputState::new),
password_state: cx.new(|cx| InputState::new(cx).input_type(InputType::Password)),
device,
server,
busy: false,
Expand Down
Loading
Loading