diff --git a/crates/ork-server/src/state.rs b/crates/ork-server/src/state.rs index 6597fa6..4bcc1cf 100644 --- a/crates/ork-server/src/state.rs +++ b/crates/ork-server/src/state.rs @@ -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: # omitted for external/notfound + /// doc: # owning document; omitted for external/notfound + /// section: # omitted for external/notfound/bare file:DOC /// offset: # 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 { 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 { + 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 { + 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::>().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"); diff --git a/ui/qml/OutlineView.qml b/ui/qml/OutlineView.qml index fd0ea88..f59b789 100644 --- a/ui/qml/OutlineView.qml +++ b/ui/qml/OutlineView.qml @@ -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 //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 diff --git a/ui/src/documentmodel.cpp b/ui/src/documentmodel.cpp index 32e5d80..3c2bf0b 100644 --- a/ui/src/documentmodel.cpp +++ b/ui/src/documentmodel.cpp @@ -2,7 +2,9 @@ #include "orkclient.h" #include +#include #include +#include 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 §ionId) +{ + 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(); diff --git a/ui/src/documentmodel.h b/ui/src/documentmodel.h index 7eff43a..1550e1c 100644 --- a/ui/src/documentmodel.h +++ b/ui/src/documentmodel.h @@ -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 §ionId); + + // Resolve a link target via //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