roots: deep-link code locations into the configured editor
commit
213dd8aroots: 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
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
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"
+ );
+ }
+}