crates/cli/ents-web/src/editor.rs
editor.rshistorycomment on this file
| 1 | //! Which editor the serving user works in, and deep links into it. |
| 2 | //! |
| 3 | //! The web surface is an escalation from the editor, never a destination |
| 4 | //! of its own (`docs/web-workbench-plan.adoc`), so every code location a |
| 5 | //! page renders carries an "open in editor" affordance pointing back at |
| 6 | //! the desk the reader came from (`crate::pages`'s `editor_open`). The |
| 7 | //! editor is resolved from `ENTS_EDITOR`, then `EDITOR` -- the same |
| 8 | //! override-then-general order git applies to `GIT_EDITOR`/`EDITOR` -- |
| 9 | //! once per process ([`detected`]); an absent or unrecognized value |
| 10 | //! renders no affordance at all rather than a dead link. |
| 11 | //! |
| 12 | //! Deep links use each editor's own URL scheme (`zed://file/...`, |
| 13 | //! `vscode://file/...`). Neovim has no scheme of its own, so its links |
| 14 | //! use the community `nvim://file/...` shape -- they work only where the |
| 15 | //! reader has registered a handler for it, which is stated here rather |
| 16 | //! than hidden: the affordance's `title` names the editor the user |
| 17 | //! configured (`crate::pages`'s `editor_open` renders the shared teal |
| 18 | //! `↗` pill for every editor alike). |
| 19 | |
| 20 | use std::path::Path; |
| 21 | use std::sync::LazyLock; |
| 22 | |
| 23 | /// The editors this crate can deep-link into, resolved by [`detected`]. |
| 24 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 25 | pub(crate) enum Editor { |
| 26 | /// `zed://file/<path>:<line>` (Zed's own scheme). |
| 27 | Zed, |
| 28 | /// `vscode://file/<path>:<line>` (VS Code's own scheme; Codium |
| 29 | /// installs it too). |
| 30 | VsCode, |
| 31 | /// `nvim://file/<path>:<line>` -- no official scheme exists, so this |
| 32 | /// is the community handler shape (see this module's own doc). |
| 33 | Neovim, |
| 34 | } |
| 35 | |
| 36 | impl Editor { |
| 37 | /// The editor's display name, the affordance's `title` text. |
| 38 | pub(crate) fn label(self) -> &'static str { |
| 39 | match self { |
| 40 | Self::Zed => "Zed", |
| 41 | Self::VsCode => "VS Code", |
| 42 | Self::Neovim => "Neovim", |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | /// The URL-scheme prefix up to and including `file` -- the deep link |
| 47 | /// is `<scheme>/<absolute path>[:<line>]`. |
| 48 | fn scheme(self) -> &'static str { |
| 49 | match self { |
| 50 | Self::Zed => "zed://file", |
| 51 | Self::VsCode => "vscode://file", |
| 52 | Self::Neovim => "nvim://file", |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | /// The deep link opening `abs` (an absolute path) in this editor, |
| 57 | /// at `line` when given. |
| 58 | pub(crate) fn deep_link(self, abs: &Path, line: Option<u64>) -> String { |
| 59 | let scheme = self.scheme(); |
| 60 | let path = abs.display(); |
| 61 | match line { |
| 62 | Some(line) => format!("{scheme}{path}:{line}"), |
| 63 | None => format!("{scheme}{path}"), |
| 64 | } |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | /// Parse one editor-variable value: the command's first token's basename, |
| 69 | /// matched against the launchers each recognized editor ships. `None` for |
| 70 | /// anything else -- an unknown editor gets no affordance, never a dead |
| 71 | /// link. |
| 72 | fn parse(value: &str) -> Option<Editor> { |
| 73 | let command = value.split_whitespace().next()?; |
| 74 | let name = Path::new(command) |
| 75 | .file_name()? |
| 76 | .to_string_lossy() |
| 77 | .to_lowercase(); |
| 78 | match name.as_str() { |
| 79 | "zed" | "zeditor" => Some(Editor::Zed), |
| 80 | "code" | "code-insiders" | "codium" | "vscodium" => Some(Editor::VsCode), |
| 81 | "nvim" | "neovim" | "neovide" | "vim" | "gvim" => Some(Editor::Neovim), |
| 82 | _ => None, |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | /// The serving user's editor: the first of `ENTS_EDITOR`, `EDITOR` that |
| 87 | /// names one this crate recognizes ([`parse`]), read once per process -- |
| 88 | /// `git ents serve` runs in the user's own environment, so the variables |
| 89 | /// are the same ones their shell hands every other tool. |
| 90 | pub(crate) fn detected() -> Option<Editor> { |
| 91 | static DETECTED: LazyLock<Option<Editor>> = LazyLock::new(|| { |
| 92 | ["ENTS_EDITOR", "EDITOR"] |
| 93 | .iter() |
| 94 | .filter_map(|name| std::env::var(name).ok()) |
| 95 | .find_map(|value| parse(&value)) |
| 96 | }); |
| 97 | *DETECTED |
| 98 | } |
| 99 | |
| 100 | #[cfg(test)] |
| 101 | mod tests { |
| 102 | use rstest::rstest; |
| 103 | |
| 104 | use super::*; |
| 105 | |
| 106 | #[rstest] |
| 107 | #[case::bare_zed("zed", Some(Editor::Zed))] |
| 108 | #[case::zed_with_flags("zed --wait", Some(Editor::Zed))] |
| 109 | #[case::absolute_code("/usr/local/bin/code -g", Some(Editor::VsCode))] |
| 110 | #[case::codium("codium", Some(Editor::VsCode))] |
| 111 | #[case::nvim("nvim", Some(Editor::Neovim))] |
| 112 | #[case::vim_maps_to_the_neovim_icon("vim", Some(Editor::Neovim))] |
| 113 | #[case::neovide("neovide", Some(Editor::Neovim))] |
| 114 | #[case::unknown("ed", None)] |
| 115 | #[case::empty("", None)] |
| 116 | fn parse_matches_the_command_basename(#[case] value: &str, #[case] expected: Option<Editor>) { |
| 117 | assert_eq!(parse(value), expected); |
| 118 | } |
| 119 | |
| 120 | #[rstest] |
| 121 | fn deep_link_carries_scheme_path_and_line() { |
| 122 | let abs = Path::new("/repo/src/main.rs"); |
| 123 | assert_eq!( |
| 124 | Editor::Zed.deep_link(abs, Some(21)), |
| 125 | "zed://file/repo/src/main.rs:21" |
| 126 | ); |
| 127 | assert_eq!( |
| 128 | Editor::VsCode.deep_link(abs, None), |
| 129 | "vscode://file/repo/src/main.rs" |
| 130 | ); |
| 131 | assert_eq!( |
| 132 | Editor::Neovim.deep_link(abs, Some(3)), |
| 133 | "nvim://file/repo/src/main.rs:3" |
| 134 | ); |
| 135 | } |
| 136 | } |