Files
ElyOffice/apps/ely-tree/src/tools.rs
T
ZacharyZhang-NY d59159cea2 Fixes for M-02 to M-14, G-01 to G-14, K-02 to K-04, X-04 to X-14, not yet reviewed
Committed before review so Windows and WSL builds can start.
Each fix was tested and mutation-checked here; the codex review
of each item is still to come, so tasks/BUGS.md stays unticked.

M-02 is mid-review: round 1 findings are fixed (apps/ely-mark/src/tally.rs).
K-01 stays deferred. The ElyMark window test mark-outside.sh failed
once at its crash-recovery step in the last run; not yet looked into.
2026-10-10 02:29:19 -04:00

282 lines
9.6 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())))
}
/// 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<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"));
}
}