ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git), ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
272 lines
9.4 KiB
Rust
272 lines
9.4 KiB
Rust
//! Terminal, file manager, a chosen editor, ElyMark (TREE-14).
|
|
|
|
use std::{
|
|
io,
|
|
path::{Path, PathBuf},
|
|
process::Command,
|
|
};
|
|
|
|
use ely_gpui_component::{
|
|
buttons::{ButtonVariant, IconButton},
|
|
primitives::IconName,
|
|
theme::ControlSize,
|
|
};
|
|
use ely_platform::AppId;
|
|
use gpui::{AppContext as _, Context, PathPromptOptions};
|
|
|
|
use crate::tree::{Tree, text};
|
|
|
|
/// Read once; written whenever the editor changes.
|
|
pub struct Tools {
|
|
file: PathBuf,
|
|
pub editor: Option<PathBuf>,
|
|
/// Why the file was unreadable; never overwritten.
|
|
pub broken: Option<String>,
|
|
}
|
|
|
|
impl Tools {
|
|
/// The kept editor; a broken file is said, never overwritten.
|
|
pub fn load(file: PathBuf) -> Tools {
|
|
match Tools::read(file.clone()) {
|
|
Ok(tools) => tools,
|
|
Err(error) => Tools {
|
|
file,
|
|
editor: None,
|
|
broken: Some(error.to_string()),
|
|
},
|
|
}
|
|
}
|
|
|
|
/// The editor kept in `file`; none until chosen.
|
|
fn read(file: PathBuf) -> io::Result<Tools> {
|
|
let editor = match std::fs::read(&file) {
|
|
Ok(bytes) => {
|
|
let said = |what: String| io::Error::other(format!("{}: {what}", file.display()));
|
|
let read: serde_json::Value =
|
|
serde_json::from_slice(&bytes).map_err(|error| said(error.to_string()))?;
|
|
match read.get("editor") {
|
|
_ if !read.is_object() => return Err(said("not an object".into())),
|
|
None => None,
|
|
Some(serde_json::Value::String(editor)) => Some(PathBuf::from(editor)),
|
|
Some(other) => return Err(said(format!("editor is {other}"))),
|
|
}
|
|
}
|
|
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
|
|
Err(error) => return Err(error),
|
|
};
|
|
Ok(Tools {
|
|
file,
|
|
editor,
|
|
broken: None,
|
|
})
|
|
}
|
|
|
|
fn keep(&mut self, editor: PathBuf) -> io::Result<()> {
|
|
if let Some(why) = &self.broken {
|
|
return Err(io::Error::other(why.clone()));
|
|
}
|
|
let bytes = serde_json::to_vec(&serde_json::json!({ "editor": editor }))
|
|
.map_err(io::Error::other)?;
|
|
if let Some(dir) = self.file.parent() {
|
|
std::fs::create_dir_all(dir)?;
|
|
}
|
|
ely_files::save_atomic(&self.file, &bytes, 0)?;
|
|
self.editor = Some(editor);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// A bundle opens through `open`, waited on.
|
|
fn launch(editor: &Path, file: &Path) -> io::Result<()> {
|
|
if cfg!(target_os = "macos") && editor.extension().is_some_and(|ext| ext == "app") {
|
|
let out = Command::new("open")
|
|
.arg("-a")
|
|
.arg(editor)
|
|
.arg(file)
|
|
.output()?;
|
|
if !out.status.success() {
|
|
let said = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
|
return Err(io::Error::other(format!("{}: {said}", editor.display())));
|
|
}
|
|
return Ok(());
|
|
}
|
|
Command::new(editor)
|
|
.arg(file)
|
|
.spawn()
|
|
.map(drop)
|
|
.map_err(|error| io::Error::other(format!("{}: {error}", editor.display())))
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn terminal_at(dir: &Path) -> io::Result<()> {
|
|
Command::new("open")
|
|
.args(["-a", "Terminal"])
|
|
.arg(dir)
|
|
.spawn()
|
|
.map(drop)
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
fn terminal_at(_dir: &Path) -> io::Result<()> {
|
|
Err(io::Error::new(
|
|
io::ErrorKind::Unsupported,
|
|
"opening a terminal is not built on this platform",
|
|
))
|
|
}
|
|
|
|
impl Tree {
|
|
fn root(&self) -> Option<PathBuf> {
|
|
self.active().map(|opened| opened.repo.root.clone())
|
|
}
|
|
|
|
pub fn open_terminal(&mut self, cx: &mut Context<Self>) {
|
|
let Some(root) = self.root() else { return };
|
|
log::info!("tree: terminal at {}", root.display());
|
|
if let Err(error) = terminal_at(&root) {
|
|
self.failed("tree.error.tool", &error.to_string(), cx);
|
|
}
|
|
}
|
|
|
|
pub fn reveal(&mut self, cx: &mut Context<Self>) {
|
|
let Some(root) = self.root() else { return };
|
|
cx.reveal_path(&root);
|
|
}
|
|
|
|
/// Asks which editor; `then` opens a file in it.
|
|
pub fn pick_editor(&mut self, then: Option<String>, cx: &mut Context<Self>) {
|
|
let picked = cx.prompt_for_paths(PathPromptOptions {
|
|
files: true,
|
|
directories: false,
|
|
multiple: false,
|
|
prompt: None,
|
|
});
|
|
cx.spawn(async move |this, cx| {
|
|
let picked = picked
|
|
.await
|
|
.map_err(|error| error.to_string())
|
|
.and_then(|picked| picked.map_err(|error| error.to_string()));
|
|
this.update(cx, |tree, cx| match picked {
|
|
Ok(Some(mut paths)) if !paths.is_empty() => {
|
|
let editor = paths.remove(0);
|
|
if let Err(error) = tree.tools.keep(editor) {
|
|
return tree.failed("tree.error.tool", &error.to_string(), cx);
|
|
}
|
|
if let Some(path) = then {
|
|
tree.open_in_editor(&path, cx);
|
|
}
|
|
}
|
|
Ok(_) => log::info!("tree: the editor panel was cancelled"),
|
|
Err(error) => tree.failed("tree.error.panel", &error, cx),
|
|
})
|
|
.ok();
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
/// A repository file in the chosen editor; asks first.
|
|
pub fn open_in_editor(&mut self, path: &str, cx: &mut Context<Self>) {
|
|
let Some(root) = self.root() else { return };
|
|
let Some(editor) = self.tools.editor.clone() else {
|
|
return self.pick_editor(Some(path.to_string()), cx);
|
|
};
|
|
let file = root.join(path);
|
|
log::info!("tree: {} opens a file", editor.display());
|
|
// The launcher's own failure arrives after it starts.
|
|
let task = cx.background_spawn(async move { launch(&editor, &file) });
|
|
cx.spawn(async move |this, cx| {
|
|
if let Err(error) = task.await {
|
|
this.update(cx, |tree, cx| {
|
|
tree.failed("tree.error.tool", &error.to_string(), cx)
|
|
})
|
|
.ok();
|
|
}
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
pub fn open_in_mark(&mut self, path: &str, cx: &mut Context<Self>) {
|
|
let Some(root) = self.root() else { return };
|
|
if let Err(error) = ely_platform::apps::open(AppId::Mark, &[root.join(path)]) {
|
|
self.failed("tree.error.tool", &error.to_string(), cx);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Tree {
|
|
/// The chosen editor, and ElyMark for Markdown.
|
|
pub fn tool_buttons(&self, path: &str, cx: &mut Context<Self>) -> Vec<IconButton> {
|
|
let (edit, mark) = (cx.weak_entity(), cx.weak_entity());
|
|
let (at, md) = (path.to_string(), path.to_string());
|
|
let mut buttons = vec![
|
|
IconButton::new("tree-open-editor", IconName::Pencil)
|
|
.variant(ButtonVariant::Ghost)
|
|
.size(ControlSize::Sm)
|
|
.tooltip(text(cx, "tree.tool.in_editor", &[]))
|
|
.on_click(move |_, _, cx| {
|
|
edit.update(cx, |tree, cx| tree.open_in_editor(&at, cx))
|
|
.ok();
|
|
}),
|
|
];
|
|
if markdown(path) {
|
|
buttons.push(
|
|
IconButton::new("tree-open-mark", IconName::FileText)
|
|
.variant(ButtonVariant::Ghost)
|
|
.size(ControlSize::Sm)
|
|
.tooltip(text(cx, "tree.tool.in_mark", &[]))
|
|
.on_click(move |_, _, cx| {
|
|
mark.update(cx, |tree, cx| tree.open_in_mark(&md, cx)).ok();
|
|
}),
|
|
);
|
|
}
|
|
buttons
|
|
}
|
|
}
|
|
|
|
/// Markdown opens in ElyMark too.
|
|
pub fn markdown(path: &str) -> bool {
|
|
Path::new(path)
|
|
.extension()
|
|
.and_then(|ext| ext.to_str())
|
|
.is_some_and(|ext| matches!(ext.to_ascii_lowercase().as_str(), "md" | "markdown"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#[test]
|
|
fn the_editor_is_kept_and_read_back() {
|
|
let dir = tempfile::tempdir().expect("a temp dir");
|
|
let file = dir.path().join("data/tools.json");
|
|
let mut tools = super::Tools::read(file.clone()).expect("none yet");
|
|
assert_eq!(tools.editor, None);
|
|
tools.keep("/Applications/Zed.app".into()).expect("keeps");
|
|
let again = super::Tools::read(file.clone()).expect("reads");
|
|
assert_eq!(
|
|
again.editor.as_deref(),
|
|
Some(std::path::Path::new("/Applications/Zed.app"))
|
|
);
|
|
std::fs::write(&file, "not json").expect("writes");
|
|
let mut broken = super::Tools::load(file.clone());
|
|
assert!(broken.broken.is_some(), "a broken file is said");
|
|
assert!(broken.keep("/x".into()).is_err());
|
|
assert_eq!(
|
|
std::fs::read(&file).expect("reads"),
|
|
b"not json",
|
|
"left alone"
|
|
);
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
#[test]
|
|
fn a_gone_editor_app_says_so() {
|
|
let gone = std::path::Path::new("/nonexistent/DeletedEditor.app");
|
|
let said = super::launch(gone, std::path::Path::new("/tmp")).expect_err("gone");
|
|
assert!(said.to_string().contains("DeletedEditor.app"), "{said}");
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_files_are_known_by_their_ending() {
|
|
assert!(super::markdown("docs/READ.ME.md") && super::markdown("a.Markdown"));
|
|
assert!(!super::markdown("notes.txt") && !super::markdown("md"));
|
|
}
|
|
}
|