git-ents.gitmain
⌘K
foforge
commit 213dd8a
roots: deep-link code locations into the configured editor

The web surface is an escalation from the editor, not a destination: every code location — the blob header, comment locators, the dashboard’s working-tree rows — now carries an open-in-editor icon deep-linking to the same file and line via the editor’s own URL scheme. The editor comes from ENTS_EDITOR, then EDITOR, resolved once per process; zed and vscode schemes are official, nvim’s is the community handler shape, and an unrecognized editor renders no affordance rather than a dead link. Selecting a line in the blob view retargets the header’s link at it.

Assisted-by: Claude:claude-fable-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/cli/ents-web/src/lib.rs @@ -108,6 +108,7 @@ pub(crate) mod asciidoc; pub(crate) mod assets; +pub(crate) mod editor; pub mod error; pub mod identity; pub(crate) mod markdown;
crates/cli/ents-web/src/assets/ents.css @@ -445,6 +445,12 @@ * (`crate::pages::{comments,issues,commits}`). */ .comment-state, .verdict { display: inline-block; padding: .05rem .5rem; border: 1px solid var(--color-border); border-radius: var(--radius-sm); font-size: .78rem; font-weight: 600; text-transform: lowercase; color: var(--color-text-muted); } .verdict { color: var(--color-text); } +/* Open-in-editor affordances (`crate::pages::editor_open`): a small, + * muted icon deep-linking a code location into the serving user's own + * editor ($ENTS_EDITOR, then $EDITOR). */ +.editor-open { display: inline-flex; align-items: center; vertical-align: -2px; color: var(--color-text-muted); } +.editor-open:hover { color: var(--color-accent); } +.editor-open .icon { width: 14px; height: 14px; } /* Check-status chips (`crate::pages::commits::checks_section`): one color * per value of the closed pass/fail/error result taxonomy. */ .status { display: inline-block; font-family: var(--font-mono); font-size: .72rem; font-weight: 700; border-radius: var(--radius-pill); padding: .05rem .6rem; text-transform: lowercase; }
crates/cli/ents-web/src/assets/ents.js @@ -66,7 +66,18 @@ var anchorLine = null; + // The blob header's open-in-editor deep link follows the selection: + // `data-editor-base` carries the line-less URL, the selected line is + // appended in the editors' shared `:{line}` suffix shape. + var editorLink = blob.querySelector("a.editor-open[data-editor-base]"); + function retargetEditor(line) { + if (editorLink) { + editorLink.href = editorLink.getAttribute("data-editor-base") + ":" + line; + } + } + function selectRange(start, end) { + retargetEditor(Math.min(start, end)); var lo = Math.min(start, end); var hi = Math.max(start, end); lineRows.forEach(function (tr) {
crates/cli/ents-web/src/assets/sprite.svg @@ -9,4 +9,7 @@ <symbol id="i-person" viewBox="0 0 16 16"><circle cx="8" cy="5" r="2.6" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M2.5 14a5.5 5.5 0 0 1 11 0" fill="none" stroke="currentColor" stroke-width="1.5"/></symbol> <symbol id="i-search" viewBox="0 0 16 16"><circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M10.5 10.5 14 14" stroke="currentColor" stroke-width="1.5"/></symbol> <symbol id="i-menu" viewBox="0 0 16 16"><path d="M2 4h12M2 8h12M2 12h12" stroke="currentColor" stroke-width="1.5"/></symbol> + <symbol id="i-ed-zed" viewBox="0 0 16 16"><path d="M2.5 3.5h11l-11 9h11" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/><path d="M6.5 8h3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol> + <symbol id="i-ed-code" viewBox="0 0 16 16"><path d="M11.6.9 5.2 6.7 2.4 4.5l-1.3.7L4.3 8l-3.2 2.8 1.3.7 2.8-2.2 6.4 5.8 3.5-1.7v-11zM11.4 4.6v6.8L7.7 8z" fill="currentColor"/></symbol> + <symbol id="i-ed-nvim" viewBox="0 0 16 16"><path d="M2.8 4.6 5.8 1.4v13.2l-3-3.2zM13.2 11.4l-3 3.2V1.4l3 3.2z" fill="currentColor"/><path d="M5.8 3.8l4.4 8.4" stroke="currentColor" stroke-width="1.6"/></symbol> </svg>
crates/cli/ents-web/src/pages/comments.rs @@ -197,6 +197,7 @@ a href={ "/files/" (anchor.path) (line_fragment(anchor.lines)) } { (anchor.path) (line_label(anchor.lines)) } + (super::editor_open(&state, &anchor.path, anchor.lines.map(|range| range.start))) } } @if let Some((_, projection)) = &projected { @@ -299,6 +300,7 @@ a href={ "/files/" (anchor.path) (line_fragment(anchor.lines)) } { (anchor.path) (line_label(anchor.lines)) } + (super::editor_open(state, &anchor.path, anchor.lines.map(|range| range.start))) } } div.doc-body { (body) } @@ -604,6 +606,11 @@ /// falling back to escaped plain text on a render failure -- a file /// view degrades, it never 500s over one unparsable comment. pub(crate) body: Markup, + /// The pre-rendered open-in-editor affordance for this comment's + /// landing spot ([`crate::pages::editor_open`]; empty when no editor + /// is recognized) -- built where `state` is at hand so + /// [`comment_card`] stays a pure markup function. + pub(crate) editor: Markup, } /// Every comment whose anchor projects onto `path` at `HEAD` in `repo` -- @@ -661,6 +668,7 @@ }; let body = crate::asciidoc::to_html(&comment.body) .unwrap_or_else(|_| html! { p { (comment.body) } }); + let editor = super::editor_open(state, &landed, lines.map(|range| range.start)); out.push(FileComment { author, seconds, @@ -668,6 +676,7 @@ lines, outdated, body, + editor, }); } out @@ -714,6 +723,7 @@ }; let body = crate::asciidoc::to_html(&comment.body) .unwrap_or_else(|_| html! { p { (comment.body) } }); + let editor = super::editor_open(state, &anchor.path, anchor.lines.map(|range| range.start)); out.push(FileComment { author, seconds, @@ -721,6 +731,7 @@ lines: anchor.lines, outdated: false, body, + editor, }); } out @@ -777,6 +788,7 @@ } } } + (comment.editor) @if comment.outdated { span.outdated { "outdated" } }
crates/cli/ents-web/src/pages/dashboard.rs @@ -73,7 +73,7 @@ "Dashboard", html! { div.desk { - (working_tree_card(changes.as_deref())) + (working_tree_card(&state, changes.as_deref())) (attention) (issues_card(&open_issues)) } @@ -85,11 +85,15 @@ } /// The "Working tree" card: every changed file [`worktree_changes`] found, -/// each linking into the Files browser with its change kind right-aligned. +/// each linking into the Files browser with its open-in-editor affordance +/// ([`super::editor_open`]) beside it and its change kind right-aligned. /// `None` (the status walk itself failed) renders a note row; an empty /// list renders a "clean" row -- either way the card itself always /// renders, so the desk's shape is stable. -fn working_tree_card(changes: Option<&[(String, &'static str)]>) -> Markup { +fn working_tree_card<O: Find>( + state: &AppState<O>, + changes: Option<&[(String, &'static str)]>, +) -> Markup { html! { section.card { div.card-header { "Working tree" } @@ -100,6 +104,7 @@ @for (path, kind) in changes { div.card-row { a href={ "/files/" (path) } { (path) } + (super::editor_open(state, path, None)) span.entry-size { (kind) } } }
crates/cli/ents-web/src/pages/files.rs @@ -188,7 +188,10 @@ .head_id() .map_err(|source| Error::Repo(source.to_string()))? .to_string(); - let (body, below) = blob_view(path, name, &head_oid, session, &blob.data, &comments)?; + let editor = super::editor_open(state, path, None); + let (body, below) = blob_view( + path, name, &head_oid, session, &blob.data, &comments, editor, + )?; let parent = path.rsplit_once('/').map_or("", |(dir, _)| dir); Ok(super::layout_split( &super::RepoHeader::from_state(state), @@ -499,6 +502,7 @@ session: &Session, bytes: &[u8], comments: &[super::comments::FileComment], + editor: Markup, ) -> Result<(Markup, Markup)> { let size = u64::try_from(bytes.len()).unwrap_or(u64::MAX); let comment_count = comments.len(); @@ -510,6 +514,7 @@ line_count: None, language: None, comments: comment_count, + editor: editor.clone(), }) }; if is_binary(bytes) { @@ -558,6 +563,7 @@ line_count: Some(line_count), language, comments: comment_count, + editor, }); let composer = composer_template(path, head_oid, session); let below: Vec<(usize, &super::comments::FileComment)> = comments @@ -602,6 +608,10 @@ /// blob -- the "N comments" jump link renders only when this is above /// zero (mirrors [`crumbs`]'s own former stance, now moved here). comments: usize, + /// The pre-rendered open-in-editor affordance ([`super::editor_open`]; + /// empty when no editor is recognized), leading the actions so the + /// jump back to the desk sits first. + editor: Markup, } /// The header bar above every blob view -- the file's name and metadata on @@ -633,6 +643,7 @@ } } span.blob-actions { + (meta.editor) a href="/commits" { "history" } @if meta.comments > 0 { a href="#comment-0" { @@ -973,6 +984,7 @@ lines, outdated: false, body: html! { p { "worth a look" } }, + editor: html! {}, } } @@ -1064,6 +1076,7 @@ &session(), b"# Title\n", &[], + maud::html! {}, ) .expect("markdown renders"); assert!(body.into_string().contains("<h1>Title</h1>")); @@ -1078,6 +1091,7 @@ &session(), b"= Title\n\nBody.\n", &[], + maud::html! {}, ) .expect("asciidoc renders"); assert!(body.into_string().contains("<h1>Title</h1>")); @@ -1092,6 +1106,7 @@ &session(), b"1 < 2 and true", &[], + maud::html! {}, ) .expect("plain text renders"); let rendered = body.into_string(); @@ -1109,6 +1124,7 @@ &session(), b"fn main() { let x = 1; }", &[], + maud::html! {}, ) .expect("rust renders"); let rendered = body.into_string(); @@ -1126,6 +1142,7 @@ &session(), b"\0\x01\x02binary", &[], + maud::html! {}, ) .expect("binary placeholder renders"); assert!(body.into_string().contains("Binary file")); @@ -1141,6 +1158,7 @@ &session(), b"# Title\n", &comments, + maud::html! {}, ) .expect("markdown renders"); // A doc view has no source line to interleave at: every comment, @@ -1159,6 +1177,7 @@ &session(), b"fn main() {}\n", &[], + maud::html! {}, ) .expect("rust renders"); let rendered = body.into_string(); @@ -1178,6 +1197,7 @@ &session(), b"fn main() {}\n", &[], + maud::html! {}, ) .expect("rust renders"); let rendered = body.into_string(); @@ -1198,6 +1218,7 @@ &session(), b"# Title\n", &[], + maud::html! {}, ) .expect("markdown renders"); assert!(
crates/cli/ents-web/src/pages/mod.rs @@ -390,6 +390,32 @@ ) } +/// The "open in editor" affordance rendered beside a code location: a +/// deep link into the serving user's own editor +/// ([`crate::editor::detected`]: `$ENTS_EDITOR`, then `$EDITOR`), its +/// icon naming which one. Renders nothing at all when no recognized +/// editor is configured -- the affordance is the escalation back to the +/// desk the reader came from (`docs/web-workbench-plan.adoc`), never a +/// dead link. The line-less deep link rides along as `data-editor-base` +/// so `ents.js` can retarget the blob header's affordance at the +/// currently selected line without rebuilding the URL client-side. +pub(crate) fn editor_open<O>(state: &AppState<O>, path: &str, line: Option<u64>) -> Markup { + let Some(editor) = crate::editor::detected() else { + return html! {}; + }; + let root = std::fs::canonicalize(&state.path).unwrap_or_else(|_io| state.path.clone()); + let abs = root.join(path); + html! { + a.editor-open + href=(editor.deep_link(&abs, line)) + data-editor-base=(editor.deep_link(&abs, None)) + title={ "Open in " (editor.label()) } + { + (crate::assets::icon_use(editor.icon())) + } + } +} + /// The signing identity's display label for [`layout`]'s `.id-chip` /// (`roots.web-signing`) -- [`crate::identity::SigningIdentity::label`]. /// Every page reads this off `state` itself rather than `layout` reaching
crates/cli/ents-web/src/editor.rs @@ -1,0 +1,143 @@ +//! Which editor the serving user works in, and deep links into it. +//! +//! The web surface is an escalation from the editor, never a destination +//! of its own (`docs/web-workbench-plan.adoc`), so every code location a +//! page renders carries an "open in editor" affordance pointing back at +//! the desk the reader came from (`crate::pages`'s `editor_open`). The +//! editor is resolved from `ENTS_EDITOR`, then `EDITOR` -- the same +//! override-then-general order git applies to `GIT_EDITOR`/`EDITOR` -- +//! once per process ([`detected`]); an absent or unrecognized value +//! renders no affordance at all rather than a dead link. +//! +//! Deep links use each editor's own URL scheme (`zed://file/...`, +//! `vscode://file/...`). Neovim has no scheme of its own, so its links +//! use the community `nvim://file/...` shape -- they work only where the +//! reader has registered a handler for it, which is stated here rather +//! than hidden: the icon still names the editor the user configured. + +use std::path::Path; +use std::sync::LazyLock; + +/// The editors this crate can deep-link into, resolved by [`detected`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Editor { + /// `zed://file/<path>:<line>` (Zed's own scheme). + Zed, + /// `vscode://file/<path>:<line>` (VS Code's own scheme; Codium + /// installs it too). + VsCode, + /// `nvim://file/<path>:<line>` -- no official scheme exists, so this + /// is the community handler shape (see this module's own doc). + Neovim, +} + +impl Editor { + /// The editor's display name, the affordance's `title` text. + pub(crate) fn label(self) -> &'static str { + match self { + Self::Zed => "Zed", + Self::VsCode => "VS Code", + Self::Neovim => "Neovim", + } + } + + /// The editor's `crate::assets::sprite` symbol id. + pub(crate) fn icon(self) -> &'static str { + match self { + Self::Zed => "i-ed-zed", + Self::VsCode => "i-ed-code", + Self::Neovim => "i-ed-nvim", + } + } + + /// The URL-scheme prefix up to and including `file` -- the deep link + /// is `<scheme>/<absolute path>[:<line>]`. + fn scheme(self) -> &'static str { + match self { + Self::Zed => "zed://file", + Self::VsCode => "vscode://file", + Self::Neovim => "nvim://file", + } + } + + /// The deep link opening `abs` (an absolute path) in this editor, + /// at `line` when given. + pub(crate) fn deep_link(self, abs: &Path, line: Option<u64>) -> String { + let scheme = self.scheme(); + let path = abs.display(); + match line { + Some(line) => format!("{scheme}{path}:{line}"), + None => format!("{scheme}{path}"), + } + } +} + +/// Parse one editor-variable value: the command's first token's basename, +/// matched against the launchers each recognized editor ships. `None` for +/// anything else -- an unknown editor gets no affordance, never a dead +/// link. +fn parse(value: &str) -> Option<Editor> { + let command = value.split_whitespace().next()?; + let name = Path::new(command) + .file_name()? + .to_string_lossy() + .to_lowercase(); + match name.as_str() { + "zed" | "zeditor" => Some(Editor::Zed), + "code" | "code-insiders" | "codium" | "vscodium" => Some(Editor::VsCode), + "nvim" | "neovim" | "neovide" | "vim" | "gvim" => Some(Editor::Neovim), + _ => None, + } +} + +/// The serving user's editor: the first of `ENTS_EDITOR`, `EDITOR` that +/// names one this crate recognizes ([`parse`]), read once per process -- +/// `git ents serve` runs in the user's own environment, so the variables +/// are the same ones their shell hands every other tool. +pub(crate) fn detected() -> Option<Editor> { + static DETECTED: LazyLock<Option<Editor>> = LazyLock::new(|| { + ["ENTS_EDITOR", "EDITOR"] + .iter() + .filter_map(|name| std::env::var(name).ok()) + .find_map(|value| parse(&value)) + }); + *DETECTED +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + + #[rstest] + #[case::bare_zed("zed", Some(Editor::Zed))] + #[case::zed_with_flags("zed --wait", Some(Editor::Zed))] + #[case::absolute_code("/usr/local/bin/code -g", Some(Editor::VsCode))] + #[case::codium("codium", Some(Editor::VsCode))] + #[case::nvim("nvim", Some(Editor::Neovim))] + #[case::vim_maps_to_the_neovim_icon("vim", Some(Editor::Neovim))] + #[case::neovide("neovide", Some(Editor::Neovim))] + #[case::unknown("ed", None)] + #[case::empty("", None)] + fn parse_matches_the_command_basename(#[case] value: &str, #[case] expected: Option<Editor>) { + assert_eq!(parse(value), expected); + } + + #[rstest] + fn deep_link_carries_scheme_path_and_line() { + let abs = Path::new("/repo/src/main.rs"); + assert_eq!( + Editor::Zed.deep_link(abs, Some(21)), + "zed://file/repo/src/main.rs:21" + ); + assert_eq!( + Editor::VsCode.deep_link(abs, None), + "vscode://file/repo/src/main.rs" + ); + assert_eq!( + Editor::Neovim.deep_link(abs, Some(3)), + "nvim://file/repo/src/main.rs:3" + ); + } +}