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
/// within a document. Mirrors Emacs `org-link-search`'s precedence ladder,
/// but terminates each branch in a section id + offset rather than moving
/// point.
/// within the org path. Mirrors Emacs `org-link-search`'s precedence
/// ladder, but terminates each branch in an owning document + section id +
/// 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:
/// ```text
/// 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
/// ```
///
/// 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)
/// - `id:uuid` -> dedicated (ID match)
/// - `id:uuid` -> dedicated (ID match, searched globally)
/// - `*Headline` -> dedicated (exact headline title)
/// - external URL schemes -> external (handed to the OS by callers)
/// - anything else (fuzzy) -> fuzzy section match (Phase 2: + offset)
@ -1883,11 +1891,62 @@ impl OrkState {
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) {
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 {
return Ok("kind: notfound\n".to_string());
};
@ -1896,13 +1955,13 @@ impl OrkState {
// because section_id() already prefers CUSTOM_ID > ID.
if let Some(cid) = target.strip_prefix('#') {
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());
}
if let Some(uuid) = target.strip_prefix("id:") {
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());
}
@ -1913,7 +1972,7 @@ impl OrkState {
if let Some(section) = d.doc.all_sections()
.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());
}
@ -1926,38 +1985,81 @@ impl OrkState {
if let Some(section) = d.doc.all_sections()
.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) {
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())
}
/// 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
/// 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 {
const SCHEMES: &[&str] = &["http:", "https:", "mailto:", "ftp:", "file:"];
const SCHEMES: &[&str] = &["http:", "https:", "mailto:", "ftp:"];
let lower = target.to_ascii_lowercase();
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
/// internal whitespace runs to a single space and trim the ends.
fn normalize_search(s: &str) -> String {
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.
fn dedicated_result(section_id: &str) -> String {
format!("kind: dedicated\nsection: {}\noffset: 0\n", section_id)
fn dedicated_result(doc: &str, section_id: &str) -> String {
format!("kind: dedicated\ndoc: {}\nsection: {}\noffset: 0\n", doc, section_id)
}
/// Format a fuzzy resolution result with an intra-section byte offset.
fn fuzzy_result(section_id: &str, offset: usize) -> String {
format!("kind: fuzzy\nsection: {}\noffset: {}\n", section_id, offset)
fn fuzzy_result(doc: &str, section_id: &str, offset: usize) -> String {
format!("kind: fuzzy\ndoc: {}\nsection: {}\noffset: {}\n", doc, section_id, offset)
}
/// One parsed query term.
@ -2174,12 +2276,25 @@ mod tests {
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]
fn test_resolve_custom_id() {
let (_d, state) = state_with(
"#+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();
assert!(r.contains("kind: dedicated"), "{}", r);
assert!(r.contains("doc: w"), "{}", r);
assert!(r.contains("section: my-anchor"), "{}", r);
assert!(r.contains("offset: 0"), "{}", r);
}
@ -2239,6 +2354,90 @@ mod tests {
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]
fn test_relevel_section() {
let (_d, state) = state_with("#+TITLE: W\n* Parent\n** Child\n* Other\n");

View File

@ -70,16 +70,36 @@ ListView {
return out
}
// Open a link target activated from body text. External URL schemes open
// in the system handler; internal org links (id:, #custom, *headline) are
// left for a future navigation pass.
// Open a link target activated from body text. Resolution and dispatch
// happen in the model (via /<doc>/resolve): external URLs open in the system
// handler; internal links reveal the target section in the outline.
function openLink(target) {
if (/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//.test(target)
|| /^(mailto:|file:)/.test(target)) {
Qt.openUrlExternally(target)
docModel.followLink(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 {
id: del
width: outline.width
@ -92,6 +112,16 @@ ListView {
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 int sectionLevel: model.level ?? 1
readonly property int indent: (sectionLevel - 1) * outline.indentWidth

View File

@ -2,7 +2,9 @@
#include "orkclient.h"
#include <QDebug>
#include <QDesktopServices>
#include <QRegularExpression>
#include <QUrl>
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()
{
beginResetModel();

View File

@ -83,9 +83,26 @@ public:
// the section body back via /body and refreshes the row. Returns success.
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:
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:
struct Section {
QString id; // section UUID/custom-id