diff --git a/Cargo.lock b/Cargo.lock index fff5f8b3c..4cf6740f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4106,6 +4106,17 @@ dependencies = [ "tree-sitter", ] +[[package]] +name = "ide-android" +version = "0.1.36" +dependencies = [ + "ide-core", + "serde", + "specta", + "tempfile", + "which", +] + [[package]] name = "ide-build" version = "0.1.36" @@ -6308,6 +6319,7 @@ dependencies = [ "gtk", "ide-agent", "ide-analysis", + "ide-android", "ide-build", "ide-clipboard", "ide-core", diff --git a/crates/ide-android/Cargo.toml b/crates/ide-android/Cargo.toml new file mode 100644 index 000000000..21c049e4d --- /dev/null +++ b/crates/ide-android/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ide-android" +description = "Android devices and logcat for the Logcat tool window, driven through the adb CLI" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +ide-core.workspace = true +serde.workspace = true +specta.workspace = true +which = "8" + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/ide-android/src/lib.rs b/crates/ide-android/src/lib.rs new file mode 100644 index 000000000..ade335cdb --- /dev/null +++ b/crates/ide-android/src/lib.rs @@ -0,0 +1,222 @@ +//! Android devices and their logs, driven through the `adb` CLI (like git is +//! driven through its CLI). +//! +//! The Logcat tool window needs two things from a device: that it is there, and +//! what it prints. Both come from `adb`, which the Android SDK installs — the +//! IDE does not bundle it. +//! +//! Every function blocks; callers run them off the UI / command threads. + +mod model; +mod process; + +pub use model::*; + +use ide_core::{CancellationToken, IdeError, IdeResult}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::OnceLock; +use std::time::Duration; + +/// `adb devices` is quick unless it starts the daemon, which can take a moment. +const DEVICES: Duration = Duration::from_secs(20); +/// `adb logcat -c` only truncates the buffers. +const CLEAR: Duration = Duration::from_secs(15); +/// How much of the buffer to show before following, so opening the window is not empty. +const TAIL_LINES: u32 = 200; +/// Logcat can print thousands of lines a second: batches keep the IPC channel sane, +/// while `IDLE` makes sure a quiet stream still shows its last line promptly. +const BATCH_IDLE: Duration = Duration::from_millis(60); +const BATCH_MAX: usize = 300; + +fn exe(name: &str) -> String { + if cfg!(windows) { format!("{name}.exe") } else { name.to_owned() } +} + +/// Android SDK locations, most specific first: the environment, then the usual +/// per-platform installs (a GUI app on macOS does not see a shell's `ANDROID_HOME`). +fn sdk_roots() -> Vec { + let mut roots: Vec = ["ANDROID_HOME", "ANDROID_SDK_ROOT"] + .iter() + .filter_map(|key| std::env::var_os(key).map(PathBuf::from)) + .collect(); + let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from); + if let Some(home) = home.filter(|_| !cfg!(windows)) { + roots.push(home.join("Library/Android/sdk")); + roots.push(home.join("Android/Sdk")); + } + if cfg!(windows) + && let Some(local) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) + { + roots.push(local.join("Android/Sdk")); + } + roots +} + +/// Locates the adb executable once per process; `OPENIDEA_ADB` overrides. +pub fn adb_executable() -> Option { + static ADB: OnceLock> = OnceLock::new(); + ADB.get_or_init(|| { + if let Some(explicit) = std::env::var_os("OPENIDEA_ADB").map(PathBuf::from).filter(|p| p.is_file()) { + return Some(explicit); + } + let name = exe("adb"); + if let Some(found) = + sdk_roots().into_iter().map(|root| root.join("platform-tools").join(&name)).find(|p| p.is_file()) + { + return Some(found); + } + if let Ok(found) = which::which("adb") { + return Some(found); + } + let mut candidates: Vec = if cfg!(windows) { + vec![PathBuf::from(r"C:\platform-tools\adb.exe")] + } else { + ["/usr/local/bin/adb", "/opt/homebrew/bin/adb", "/usr/bin/adb"].iter().map(PathBuf::from).collect() + }; + candidates.extend([ + PathBuf::from("/opt/android-sdk/platform-tools/adb"), + PathBuf::from("/usr/lib/android-sdk/platform-tools/adb"), + ]); + candidates.into_iter().find(|p: &PathBuf| p.is_file()) + }) + .clone() +} + +fn executable() -> IdeResult { + adb_executable().ok_or_else(|| { + IdeError::unavailable( + "adb was not found: install the Android SDK platform-tools, or set ANDROID_HOME (or OPENIDEA_ADB)", + ) + }) +} + +/// The directory holding adb, for command lines that expect `adb` on `PATH`. +pub fn platform_tools_dir() -> Option<&'static Path> { + static DIR: OnceLock> = OnceLock::new(); + DIR.get_or_init(|| adb_executable().as_deref().and_then(Path::parent).map(Path::to_path_buf)).as_deref() +} + +/// Connected devices, online ones first; starting the adb daemon when needed. +pub fn devices() -> IdeResult> { + let adb = executable()?; + let mut cmd = Command::new(adb); + cmd.args(["devices", "-l"]); + let out = process::run(cmd, DEVICES, None)?; + if !out.success { + let message = out.stderr.trim(); + return Err(IdeError::unavailable(if message.is_empty() { + "adb devices failed".to_owned() + } else { + format!("adb devices failed: {message}") + })); + } + Ok(parse_devices(&out.stdout)) +} + +/// Empties the device's log buffers (`adb logcat -c`). +pub fn clear_log(serial: &str, cancel: &CancellationToken) -> IdeResult<()> { + let adb = executable()?; + let mut cmd = Command::new(adb); + cmd.args(["-s", serial, "logcat", "-c"]); + let out = process::run(cmd, CLEAR, Some(cancel))?; + if out.success { + Ok(()) + } else { + Err(IdeError::unavailable(format!("adb logcat -c failed: {}", out.stderr.trim()))) + } +} + +fn command(adb: &Path, serial: &str, tail: Option) -> Command { + let mut cmd = Command::new(adb); + cmd.args(["-s", serial, "logcat", "-v", "threadtime"]); + if let Some(lines) = tail { + // Recent history first, then follow: `-T ` is lines, not an exit. + cmd.arg("-T").arg(lines.to_string()); + } + cmd +} + +/// Follows `serial`'s log until `cancel` fires, handing lines over in batches. +/// With `clear`, the buffers are emptied first so the window starts clean. +pub fn logcat( + serial: &str, + clear: bool, + cancel: &CancellationToken, + on_batch: &mut dyn FnMut(Vec), +) -> IdeResult<()> { + let adb = executable()?; + if clear { + clear_log(serial, cancel)?; + } + let mut parse = |line: &str| Some(parse_line(line)); + let (ok, stderr) = match process::stream_batched( + command(&adb, serial, Some(TAIL_LINES)), + cancel, + BATCH_IDLE, + BATCH_MAX, + &mut parse, + on_batch, + ) { + Ok(result) => result, + // The window closed or switched devices: stopping is the expected end. + Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Ok(()), + Err(e) => return Err(e), + }; + if ok || cancel.is_cancelled() { + return Ok(()); + } + // `-T` is old (API 21) but adb forwards logcat options it does not know on + // very old devices; retry without the history rather than showing nothing. + let unsupported = stderr.iter().any(|line| { + line.contains("Unrecognized option") || line.contains("unknown option") || line.contains("Invalid option") + }); + if unsupported { + let (ok, stderr) = match process::stream_batched( + command(&adb, serial, None), + cancel, + BATCH_IDLE, + BATCH_MAX, + &mut parse, + on_batch, + ) { + Ok(result) => result, + Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Ok(()), + Err(e) => return Err(e), + }; + if ok || cancel.is_cancelled() { + return Ok(()); + } + return Err(failure(serial, &stderr)); + } + Err(failure(serial, &stderr)) +} + +fn failure(serial: &str, stderr: &[String]) -> IdeError { + let message = stderr.join("\n"); + IdeError::unavailable(if message.trim().is_empty() { + format!("adb logcat on {serial} stopped") + } else { + format!("adb logcat on {serial} stopped: {message}") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn adb_candidates_are_absolute() { + for root in sdk_roots() { + assert!(root.is_absolute(), "{root:?}"); + } + } + + #[test] + fn executable_is_absolute_when_found() { + if let Some(adb) = adb_executable() { + assert!(adb.is_absolute()); + assert!(adb.is_file()); + } + } +} diff --git a/crates/ide-android/src/model.rs b/crates/ide-android/src/model.rs new file mode 100644 index 000000000..1bbada32d --- /dev/null +++ b/crates/ide-android/src/model.rs @@ -0,0 +1,276 @@ +//! What the Logcat tool window shows: the devices `adb` reports and the lines +//! `adb logcat` prints, parsed from its `threadtime` format. + +use serde::{Deserialize, Serialize}; +use specta::Type; + +/// One entry of `adb devices -l`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AndroidDevice { + /// `adb -s` value: a USB serial number, `emulator-5554`, or `host:port`. + pub serial: String, + /// `device` (usable), `offline`, `unauthorized` or `no permissions`. + pub state: String, + pub model: Option, + pub product: Option, + /// The device's own name from the build (`ro.product.device`). + pub device: Option, + pub transport_id: Option, +} + +impl AndroidDevice { + /// Ready for `logcat` / `install`. + pub fn is_online(&self) -> bool { + self.state == "device" + } + + /// What the picker calls it: the model when known, the serial otherwise. + pub fn label(&self) -> &str { + self.model.as_deref().filter(|m| !m.trim().is_empty()).unwrap_or(&self.serial) + } +} + +/// The single-letter logcat priority, in increasing severity. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum LogcatLevel { + Verbose, + Debug, + Info, + Warn, + Error, + Fatal, + /// A line logcat prints outside the message format (`--------- beginning of main`). + Unknown, +} + +impl LogcatLevel { + pub fn letter(self) -> char { + match self { + Self::Verbose => 'V', + Self::Debug => 'D', + Self::Info => 'I', + Self::Warn => 'W', + Self::Error => 'E', + Self::Fatal => 'F', + Self::Unknown => '?', + } + } + + fn parse(c: char) -> Self { + match c.to_ascii_uppercase() { + 'V' => Self::Verbose, + 'D' => Self::Debug, + 'I' => Self::Info, + 'W' => Self::Warn, + 'E' => Self::Error, + 'F' | 'A' => Self::Fatal, + _ => Self::Unknown, + } + } +} + +/// One [`LogcatLevel`]-tagged line of `adb logcat -v threadtime`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct LogcatLine { + /// `MM-DD HH:MM:SS.mmm`, as logcat prints it (no year, device time zone). + pub time: String, + pub pid: i32, + pub tid: i32, + pub level: LogcatLevel, + pub tag: String, + pub message: String, +} + +impl LogcatLine { + /// A line logcat printed outside the message format, kept so nothing is lost. + fn unparsed(text: &str) -> Self { + Self { + time: String::new(), + pid: 0, + tid: 0, + level: LogcatLevel::Unknown, + tag: String::new(), + message: text.to_owned(), + } + } +} + +/// Parses `adb devices -l` output; the daemon's chatter on stderr is not included. +pub fn parse_devices(output: &str) -> Vec { + let mut devices: Vec = Vec::new(); + for line in output.lines() { + let line = line.trim_end(); + if line.trim().is_empty() || line.starts_with("List of devices") || line.starts_with('*') { + continue; + } + let mut fields = line.split_whitespace(); + let (Some(serial), Some(state)) = (fields.next(), fields.next()) else { + continue; + }; + // "no permissions (user in plugdev group; are your udev rules wrong?)" + // is the one state that carries spaces; the rest is a `key:value` list. + let mut device = AndroidDevice { + serial: serial.to_owned(), + state: state.to_owned(), + model: None, + product: None, + device: None, + transport_id: None, + }; + if device.state == "no" { + device.state = "no permissions".to_owned(); + devices.push(device); + continue; + } + for field in fields { + let Some((key, value)) = field.split_once(':') else { + continue; + }; + match key { + "model" => device.model = Some(value.to_owned()), + "product" => device.product = Some(value.to_owned()), + "device" => device.device = Some(value.to_owned()), + "transport_id" => device.transport_id = Some(value.to_owned()), + _ => {} + } + } + devices.push(device); + } + // Online first, then by name, so the picker's default is the device in hand. + devices.sort_by(|a, b| b.is_online().cmp(&a.is_online()).then_with(|| a.serial.cmp(&b.serial))); + devices +} + +/// Parses one `logcat -v threadtime` line: +/// +/// ```text +/// 11-04 12:52:39.123 3048 3059 I WindowManager: displayed +/// ``` +/// +/// Text that does not follow the format still becomes a line, so a device that +/// prints something unexpected does not look like a dead stream. +pub fn parse_line(text: &str) -> LogcatLine { + let Some(line) = parse_formatted(text) else { + return LogcatLine::unparsed(text); + }; + line +} + +fn parse_formatted(text: &str) -> Option { + // "MM-DD HH:MM:SS.mmm" is 18 bytes; logcat pads the rest with spaces. + let stamp = text.get(..18)?; + if !looks_like_stamp(stamp) { + return None; + } + let mut rest = text.get(18..)?; + let pid: i32 = take_field(&mut rest)?.parse().ok()?; + let tid: i32 = take_field(&mut rest)?.parse().ok()?; + let level = LogcatLevel::parse(take_field(&mut rest)?.chars().next()?); + let tail = rest.trim_start(); + // "Tag : message": logcat pads the tag to 8 columns, and the tag + // itself never contains a colon followed by a space. + let (tag, message) = match tail.split_once(": ") { + Some((tag, message)) => (tag.trim_end(), message), + None => (tail.trim_end(), ""), + }; + Some(LogcatLine { time: stamp.to_owned(), pid, tid, level, tag: tag.to_owned(), message: message.to_owned() }) +} + +/// Consumes the next space-separated field of `rest`, leaving the remainder. +fn take_field<'a>(rest: &mut &'a str) -> Option<&'a str> { + *rest = rest.trim_start(); + if rest.is_empty() { + return None; + } + let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); + let field = &rest[..end]; + *rest = &rest[end..]; + Some(field) +} + +fn looks_like_stamp(s: &str) -> bool { + let bytes = s.as_bytes(); + bytes.len() == 18 + && bytes[2] == b'-' + && bytes[5] == b' ' + && bytes[8] == b':' + && bytes[11] == b':' + && bytes[14] == b'.' + && bytes.iter().enumerate().all(|(i, b)| matches!(i, 2 | 5 | 8 | 11 | 14) || b.is_ascii_digit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_adb_devices_with_attributes() { + let out = "\ +List of devices attached +HT7A1B2C3D4 device product:generic model:Generic_Phone device:generic transport_id:1 +emulator-5554 offline transport_id:2 +0123456789ABCDEF unauthorized usb:1-1 transport_id:3 + +"; + let devices = parse_devices(out); + assert_eq!(devices.len(), 3); + // Online first, then by serial. + assert_eq!(devices[0].serial, "HT7A1B2C3D4"); + assert_eq!(devices[0].state, "device"); + assert_eq!(devices[0].model.as_deref(), Some("Generic_Phone")); + assert_eq!(devices[0].transport_id.as_deref(), Some("1")); + assert!(devices[0].is_online()); + assert_eq!(devices[1].serial, "0123456789ABCDEF"); + assert_eq!(devices[1].state, "unauthorized"); + assert_eq!(devices[1].label(), "0123456789ABCDEF"); + assert_eq!(devices[2].serial, "emulator-5554"); + assert!(!devices[2].is_online()); + } + + #[test] + fn reads_adb_devices_when_the_list_is_empty() { + assert!(parse_devices("List of devices attached\n\n").is_empty()); + } + + #[test] + fn reads_no_permissions() { + let devices = parse_devices("ABC\tno permissions (user in plugdev group; are your udev rules wrong?)\n"); + assert_eq!(devices.len(), 1); + assert_eq!(devices[0].state, "no permissions"); + } + + #[test] + fn parses_a_threadtime_line() { + let line = parse_line("11-04 12:52:39.123 3048 3059 I WindowManager: Displayed activity"); + assert_eq!(line.time, "11-04 12:52:39.123"); + assert_eq!(line.pid, 3048); + assert_eq!(line.tid, 3059); + assert_eq!(line.level, LogcatLevel::Info); + assert_eq!(line.tag, "WindowManager"); + assert_eq!(line.message, "Displayed activity"); + } + + #[test] + fn keeps_colons_in_the_message() { + let line = parse_line("11-04 12:52:39.123 3048 3059 E flutter : java.lang.IllegalStateException: boom"); + assert_eq!(line.tag, "flutter"); + assert_eq!(line.level, LogcatLevel::Error); + assert_eq!(line.message, "java.lang.IllegalStateException: boom"); + } + + #[test] + fn keeps_lines_outside_the_format() { + let line = parse_line("--------- beginning of main"); + assert_eq!(line.level, LogcatLevel::Unknown); + assert_eq!(line.message, "--------- beginning of main"); + } + + #[test] + fn levels_order_by_severity() { + assert!(LogcatLevel::Warn > LogcatLevel::Info); + assert!(LogcatLevel::Fatal > LogcatLevel::Error); + } +} diff --git a/crates/ide-android/src/process.rs b/crates/ide-android/src/process.rs new file mode 100644 index 000000000..b5b858389 --- /dev/null +++ b/crates/ide-android/src/process.rs @@ -0,0 +1,206 @@ +//! Child processes of the `adb` CLI: bounded runs, and cancellable line streams +//! that are handed over in batches (logcat is far too chatty for one message a line). + +use ide_core::process::terminate_tree; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use std::io::Read; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +/// How long a cancelled child gets to exit on `SIGTERM` (and flush) before `SIGKILL`. +const KILL_GRACE: Duration = Duration::from_millis(20); + +pub(crate) struct Output { + pub stdout: String, + pub stderr: String, + pub success: bool, +} + +fn prepare(cmd: &mut Command) { + cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); + ide_core::process::prepare(ide_core::process::own_group(cmd)); +} + +fn spawn(cmd: &mut Command) -> IdeResult { + cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run adb: {e}"))) +} + +fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle { + std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = pipe.read_to_end(&mut buf); + ide_core::output_encoding::decode_tool_output(&buf) + }) +} + +/// Runs `cmd` to completion. Fails when it outlives `timeout` or `cancel` fires; +/// a non-zero exit is reported in [`Output::success`], not as an error. +pub(crate) fn run(mut cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult { + prepare(&mut cmd); + let mut child = spawn(&mut cmd)?; + let out = child.stdout.take().map(read_all); + let err = child.stderr.take().map(read_all); + let deadline = Instant::now() + timeout; + let status = loop { + if let Some(status) = child.try_wait()? { + break status; + } + if cancel.is_some_and(CancellationToken::is_cancelled) { + terminate_tree(&mut child, KILL_GRACE); + return Err(IdeError::cancelled()); + } + if Instant::now() >= deadline { + terminate_tree(&mut child, KILL_GRACE); + return Err(IdeError::unavailable("adb did not respond in time")); + } + std::thread::sleep(Duration::from_millis(10)); + }; + Ok(Output { + stdout: out.map(|t| t.join().unwrap_or_default()).unwrap_or_default(), + stderr: err.map(|t| t.join().unwrap_or_default()).unwrap_or_default(), + success: status.success(), + }) +} + +enum Event { + Line(bool, String), + Closed, +} + +fn spawn_reader(mut pipe: impl Read + Send + 'static, tx: mpsc::Sender, stdout: bool) { + std::thread::spawn(move || { + let mut buf = [0u8; 8192]; + let mut pending: Vec = Vec::new(); + let flush = |pending: &mut Vec| { + if !pending.is_empty() { + let line = ide_core::output_encoding::decode_tool_output(pending); + pending.clear(); + let _ = tx.send(Event::Line(stdout, line.trim_end().to_string())); + } + }; + loop { + match pipe.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + for &b in &buf[..n] { + if b == b'\n' { + flush(&mut pending); + } else if b != b'\r' { + pending.push(b); + } + } + } + } + } + flush(&mut pending); + let _ = tx.send(Event::Closed); + }); +} + +fn flush(batch: &mut Vec, on_batch: &mut dyn FnMut(Vec)) { + if !batch.is_empty() { + on_batch(std::mem::take(batch)); + } +} + +/// Runs `cmd`, parsing every stdout line with `parse` and handing the results to +/// `on_batch` — as soon as `max` are ready, or once the oldest waiting line is `idle` +/// old, so neither a quiet nor a steady stream holds lines back. Stderr is kept as a +/// tail for the error message. Returns whether the child exited successfully. +pub(crate) fn stream_batched( + mut cmd: Command, + cancel: &CancellationToken, + idle: Duration, + max: usize, + mut parse: impl FnMut(&str) -> Option, + on_batch: &mut dyn FnMut(Vec), +) -> IdeResult<(bool, Vec)> { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + prepare(&mut cmd); + let mut child = spawn(&mut cmd)?; + let (tx, rx) = mpsc::channel::(); + let mut open = 0; + if let Some(out) = child.stdout.take() { + spawn_reader(out, tx.clone(), true); + open += 1; + } + if let Some(err) = child.stderr.take() { + spawn_reader(err, tx.clone(), false); + open += 1; + } + drop(tx); + let mut stderr_tail: Vec = Vec::new(); + let mut batch: Vec = Vec::with_capacity(max); + // When the first line of the waiting batch arrived. + let mut since = Instant::now(); + while open > 0 { + if cancel.is_cancelled() { + terminate_tree(&mut child, KILL_GRACE); + return Err(IdeError::cancelled()); + } + let wait = if batch.is_empty() { idle } else { idle.saturating_sub(since.elapsed()) }; + match rx.recv_timeout(wait) { + Ok(Event::Line(true, line)) => { + if let Some(item) = parse(&line) { + if batch.is_empty() { + since = Instant::now(); + } + batch.push(item); + if batch.len() >= max || since.elapsed() >= idle { + flush(&mut batch, on_batch); + } + } + } + Ok(Event::Line(false, line)) => { + if !line.trim().is_empty() { + stderr_tail.push(line); + if stderr_tail.len() > 20 { + stderr_tail.remove(0); + } + } + } + Ok(Event::Closed) => open -= 1, + Err(mpsc::RecvTimeoutError::Timeout) => flush(&mut batch, on_batch), + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + flush(&mut batch, on_batch); + let status = loop { + if let Some(status) = child.try_wait()? { + break status; + } + if cancel.is_cancelled() { + terminate_tree(&mut child, KILL_GRACE); + return Err(IdeError::cancelled()); + } + std::thread::sleep(Duration::from_millis(10)); + }; + Ok((status.success(), stderr_tail)) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + #[test] + fn a_steady_stream_is_handed_over_while_it_runs() { + let mut cmd = Command::new("sh"); + cmd.args(["-c", "for i in $(seq 1 20); do echo $i; sleep 0.02; done"]); + let mut sizes = Vec::new(); + let (ok, _) = stream_batched( + cmd, + &CancellationToken::new(), + Duration::from_millis(60), + 300, + |line| Some(line.to_owned()), + &mut |batch| sizes.push(batch.len()), + ) + .unwrap(); + assert!(ok); + assert_eq!(sizes.iter().sum::(), 20); + assert!(sizes.len() >= 3, "lines arriving every 20 ms waited for the end: {sizes:?}"); + } +} diff --git a/crates/ide-android/tests/device.rs b/crates/ide-android/tests/device.rs new file mode 100644 index 000000000..d8afd6c52 --- /dev/null +++ b/crates/ide-android/tests/device.rs @@ -0,0 +1,65 @@ +//! Drives a real device through `adb`, checking what the Logcat tool window does: +//! that the device list parses, that the stream delivers batches, and that +//! cancelling stops both the stream and its `adb` process. +//! +//! Ignored by default (needs a connected device). Run with +//! `OPENIDEA_ADB_DEVICE= cargo test -p ide-android --test device -- --ignored --nocapture` +//! — without the variable, the connected device is used when there is exactly one. + +use ide_android::*; +use ide_core::CancellationToken; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +fn serial() -> Option { + if let Ok(serial) = std::env::var("OPENIDEA_ADB_DEVICE") { + return Some(serial); + } + let online = devices().ok()?.into_iter().filter(AndroidDevice::is_online).collect::>(); + (online.len() == 1).then(|| online[0].serial.clone()) +} + +#[test] +#[ignore] +fn finds_the_adb_executable() { + let adb = adb_executable().expect("adb (set ANDROID_HOME or OPENIDEA_ADB)"); + println!("adb: {}", adb.display()); + assert!(adb.is_file()); +} + +#[test] +#[ignore] +fn lists_connected_devices() { + let devices = devices().expect("adb devices"); + for device in &devices { + println!("{:<16} {:<14} {} ({:?})", device.serial, device.state, device.label(), device.transport_id); + } + assert!(!devices.is_empty(), "no devices connected"); +} + +#[test] +#[ignore] +fn streams_batches_and_stops_on_cancel() { + let serial = serial().expect("a single connected device, or OPENIDEA_ADB_DEVICE"); + let cancel = CancellationToken::new(); + let stop = cancel.clone(); + let received = Arc::new(Mutex::new(Vec::::new())); + let sink = received.clone(); + let started = Instant::now(); + let stream = std::thread::spawn(move || { + ide_android::logcat(&serial, false, &stop, &mut |batch| { + sink.lock().unwrap().extend(batch); + }) + }); + // Enough to see the tail seed and a few live lines from a device that idles. + std::thread::sleep(Duration::from_secs(4)); + cancel.cancel(); + stream.join().unwrap().expect("logcat stops cleanly when cancelled"); + let lines = received.lock().unwrap(); + println!("{} lines in {:?}", lines.len(), started.elapsed()); + for line in lines.iter().rev().take(5) { + println!("{:<14} {:>6} {}", line.tag, line.pid, line.message); + } + assert!(!lines.is_empty(), "no lines within 4s"); + assert!(lines.iter().any(|l| l.level != LogcatLevel::Unknown), "every line failed to parse"); +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e99b4838e..0057b6ac3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -71,6 +71,7 @@ ide-tools = { path = "../crates/ide-tools" } ide-plugin = { path = "../crates/ide-plugin" } ide-extension = { path = "../crates/ide-extension" } ide-docker = { path = "../crates/ide-docker" } +ide-android = { path = "../crates/ide-android" } ide-github = { path = "../crates/ide-github" } [target.'cfg(target_os = "macos")'.dependencies] diff --git a/src-tauri/src/commands/android.rs b/src-tauri/src/commands/android.rs new file mode 100644 index 000000000..3b34b7b52 --- /dev/null +++ b/src-tauri/src/commands/android.rs @@ -0,0 +1,40 @@ +//! Logcat tool window: Android devices and their logs (`ide-android`). + +use crate::state::AppState; +use ide_android::{AndroidDevice, LogcatLine}; +use ide_core::{IdeError, IdeResult, RequestId}; +use tauri::State; +use tauri::ipc::Channel; + +async fn blocking(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { + tauri::async_runtime::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))? +} + +/// Devices `adb` knows about, online ones first. +#[tauri::command] +#[specta::specta] +pub async fn android_devices() -> IdeResult> { + blocking(ide_android::devices).await +} + +/// Follows `serial`'s log until the request is cancelled, in batches of lines. +/// `clear` empties the device's buffers first. +#[tauri::command] +#[specta::specta] +pub async fn android_logcat( + state: State<'_, AppState>, + request: RequestId, + serial: String, + clear: bool, + on_lines: Channel>, +) -> IdeResult<()> { + let cancel = state.begin_request(request); + let result = blocking(move || { + ide_android::logcat(&serial, clear, &cancel, &mut |batch| { + let _ = on_lines.send(batch); + }) + }) + .await; + state.end_request(request); + result +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index adaadc17e..1ffdd6dd0 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -8,6 +8,7 @@ pub mod agent; pub mod analysis; +pub mod android; pub mod app; pub mod build; pub mod bytes; @@ -63,6 +64,8 @@ pub(crate) fn sender( pub fn builder() -> tauri_specta::Builder { tauri_specta::Builder::::new().commands(collect_commands![ app::app_info, + android::android_devices, + android::android_logcat, app::recent_projects, app::remove_recent_project, app::recent_project_groups, diff --git a/src/bindings.ts b/src/bindings.ts index 6601d340e..472db3c02 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -8,6 +8,13 @@ import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core"; /** Commands */ export const commands = { appInfo: () => __TAURI_INVOKE("app_info"), + /** Devices `adb` knows about, online ones first. */ + androidDevices: () => typedError(__TAURI_INVOKE("android_devices")), + /** + * Follows `serial`'s log until the request is cancelled, in batches of lines. + * `clear` empties the device's buffers first. + */ + androidLogcat: (request: RequestId, serial: string, clear: boolean, onLines: Channel) => typedError(__TAURI_INVOKE("android_logcat", { request, serial, clear, onLines })), recentProjects: () => __TAURI_INVOKE("recent_projects"), removeRecentProject: (path: string) => typedError(__TAURI_INVOKE("remove_recent_project", { path })), /** The welcome screen's project groups. */ @@ -1593,6 +1600,19 @@ export type AgentUsage = { cost?: AgentCost | null, }; +/** One entry of `adb devices -l`. */ +export type AndroidDevice = { + /** `adb -s` value: a USB serial number, `emulator-5554`, or `host:port`. */ + serial: string, + /** `device` (usable), `offline`, `unauthorized` or `no permissions`. */ + state: string, + model: string | null, + product: string | null, + /** The device's own name from the build (`ro.product.device`). */ + device: string | null, + transportId: string | null, +}; + export type AppInfo = { version: string, platform: string, @@ -5022,6 +5042,22 @@ export type LogQuery = { stopAt?: string | null, }; +/** The single-letter logcat priority, in increasing severity. */ +export type LogcatLevel = "verbose" | "debug" | "info" | "warn" | "error" | "fatal" | +/** A line logcat prints outside the message format (`--------- beginning of main`). */ +"unknown"; + +/** One [`LogcatLevel`]-tagged line of `adb logcat -v threadtime`. */ +export type LogcatLine = { + /** `MM-DD HH:MM:SS.mmm`, as logcat prints it (no year, device time zone). */ + time: string, + pid: number, + tid: number, + level: LogcatLevel, + tag: string, + message: string, +}; + export type LspRefreshKind = "inlayHints" | "semanticTokens" | "codeLens"; export type MainToolbarSettings = { diff --git a/src/features/index.ts b/src/features/index.ts index 5086d7790..cd58ab5b9 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -26,6 +26,7 @@ import { activate as debuggerFeature } from "./debugger"; import { activate as coverage } from "./coverage"; import { activate as colorPreview } from "./color-preview"; import { activate as profiler } from "./profiler"; +import { activate as logcat } from "./logcat"; import { activate as buildTools } from "./build-tools"; import { activate as build } from "./build"; import { activate as templates, activateSettingsPages as templatesPages } from "./templates"; @@ -73,6 +74,7 @@ const features: Array<[string, Activate]> = [ ["coverage", coverage], ["color-preview", colorPreview], ["profiler", profiler], + ["logcat", logcat], ["build-tools", buildTools], ["build", build], ["templates", templates], diff --git a/src/features/logcat/Logcat.module.css b/src/features/logcat/Logcat.module.css new file mode 100644 index 000000000..66923ee43 --- /dev/null +++ b/src/features/logcat/Logcat.module.css @@ -0,0 +1,144 @@ +.view { + height: 100%; + display: flex; + flex-direction: column; + min-height: 0; + background: var(--bg-editor); +} + +.toolbar { + flex: none; + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-bottom: 1px solid var(--border); + background: var(--bg-elev); +} + +.device { + width: 220px; + min-width: 0; +} +.level { + width: 130px; + flex: none; +} +.search { + flex: 1; + min-width: 80px; +} + +/* The virtual list: rows are absolutely positioned inside a spacer of total height. */ +.log { + flex: 1; + min-height: 0; + overflow: auto; + font-family: var(--font-mono); + font-size: var(--fs-code); + line-height: var(--lh-code); + color: var(--fg-editor); + -webkit-user-select: text; + user-select: text; + cursor: text; + position: relative; +} +.canvas { + position: relative; + width: 100%; +} +.row { + position: absolute; + top: 0; + left: 0; + width: 100%; + display: flex; + gap: 8px; + padding: 0 8px; + box-sizing: border-box; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.row:hover { + background: var(--bg-hover); +} +.time { + flex: none; + width: 92px; + color: var(--fg-subtle); +} +.pid { + flex: none; + width: 44px; + text-align: right; + color: var(--fg-subtle); +} +.tag { + flex: none; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--fg-muted); +} +.message { + flex: 1; + min-width: 0; +} + +/* Level colours follow Android Studio: errors red, warnings amber, info default, + debug/verbose muted, and lines outside the message format dimmed. */ +.levelV, +.levelD, +.levelU { + color: var(--fg-subtle); +} +.levelI { + color: var(--fg-editor); +} +.levelW { + color: var(--warning); +} +.levelE, +.levelF { + color: var(--danger); +} +.levelF .message { + font-weight: 600; +} + +.empty { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--fg-subtle); + font-family: var(--font-ui); + font-size: var(--fs-small); + pointer-events: none; +} + +.status { + flex: none; + display: flex; + align-items: center; + gap: 10px; + padding: 2px 8px; + border-top: 1px solid var(--border); + color: var(--fg-subtle); + font-size: var(--fs-small); + white-space: nowrap; + overflow: hidden; +} +.count { + margin-left: auto; +} +.warn { + color: var(--warning); +} +.error { + color: var(--danger); + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/features/logcat/LogcatView.tsx b/src/features/logcat/LogcatView.tsx new file mode 100644 index 000000000..500d0b1cf --- /dev/null +++ b/src/features/logcat/LogcatView.tsx @@ -0,0 +1,280 @@ +/** + * Logcat tool window: the device picker, the level and text filters, and the + * streaming log below them (virtualised, following the tail until scrolled up). + */ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react"; +import clsx from "clsx"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { Channel } from "@tauri-apps/api/core"; +import { call, commands, isCancelled, nextRequestId } from "@/platform/ipc"; +import type { LogcatLevel, LogcatLine } from "@/platform/ipc"; +import { Input, IconButton, Select, T, EmptyState, showContextMenu, type MenuItem } from "@/ui"; +import { FILTER_LEVELS, LEVEL_CLASS, filterLines, shortTime } from "./model"; +import { compileQuery } from "./query"; +import { forgetFilters, rememberFilter } from "./history"; +import { appendLines, clearLines, setDevices, setDevicesError, setPaused, setStatus, useLogcat } from "./store"; +import styles from "./Logcat.module.css"; + +/** Rows are measured: a wrapped message makes its row taller. */ +const ROW_HEIGHT = 22; +const LEVEL_LABELS: Record = { + verbose: "Verbose", + debug: "Debug", + info: "Info", + warn: "Warnings", + error: "Errors", + fatal: "Fatal", + unknown: "Unknown", +}; + +/** The device whose log the buffer holds: reopening the window keeps it, switching + * devices starts clean. */ +let bufferedFor: string | null = null; + +function message(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +export function LogcatView() { + const devices = useLogcat((s) => s.devices); + const devicesError = useLogcat((s) => s.devicesError); + const loadingDevices = useLogcat((s) => s.loadingDevices); + const serial = useLogcat((s) => s.serial); + const level = useLogcat((s) => s.level); + const query = useLogcat((s) => s.query); + const history = useLogcat((s) => s.history); + const paused = useLogcat((s) => s.paused); + const follow = useLogcat((s) => s.follow); + const status = useLogcat((s) => s.status); + const error = useLogcat((s) => s.error); + const lines = useLogcat((s) => s.lines); + + const refreshDevices = useCallback(() => { + useLogcat.setState({ loadingDevices: true }); + call(commands.androidDevices()) + .then(setDevices) + .catch((e) => setDevicesError(message(e))); + }, []); + + useEffect(() => { + refreshDevices(); + }, [refreshDevices]); + + const scrollRef = useRef(null); + /** The scrollTop we last wrote: only the user can move the view above it. */ + const written = useRef(0); + /** Where ↑/↓ are in the history; `null` while the box holds freshly typed text. */ + const browse = useRef<{ index: number; draft: string } | null>(null); + + /** ↑ walks back in time, ↓ returns to what was typed (−1 is that draft). */ + const recall = (step: 1 | -1) => { + if (!history.length) return; + if (!browse.current) browse.current = { index: -1, draft: query }; + const index = Math.max(-1, Math.min(browse.current.index + step, history.length - 1)); + if (index === browse.current.index) return; + browse.current.index = index; + useLogcat.setState({ query: index < 0 ? browse.current.draft : history[index]! }); + }; + + const applyFilter = (value: string) => { + browse.current = null; + useLogcat.setState({ query: value, history: rememberFilter(value) }); + }; + + const historyItems = (): MenuItem[] => [ + ...history.map((entry) => ({ label: entry, verbatim: true, run: () => applyFilter(entry) })), + { kind: "separator" as const }, + { label: "Clear History", run: () => useLogcat.setState({ history: forgetFilters() }) }, + ]; + + // One stream per device: switching devices (or closing the window) cancels it. + useEffect(() => { + if (!serial) { + setStatus("idle"); + return; + } + if (bufferedFor !== serial) { + clearLines(); + bufferedFor = serial; + written.current = 0; + } + const request = nextRequestId(); + const channel = new Channel(); + channel.onmessage = (batch) => appendLines(batch); + setStatus("running"); + call(commands.androidLogcat(request, serial, false, channel)) + .then(() => setStatus("idle")) + .catch((e) => { + if (isCancelled(e)) setStatus("idle"); + else setStatus("error", message(e)); + }); + return () => { + void commands.cancelRequest(request); + }; + }, [serial]); + + const rows = useMemo(() => filterLines(lines, level, query), [lines, level, query]); + const filterError = useMemo(() => compileQuery(query).error, [query]); + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => ROW_HEIGHT, + // Rows are identified by their line, not by position: the buffer drops old lines. + getItemKey: (index) => rows[index]!.id, + overscan: 16, + }); + + // Follow the tail: `totalSize` changes only after the rows below have been + // measured, so waiting on it as well is what lands us on the real last line + // when a burst arrives (React commits, the virtualiser measures, this runs again). + const totalSize = virtualizer.getTotalSize(); + useLayoutEffect(() => { + if (!follow) return; + const el = scrollRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + written.current = el.scrollTop; + }, [rows, follow, totalSize]); + + const deviceOptions = devices.map((d) => ({ + value: d.serial, + label: d.model ? `${d.model} (${d.serial})` : d.serial, + detail: d.state === "device" ? undefined : d.state, + disabled: d.state !== "device", + })); + + return ( +
+
+ ({ value, label: LEVEL_LABELS[value] }))} + onChange={(value) => useLogcat.setState({ level: value })} + /> + { + if (e.key === "Enter") { + useLogcat.setState({ history: rememberFilter(query) }); + browse.current = null; + } else if (e.key === "ArrowUp" || e.key === "ArrowDown") { + e.preventDefault(); + recall(e.key === "ArrowUp" ? 1 : -1); + } + }} + onChange={(e) => { + browse.current = null; + useLogcat.setState({ query: e.target.value }); + }} + /> + showContextMenu({ clientX: e.clientX, clientY: e.clientY }, historyItems())} + /> + setPaused(!paused)} + /> + useLogcat.setState({ follow: !follow })} + /> + + +
+ + {devicesError ? ( + + {devicesError} + + ) : !devices.length ? ( + + + Connect a device over USB, or start an emulator. Logcat uses the Android SDK's adb. + + + ) : ( +
{ + const el = e.currentTarget; + // Our own writes always end at the bottom, so a move upwards is the user's: + // scrolling up stops following, and only the toolbar arrow resumes it. + const up = el.scrollTop < written.current - 4; + written.current = el.scrollTop; + if (up && follow) useLogcat.setState({ follow: false }); + }} + > +
+ {virtualizer.getVirtualItems().map((item) => { + const line = rows[item.index]!; + return ( +
+ {shortTime(line.time)} + {line.pid || ""} + {line.tag} + {line.message} +
+ ); + })} +
+ {!rows.length && ( +
+ {lines.length ? No lines match the filter : Waiting for output…} +
+ )} +
+ )} + +
+ {status === "running" ? Streaming : status === "error" ? Stopped : Idle} + {paused && Paused} + {filterError && Filter ignored: {filterError}} + + {rows.length === lines.length ? lines.length : `${rows.length} / ${lines.length}`} + + {error && {error}} +
+
+ ); +} diff --git a/src/features/logcat/history.test.ts b/src/features/logcat/history.test.ts new file mode 100644 index 000000000..e446c4497 --- /dev/null +++ b/src/features/logcat/history.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { forgetFilters, loadFilters, rememberFilter } from "./history"; + +const KEY = "openidea.logcat.filters"; + +function fakeStorage(): Storage { + const map = new Map(); + return { + get length() { + return map.size; + }, + clear: () => map.clear(), + getItem: (k: string) => map.get(k) ?? null, + key: (i: number) => [...map.keys()][i] ?? null, + removeItem: (k: string) => void map.delete(k), + setItem: (k: string, v: string) => void map.set(k, v), + } as Storage; +} + +beforeEach(() => { + vi.stubGlobal("localStorage", fakeStorage()); + forgetFilters(); +}); + +afterEach(() => vi.unstubAllGlobals()); + +describe("filter history", () => { + it("keeps the newest first without duplicates", () => { + rememberFilter("tag:aa"); + rememberFilter("tag:bb"); + expect(loadFilters()).toEqual(["tag:bb", "tag:aa"]); + expect(rememberFilter(" tag:aa ")).toEqual(["tag:aa", "tag:bb"]); + }); + + it("takes ↑/↓ order from the list and ignores an empty filter", () => { + expect(rememberFilter(" ")).toEqual([]); + for (let i = 0; i < 30; i++) rememberFilter(`tag:${i}`); + const list = loadFilters(); + expect(list).toHaveLength(20); + expect(list[0]).toBe("tag:29"); + expect(list.at(-1)).toBe("tag:10"); + }); + + it("survives storage content that is not a list", () => { + localStorage.setItem(KEY, "{not json"); + expect(loadFilters()).toEqual([]); + localStorage.setItem(KEY, JSON.stringify([1, "", "tag:x"])); + expect(loadFilters()).toEqual(["tag:x"]); + }); + + it("works when storage is unavailable", () => { + vi.stubGlobal("localStorage", undefined); + expect(loadFilters()).toEqual([]); + expect(rememberFilter("tag:aa")).toEqual(["tag:aa"]); + }); +}); diff --git a/src/features/logcat/history.ts b/src/features/logcat/history.ts new file mode 100644 index 000000000..05673ee13 --- /dev/null +++ b/src/features/logcat/history.ts @@ -0,0 +1,47 @@ +/** + * Recent filter expressions of the Logcat tool window, kept in `localStorage` so + * they survive a restart (IDEA's logcat keeps a short history too). Newest first. + */ +const KEY = "openidea.logcat.filters"; +const MAX = 20; + +function store(): Storage | null { + try { + return typeof localStorage === "undefined" ? null : localStorage; + } catch { + return null; + } +} + +function save(list: string[]) { + try { + store()?.setItem(KEY, JSON.stringify(list)); + } catch { + // Storage disabled or full: the history is a convenience, not state to lose sleep over. + } +} + +export function loadFilters(): string[] { + try { + const raw = store()?.getItem(KEY); + const parsed: unknown = raw ? JSON.parse(raw) : []; + if (!Array.isArray(parsed)) return []; + return parsed.filter((v): v is string => typeof v === "string" && v.trim() !== "").slice(0, MAX); + } catch { + return []; + } +} + +/** Puts `query` at the front (an empty one is not worth remembering) and returns the new list. */ +export function rememberFilter(query: string): string[] { + const text = query.trim(); + const rest = loadFilters().filter((q) => q !== text); + const list = text ? [text, ...rest].slice(0, MAX) : rest; + save(list); + return list; +} + +export function forgetFilters(): string[] { + save([]); + return []; +} diff --git a/src/features/logcat/index.ts b/src/features/logcat/index.ts new file mode 100644 index 000000000..5eef15f83 --- /dev/null +++ b/src/features/logcat/index.ts @@ -0,0 +1,26 @@ +/** + * Logcat tool window: what an Android device prints, via `adb logcat`, with the + * level and text filters of IDEA's Android plugin. + */ +import { registerToolWindow } from "@/workbench/contributions"; +import { LogcatView } from "./LogcatView"; +import { resetLogcat } from "./store"; + +export function activate(): () => void { + const dispose = registerToolWindow({ + id: "logcat", + title: "Logcat", + icon: "toolwindow-logcat", + anchor: "bottom", + // Listed under the stripe's "…" until first opened: most projects are not Android ones. + stripe: "onUse", + order: 26, + component: LogcatView, + // A device can be watched without a project open. + requiresProject: false, + }); + return () => { + dispose(); + resetLogcat(); + }; +} diff --git a/src/features/logcat/model.test.ts b/src/features/logcat/model.test.ts new file mode 100644 index 000000000..2aeb461ba --- /dev/null +++ b/src/features/logcat/model.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import type { LogcatLine } from "@/platform/ipc"; +import { LEVEL_CLASS, LEVELS, filterLines, shortTime } from "./model"; +import { compileQuery } from "./query"; + +const line = (level: LogcatLine["level"], tag: string, message: string, pid = 100): LogcatLine => ({ + time: "11-04 12:52:39.123", + pid, + tid: pid + 1, + level, + tag, + message, +}); + +const lines = [ + line("verbose", "chatty", "starting up"), + line("info", "ActivityManager", "Displayed com.example/.Main"), + line("warn", "Glide", "Failed to load bitmap"), + line("error", "flutter", "IllegalStateException: boom", 4242), + { ...line("unknown", "", "--------- beginning of main") }, +]; + +describe("filterLines", () => { + it("keeps everything at the least severe level", () => { + expect(filterLines(lines, "verbose", "")).toHaveLength(5); + }); + + it("drops lines below the chosen level", () => { + expect(filterLines(lines, "warn", "").map((l) => l.tag)).toEqual(["Glide", "flutter", ""]); + expect(filterLines(lines, "error", "").map((l) => l.tag)).toEqual(["flutter", ""]); + }); + + it("matches the tag, the message, and #pid", () => { + expect(filterLines(lines, "verbose", "glide")).toHaveLength(1); + expect(filterLines(lines, "verbose", "bitmap")).toHaveLength(1); + expect(filterLines(lines, "verbose", "#4242").map((l) => l.tag)).toEqual(["flutter"]); + }); + + it("combines level and text", () => { + expect(filterLines(lines, "warn", "boom")).toHaveLength(1); + expect(filterLines(lines, "error", "bitmap")).toHaveLength(0); + }); +}); + +describe("filter expressions", () => { + const tags = (query: string) => filterLines(lines, "verbose", query).map((l) => l.tag); + + it("drops what a negated term matches", () => { + expect(tags("-message:boom")).toEqual(["chatty", "ActivityManager", "Glide", ""]); + expect(tags("-tag:glide -tag:flutter")).toEqual(["chatty", "ActivityManager", ""]); + }); + + it("ands adjacent terms and or-s them with |", () => { + expect(tags("tag:glide message:bitmap")).toEqual(["Glide"]); + expect(tags("tag:glide|tag:flutter")).toEqual(["Glide", "flutter"]); + expect(tags("tag:glide | tag:flutter")).toEqual(["Glide", "flutter"]); + }); + + it("groups, so a negation can apply to either term", () => { + expect(tags("(tag:glide|tag:flutter) -level:error")).toEqual(["Glide"]); + expect(tags("-(tag:glide|tag:flutter)")).toEqual(["chatty", "ActivityManager", ""]); + }); + + it("matches a level by name, letter or list", () => { + expect(tags("level:error")).toEqual(["flutter"]); + expect(tags("level:w,e")).toEqual(["Glide", "flutter"]); + expect(tags("level:ERROR")).toEqual(["flutter"]); + }); + + it("matches pid and tid, and still #pid", () => { + expect(tags("pid:4242")).toEqual(["flutter"]); + expect(tags("pid:4243")).toEqual(["flutter"]); + expect(tags("#4242")).toEqual(["flutter"]); + }); + + it("takes a regular expression after ~", () => { + expect(tags("message~:^Displayed")).toEqual(["ActivityManager"]); + expect(tags("tag~:^ACTIVITY")).toEqual(["ActivityManager"]); + }); + + it("reads a quoted value, including an empty one", () => { + expect(tags('message:"IllegalState boom"')).toEqual([]); + expect(tags("message:'IllegalStateException: boom'")).toEqual(["flutter"]); + expect(tags('tag:""')).toEqual([""]); + }); + + it("treats an unknown key as plain text", () => { + expect(tags("package:com.example")).toEqual([]); + }); + + it("ignores an invalid query and says why", () => { + expect(compileQuery("tag:((").error).toBeTruthy(); + expect(compileQuery("message~:(").error).toBeTruthy(); + expect(compileQuery("").error).toBeNull(); + expect(tags("tag:((")).toHaveLength(5); + }); +}); + +it("orders levels least severe first, with unknown last so it is never hidden", () => { + expect(LEVELS[0]).toBe("verbose"); + expect(LEVELS.at(-1)).toBe("unknown"); + expect(LEVELS.indexOf("error")).toBeGreaterThan(LEVELS.indexOf("warn")); + expect(LEVEL_CLASS.error).toBe("levelE"); +}); + +it("shortens a logcat timestamp to the time of day", () => { + expect(shortTime("11-04 12:52:39.123")).toBe("12:52:39.123"); +}); diff --git a/src/features/logcat/model.ts b/src/features/logcat/model.ts new file mode 100644 index 000000000..af1a4daf3 --- /dev/null +++ b/src/features/logcat/model.ts @@ -0,0 +1,34 @@ +/** Logcat tool window: filtering and formatting, independent of React. */ +import type { LogcatLevel, LogcatLine } from "@/platform/ipc"; +import { compileQuery } from "./query"; + +/** Severity order of the level filter, least severe first. `unknown` sits last so + * lines logcat prints outside its message format are never filtered away. */ +export const LEVELS: readonly LogcatLevel[] = ["verbose", "debug", "info", "warn", "error", "fatal", "unknown"]; + +/** The levels the filter offers as a minimum; `unknown` always shows, so it is not one. */ +export const FILTER_LEVELS = LEVELS.filter((level) => level !== "unknown"); + +/** Row style per level, matching the classes in `Logcat.module.css`. */ +export const LEVEL_CLASS: Record = { + verbose: "levelV", + debug: "levelD", + info: "levelI", + warn: "levelW", + error: "levelE", + fatal: "levelF", + unknown: "levelU", +}; + +/** "11-04 12:52:39.123" → "12:52:39.123": the date is the same for the whole session. */ +export function shortTime(time: string): string { + return time.length > 6 ? time.slice(6) : time; +} + +/** Lines at or above `min`, then only those the query accepts (see `query.ts`). */ +export function filterLines(lines: readonly T[], min: LogcatLevel, query: string): readonly T[] { + const floor = LEVELS.indexOf(min); + const filter = compileQuery(query); + if (floor === 0 && !query.trim()) return lines; + return lines.filter((line) => LEVELS.indexOf(line.level) >= floor && filter.test(line)); +} diff --git a/src/features/logcat/query.ts b/src/features/logcat/query.ts new file mode 100644 index 000000000..a0a6f2219 --- /dev/null +++ b/src/features/logcat/query.ts @@ -0,0 +1,253 @@ +/** + * Logcat filter expressions in the shape Android Studio accepts: + * + * tag:glide the tag contains "glide" + * message:boom the message contains "boom" + * pid:4242 the process (or thread) id is 4242 + * level:error the level is error (or `level:warn,error`, names or letters) + * -tag:chatty "not": drops matching lines + * a b both, implicitly and-ed (`&` works too) + * a|b either, or-ed + * (a|b) -c grouping + * message~:^Tile.* a case-insensitive regular expression + * message:"two words" quoted values, single or double + * + * A bare word matches the tag, the message, or `#pid`, as before. + */ +import type { LogcatLevel, LogcatLine } from "@/platform/ipc"; + +export type QueryField = "tag" | "message" | "pid" | "level" | "text"; + +export interface QueryTerm { + field: QueryField; + value: string; + /** `key~:value`: `value` is a regular expression. */ + regex: boolean; +} + +type QueryNode = + | { kind: "term"; term: QueryTerm } + | { kind: "not"; node: QueryNode } + | { kind: "and"; nodes: QueryNode[] } + | { kind: "or"; nodes: QueryNode[] }; + +export interface LogFilter { + /** Whether a line passes. Every line passes when the query is empty or invalid. */ + test: (line: LogcatLine) => boolean; + /** Why an invalid query was ignored, or `null`. */ + error: string | null; +} + +/** `message` is also the name of a translated UI text, so name the field once. */ +const MESSAGE_FIELD: QueryField = "message"; + +const FIELD_ALIASES: Record = { + tag: "tag", + message: MESSAGE_FIELD, + msg: MESSAGE_FIELD, + pid: "pid", + level: "level", +}; + +const LEVEL_NAMES: Record = { + verbose: "verbose", + v: "verbose", + debug: "debug", + d: "debug", + info: "info", + i: "info", + warn: "warn", + warning: "warn", + w: "warn", + error: "error", + e: "error", + fatal: "fatal", + assert: "fatal", + a: "fatal", + f: "fatal", + unknown: "unknown", +}; + +class QueryError extends Error {} + +/** Splits one word into a term. Unknown `key:value` pairs stay plain text. */ +function toTerm(raw: string): QueryTerm { + const colon = raw.indexOf(":"); + if (colon > 0) { + let key = raw.slice(0, colon).toLowerCase(); + const regex = key.endsWith("~"); + if (regex) key = key.slice(0, -1); + const field = FIELD_ALIASES[key]; + if (field) return { field, value: raw.slice(colon + 1), regex }; + } + return { field: "text", value: raw, regex: false }; +} + +class Parser { + private index = 0; + + constructor(private readonly source: string) {} + + parse(): QueryNode | null { + const node = this.or(); + if (this.peek() !== "") throw new QueryError(`Unexpected “${this.peek()}”`); + return node; + } + + /** Whitespace is insignificant; anything else is a token. */ + private peek(): string { + while (this.index < this.source.length && /\s/.test(this.source[this.index]!)) this.index++; + return this.source[this.index] ?? ""; + } + + private or(): QueryNode | null { + const nodes: QueryNode[] = []; + const first = this.and(); + if (first) nodes.push(first); + while (this.peek() === "|") { + this.index++; + const next = this.and(); + if (!next) throw new QueryError("Nothing to match after |"); + nodes.push(next); + } + if (!nodes.length) return null; + return nodes.length === 1 ? nodes[0]! : { kind: "or", nodes }; + } + + private and(): QueryNode | null { + const nodes: QueryNode[] = []; + for (;;) { + const next = this.peek(); + if (next === "" || next === "|" || next === ")") break; + if (next === "&") { + this.index++; + continue; + } + nodes.push(this.unary()); + } + if (!nodes.length) return null; + return nodes.length === 1 ? nodes[0]! : { kind: "and", nodes }; + } + + private unary(): QueryNode { + const next = this.peek(); + if (next === "-" || next === "!") { + this.index++; + return { kind: "not", node: this.unary() }; + } + if (next === "(") { + this.index++; + const node = this.or(); + if (!node) throw new QueryError("Empty parentheses"); + if (this.peek() !== ")") throw new QueryError("Missing )"); + this.index++; + return node; + } + const word = this.word(); + if (!word) throw new QueryError(`Unexpected “${next}”`); + return { kind: "term", term: toTerm(word) }; + } + + /** A run of characters up to whitespace or a separator, quotes removed. */ + private word(): string { + let word = ""; + for (;;) { + const c = this.source[this.index]; + if (c === undefined || /\s/.test(c) || c === "|" || c === "&" || c === ")" || c === "(") break; + if (c === '"' || c === "'") { + this.index++; + while (this.index < this.source.length && this.source[this.index] !== c) { + word += this.source[this.index]!; + this.index++; + } + if (this.index < this.source.length) this.index++; + continue; + } + word += c; + this.index++; + } + return word; + } +} + +function levels(value: string): Set { + const names = value.split(",").map((part) => part.trim().toLowerCase()); + const chosen = new Set(); + for (const name of names) { + const level = LEVEL_NAMES[name]; + if (!level) throw new QueryError(`Unknown level “${name}”`); + chosen.add(level); + } + return chosen; +} + +function termMatcher(term: QueryTerm): (line: LogcatLine) => boolean { + const value = term.value.toLowerCase(); + const named = term.field === "level" ? levels(term.value) : null; + const pattern = term.regex && term.field !== "level" ? new RegExp(term.value, "i") : null; + + const hit = (text: string): boolean => { + if (!term.regex) return value.length === 0 ? text.length === 0 : text.toLowerCase().includes(value); + return pattern!.test(text); + }; + + switch (term.field) { + case "tag": + return (line) => hit(line.tag); + case "message": + return (line) => hit(line.message); + case "pid": + return (line) => line.pid === Number(term.value) || line.tid === Number(term.value); + case "level": + return (line) => named!.has(line.level); + case "text": { + const hash = value.startsWith("#") ? value.slice(1) : null; + return (line) => + hit(line.message) || + hit(line.tag) || + (hash !== null && (String(line.pid) === hash || String(line.tid) === hash)); + } + } +} + +function compile(node: QueryNode): (line: LogcatLine) => boolean { + switch (node.kind) { + case "term": + return termMatcher(node.term); + case "not": { + const inner = compile(node.node); + return (line) => !inner(line); + } + case "and": { + const parts = node.nodes.map(compile); + return (line) => parts.every((part) => part(line)); + } + case "or": { + const parts = node.nodes.map(compile); + return (line) => parts.some((part) => part(line)); + } + } +} + +const cache = new Map(); + +/** Parses and compiles a query. Compiling is cached: the filter runs per line. */ +export function compileQuery(query: string): LogFilter { + const source = query.trim(); + if (!source) return { test: () => true, error: null }; + const hit = cache.get(source); + if (hit) return hit; + + let filter: LogFilter; + try { + const node = new Parser(source).parse(); + filter = node ? { test: compile(node), error: null } : { test: () => true, error: null }; + } catch (e) { + filter = { test: () => true, error: e instanceof Error ? e.message : String(e) }; + } + + // A handful of entries is enough: there is one query per keystroke at most. + if (cache.size >= 32) cache.delete(cache.keys().next().value!); + cache.set(source, filter); + return filter; +} diff --git a/src/features/logcat/store.ts b/src/features/logcat/store.ts new file mode 100644 index 000000000..a5d06e3ab --- /dev/null +++ b/src/features/logcat/store.ts @@ -0,0 +1,130 @@ +/** + * Logcat tool window state: the device being watched, the lines streamed from it + * and the filters over them. The stream itself lives in the view (one per window). + */ +import { create } from "zustand"; +import type { AndroidDevice, LogcatLevel, LogcatLine } from "@/platform/ipc"; +import { loadFilters } from "./history"; + +/** Kept lines; a chatty device must not grow the buffer without bound. */ +export const MAX_LINES = 20_000; + +export type LogcatStatus = "idle" | "running" | "error"; + +/** A line plus the identity the list needs: the buffer drops old lines from the + * front, so the index is not stable. */ +export interface LogcatRow extends LogcatLine { + id: number; +} + +interface LogcatState { + devices: AndroidDevice[]; + devicesError: string | null; + loadingDevices: boolean; + /** `adb -s` value of the device being watched. */ + serial: string | null; + /** Drop lines below this level. */ + level: LogcatLevel; + /** Filter expression: `tag:`, `message:`, `-term`, … (see `query.ts`). */ + query: string; + /** Filters used before, newest first; ↑/↓ and the toolbar list walk it. */ + history: string[]; + /** Freeze the view: lines keep arriving but are only applied when resumed. */ + paused: boolean; + /** Keep the view on the last line; scrolling up turns it off, the icon turns it back on. */ + follow: boolean; + status: LogcatStatus; + error: string | null; + lines: LogcatRow[]; +} + +export const useLogcat = create(() => ({ + devices: [], + devicesError: null, + loadingDevices: false, + serial: null, + level: "verbose", + query: "", + history: loadFilters(), + paused: false, + follow: true, + status: "idle", + error: null, + lines: [], +})); + +/** Lines received while paused, applied on resume. */ +let pending: LogcatRow[] = []; + +/** Row ids keep growing: a reopened window's stale keys can never collide. */ +let nextId = 0; + +function push(lines: LogcatRow[], batch: LogcatRow[]): LogcatRow[] { + const next = lines.concat(batch); + return next.length > MAX_LINES ? next.slice(next.length - MAX_LINES) : next; +} + +/** + * Applies a stream batch. While paused the lines wait in `pending` instead, so + * resuming shows what happened meanwhile rather than a hole. + */ +export function appendLines(batch: LogcatLine[]) { + const rows = batch.map((line) => ({ ...line, id: ++nextId })); + const { paused } = useLogcat.getState(); + if (paused) { + pending = pending.concat(rows); + if (pending.length > MAX_LINES) pending = pending.slice(pending.length - MAX_LINES); + return; + } + useLogcat.setState((s) => ({ lines: push(s.lines, rows) })); +} + +export function setPaused(paused: boolean) { + if (!paused && pending.length) { + const lines = pending; + pending = []; + useLogcat.setState((s) => ({ lines: push(s.lines, lines), paused })); + return; + } + useLogcat.setState({ paused }); +} + +export function clearLines() { + pending = []; + useLogcat.setState({ lines: [] }); +} + +/** Takes a fresh device list, keeping the watched device while it is still there. */ +export function setDevices(devices: AndroidDevice[]) { + const { serial } = useLogcat.getState(); + const usable = (d: AndroidDevice) => d.state === "device"; + const keep = serial !== null && devices.some((d) => d.serial === serial && usable(d)); + useLogcat.setState({ + devices, + devicesError: null, + loadingDevices: false, + serial: keep ? serial : (devices.find(usable)?.serial ?? null), + }); +} + +export function setDevicesError(message: string) { + useLogcat.setState({ devicesError: message, loadingDevices: false }); +} + +export function setStatus(status: LogcatStatus, error: string | null = null) { + useLogcat.setState({ status, error }); +} + +export function resetLogcat() { + pending = []; + useLogcat.setState({ + devices: [], + devicesError: null, + loadingDevices: false, + serial: null, + paused: false, + status: "idle", + error: null, + lines: [], + }); +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 5fb66be34..4dfcc1d96 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6220,5 +6220,23 @@ "Count rows": "统计行数", "Last Page": "最后一页", "{first}-{last} of {total}": "{first}-{last},共 {total}", - "Model & effort settings": "模型与推理强度" + "Model & effort settings": "模型与推理强度", + "Logcat": "Logcat", + "Device": "设备", + "No devices": "没有设备", + "Minimum level": "最低级别", + "Filter log lines": "过滤日志行", + "Filter, e.g. tag:glide -message:bitmap level:error": "过滤,例如 tag:glide -message:bitmap level:error", + "tag: · message: · pid: · level: · -term · a|b · (a|b) -c · message~:regex · ↑ for history": "tag: · message: · pid: · level: · -term · a|b · (a|b) -c · message~:regex · ↑ 调出历史", + "Filter history": "过滤历史", + "Resume": "继续", + "Following the end (click to stop)": "正在跟随末尾(点击停止)", + "Scroll to end": "滚动到末尾", + "Refresh devices": "刷新设备", + "No Android devices": "没有 Android 设备", + "Looking for devices…": "正在查找设备…", + "Connect a device over USB, or start an emulator. Logcat uses the Android SDK's adb.": "通过 USB 连接设备,或启动模拟器。Logcat 使用 Android SDK 的 adb。", + "No lines match the filter": "没有符合过滤条件的日志行", + "Streaming": "正在接收", + "Idle": "空闲" } diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 72f13e6f5..2de5f82c7 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -351,6 +351,7 @@ const ICONS = { profile: "run/profile", "toolwindow-coverage": "toolwindows/coverage", "toolwindow-profiler": "toolwindows/profiler", + "toolwindow-logcat": "openidea/fileTypes/log", "gutter-run": "gutter/run", "gutter-run-all": "actions/runAll", "gutter-run-passed": "gutter/runSuccess",