diff --git a/apps/obsidian/src/components/NodeSearchFooter.tsx b/apps/obsidian/src/components/NodeSearchFooter.tsx
new file mode 100644
index 000000000..2778c840f
--- /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;
+ onClose: () => void;
+ onOpenInNewTab: () => 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 = false,
+ keys,
+ label,
+ onClick,
+}: FooterActionProps): ReactElement => (
+
+);
+
+// Sits in Obsidian's `prompt-instructions` container for its type and spacing.
+// Obsidian centres that row for the narrow quick switcher; this footer spans a
+// full-width result list, so the actions start at its left edge instead.
+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..6bbf4dfa4 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,44 @@ 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;
+ // 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.
+ if (
+ event.target instanceof HTMLElement &&
+ event.target.closest("button") !== null
+ ) {
+ return;
+ }
+
event.preventDefault();
- moveActiveIndex(event.key === "ArrowDown" ? 1 : -1);
+ openActiveResult(event.shiftKey ? openFileInNewLeaf : openFileInNewTab);
};
return (
@@ -437,6 +477,12 @@ const NodeSearch = ({
+ openActiveResult(openFileInNewTab)}
+ onOpenInSplit={() => openActiveResult(openFileInNewLeaf)}
+ />
);
};
@@ -457,7 +503,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..f5be6226e 100644
--- a/apps/obsidian/src/styles/style.css
+++ b/apps/obsidian/src/styles/style.css
@@ -3921,3 +3921,45 @@ kbd.tlui-kbd {
border-radius: var(--radius-s);
padding: 0 1px;
}
+
+/* Only the properties Tailwind utilities cannot win here; the rest of this
+ footer's layout is utilities on the elements themselves. Obsidian sets
+ `color`, `background-color`, and `box-shadow` in `button:not(.clickable-icon)`
+ and `button:hover` — both (0,1,1), which outrank a single utility class — so a
+ utility would leave the label in `--text-normal` on an interactive-grey pill.
+ `font-size` has no inherit utility, and without it the button takes
+ `--font-ui-small` rather than the smaller type of the row it sits in. */
+.dg-node-search-modal .dg-search-footer-action,
+.dg-node-search-modal .dg-search-footer-action:hover {
+ color: inherit;
+ background-color: transparent;
+ box-shadow: none;
+ font-size: inherit;
+}
+
+/* Each key is a bordered cap, so `esc` reads as one of the set rather than as
+ emphasised text. Kept in CSS for the inherited font and em-based sizing. */
+.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..a14586ab3
--- /dev/null
+++ b/apps/obsidian/src/utils/keyboardHints.ts
@@ -0,0 +1,33 @@
+import { Platform } from "obsidian";
+
+export type HintKey = "Mod" | "Alt" | "Shift" | "Enter" | "Escape";
+
+// Obsidian shows glyphs on macOS and spelled-out words everywhere else.
+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 });