diff --git a/Cargo.lock b/Cargo.lock index 6947854a6..0223f8382 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,6 +87,26 @@ version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "async-channel" version = "2.3.1" @@ -191,12 +211,24 @@ version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "0.4.12" @@ -322,6 +354,15 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "colorchoice" version = "1.0.3" @@ -557,6 +598,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.6" @@ -722,6 +769,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.9.1", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -782,6 +839,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "event-listener" version = "5.3.1" @@ -809,6 +872,35 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.93", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "filetime" version = "0.2.25" @@ -1025,6 +1117,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.2", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.15" @@ -1155,6 +1257,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy 0.8.27", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1511,6 +1624,20 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1798,6 +1925,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ffbe83022cedc1d264172192511ae958937694cd57ce297164951b8b3568394" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -1824,6 +1952,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "nanorand" version = "0.7.0" @@ -1907,6 +2045,79 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.9.1", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.9.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.9.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.9.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.9.1", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.20.2" @@ -2021,6 +2232,19 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags 2.9.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "portable-atomic" version = "1.10.0" @@ -2039,7 +2263,7 @@ version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" dependencies = [ - "zerocopy", + "zerocopy 0.7.35", ] [[package]] @@ -2051,6 +2275,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quinn" version = "0.11.6" @@ -2123,6 +2362,7 @@ name = "railwayapp" version = "4.25.1" dependencies = [ "anyhow", + "arboard", "async-trait", "async-tungstenite", "base64", @@ -2624,6 +2864,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "similar" version = "2.7.0" @@ -2912,6 +3158,20 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.37" @@ -3348,6 +3608,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "which" version = "7.0.1" @@ -3701,6 +3967,23 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.2", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xattr" version = "1.3.1" @@ -3743,7 +4026,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ "byteorder", - "zerocopy-derive", + "zerocopy-derive 0.7.35", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive 0.8.27", ] [[package]] @@ -3757,6 +4049,17 @@ dependencies = [ "syn 2.0.93", ] +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.93", +] + [[package]] name = "zerofrom" version = "0.1.5" @@ -3805,3 +4108,18 @@ dependencies = [ "quote", "syn 2.0.93", ] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index f581bc0fa..5ead36e31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.95" +arboard = "3" clap = { version = "4.5.23", features = ["derive", "suggestions", "cargo"] } colored = "2.2.0" dirs = "5.0.1" diff --git a/src/controllers/develop/tui/app.rs b/src/controllers/develop/tui/app.rs index bfc417efb..f0949ad2d 100644 --- a/src/controllers/develop/tui/app.rs +++ b/src/controllers/develop/tui/app.rs @@ -1,7 +1,9 @@ +use std::collections::HashMap; + use colored::Color; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; -use super::log_store::{LogStore, StoredLogLine}; +use super::log_store::{LogRef, LogStore, StoredLogLine}; use crate::controllers::develop::LogLine; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -38,16 +40,40 @@ pub struct ServiceInfo { pub process_index: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Selection { + pub start: (usize, usize), // (row, col) in visible log area + pub end: (usize, usize), +} + +impl Selection { + pub fn normalized(&self) -> ((usize, usize), (usize, usize)) { + if self.start <= self.end { + (self.start, self.end) + } else { + (self.end, self.start) + } + } +} + pub struct TuiApp { pub current_tab: Tab, pub scroll_offset: usize, pub follow_mode: bool, + pub show_info: bool, pub log_store: LogStore, pub services: Vec, - service_name_to_idx: std::collections::HashMap, + pub selection: Option, + pub selecting: bool, + pub copied_feedback: Option, + pub copy_failed: Option, + pub needs_clear: bool, + service_name_to_idx: HashMap, visible_height: usize, code_count: usize, image_count: usize, + log_area_top: u16, + log_area_height: u16, } impl TuiApp { @@ -75,12 +101,20 @@ impl TuiApp { current_tab: initial_tab, scroll_offset: 0, follow_mode: true, + show_info: true, log_store: LogStore::new(services.len()), services, + selection: None, + selecting: false, + copied_feedback: None, + copy_failed: None, + needs_clear: false, service_name_to_idx, - visible_height: 20, + visible_height: 0, code_count, image_count, + log_area_top: 0, + log_area_height: 0, } } @@ -96,6 +130,11 @@ impl TuiApp { self.visible_height = height; } + pub fn set_log_area(&mut self, top: u16, height: u16) { + self.log_area_top = top; + self.log_area_height = height; + } + pub fn push_log(&mut self, log: LogLine, is_docker: bool) { let service_idx = self .service_name_to_idx @@ -104,6 +143,7 @@ impl TuiApp { .unwrap_or(0); let stored = StoredLogLine { + service_name: log.service_name, message: log.message, color: log.color, }; @@ -115,11 +155,19 @@ impl TuiApp { } } - pub fn handle_key(&mut self, key: KeyEvent) -> TuiAction { + /// Returns (action, tab_changed) + pub fn handle_key(&mut self, key: KeyEvent) -> (TuiAction, bool) { + let prev_tab = self.current_tab; + match key.code { - KeyCode::Char('q') | KeyCode::Esc => return TuiAction::Quit, + KeyCode::Char('q') | KeyCode::Esc => return (TuiAction::Quit, false), KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - return TuiAction::Quit; + return (TuiAction::Quit, false); + } + + // Copy selection + KeyCode::Char('y') => { + self.copy_selection(); } // Restart @@ -129,7 +177,12 @@ impl TuiApp { Tab::Image => RestartRequest::Image, Tab::Service(idx) => RestartRequest::Service(idx), }; - return TuiAction::Restart(request); + return (TuiAction::Restart(request), false); + } + + // Toggle info pane + KeyCode::Char('i') => { + self.show_info = !self.show_info; } // Tab selection by number @@ -155,16 +208,18 @@ impl TuiApp { // Scrolling KeyCode::Char('j') | KeyCode::Down => { - self.exit_follow_mode(); - self.scroll_down(1); + if !self.follow_mode { + self.scroll_down(1); + } } KeyCode::Char('k') | KeyCode::Up => { self.exit_follow_mode(); self.scroll_up(1); } KeyCode::PageDown => { - self.exit_follow_mode(); - self.scroll_down(20); + if !self.follow_mode { + self.scroll_down(20); + } } KeyCode::PageUp => { self.exit_follow_mode(); @@ -182,6 +237,7 @@ impl TuiApp { // Follow mode toggle KeyCode::Char('f') => { self.follow_mode = !self.follow_mode; + self.needs_clear = true; if self.follow_mode { self.scroll_to_bottom(); } @@ -189,36 +245,179 @@ impl TuiApp { _ => {} } - TuiAction::None + + let tab_changed = self.current_tab != prev_tab; + (TuiAction::None, tab_changed) } pub fn handle_mouse(&mut self, event: MouseEvent) { + let row = event.row; + let col = event.column; + match event.kind { + MouseEventKind::Down(MouseButton::Left) => { + // Check if click is in log area + if row >= self.log_area_top && row < self.log_area_top + self.log_area_height { + let log_row = (row - self.log_area_top) as usize; + self.selection = Some(Selection { + start: (log_row, col as usize), + end: (log_row, col as usize), + }); + self.selecting = true; + } else { + self.selection = None; + self.selecting = false; + } + } + MouseEventKind::Drag(MouseButton::Left) if self.selecting => { + if row >= self.log_area_top && row < self.log_area_top + self.log_area_height { + let log_row = (row - self.log_area_top) as usize; + if let Some(sel) = &mut self.selection { + sel.end = (log_row, col as usize); + } + } + } + MouseEventKind::Up(MouseButton::Left) => { + self.selecting = false; + // Auto-copy if selection is non-trivial + if let Some(sel) = &self.selection { + if sel.start != sel.end { + self.copy_selection(); + } else { + self.selection = None; + } + } + } MouseEventKind::ScrollDown => { - self.exit_follow_mode(); - self.scroll_down(1); + // Don't exit follow mode when scrolling down - it's a no-op at bottom + if !self.follow_mode { + self.scroll_down(3); + } } MouseEventKind::ScrollUp => { self.exit_follow_mode(); - self.scroll_up(1); + self.scroll_up(3); } _ => {} } } + fn copy_selection(&mut self) { + let Some(sel) = &self.selection else { return }; + + let text = self.get_selected_text(sel); + if text.is_empty() { + return; + } + + let now = std::time::Instant::now(); + match arboard::Clipboard::new().and_then(|mut cb| cb.set_text(&text)) { + Ok(()) => self.copied_feedback = Some(now), + Err(_) => self.copy_failed = Some(now), + } + self.selection = None; + } + + fn get_selected_text(&self, sel: &Selection) -> String { + let ((sr, sc), (er, ec)) = sel.normalized(); + let visible_height = self.log_area_height as usize; + let total = self.current_log_count(); + + let view_start = if self.follow_mode { + total.saturating_sub(visible_height) + } else { + self.scroll_offset + }; + + // Only collect logs in the selected range + let log_start = view_start + sr; + let log_end = (view_start + er + 1).min(total); + let selected_rows = er - sr + 1; + + let logs: Vec> = match self.current_tab { + Tab::Local => self + .log_store + .local_logs + .iter() + .skip(log_start) + .take(selected_rows) + .map(LogRef::Entry) + .collect(), + Tab::Image => self + .log_store + .image_logs + .iter() + .skip(log_start) + .take(selected_rows) + .map(LogRef::Entry) + .collect(), + Tab::Service(idx) => self + .log_store + .services + .get(idx) + .map(|buf| { + buf.lines + .iter() + .skip(log_start) + .take(selected_rows) + .map(|line| LogRef::Service(idx, line)) + .collect() + }) + .unwrap_or_default(), + }; + + let mut result = String::new(); + for (i, log_ref) in logs.iter().enumerate() { + let vis_row = sr + i; + if vis_row > er || view_start + vis_row >= log_end { + break; + } + + let (_, service_name, message, _) = log_ref.parts(); + let full_line = format!("[{}] {}", service_name, message); + let line_chars: Vec = full_line.chars().collect(); + let line_len = line_chars.len(); + + let col_start = if vis_row == sr { sc } else { 0 }; + let col_end = if vis_row == er { + ec.min(line_len.saturating_sub(1)) + } else { + line_len.saturating_sub(1) + }; + + if col_start <= col_end && col_start < line_len { + let selected: String = line_chars[col_start..=col_end.min(line_len - 1)] + .iter() + .collect(); + result.push_str(&selected); + } + + if vis_row < er { + result.push('\n'); + } + } + + result + } + fn exit_follow_mode(&mut self) { if self.follow_mode { let total = self.current_log_count(); self.scroll_offset = total.saturating_sub(self.visible_height); self.follow_mode = false; + self.needs_clear = true; } } fn select_tab(&mut self, visual_idx: usize) { let tab = self.visual_to_tab(visual_idx); if let Some(t) = tab { + if self.current_tab != t { + self.needs_clear = true; + } self.current_tab = t; self.scroll_offset = 0; + self.selection = None; if self.follow_mode { self.scroll_to_bottom(); } @@ -330,8 +529,15 @@ impl TuiApp { } fn scroll_down(&mut self, amount: usize) { - let max_scroll = self.current_log_count().saturating_sub(1); + let total = self.current_log_count(); + let max_scroll = total.saturating_sub(self.visible_height); self.scroll_offset = (self.scroll_offset + amount).min(max_scroll); + + // Auto-enable follow mode when scrolled to bottom + if self.scroll_offset >= max_scroll && !self.follow_mode { + self.follow_mode = true; + self.needs_clear = true; + } } fn scroll_up(&mut self, amount: usize) { @@ -343,6 +549,7 @@ impl TuiApp { } fn scroll_to_bottom(&mut self) { - self.scroll_offset = self.current_log_count().saturating_sub(1); + let total = self.current_log_count(); + self.scroll_offset = total.saturating_sub(self.visible_height); } } diff --git a/src/controllers/develop/tui/log_store.rs b/src/controllers/develop/tui/log_store.rs index 142707192..4f249bad3 100644 --- a/src/controllers/develop/tui/log_store.rs +++ b/src/controllers/develop/tui/log_store.rs @@ -6,6 +6,7 @@ const MAX_LINES: usize = 100_000; #[derive(Debug, Clone)] pub struct StoredLogLine { + pub service_name: String, pub message: String, pub color: Color, } @@ -45,6 +46,27 @@ pub struct LogStore { pub image_logs: VecDeque, } +/// Reference to a log entry, used for unified iteration over different log sources +pub enum LogRef<'a> { + Entry(&'a LogEntry), + Service(usize, &'a StoredLogLine), +} + +impl<'a> LogRef<'a> { + /// Returns (service_idx, service_name, message, color) + pub fn parts(&self) -> (usize, &str, &str, Color) { + match self { + LogRef::Entry(e) => ( + e.service_idx, + &e.line.service_name, + &e.line.message, + e.line.color, + ), + LogRef::Service(idx, line) => (*idx, &line.service_name, &line.message, line.color), + } + } +} + impl LogStore { pub fn new(service_count: usize) -> Self { Self { diff --git a/src/controllers/develop/tui/mod.rs b/src/controllers/develop/tui/mod.rs index 6e8dcd951..c0e397021 100644 --- a/src/controllers/develop/tui/mod.rs +++ b/src/controllers/develop/tui/mod.rs @@ -8,14 +8,11 @@ pub use docker_logs::{ServiceMapping, spawn_docker_logs}; use std::io::stdout; use std::panic; -use std::time::Duration; use anyhow::Result; use app::TuiApp; use crossterm::cursor::{Hide, Show}; -use crossterm::event::{ - DisableMouseCapture, EnableMouseCapture, Event, EventStream, MouseEventKind, -}; +use crossterm::event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream}; use crossterm::execute; use crossterm::terminal::{ EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, @@ -29,21 +26,16 @@ use super::LogLine; fn setup_terminal() -> Result>> { enable_raw_mode()?; - execute!(stdout(), EnterAlternateScreen, Hide)?; - execute!(stdout(), EnableMouseCapture)?; - + execute!(stdout(), EnterAlternateScreen, Hide, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout()); - let terminal = Terminal::new(backend)?; + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; Ok(terminal) } fn restore_terminal() { - let _ = execute!(stdout(), DisableMouseCapture); - let _ = execute!(stdout(), LeaveAlternateScreen, Show); + let _ = execute!(stdout(), DisableMouseCapture, LeaveAlternateScreen, Show); let _ = disable_raw_mode(); - // Ensure cursor starts at column 0 on a fresh line - print!("\r\n"); - let _ = std::io::Write::flush(&mut stdout()); } pub async fn run( @@ -68,6 +60,10 @@ pub async fn run( let mut events = EventStream::new(); 'main: loop { + if app.needs_clear { + terminal.clear()?; + app.needs_clear = false; + } terminal.draw(|f| ui::render(&mut app, f))?; tokio::select! { @@ -78,7 +74,7 @@ pub async fn run( app.push_log(log, true); } Some(Ok(event)) = events.next() => { - match process_event(&mut app, event) { + match process_event(&mut app, &mut terminal, event) { TuiAction::Quit => break 'main, TuiAction::Restart(req) => { if let Some(tx) = &restart_tx { @@ -87,20 +83,6 @@ pub async fn run( } TuiAction::None => {} } - // Drain any queued events to batch scroll and prevent momentum lag - while let Ok(Some(Ok(event))) = - tokio::time::timeout(Duration::from_millis(1), events.next()).await - { - match process_event(&mut app, event) { - TuiAction::Quit => break 'main, - TuiAction::Restart(req) => { - if let Some(tx) = &restart_tx { - let _ = tx.send(req).await; - } - } - TuiAction::None => {} - } - } } _ = tokio::signal::ctrl_c() => { break; @@ -111,18 +93,25 @@ pub async fn run( Ok(()) } -fn process_event(app: &mut TuiApp, event: Event) -> TuiAction { +fn process_event( + app: &mut TuiApp, + terminal: &mut Terminal>, + event: Event, +) -> TuiAction { match event { Event::Key(key) => { - return app.handle_key(key); + let (action, _tab_changed) = app.handle_key(key); + action } - Event::Mouse(mouse) => match mouse.kind { - MouseEventKind::ScrollDown | MouseEventKind::ScrollUp => { - app.handle_mouse(mouse); - } - _ => {} - }, - _ => {} + Event::Mouse(mouse) => { + app.handle_mouse(mouse); + TuiAction::None + } + Event::Resize(_, _) => { + // Force full redraw on resize to prevent artifacts + let _ = terminal.clear(); + TuiAction::None + } + _ => TuiAction::None, } - TuiAction::None } diff --git a/src/controllers/develop/tui/ui.rs b/src/controllers/develop/tui/ui.rs index 5b7ab5c6f..e58d156f3 100644 --- a/src/controllers/develop/tui/ui.rs +++ b/src/controllers/develop/tui/ui.rs @@ -1,13 +1,13 @@ use ratatui::{ Frame, - layout::{Constraint, Layout}, + layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph, Tabs}, + widgets::{Block, Borders, Paragraph, Tabs}, }; -use super::app::{Tab, TuiApp}; -use super::log_store::LogEntry; +use super::app::{Selection, Tab, TuiApp}; +use super::log_store::LogRef; fn convert_color(c: colored::Color) -> Color { match c { @@ -32,24 +32,28 @@ fn convert_color(c: colored::Color) -> Color { } pub fn render(app: &mut TuiApp, frame: &mut Frame) { + let info_height = if app.show_info { 3 } else { 0 }; + let chunks = Layout::vertical([ - Constraint::Length(1), - Constraint::Min(3), - Constraint::Length(5), - Constraint::Length(1), + Constraint::Length(1), // Tab bar + Constraint::Min(1), // Log area + Constraint::Length(info_height), // Info pane + Constraint::Length(1), // Help bar ]) .split(frame.area()); - let visible_height = chunks[1].height.saturating_sub(2) as usize; - app.set_visible_height(visible_height); + // Store log area bounds for mouse handling + app.set_log_area(chunks[1].y, chunks[1].height); render_tabs(app, frame, chunks[0]); render_logs(app, frame, chunks[1]); - render_info_pane(app, frame, chunks[2]); + if app.show_info { + render_info_pane(app, frame, chunks[2]); + } render_help_bar(app, frame, chunks[3]); } -fn render_tabs(app: &TuiApp, frame: &mut Frame, area: ratatui::layout::Rect) { +fn render_tabs(app: &TuiApp, frame: &mut Frame, area: Rect) { let mut titles: Vec = Vec::new(); if app.show_local_tab() { @@ -71,6 +75,12 @@ fn render_tabs(app: &TuiApp, frame: &mut Frame, area: ratatui::layout::Rect) { let selected = app.tab_index(); + let follow_indicator = if app.follow_mode { + Span::styled(" [FOLLOW]", Style::default().fg(Color::Green)) + } else { + Span::styled(" [PAUSED]", Style::default().fg(Color::Yellow)) + }; + let tabs = Tabs::new(titles) .select(selected) .style(Style::default().fg(Color::Gray)) @@ -81,126 +91,185 @@ fn render_tabs(app: &TuiApp, frame: &mut Frame, area: ratatui::layout::Rect) { ) .divider("|"); - frame.render_widget(tabs, area); + let tab_chunks = Layout::horizontal([Constraint::Min(10), Constraint::Length(10)]).split(area); + + frame.render_widget(tabs, tab_chunks[0]); + frame.render_widget(Paragraph::new(Line::from(follow_indicator)), tab_chunks[1]); } -fn render_logs(app: &TuiApp, frame: &mut Frame, area: ratatui::layout::Rect) { - let visible_height = area.height.saturating_sub(2) as usize; - let total_logs = app.current_log_count(); +fn render_logs(app: &mut TuiApp, frame: &mut Frame, area: Rect) { + let visible_height = area.height as usize; + app.set_visible_height(visible_height); - let start = if app.follow_mode { - total_logs.saturating_sub(visible_height) + // Get total count without collecting all logs + let total = app.current_log_count(); + let start_idx = if app.follow_mode { + total.saturating_sub(visible_height) } else { - app.scroll_offset + app.scroll_offset.min(total.saturating_sub(visible_height)) }; - let lines: Vec = match app.current_tab { - Tab::Local => render_log_entries( - &app.log_store.local_logs, - &app.services, - start, - visible_height, - ), - Tab::Image => render_log_entries( - &app.log_store.image_logs, - &app.services, - start, - visible_height, - ), - Tab::Service(idx) => { - if let Some(service) = app.services.get(idx) { - if let Some(buffer) = app.log_store.services.get(idx) { - buffer - .lines - .iter() - .skip(start) - .take(visible_height) - .map(|log| { - let prefix = Span::styled( - format!("[{}] ", service.name), - Style::default().fg(convert_color(log.color)), - ); - let content = Span::raw(&log.message); - Line::from(vec![prefix, content]) - }) - .collect() - } else { - vec![] - } - } else { - vec![] - } - } - }; - - let title = match app.current_tab { - Tab::Local => "Local Services", - Tab::Image => "Image Services", + // Only collect visible logs + let logs: Vec = match app.current_tab { + Tab::Local => app + .log_store + .local_logs + .iter() + .skip(start_idx) + .take(visible_height) + .map(LogRef::Entry) + .collect(), + Tab::Image => app + .log_store + .image_logs + .iter() + .skip(start_idx) + .take(visible_height) + .map(LogRef::Entry) + .collect(), Tab::Service(idx) => app + .log_store .services .get(idx) - .map(|s| s.name.as_str()) - .unwrap_or("Service"), + .map(|buf| { + buf.lines + .iter() + .skip(start_idx) + .take(visible_height) + .map(|line| LogRef::Service(idx, line)) + .collect() + }) + .unwrap_or_default(), }; - let scroll_indicator = if total_logs > 0 { - let pos = if app.follow_mode { - total_logs - } else { - start + visible_height.min(total_logs) - }; - format!(" [{}/{}]", pos, total_logs) - } else { - String::new() - }; + let mut lines: Vec = Vec::with_capacity(visible_height); + + for (vis_row, log_ref) in logs.iter().enumerate() { + let (service_idx, service_name, message, log_color) = log_ref.parts(); + let service_color = app + .services + .get(service_idx) + .map(|s| convert_color(s.color)) + .unwrap_or_else(|| convert_color(log_color)); - let follow_indicator = if app.follow_mode { " [FOLLOW]" } else { "" }; + let line = render_log_line( + service_name, + service_color, + message, + vis_row, + &app.selection, + ); + lines.push(line); + } + + // Fill remaining space + while lines.len() < visible_height { + lines.push(Line::from("")); + } let block = Block::default() - .title(format!("{}{}{}", title, scroll_indicator, follow_indicator)) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)); + .borders(Borders::NONE) + .style(Style::default()); let paragraph = Paragraph::new(lines).block(block); - // Clear prevents stale logs from previous tab rendering through - frame.render_widget(Clear, area); frame.render_widget(paragraph, area); } -fn render_log_entries( - logs: &std::collections::VecDeque, - services: &[super::app::ServiceInfo], - start: usize, - count: usize, -) -> Vec> { - logs.iter() - .skip(start) - .take(count) - .map(|entry| { - let service_name = services - .get(entry.service_idx) - .map(|s| s.name.as_str()) - .unwrap_or("unknown"); - - let prefix = Span::styled( - format!("[{}] ", service_name), - Style::default().fg(convert_color(entry.line.color)), - ); - let content = Span::raw(entry.line.message.clone()); - Line::from(vec![prefix, content]) - }) - .collect() +fn render_log_line<'a>( + service_name: &str, + service_color: Color, + message: &str, + vis_row: usize, + selection: &Option, +) -> Line<'a> { + let prefix = format!("[{}] ", service_name); + let prefix_len = prefix.chars().count(); + + let Some(sel) = selection else { + return Line::from(vec![ + Span::styled(prefix, Style::default().fg(service_color)), + Span::raw(message.to_string()), + ]); + }; + + let ((start_row, start_col), (end_row, end_col)) = sel.normalized(); + + // Row not in selection at all + if vis_row < start_row || vis_row > end_row { + return Line::from(vec![ + Span::styled(prefix, Style::default().fg(service_color)), + Span::raw(message.to_string()), + ]); + } + + let full_line = format!("{}{}", prefix, message); + let chars: Vec = full_line.chars().collect(); + let line_len = chars.len(); + + // Calculate selection bounds for this row + let sel_start = if vis_row == start_row { start_col } else { 0 }; + let sel_end = if vis_row == end_row { + (end_col + 1).min(line_len) + } else { + line_len + }; + + // Build spans: [before selection] [selection] [after selection] + let mut spans = Vec::new(); + + if sel_start > 0 { + let text: String = chars[..sel_start.min(line_len)].iter().collect(); + let style = if sel_start <= prefix_len { + Style::default().fg(service_color) + } else { + Style::default() + }; + // Split if spans both prefix and message + if sel_start > prefix_len { + let prefix_text: String = chars[..prefix_len].iter().collect(); + let msg_text: String = chars[prefix_len..sel_start].iter().collect(); + spans.push(Span::styled( + prefix_text, + Style::default().fg(service_color), + )); + spans.push(Span::raw(msg_text)); + } else { + spans.push(Span::styled(text, style)); + } + } + + if sel_start < line_len && sel_end > sel_start { + let text: String = chars[sel_start..sel_end.min(line_len)].iter().collect(); + spans.push(Span::styled( + text, + Style::default().bg(Color::White).fg(Color::Black), + )); + } + + if sel_end < line_len { + let text: String = chars[sel_end..].iter().collect(); + let style = if sel_end < prefix_len { + Style::default().fg(service_color) + } else { + Style::default() + }; + spans.push(Span::styled(text, style)); + } + + Line::from(spans) } -fn render_info_pane(app: &TuiApp, frame: &mut Frame, area: ratatui::layout::Rect) { +fn render_info_pane(app: &TuiApp, frame: &mut Frame, area: Rect) { let services_to_show: Vec<&super::app::ServiceInfo> = match app.current_tab { Tab::Local => app.services.iter().filter(|s| !s.is_docker).collect(), Tab::Image => app.services.iter().filter(|s| s.is_docker).collect(), Tab::Service(idx) => app.services.get(idx).into_iter().collect(), }; + // Limit to 2 services to fit within the fixed 3-line info pane height let lines: Vec = services_to_show .iter() + .take(2) .map(|svc| { let mut spans = vec![Span::styled( format!("{}: ", svc.name), @@ -236,31 +305,25 @@ fn render_info_pane(app: &TuiApp, frame: &mut Frame, area: ratatui::layout::Rect }) .collect(); - let title = match app.current_tab { - Tab::Local => "Local Services", - Tab::Image => "Image Services", - Tab::Service(_) => "Service Info", - }; - let block = Block::default() - .title(title) - .borders(Borders::ALL) + .borders(Borders::TOP) .border_style(Style::default().fg(Color::DarkGray)); let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, area); } -fn render_help_bar(_app: &TuiApp, frame: &mut Frame, area: ratatui::layout::Rect) { - let help_text = vec![ +fn render_help_bar(app: &TuiApp, frame: &mut Frame, area: Rect) { + let mut help_text = vec![ Span::styled("1-9", Style::default().fg(Color::Yellow)), Span::raw(" tab "), - Span::styled("Tab", Style::default().fg(Color::Yellow)), - Span::raw(" cycle "), Span::styled("j/k", Style::default().fg(Color::Yellow)), Span::raw(" scroll "), - Span::styled("g/G", Style::default().fg(Color::Yellow)), - Span::raw(" top/bottom "), + Span::styled("drag", Style::default().fg(Color::Yellow)), + Span::raw(" copy "), + Span::styled("i", Style::default().fg(Color::Yellow)), + Span::raw(if app.show_info { " hide" } else { " info" }), + Span::raw(" "), Span::styled("f", Style::default().fg(Color::Yellow)), Span::raw(" follow "), Span::styled("r", Style::default().fg(Color::Yellow)), @@ -269,6 +332,23 @@ fn render_help_bar(_app: &TuiApp, frame: &mut Frame, area: ratatui::layout::Rect Span::raw(" quit"), ]; + // Show copy feedback + if let Some(instant) = app.copied_feedback { + if instant.elapsed().as_secs() < 2 { + help_text.push(Span::styled( + " [Copied!]", + Style::default().fg(Color::Green), + )); + } + } else if let Some(instant) = app.copy_failed { + if instant.elapsed().as_secs() < 2 { + help_text.push(Span::styled( + " [Copy failed]", + Style::default().fg(Color::Red), + )); + } + } + let paragraph = Paragraph::new(Line::from(help_text)); frame.render_widget(paragraph, area); }