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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 7 additions & 37 deletions crates/engine/src/cmd/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,6 @@ fn log(s: &mut Session, p: &Value) -> CmdResult {
Ok(json!({"entries": shown, "count": count, "total": total, "on": s.log.on()}))
}

/// A CSV cell: quoted, and kept from being read as a formula where it starts as one does (`=`,
/// `+`, `-`, `@`, or a tab or carriage return before them), as spreadsheets would.
fn csv_cell(t: &str) -> String {
let lead = if t.starts_with(['=', '+', '-', '@', '\t', '\r']) { "'" } else { "" };
format!("\"{lead}{}\"", t.replace('"', "\"\""))
}

/// `project.exportLog`: the log written out, as CSV (one row an entry, the parameters as a JSON
/// column) or JSON Lines (one entry a line).
fn export_log(s: &mut Session, p: &Value) -> CmdResult {
Expand Down Expand Up @@ -126,39 +119,16 @@ fn export_log(s: &mut Session, p: &Value) -> CmdResult {
// Written entry by entry, so a long log isn't held as text first; a failure part way leaves
// an existing file as it was.
let n = s.log.len();
rustgis_data::io::write_atomic_with(&path, |w| {
use std::io::Write;
if format == "csv" {
w.write_all(b"time_utc,at,command,ok,millis,error,params\n")?;
}
s.log.entries().try_for_each(|e| write_entry(w, e, format))
})
.map_err(|e| format!("{}: {e}", path.display()))?;
s.notify(format!("Exported the session log ({n} entries) to {}", path.display()), false);
rustgis_data::io::write_atomic_with(&path, |w| s.log.write(w, format == "csv")).map_err(|e| format!("{}: {e}", path.display()))?;
// Entries dropped past the log's limits aren't in the file: said.
let dropped = match s.log.dropped() {
0 => String::new(),
d => format!("; the {d} oldest were dropped past the log's limits before it"),
};
s.notify(format!("Exported the session log ({n} entries) to {}{dropped}", path.display()), false);
Ok(json!({"path": path.display().to_string(), "entries": n, "format": format}))
}

/// Entry `e` as a CSV row or a JSON line, written to `w`.
fn write_entry(w: &mut dyn std::io::Write, e: &crate::project_log::LogEntry, format: &str) -> std::io::Result<()> {
if format == "csv" {
let (date, time) = rustgis_map::graphics::civil(e.at / 1000);
writeln!(
w,
"{date} {time}:{:02},{},{},{},{},{},{}",
(e.at / 1000) % 60,
e.at,
csv_cell(&e.command),
e.ok,
e.millis,
csv_cell(e.error.as_deref().unwrap_or("")),
csv_cell(&e.params.to_string())
)
} else {
serde_json::to_writer(&mut *w, e).map_err(std::io::Error::other)?;
w.write_all(b"\n")
}
}

/// `project.clearLog`: empties the log, which then holds this command's own entry, so a cleared
/// log says it was cleared.
fn clear_log(s: &mut Session, _: &Value) -> CmdResult {
Expand Down
69 changes: 69 additions & 0 deletions crates/engine/src/project_log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,14 @@ pub struct LogEntry {
pub millis: u64,
}

impl LogEntry {
/// When it started, as `2026-10-30 14:05:09` (UTC).
pub fn time_utc(&self) -> String {
let (date, time) = rustgis_map::graphics::civil(self.at / 1000);
format!("{date} {time}:{:02}", (self.at / 1000) % 60)
}
}

/// The session's log (#797): one entry for each command run in the session, from any front end
/// and in whatever project was open, oldest first. It sits outside the project and its undo
/// history: undoing a command doesn't take its entry back, and saving, sharing or packaging a
Expand All @@ -355,6 +363,12 @@ pub struct SessionLog {
/// How many of `entries` are plugins' commands', kept in step with them so a push doesn't
/// count them.
plugin_entries: usize,
/// Counts each change to `entries` (an entry added, the log cleared), so a view of it knows
/// when to read it again ([`SessionLog::changes`]).
changes: u64,
/// How many entries were dropped past [`LOG_LIMIT`] or [`LOG_PLUGIN_LIMIT`] since the log
/// was last cleared ([`SessionLog::dropped`]), so a view or a saved file can say so.
dropped: u64,
}

impl SessionLog {
Expand All @@ -381,15 +395,18 @@ impl SessionLog {
{
self.entries.remove(oldest);
self.plugin_entries = self.plugin_entries.saturating_sub(1);
self.dropped = self.dropped.saturating_add(1);
}
if plugin(&e) {
self.plugin_entries += 1;
}
self.changes = self.changes.wrapping_add(1);
Comment thread
elpidioassinaturas marked this conversation as resolved.
Resolved
self.entries.push_back(e);
while self.entries.len() > LOG_LIMIT {
Comment thread
elpidioassinaturas marked this conversation as resolved.
Resolved
if self.entries.pop_front().is_some_and(|x| plugin(&x)) {
self.plugin_entries = self.plugin_entries.saturating_sub(1);
}
self.dropped = self.dropped.saturating_add(1);
}
}

Expand All @@ -401,6 +418,8 @@ impl SessionLog {
e.at = last.at;
e.millis = last.millis.saturating_add(e.millis);
*last = e;
// The newest entry changed: a view of the log reads it again.
self.changes = self.changes.wrapping_add(1);
return;
}
self.push(e);
Expand All @@ -423,13 +442,63 @@ impl SessionLog {
self.entries.len()
}

/// How many times the entries have changed (one added, the log cleared): the same number
/// means the same entries.
pub fn changes(&self) -> u64 {
self.changes
}

/// How many entries were dropped past the log's limits since it was last cleared.
pub fn dropped(&self) -> u64 {
self.dropped
}

pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}

/// Writes the log to `w`, oldest first: as CSV (`csv`: a header, then one row an entry, a
/// readable UTC time first and the parameters as a JSON column) or as JSON Lines (one entry a
/// line). Entry by entry, so a long log isn't held as text first.
pub fn write(&self, w: &mut dyn std::io::Write, csv: bool) -> std::io::Result<()> {
if csv {
w.write_all(b"time_utc,at,command,ok,millis,error,params\n")?;
}
self.entries.iter().try_for_each(|e| write_entry(w, e, csv))
}

/// Empties the log; how many entries it had.
pub fn clear(&mut self) -> usize {
self.plugin_entries = 0;
self.dropped = 0;
self.changes = self.changes.wrapping_add(1);
std::mem::take(&mut self.entries).len()
}
}

/// A CSV cell: quoted, and kept from being read as a formula where it starts as one does (`=`,
/// `+`, `-`, `@`, or a tab or carriage return before them), as spreadsheets would.
fn csv_cell(t: &str) -> String {
let lead = if t.starts_with(['=', '+', '-', '@', '\t', '\r']) { "'" } else { "" };
format!("\"{lead}{}\"", t.replace('"', "\"\""))
}

/// Entry `e` as a CSV row (`csv`) or a JSON line, written to `w`.
fn write_entry(w: &mut dyn std::io::Write, e: &LogEntry, csv: bool) -> std::io::Result<()> {
if csv {
writeln!(
w,
"{},{},{},{},{},{},{}",
e.time_utc(),
e.at,
csv_cell(&e.command),
e.ok,
e.millis,
csv_cell(e.error.as_deref().unwrap_or("")),
csv_cell(&e.params.to_string())
)
} else {
serde_json::to_writer(&mut *w, e).map_err(std::io::Error::other)?;
w.write_all(b"\n")
}
}
25 changes: 25 additions & 0 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21698,6 +21698,13 @@ fn the_session_log_records_each_command_run_in_the_session() {
let _ = s.run("project.importQgis", &json!({"text": qgs}));
let doc = last(&s);
assert_eq!(doc.params["text"], json!(format!("(a document of {} characters)", qgs.chars().count())), "{doc:?}");
// A step joined to the newest entry (a held key's nudges) is a change too, so a view of the
// log reads it again.
let nudge = |millis| LogEntry { at: 1, command: "layout.nudge".into(), params: json!({"dx": 1}), ok: true, error: None, millis };
s.log.push(nudge(2));
let before = s.log.changes();
s.log.join_last(nudge(3));
assert!(s.log.changes() != before && last(&s).millis == 5, "{:?}", last(&s));
// So is one after a byte-order mark (a QGIS project read from a file that has one).
let marked = format!("\u{feff}{qgs}");
let _ = s.run("project.importQgis", &json!({"text": marked}));
Expand Down Expand Up @@ -21808,6 +21815,24 @@ fn the_session_log_records_each_command_run_in_the_session() {
}
assert_eq!(s.log.len(), LOG_LIMIT);
assert_eq!(s.log.entries().next().unwrap().at, 5);
// What was dropped is counted, and an export says so; a clear starts the count again.
assert!(s.log.dropped() >= 5, "{}", s.log.dropped());
Comment thread
elpidioassinaturas marked this conversation as resolved.
Resolved
let out = std::env::temp_dir().join(format!("rustgis-log-dropped-{}.csv", std::process::id()));
let mark = s.notice_mark();
s.run("project.exportLog", &json!({"path": out.display().to_string()})).unwrap();
assert!(
s.notices_since(mark).iter().any(|n| n.text.contains("oldest were dropped past the log's limits")),
"{:?}",
s.notices_since(mark)
);
let _ = std::fs::remove_file(&out);
s.log.clear();
assert_eq!(s.log.dropped(), 0);
// A clean export says nothing of it.
let mark = s.notice_mark();
s.run("project.exportLog", &json!({"path": out.display().to_string()})).unwrap();
assert!(!s.notices_since(mark).iter().any(|n| n.text.contains("were dropped past")), "{:?}", s.notices_since(mark));
let _ = std::fs::remove_file(&out);
// Undo or redo with nothing to take back changes nothing, and isn't logged.
s.clear_history();
let n = s.log.len();
Expand Down
4 changes: 4 additions & 0 deletions crates/ui-egui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ mod scriptcolor;
mod scriptcomplete;
mod searchbox;
mod serviceui;
mod sessionlog;
mod shortcutfile;
mod shortcutui;
mod showme;
Expand Down Expand Up @@ -690,6 +691,8 @@ pub struct UiState {
pub embeddings: embeddingsui::State,
/// Data Cubes: the filters, the period, and what the last action said.
pub cubes: cubesui::State,
/// The History pane: the tools run or the session log, and the log's filters (#797).
pub session_log: sessionlog::State,
/// NASA Earthaccess: the dataset, the granules found and the request under way.
pub earthaccess: earthaccessui::State,
/// Add From Service › Sign in to ArcGIS.
Expand Down Expand Up @@ -837,6 +840,7 @@ impl Default for UiState {
opendata: Default::default(),
embeddings: Default::default(),
cubes: Default::default(),
session_log: Default::default(),
earthaccess: Default::default(),
arcgis: Default::default(),
nldi: Default::default(),
Expand Down
3 changes: 3 additions & 0 deletions crates/ui-egui/src/panes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5459,6 +5459,9 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma
// History and Details

fn history(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) {
if crate::sessionlog::tabs(app, ui) {
return crate::sessionlog::pane(app, ui, t);
}
let hist = app.session.project.gp_history.clone();
if hist.is_empty() {
ui.label(egui::RichText::new(tl!("Tools you run appear here.")).color(t.text_dim));
Expand Down
Loading
Loading