//! 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, /// Why the file was unreadable; never overwritten. pub broken: Option, } 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 { 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()))) } /// Only macOS opens a terminal; elsewhere it is hidden. pub const TERMINAL: bool = cfg!(target_os = "macos"); /// The reveal entry, named for this platform's file browser. pub const REVEAL: &str = if cfg!(target_os = "macos") { "tree.tool.reveal" } else { "tree.tool.reveal_folder" }; #[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 { self.active().map(|opened| opened.repo.root.clone()) } pub fn open_terminal(&mut self, cx: &mut Context) { 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) { 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, cx: &mut Context) { 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) { 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) { 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) -> Vec { 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")); } }