From c719fae8d6232bca950edfec9c68607b4894e12f Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 12 Aug 2026 14:41:41 -0400 Subject: [PATCH 1/6] ENG-2113 Add platform-aware keyboard hint symbols Obsidian renders modifiers as glyphs on macOS and as words on Windows and Linux. Roam's search footer hardcoded the macOS glyphs at each call site and showed the wrong hint on Windows (ENG-2000); routing every hint through one map is what keeps that from repeating. `formatHintKeys` takes `isMacOS` so the non-mac branch can be exercised without that platform. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/utils/keyboardHints.ts | 36 ++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 apps/obsidian/src/utils/keyboardHints.ts diff --git a/apps/obsidian/src/utils/keyboardHints.ts b/apps/obsidian/src/utils/keyboardHints.ts new file mode 100644 index 000000000..b145a1c67 --- /dev/null +++ b/apps/obsidian/src/utils/keyboardHints.ts @@ -0,0 +1,36 @@ +import { Platform } from "obsidian"; + +export type HintKey = "Mod" | "Alt" | "Shift" | "Enter" | "Escape"; + +// Obsidian shows glyphs on macOS and spelled-out words everywhere else, so the +// same shortcut has to render two ways. Roam's search footer hardcoded the macOS +// glyphs per action (ENG-2000) and showed the wrong hint on Windows; routing +// every hint through these maps is what keeps that from repeating here. +const MAC_SYMBOLS: Record = { + Mod: "⌘", + Alt: "⌥", + Shift: "⇧", + Enter: "↵", + Escape: "esc", +}; + +const NON_MAC_SYMBOLS: Record = { + Mod: "Ctrl", + Alt: "Alt", + Shift: "Shift", + Enter: "Enter", + Escape: "Esc", +}; + +/** Takes `isMacOS` so the non-mac branch can be checked without that platform. */ +export const formatHintKeys = ({ + keys, + isMacOS, +}: { + keys: HintKey[]; + isMacOS: boolean; +}): string[] => + keys.map((key) => (isMacOS ? MAC_SYMBOLS : NON_MAC_SYMBOLS)[key]); + +export const getHintKeys = (keys: HintKey[]): string[] => + formatHintKeys({ keys, isMacOS: Platform.isMacOS }); From d9f017e17308303d813ae7c31135a51abd35cf5f Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 12 Aug 2026 14:42:03 -0400 Subject: [PATCH 2/6] ENG-2113 Add footer action bar with open in active pane and split Enter opens the active result in the current pane, Shift+Enter in a split, and both close the modal. Mod+Enter and Alt+Enter deliberately fall through, so the insert action (ENG-2114) can claim Mod+Enter as it does in Roam. The footer reuses Obsidian's own `prompt-instruction` markup, the classes `SuggestModal.setInstructions()` emits, so it matches the native quick switcher. This modal extends plain `Modal`, so that API is unavailable. Its actions are left-aligned rather than centred because they sit under a full-width result list. The Enter branch lives in the existing wrapper `onKeyDown`, which ENG-2109 moved off the input so result actions would have one place to live. Co-Authored-By: Claude Opus 5 --- .../src/components/NodeSearchFooter.tsx | 74 +++++++++++++++++++ .../src/components/NodeSearchModal.tsx | 45 ++++++++++- .../components/canvas/utils/openFileUtils.ts | 10 +++ apps/obsidian/src/styles/style.css | 36 +++++++++ 4 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 apps/obsidian/src/components/NodeSearchFooter.tsx diff --git a/apps/obsidian/src/components/NodeSearchFooter.tsx b/apps/obsidian/src/components/NodeSearchFooter.tsx new file mode 100644 index 000000000..cbb89a457 --- /dev/null +++ b/apps/obsidian/src/components/NodeSearchFooter.tsx @@ -0,0 +1,74 @@ +import { type ReactElement } from "react"; +import { getHintKeys, type HintKey } from "~/utils/keyboardHints"; + +type NodeSearchFooterProps = { + canAct: boolean; + onOpenInActivePane: () => void; + onOpenInSplit: () => void; +}; + +type FooterActionProps = { + disabled: boolean; + keys: HintKey[]; + label: string; + onClick: () => void; +}; + +const KeyHints = ({ keys }: { keys: HintKey[] }): ReactElement => ( + <> + {getHintKeys(keys).map((symbol) => ( + + {symbol} + + ))} + +); + +const FooterAction = ({ + disabled, + keys, + label, + onClick, +}: FooterActionProps): ReactElement => ( + +); + +// Reuses Obsidian's own `prompt-instruction` markup, the same classes +// `SuggestModal.setInstructions()` emits, so the footer matches the native +// quick switcher. This modal extends plain `Modal`, so that API is unavailable. +export const NodeSearchFooter = ({ + canAct, + onOpenInActivePane, + onOpenInSplit, +}: NodeSearchFooterProps): ReactElement => ( +
+ + + {/* Escape is handled by Obsidian's modal scope, so this is a hint only. */} + + + close + +
+); diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx index 4735ea142..5dcfd4276 100644 --- a/apps/obsidian/src/components/NodeSearchModal.tsx +++ b/apps/obsidian/src/components/NodeSearchModal.tsx @@ -19,6 +19,11 @@ import { } from "react"; import { createRoot, Root } from "react-dom/client"; import type DiscourseGraphPlugin from "~/index"; +import { NodeSearchFooter } from "~/components/NodeSearchFooter"; +import { + openFileInActivePane, + openFileInNewLeaf, +} from "~/components/canvas/utils/openFileUtils"; import { QueryEngine, rankDiscourseNodesByTitle, @@ -299,8 +304,10 @@ const ResultList = ({ const NodeSearch = ({ plugin, + onClose, }: { plugin: DiscourseGraphPlugin; + onClose: () => void; }): ReactElement => { const { app } = plugin; const [candidateState, setCandidateState] = useState({ @@ -395,11 +402,36 @@ const NodeSearch = ({ }); }; + // Closes before opening: `close()` unmounts this React root, so the file and + // app are read first and nothing touches state afterwards. + const openActiveResult = ( + open: (app: App, file: TFile) => Promise, + ): void => { + if (!activeResult) return; + const { file } = activeResult; + onClose(); + void open(app, file).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + new Notice(`Could not open ${file.basename}: ${message}`); + }); + }; + const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; - // Otherwise the caret jumps to the start or end of the query. + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + // Otherwise the caret jumps to the start or end of the query. + event.preventDefault(); + moveActiveIndex(event.key === "ArrowDown" ? 1 : -1); + return; + } + + 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) return; + event.preventDefault(); - moveActiveIndex(event.key === "ArrowDown" ? 1 : -1); + openActiveResult(event.shiftKey ? openFileInNewLeaf : openFileInActivePane); }; return ( @@ -437,6 +469,11 @@ const NodeSearch = ({ + openActiveResult(openFileInActivePane)} + onOpenInSplit={() => openActiveResult(openFileInNewLeaf)} + /> ); }; @@ -457,7 +494,7 @@ export class NodeSearchModal extends Modal { this.root = createRoot(contentEl); this.root.render( - + this.close()} /> , ); } diff --git a/apps/obsidian/src/components/canvas/utils/openFileUtils.ts b/apps/obsidian/src/components/canvas/utils/openFileUtils.ts index 4dbe52180..c67528c3a 100644 --- a/apps/obsidian/src/components/canvas/utils/openFileUtils.ts +++ b/apps/obsidian/src/components/canvas/utils/openFileUtils.ts @@ -102,3 +102,13 @@ export const openFileInNewLeaf = async ( await leaf.openFile(file); app.workspace.setActiveLeaf(leaf); }; + +/** `getLeaf(false)` reuses the active leaf rather than creating one. */ +export const openFileInActivePane = async ( + app: App, + file: TFile, +): Promise => { + const leaf = app.workspace.getLeaf(false); + await leaf.openFile(file); + app.workspace.setActiveLeaf(leaf); +}; diff --git a/apps/obsidian/src/styles/style.css b/apps/obsidian/src/styles/style.css index 63949fec5..d55e965d8 100644 --- a/apps/obsidian/src/styles/style.css +++ b/apps/obsidian/src/styles/style.css @@ -3921,3 +3921,39 @@ kbd.tlui-kbd { border-radius: var(--radius-s); padding: 0 1px; } + +/* Obsidian's own `.prompt-instructions` rules supply the type and colour. They + centre the row for the quick switcher, which is too narrow to need alignment; + this footer sits under a full-width result list, so the actions line up with + its left edge instead. `flex-shrink` keeps the footer from collapsing inside + the fixed-height flex column above it. */ +.dg-node-search-modal .dg-search-footer { + flex-shrink: 0; + justify-content: flex-start; + text-align: left; + padding-inline: 0; + padding-bottom: 0; +} + +/* Obsidian styles every `button` with its own chrome, so an action that should + read as a shortcut hint has to opt out of it. */ +.dg-node-search-modal .dg-search-footer-action { + background-color: transparent; + border: none; + border-radius: 0; + box-shadow: none; + padding: 0; + height: auto; + font-size: inherit; + color: inherit; + cursor: pointer; +} + +.dg-node-search-modal .dg-search-footer-action:hover:not(:disabled) { + color: var(--text-normal); +} + +.dg-node-search-modal .dg-search-footer-action:disabled { + cursor: not-allowed; + opacity: 0.5; +} From f79c3c1a85ab8096639625b3b2390db8fde4d22a Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 12 Aug 2026 14:52:28 -0400 Subject: [PATCH 3/6] ENG-2113 Open the active result in a new tab rather than the current one Replacing the page the user was already reading loses their place, which is the opposite of what a lookup surface should do. `getLeaf("tab")` adds a tab to the main panel instead, so the previous note stays open behind it. This reuses the existing `openFileInNewTab`, so the `openFileInActivePane` helper added earlier in this branch is no longer needed. The label now reads "open in new tab" to match. Diverges from the ticket's stated Solution, which specified `getLeaf(false)`. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/components/NodeSearchFooter.tsx | 8 ++++---- apps/obsidian/src/components/NodeSearchModal.tsx | 6 +++--- .../src/components/canvas/utils/openFileUtils.ts | 10 ---------- 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/apps/obsidian/src/components/NodeSearchFooter.tsx b/apps/obsidian/src/components/NodeSearchFooter.tsx index cbb89a457..7d992cde3 100644 --- a/apps/obsidian/src/components/NodeSearchFooter.tsx +++ b/apps/obsidian/src/components/NodeSearchFooter.tsx @@ -3,7 +3,7 @@ import { getHintKeys, type HintKey } from "~/utils/keyboardHints"; type NodeSearchFooterProps = { canAct: boolean; - onOpenInActivePane: () => void; + onOpenInNewTab: () => void; onOpenInSplit: () => void; }; @@ -49,15 +49,15 @@ const FooterAction = ({ // quick switcher. This modal extends plain `Modal`, so that API is unavailable. export const NodeSearchFooter = ({ canAct, - onOpenInActivePane, + onOpenInNewTab, onOpenInSplit, }: NodeSearchFooterProps): ReactElement => (
openActiveResult(openFileInActivePane)} + onOpenInNewTab={() => openActiveResult(openFileInNewTab)} onOpenInSplit={() => openActiveResult(openFileInNewLeaf)} />
diff --git a/apps/obsidian/src/components/canvas/utils/openFileUtils.ts b/apps/obsidian/src/components/canvas/utils/openFileUtils.ts index c67528c3a..4dbe52180 100644 --- a/apps/obsidian/src/components/canvas/utils/openFileUtils.ts +++ b/apps/obsidian/src/components/canvas/utils/openFileUtils.ts @@ -102,13 +102,3 @@ export const openFileInNewLeaf = async ( await leaf.openFile(file); app.workspace.setActiveLeaf(leaf); }; - -/** `getLeaf(false)` reuses the active leaf rather than creating one. */ -export const openFileInActivePane = async ( - app: App, - file: TFile, -): Promise => { - const leaf = app.workspace.getLeaf(false); - await leaf.openFile(file); - app.workspace.setActiveLeaf(leaf); -}; From e54e2f1cd37c0777f873acf9513d8f09833b621f Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 12 Aug 2026 16:43:43 -0400 Subject: [PATCH 4/6] ENG-2113 Style footer keys as caps, matching Roam Obsidian's `prompt-instruction-command` is bold with no border, which made the lone `esc` hint read as emphasis rather than as a key. Roam's search footer draws every key as a bordered cap instead, so `esc` sits with the rest of the set. Keeps the `prompt-instructions` container for its native type and spacing, and takes the cap's border, radius, and background from Obsidian's CSS variables so it still follows the active theme. Co-Authored-By: Claude Opus 5 --- .../src/components/NodeSearchFooter.tsx | 18 ++++++----- apps/obsidian/src/styles/style.css | 31 ++++++++++++++++++- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/apps/obsidian/src/components/NodeSearchFooter.tsx b/apps/obsidian/src/components/NodeSearchFooter.tsx index 7d992cde3..12b25d13a 100644 --- a/apps/obsidian/src/components/NodeSearchFooter.tsx +++ b/apps/obsidian/src/components/NodeSearchFooter.tsx @@ -14,12 +14,15 @@ type FooterActionProps = { onClick: () => void; }; +// Roam's footer renders each key as a bordered cap rather than bold text, which +// keeps `esc` reading as one of the set instead of standing out. Deliberately +// not `prompt-instruction-command`, whose weight is what makes it stand out. const KeyHints = ({ keys }: { keys: HintKey[] }): ReactElement => ( <> {getHintKeys(keys).map((symbol) => ( - + {symbol} - + ))} ); @@ -40,13 +43,12 @@ const FooterAction = ({ onMouseDown={(event) => event.preventDefault()} > - {label} + {label} ); -// Reuses Obsidian's own `prompt-instruction` markup, the same classes -// `SuggestModal.setInstructions()` emits, so the footer matches the native -// quick switcher. This modal extends plain `Modal`, so that API is unavailable. +// Sits in Obsidian's `prompt-instructions` container for its type and spacing, +// but styles the keys as Roam's search footer does. export const NodeSearchFooter = ({ canAct, onOpenInNewTab, @@ -66,9 +68,9 @@ export const NodeSearchFooter = ({ onClick={onOpenInSplit} /> {/* Escape is handled by Obsidian's modal scope, so this is a hint only. */} - + - close + close ); diff --git a/apps/obsidian/src/styles/style.css b/apps/obsidian/src/styles/style.css index d55e965d8..9fcc9c72a 100644 --- a/apps/obsidian/src/styles/style.css +++ b/apps/obsidian/src/styles/style.css @@ -3937,7 +3937,11 @@ kbd.tlui-kbd { /* Obsidian styles every `button` with its own chrome, so an action that should read as a shortcut hint has to opt out of it. */ -.dg-node-search-modal .dg-search-footer-action { +.dg-node-search-modal .dg-search-footer-action, +.dg-node-search-modal .dg-search-footer-hint { + display: inline-flex; + align-items: center; + gap: var(--size-2-1); background-color: transparent; border: none; border-radius: 0; @@ -3946,9 +3950,34 @@ kbd.tlui-kbd { height: auto; font-size: inherit; color: inherit; +} + +.dg-node-search-modal .dg-search-footer-action { cursor: pointer; } +.dg-node-search-modal .dg-search-footer-label { + margin-inline-start: var(--size-2-1); +} + +/* Matches Roam's search footer: each key is a bordered cap, so `esc` reads as + one of the set rather than as emphasised text. */ +.dg-node-search-modal .dg-search-footer-key { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.5em; + padding: 0 var(--size-2-1); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background-color: var(--background-primary); + color: var(--text-muted); + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: 1.6; +} + .dg-node-search-modal .dg-search-footer-action:hover:not(:disabled) { color: var(--text-normal); } From 74a642b65128636aa38afd9cb540f0970771599f Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 12 Aug 2026 17:05:09 -0400 Subject: [PATCH 5/6] ENG-2113 Drop the results list tooltip Obsidian renders `aria-label` as a hover tooltip, so labelling the listbox meant a tooltip covered the results as soon as the pointer entered the list. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/components/NodeSearchModal.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx index 53516508d..b5e06ad82 100644 --- a/apps/obsidian/src/components/NodeSearchModal.tsx +++ b/apps/obsidian/src/components/NodeSearchModal.tsx @@ -261,10 +261,11 @@ const ResultList = ({ }, [activeIndex]); return ( + // No `aria-label` here: Obsidian renders one as a hover tooltip, which + // covers the results the moment the pointer enters the list.
(pointerMovedRef.current = true)} className="flex-1 overflow-y-auto" > From 005b5a2036bd206b2cdc627083a13ea97d0e3c28 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 12 Aug 2026 17:08:51 -0400 Subject: [PATCH 6/6] ENG-2113 Make close clickable and drop the duplicate badge tooltip The close hint was the only footer item that ignored a click, which read as broken next to two working actions. It now goes through the same `FooterAction` as the others and calls the modal's own close. The badge carried both `title` and `aria-label` with the same text, so hovering one stacked a native tooltip on top of Obsidian's. Keeping `aria-label`, since Obsidian's is the themed one. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/components/NodeSearchFooter.tsx | 14 +++++++------- apps/obsidian/src/components/NodeSearchModal.tsx | 2 +- apps/obsidian/src/styles/style.css | 6 +----- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/apps/obsidian/src/components/NodeSearchFooter.tsx b/apps/obsidian/src/components/NodeSearchFooter.tsx index 12b25d13a..74edf8d63 100644 --- a/apps/obsidian/src/components/NodeSearchFooter.tsx +++ b/apps/obsidian/src/components/NodeSearchFooter.tsx @@ -3,12 +3,13 @@ import { getHintKeys, type HintKey } from "~/utils/keyboardHints"; type NodeSearchFooterProps = { canAct: boolean; + onClose: () => void; onOpenInNewTab: () => void; onOpenInSplit: () => void; }; type FooterActionProps = { - disabled: boolean; + disabled?: boolean; keys: HintKey[]; label: string; onClick: () => void; @@ -28,7 +29,7 @@ const KeyHints = ({ keys }: { keys: HintKey[] }): ReactElement => ( ); const FooterAction = ({ - disabled, + disabled = false, keys, label, onClick, @@ -51,6 +52,7 @@ const FooterAction = ({ // but styles the keys as Roam's search footer does. export const NodeSearchFooter = ({ canAct, + onClose, onOpenInNewTab, onOpenInSplit, }: NodeSearchFooterProps): ReactElement => ( @@ -67,10 +69,8 @@ export const NodeSearchFooter = ({ label="open in split" onClick={onOpenInSplit} /> - {/* Escape is handled by Obsidian's modal scope, so this is a hint only. */} - - - close - + {/* The Escape key itself is handled by Obsidian's modal scope; this button + is the pointer equivalent, so every footer item responds to a click. */} +
); diff --git a/apps/obsidian/src/components/NodeSearchModal.tsx b/apps/obsidian/src/components/NodeSearchModal.tsx index b5e06ad82..ea1d61168 100644 --- a/apps/obsidian/src/components/NodeSearchModal.tsx +++ b/apps/obsidian/src/components/NodeSearchModal.tsx @@ -285,7 +285,6 @@ const ResultList = ({ > {result.nodeType.badge && ( openActiveResult(openFileInNewTab)} onOpenInSplit={() => openActiveResult(openFileInNewLeaf)} /> diff --git a/apps/obsidian/src/styles/style.css b/apps/obsidian/src/styles/style.css index 9fcc9c72a..af23fd927 100644 --- a/apps/obsidian/src/styles/style.css +++ b/apps/obsidian/src/styles/style.css @@ -3937,8 +3937,7 @@ kbd.tlui-kbd { /* Obsidian styles every `button` with its own chrome, so an action that should read as a shortcut hint has to opt out of it. */ -.dg-node-search-modal .dg-search-footer-action, -.dg-node-search-modal .dg-search-footer-hint { +.dg-node-search-modal .dg-search-footer-action { display: inline-flex; align-items: center; gap: var(--size-2-1); @@ -3950,9 +3949,6 @@ kbd.tlui-kbd { height: auto; font-size: inherit; color: inherit; -} - -.dg-node-search-modal .dg-search-footer-action { cursor: pointer; }