Add tailnew: follow the newest log file in a directory
tailnew tails the most recently started file in a directory and switches to each new one as it appears, for watching harnesses such as boxmaker's run-plan.sh that write one log per task. A file counts as new when its name is new, when the name points at a different file, or when it has shrunk (a rerun truncating its old log). Three front ends share one follower: plain tail -f style output, a ratatui terminal view with scrollback, and an egui window with a folder dialog and a Recursive checkbox. The Makefile provides build, gate and a per-user install that also installs a desktop entry for the window. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+4601
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "tailnew"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Follow the newest file in a directory, switching to each new file as it appears"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.6", features = ["derive"] }
|
||||
eframe = { version = "0.32", optional = true }
|
||||
rfd = { version = "0.15", optional = true }
|
||||
ratatui = "0.29"
|
||||
unicode-width = "0.2"
|
||||
|
||||
[features]
|
||||
default = ["gui"]
|
||||
# The desktop window (--gui) and its folder dialog. Build with --no-default-features for a terminal-only binary.
|
||||
gui = ["dep:eframe", "dep:rfd"]
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,31 @@
|
||||
# Per-user install: the binary and the desktop entry for the window.
|
||||
|
||||
PREFIX ?= $(HOME)/.local
|
||||
BINDIR := $(PREFIX)/bin
|
||||
APPDIR := $(or $(XDG_DATA_HOME),$(HOME)/.local/share)/applications
|
||||
|
||||
.PHONY: build gate install uninstall
|
||||
|
||||
build:
|
||||
cargo build --release
|
||||
|
||||
# Formatting, lints with and without the window, tests, and the desktop entry. Offline once
|
||||
# dependencies are fetched.
|
||||
gate:
|
||||
cargo fmt --check
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo clippy --all-targets --no-default-features -- -D warnings
|
||||
cargo test
|
||||
desktop-file-validate dist/tailnew.desktop
|
||||
@echo "gate: ok"
|
||||
|
||||
# dist/tailnew.desktop carries an @BINDIR@ placeholder so it points wherever PREFIX put the binary.
|
||||
install: build
|
||||
install -Dm755 target/release/tailnew $(BINDIR)/tailnew
|
||||
install -d $(APPDIR)
|
||||
sed 's|@BINDIR@|$(BINDIR)|' dist/tailnew.desktop > $(APPDIR)/tailnew.desktop
|
||||
-update-desktop-database $(APPDIR)
|
||||
|
||||
uninstall:
|
||||
rm -f $(BINDIR)/tailnew $(APPDIR)/tailnew.desktop
|
||||
-update-desktop-database $(APPDIR)
|
||||
@@ -0,0 +1,40 @@
|
||||
# tailnew
|
||||
|
||||
Follows the newest file in a directory, like `tail -f`, and switches to each new file as it
|
||||
appears. Built for watching harnesses such as boxmaker's `tools/run-plan.sh`, which write one log
|
||||
per task.
|
||||
|
||||
make install # ~/.local/bin/tailnew and a desktop entry for the window
|
||||
tailnew .state/runs/M2a # plain output, `==> file <==` banner on each switch
|
||||
tailnew -t -r .state/runs # full-screen terminal view, every milestone's directory
|
||||
tailnew --gui -r .state/runs # desktop window (egui)
|
||||
tailnew --gui # desktop window, asks for the folder first
|
||||
|
||||
`make gate` checks formatting, runs clippy with and without the window, runs the tests, and
|
||||
validates the desktop entry. `PREFIX=/usr/local make install` installs elsewhere;
|
||||
`cargo build --no-default-features` builds without the window.
|
||||
|
||||
A file is new when its name has not been seen before, when the name now points at a different
|
||||
file, or when it has shrunk (a rerun that truncated its old log with `>`). The directory is polled
|
||||
(`-i MS`, default 250) and need not exist yet. Hidden files are ignored; `-g '*.log'` narrows the
|
||||
match further.
|
||||
|
||||
Keys in the full-screen terminal view (`-t`):
|
||||
|
||||
| key | action |
|
||||
|-------------------------|----------------------------------------------------|
|
||||
| ↑ ↓ / k j | scroll a line |
|
||||
| PgUp PgDn / b space | scroll a page (ctrl-u / ctrl-d: half a page) |
|
||||
| g / G | top / back to following the tail |
|
||||
| [ ] | older / newer file (pins the view to it) |
|
||||
| p | pin to the current file; again to unpin and jump to the newest |
|
||||
| L | unpin and jump to the newest file |
|
||||
| w | toggle line wrapping |
|
||||
| f | toggle the file list |
|
||||
| q / esc | quit |
|
||||
|
||||
The window (`--gui`) has a **Folder…** button (the desktop's folder dialog, also `o`) and a
|
||||
**Recursive** checkbox, buttons for the same file controls, a clickable file list, and the keys
|
||||
`[` `]` `p` `L` `f`, `G`/End to jump to the end, and `q`/Esc to quit. Started without a folder,
|
||||
as from the launcher, it opens the folder dialog straight away. It scrolls with the mouse,
|
||||
sticks to the bottom while you are there, and does not wrap long lines (scroll sideways instead).
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=tailnew
|
||||
GenericName=Log Follower
|
||||
Comment=Follow the newest log file in a folder
|
||||
Exec=@BINDIR@/tailnew --gui %f
|
||||
Icon=utilities-log-viewer
|
||||
Terminal=false
|
||||
Categories=Development;
|
||||
Keywords=log;tail;follow;
|
||||
StartupWMClass=tailnew
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Plain `tail -f` style output: bytes go to stdout as they arrive, with a `==> file <==` banner
|
||||
//! whenever the followed file changes.
|
||||
|
||||
use std::io::{self, Write};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::watch::{Event, Follower};
|
||||
|
||||
pub fn run(mut follower: Follower, interval: Duration, quiet: bool) -> io::Result<()> {
|
||||
let mut out = io::stdout().lock();
|
||||
let mut at_line_start = true;
|
||||
let mut first_banner = true;
|
||||
let mut announced_wait = false;
|
||||
loop {
|
||||
let events = follower.poll()?;
|
||||
if follower.current_path().is_none() && !announced_wait {
|
||||
eprintln!("tailnew: waiting for files in {}", follower.dir().display());
|
||||
announced_wait = true;
|
||||
}
|
||||
for event in events {
|
||||
let banner = match &event {
|
||||
Event::Switched(path) => format!("==> {} <==", path.display()),
|
||||
Event::Restarted(path) => format!("==> {} (restarted) <==", path.display()),
|
||||
Event::Data(bytes) => {
|
||||
out.write_all(bytes)?;
|
||||
at_line_start = bytes.ends_with(b"\n");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if quiet {
|
||||
continue;
|
||||
}
|
||||
if !at_line_start {
|
||||
writeln!(out)?;
|
||||
}
|
||||
if !first_banner {
|
||||
writeln!(out)?;
|
||||
}
|
||||
writeln!(out, "{banner}")?;
|
||||
first_banner = false;
|
||||
at_line_start = true;
|
||||
}
|
||||
out.flush()?;
|
||||
thread::sleep(interval);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
//! Desktop window (egui): a header with the folder and file controls, an optional file list, the
|
||||
//! log itself, and a status line.
|
||||
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{self, Receiver, TryRecvError};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use eframe::egui::{
|
||||
self, Color32, Key, Label, RichText, ScrollArea, TextStyle, TextWrapMode, ViewportCommand,
|
||||
};
|
||||
|
||||
use crate::view::View;
|
||||
use crate::watch::{Follower, Options, Start};
|
||||
|
||||
/// Follows `opts.dir`, or with `chosen` false, opens the folder dialog first.
|
||||
pub fn run(
|
||||
opts: Options,
|
||||
chosen: bool,
|
||||
lines: usize,
|
||||
interval: Duration,
|
||||
scrollback: usize,
|
||||
) -> io::Result<()> {
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("tailnew")
|
||||
.with_app_id("tailnew")
|
||||
.with_inner_size([1100.0, 750.0]),
|
||||
..Default::default()
|
||||
};
|
||||
let recursive = opts.recursive;
|
||||
let mut gui = Gui {
|
||||
view: View::new(Follower::new(opts, Start::LastLines(lines)), scrollback),
|
||||
chosen,
|
||||
recursive,
|
||||
picking: None,
|
||||
lines,
|
||||
scrollback,
|
||||
interval,
|
||||
next_poll: Instant::now(),
|
||||
show_files: true,
|
||||
seen_generation: 0,
|
||||
to_end: false,
|
||||
};
|
||||
eframe::run_native(
|
||||
"tailnew",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
if !gui.chosen {
|
||||
gui.pick(&cc.egui_ctx);
|
||||
}
|
||||
Ok(Box::new(gui))
|
||||
}),
|
||||
)
|
||||
.map_err(|e| io::Error::other(e.to_string()))
|
||||
}
|
||||
|
||||
struct Gui {
|
||||
view: View,
|
||||
/// False until a folder has been chosen, when started without one; nothing is polled till then.
|
||||
chosen: bool,
|
||||
/// The Recursive checkbox; applied by rebuilding the view.
|
||||
recursive: bool,
|
||||
/// The folder dialog runs on its own thread so the window keeps drawing; its answer lands here.
|
||||
picking: Option<Receiver<Option<PathBuf>>>,
|
||||
lines: usize,
|
||||
scrollback: usize,
|
||||
interval: Duration,
|
||||
next_poll: Instant,
|
||||
show_files: bool,
|
||||
seen_generation: u64,
|
||||
/// Scroll to the end on the next frame; the scroll area then sticks there as lines arrive.
|
||||
to_end: bool,
|
||||
}
|
||||
|
||||
impl eframe::App for Gui {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.check_pick();
|
||||
if self.chosen && Instant::now() >= self.next_poll {
|
||||
self.view.poll();
|
||||
self.next_poll = Instant::now() + self.interval;
|
||||
}
|
||||
ctx.request_repaint_after(self.next_poll.saturating_duration_since(Instant::now()));
|
||||
|
||||
self.keys(ctx);
|
||||
if self.view.generation() != self.seen_generation {
|
||||
self.seen_generation = self.view.generation();
|
||||
self.to_end = true;
|
||||
}
|
||||
|
||||
egui::TopBottomPanel::top("header").show(ctx, |ui| self.header(ui));
|
||||
egui::TopBottomPanel::bottom("status").show(ctx, |ui| self.status(ui));
|
||||
if self.show_files {
|
||||
egui::SidePanel::left("files")
|
||||
.resizable(true)
|
||||
.default_width(220.0)
|
||||
.show(ctx, |ui| self.files(ui));
|
||||
}
|
||||
egui::CentralPanel::default().show(ctx, |ui| self.log(ui));
|
||||
}
|
||||
}
|
||||
|
||||
impl Gui {
|
||||
/// Opens the folder dialog, starting from the current folder.
|
||||
fn pick(&mut self, ctx: &egui::Context) {
|
||||
if self.picking.is_some() {
|
||||
return;
|
||||
}
|
||||
let start = std::fs::canonicalize(self.view.follower.dir()).ok();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let ctx = ctx.clone();
|
||||
thread::spawn(move || {
|
||||
let mut dialog = rfd::FileDialog::new().set_title("Folder to watch");
|
||||
if let Some(start) = start {
|
||||
dialog = dialog.set_directory(start);
|
||||
}
|
||||
let _ = tx.send(dialog.pick_folder());
|
||||
ctx.request_repaint();
|
||||
});
|
||||
self.picking = Some(rx);
|
||||
}
|
||||
|
||||
fn check_pick(&mut self) {
|
||||
let Some(rx) = &self.picking else { return };
|
||||
match rx.try_recv() {
|
||||
Ok(Some(dir)) => {
|
||||
self.picking = None;
|
||||
self.watch(dir, self.recursive);
|
||||
}
|
||||
Ok(None) | Err(TryRecvError::Disconnected) => self.picking = None,
|
||||
Err(TryRecvError::Empty) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts over on `dir` with a fresh view.
|
||||
fn watch(&mut self, dir: PathBuf, recursive: bool) {
|
||||
let opts = Options {
|
||||
dir,
|
||||
recursive,
|
||||
..self.view.follower.options().clone()
|
||||
};
|
||||
self.view = View::new(
|
||||
Follower::new(opts, Start::LastLines(self.lines)),
|
||||
self.scrollback,
|
||||
);
|
||||
self.chosen = true;
|
||||
self.recursive = recursive;
|
||||
self.seen_generation = 0;
|
||||
self.next_poll = Instant::now();
|
||||
}
|
||||
|
||||
fn keys(&mut self, ctx: &egui::Context) {
|
||||
let pressed = |key| ctx.input(|i| i.key_pressed(key));
|
||||
if pressed(Key::Q) || pressed(Key::Escape) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::Close);
|
||||
}
|
||||
if pressed(Key::OpenBracket) {
|
||||
self.view.step(-1);
|
||||
}
|
||||
if pressed(Key::CloseBracket) {
|
||||
self.view.step(1);
|
||||
}
|
||||
if pressed(Key::P) {
|
||||
self.view.toggle_pin();
|
||||
}
|
||||
if pressed(Key::L) {
|
||||
self.view.go_latest();
|
||||
}
|
||||
if pressed(Key::F) {
|
||||
self.show_files = !self.show_files;
|
||||
}
|
||||
if pressed(Key::O) {
|
||||
self.pick(ctx);
|
||||
}
|
||||
if pressed(Key::End) || pressed(Key::G) {
|
||||
self.to_end = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn header(&mut self, ui: &mut egui::Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
let button = egui::Button::new("📁 Folder…");
|
||||
if ui
|
||||
.add_enabled(self.picking.is_none(), button)
|
||||
.on_hover_text("Choose the folder to watch (o)")
|
||||
.clicked()
|
||||
{
|
||||
self.pick(ui.ctx());
|
||||
}
|
||||
let mut recursive = self.recursive;
|
||||
if ui
|
||||
.checkbox(&mut recursive, "Recursive")
|
||||
.on_hover_text("Also watch subfolders")
|
||||
.changed()
|
||||
{
|
||||
if self.chosen {
|
||||
let dir = self.view.follower.dir().to_path_buf();
|
||||
self.watch(dir, recursive);
|
||||
} else {
|
||||
self.recursive = recursive;
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if !self.chosen {
|
||||
ui.weak("no folder chosen");
|
||||
return;
|
||||
}
|
||||
let dir = self.view.follower.dir().display().to_string();
|
||||
ui.weak(dir);
|
||||
// The folder is already shown, so the title's own "waiting for files in …" would repeat it.
|
||||
if self.view.follower.current_path().is_some() {
|
||||
ui.label(RichText::new(self.view.title()).strong().monospace());
|
||||
} else {
|
||||
ui.weak("waiting for files");
|
||||
}
|
||||
if self.view.follower.pinned() {
|
||||
ui.label(
|
||||
RichText::new("PINNED")
|
||||
.strong()
|
||||
.color(Color32::from_rgb(230, 180, 40)),
|
||||
);
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui
|
||||
.button("Newest")
|
||||
.on_hover_text("Unpin and follow the newest file (L)")
|
||||
.clicked()
|
||||
{
|
||||
self.view.go_latest();
|
||||
}
|
||||
let mut pinned = self.view.follower.pinned();
|
||||
if ui
|
||||
.toggle_value(&mut pinned, "Pin")
|
||||
.on_hover_text("Stay on this file when new ones appear (p)")
|
||||
.clicked()
|
||||
{
|
||||
self.view.toggle_pin();
|
||||
}
|
||||
if ui.button("Newer ▶").on_hover_text("]").clicked() {
|
||||
self.view.step(1);
|
||||
}
|
||||
if ui.button("◀ Older").on_hover_text("[").clicked() {
|
||||
self.view.step(-1);
|
||||
}
|
||||
ui.toggle_value(&mut self.show_files, "Files")
|
||||
.on_hover_text("Show the file list (f)");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn status(&self, ui: &mut egui::Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(e) = self.view.error() {
|
||||
ui.colored_label(ui.visuals().error_fg_color, e);
|
||||
} else if let Some(status) = self.view.status() {
|
||||
ui.weak(status);
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.weak(format!("{} lines", self.view.total()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn files(&mut self, ui: &mut egui::Ui) {
|
||||
ui.strong("Files, newest first");
|
||||
ui.separator();
|
||||
let mut open = None;
|
||||
ScrollArea::vertical().auto_shrink(false).show(ui, |ui| {
|
||||
let current = self.view.follower.current_path();
|
||||
for e in self.view.follower.entries().iter().rev() {
|
||||
let here = Some(e.path.as_path()) == current;
|
||||
let name = RichText::new(self.view.relative(&e.path)).monospace();
|
||||
if ui.selectable_label(here, name).clicked() && !here {
|
||||
open = Some(e.path.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(path) = open {
|
||||
self.view.open_pinned(&path);
|
||||
}
|
||||
}
|
||||
|
||||
fn log(&mut self, ui: &mut egui::Ui) {
|
||||
if !self.chosen {
|
||||
ui.centered_and_justified(|ui| {
|
||||
let button = egui::Button::new("📁 Choose a folder to watch…");
|
||||
if ui.add_enabled(self.picking.is_none(), button).clicked() {
|
||||
self.pick(ui.ctx());
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
let row_height = ui.text_style_height(&TextStyle::Monospace);
|
||||
let total = self.view.total();
|
||||
let mut area = ScrollArea::both().auto_shrink(false).stick_to_bottom(true);
|
||||
if std::mem::take(&mut self.to_end) {
|
||||
// The whole content height; the scroll area clamps it to the last page. Not f32::MAX:
|
||||
// show_rows turns the offset into a row index, which would overflow.
|
||||
area = area.vertical_scroll_offset(total as f32 * row_height);
|
||||
}
|
||||
// Only the visible rows are laid out, so a long scrollback costs nothing per frame.
|
||||
area.show_rows(ui, row_height, total, |ui, rows| {
|
||||
for i in rows {
|
||||
let text = RichText::new(self.view.line(i)).monospace();
|
||||
ui.add(Label::new(text).wrap_mode(TextWrapMode::Extend));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
mod cli;
|
||||
#[cfg(feature = "gui")]
|
||||
mod gui;
|
||||
mod tui;
|
||||
mod view;
|
||||
mod watch;
|
||||
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use watch::{Follower, Options, Start};
|
||||
|
||||
/// Follow the newest file in a directory, switching to each new file as it appears.
|
||||
///
|
||||
/// A file is new when its name has not been seen before, when the name now points at a different
|
||||
/// file, or when it has shrunk (a rerun that truncated its old log). Hidden files are ignored.
|
||||
#[derive(Parser)]
|
||||
#[command(version)]
|
||||
struct Args {
|
||||
/// Directory to watch; it need not exist yet [default: ".", or a folder dialog with --gui].
|
||||
dir: Option<PathBuf>,
|
||||
|
||||
/// Full-screen terminal view with scrollback instead of plain output.
|
||||
#[arg(short, long)]
|
||||
tui: bool,
|
||||
|
||||
/// Desktop window instead of plain output. Its folder dialog changes the directory.
|
||||
#[cfg(feature = "gui")]
|
||||
#[arg(long, conflicts_with = "tui")]
|
||||
gui: bool,
|
||||
|
||||
/// Also watch subdirectories (for example `.state/runs` for every milestone).
|
||||
#[arg(short, long)]
|
||||
recursive: bool,
|
||||
|
||||
/// Only follow files whose names match, with `*` and `?` wildcards, such as '*.log'.
|
||||
#[arg(short, long, value_name = "PATTERN")]
|
||||
glob: Option<String>,
|
||||
|
||||
/// Lines of the newest file to show at startup [default: 10, or the scrollback with --tui/--gui].
|
||||
#[arg(short = 'n', long, value_name = "N")]
|
||||
lines: Option<usize>,
|
||||
|
||||
/// How often to check for new files and data, in milliseconds.
|
||||
#[arg(short, long, value_name = "MS", default_value_t = 250)]
|
||||
interval: u64,
|
||||
|
||||
/// Leave out the `==> file <==` banners in plain output.
|
||||
#[arg(short, long)]
|
||||
quiet: bool,
|
||||
|
||||
/// Lines kept for scrolling back in the full-screen view and the window.
|
||||
#[arg(long, value_name = "N", default_value_t = 50_000)]
|
||||
scrollback: usize,
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args = Args::parse();
|
||||
#[cfg(feature = "gui")]
|
||||
let chosen = args.dir.is_some();
|
||||
let opts = Options {
|
||||
dir: args.dir.unwrap_or_else(|| PathBuf::from(".")),
|
||||
recursive: args.recursive,
|
||||
glob: args.glob,
|
||||
};
|
||||
let interval = Duration::from_millis(args.interval.max(10));
|
||||
#[cfg(feature = "gui")]
|
||||
if args.gui {
|
||||
let lines = args.lines.unwrap_or(args.scrollback);
|
||||
return finish(gui::run(opts, chosen, lines, interval, args.scrollback));
|
||||
}
|
||||
let result = if args.tui {
|
||||
let lines = args.lines.unwrap_or(args.scrollback);
|
||||
tui::run(
|
||||
Follower::new(opts, Start::LastLines(lines)),
|
||||
interval,
|
||||
args.scrollback,
|
||||
)
|
||||
} else {
|
||||
let lines = args.lines.unwrap_or(10);
|
||||
cli::run(
|
||||
Follower::new(opts, Start::LastLines(lines)),
|
||||
interval,
|
||||
args.quiet,
|
||||
)
|
||||
};
|
||||
finish(result)
|
||||
}
|
||||
|
||||
fn finish(result: io::Result<()>) -> ExitCode {
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
// `tailnew | head` should end quietly.
|
||||
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("tailnew: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
//! Full-screen view: a header naming the followed file, the log itself with scrollback, an
|
||||
//! optional list of the files in the directory, and a footer with the keys.
|
||||
|
||||
use std::io;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ratatui::crossterm::event::{
|
||||
self, Event as TermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
|
||||
};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style, Stylize};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
|
||||
use ratatui::{DefaultTerminal, Frame};
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
use crate::view::View;
|
||||
use crate::watch::Follower;
|
||||
|
||||
const HELP: &str = "q quit ↑↓ PgUp PgDn g G scroll [ ] older/newer p pin w wrap f files";
|
||||
|
||||
pub fn run(follower: Follower, interval: Duration, scrollback: usize) -> io::Result<()> {
|
||||
let mut terminal = ratatui::init();
|
||||
let result = App::new(follower, scrollback).event_loop(&mut terminal, interval);
|
||||
ratatui::restore();
|
||||
result
|
||||
}
|
||||
|
||||
struct App {
|
||||
view: View,
|
||||
/// How many logical lines the view is scrolled up from the bottom; 0 follows the tail.
|
||||
scroll: usize,
|
||||
wrap: bool,
|
||||
show_files: bool,
|
||||
page: usize,
|
||||
seen_generation: u64,
|
||||
seen_appended: u64,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new(follower: Follower, scrollback: usize) -> Self {
|
||||
App {
|
||||
view: View::new(follower, scrollback),
|
||||
scroll: 0,
|
||||
wrap: true,
|
||||
show_files: false,
|
||||
page: 20,
|
||||
seen_generation: 0,
|
||||
seen_appended: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn event_loop(&mut self, terminal: &mut DefaultTerminal, interval: Duration) -> io::Result<()> {
|
||||
let mut next_poll = Instant::now();
|
||||
loop {
|
||||
if Instant::now() >= next_poll {
|
||||
self.view.poll();
|
||||
next_poll = Instant::now() + interval;
|
||||
}
|
||||
self.sync_scroll();
|
||||
terminal.draw(|frame| self.render(frame))?;
|
||||
let timeout = next_poll.saturating_duration_since(Instant::now());
|
||||
if event::poll(timeout)?
|
||||
&& let TermEvent::Key(key) = event::read()?
|
||||
&& key.kind == KeyEventKind::Press
|
||||
&& self.key(key)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Follows the tail after the lines start over, and otherwise keeps a scrolled-back view
|
||||
/// still while the log grows beneath it.
|
||||
fn sync_scroll(&mut self) {
|
||||
if self.view.generation() != self.seen_generation {
|
||||
self.scroll = 0;
|
||||
} else if self.scroll > 0 {
|
||||
self.scroll += (self.view.appended() - self.seen_appended) as usize;
|
||||
}
|
||||
self.seen_generation = self.view.generation();
|
||||
self.seen_appended = self.view.appended();
|
||||
self.scroll = self.scroll.min(self.view.total().saturating_sub(1));
|
||||
}
|
||||
|
||||
/// Handles a key; returns true to quit.
|
||||
fn key(&mut self, key: KeyEvent) -> bool {
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
let total = self.view.total();
|
||||
let max = total.saturating_sub(1);
|
||||
match key.code {
|
||||
KeyCode::Char('q') | KeyCode::Esc => return true,
|
||||
KeyCode::Char('c') if ctrl => return true,
|
||||
KeyCode::Up | KeyCode::Char('k') => self.scroll = (self.scroll + 1).min(max),
|
||||
KeyCode::Down | KeyCode::Char('j') => self.scroll = self.scroll.saturating_sub(1),
|
||||
KeyCode::PageUp | KeyCode::Char('b') => {
|
||||
self.scroll = (self.scroll + self.page).min(max)
|
||||
}
|
||||
KeyCode::PageDown | KeyCode::Char(' ') => {
|
||||
self.scroll = self.scroll.saturating_sub(self.page)
|
||||
}
|
||||
KeyCode::Char('u') if ctrl => self.scroll = (self.scroll + self.page / 2).min(max),
|
||||
KeyCode::Char('d') if ctrl => self.scroll = self.scroll.saturating_sub(self.page / 2),
|
||||
KeyCode::Home | KeyCode::Char('g') => self.scroll = total.saturating_sub(self.page),
|
||||
KeyCode::End | KeyCode::Char('G') => self.scroll = 0,
|
||||
KeyCode::Char('w') => self.wrap = !self.wrap,
|
||||
KeyCode::Char('f') => self.show_files = !self.show_files,
|
||||
KeyCode::Char('[') => self.view.step(-1),
|
||||
KeyCode::Char(']') => self.view.step(1),
|
||||
KeyCode::Char('p') => self.view.toggle_pin(),
|
||||
KeyCode::Char('L') => self.view.go_latest(),
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn render(&mut self, frame: &mut Frame) {
|
||||
let [header, body, footer] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(frame.area());
|
||||
self.render_header(frame, header);
|
||||
self.render_footer(frame, footer);
|
||||
|
||||
let log = if self.show_files {
|
||||
let [files, log] =
|
||||
Layout::horizontal([Constraint::Percentage(25), Constraint::Min(20)]).areas(body);
|
||||
self.render_files(frame, files);
|
||||
log
|
||||
} else {
|
||||
body
|
||||
};
|
||||
self.page = (log.height as usize).max(1);
|
||||
let total = self.view.total();
|
||||
let end = total - self.scroll.min(total);
|
||||
let rows = visible_rows(
|
||||
end,
|
||||
log.height as usize,
|
||||
log.width as usize,
|
||||
self.wrap,
|
||||
|i| self.view.line(i).into_owned(),
|
||||
);
|
||||
let text: Vec<Line> = rows.into_iter().map(Line::from).collect();
|
||||
frame.render_widget(Paragraph::new(text), log);
|
||||
}
|
||||
|
||||
fn render_header(&self, frame: &mut Frame, area: Rect) {
|
||||
let name = self.view.title();
|
||||
let mut spans = vec![
|
||||
Span::raw(" tailnew ").bold(),
|
||||
Span::raw("│ "),
|
||||
Span::raw(name).bold(),
|
||||
];
|
||||
if self.view.follower.pinned() {
|
||||
spans.push(Span::raw(" PINNED").yellow().bold());
|
||||
}
|
||||
if self.scroll > 0 {
|
||||
spans.push(
|
||||
Span::raw(format!(" SCROLLED -{}", self.scroll))
|
||||
.cyan()
|
||||
.bold(),
|
||||
);
|
||||
} else {
|
||||
spans.push(Span::raw(" FOLLOWING").green());
|
||||
}
|
||||
let style = Style::new().add_modifier(Modifier::REVERSED);
|
||||
frame.render_widget(Paragraph::new(Line::from(spans)).style(style), area);
|
||||
}
|
||||
|
||||
fn render_footer(&self, frame: &mut Frame, area: Rect) {
|
||||
let status = if let Some(e) = self.view.error() {
|
||||
Span::raw(format!(" {e} ")).red().bold()
|
||||
} else if let Some(status) = self.view.status() {
|
||||
Span::raw(format!(" {status} ")).dim()
|
||||
} else {
|
||||
Span::raw("")
|
||||
};
|
||||
let [help, right] = Layout::horizontal([
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(status.width() as u16),
|
||||
])
|
||||
.areas(area);
|
||||
frame.render_widget(Paragraph::new(Span::raw(format!(" {HELP}")).dim()), help);
|
||||
frame.render_widget(Paragraph::new(status), right);
|
||||
}
|
||||
|
||||
fn render_files(&self, frame: &mut Frame, area: Rect) {
|
||||
let current = self.view.follower.current_path();
|
||||
let items: Vec<ListItem> = self
|
||||
.view
|
||||
.follower
|
||||
.entries()
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|e| {
|
||||
let name = self.view.relative(&e.path);
|
||||
if Some(e.path.as_path()) == current {
|
||||
ListItem::new(format!("▸ {name}")).bold()
|
||||
} else {
|
||||
ListItem::new(format!(" {name}"))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let block = Block::new()
|
||||
.borders(Borders::RIGHT)
|
||||
.title(" files, newest first ");
|
||||
frame.render_widget(List::new(items).block(block), area);
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen rows that show logical lines `..end`, bottom-aligned in `height` rows of `width`
|
||||
/// columns. With `wrap`, long lines take several rows; without it, the terminal clips them.
|
||||
fn visible_rows(
|
||||
end: usize,
|
||||
height: usize,
|
||||
width: usize,
|
||||
wrap: bool,
|
||||
line: impl Fn(usize) -> String,
|
||||
) -> Vec<String> {
|
||||
let mut rows: Vec<String> = Vec::new();
|
||||
let mut i = end;
|
||||
while i > 0 && rows.len() < height {
|
||||
i -= 1;
|
||||
let text = line(i);
|
||||
let mut pieces = if wrap {
|
||||
wrap_line(&text, width)
|
||||
} else {
|
||||
vec![text]
|
||||
};
|
||||
pieces.reverse();
|
||||
rows.extend(pieces);
|
||||
}
|
||||
rows.truncate(height);
|
||||
rows.reverse();
|
||||
rows
|
||||
}
|
||||
|
||||
fn wrap_line(text: &str, width: usize) -> Vec<String> {
|
||||
let width = width.max(1);
|
||||
let mut out = vec![String::new()];
|
||||
let mut used = 0;
|
||||
for c in text.chars() {
|
||||
let w = c.width().unwrap_or(0);
|
||||
if used + w > width && used > 0 {
|
||||
out.push(String::new());
|
||||
used = 0;
|
||||
}
|
||||
out.last_mut().unwrap().push(c);
|
||||
used += w;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wrap_line_splits_at_width() {
|
||||
assert_eq!(wrap_line("abcdefg", 3), vec!["abc", "def", "g"]);
|
||||
assert_eq!(wrap_line("", 3), vec![""]);
|
||||
assert_eq!(wrap_line("日本語", 4), vec!["日本", "語"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_rows_are_bottom_aligned() {
|
||||
let lines = ["one", "two", "three", "a long line here"];
|
||||
let get = |i: usize| lines[i].to_string();
|
||||
assert_eq!(
|
||||
visible_rows(4, 2, 80, true, get),
|
||||
vec!["three", "a long line here"]
|
||||
);
|
||||
assert_eq!(
|
||||
visible_rows(4, 3, 6, true, get),
|
||||
vec!["a long", " line ", "here"]
|
||||
);
|
||||
assert_eq!(visible_rows(3, 2, 80, true, get), vec!["two", "three"]);
|
||||
assert_eq!(visible_rows(2, 5, 80, false, get), vec!["one", "two"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! State shared by the interactive front ends: the lines of the followed file, moving between
|
||||
//! files, pinning, and the status line. Scrolling belongs to each front end.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::watch::{Event, Follower, Start};
|
||||
|
||||
pub struct View {
|
||||
pub follower: Follower,
|
||||
lines: VecDeque<String>,
|
||||
/// The bytes after the last newline, shown as a live last line.
|
||||
partial: Vec<u8>,
|
||||
scrollback: usize,
|
||||
/// Bumped whenever the lines start over (a new file, or the current one restarted).
|
||||
generation: u64,
|
||||
/// Complete lines added since the lines last started over, including any since dropped.
|
||||
appended: u64,
|
||||
since: Option<Instant>,
|
||||
note: &'static str,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl View {
|
||||
pub fn new(follower: Follower, scrollback: usize) -> Self {
|
||||
View {
|
||||
follower,
|
||||
lines: VecDeque::new(),
|
||||
partial: Vec::new(),
|
||||
scrollback: scrollback.max(1),
|
||||
generation: 0,
|
||||
appended: 0,
|
||||
since: None,
|
||||
note: "",
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
|
||||
pub fn appended(&self) -> u64 {
|
||||
self.appended
|
||||
}
|
||||
|
||||
pub fn poll(&mut self) {
|
||||
match self.follower.poll() {
|
||||
Ok(events) => {
|
||||
self.error = None;
|
||||
for event in events {
|
||||
self.apply(event);
|
||||
}
|
||||
}
|
||||
Err(e) => self.error = Some(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(&mut self, event: Event) {
|
||||
match event {
|
||||
Event::Switched(_) | Event::Restarted(_) => {
|
||||
self.lines.clear();
|
||||
self.partial.clear();
|
||||
self.generation += 1;
|
||||
self.appended = 0;
|
||||
self.since = Some(Instant::now());
|
||||
self.note = if matches!(event, Event::Restarted(_)) {
|
||||
"restarted"
|
||||
} else {
|
||||
"opened"
|
||||
};
|
||||
}
|
||||
Event::Data(bytes) => {
|
||||
self.partial.extend_from_slice(&bytes);
|
||||
let Some(last) = self.partial.iter().rposition(|&b| b == b'\n') else {
|
||||
return;
|
||||
};
|
||||
let rest = self.partial.split_off(last + 1);
|
||||
let complete = std::mem::replace(&mut self.partial, rest);
|
||||
for raw in complete[..complete.len() - 1].split(|&b| b == b'\n') {
|
||||
self.lines
|
||||
.push_back(clean_line(&String::from_utf8_lossy(raw)));
|
||||
if self.lines.len() > self.scrollback {
|
||||
self.lines.pop_front();
|
||||
}
|
||||
self.appended += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines available to show, counting a partial last line.
|
||||
pub fn total(&self) -> usize {
|
||||
self.lines.len() + usize::from(!self.partial.is_empty())
|
||||
}
|
||||
|
||||
pub fn line(&self, i: usize) -> Cow<'_, str> {
|
||||
match self.lines.get(i) {
|
||||
Some(line) => Cow::Borrowed(line),
|
||||
None => Cow::Owned(clean_line(&String::from_utf8_lossy(&self.partial))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the file `delta` places newer (positive) or older (negative) than the current one,
|
||||
/// and pins it so that the next new file does not pull the view away.
|
||||
pub fn step(&mut self, delta: isize) {
|
||||
let entries = self.follower.entries();
|
||||
if entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
let here = self
|
||||
.follower
|
||||
.current_path()
|
||||
.and_then(|p| entries.iter().position(|e| e.path == p))
|
||||
.unwrap_or(entries.len() - 1);
|
||||
let to = (here as isize + delta).clamp(0, entries.len() as isize - 1) as usize;
|
||||
if to != here {
|
||||
let path = entries[to].path.clone();
|
||||
self.open_pinned(&path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pins the current file, or if already pinned, unpins and returns to the newest.
|
||||
pub fn toggle_pin(&mut self) {
|
||||
if self.follower.pinned() {
|
||||
self.go_latest();
|
||||
} else {
|
||||
self.follower.set_pinned(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Unpins and returns to the newest file.
|
||||
pub fn go_latest(&mut self) {
|
||||
self.follower.set_pinned(false);
|
||||
let newest = self.follower.entries().last().map(|e| e.path.clone());
|
||||
if let Some(path) = newest
|
||||
&& self.follower.current_path() != Some(path.as_path())
|
||||
{
|
||||
self.open(&path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens `path` and pins it.
|
||||
pub fn open_pinned(&mut self, path: &Path) {
|
||||
self.follower.set_pinned(true);
|
||||
self.open(path);
|
||||
}
|
||||
|
||||
fn open(&mut self, path: &Path) {
|
||||
if let Err(e) = self.follower.open(path, Start::LastLines(self.scrollback)) {
|
||||
self.error = Some(format!("{}: {e}", path.display()));
|
||||
return;
|
||||
}
|
||||
self.poll();
|
||||
}
|
||||
|
||||
/// `path` relative to the watched directory, for display.
|
||||
pub fn relative(&self, path: &Path) -> String {
|
||||
path.strip_prefix(self.follower.dir())
|
||||
.unwrap_or(path)
|
||||
.display()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// The followed file, or what is being waited for.
|
||||
pub fn title(&self) -> String {
|
||||
match self.follower.current_path() {
|
||||
Some(p) => self.relative(p),
|
||||
None => format!("waiting for files in {}", self.follower.dir().display()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(&self) -> Option<&str> {
|
||||
self.error.as_deref()
|
||||
}
|
||||
|
||||
/// Such as "opened 2m05s ago".
|
||||
pub fn status(&self) -> Option<String> {
|
||||
self.since
|
||||
.map(|since| format!("{} {} ago", self.note, elapsed(since.elapsed())))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn elapsed(d: Duration) -> String {
|
||||
let s = d.as_secs();
|
||||
match s {
|
||||
0..60 => format!("{s}s"),
|
||||
60..3600 => format!("{}m{:02}s", s / 60, s % 60),
|
||||
_ => format!("{}h{:02}m", s / 3600, s % 3600 / 60),
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes one line of log output safe to draw: drops ANSI escape sequences and other control
|
||||
/// characters, keeps only what follows the last carriage return (as a terminal would show a
|
||||
/// progress line), and expands tabs.
|
||||
pub fn clean_line(raw: &str) -> String {
|
||||
let raw = raw.strip_suffix('\r').unwrap_or(raw);
|
||||
let raw = raw.rsplit('\r').next().unwrap_or(raw);
|
||||
let mut out = String::with_capacity(raw.len());
|
||||
let mut chars = raw.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'\x1b' => match chars.next() {
|
||||
// CSI: parameters and intermediates, then a final byte in @..~.
|
||||
Some('[') => {
|
||||
for c in chars.by_ref() {
|
||||
if ('@'..='~').contains(&c) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// OSC: runs to BEL or ST (ESC \).
|
||||
Some(']') => {
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\x07' || (c == '\x1b' && chars.next_if_eq(&'\\').is_some()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
'\t' => {
|
||||
let col = out.chars().count();
|
||||
out.extend(std::iter::repeat_n(' ', 8 - col % 8));
|
||||
}
|
||||
c if c.is_control() => {}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::watch::Options;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn view(dir: PathBuf, scrollback: usize) -> View {
|
||||
let opts = Options {
|
||||
dir,
|
||||
recursive: false,
|
||||
glob: None,
|
||||
};
|
||||
View::new(Follower::new(opts, Start::Beginning), scrollback)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_lines_keeps_partial_and_caps_scrollback() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(tmp.path().join("a.log"), "one\ntwo\nthree\nfou").unwrap();
|
||||
let mut v = view(tmp.path().to_path_buf(), 2);
|
||||
v.poll();
|
||||
assert_eq!(v.generation(), 1);
|
||||
assert_eq!(v.appended(), 3);
|
||||
let shown: Vec<_> = (0..v.total()).map(|i| v.line(i).into_owned()).collect();
|
||||
assert_eq!(shown, ["two", "three", "fou"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_line_strips_escapes_and_handles_carriage_returns() {
|
||||
assert_eq!(clean_line("\x1b[1;32mok\x1b[0m done"), "ok done");
|
||||
assert_eq!(clean_line("\x1b]0;title\x07text"), "text");
|
||||
assert_eq!(clean_line("\x1b]8;;http://x\x1b\\link"), "link");
|
||||
assert_eq!(clean_line("10%\r50%\r100%"), "100%");
|
||||
assert_eq!(clean_line("dos line\r"), "dos line");
|
||||
assert_eq!(clean_line("a\tb"), "a b");
|
||||
assert_eq!(clean_line("bell\x07"), "bell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elapsed_formats() {
|
||||
assert_eq!(elapsed(Duration::from_secs(5)), "5s");
|
||||
assert_eq!(elapsed(Duration::from_secs(125)), "2m05s");
|
||||
assert_eq!(elapsed(Duration::from_secs(3720)), "1h02m");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
//! Finds the newest file in a directory and follows it, switching whenever a new file appears.
|
||||
//!
|
||||
//! The directory is polled rather than watched with inotify: it is one directory, the cost is a
|
||||
//! `readdir` per interval, and polling behaves the same on every filesystem.
|
||||
//!
|
||||
//! A file counts as new when its path has not been seen before, when the path now names a
|
||||
//! different inode, or when it has shrunk since the last scan. The last case catches a harness
|
||||
//! that reruns a task and truncates the old log with `>` rather than creating a fresh file.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::{self, File, Metadata};
|
||||
use std::io::{self, Read, Seek, SeekFrom};
|
||||
use std::mem;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// The most bytes handed out by one poll, so a huge backlog cannot stall a front end.
|
||||
const MAX_READ: u64 = 4 << 20;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Options {
|
||||
pub dir: PathBuf,
|
||||
pub recursive: bool,
|
||||
/// Matched against the file name only, with `*` and `?` wildcards.
|
||||
pub glob: Option<String>,
|
||||
}
|
||||
|
||||
/// Where to begin reading a file that is being opened.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Start {
|
||||
Beginning,
|
||||
LastLines(usize),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Event {
|
||||
/// Now following this file; any data that follows belongs to it.
|
||||
Switched(PathBuf),
|
||||
/// The followed file was truncated or replaced; its data starts over.
|
||||
Restarted(PathBuf),
|
||||
Data(Vec<u8>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Entry {
|
||||
pub path: PathBuf,
|
||||
/// When the file's current contents began: its creation time, or when it was seen to restart.
|
||||
pub started: SystemTime,
|
||||
id: (u64, u64),
|
||||
size: u64,
|
||||
}
|
||||
|
||||
struct Current {
|
||||
path: PathBuf,
|
||||
id: (u64, u64),
|
||||
file: File,
|
||||
offset: u64,
|
||||
}
|
||||
|
||||
pub struct Follower {
|
||||
opts: Options,
|
||||
initial: Start,
|
||||
/// What the previous scan saw; `None` until the first scan.
|
||||
known: Option<HashMap<PathBuf, Entry>>,
|
||||
/// Oldest first.
|
||||
entries: Vec<Entry>,
|
||||
current: Option<Current>,
|
||||
pinned: bool,
|
||||
pending: Vec<Event>,
|
||||
}
|
||||
|
||||
impl Follower {
|
||||
/// `initial` is where to begin in the file that is newest at startup. Files that appear later
|
||||
/// are always read from the beginning.
|
||||
pub fn new(opts: Options, initial: Start) -> Self {
|
||||
Follower {
|
||||
opts,
|
||||
initial,
|
||||
known: None,
|
||||
entries: Vec::new(),
|
||||
current: None,
|
||||
pinned: false,
|
||||
pending: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.opts.dir
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
|
||||
pub fn options(&self) -> &Options {
|
||||
&self.opts
|
||||
}
|
||||
|
||||
/// Files seen by the last scan, oldest first.
|
||||
pub fn entries(&self) -> &[Entry] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
pub fn current_path(&self) -> Option<&Path> {
|
||||
self.current.as_ref().map(|c| c.path.as_path())
|
||||
}
|
||||
|
||||
pub fn pinned(&self) -> bool {
|
||||
self.pinned
|
||||
}
|
||||
|
||||
/// While pinned, new files do not take over from the current one.
|
||||
pub fn set_pinned(&mut self, pinned: bool) {
|
||||
self.pinned = pinned;
|
||||
}
|
||||
|
||||
/// Follows `path` from now on. The `Switched` event arrives with the next poll.
|
||||
pub fn open(&mut self, path: &Path, start: Start) -> io::Result<()> {
|
||||
let mut file = File::open(path)?;
|
||||
let meta = file.metadata()?;
|
||||
let offset = match start {
|
||||
Start::Beginning => 0,
|
||||
Start::LastLines(n) => tail_offset(&mut file, meta.len(), n)?,
|
||||
};
|
||||
self.current = Some(Current {
|
||||
path: path.to_path_buf(),
|
||||
id: file_id(&meta),
|
||||
file,
|
||||
offset,
|
||||
});
|
||||
self.pending.push(Event::Switched(path.to_path_buf()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rescans the directory, switches files if a new one appeared, and returns what happened
|
||||
/// along with any data written since the last poll.
|
||||
pub fn poll(&mut self) -> io::Result<Vec<Event>> {
|
||||
let mut entries = scan(&self.opts)?;
|
||||
let mut fresh = HashSet::new();
|
||||
if let Some(known) = &self.known {
|
||||
let now = SystemTime::now();
|
||||
for e in &mut entries {
|
||||
match known.get(&e.path) {
|
||||
Some(k) if k.id == e.id && e.size >= k.size => {
|
||||
e.started = k.started;
|
||||
continue;
|
||||
}
|
||||
// Truncated in place keeps its creation time, so date the restart ourselves.
|
||||
Some(k) if k.id == e.id => e.started = now,
|
||||
// New, or replaced by another file: its creation time is right.
|
||||
_ => {}
|
||||
}
|
||||
fresh.insert(e.path.clone());
|
||||
}
|
||||
entries.sort_by(|a, b| (a.started, &a.path).cmp(&(b.started, &b.path)));
|
||||
}
|
||||
let target = match &self.known {
|
||||
None => entries.last().map(|e| (e.path.clone(), self.initial)),
|
||||
Some(_) => entries
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|e| fresh.contains(&e.path))
|
||||
.map(|e| (e.path.clone(), Start::Beginning)),
|
||||
};
|
||||
self.known = Some(
|
||||
entries
|
||||
.iter()
|
||||
.map(|e| (e.path.clone(), e.clone()))
|
||||
.collect(),
|
||||
);
|
||||
self.entries = entries;
|
||||
|
||||
if let Some((path, start)) = target {
|
||||
// A restart of the current file is handled when it is read, below.
|
||||
let is_current = self.current_path() == Some(path.as_path());
|
||||
if !is_current && (!self.pinned || self.current.is_none()) {
|
||||
match self.open(&path, start) {
|
||||
Ok(()) => {}
|
||||
// Deleted between the scan and the open; the next scan will sort it out.
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut events = mem::take(&mut self.pending);
|
||||
self.read_current(&mut events)?;
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
fn read_current(&mut self, events: &mut Vec<Event>) -> io::Result<()> {
|
||||
let Some(cur) = self.current.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Ok(meta) = fs::metadata(&cur.path)
|
||||
&& file_id(&meta) != cur.id
|
||||
&& let Ok(file) = File::open(&cur.path)
|
||||
{
|
||||
cur.id = file_id(&file.metadata()?);
|
||||
cur.file = file;
|
||||
cur.offset = 0;
|
||||
events.push(Event::Restarted(cur.path.clone()));
|
||||
}
|
||||
let len = cur.file.metadata()?.len();
|
||||
if len < cur.offset {
|
||||
cur.offset = 0;
|
||||
events.push(Event::Restarted(cur.path.clone()));
|
||||
}
|
||||
if len > cur.offset {
|
||||
cur.file.seek(SeekFrom::Start(cur.offset))?;
|
||||
let mut buf = Vec::new();
|
||||
(&cur.file)
|
||||
.take((len - cur.offset).min(MAX_READ))
|
||||
.read_to_end(&mut buf)?;
|
||||
cur.offset += buf.len() as u64;
|
||||
if !buf.is_empty() {
|
||||
events.push(Event::Data(buf));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn file_id(meta: &Metadata) -> (u64, u64) {
|
||||
(meta.dev(), meta.ino())
|
||||
}
|
||||
|
||||
/// Lists the matching regular files under `opts.dir`, oldest first. A missing directory is
|
||||
/// treated as empty so that the follower can be started before the harness creates it.
|
||||
fn scan(opts: &Options) -> io::Result<Vec<Entry>> {
|
||||
let mut out = Vec::new();
|
||||
match walk(&opts.dir, opts, &mut out) {
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
|
||||
other => other?,
|
||||
}
|
||||
out.sort_by(|a, b| (a.started, &a.path).cmp(&(b.started, &b.path)));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, opts: &Options, out: &mut Vec<Entry>) -> io::Result<()> {
|
||||
for dirent in fs::read_dir(dir)? {
|
||||
let Ok(dirent) = dirent else { continue };
|
||||
let name = dirent.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let path = dirent.path();
|
||||
let Ok(kind) = dirent.file_type() else {
|
||||
continue;
|
||||
};
|
||||
if kind.is_dir() {
|
||||
if opts.recursive {
|
||||
// Unreadable or vanished subdirectories are skipped, not fatal.
|
||||
let _ = walk(&path, opts, out);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(glob) = &opts.glob
|
||||
&& !wildcard(glob.as_bytes(), name.as_bytes())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Follows symlinks, so a `latest.log` link to a real file is still followed.
|
||||
let Ok(meta) = fs::metadata(&path) else {
|
||||
continue;
|
||||
};
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
let created = meta
|
||||
.created()
|
||||
.or_else(|_| meta.modified())
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
out.push(Entry {
|
||||
path,
|
||||
started: created,
|
||||
id: file_id(&meta),
|
||||
size: meta.len(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Matches `s` against `pat`, where `*` matches any run of bytes and `?` any single byte.
|
||||
pub fn wildcard(pat: &[u8], s: &[u8]) -> bool {
|
||||
let (mut p, mut i) = (0, 0);
|
||||
let mut star: Option<(usize, usize)> = None;
|
||||
while i < s.len() {
|
||||
if p < pat.len() && (pat[p] == b'?' || pat[p] == s[i]) {
|
||||
p += 1;
|
||||
i += 1;
|
||||
} else if p < pat.len() && pat[p] == b'*' {
|
||||
star = Some((p, i));
|
||||
p += 1;
|
||||
} else if let Some((sp, si)) = star {
|
||||
p = sp + 1;
|
||||
i = si + 1;
|
||||
star = Some((sp, si + 1));
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
pat[p..].iter().all(|&c| c == b'*')
|
||||
}
|
||||
|
||||
/// The offset at which the last `n` lines of a file of length `len` begin. A final newline does
|
||||
/// not count as starting an empty last line.
|
||||
fn tail_offset(file: &mut File, len: u64, n: usize) -> io::Result<u64> {
|
||||
if n == 0 {
|
||||
return Ok(len);
|
||||
}
|
||||
let mut buf = vec![0u8; 64 * 1024];
|
||||
let mut pos = len;
|
||||
let mut seen = 0;
|
||||
while pos > 0 {
|
||||
let chunk = pos.min(buf.len() as u64);
|
||||
pos -= chunk;
|
||||
file.seek(SeekFrom::Start(pos))?;
|
||||
file.read_exact(&mut buf[..chunk as usize])?;
|
||||
for i in (0..chunk as usize).rev() {
|
||||
let at = pos + i as u64;
|
||||
if buf[i] == b'\n' && at != len - 1 {
|
||||
seen += 1;
|
||||
if seen == n {
|
||||
return Ok(at + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
fn follower(dir: &Path, initial: Start) -> Follower {
|
||||
Follower::new(
|
||||
Options {
|
||||
dir: dir.to_path_buf(),
|
||||
recursive: false,
|
||||
glob: None,
|
||||
},
|
||||
initial,
|
||||
)
|
||||
}
|
||||
|
||||
/// Creation times need to differ for "newest" to mean anything.
|
||||
fn write_new(path: &Path, text: &str) {
|
||||
thread::sleep(Duration::from_millis(15));
|
||||
fs::write(path, text).unwrap();
|
||||
}
|
||||
|
||||
fn append(path: &Path, text: &str) {
|
||||
let mut f = OpenOptions::new().append(true).open(path).unwrap();
|
||||
f.write_all(text.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
fn data(events: &[Event]) -> String {
|
||||
events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Event::Data(d) => Some(String::from_utf8_lossy(d).into_owned()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starts_on_newest_file_with_last_lines() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_new(&tmp.path().join("00-a.log"), "old\n");
|
||||
write_new(&tmp.path().join("01-b.log"), "1\n2\n3\n4\n");
|
||||
let mut f = follower(tmp.path(), Start::LastLines(2));
|
||||
let ev = f.poll().unwrap();
|
||||
assert_eq!(ev[0], Event::Switched(tmp.path().join("01-b.log")));
|
||||
assert_eq!(data(&ev), "3\n4\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follows_appended_data() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let p = tmp.path().join("a.log");
|
||||
write_new(&p, "one\n");
|
||||
let mut f = follower(tmp.path(), Start::Beginning);
|
||||
f.poll().unwrap();
|
||||
assert!(f.poll().unwrap().is_empty());
|
||||
append(&p, "two\npart");
|
||||
assert_eq!(data(&f.poll().unwrap()), "two\npart");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switches_to_a_new_file_from_its_beginning() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_new(&tmp.path().join("00.log"), "a\n");
|
||||
let mut f = follower(tmp.path(), Start::LastLines(0));
|
||||
f.poll().unwrap();
|
||||
let p = tmp.path().join("01.log");
|
||||
write_new(&p, "first\nsecond\n");
|
||||
let ev = f.poll().unwrap();
|
||||
assert_eq!(ev[0], Event::Switched(p));
|
||||
assert_eq!(data(&ev), "first\nsecond\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waits_for_a_directory_that_does_not_exist_yet() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("runs/M2a");
|
||||
let mut f = follower(&dir, Start::LastLines(10));
|
||||
assert!(f.poll().unwrap().is_empty());
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
write_new(&dir.join("00.log"), "hello\n");
|
||||
let ev = f.poll().unwrap();
|
||||
assert_eq!(ev[0], Event::Switched(dir.join("00.log")));
|
||||
assert_eq!(data(&ev), "hello\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restarts_when_the_current_file_is_truncated() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let p = tmp.path().join("a.log");
|
||||
write_new(&p, "a long first run\n");
|
||||
let mut f = follower(tmp.path(), Start::Beginning);
|
||||
f.poll().unwrap();
|
||||
fs::write(&p, "rerun\n").unwrap();
|
||||
let ev = f.poll().unwrap();
|
||||
assert_eq!(ev[0], Event::Restarted(p));
|
||||
assert_eq!(data(&ev), "rerun\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restarts_when_the_current_file_is_replaced() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let p = tmp.path().join("a.log");
|
||||
write_new(&p, "one\n");
|
||||
let mut f = follower(tmp.path(), Start::Beginning);
|
||||
f.poll().unwrap();
|
||||
let q = tmp.path().join(".tmp");
|
||||
fs::write(&q, "a whole new file\n").unwrap();
|
||||
fs::rename(&q, &p).unwrap();
|
||||
let ev = f.poll().unwrap();
|
||||
assert_eq!(ev[0], Event::Restarted(p));
|
||||
assert_eq!(data(&ev), "a whole new file\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switches_to_an_older_file_that_is_rewritten() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old = tmp.path().join("00.log");
|
||||
write_new(&old, "a long first run of task zero\n");
|
||||
write_new(&tmp.path().join("01.log"), "b\n");
|
||||
let mut f = follower(tmp.path(), Start::Beginning);
|
||||
f.poll().unwrap();
|
||||
fs::write(&old, "rerun\n").unwrap();
|
||||
let ev = f.poll().unwrap();
|
||||
assert_eq!(ev[0], Event::Switched(old.clone()));
|
||||
assert_eq!(data(&ev), "rerun\n");
|
||||
// It now ranks as the newest file, and keeps that rank on later scans.
|
||||
assert_eq!(f.entries().last().unwrap().path, old);
|
||||
append(&old, "more\n");
|
||||
f.poll().unwrap();
|
||||
assert_eq!(f.entries().last().unwrap().path, old);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appends_to_other_files_do_not_switch() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old = tmp.path().join("00.log");
|
||||
write_new(&old, "a\n");
|
||||
write_new(&tmp.path().join("01.log"), "b\n");
|
||||
let mut f = follower(tmp.path(), Start::Beginning);
|
||||
f.poll().unwrap();
|
||||
append(&old, "more\n");
|
||||
assert!(f.poll().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_stays_put() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let a = tmp.path().join("00.log");
|
||||
write_new(&a, "a\n");
|
||||
let mut f = follower(tmp.path(), Start::Beginning);
|
||||
f.poll().unwrap();
|
||||
f.set_pinned(true);
|
||||
write_new(&tmp.path().join("01.log"), "b\n");
|
||||
append(&a, "more\n");
|
||||
assert_eq!(f.poll().unwrap(), vec![Event::Data(b"more\n".to_vec())]);
|
||||
assert_eq!(f.entries().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_switches_by_hand() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let a = tmp.path().join("00.log");
|
||||
write_new(&a, "x\ny\n");
|
||||
write_new(&tmp.path().join("01.log"), "b\n");
|
||||
let mut f = follower(tmp.path(), Start::Beginning);
|
||||
f.poll().unwrap();
|
||||
f.open(&a, Start::LastLines(1)).unwrap();
|
||||
let ev = f.poll().unwrap();
|
||||
assert_eq!(ev[0], Event::Switched(a));
|
||||
assert_eq!(data(&ev), "y\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_by_glob_skips_hidden_and_recurses_on_request() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_new(&tmp.path().join("a.log"), "log\n");
|
||||
write_new(&tmp.path().join(".hidden.log"), "hidden\n");
|
||||
write_new(&tmp.path().join("b.txt"), "txt\n");
|
||||
let mut f = Follower::new(
|
||||
Options {
|
||||
dir: tmp.path().to_path_buf(),
|
||||
recursive: false,
|
||||
glob: Some("*.log".into()),
|
||||
},
|
||||
Start::Beginning,
|
||||
);
|
||||
assert_eq!(data(&f.poll().unwrap()), "log\n");
|
||||
|
||||
let sub = tmp.path().join("M2b");
|
||||
fs::create_dir(&sub).unwrap();
|
||||
write_new(&sub.join("00.log"), "nested\n");
|
||||
assert!(f.poll().unwrap().is_empty());
|
||||
|
||||
let mut r = Follower::new(
|
||||
Options {
|
||||
dir: tmp.path().to_path_buf(),
|
||||
recursive: true,
|
||||
glob: Some("*.log".into()),
|
||||
},
|
||||
Start::Beginning,
|
||||
);
|
||||
assert_eq!(data(&r.poll().unwrap()), "nested\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcard_matching() {
|
||||
assert!(wildcard(b"*.log", b"04-task.log"));
|
||||
assert!(!wildcard(b"*.log", b"04-task.log.swp"));
|
||||
assert!(wildcard(b"??-*", b"04-task.log"));
|
||||
assert!(!wildcard(b"??-*", b"4-task.log"));
|
||||
assert!(wildcard(b"*", b""));
|
||||
assert!(wildcard(b"a*b*c", b"aXbYbZc"));
|
||||
assert!(!wildcard(b"a*b*c", b"aXbYbZ"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_offset_counts_lines_from_the_end() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let p = tmp.path().join("f");
|
||||
let check = |text: &str, n: usize, want: &str| {
|
||||
fs::write(&p, text).unwrap();
|
||||
let mut file = File::open(&p).unwrap();
|
||||
let off = tail_offset(&mut file, text.len() as u64, n).unwrap();
|
||||
assert_eq!(&text[off as usize..], want, "text {text:?} n {n}");
|
||||
};
|
||||
check("a\nb\nc\n", 2, "b\nc\n");
|
||||
check("a\nb\nc", 2, "b\nc");
|
||||
check("a\nb\nc\n", 10, "a\nb\nc\n");
|
||||
check("a\nb\nc\n", 0, "");
|
||||
check("", 3, "");
|
||||
check("\n\n", 1, "\n");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user