Skip to main content

datui_lib/inspector/
external_open.rs

1//! A value handed to another program: the inspector's `o`.
2//!
3//! The value is written read-only to a file of its own, with an extension that
4//! says what it is. Text goes to the user's `$VISUAL`, `$EDITOR` or `$PAGER`
5//! (then `less`), run in the terminal while datui steps aside, and the file is
6//! removed when it returns; nothing is read back. Images and PDFs, and text when
7//! no program is named, go to the system's opener (`xdg-open`, `open`, or
8//! Windows' `start`, which asks which program to use when none is set), which
9//! returns at once, so their files stay until datui quits.
10
11use std::path::{Path, PathBuf};
12
13/// A file to open, written and waiting for the run loop.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ExternalOpen {
16    pub path: PathBuf,
17    /// An image or a document: for the system's viewer, not a text editor.
18    pub document: bool,
19}
20
21/// How a file is opened.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum Program {
24    /// Run in the terminal, which datui hands over until it returns.
25    Wait(Vec<String>),
26    /// Started, not waited on: it opens a window of its own.
27    Opener(Vec<String>),
28}
29
30/// The system's opener for a file.
31fn opener() -> Vec<String> {
32    if cfg!(windows) {
33        // `start`'s first quoted argument is the window title.
34        ["cmd", "/c", "start", ""].map(String::from).to_vec()
35    } else if cfg!(target_os = "macos") {
36        vec!["open".to_string()]
37    } else {
38        vec!["xdg-open".to_string()]
39    }
40}
41
42/// The program for a file, from the environment `env` looks up: a document goes
43/// to the opener; text to `$VISUAL`, `$EDITOR`, `$PAGER`, then `less` outside
44/// Windows, then the opener. A program set to blank counts as unset. Its words
45/// are split at spaces, so `code --wait` runs `code` with `--wait`.
46pub fn program_for(document: bool, env: impl Fn(&str) -> Option<String>) -> Program {
47    if document {
48        return Program::Opener(opener());
49    }
50    for name in ["VISUAL", "EDITOR", "PAGER"] {
51        if let Some(value) = env(name).filter(|v| !v.trim().is_empty()) {
52            return Program::Wait(value.split_whitespace().map(String::from).collect());
53        }
54    }
55    if cfg!(windows) {
56        Program::Opener(opener())
57    } else {
58        Program::Wait(vec!["less".to_string()])
59    }
60}
61
62/// A file name for a value: the field's name with anything a path cannot hold
63/// replaced, the row, and the extension.
64pub fn file_name(field: &str, row: usize, extension: &str) -> String {
65    let safe: String = field
66        .chars()
67        .map(|c| {
68            if c.is_alphanumeric() || matches!(c, '-' | '_' | '.') {
69                c
70            } else {
71                '_'
72            }
73        })
74        .take(60)
75        .collect();
76    let safe = safe.trim_matches('.');
77    let safe = if safe.is_empty() { "value" } else { safe };
78    format!("{safe}-row{row}.{extension}")
79}
80
81/// Write `bytes` to `name` in `dir`, read-only. A name already taken, by an
82/// earlier open of the same value, takes a number.
83pub fn write_value(dir: &Path, name: &str, bytes: &[u8]) -> std::io::Result<PathBuf> {
84    let mut path = dir.join(name);
85    let mut n = 1;
86    while path.exists() {
87        n += 1;
88        path = dir.join(format!("{n}-{name}"));
89    }
90    std::fs::write(&path, bytes)?;
91    // Read-only where that does not stop the file being removed afterward.
92    if cfg!(unix) {
93        let mut perms = std::fs::metadata(&path)?.permissions();
94        perms.set_readonly(true);
95        std::fs::set_permissions(&path, perms)?;
96    }
97    Ok(path)
98}
99
100/// Run `program` on `path`: wait for one that takes the terminal, start one that
101/// does not. The caller has handed the terminal over for a wait.
102pub fn run(program: &Program, path: &Path) -> std::io::Result<()> {
103    let (argv, wait) = match program {
104        Program::Wait(argv) => (argv, true),
105        Program::Opener(argv) => (argv, false),
106    };
107    let Some((first, rest)) = argv.split_first() else {
108        return Err(std::io::Error::other("no program to open the value with"));
109    };
110    if !wait {
111        let mut argv: Vec<&std::ffi::OsStr> = argv.iter().map(|a| a.as_ref()).collect();
112        argv.push(path.as_os_str());
113        return start(&argv);
114    }
115    let status = std::process::Command::new(first)
116        .args(rest)
117        .arg(path)
118        .status()?;
119    if !status.success() {
120        return Err(std::io::Error::other(format!(
121            "{first} exited with {status}"
122        )));
123    }
124    Ok(())
125}
126
127/// Start `argv` as a program, no shell between, and return without waiting: it
128/// opens a window of its own. A thread reaps it when it exits.
129pub fn start<S: AsRef<std::ffi::OsStr>>(argv: &[S]) -> std::io::Result<()> {
130    let Some((first, rest)) = argv.split_first() else {
131        return Err(std::io::Error::other("no program to open with"));
132    };
133    let mut child = std::process::Command::new(first)
134        .args(rest)
135        .stdin(std::process::Stdio::null())
136        .stdout(std::process::Stdio::null())
137        .stderr(std::process::Stdio::null())
138        .spawn()?;
139    std::thread::spawn(move || child.wait());
140    Ok(())
141}
142
143#[cfg(test)]
144mod tests;