diff --git a/apps/obsidian/src/components/NodeSearchFooter.tsx b/apps/obsidian/src/components/NodeSearchFooter.tsx
new file mode 100644
index 000000000..74edf8d63
--- /dev/null
+++ b/apps/obsidian/src/components/NodeSearchFooter.tsx
@@ -0,0 +1,76 @@
+import { type ReactElement } from "react";
+import { getHintKeys, type HintKey } from "~/utils/keyboardHints";
+
+type NodeSearchFooterProps = {
+ canAct: boolean;
+ onClose: () => void;
+ onOpenInNewTab: () => void;
+ onOpenInSplit: () => void;
+};
+
+type FooterActionProps = {
+ disabled?: boolean;
+ keys: HintKey[];
+ label: string;
+ 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}
+
+ ))}
+ >
+);
+
+const FooterAction = ({
+ disabled = false,
+ keys,
+ label,
+ onClick,
+}: FooterActionProps): ReactElement => (
+
+);
+
+// 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,
+ onClose,
+ onOpenInNewTab,
+ onOpenInSplit,
+}: NodeSearchFooterProps): ReactElement => (
+
+
+
+ {/* 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 4735ea142..ea1d61168 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 {
+ openFileInNewLeaf,
+ openFileInNewTab,
+} from "~/components/canvas/utils/openFileUtils";
import {
QueryEngine,
rankDiscourseNodesByTitle,
@@ -256,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"
>
@@ -279,7 +285,6 @@ const ResultList = ({
>
{result.nodeType.badge && (
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 : openFileInNewTab);
};
return (
@@ -437,6 +469,12 @@ const NodeSearch = ({
+ openActiveResult(openFileInNewTab)}
+ onOpenInSplit={() => openActiveResult(openFileInNewLeaf)}
+ />
);
};
@@ -457,7 +495,7 @@ export class NodeSearchModal extends Modal {
this.root = createRoot(contentEl);
this.root.render(
-
+ this.close()} />
,
);
}
diff --git a/apps/obsidian/src/styles/style.css b/apps/obsidian/src/styles/style.css
index 63949fec5..af23fd927 100644
--- a/apps/obsidian/src/styles/style.css
+++ b/apps/obsidian/src/styles/style.css
@@ -3921,3 +3921,64 @@ 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 {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--size-2-1);
+ 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-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);
+}
+
+.dg-node-search-modal .dg-search-footer-action:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
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 });