mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
feat(android): add a Logcat tool window (#1473)
A bottom tool window lists the connected Android devices (adb devices -l) and streams the selected one's log (adb logcat -v threadtime, the last 200 lines first), parsed into time, pid, level, tag and message in a virtualised list that follows the end until scrolled up. The filter box takes Android Studio's logcat expressions (tag:, message:, pid:, level:, -term, |, parentheses, regex) and remembers recent ones. The new ide-android crate runs adb, which it finds through ANDROID_HOME / ANDROID_SDK_ROOT, the usual SDK locations or PATH; the stream goes over a Channel in batches (300 lines, or once the oldest waiting line is 60 ms old) and stops with its request. The window is listed under the stripe's overflow until first opened. Co-authored-by: payailk <[email protected]>
This commit is contained in:
1 parent
2b86dd2d9f
commit
090a1cd6df
22 files changed
+1976
-1
No files matched your search
Generated
+12
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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<PathBuf> {
|
||||
let mut roots: Vec<PathBuf> = ["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<PathBuf> {
|
||||
static ADB: OnceLock<Option<PathBuf>> = 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<PathBuf> = 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<PathBuf> {
|
||||
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<Option<PathBuf>> = 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<Vec<AndroidDevice>> {
|
||||
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<u32>) -> 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 <count>` 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<LogcatLine>),
|
||||
) -> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub product: Option<String>,
|
||||
/// The device's own name from the build (`ro.product.device`).
|
||||
pub device: Option<String>,
|
||||
pub transport_id: Option<String>,
|
||||
}
|
||||
|
||||
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<AndroidDevice> {
|
||||
let mut devices: Vec<AndroidDevice> = 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<LogcatLine> {
|
||||
// "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);
|
||||
}
|
||||
}
|
||||
@@ -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<Child> {
|
||||
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<String> {
|
||||
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<Output> {
|
||||
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<Event>, stdout: bool) {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
let mut pending: Vec<u8> = Vec::new();
|
||||
let flush = |pending: &mut Vec<u8>| {
|
||||
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<T>(batch: &mut Vec<T>, on_batch: &mut dyn FnMut(Vec<T>)) {
|
||||
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<T>(
|
||||
mut cmd: Command,
|
||||
cancel: &CancellationToken,
|
||||
idle: Duration,
|
||||
max: usize,
|
||||
mut parse: impl FnMut(&str) -> Option<T>,
|
||||
on_batch: &mut dyn FnMut(Vec<T>),
|
||||
) -> IdeResult<(bool, Vec<String>)> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
prepare(&mut cmd);
|
||||
let mut child = spawn(&mut cmd)?;
|
||||
let (tx, rx) = mpsc::channel::<Event>();
|
||||
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<String> = Vec::new();
|
||||
let mut batch: Vec<T> = 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::<usize>(), 20);
|
||||
assert!(sizes.len() >= 3, "lines arriving every 20 ms waited for the end: {sizes:?}");
|
||||
}
|
||||
}
|
||||
@@ -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=<serial> 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<String> {
|
||||
if let Ok(serial) = std::env::var("OPENIDEA_ADB_DEVICE") {
|
||||
return Some(serial);
|
||||
}
|
||||
let online = devices().ok()?.into_iter().filter(AndroidDevice::is_online).collect::<Vec<_>>();
|
||||
(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::<LogcatLine>::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");
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
|
||||
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<Vec<AndroidDevice>> {
|
||||
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<Vec<LogcatLine>>,
|
||||
) -> 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
|
||||
}
|
||||
@@ -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<T: tauri::ipc::IpcResponse + Send + Sync + 'static>(
|
||||
pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
tauri_specta::Builder::<tauri::Wry>::new().commands(collect_commands![
|
||||
app::app_info,
|
||||
android::android_devices,
|
||||
android::android_logcat,
|
||||
app::recent_projects,
|
||||
app::remove_recent_project,
|
||||
app::recent_project_groups,
|
||||
|
||||
@@ -8,6 +8,13 @@ import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core";
|
||||
/** Commands */
|
||||
export const commands = {
|
||||
appInfo: () => __TAURI_INVOKE<AppInfo>("app_info"),
|
||||
/** Devices `adb` knows about, online ones first. */
|
||||
androidDevices: () => typedError<AndroidDevice[], IdeError>(__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<LogcatLine[]>) => typedError<null, IdeError>(__TAURI_INVOKE("android_logcat", { request, serial, clear, onLines })),
|
||||
recentProjects: () => __TAURI_INVOKE<RecentProject[]>("recent_projects"),
|
||||
removeRecentProject: (path: string) => typedError<null, IdeError>(__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 = {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<LogcatLevel, string> = {
|
||||
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<HTMLDivElement>(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<LogcatLine[]>();
|
||||
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 (
|
||||
<div className={styles.view}>
|
||||
<div className={styles.toolbar}>
|
||||
<Select
|
||||
className={styles.device}
|
||||
aria-label={"Device"}
|
||||
placeholder="No devices"
|
||||
value={serial}
|
||||
options={deviceOptions}
|
||||
onChange={(value) => useLogcat.setState({ serial: value })}
|
||||
/>
|
||||
<Select
|
||||
className={styles.level}
|
||||
aria-label={"Minimum level"}
|
||||
value={level}
|
||||
options={FILTER_LEVELS.map((value) => ({ value, label: LEVEL_LABELS[value] }))}
|
||||
onChange={(value) => useLogcat.setState({ level: value })}
|
||||
/>
|
||||
<Input
|
||||
className={styles.search}
|
||||
placeholder="Filter, e.g. tag:glide -message:bitmap level:error"
|
||||
aria-label={"Filter log lines"}
|
||||
title={"tag: · message: · pid: · level: · -term · a|b · (a|b) -c · message~:regex · ↑ for history"}
|
||||
invalid={filterError !== null}
|
||||
value={query}
|
||||
onKeyDown={(e) => {
|
||||
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 });
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon="history"
|
||||
title="Filter history"
|
||||
size="small"
|
||||
disabled={!history.length}
|
||||
onClick={(e) => showContextMenu({ clientX: e.clientX, clientY: e.clientY }, historyItems())}
|
||||
/>
|
||||
<IconButton
|
||||
icon={paused ? "resume" : "pause"}
|
||||
title={paused ? "Resume" : "Pause"}
|
||||
size="small"
|
||||
pressed={paused}
|
||||
onClick={() => setPaused(!paused)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="arrow-down"
|
||||
title={follow ? "Following the end (click to stop)" : "Scroll to end"}
|
||||
size="small"
|
||||
pressed={follow}
|
||||
onClick={() => useLogcat.setState({ follow: !follow })}
|
||||
/>
|
||||
<IconButton icon="trash" title="Clear" size="small" onClick={clearLines} />
|
||||
<IconButton
|
||||
icon="refresh"
|
||||
title="Refresh devices"
|
||||
size="small"
|
||||
disabled={loadingDevices}
|
||||
onClick={refreshDevices}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{devicesError ? (
|
||||
<EmptyState title="No Android devices">
|
||||
<span>{devicesError}</span>
|
||||
</EmptyState>
|
||||
) : !devices.length ? (
|
||||
<EmptyState title={loadingDevices ? "Looking for devices…" : "No Android devices"}>
|
||||
<span>
|
||||
<T>Connect a device over USB, or start an emulator. Logcat uses the Android SDK's adb.</T>
|
||||
</span>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={styles.log}
|
||||
role="log"
|
||||
aria-label={"Device log"}
|
||||
tabIndex={0}
|
||||
onScroll={(e) => {
|
||||
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 });
|
||||
}}
|
||||
>
|
||||
<div className={styles.canvas} style={{ height: virtualizer.getTotalSize() }}>
|
||||
{virtualizer.getVirtualItems().map((item) => {
|
||||
const line = rows[item.index]!;
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
ref={virtualizer.measureElement}
|
||||
data-index={item.index}
|
||||
className={clsx(styles.row, styles[LEVEL_CLASS[line.level]])}
|
||||
style={{ transform: `translateY(${item.start}px)` }}
|
||||
>
|
||||
<span className={styles.time}>{shortTime(line.time)}</span>
|
||||
<span className={styles.pid}>{line.pid || ""}</span>
|
||||
<span className={styles.tag}>{line.tag}</span>
|
||||
<span className={styles.message}>{line.message}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!rows.length && (
|
||||
<div className={styles.empty}>
|
||||
{lines.length ? <T>No lines match the filter</T> : <T>Waiting for output…</T>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.status}>
|
||||
<span>{status === "running" ? <T>Streaming</T> : status === "error" ? <T>Stopped</T> : <T>Idle</T>}</span>
|
||||
{paused && <span className={styles.warn}>Paused</span>}
|
||||
{filterError && <span className={styles.error}>Filter ignored: {filterError}</span>}
|
||||
<span className={styles.count}>
|
||||
{rows.length === lines.length ? lines.length : `${rows.length} / ${lines.length}`}
|
||||
</span>
|
||||
{error && <span className={styles.error}>{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string>();
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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<LogcatLevel, string> = {
|
||||
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<T extends LogcatLine>(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));
|
||||
}
|
||||
@@ -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<string, QueryField> = {
|
||||
tag: "tag",
|
||||
message: MESSAGE_FIELD,
|
||||
msg: MESSAGE_FIELD,
|
||||
pid: "pid",
|
||||
level: "level",
|
||||
};
|
||||
|
||||
const LEVEL_NAMES: Record<string, LogcatLevel> = {
|
||||
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<LogcatLevel> {
|
||||
const names = value.split(",").map((part) => part.trim().toLowerCase());
|
||||
const chosen = new Set<LogcatLevel>();
|
||||
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<string, LogFilter>();
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -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<LogcatState>(() => ({
|
||||
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: [],
|
||||
});
|
||||
}
|
||||
@@ -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": "空闲"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user