@@ -542,6 +614,7 @@ const NodeSearch = ({
openActiveResult(openFileInNewTab)}
diff --git a/apps/obsidian/src/components/NodeSortMenu.tsx b/apps/obsidian/src/components/NodeSortMenu.tsx
index ade1326ec5..6840d56f9b 100644
--- a/apps/obsidian/src/components/NodeSortMenu.tsx
+++ b/apps/obsidian/src/components/NodeSortMenu.tsx
@@ -1,6 +1,7 @@
import { App, setIcon } from "obsidian";
-import type { KeyboardEvent, ReactElement } from "react";
+import type { ReactElement } from "react";
import { SearchDropdown } from "~/components/SearchDropdown";
+import { activateOnKey } from "~/utils/keyboardHints";
import {
SORT_OPTIONS,
getDefaultDirectionForKey,
@@ -16,16 +17,6 @@ const DIRECTIONS: { direction: SortDirection; label: string }[] = [
{ direction: "desc", label: "Desc" },
];
-// Rows are divs, so Enter and Space have to be wired up the way a button gets them free.
-const activateOnKey = (
- event: KeyboardEvent,
- activate: () => void,
-): void => {
- if (event.key !== "Enter" && event.key !== " ") return;
- event.preventDefault();
- activate();
-};
-
const getDirectionIconName = (direction: SortDirection): string =>
direction === "asc" ? "arrow-up-narrow-wide" : "arrow-down-wide-narrow";
diff --git a/apps/obsidian/src/components/SearchDropdown.tsx b/apps/obsidian/src/components/SearchDropdown.tsx
index d4030164e1..c121961529 100644
--- a/apps/obsidian/src/components/SearchDropdown.tsx
+++ b/apps/obsidian/src/components/SearchDropdown.tsx
@@ -2,7 +2,11 @@ import { App, Scope, setIcon } from "obsidian";
import { useEffect, useRef, type ReactElement, type ReactNode } from "react";
/** Which toolbar panel is open, so two can never be open at once. */
-export type SearchDropdownId = "type-filter" | "sort" | null;
+export type SearchDropdownId =
+ | "type-filter"
+ | "sort"
+ | "display-options"
+ | null;
export const SearchDropdown = ({
app,
diff --git a/apps/obsidian/src/components/canvas/utils/openFileUtils.ts b/apps/obsidian/src/components/canvas/utils/openFileUtils.ts
index 4dbe521800..f754f78d07 100644
--- a/apps/obsidian/src/components/canvas/utils/openFileUtils.ts
+++ b/apps/obsidian/src/components/canvas/utils/openFileUtils.ts
@@ -88,17 +88,25 @@ export const openFileInSidebar = async (
export const openFileInNewTab = async (
app: App,
file: TFile,
+ { line }: { line?: number } = {},
): Promise => {
const leaf = app.workspace.getLeaf("tab");
- await leaf.openFile(file);
+ await leaf.openFile(
+ file,
+ line === undefined ? undefined : { eState: { line } },
+ );
app.workspace.setActiveLeaf(leaf);
};
export const openFileInNewLeaf = async (
app: App,
file: TFile,
+ { line }: { line?: number } = {},
): Promise => {
const leaf = app.workspace.getLeaf("split");
- await leaf.openFile(file);
+ await leaf.openFile(
+ file,
+ line === undefined ? undefined : { eState: { line } },
+ );
app.workspace.setActiveLeaf(leaf);
};
diff --git a/apps/obsidian/src/services/QueryEngine.ts b/apps/obsidian/src/services/QueryEngine.ts
index e817459b11..883fbb0107 100644
--- a/apps/obsidian/src/services/QueryEngine.ts
+++ b/apps/obsidian/src/services/QueryEngine.ts
@@ -10,6 +10,7 @@ import { BulkImportPattern, BulkImportCandidate, DiscourseNode } from "~/types";
import { getDiscourseNodeFormatExpression } from "~/utils/getDiscourseNodeFormatExpression";
import { extractContentFromTitle } from "~/utils/extractContentFromTitle";
import { AppWithUnofficialApis } from "~/utils/obsidianUnofficialTypes";
+import { titleFromTaggedLine } from "~/utils/taggedLine";
// This is a workaround to get the datacore API.
// TODO: Remove once we can use datacore npm package
@@ -45,6 +46,8 @@ export type DiscourseNodeCandidate = {
*/
title: string;
nodeTypeId: string;
+ /** Set for candidate nodes: an inline line tagged with a node type's tag. */
+ tagLine?: { line: number; tag: string };
};
export type RankedDiscourseNode = DiscourseNodeCandidate & {
@@ -87,6 +90,58 @@ export class QueryEngine {
return candidates;
};
+ /** One pass over the metadata cache's tag index; only files with a hit are read. */
+ getCandidateNodes = async (
+ nodeTypes: DiscourseNode[],
+ ): Promise => {
+ const nodeTypeByTag = new Map<
+ string,
+ { nodeType: DiscourseNode; tag: string }
+ >();
+ for (const nodeType of nodeTypes) {
+ if (nodeType.tag) {
+ nodeTypeByTag.set(nodeType.tag.toLowerCase(), {
+ nodeType,
+ tag: nodeType.tag,
+ });
+ }
+ }
+ if (!nodeTypeByTag.size) return [];
+
+ type TagHit = { line: number; nodeType: DiscourseNode; tag: string };
+ const hitsByFile: { file: TFile; hits: TagHit[] }[] = [];
+ for (const file of this.app.vault.getMarkdownFiles()) {
+ const hits: TagHit[] = [];
+ const seen = new Set();
+ for (const tagCache of this.app.metadataCache.getFileCache(file)?.tags ??
+ []) {
+ const match = nodeTypeByTag.get(tagCache.tag.slice(1).toLowerCase());
+ if (!match) continue;
+ const line = tagCache.position.start.line;
+ const key = `${line}:${match.nodeType.id}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ hits.push({ line, ...match });
+ }
+ if (hits.length) hitsByFile.push({ file, hits });
+ }
+
+ const perFile = await Promise.all(
+ hitsByFile.map(async ({ file, hits }) => {
+ // A file deleted or locked mid-scan shouldn't sink the other candidates.
+ const content = await this.app.vault.cachedRead(file).catch(() => "");
+ const lines = content.split("\n");
+ return hits.flatMap(({ line, nodeType, tag }) => {
+ const title = titleFromTaggedLine(lines[line] ?? "");
+ return title
+ ? [{ file, title, nodeTypeId: nodeType.id, tagLine: { line, tag } }]
+ : [];
+ });
+ }),
+ );
+ return perFile.flat();
+ };
+
/**
* Search across all discourse nodes (files that have frontmatter nodeTypeId)
*/
@@ -744,6 +799,11 @@ const filterCandidatesByNodeTypeIds = (
return candidates.filter((candidate) => selected.has(candidate.nodeTypeId));
};
+const nodesFirst = (
+ a: DiscourseNodeCandidate,
+ b: DiscourseNodeCandidate,
+): number => Number(!!a.tagLine) - Number(!!b.tagLine);
+
/**
* Best match first, uncapped — capping is the caller's, so a later re-sort orders the
* whole set rather than a top slice. Filters before scoring: same results, less work.
@@ -763,7 +823,7 @@ export const rankDiscourseNodesByTitle = ({
// Filter-only searches still need a list, so an empty query is not an empty result.
if (!trimmedQuery) {
return [...filtered]
- .sort((a, b) => a.title.localeCompare(b.title))
+ .sort((a, b) => a.title.localeCompare(b.title) || nodesFirst(a, b))
.map((candidate) => ({
...candidate,
match: { score: 0, matches: [] },
@@ -778,8 +838,9 @@ export const rankDiscourseNodesByTitle = ({
if (match) ranked.push({ ...candidate, match });
}
- // Sort is stable, so equal scores keep candidate order.
- return ranked.sort((a, b) => b.match.score - a.match.score);
+ return ranked.sort(
+ (a, b) => b.match.score - a.match.score || nodesFirst(a, b),
+ );
};
/**
diff --git a/apps/obsidian/src/services/__tests__/QueryEngine.test.ts b/apps/obsidian/src/services/__tests__/QueryEngine.test.ts
index 18d30e9c09..ed56c78e35 100644
--- a/apps/obsidian/src/services/__tests__/QueryEngine.test.ts
+++ b/apps/obsidian/src/services/__tests__/QueryEngine.test.ts
@@ -1,6 +1,22 @@
import { TFile, type App } from "obsidian";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { QueryEngine } from "~/services/QueryEngine";
+import {
+ QueryEngine,
+ rankDiscourseNodesByTitle,
+ type DiscourseNodeCandidate,
+} from "~/services/QueryEngine";
+import type { DiscourseNode } from "~/types";
+
+// Stand-in scorer: earlier substring hits score higher, mirroring fuzzy search's ordering.
+vi.mock("obsidian", async (importOriginal) => ({
+ ...(await importOriginal