use std::collections::HashMap;
use std::io;
use std::path::Path;
use std::sync::{Mutex, PoisonError, mpsc};
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_OUTPUT: &str = "runandlog://output";
const EVENT_FINISHED: &str = "runandlog://finished";
const EVENT_PASSWORD: &str = "runandlog://password";
const MAX_OUTPUT_CHUNK_BYTES: usize = 64 * 1024;
const LICENSES: &str = "licenses";
#[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 OutputChunk {
index: usize,
text: String,
}
#[derive(Debug, Clone, Serialize)]
struct RunReport {
index: usize,
status: String,
success: bool,
cancelled: bool,
}
#[derive(Debug, Clone, Serialize)]
struct PasswordRequest {
id: u64,
prompt: String,
}
#[derive(Default)]
struct Passwords {
window_listens: bool,
accepting: bool,
next: u64,
pending: HashMap<u64, mpsc::Sender<Option<String>>>,
}
#[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>,
passwords: Mutex<Passwords>,
}
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 passwords(&self) -> std::sync::MutexGuard<'_, Passwords> {
self.passwords
.lock()
.unwrap_or_else(PoisonError::into_inner)
}
fn request_password(
&self,
gone: &dyn Fn() -> bool,
) -> Option<(u64, mpsc::Receiver<Option<String>>)> {
let mut passwords = self.passwords();
if !passwords.window_listens || !passwords.accepting || gone() {
return None;
}
let id = passwords.next;
passwords.next += 1;
let (reply, answer) = mpsc::channel();
passwords.pending.insert(id, reply);
Some((id, answer))
}
fn show_password_request(&self, id: u64, show: impl FnOnce() -> bool) -> bool {
let mut passwords = self.passwords();
if !passwords.pending.contains_key(&id) {
return false;
}
if show() {
return true;
}
passwords.pending.remove(&id);
false
}
fn answer_password(&self, id: u64, answer: Option<String>) -> bool {
match self.passwords().pending.remove(&id) {
Some(reply) => reply.send(answer).is_ok(),
None => false,
}
}
fn accept_passwords(&self) {
self.passwords().accepting = true;
}
fn abandon_passwords(&self) {
let mut passwords = self.passwords();
passwords.accepting = false;
passwords.pending.clear();
}
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()
}
#[tauri::command]
fn listen_for_passwords(state: State<'_, GuiState>) {
state.passwords().window_listens = true;
}
#[tauri::command]
fn answer_password(state: State<'_, GuiState>, id: u64, answer: Option<String>) -> bool {
state.answer_password(id, answer)
}
async fn execute(
app: &AppHandle,
state: &State<'_, GuiState>,
index: usize,
) -> Result<RunReport, String> {
let (command, run) = {
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.start_run())
};
let canceller = Canceller::new();
if state.arm(Some(canceller.clone())) {
canceller.cancel();
}
let _ = app.emit(EVENT_STARTED, index);
state.accept_passwords();
let reporter = app.clone();
let outcome = tauri::async_runtime::spawn_blocking(move || {
let outcome = runandlog_core::run_streaming(&command, &run.options, &canceller, |chunk| {
let _ = reporter.emit(
EVENT_OUTPUT,
OutputChunk {
index,
text: crate::live::tail(chunk, MAX_OUTPUT_CHUNK_BYTES).to_string(),
},
);
});
drop(run);
outcome
})
.await
.map_err(|error| format!("The worker thread died unexpectedly: {error}"))
.inspect_err(|_| {
state.arm(None);
state.abandon_passwords();
})?
.map_err(|error| format!("The run failed: {error}"))
.inspect_err(|_| {
state.arm(None);
state.abandon_passwords();
})?;
state.arm(None);
state.abandon_passwords();
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)
}
#[tauri::command]
fn third_party_notices() -> String {
crate::notices::license_text()
}
fn app_menu(app: &AppHandle) -> tauri::Result<tauri::menu::Menu<tauri::Wry>> {
let menu = tauri::menu::Menu::default(app)?;
let about_menu = if cfg!(target_os = "macos") {
menu.items()?.into_iter().next()
} else {
menu.get(tauri::menu::HELP_SUBMENU_ID)
};
if let Some(submenu) = about_menu.as_ref().and_then(|item| item.as_submenu()) {
let licenses = tauri::menu::MenuItem::with_id(
app,
LICENSES,
"Third-Party Licenses",
true,
None::<&str>,
)?;
submenu.insert(&licenses, 1)?;
}
Ok(menu)
}
fn show_licenses(app: &AppHandle) -> tauri::Result<()> {
if let Some(window) = app.get_webview_window(LICENSES) {
return window.set_focus();
}
tauri::WebviewWindowBuilder::new(
app,
LICENSES,
tauri::WebviewUrl::App("licenses.html".into()),
)
.title("Third-Party Licenses")
.inner_size(720.0, 640.0)
.build()?;
Ok(())
}
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());
let app = tauri::Builder::default()
.manage(GuiState {
session: Mutex::new(session),
operation: Mutex::new(Operation::default()),
passwords: Mutex::new(Passwords::default()),
})
.manage(AskpassSlot::default())
.menu(app_menu)
.on_menu_event(|app, event| {
if event.id() == LICENSES
&& let Err(error) = show_licenses(app)
{
eprintln!("runandlog: could not open the licenses window: {error}");
}
})
.setup(move |app| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_title(&title);
}
start_askpass(app.handle());
Ok(())
})
.invoke_handler(tauri::generate_handler![
document,
reload,
run_cell,
run_all,
cancel,
listen_for_passwords,
answer_password,
third_party_notices
])
.build(tauri::generate_context!())
.map_err(io::Error::other)?;
app.run(|app, event| {
if let tauri::RunEvent::Exit = event {
let slot = app.state::<AskpassSlot>();
drop(slot.0.lock().unwrap_or_else(PoisonError::into_inner).take());
}
});
Ok(())
}
#[derive(Default)]
struct AskpassSlot(
#[cfg(unix)] Mutex<Option<crate::askpass::Askpass>>,
#[cfg(not(unix))] Mutex<Option<()>>,
);
#[cfg(unix)]
fn start_askpass(app: &AppHandle) {
let handle = app.clone();
let prompter = Box::new(move |prompt: &str, gone: &dyn Fn() -> bool| {
let state = handle.state::<GuiState>();
let (id, answer) = state.request_password(gone)?;
let request = PasswordRequest {
id,
prompt: prompt.to_string(),
};
state.show_password_request(id, || handle.emit(EVENT_PASSWORD, request).is_ok());
crate::askpass::wait_for_answer(&answer, gone)
});
let Ok(askpass) = crate::askpass::Askpass::start(prompter) else {
return;
};
let state = app.state::<GuiState>();
state
.session
.lock()
.unwrap_or_else(PoisonError::into_inner)
.set_run_hook(askpass.hook());
*app.state::<AskpassSlot>()
.0
.lock()
.unwrap_or_else(PoisonError::into_inner) = Some(askpass);
}
#[cfg(not(unix))]
fn start_askpass(_app: &AppHandle) {}
#[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()),
passwords: Mutex::new(Passwords::default()),
}
}
#[test]
fn a_password_is_only_asked_for_while_a_command_runs() {
let dir = TempDir::new();
let state = state(&dir);
state.passwords().window_listens = true;
assert!(state.request_password(&|| false).is_none());
state.accept_passwords();
assert!(state.request_password(&|| true).is_none());
let (id, answer) = state.request_password(&|| false).unwrap();
assert!(state.show_password_request(id, || true));
assert!(state.answer_password(id, Some("s3cret".to_string())));
assert_eq!(answer.recv().unwrap(), Some("s3cret".to_string()));
assert!(!state.answer_password(id, None));
}
#[test]
fn a_prompt_left_up_when_the_command_ends_is_declined() {
let dir = TempDir::new();
let state = state(&dir);
state.passwords().window_listens = true;
state.accept_passwords();
let (id, answer) = state.request_password(&|| false).unwrap();
state.abandon_passwords();
assert!(!state.show_password_request(id, || unreachable!("shown after abandon")));
assert!(answer.recv().is_err());
assert!(!state.answer_password(id, Some("late".to_string())));
assert!(state.request_password(&|| false).is_none());
}
#[test]
fn a_request_the_window_cannot_be_shown_is_declined() {
let dir = TempDir::new();
let state = state(&dir);
state.passwords().window_listens = true;
state.accept_passwords();
let (id, answer) = state.request_password(&|| false).unwrap();
assert!(!state.show_password_request(id, || false));
assert!(answer.recv().is_err());
assert!(!state.answer_password(id, Some("late".to_string())));
}
#[test]
fn nothing_is_asked_through_a_window_that_is_not_listening() {
let dir = TempDir::new();
let state = state(&dir);
state.accept_passwords();
assert!(state.request_password(&|| false).is_none());
}
#[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());
}
}