(el && setIcon(el, "info")) || undefined} />
+
+);
diff --git a/apps/obsidian/src/components/RelationshipSection.tsx b/apps/obsidian/src/components/RelationshipSection.tsx
index 04e9f69c4..6b7ace8bf 100644
--- a/apps/obsidian/src/components/RelationshipSection.tsx
+++ b/apps/obsidian/src/components/RelationshipSection.tsx
@@ -26,7 +26,7 @@ import {
removeRelationBySourceDestinationType,
updateRelation,
} from "~/utils/relationsStore";
-import { InfoTooltip } from "./DiscourseContextView";
+import { InfoTooltip } from "./InfoTooltip";
type RelationTypeOption = {
id: string;
diff --git a/apps/obsidian/src/components/discourseContextBadge.ts b/apps/obsidian/src/components/discourseContextBadge.ts
new file mode 100644
index 000000000..7a2c2f6fa
--- /dev/null
+++ b/apps/obsidian/src/components/discourseContextBadge.ts
@@ -0,0 +1,66 @@
+import { setIcon, setTooltip, TFile } from "obsidian";
+import type { DiscourseNode } from "~/types";
+
+/** Marks a badge so a re-run can find and replace it. */
+export const DISCOURSE_CONTEXT_BADGE_CLASS = "dg-discourse-context-badge";
+
+export type DiscourseContextBadgeProps = {
+ file: TFile;
+ nodeType: DiscourseNode;
+ relationCount: number;
+ onActivate: (args: { file: TFile; anchor: HTMLElement }) => void;
+};
+
+const badgeTooltip = ({
+ nodeType,
+ relationCount,
+}: Pick
): string => {
+ const relations = relationCount === 1 ? "relation" : "relations";
+ return `${nodeType.name}: ${relationCount} ${relations} — open discourse context`;
+};
+
+/**
+ * Inline badge next to a link to a discourse node. Plain DOM, not React, so both
+ * render paths share it without mounting a React root per link.
+ */
+export const createDiscourseContextBadge = ({
+ file,
+ nodeType,
+ relationCount,
+ onActivate,
+}: DiscourseContextBadgeProps): HTMLElement => {
+ const badge = createSpan();
+ badge.className = `${DISCOURSE_CONTEXT_BADGE_CLASS} inline-flex items-center gap-0.5 align-middle ml-1 px-1 rounded cursor-pointer select-none text-[10px] leading-none text-[var(--text-muted)] hover:text-[var(--text-normal)] hover:bg-[var(--background-modifier-hover)] transition-colors duration-150`;
+
+ const icon = badge.createSpan({
+ cls: "inline-flex items-center [&>svg]:h-3 [&>svg]:w-3",
+ });
+ setIcon(icon, "network");
+
+ badge.createSpan({ text: String(relationCount) });
+
+ const label = badgeTooltip({ nodeType, relationCount });
+ setTooltip(badge, label);
+ badge.setAttribute("aria-label", label);
+ badge.setAttribute("role", "button");
+ badge.setAttribute("tabindex", "0");
+
+ const activate = (event: Event): void => {
+ // Do not follow the link the badge sits next to.
+ event.preventDefault();
+ event.stopPropagation();
+ onActivate({ file, anchor: badge });
+ };
+
+ // Otherwise the caret moves, expanding the raw [[...]] under the popover.
+ badge.addEventListener("mousedown", (event: MouseEvent) => {
+ event.preventDefault();
+ });
+ badge.addEventListener("click", activate);
+ badge.addEventListener("keydown", (event: KeyboardEvent) => {
+ if (event.key !== "Enter" && event.key !== " ") return;
+ activate(event);
+ });
+
+ return badge;
+};
diff --git a/apps/obsidian/src/constants.ts b/apps/obsidian/src/constants.ts
index 95fab14a3..a9cf5632b 100644
--- a/apps/obsidian/src/constants.ts
+++ b/apps/obsidian/src/constants.ts
@@ -119,6 +119,7 @@ export const DEFAULT_SETTINGS: Settings = {
canvasAttachmentsFolderPath: "attachments",
nodeTagHotkey: "\\",
showHelpMenuStatusBarIcon: false,
+ showDiscourseContextOverlay: true,
spacePassword: undefined,
accountLocalId: undefined,
syncModeEnabled: false,
diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts
index 06d169332..059878381 100644
--- a/apps/obsidian/src/index.ts
+++ b/apps/obsidian/src/index.ts
@@ -20,6 +20,14 @@ import {
} from "~/utils/editorMenuUtils";
import { createImageEmbedHoverExtension } from "~/utils/imageEmbedHoverIcon";
import { createWikilinkDragExtension } from "~/utils/wikilinkDragHandler";
+import { createDiscourseContextOverlayExtension } from "~/utils/discourseContextOverlayExtension";
+import {
+ createDiscourseContextOverlayPostProcessor,
+ registerDiscourseContextOverlayRefresh,
+ refreshDiscourseContextOverlaySurfaces,
+} from "~/utils/discourseContextOverlayPostProcessor";
+import { refreshMarkdownEditors } from "~/utils/markdownViewRefresh";
+import { closeDiscourseContextPopover } from "~/components/DiscourseContextPopover";
import {
registerCommands,
createModifyNodeModalSubmitHandler,
@@ -35,6 +43,7 @@ import { NodeTagSuggestPopover } from "~/components/NodeTagSuggestModal";
import { InlineNodeTypePicker } from "~/components/InlineNodeTypePicker";
import { initializeSupabaseSync } from "~/utils/syncDgNodesToSupabase";
import { FileChangeListener } from "~/utils/fileChangeListener";
+import { RelationsIndex } from "~/utils/relationsIndex";
import generateUid from "~/utils/generateUid";
import {
migrateFrontmatterRelationsToRelationsJson,
@@ -51,6 +60,7 @@ import {
export default class DiscourseGraphPlugin extends Plugin {
settings: Settings = { ...DEFAULT_SETTINGS };
+ relationsIndex: RelationsIndex = new RelationsIndex(this);
private tagNodeHandler: TagNodeHandler | null = null;
private fileChangeListener: FileChangeListener | null = null;
private activeNodePopover:
@@ -98,6 +108,12 @@ export default class DiscourseGraphPlugin extends Plugin {
}
}
+ this.relationsIndex.initialize();
+ this.registerMarkdownPostProcessor(
+ createDiscourseContextOverlayPostProcessor(this),
+ );
+ registerDiscourseContextOverlayRefresh(this);
+
registerCommands(this);
this.addSettingTab(new SettingsTab(this.app, this));
addIcon(DISCOURSE_GRAPH_LOGO_ICON_ID, WHITE_LOGO_SVG);
@@ -268,36 +284,25 @@ export default class DiscourseGraphPlugin extends Plugin {
}),
);
- type EditorWithCm = { cm: EditorView };
- const hasCodeMirrorView = (editor: unknown): editor is EditorWithCm => {
- if (!editor || typeof editor !== "object") return false;
- return "cm" in editor;
- };
-
- // Dispatch a no-op CM6 transaction to every markdown editor so their
- // ViewPlugin re-evaluates hasVisibleCanvasLeaf and shows/hides widgets.
- // layout-change covers splits/moves, active-leaf-change covers tab switches.
- const refreshMarkdownEditors = (): void => {
- this.app.workspace.iterateAllLeaves((leaf) => {
- if (
- leaf.view instanceof MarkdownView &&
- hasCodeMirrorView(leaf.view.editor)
- ) {
- leaf.view.editor.cm.dispatch({});
- }
- });
- };
- this.registerEvent(
- this.app.workspace.on("layout-change", refreshMarkdownEditors),
- );
+ // Re-evaluate ViewPlugins on splits/moves (layout-change) and tab switches.
+ const refreshEditors = (): void => refreshMarkdownEditors(this.app);
+ this.registerEvent(this.app.workspace.on("layout-change", refreshEditors));
this.registerEvent(
- this.app.workspace.on("active-leaf-change", refreshMarkdownEditors),
+ this.app.workspace.on("active-leaf-change", refreshEditors),
);
// Register editor keydown listener for node tag hotkey
this.setupNodeTagHotkey();
}
+ /**
+ * Re-renders both markdown surfaces so the discourse context overlay appears
+ * or disappears immediately when its setting is toggled, without a reload.
+ */
+ refreshDiscourseContextOverlay(): void {
+ refreshDiscourseContextOverlaySurfaces(this);
+ }
+
setHelpMenuStatusBarItemVisibility(): void {
if (!this.settings.showHelpMenuStatusBarIcon) {
this.helpMenuStatusBarItem?.remove();
@@ -369,6 +374,7 @@ export default class DiscourseGraphPlugin extends Plugin {
this.registerEditorExtension(createImageEmbedHoverExtension(this));
this.registerEditorExtension(createWikilinkDragExtension(this));
+ this.registerEditorExtension(createDiscourseContextOverlayExtension(this));
}
updateFrontmatterStyles(): void {
@@ -488,5 +494,9 @@ export default class DiscourseGraphPlugin extends Plugin {
this.fileChangeListener.cleanup();
this.fileChangeListener = null;
}
+
+ // Lives on document.body with its own listeners; would outlive the plugin.
+ closeDiscourseContextPopover();
+ this.relationsIndex.unload();
}
}
diff --git a/apps/obsidian/src/types.ts b/apps/obsidian/src/types.ts
index 050c476a3..fe998bafe 100644
--- a/apps/obsidian/src/types.ts
+++ b/apps/obsidian/src/types.ts
@@ -68,6 +68,7 @@ export type Settings = {
canvasAttachmentsFolderPath: string;
nodeTagHotkey: string;
showHelpMenuStatusBarIcon: boolean;
+ showDiscourseContextOverlay: boolean;
spacePassword?: string;
accountLocalId?: string;
syncModeEnabled?: boolean;
diff --git a/apps/obsidian/src/utils/discourseContextOverlayExtension.ts b/apps/obsidian/src/utils/discourseContextOverlayExtension.ts
new file mode 100644
index 000000000..43986daac
--- /dev/null
+++ b/apps/obsidian/src/utils/discourseContextOverlayExtension.ts
@@ -0,0 +1,141 @@
+import {
+ type PluginValue,
+ ViewPlugin,
+ type ViewUpdate,
+ WidgetType,
+ Decoration,
+ type DecorationSet,
+ EditorView,
+} from "@codemirror/view";
+import { editorInfoField, editorLivePreviewField } from "obsidian";
+import type DiscourseGraphPlugin from "~/index";
+import { createDiscourseContextBadge } from "~/components/discourseContextBadge";
+import { openDiscourseContextPopover } from "~/components/DiscourseContextPopover";
+import {
+ resolveDiscourseLinkTarget,
+ type DiscourseLinkTarget,
+} from "./discourseLinkUtils";
+import { extractLinktext, INTERNAL_LINK_RE } from "./internalLinkParsing";
+
+class DiscourseContextBadgeWidget extends WidgetType {
+ constructor(
+ private target: DiscourseLinkTarget,
+ private plugin: DiscourseGraphPlugin,
+ ) {
+ super();
+ }
+
+ /** Keyed on what the badge displays, so keystrokes elsewhere do not rebuild it. */
+ eq(other: DiscourseContextBadgeWidget): boolean {
+ return (
+ this.target.file.path === other.target.file.path &&
+ this.target.relationCount === other.target.relationCount &&
+ this.target.nodeType.id === other.target.nodeType.id &&
+ this.target.nodeType.name === other.target.nodeType.name
+ );
+ }
+
+ toDOM(): HTMLElement {
+ return createDiscourseContextBadge({
+ file: this.target.file,
+ nodeType: this.target.nodeType,
+ relationCount: this.target.relationCount,
+ onActivate: ({ file, anchor }) =>
+ openDiscourseContextPopover({
+ plugin: this.plugin,
+ file,
+ anchor,
+ relationCount: this.target.relationCount,
+ }),
+ });
+ }
+
+ /** True (the CM6 default) means the editor ignores the event, so our click handler runs. */
+ ignoreEvent(): boolean {
+ return true;
+ }
+}
+
+const buildBadgeDecorations = (
+ view: EditorView,
+ plugin: DiscourseGraphPlugin,
+): DecorationSet => {
+ if (!plugin.settings.showDiscourseContextOverlay) return Decoration.none;
+ // Source mode shows raw markdown; a badge there is noise.
+ if (!view.state.field(editorLivePreviewField, false)) return Decoration.none;
+
+ const sourcePath = view.state.field(editorInfoField, false)?.file?.path;
+ if (!sourcePath) return Decoration.none;
+
+ const widgets = [];
+
+ for (const { from, to } of view.visibleRanges) {
+ const text = view.state.doc.sliceString(from, to);
+ let match: RegExpExecArray | null;
+ INTERNAL_LINK_RE.lastIndex = 0;
+
+ while ((match = INTERNAL_LINK_RE.exec(text)) !== null) {
+ const checkPos = from + match.index - 1;
+ const isEmbed =
+ checkPos >= 0 &&
+ view.state.doc.sliceString(checkPos, checkPos + 1) === "!";
+ if (isEmbed) continue;
+
+ const target = resolveDiscourseLinkTarget({
+ plugin,
+ linktext: extractLinktext(match[0]),
+ sourcePath,
+ });
+ if (!target) continue;
+
+ const matchEnd = from + match.index + match[0].length;
+ widgets.push(
+ Decoration.widget({
+ widget: new DiscourseContextBadgeWidget(target, plugin),
+ side: 1,
+ }).range(matchEnd),
+ );
+ }
+ }
+
+ return Decoration.set(widgets, true);
+};
+
+/** Renders the badge after each discourse-node link in Live Preview. */
+export const createDiscourseContextOverlayExtension = (
+ plugin: DiscourseGraphPlugin,
+): ViewPlugin =>
+ ViewPlugin.fromClass(
+ class {
+ decorations: DecorationSet;
+ private enabled: boolean;
+ private indexVersion: number;
+
+ constructor(view: EditorView) {
+ this.enabled = plugin.settings.showDiscourseContextOverlay;
+ this.indexVersion = plugin.relationsIndex.getVersion();
+ this.decorations = buildBadgeDecorations(view, plugin);
+ }
+
+ update(update: ViewUpdate): void {
+ // Setting and relation changes arrive as an empty transaction, which
+ // changes neither doc nor viewport, so both need comparing explicitly.
+ const enabled = plugin.settings.showDiscourseContextOverlay;
+ const indexVersion = plugin.relationsIndex.getVersion();
+ if (
+ !update.docChanged &&
+ !update.viewportChanged &&
+ enabled === this.enabled &&
+ indexVersion === this.indexVersion
+ ) {
+ return;
+ }
+ this.enabled = enabled;
+ this.indexVersion = indexVersion;
+ this.decorations = buildBadgeDecorations(update.view, plugin);
+ }
+ },
+ {
+ decorations: (v) => v.decorations,
+ },
+ );
diff --git a/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts b/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts
new file mode 100644
index 000000000..271e54f39
--- /dev/null
+++ b/apps/obsidian/src/utils/discourseContextOverlayPostProcessor.ts
@@ -0,0 +1,138 @@
+import {
+ debounce,
+ MarkdownView,
+ type MarkdownPostProcessorContext,
+ type TFile,
+} from "obsidian";
+import type DiscourseGraphPlugin from "~/index";
+import {
+ createDiscourseContextBadge,
+ DISCOURSE_CONTEXT_BADGE_CLASS,
+} from "~/components/discourseContextBadge";
+import { openDiscourseContextPopover } from "~/components/DiscourseContextPopover";
+import { resolveDiscourseLinkTarget } from "./discourseLinkUtils";
+import { getNodeTypeIdFromFrontmatter } from "./discourseLinkFrontmatter";
+import { refreshMarkdownEditors } from "./markdownViewRefresh";
+
+const REFRESH_DEBOUNCE_MS = 300;
+
+/** Only a discourse node's own frontmatter can change what a badge shows. */
+const isDiscourseNodeFile = (
+ plugin: DiscourseGraphPlugin,
+ file: TFile,
+): boolean =>
+ !!getNodeTypeIdFromFrontmatter(
+ plugin.app.metadataCache.getFileCache(file)?.frontmatter,
+ );
+
+/**
+ * Adds, updates or removes the badge on every discourse-node link in `el`.
+ * Idempotent: Obsidian reuses rendered sections and re-runs post processors.
+ */
+export const applyDiscourseContextBadges = ({
+ plugin,
+ el,
+ sourcePath,
+}: {
+ plugin: DiscourseGraphPlugin;
+ el: HTMLElement;
+ sourcePath: string;
+}): void => {
+ const links = el.querySelectorAll("a.internal-link");
+
+ for (const link of Array.from(links)) {
+ const existing = link.nextElementSibling?.hasClass(
+ DISCOURSE_CONTEXT_BADGE_CLASS,
+ )
+ ? link.nextElementSibling
+ : null;
+
+ // data-href holds the link as written; href is resolved and URL-encoded.
+ const linktext =
+ link.getAttribute("data-href") ?? link.getAttribute("href");
+ if (!linktext) continue;
+
+ const target = resolveDiscourseLinkTarget({
+ plugin,
+ linktext,
+ sourcePath,
+ });
+ if (!target) {
+ existing?.remove();
+ continue;
+ }
+
+ const badge = createDiscourseContextBadge({
+ file: target.file,
+ nodeType: target.nodeType,
+ relationCount: target.relationCount,
+ onActivate: ({ file, anchor }) =>
+ openDiscourseContextPopover({
+ plugin,
+ file,
+ anchor,
+ relationCount: target.relationCount,
+ }),
+ });
+
+ // Replaced, not skipped, or it keeps a count from before the last change.
+ existing?.remove();
+ link.insertAdjacentElement("afterend", badge);
+ }
+};
+
+/** Strips every badge under `el`, for when the setting is switched off. */
+const removeDiscourseContextBadges = (el: HTMLElement): void => {
+ el.querySelectorAll(`.${DISCOURSE_CONTEXT_BADGE_CLASS}`).forEach((badge) =>
+ badge.remove(),
+ );
+};
+
+export const createDiscourseContextOverlayPostProcessor =
+ (plugin: DiscourseGraphPlugin) =>
+ (el: HTMLElement, ctx: MarkdownPostProcessorContext): void => {
+ if (!plugin.settings.showDiscourseContextOverlay) return;
+ if (!ctx.sourcePath) return;
+ applyDiscourseContextBadges({ plugin, el, sourcePath: ctx.sourcePath });
+ };
+
+/**
+ * Redraws both surfaces when relations or frontmatter change. Reading view is
+ * refreshed in place: rerender() blanks a pane that is not currently painting.
+ */
+export const refreshDiscourseContextOverlaySurfaces = (
+ plugin: DiscourseGraphPlugin,
+): void => {
+ refreshMarkdownEditors(plugin.app);
+ plugin.app.workspace.iterateAllLeaves((leaf) => {
+ if (!(leaf.view instanceof MarkdownView)) return;
+ const el = leaf.view.previewMode?.containerEl;
+ if (!el) return;
+ if (!plugin.settings.showDiscourseContextOverlay) {
+ removeDiscourseContextBadges(el);
+ return;
+ }
+ const sourcePath = leaf.view.file?.path;
+ if (!sourcePath) return;
+ applyDiscourseContextBadges({ plugin, el, sourcePath });
+ });
+};
+
+export const registerDiscourseContextOverlayRefresh = (
+ plugin: DiscourseGraphPlugin,
+): void => {
+ const refresh = debounce(
+ () => refreshDiscourseContextOverlaySurfaces(plugin),
+ REFRESH_DEBOUNCE_MS,
+ true,
+ );
+
+ plugin.register(plugin.relationsIndex.onChange(refresh));
+ // "changed", not "resolved": resolved also fires while a preview renders.
+ plugin.registerEvent(
+ plugin.app.metadataCache.on("changed", (file) => {
+ if (!isDiscourseNodeFile(plugin, file)) return;
+ refresh();
+ }),
+ );
+};
diff --git a/apps/obsidian/src/utils/discourseLinkFrontmatter.ts b/apps/obsidian/src/utils/discourseLinkFrontmatter.ts
new file mode 100644
index 000000000..168235760
--- /dev/null
+++ b/apps/obsidian/src/utils/discourseLinkFrontmatter.ts
@@ -0,0 +1,40 @@
+import type { RelationInstance } from "~/types";
+
+const asString = (value: unknown): string | undefined =>
+ typeof value === "string" && value.length > 0 ? value : undefined;
+
+export const getNodeTypeIdFromFrontmatter = (
+ frontmatter: Record | undefined,
+): string | undefined => asString(frontmatter?.nodeTypeId);
+
+/** An imported node is referenced by both its nodeInstanceId and its importedFromRid. */
+export const getEndpointIdsFromFrontmatter = (
+ frontmatter: Record | undefined,
+): string[] => {
+ const endpointIds: string[] = [];
+ const nodeInstanceId = asString(frontmatter?.nodeInstanceId);
+ const importedFromRid = asString(frontmatter?.importedFromRid);
+
+ if (nodeInstanceId) endpointIds.push(nodeInstanceId);
+ if (importedFromRid && importedFromRid !== nodeInstanceId) {
+ endpointIds.push(importedFromRid);
+ }
+
+ return endpointIds;
+};
+
+/**
+ * Counts what the panel would list. Excludes unaccepted imports and relations
+ * orphaned by a deleted relation type, both of which the panel hides.
+ */
+export const countDisplayableRelations = ({
+ relations,
+ isConfiguredType,
+}: {
+ relations: RelationInstance[];
+ isConfiguredType: (relationTypeId: string) => boolean;
+}): number =>
+ relations.filter(
+ (relation) =>
+ relation.tentative !== false && isConfiguredType(relation.type),
+ ).length;
diff --git a/apps/obsidian/src/utils/discourseLinkUtils.ts b/apps/obsidian/src/utils/discourseLinkUtils.ts
new file mode 100644
index 000000000..28cf5e873
--- /dev/null
+++ b/apps/obsidian/src/utils/discourseLinkUtils.ts
@@ -0,0 +1,58 @@
+import { parseLinktext, TFile } from "obsidian";
+import type DiscourseGraphPlugin from "~/index";
+import type { DiscourseNode } from "~/types";
+import { getNodeTypeById, getRelationTypeById } from "./typeUtils";
+import {
+ countDisplayableRelations,
+ getEndpointIdsFromFrontmatter,
+ getNodeTypeIdFromFrontmatter,
+} from "./discourseLinkFrontmatter";
+
+export type DiscourseLinkTarget = {
+ file: TFile;
+ nodeType: DiscourseNode;
+ relationCount: number;
+};
+
+/**
+ * Resolves a link to a discourse node and its relation count from in-memory
+ * caches only; avoids getNodeTypeIdForFile, which polls 500ms for frontmatter.
+ */
+export const resolveDiscourseLinkTarget = ({
+ plugin,
+ linktext,
+ sourcePath,
+}: {
+ plugin: DiscourseGraphPlugin;
+ linktext: string;
+ sourcePath: string;
+}): DiscourseLinkTarget | null => {
+ // Strips any #heading or #^block subpath.
+ const { path } = parseLinktext(linktext);
+ if (!path) return null;
+
+ const file = plugin.app.metadataCache.getFirstLinkpathDest(path, sourcePath);
+ if (!file) return null;
+
+ const frontmatter = plugin.app.metadataCache.getFileCache(file)?.frontmatter;
+
+ const nodeTypeId = getNodeTypeIdFromFrontmatter(frontmatter);
+ if (!nodeTypeId) return null;
+
+ const nodeType = getNodeTypeById(plugin, nodeTypeId);
+ if (!nodeType) return null;
+
+ const endpointIds = getEndpointIdsFromFrontmatter(frontmatter);
+ if (endpointIds.length === 0) return { file, nodeType, relationCount: 0 };
+
+ const relations =
+ plugin.relationsIndex.getRelationsForEndpointIds(endpointIds);
+
+ const relationCount = countDisplayableRelations({
+ relations,
+ isConfiguredType: (relationTypeId) =>
+ !!getRelationTypeById(plugin, relationTypeId),
+ });
+
+ return { file, nodeType, relationCount };
+};
diff --git a/apps/obsidian/src/utils/internalLinkParsing.ts b/apps/obsidian/src/utils/internalLinkParsing.ts
new file mode 100644
index 000000000..d95edb4f3
--- /dev/null
+++ b/apps/obsidian/src/utils/internalLinkParsing.ts
@@ -0,0 +1,21 @@
+// Shared by the CM6 extensions that scan raw markdown for internal links.
+
+/** Embeds are not matched: the leading `!` sits outside, so callers check it. */
+export const INTERNAL_LINK_RE = /\[\[([^\]]+)\]\]|\[([^\]]+)\]\(([^)]+\.md)\)/g;
+
+/** Target of a wikilink or markdown link; any `#subpath` is left for parseLinktext. */
+export const extractLinktext = (match: string): string => {
+ if (match.startsWith("[[")) {
+ const inner = match.slice(2, -2);
+ const pipeIndex = inner.indexOf("|");
+ return pipeIndex >= 0 ? inner.slice(0, pipeIndex) : inner;
+ }
+
+ const parenOpen = match.lastIndexOf("(");
+ const rawPath = match.slice(parenOpen + 1, -1);
+ try {
+ return decodeURIComponent(rawPath);
+ } catch {
+ return rawPath;
+ }
+};
diff --git a/apps/obsidian/src/utils/markdownViewRefresh.ts b/apps/obsidian/src/utils/markdownViewRefresh.ts
new file mode 100644
index 000000000..cc54f96bb
--- /dev/null
+++ b/apps/obsidian/src/utils/markdownViewRefresh.ts
@@ -0,0 +1,24 @@
+import { MarkdownView, type App } from "obsidian";
+import type { EditorView } from "@codemirror/view";
+
+type EditorWithCm = { cm: EditorView };
+
+export const hasCodeMirrorView = (editor: unknown): editor is EditorWithCm => {
+ if (!editor || typeof editor !== "object") return false;
+ return "cm" in editor;
+};
+
+/**
+ * Empty CM6 transaction to every open editor, forcing ViewPlugin.update() to
+ * run when something it reads changes outside the editor.
+ */
+export const refreshMarkdownEditors = (app: App): void => {
+ app.workspace.iterateAllLeaves((leaf) => {
+ if (
+ leaf.view instanceof MarkdownView &&
+ hasCodeMirrorView(leaf.view.editor)
+ ) {
+ leaf.view.editor.cm.dispatch({});
+ }
+ });
+};
diff --git a/apps/obsidian/src/utils/relationsEndpointIndex.ts b/apps/obsidian/src/utils/relationsEndpointIndex.ts
new file mode 100644
index 000000000..f200a392e
--- /dev/null
+++ b/apps/obsidian/src/utils/relationsEndpointIndex.ts
@@ -0,0 +1,54 @@
+import type { RelationInstance } from "~/types";
+
+/**
+ * Groups relations by the ids at either end, so a lookup is a Map hit rather
+ * than a scan. Self-relations are filed once, not twice.
+ */
+export const buildEndpointIndex = (
+ relations: Record,
+): Map => {
+ const index = new Map();
+
+ const fileUnder = (endpointId: string, relation: RelationInstance): void => {
+ const existing = index.get(endpointId);
+ if (existing) {
+ existing.push(relation);
+ return;
+ }
+ index.set(endpointId, [relation]);
+ };
+
+ for (const relation of Object.values(relations)) {
+ if (!relation) continue;
+ if (relation.source) fileUnder(relation.source, relation);
+ if (relation.destination && relation.destination !== relation.source) {
+ fileUnder(relation.destination, relation);
+ }
+ }
+
+ return index;
+};
+
+/** Relations touching any of `endpointIds`, deduped: an imported node matches on two ids. */
+export const collectRelations = ({
+ index,
+ endpointIds,
+}: {
+ index: Map;
+ endpointIds: Iterable;
+}): RelationInstance[] => {
+ const seen = new Set();
+ const collected: RelationInstance[] = [];
+
+ for (const endpointId of endpointIds) {
+ const relations = index.get(endpointId);
+ if (!relations) continue;
+ for (const relation of relations) {
+ if (seen.has(relation.id)) continue;
+ seen.add(relation.id);
+ collected.push(relation);
+ }
+ }
+
+ return collected;
+};
diff --git a/apps/obsidian/src/utils/relationsIndex.ts b/apps/obsidian/src/utils/relationsIndex.ts
new file mode 100644
index 000000000..64b154087
--- /dev/null
+++ b/apps/obsidian/src/utils/relationsIndex.ts
@@ -0,0 +1,112 @@
+import { TAbstractFile, TFile } from "obsidian";
+import type DiscourseGraphPlugin from "~/index";
+import type { RelationInstance } from "~/types";
+import { getRelationsFilePath, loadRelations } from "./relationsStore";
+import { buildEndpointIndex, collectRelations } from "./relationsEndpointIndex";
+
+/**
+ * Parsed snapshot of relations.json so a render path can ask synchronously,
+ * rebuilt from vault events (which covers our own writes and sync alike).
+ */
+export class RelationsIndex {
+ private plugin: DiscourseGraphPlugin;
+ private index: Map | null = null;
+ private inFlight: Promise | null = null;
+ private stale = false;
+ private unloaded = false;
+ /** Lets a ViewPlugin, which only sees transactions, detect a changed snapshot. */
+ private version = 0;
+ private subscribers = new Set<() => void>();
+ /** Guards against a load that started before an invalidation overwriting a newer one. */
+ private generation = 0;
+
+ constructor(plugin: DiscourseGraphPlugin) {
+ this.plugin = plugin;
+ }
+
+ initialize(): void {
+ const invalidateIfRelationsFile = (file: TAbstractFile): void => {
+ if (!(file instanceof TFile)) return;
+ if (file.path !== getRelationsFilePath()) return;
+ this.invalidate();
+ };
+
+ const { vault } = this.plugin.app;
+ this.plugin.registerEvent(vault.on("modify", invalidateIfRelationsFile));
+ this.plugin.registerEvent(vault.on("create", invalidateIfRelationsFile));
+ this.plugin.registerEvent(vault.on("delete", invalidateIfRelationsFile));
+
+ void this.ensureLoaded();
+ }
+
+ unload(): void {
+ this.unloaded = true;
+ this.subscribers.clear();
+ this.index = null;
+ this.inFlight = null;
+ this.generation += 1;
+ }
+
+ /** Changes whenever the snapshot is replaced; see the field comment. */
+ getVersion(): number {
+ return this.version;
+ }
+
+ /** Fires when the snapshot changes. Returns an unsubscribe function. */
+ onChange(subscriber: () => void): () => void {
+ this.subscribers.add(subscriber);
+ return () => this.subscribers.delete(subscriber);
+ }
+
+ async ensureLoaded(): Promise {
+ if (this.unloaded) return;
+ if (this.index !== null && !this.stale) return;
+ if (this.inFlight) return this.inFlight;
+
+ const generation = this.generation;
+ this.inFlight = (async () => {
+ try {
+ const relationsFile = await loadRelations(this.plugin);
+ // Superseded mid-read; the invalidation already scheduled a reload.
+ if (generation !== this.generation || this.unloaded) return;
+ this.index = buildEndpointIndex(relationsFile.relations ?? {});
+ this.stale = false;
+ this.version += 1;
+ } finally {
+ // Every path, or ensureLoaded hands out a settled promise forever.
+ this.inFlight = null;
+ }
+ // The skipped invalidation above still needs a load of its own.
+ if (this.stale && !this.unloaded) {
+ void this.ensureLoaded();
+ return;
+ }
+ this.notify();
+ })();
+
+ return this.inFlight;
+ }
+
+ /**
+ * Empty while cold, so treat that as "not loaded yet", not "no relations".
+ * Never schedules a load: that would make notify -> re-render -> read loop.
+ */
+ getRelationsForEndpointIds(
+ endpointIds: Iterable,
+ ): RelationInstance[] {
+ if (this.index === null) return [];
+ return collectRelations({ index: this.index, endpointIds });
+ }
+
+ /** Keeps the old snapshot while reloading, so badges do not flash to 0. */
+ private invalidate(): void {
+ this.generation += 1;
+ this.inFlight = null;
+ this.stale = true;
+ void this.ensureLoaded();
+ }
+
+ private notify(): void {
+ for (const subscriber of this.subscribers) subscriber();
+ }
+}
diff --git a/apps/obsidian/src/utils/wikilinkDragHandler.ts b/apps/obsidian/src/utils/wikilinkDragHandler.ts
index 12872979f..a1cde6245 100644
--- a/apps/obsidian/src/utils/wikilinkDragHandler.ts
+++ b/apps/obsidian/src/utils/wikilinkDragHandler.ts
@@ -10,6 +10,7 @@ import {
import { TFile, WorkspaceLeaf } from "obsidian";
import { VIEW_TYPE_TLDRAW_DG_PREVIEW } from "~/constants";
import type DiscourseGraphPlugin from "~/index";
+import { extractLinktext, INTERNAL_LINK_RE } from "./internalLinkParsing";
const buildObsidianUrl = (vaultName: string, filePath: string): string => {
return `obsidian://open?vault=${encodeURIComponent(vaultName)}&file=${encodeURIComponent(filePath)}`;
@@ -42,29 +43,6 @@ const setDragData = (
// --- Live Preview ---
-/**
- * Extract the file path from a link match.
- * Handles wikilinks (`[[path]]`, `[[path|alias]]`) and
- * markdown links (`[text](path.md)`), decoding URL-encoded paths.
- */
-const extractLinkPath = (match: string): string => {
- // Wikilink: [[path]] or [[path|alias]]
- if (match.startsWith("[[")) {
- const inner = match.slice(2, -2);
- const pipeIndex = inner.indexOf("|");
- return pipeIndex >= 0 ? inner.slice(0, pipeIndex) : inner;
- }
-
- // Markdown link: [text](path)
- const parenOpen = match.lastIndexOf("(");
- const rawPath = match.slice(parenOpen + 1, -1);
- try {
- return decodeURIComponent(rawPath);
- } catch (error) {
- return rawPath;
- }
-};
-
/**
* Widget that renders a small drag handle next to an internal link.
* CM6 widgets get `ignoreEvent() → true` by default, which means
@@ -103,10 +81,6 @@ class WikilinkDragHandleWidget extends WidgetType {
}
}
-// Matches wikilinks [[...]] and markdown links [text](path.md).
-// Embed exclusion (![[...]] and ) is handled in the loop.
-const INTERNAL_LINK_RE = /\[\[([^\]]+)\]\]|\[([^\]]+)\]\(([^)]+\.md)\)/g;
-
const hasVisibleCanvasLeaf = (plugin: DiscourseGraphPlugin): boolean =>
plugin.app.workspace
.getLeavesOfType(VIEW_TYPE_TLDRAW_DG_PREVIEW)
@@ -133,7 +107,7 @@ const buildWidgetDecorations = (
view.state.doc.sliceString(checkPos, checkPos + 1) === "!";
if (isEmbed) continue;
const matchEnd = from + match.index + match[0].length;
- const linkPath = extractLinkPath(match[0]);
+ const linkPath = extractLinktext(match[0]);
const widget = new WikilinkDragHandleWidget(linkPath, plugin);
widgets.push(Decoration.widget({ widget, side: 1 }).range(matchEnd));
}
diff --git a/apps/website/content/obsidian/configuration/general-settings.md b/apps/website/content/obsidian/configuration/general-settings.md
index a7f2b13e7..1fcb6e2e2 100644
--- a/apps/website/content/obsidian/configuration/general-settings.md
+++ b/apps/website/content/obsidian/configuration/general-settings.md
@@ -15,6 +15,16 @@ This setting controls the visibility of identifiers in your note's frontmatter s
- When disabled, these IDs will be hidden from view
- This can be useful if you prefer a cleaner frontmatter appearance while still maintaining the underlying structure
+## Show discourse context overlay
+
+This setting controls whether links to discourse nodes carry an inline badge showing how many relations the linked node has.
+
+- When enabled, a badge appears after each link to a discourse node, in both Live Preview and Reading view
+- Selecting a badge opens that node's discourse context in a popover, where you can review its relationships and add a new one
+- A node with no relations shows a badge reading `0`, and its popover says "No discourse relation found"
+- Links to notes that are not discourse nodes never show a badge
+- When disabled, the badges are removed immediately; the [discourse context view](/docs/obsidian/core-features/discourse-context) remains available from the sidebar
+
## Discourse nodes folder path
This setting determines where new discourse nodes will be created in your vault.
diff --git a/apps/website/content/obsidian/core-features/discourse-context.md b/apps/website/content/obsidian/core-features/discourse-context.md
index 6251122c2..665d1ba4c 100644
--- a/apps/website/content/obsidian/core-features/discourse-context.md
+++ b/apps/website/content/obsidian/core-features/discourse-context.md
@@ -26,6 +26,12 @@ You can configure a custom hotkey in the Obsidian settings to quickly toggle the
3. Configure a custom hotkey in settings
+### Method 4: Using the discourse context overlay
+
+Links to a discourse node show a small badge with the number of relations that node has. Select the badge to open its discourse context in place, without leaving the note you are reading.
+
+The badge appears in both Live Preview and Reading view, on every link to a discourse node. A node with no relations yet shows a badge reading `0`, and opening it says "No discourse relation found" alongside the option to add one. You can turn the badge off in [General settings](/docs/obsidian/configuration/general-settings).
+
## Using the discourse context
The discourse context view shows you:
@@ -41,3 +47,5 @@ You can use this view to:
- Understand how nodes connect to each other
- Add new relationships
- Get a quick overview of your graph structure
+
+The overlay badge opens the same relationships in a popover, so it shows exactly what the sidebar view would show for that node. Relations that are still waiting to be accepted after an import are not counted in the badge; open the discourse context view to review those.