use std::io;
use std::path::Path;
use std::sync::{Mutex, PoisonError};
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, State};
use runandlog_core::Canceller;
use crate::session::Session;
const EVENT_DOCUMENT: &str = "runandlog://document";
const EVENT_STARTED: &str = "runandlog://started";
const EVENT_FINISHED: &str = "runandlog://finished";
#[derive(Debug, Clone, Serialize)]
struct CellView {
index: usize,
number: usize,
lang: String,
command: String,
out_file: Option<String>,
result: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
struct DocumentView {
path: String,
cells: Vec<CellView>,
}
#[derive(Debug, Clone, Serialize)]
struct RunReport {
index: usize,
status: String,
success: bool,
cancelled: bool,
}
#[derive(Debug, Clone, Serialize)]
struct BatchReport {
reports: Vec<RunReport>,
stopped: bool,
}
#[derive(Default)]
struct Operation {
busy: bool,
stop_requested: bool,
canceller: Option<Canceller>,
}
struct GuiState {
session: Mutex<Session>,
operation: Mutex<Operation>,
}
impl GuiState {
fn operation(&self) -> std::sync::MutexGuard<'_, Operation> {
self.operation
.lock()
.unwrap_or_else(PoisonError::into_inner)
}
fn arm(&self, canceller: Option<Canceller>) -> bool {
let mut operation = self.operation();
operation.canceller = canceller;
operation.stop_requested
}
fn stop(&self) -> bool {
let mut operation = self.operation();
if !operation.busy {
return false;
}
operation.stop_requested = true;
if let Some(canceller) = &operation.canceller {
canceller.cancel();
}
true
}
fn stop_requested(&self) -> bool {
self.operation().stop_requested
}
fn acquire(&self) -> Result<BusyGuard<'_>, String> {
let mut operation = self.operation();
if operation.busy {
return Err("A command is already running.".to_string());
}
*operation = Operation {
busy: true,
..Operation::default()
};
Ok(BusyGuard { state: self })
}
}
struct BusyGuard<'a> {
state: &'a GuiState,
}
impl Drop for BusyGuard<'_> {
fn drop(&mut self) {
let mut operation = self.state.operation();
operation.busy = false;
operation.canceller = None;
}
}
fn document_view(session: &Session) -> DocumentView {
let doc = session.doc();
let cells = doc
.cells
.iter()
.map(|cell| CellView {
index: cell.index,
number: cell.display_number(),
lang: cell.lang.clone(),
command: cell.command.clone(),
out_file: cell.out_file.clone(),
result: doc.result_text(cell).map(str::to_string),
})
.collect();
DocumentView {
path: session.path().display().to_string(),
cells,
}
}
#[tauri::command]
fn document(state: State<'_, GuiState>) -> Result<DocumentView, String> {
let session = state.session.lock().map_err(lock_error)?;
Ok(document_view(&session))
}
#[tauri::command]
fn reload(state: State<'_, GuiState>) -> Result<DocumentView, String> {
reload_session(&state)
}
fn reload_session(state: &GuiState) -> Result<DocumentView, String> {
let _busy = state
.acquire()
.map_err(|_| "A command is running, so the file cannot be reloaded yet.".to_string())?;
let mut session = state.session.lock().map_err(lock_error)?;
session.reload().map_err(|error| error.to_string())?;
Ok(document_view(&session))
}
#[tauri::command]
async fn run_cell(
app: AppHandle,
state: State<'_, GuiState>,
index: usize,
) -> Result<RunReport, String> {
let _busy = state.acquire()?;
execute(&app, &state, index).await
}
#[tauri::command]
async fn run_all(app: AppHandle, state: State<'_, GuiState>) -> Result<BatchReport, String> {
let _busy = state.acquire()?;
let count = {
let session = state.session.lock().map_err(lock_error)?;
session.len()
};
let mut reports = Vec::new();
let mut stopped = false;
for index in 0..count {
if state.stop_requested() {
stopped = true;
break;
}
match execute(&app, &state, index).await {
Ok(report) => {
stopped = report.cancelled;
reports.push(report);
if stopped {
break;
}
}
Err(error) => return Err(error),
}
}
Ok(BatchReport {
reports,
stopped: stopped || state.stop_requested(),
})
}
#[tauri::command]
fn cancel(state: State<'_, GuiState>) -> bool {
state.stop()
}
async fn execute(
app: &AppHandle,
state: &State<'_, GuiState>,
index: usize,
) -> Result<RunReport, String> {
let (command, options) = {
let session = state.session.lock().map_err(lock_error)?;
if index >= session.len() {
return Err(format!("There is no cell {}.", index + 1));
}
(session.command_of(index), session.exec_options())
};
let canceller = Canceller::new();
if state.arm(Some(canceller.clone())) {
canceller.cancel();
}
let _ = app.emit(EVENT_STARTED, index);
let outcome = tauri::async_runtime::spawn_blocking(move || {
runandlog_core::run_cancellable(&command, &options, &canceller)
})
.await
.map_err(|error| format!("The worker thread died unexpectedly: {error}"))
.inspect_err(|_| {
state.arm(None);
})?
.map_err(|error| format!("The run failed: {error}"))
.inspect_err(|_| {
state.arm(None);
})?;
state.arm(None);
let view = {
let mut session = state.session.lock().map_err(lock_error)?;
session
.apply_outcome(index, &outcome)
.map_err(|error| format!("Writing the result failed: {error}"))?;
document_view(&session)
};
let _ = app.emit(EVENT_DOCUMENT, &view);
let report = RunReport {
index,
status: outcome.status_text(),
success: outcome.is_success(),
cancelled: outcome.cancelled,
};
let _ = app.emit(EVENT_FINISHED, &report);
Ok(report)
}
fn lock_error<T>(_: std::sync::PoisonError<T>) -> String {
"The session is no longer usable because a background task panicked.".to_string()
}
pub fn run(session: Session) -> io::Result<()> {
if let Some(reason) = no_display_reason() {
return Err(io::Error::other(reason));
}
let title = window_title(session.path());
tauri::Builder::default()
.manage(GuiState {
session: Mutex::new(session),
operation: Mutex::new(Operation::default()),
})
.setup(move |app| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_title(&title);
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
document, reload, run_cell, run_all, cancel
])
.run(tauri::generate_context!())
.map_err(io::Error::other)
}
#[cfg(target_os = "linux")]
fn no_display_reason() -> Option<&'static str> {
display_reason(
std::env::var_os("DISPLAY").as_deref(),
std::env::var_os("WAYLAND_DISPLAY").as_deref(),
)
}
#[cfg(target_os = "linux")]
fn display_reason(
display: Option<&std::ffi::OsStr>,
wayland: Option<&std::ffi::OsStr>,
) -> Option<&'static str> {
let usable = |value: Option<&std::ffi::OsStr>| value.is_some_and(|value| !value.is_empty());
if usable(display) || usable(wayland) {
return None;
}
Some(
"no display is available (DISPLAY and WAYLAND_DISPLAY are both unset), so the GUI cannot open; drop --gui to use the TUI",
)
}
#[cfg(not(target_os = "linux"))]
fn no_display_reason() -> Option<&'static str> {
None
}
fn window_title(path: &Path) -> String {
let name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string());
format!("Run and Log - {name}")
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use runandlog_core::ExecOptions;
use super::*;
static NEXT_DIR: AtomicUsize = AtomicUsize::new(0);
struct TempDir(PathBuf);
impl TempDir {
fn new() -> TempDir {
let path = std::env::temp_dir().join(format!(
"runandlog-gui-test-{}-{}",
std::process::id(),
NEXT_DIR.fetch_add(1, Ordering::SeqCst)
));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).unwrap();
TempDir(path)
}
fn write(&self, name: &str, contents: &str) -> PathBuf {
let path = self.0.join(name);
std::fs::write(&path, contents).unwrap();
path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn session(path: &Path) -> Session {
let mut options = ExecOptions::new(path.parent().unwrap());
options.shell = PathBuf::from("/bin/sh");
Session::load(path, options, 50).unwrap()
}
fn state(dir: &TempDir) -> GuiState {
GuiState {
session: Mutex::new(session(&dir.write("doc.md", "# no cells\n"))),
operation: Mutex::new(Operation::default()),
}
}
#[test]
fn the_title_shows_the_file_name() {
assert_eq!(
window_title(Path::new("/tmp/notes/exam.md")),
"Run and Log - exam.md"
);
}
#[test]
fn the_title_falls_back_to_the_whole_path() {
assert_eq!(window_title(Path::new("/tmp/..")), "Run and Log - /tmp/..");
}
#[test]
fn a_second_run_is_refused_while_one_is_in_flight() {
let dir = TempDir::new();
let state = state(&dir);
let _first = state.acquire().unwrap();
assert!(state.acquire().is_err());
}
#[test]
fn the_busy_flag_is_released_when_the_guard_is_dropped() {
let dir = TempDir::new();
let state = state(&dir);
drop(state.acquire().unwrap());
assert!(state.acquire().is_ok());
}
#[test]
fn stopping_while_idle_says_there_was_nothing_to_stop() {
let dir = TempDir::new();
let state = state(&dir);
assert!(!state.stop());
assert!(!state.stop_requested());
}
#[test]
fn stopping_reaches_the_run_in_flight() {
let dir = TempDir::new();
let state = state(&dir);
let _busy = state.acquire().unwrap();
let canceller = Canceller::new();
state.arm(Some(canceller.clone()));
assert!(state.stop());
assert!(canceller.is_cancelled());
}
#[test]
fn a_stop_between_two_cells_still_stops_the_batch() {
let dir = TempDir::new();
let state = state(&dir);
let _busy = state.acquire().unwrap();
state.arm(None);
assert!(state.stop());
assert!(state.stop_requested());
}
#[test]
fn a_stop_that_beats_the_command_to_the_start_is_honoured() {
let dir = TempDir::new();
let state = state(&dir);
let _busy = state.acquire().unwrap();
state.stop();
let canceller = Canceller::new();
assert!(state.arm(Some(canceller.clone())));
}
#[test]
fn a_stop_does_not_carry_over_to_the_next_run() {
let dir = TempDir::new();
let state = state(&dir);
let busy = state.acquire().unwrap();
let stopped = Canceller::new();
state.arm(Some(stopped.clone()));
state.stop();
state.arm(None);
drop(busy);
let _busy = state.acquire().unwrap();
let next = Canceller::new();
assert!(!state.arm(Some(next.clone())));
assert!(!next.is_cancelled());
assert!(stopped.is_cancelled());
}
#[test]
fn reloading_is_refused_while_a_command_is_running() {
let dir = TempDir::new();
let state = state(&dir);
let _running = state.acquire().unwrap();
assert!(reload_session(&state).is_err());
}
#[test]
fn reloading_works_again_once_the_run_is_over() {
let dir = TempDir::new();
let state = state(&dir);
drop(state.acquire().unwrap());
assert!(reload_session(&state).is_ok());
}
#[test]
fn reloading_picks_up_an_external_edit() {
let dir = TempDir::new();
let state = state(&dir);
assert!(reload_session(&state).unwrap().cells.is_empty());
dir.write("doc.md", "```shell\ndate\n```\n");
let view = reload_session(&state).unwrap();
assert_eq!(view.cells.len(), 1);
assert_eq!(view.cells[0].command, "date\n");
}
#[test]
fn the_view_carries_what_the_window_draws() {
let dir = TempDir::new();
let path = dir.write(
"doc.md",
"# notes\n\n```shell\ndate\n```\n\n```shell out=log.txt\nls\n```\n",
);
let view = document_view(&session(&path));
assert_eq!(view.cells.len(), 2);
assert_eq!(view.cells[0].index, 0);
assert_eq!(view.cells[0].number, 1);
assert_eq!(view.cells[0].command, "date\n");
assert_eq!(view.cells[0].out_file, None);
assert_eq!(view.cells[0].result, None);
assert_eq!(view.cells[1].number, 2);
assert_eq!(view.cells[1].out_file.as_deref(), Some("log.txt"));
}
#[test]
fn the_view_carries_the_previous_result() {
let dir = TempDir::new();
let path = dir.write(
"doc.md",
"```shell\ndate\n```\n\n<!-- runandlog:begin -->\nRan result: earlier\n<!-- runandlog:end -->\n",
);
let view = document_view(&session(&path));
assert_eq!(view.cells[0].result.as_deref(), Some("Ran result: earlier"));
}
}
#[cfg(all(test, target_os = "linux"))]
mod display_tests {
use std::ffi::OsStr;
use super::*;
#[test]
fn a_missing_display_is_reported_instead_of_crashing_gtk() {
assert!(display_reason(None, None).is_some());
}
#[test]
fn either_display_variable_is_enough() {
assert!(display_reason(Some(OsStr::new(":0")), None).is_none());
assert!(display_reason(None, Some(OsStr::new("wayland-0"))).is_none());
}
#[test]
fn an_empty_value_does_not_count_as_a_display() {
assert!(display_reason(Some(OsStr::new("")), Some(OsStr::new(""))).is_some());
}
}