Follow org links in the GUI, resolving across the org path

Clicking an internal org link now navigates the outline to its target.
The GUI resolves the link via /<doc>/resolve and acts on the result:
external targets open in the OS handler; internal targets expand the
target's ancestors, scroll to it, and flash it.

Resolution spans the whole org path rather than a single document:

  - file:DOC::TARGET and file:DOC resolve TARGET within the named
    document (a file: link to a document not in the path stays external).
  - id:uuid links are globally unique and resolve in whichever document
    owns the ID.
  - #custom-id, *Headline, and bare fuzzy targets resolve in the current
    document first, then fall back across every other document.

The resolve result gains a `doc:` field naming the owning document. When
it differs from the current document, DocumentModel::followLink switches
the model to that document before revealing the section.

DocumentModel gains revealSection() and followLink(); OutlineView wires
link clicks to followLink and animates the revealed row via the new
sectionRevealed signal.
This commit is contained in:
Levi Neely 2026-10-02 09:26:03 +02:00
parent b21b675980
commit 27e63f7a6f
4 changed files with 342 additions and 23 deletions

View File

@ -1860,20 +1860,28 @@ impl OrkState {
} }
/// Resolve a link target to a structural (and optionally textual) address /// Resolve a link target to a structural (and optionally textual) address
/// within a document. Mirrors Emacs `org-link-search`'s precedence ladder, /// within the org path. Mirrors Emacs `org-link-search`'s precedence
/// but terminates each branch in a section id + offset rather than moving /// ladder, but terminates each branch in an owning document + section id +
/// point. /// offset rather than moving point.
///
/// `doc_name` is the document the link originates from; internal targets
/// resolve there first and then fall back across every other document in
/// the path, so a link can reach a heading in another file.
/// ///
/// Returns a small structured blob: /// Returns a small structured blob:
/// ```text /// ```text
/// kind: dedicated | fuzzy | external | notfound /// kind: dedicated | fuzzy | external | notfound
/// section: <section-id> # omitted for external/notfound /// doc: <doc-name> # owning document; omitted for external/notfound
/// section: <section-id> # omitted for external/notfound/bare file:DOC
/// offset: <n> # byte offset within the section, 0 for dedicated /// offset: <n> # byte offset within the section, 0 for dedicated
/// ``` /// ```
/// ///
/// Dispatch by target syntax: /// Dispatch by target syntax:
/// - `file:DOC::TARGET` -> resolve TARGET inside document DOC
/// - `file:DOC` -> navigate to document DOC
/// - `file:other` (non-doc) -> external (handed to the OS by callers)
/// - `#custom-id` -> dedicated (CUSTOM_ID match) /// - `#custom-id` -> dedicated (CUSTOM_ID match)
/// - `id:uuid` -> dedicated (ID match) /// - `id:uuid` -> dedicated (ID match, searched globally)
/// - `*Headline` -> dedicated (exact headline title) /// - `*Headline` -> dedicated (exact headline title)
/// - external URL schemes -> external (handed to the OS by callers) /// - external URL schemes -> external (handed to the OS by callers)
/// - anything else (fuzzy) -> fuzzy section match (Phase 2: + offset) /// - anything else (fuzzy) -> fuzzy section match (Phase 2: + offset)
@ -1883,11 +1891,62 @@ impl OrkState {
return Ok("kind: notfound\n".to_string()); return Ok("kind: notfound\n".to_string());
} }
// External URL schemes are not navigation within the model. // `file:DOC` / `file:DOC::TARGET` links name another document in the org
// path explicitly. If DOC is a known document, resolve TARGET within it;
// otherwise the link points outside the model and is external.
if let Some(rest) = strip_file_prefix(target) {
let (file_part, inner) = match rest.split_once("::") {
Some((f, t)) => (f, Some(t)),
None => (rest, None),
};
let Some(name) = self.doc_name_for_path(file_part) else {
// Not a document we serve — hand it to the OS.
return Ok("kind: external\n".to_string());
};
match inner {
// `file:DOC::TARGET` resolves TARGET inside DOC.
Some(t) => return self.resolve_in_doc(&name, t.trim()),
// `file:DOC` with no target navigates to the document itself.
None => return Ok(doc_result(&name)),
}
}
// Other external URL schemes are not navigation within the model.
if is_external_target(target) { if is_external_target(target) {
return Ok("kind: external\n".to_string()); return Ok("kind: external\n".to_string());
} }
// `id:` links are globally unique: search the current document first,
// then every other document in the path.
if let Some(uuid) = target.strip_prefix("id:") {
if let Some(name) = self.find_doc_with_section(uuid) {
return self.resolve_in_doc(&name, &format!("id:{}", uuid));
}
return Ok("kind: notfound\n".to_string());
}
// Internal targets (#custom-id, *Headline, bare fuzzy) resolve in the
// current document first (Emacs semantics), then fall back across the
// whole org path so a link can reach a heading in another file.
let local = self.resolve_in_doc(doc_name, target)?;
if !local.starts_with("kind: notfound") {
return Ok(local);
}
for name in self.doc_names() {
if name == doc_name {
continue;
}
let r = self.resolve_in_doc(&name, target)?;
if !r.starts_with("kind: notfound") {
return Ok(r);
}
}
Ok("kind: notfound\n".to_string())
}
/// Resolve an internal `target` within a single named document. Returns a
/// `kind: notfound` result if the document is missing or has no match.
fn resolve_in_doc(&self, doc_name: &str, target: &str) -> Result<String> {
let Some(d) = self.get_doc(doc_name) else { let Some(d) = self.get_doc(doc_name) else {
return Ok("kind: notfound\n".to_string()); return Ok("kind: notfound\n".to_string());
}; };
@ -1896,13 +1955,13 @@ impl OrkState {
// because section_id() already prefers CUSTOM_ID > ID. // because section_id() already prefers CUSTOM_ID > ID.
if let Some(cid) = target.strip_prefix('#') { if let Some(cid) = target.strip_prefix('#') {
if let Some(section) = self.find_section(&d.doc, cid) { if let Some(section) = self.find_section(&d.doc, cid) {
return Ok(dedicated_result(&self.section_id(section))); return Ok(dedicated_result(doc_name, &self.section_id(section)));
} }
return Ok("kind: notfound\n".to_string()); return Ok("kind: notfound\n".to_string());
} }
if let Some(uuid) = target.strip_prefix("id:") { if let Some(uuid) = target.strip_prefix("id:") {
if let Some(section) = self.find_section(&d.doc, uuid) { if let Some(section) = self.find_section(&d.doc, uuid) {
return Ok(dedicated_result(&self.section_id(section))); return Ok(dedicated_result(doc_name, &self.section_id(section)));
} }
return Ok("kind: notfound\n".to_string()); return Ok("kind: notfound\n".to_string());
} }
@ -1913,7 +1972,7 @@ impl OrkState {
if let Some(section) = d.doc.all_sections() if let Some(section) = d.doc.all_sections()
.find(|s| normalize_search(&s.headline.title_text()) == want) .find(|s| normalize_search(&s.headline.title_text()) == want)
{ {
return Ok(dedicated_result(&self.section_id(section))); return Ok(dedicated_result(doc_name, &self.section_id(section)));
} }
return Ok("kind: notfound\n".to_string()); return Ok("kind: notfound\n".to_string());
} }
@ -1926,38 +1985,81 @@ impl OrkState {
if let Some(section) = d.doc.all_sections() if let Some(section) = d.doc.all_sections()
.find(|s| normalize_search(&s.headline.title_text()) == want) .find(|s| normalize_search(&s.headline.title_text()) == want)
{ {
return Ok(fuzzy_result(&self.section_id(section), 0)); return Ok(fuzzy_result(doc_name, &self.section_id(section), 0));
} }
if let Some(section) = self.find_section(&d.doc, target) { if let Some(section) = self.find_section(&d.doc, target) {
return Ok(fuzzy_result(&self.section_id(section), 0)); return Ok(fuzzy_result(doc_name, &self.section_id(section), 0));
} }
Ok("kind: notfound\n".to_string()) Ok("kind: notfound\n".to_string())
} }
/// Map a `file:` link's file component to a served document name, if any.
/// Matches on the file stem (e.g. `notes.org`, `./notes.org`, `notes`
/// all map to the document `notes`).
fn doc_name_for_path(&self, file_part: &str) -> Option<String> {
let stem = std::path::Path::new(file_part.trim())
.file_stem()
.and_then(|s| s.to_str())?
.to_string();
if self.get_doc(&stem).is_some() {
Some(stem)
} else {
None
}
}
/// Find the document whose section id (CUSTOM_ID or ID) matches `id`.
/// Used for globally unique `id:` links that may live in another file.
fn find_doc_with_section(&self, id: &str) -> Option<String> {
self.doc_names().into_iter().find(|name| {
self.get_doc(name)
.map(|d| self.find_section(&d.doc, id).is_some())
.unwrap_or(false)
})
}
} }
/// Whether a link target is an external URL scheme (handed to the OS, not /// Whether a link target is an external URL scheme (handed to the OS, not
/// resolved within the document model). /// resolved within the document model). `file:` is handled separately by
/// `resolve_target` because it can name a document in the org path.
fn is_external_target(target: &str) -> bool { fn is_external_target(target: &str) -> bool {
const SCHEMES: &[&str] = &["http:", "https:", "mailto:", "ftp:", "file:"]; const SCHEMES: &[&str] = &["http:", "https:", "mailto:", "ftp:"];
let lower = target.to_ascii_lowercase(); let lower = target.to_ascii_lowercase();
SCHEMES.iter().any(|s| lower.starts_with(s)) SCHEMES.iter().any(|s| lower.starts_with(s))
} }
/// Strip a leading `file:` scheme (case-insensitive), returning the remainder.
/// Handles the `file:path` form; the optional `::target` is split by the caller.
fn strip_file_prefix(target: &str) -> Option<&str> {
let lower = target.to_ascii_lowercase();
if lower.starts_with("file:") {
Some(&target["file:".len()..])
} else {
None
}
}
/// Normalize a search string the way Emacs `org-link-search` does: collapse /// Normalize a search string the way Emacs `org-link-search` does: collapse
/// internal whitespace runs to a single space and trim the ends. /// internal whitespace runs to a single space and trim the ends.
fn normalize_search(s: &str) -> String { fn normalize_search(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ") s.split_whitespace().collect::<Vec<_>>().join(" ")
} }
/// Format a resolution result that names only the owning document (used for a
/// bare `file:DOC` link with no in-document target).
fn doc_result(doc: &str) -> String {
format!("kind: dedicated\ndoc: {}\noffset: 0\n", doc)
}
/// Format a dedicated (exact structural) resolution result. /// Format a dedicated (exact structural) resolution result.
fn dedicated_result(section_id: &str) -> String { fn dedicated_result(doc: &str, section_id: &str) -> String {
format!("kind: dedicated\nsection: {}\noffset: 0\n", section_id) format!("kind: dedicated\ndoc: {}\nsection: {}\noffset: 0\n", doc, section_id)
} }
/// Format a fuzzy resolution result with an intra-section byte offset. /// Format a fuzzy resolution result with an intra-section byte offset.
fn fuzzy_result(section_id: &str, offset: usize) -> String { fn fuzzy_result(doc: &str, section_id: &str, offset: usize) -> String {
format!("kind: fuzzy\nsection: {}\noffset: {}\n", section_id, offset) format!("kind: fuzzy\ndoc: {}\nsection: {}\noffset: {}\n", doc, section_id, offset)
} }
/// One parsed query term. /// One parsed query term.
@ -2174,12 +2276,25 @@ mod tests {
state.move_section("w", "three", MoveDir::Down).unwrap(); state.move_section("w", "three", MoveDir::Down).unwrap();
} }
/// Build a state from several named documents: (doc_name, content).
fn state_with_docs(docs: &[(&str, &str)]) -> (tempfile::TempDir, OrkState) {
use std::fs;
use tempfile::tempdir;
let dir = tempdir().unwrap();
for (name, content) in docs {
fs::write(dir.path().join(format!("{}.org", name)), content).unwrap();
}
let state = OrkState::new(dir.path()).unwrap();
(dir, state)
}
#[test] #[test]
fn test_resolve_custom_id() { fn test_resolve_custom_id() {
let (_d, state) = state_with( let (_d, state) = state_with(
"#+TITLE: W\n* Alpha\n:PROPERTIES:\n:CUSTOM_ID: my-anchor\n:END:\n* Beta\n"); "#+TITLE: W\n* Alpha\n:PROPERTIES:\n:CUSTOM_ID: my-anchor\n:END:\n* Beta\n");
let r = state.resolve_target("w", "#my-anchor").unwrap(); let r = state.resolve_target("w", "#my-anchor").unwrap();
assert!(r.contains("kind: dedicated"), "{}", r); assert!(r.contains("kind: dedicated"), "{}", r);
assert!(r.contains("doc: w"), "{}", r);
assert!(r.contains("section: my-anchor"), "{}", r); assert!(r.contains("section: my-anchor"), "{}", r);
assert!(r.contains("offset: 0"), "{}", r); assert!(r.contains("offset: 0"), "{}", r);
} }
@ -2239,6 +2354,90 @@ mod tests {
assert!(r.contains("kind: notfound"), "{}", r); assert!(r.contains("kind: notfound"), "{}", r);
} }
#[test]
fn test_resolve_file_doc_headline() {
// file:DOC::*Headline resolves the heading inside the named document.
let (_d, state) = state_with_docs(&[
("a", "#+TITLE: A\n* Start\n"),
("b", "#+TITLE: B\n* Target Heading\n"),
]);
let r = state.resolve_target("a", "file:b.org::*Target Heading").unwrap();
assert!(r.contains("kind: dedicated"), "{}", r);
assert!(r.contains("doc: b"), "{}", r);
assert!(r.contains("section: target-heading"), "{}", r);
}
#[test]
fn test_resolve_file_doc_custom_id() {
// file:DOC::#custom-id resolves the anchor inside the named document.
let (_d, state) = state_with_docs(&[
("a", "#+TITLE: A\n* Start\n"),
("b", "#+TITLE: B\n* Beta\n:PROPERTIES:\n:CUSTOM_ID: anchor\n:END:\n"),
]);
let r = state.resolve_target("a", "file:b.org::#anchor").unwrap();
assert!(r.contains("kind: dedicated"), "{}", r);
assert!(r.contains("doc: b"), "{}", r);
assert!(r.contains("section: anchor"), "{}", r);
}
#[test]
fn test_resolve_file_doc_no_target() {
// file:DOC with no ::target navigates to the document itself.
let (_d, state) = state_with_docs(&[
("a", "#+TITLE: A\n* Start\n"),
("b", "#+TITLE: B\n* Beta\n"),
]);
let r = state.resolve_target("a", "file:b.org").unwrap();
assert!(r.contains("kind: dedicated"), "{}", r);
assert!(r.contains("doc: b"), "{}", r);
}
#[test]
fn test_resolve_file_unknown_doc_external() {
// file: link to a document not in the org path stays external.
let (_d, state) = state_with_docs(&[("a", "#+TITLE: A\n* Start\n")]);
let r = state.resolve_target("a", "file:/tmp/elsewhere.org::*X").unwrap();
assert!(r.contains("kind: external"), "{}", r);
}
#[test]
fn test_resolve_id_cross_document() {
// id: links are globally unique and resolve in whichever doc owns them.
let (_d, state) = state_with_docs(&[
("a", "#+TITLE: A\n* Start\n"),
("b", "#+TITLE: B\n* Beta\n:PROPERTIES:\n:ID: uuid-xyz\n:END:\n"),
]);
let r = state.resolve_target("a", "id:uuid-xyz").unwrap();
assert!(r.contains("kind: dedicated"), "{}", r);
assert!(r.contains("doc: b"), "{}", r);
assert!(r.contains("section: uuid-xyz"), "{}", r);
}
#[test]
fn test_resolve_headline_cross_document_fallback() {
// A bare/headline target missing in the current doc falls back across
// the org path to a matching heading elsewhere.
let (_d, state) = state_with_docs(&[
("a", "#+TITLE: A\n* Start\n"),
("b", "#+TITLE: B\n* Faraway\n"),
]);
let r = state.resolve_target("a", "*Faraway").unwrap();
assert!(r.contains("kind: dedicated"), "{}", r);
assert!(r.contains("doc: b"), "{}", r);
assert!(r.contains("section: faraway"), "{}", r);
}
#[test]
fn test_resolve_prefers_current_document() {
// When both documents have a matching heading, the current doc wins.
let (_d, state) = state_with_docs(&[
("a", "#+TITLE: A\n* Shared\n"),
("b", "#+TITLE: B\n* Shared\n"),
]);
let r = state.resolve_target("a", "*Shared").unwrap();
assert!(r.contains("doc: a"), "{}", r);
}
#[test] #[test]
fn test_relevel_section() { fn test_relevel_section() {
let (_d, state) = state_with("#+TITLE: W\n* Parent\n** Child\n* Other\n"); let (_d, state) = state_with("#+TITLE: W\n* Parent\n** Child\n* Other\n");

View File

@ -70,14 +70,34 @@ ListView {
return out return out
} }
// Open a link target activated from body text. External URL schemes open // Open a link target activated from body text. Resolution and dispatch
// in the system handler; internal org links (id:, #custom, *headline) are // happen in the model (via /<doc>/resolve): external URLs open in the system
// left for a future navigation pass. // handler; internal links reveal the target section in the outline.
function openLink(target) { function openLink(target) {
if (/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//.test(target) docModel.followLink(target)
|| /^(mailto:|file:)/.test(target)) {
Qt.openUrlExternally(target)
} }
// Row currently flashing from a reveal (−1 = none).
property int flashRow: -1
// When the model reveals a section, select it, scroll it into view, and
// flash it briefly.
Connections {
target: docModel
function onSectionRevealed(row) {
if (row < 0)
return
outline.currentIndex = row
outline.positionViewAtIndex(row, ListView.Contain)
outline.flashRow = row
flashTimer.restart()
}
}
Timer {
id: flashTimer
interval: 900
onTriggered: outline.flashRow = -1
} }
delegate: Rectangle { delegate: Rectangle {
@ -92,6 +112,16 @@ ListView {
property bool drawerOpen: false property bool drawerOpen: false
// Transient flash overlay when this row is revealed via a link.
Rectangle {
anchors.fill: parent
radius: parent.radius
color: theme.linkColor
z: -1
opacity: outline.flashRow === del.index ? 0.35 : 0.0
Behavior on opacity { NumberAnimation { duration: 300 } }
}
readonly property string sectionBody: (model.body ?? "").trim() readonly property string sectionBody: (model.body ?? "").trim()
readonly property int sectionLevel: model.level ?? 1 readonly property int sectionLevel: model.level ?? 1
readonly property int indent: (sectionLevel - 1) * outline.indentWidth readonly property int indent: (sectionLevel - 1) * outline.indentWidth

View File

@ -2,7 +2,9 @@
#include "orkclient.h" #include "orkclient.h"
#include <QDebug> #include <QDebug>
#include <QDesktopServices>
#include <QRegularExpression> #include <QRegularExpression>
#include <QUrl>
static const QStringList DONE_KEYWORDS = {"DONE", "CANCELLED", "CANCELED"}; static const QStringList DONE_KEYWORDS = {"DONE", "CANCELLED", "CANCELED"};
@ -276,6 +278,77 @@ bool DocumentModel::cycleCheckbox(int row, int checkboxOrdinal, bool forward)
}); });
} }
int DocumentModel::revealSection(const QString &sectionId)
{
if (sectionId.isEmpty() || !m_sections.contains(sectionId))
return -1;
// Walk up the parent chain and expand every ancestor so the target becomes
// visible. (The target itself is not expanded — only revealed.)
QString cur = m_sections[sectionId].parentId;
bool changed = false;
while (!cur.isEmpty() && m_sections.contains(cur)) {
if (!m_expanded.contains(cur)) {
m_expanded.insert(cur);
changed = true;
}
cur = m_sections[cur].parentId;
}
if (changed)
rebuildVisibleRows();
const int row = m_visibleRows.indexOf(sectionId);
if (row >= 0)
emit sectionRevealed(row);
return row;
}
QString DocumentModel::followLink(const QString &target)
{
if (!m_client || m_document.isEmpty())
return QStringLiteral("error");
QString doc = m_document;
if (doc.startsWith('/'))
doc = doc.mid(1);
// Resolve the target via the document's /resolve file.
const QString resp =
m_client->rdwr(QStringLiteral("/%1/resolve").arg(doc), target);
// Parse the small "key: value" blob.
QString kind;
QString section;
QString targetDoc;
for (const QString &line : resp.split('\n', Qt::SkipEmptyParts)) {
const int colon = line.indexOf(':');
if (colon < 0)
continue;
const QString key = line.left(colon).trimmed();
const QString val = line.mid(colon + 1).trimmed();
if (key == QLatin1String("kind"))
kind = val;
else if (key == QLatin1String("section"))
section = val;
else if (key == QLatin1String("doc"))
targetDoc = val;
}
if (kind == QLatin1String("external")) {
QDesktopServices::openUrl(QUrl(target));
} else if (kind == QLatin1String("dedicated") || kind == QLatin1String("fuzzy")) {
// The resolver may hand back a target in another document (file: links,
// globally unique id: links, cross-file headline fallback). Switch the
// model to the owning document before revealing the section.
if (!targetDoc.isEmpty() && targetDoc != doc)
setDocument(targetDoc);
if (!section.isEmpty())
revealSection(section);
}
return kind.isEmpty() ? QStringLiteral("notfound") : kind;
}
void DocumentModel::loadDocument() void DocumentModel::loadDocument()
{ {
beginResetModel(); beginResetModel();

View File

@ -83,9 +83,26 @@ public:
// the section body back via /body and refreshes the row. Returns success. // the section body back via /body and refreshes the row. Returns success.
Q_INVOKABLE bool cycleCheckbox(int row, int checkboxOrdinal, bool forward); Q_INVOKABLE bool cycleCheckbox(int row, int checkboxOrdinal, bool forward);
// Reveal a section by id: expand all its ancestors so it becomes visible,
// and return its visible row index (-1 if the id is unknown). Emits
// sectionRevealed(row) so the view can scroll to and flash it.
Q_INVOKABLE int revealSection(const QString &sectionId);
// Resolve a link target via /<doc>/resolve, then act on it: external
// targets open in the OS handler; internal targets are revealed in the
// outline. Resolution spans the whole org path — file:/id:/fuzzy links may
// resolve into another document, in which case the model switches to the
// owning document before revealing. Returns the resolved kind
// ("dedicated"/"fuzzy"/"external"/"notfound"), or "error" if not connected.
Q_INVOKABLE QString followLink(const QString &target);
signals: signals:
void documentChanged(); void documentChanged();
// Emitted when a section has been revealed (ancestors expanded); `row` is
// its visible index. The view scrolls to and highlights it.
void sectionRevealed(int row);
private: private:
struct Section { struct Section {
QString id; // section UUID/custom-id QString id; // section UUID/custom-id