From b9dd5509caefb69aecc5a105feee02b9ad0a83b3 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sun, 23 Aug 2026 15:17:41 -0400 Subject: [PATCH 1/2] ENG-2114 Insert active result as a link at cursor Mod+Enter on the active result, and a matching footer action, insert a link to it at the cursor the editor held before the modal opened. The cursor is snapshotted in the modal constructor, before open() takes focus. The link comes from generateMarkdownLink so the vault's link format settings are honoured, and a selection is replaced rather than left beside the link. Co-Authored-By: Claude Opus 5 --- .../src/components/NodeSearchFooter.tsx | 13 +++++ .../src/components/NodeSearchModal.tsx | 38 +++++++++++++- apps/obsidian/src/utils/editorInsertTarget.ts | 50 +++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 apps/obsidian/src/utils/editorInsertTarget.ts diff --git a/apps/obsidian/src/components/NodeSearchFooter.tsx b/apps/obsidian/src/components/NodeSearchFooter.tsx index 2778c840f..176aa1edd 100644 --- a/apps/obsidian/src/components/NodeSearchFooter.tsx +++ b/apps/obsidian/src/components/NodeSearchFooter.tsx @@ -3,7 +3,9 @@ import { getHintKeys, type HintKey } from "~/utils/keyboardHints"; type NodeSearchFooterProps = { canAct: boolean; + canInsertLink: boolean; onClose: () => void; + onInsertLink: () => void; onOpenInNewTab: () => void; onOpenInSplit: () => void; }; @@ -50,11 +52,22 @@ const FooterAction = ({ // full-width result list, so the actions start at its left edge instead. export const NodeSearchFooter = ({ canAct, + canInsertLink, onClose, + onInsertLink, onOpenInNewTab, onOpenInSplit, }: NodeSearchFooterProps): ReactElement => (
+ {/* Absent, not disabled: with no cursor there is nothing to insert into. */} + {canInsertLink && ( + + )} void; }): ReactElement => { const { app } = plugin; @@ -440,6 +447,19 @@ const NodeSearch = ({ if (!nextOpen) inputRef.current?.focus(); }; + // Closes before inserting, like `openActiveResult`. + const insertLinkToActiveResult = (): void => { + if (!activeResult || !insertTarget) return; + const { file } = activeResult; + onClose(); + try { + insertLinkAtInsertTarget({ app, file, target: insertTarget }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + new Notice(`Could not insert a link to ${file.basename}: ${message}`); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "ArrowDown" || event.key === "ArrowUp") { // Otherwise the caret jumps to the start or end of the query. @@ -451,7 +471,12 @@ const NodeSearch = ({ if (event.key !== "Enter") return; // Enter also commits an IME candidate, which must not open a file. if (event.nativeEvent.isComposing) return; - // Mod+Enter and Alt+Enter are left alone for the insert and dock actions. + if ((event.metaKey || event.ctrlKey) && !event.altKey && insertTarget) { + event.preventDefault(); + insertLinkToActiveResult(); + return; + } + // Alt+Enter is left alone for the dock action. if (event.metaKey || event.ctrlKey || event.altKey) return; // A footer button reached by Tab runs its own action on Enter. Preventing the // default here would suppress that click and open a new tab instead. @@ -516,7 +541,9 @@ const NodeSearch = ({
openActiveResult(openFileInNewTab)} onOpenInSplit={() => openActiveResult(openFileInNewLeaf)} /> @@ -527,10 +554,13 @@ const NodeSearch = ({ export class NodeSearchModal extends Modal { private plugin: DiscourseGraphPlugin; private root: Root | null = null; + /** Snapshotted in the constructor: `open()` has not taken focus yet. */ + private insertTarget: EditorInsertTarget | null; constructor(app: App, plugin: DiscourseGraphPlugin) { super(app); this.plugin = plugin; + this.insertTarget = snapshotInsertTarget(app); } onOpen() { @@ -549,7 +579,11 @@ export class NodeSearchModal extends Modal { this.root = createRoot(contentEl); this.root.render( - this.close()} /> + this.close()} + /> , ); } diff --git a/apps/obsidian/src/utils/editorInsertTarget.ts b/apps/obsidian/src/utils/editorInsertTarget.ts new file mode 100644 index 000000000..bc386dcd3 --- /dev/null +++ b/apps/obsidian/src/utils/editorInsertTarget.ts @@ -0,0 +1,50 @@ +import { App, MarkdownView, TFile, type EditorPosition } from "obsidian"; + +/** Held rather than re-looked-up, so the link lands in the pre-open note. */ +export type EditorInsertTarget = { + view: MarkdownView; + from: EditorPosition; + to: EditorPosition; +}; + +/** + * Call before the modal mounts, while the editor still owns the cursor. + * `hasFocus()` is not part of the gate: opening the search from the command + * palette means that palette already took focus. + */ +export const snapshotInsertTarget = (app: App): EditorInsertTarget | null => { + const view = app.workspace.getActiveViewOfType(MarkdownView); + // Reading mode has no cursor. + if (!view || !view.file || view.getMode() !== "source") return null; + + const { editor } = view; + return { + view, + // A selection is replaced rather than left beside the link. + from: editor.getCursor("from"), + to: editor.getCursor("to"), + }; +}; + +/** `generateMarkdownLink` is what honours the vault's link-format settings. */ +export const insertLinkAtInsertTarget = ({ + app, + file, + target, +}: { + app: App; + file: TFile; + target: EditorInsertTarget; +}): void => { + const { view, from, to } = target; + const sourceFile = view.file; + if (!sourceFile) return; + + const link = app.fileManager.generateMarkdownLink(file, sourceFile.path); + const { editor } = view; + editor.replaceRange(link, from, to); + + app.workspace.setActiveLeaf(view.leaf, { focus: true }); + editor.setCursor({ line: from.line, ch: from.ch + link.length }); + editor.focus(); +}; From 6db2163e068bc17abeaffa69008cacae20b0c4ae Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sun, 23 Aug 2026 17:33:39 -0400 Subject: [PATCH 2/2] ENG-2114 Gate the insert chord on an active result Mod+Enter no longer claims the event while the results are still loading, so the chord matches the footer button's disabled state instead of preventing the default and doing nothing. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/components/NodeSearchModal.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx index 6479b06a9..7ef5bbadd 100644 --- a/apps/obsidian/src/components/NodeSearchModal.tsx +++ b/apps/obsidian/src/components/NodeSearchModal.tsx @@ -471,7 +471,14 @@ const NodeSearch = ({ if (event.key !== "Enter") return; // Enter also commits an IME candidate, which must not open a file. if (event.nativeEvent.isComposing) return; - if ((event.metaKey || event.ctrlKey) && !event.altKey && insertTarget) { + // activeResult is part of the gate so the chord is not claimed while the + // results are still loading, matching the footer button's disabled state. + if ( + (event.metaKey || event.ctrlKey) && + !event.altKey && + insertTarget && + activeResult + ) { event.preventDefault(); insertLinkToActiveResult(); return;