22 | {(section) => {
23 | const line = section.cache.position.start.line;
24 |
25 | return (
26 | {
29 | await MarkdownRenderer.render(
30 | app,
31 | section.text,
32 | el,
33 | section.filePath,
34 | matchLifecycleManager
35 | );
36 |
37 | matchLifecycleManager.load();
38 | }}
39 | onClick={async (event) => {
40 | await handleClick(event, section.filePath, line);
41 | }}
42 | onMouseOver={(event) => {
43 | handleMouseover(event, section.filePath, line);
44 | }}
45 | />
46 | );
47 | }}
48 |
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/src/context-tree/dedupe/dedupe-matches.test.js:
--------------------------------------------------------------------------------
1 | import { dedupeMatches } from "./dedupe-matches";
2 | import { cloneDeep } from "lodash";
3 |
4 | test("Removes neighboring identical matches without mutating the tree", () => {
5 | const input = {
6 | sectionsWithMatches: [
7 | {
8 | text: "foo",
9 | cache: {
10 | position: {
11 | start: {
12 | offset: 0,
13 | },
14 | end: {
15 | offset: 1,
16 | },
17 | },
18 | },
19 | },
20 | {
21 | text: "foo",
22 | cache: {
23 | position: {
24 | start: {
25 | offset: 0,
26 | },
27 | end: {
28 | offset: 1,
29 | },
30 | },
31 | },
32 | },
33 | ],
34 | branches: [
35 | {
36 | branches: [],
37 | sectionsWithMatches: [
38 | {
39 | text: "foo",
40 | cache: {
41 | position: {
42 | start: {
43 | offset: 0,
44 | },
45 | end: {
46 | offset: 1,
47 | },
48 | },
49 | },
50 | },
51 | {
52 | text: "foo",
53 | cache: {
54 | position: {
55 | start: {
56 | offset: 0,
57 | },
58 | end: {
59 | offset: 1,
60 | },
61 | },
62 | },
63 | },
64 | ],
65 | },
66 | ],
67 | };
68 |
69 | const clone = cloneDeep(input);
70 |
71 | const deduped = dedupeMatches(input);
72 |
73 | expect(deduped.sectionsWithMatches).toHaveLength(1);
74 | expect(deduped.branches[0].sectionsWithMatches).toHaveLength(1);
75 |
76 | expect(input).toEqual(clone);
77 | });
78 |
--------------------------------------------------------------------------------
/src/ui/solid/branch.tsx:
--------------------------------------------------------------------------------
1 | import { createSignal, For, Show } from "solid-js";
2 | import { Title } from "./title";
3 | import { MatchSection } from "./match-section";
4 | import { CollapsedContextTree } from "../../types";
5 | import { CollapseIcon } from "./icons/collapse-icon";
6 | import { usePluginContext } from "./plugin-context";
7 |
8 | interface BranchProps {
9 | contextTree: CollapsedContextTree;
10 | }
11 |
12 | export function Branch(props: BranchProps) {
13 | const { handleHeightChange } = usePluginContext();
14 | const [isHidden, setIsHidden] = createSignal(false);
15 |
16 | // todo: this looks out of place
17 | const breadcrumbs = () => {
18 | const breadcrumbForBranch = {
19 | text: props.contextTree.text,
20 | type: props.contextTree.type,
21 | position: props.contextTree.cacheItem.position,
22 | };
23 | return [breadcrumbForBranch, ...(props.contextTree.breadcrumbs || [])];
24 | };
25 |
26 | return (
27 |
28 |
29 |
30 |
{
35 | setIsHidden(!isHidden());
36 | handleHeightChange();
37 | }}
38 | >
39 |
40 |
41 |
42 |
43 |
44 |
45 |
48 |
49 |
50 | {(branch) => }
51 |
52 |
53 |
54 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/src/context-tree/collapse/collapse-empty-nodes.test.js:
--------------------------------------------------------------------------------
1 | import { collapseEmptyNodes } from "./collapse-empty-nodes";
2 | import { cloneDeep } from "lodash";
3 |
4 | test("collapse empty nodes with 2 leaves, don't mutate original", () => {
5 | const input = {
6 | text: "file",
7 | sectionsWithMatches: [],
8 | branches: [
9 | {
10 | text: "empty 1",
11 | sectionsWithMatches: [],
12 | cacheItem: { position: { start: { line: 1 } } },
13 | branches: [
14 | {
15 | text: "empty 1.1",
16 | sectionsWithMatches: [],
17 | cacheItem: { position: { start: { line: 2 } } },
18 | branches: [
19 | {
20 | text: "empty 1.1.1",
21 | sectionsWithMatches: [],
22 | cacheItem: { position: { start: { line: 3 } } },
23 | branches: [
24 | {
25 | text: "empty 1.1.1.1",
26 | sectionsWithMatches: [],
27 | branches: [],
28 | },
29 | {
30 | text: "empty 1.1.1.2",
31 | sectionsWithMatches: [],
32 | branches: [],
33 | },
34 | ],
35 | },
36 | ],
37 | },
38 | ],
39 | },
40 | ],
41 | };
42 |
43 | const clone = cloneDeep(input);
44 |
45 | const collapsed = collapseEmptyNodes(input);
46 |
47 | expect(collapsed).toMatchObject({
48 | breadcrumbs: [
49 | { text: "empty 1", position: { start: { line: 1 } } },
50 | { text: "empty 1.1", position: { start: { line: 2 } } },
51 | { text: "empty 1.1.1", position: { start: { line: 3 } } },
52 | ],
53 | branches: [
54 | {
55 | text: "empty 1.1.1.1",
56 | sectionsWithMatches: [],
57 | branches: [],
58 | },
59 | {
60 | text: "empty 1.1.1.2",
61 | sectionsWithMatches: [],
62 | branches: [],
63 | },
64 | ],
65 | });
66 |
67 | expect(input).toEqual(clone);
68 | });
69 |
--------------------------------------------------------------------------------
/src/ui/solid/title.tsx:
--------------------------------------------------------------------------------
1 | import { For, Match, Switch } from "solid-js";
2 | import { usePluginContext } from "./plugin-context";
3 | import { ListIcon } from "./icons/list-icon";
4 | import { ArrowRightIcon } from "./icons/arrow-right-icon";
5 | import { HeadingIcon } from "./icons/heading-icon";
6 | import { Breadcrumb, ContextTree, MouseOverEvent } from "../../types";
7 | import { listItemToken } from "../../patterns";
8 |
9 | interface TitleProps {
10 | breadcrumbs: Breadcrumb[];
11 | contextTree: ContextTree;
12 | }
13 |
14 | function removeListToken(text: string) {
15 | return text.trim().replace(listItemToken, "");
16 | }
17 |
18 | export function Title(props: TitleProps) {
19 | const { handleClick, handleMouseover } = usePluginContext();
20 |
21 | return (
22 |
23 |
24 | {(breadcrumb, i) => {
25 | const handleTitleClick = async (event: MouseEvent) =>
26 | await handleClick(
27 | event,
28 | props.contextTree.filePath,
29 | breadcrumb.position.start.line,
30 | );
31 |
32 | const handleTitleMouseover = (event: MouseOverEvent) =>
33 | handleMouseover(
34 | event,
35 | props.contextTree.filePath,
36 | breadcrumb.position.start.line,
37 | );
38 |
39 | return (
40 |
41 |
46 |
47 | }>
48 |
49 |
50 |
51 |
52 |
53 |
{removeListToken(breadcrumb.text)}
54 |
55 |
56 | );
57 | }}
58 |
59 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/src/metadata-cache-util/heading.ts:
--------------------------------------------------------------------------------
1 | import { HeadingCache, Pos } from "obsidian";
2 |
3 | export function getHeadingIndexContaining(
4 | position: Pos,
5 | headings: HeadingCache[]
6 | ) {
7 | return headings.findIndex(
8 | (heading) => heading.position.start.line === position.start.line
9 | );
10 | }
11 |
12 | function getIndexOfHeadingAbove(position: Pos, headings: HeadingCache[]) {
13 | return headings.reduce(
14 | (previousIndex, lookingAtHeading, index) =>
15 | lookingAtHeading.position.start.line < position.start.line
16 | ? index
17 | : previousIndex,
18 | -1
19 | );
20 | }
21 |
22 | export function getHeadingBreadcrumbs(position: Pos, headings: HeadingCache[]) {
23 | const headingBreadcrumbs: HeadingCache[] = [];
24 | if (headings.length === 0) {
25 | return headingBreadcrumbs;
26 | }
27 |
28 | const collectAncestorHeadingsForHeadingAtIndex = (startIndex: number) => {
29 | let currentLevel = headings[startIndex].level;
30 | const previousHeadingIndex = startIndex - 1;
31 |
32 | for (let i = previousHeadingIndex; i >= 0; i--) {
33 | const lookingAtHeading = headings[i];
34 |
35 | if (lookingAtHeading.level < currentLevel) {
36 | currentLevel = lookingAtHeading.level;
37 | headingBreadcrumbs.unshift(lookingAtHeading);
38 | }
39 | }
40 | };
41 |
42 | const headingIndexAtPosition = getHeadingIndexContaining(position, headings);
43 | const positionIsInsideHeading = headingIndexAtPosition >= 0;
44 |
45 | if (positionIsInsideHeading) {
46 | headingBreadcrumbs.unshift(headings[headingIndexAtPosition]);
47 | collectAncestorHeadingsForHeadingAtIndex(headingIndexAtPosition);
48 | return headingBreadcrumbs;
49 | }
50 |
51 | const headingIndexAbovePosition = getIndexOfHeadingAbove(position, headings);
52 | const positionIsBelowHeading = headingIndexAbovePosition >= 0;
53 |
54 | if (positionIsBelowHeading) {
55 | const headingAbovePosition = headings[headingIndexAbovePosition];
56 | headingBreadcrumbs.unshift(headingAbovePosition);
57 | collectAncestorHeadingsForHeadingAtIndex(headingIndexAbovePosition);
58 | return headingBreadcrumbs;
59 | }
60 |
61 | return headingBreadcrumbs;
62 | }
63 |
--------------------------------------------------------------------------------
/src/metadata-cache-util/list.ts:
--------------------------------------------------------------------------------
1 | import { ListItemCache, Pos } from "obsidian";
2 | import { doesPositionIncludeAnother } from "./position";
3 |
4 | export function getListItemWithDescendants(
5 | listItemIndex: number,
6 | listItems: ListItemCache[]
7 | ) {
8 | const rootListItem = listItems[listItemIndex];
9 | const listItemWithDescendants = [rootListItem];
10 |
11 | for (let i = listItemIndex + 1; i < listItems.length; i++) {
12 | const nextItem = listItems[i];
13 | if (nextItem.parent < rootListItem.position.start.line) {
14 | return listItemWithDescendants;
15 | }
16 | listItemWithDescendants.push(nextItem);
17 | }
18 |
19 | return listItemWithDescendants;
20 | }
21 |
22 | export function getListBreadcrumbs(position: Pos, listItems: ListItemCache[]) {
23 | const listBreadcrumbs: ListItemCache[] = [];
24 |
25 | if (listItems.length === 0) {
26 | return listBreadcrumbs;
27 | }
28 |
29 | const thisItemIndex = getListItemIndexContaining(position, listItems);
30 | const isPositionOutsideListItem = thisItemIndex < 0;
31 |
32 | if (isPositionOutsideListItem) {
33 | return listBreadcrumbs;
34 | }
35 |
36 | const thisItem = listItems[thisItemIndex];
37 | let currentParent = thisItem.parent;
38 |
39 | if (isTopLevelListItem(thisItem)) {
40 | return listBreadcrumbs;
41 | }
42 |
43 | for (let i = thisItemIndex - 1; i >= 0; i--) {
44 | const currentItem = listItems[i];
45 |
46 | const currentItemIsHigherUp = currentItem.parent < currentParent;
47 | if (currentItemIsHigherUp) {
48 | listBreadcrumbs.unshift(currentItem);
49 | currentParent = currentItem.parent;
50 | }
51 |
52 | if (isTopLevelListItem(currentItem)) {
53 | return listBreadcrumbs;
54 | }
55 | }
56 |
57 | return listBreadcrumbs;
58 | }
59 |
60 | function isTopLevelListItem(listItem: ListItemCache) {
61 | return listItem.parent < 0;
62 | }
63 |
64 | export function getListItemIndexContaining(
65 | searchedForPosition: Pos,
66 | listItems: ListItemCache[]
67 | ) {
68 | return listItems.findIndex(({ position }) =>
69 | doesPositionIncludeAnother(position, searchedForPosition)
70 | );
71 | }
72 |
73 | export function isPositionInList(position: Pos, listItems: ListItemCache[]) {
74 | return getListItemIndexContaining(position, listItems) >= 0;
75 | }
76 |
--------------------------------------------------------------------------------
/src/metadata-cache-util/position.ts:
--------------------------------------------------------------------------------
1 | import { Pos } from "obsidian";
2 |
3 | export const getTextAtPosition = (textInput: string, pos: Pos) =>
4 | textInput.substring(pos.start.offset, pos.end.offset);
5 |
6 | export const getTextFromLineStartToPositionEnd = (
7 | textInput: string,
8 | pos: Pos
9 | ) => textInput.substring(pos.start.offset - pos.start.col, pos.end.offset);
10 |
11 | export const doesPositionIncludeAnother = (container: Pos, child: Pos) =>
12 | container.start.offset <= child.start.offset &&
13 | container.end.offset >= child.end.offset;
14 |
15 | export function isSamePosition(a?: Pos, b?: Pos) {
16 | return (
17 | a && b && a.start.offset === b.start.offset && a.end.offset === b.end.offset
18 | );
19 | }
20 |
21 | export function createPositionFromOffsets(
22 | content: string,
23 | startOffset: number,
24 | endOffset: number
25 | ) {
26 | const startLine = content.substring(0, startOffset).split("\n").length - 1;
27 | const endLine = content.substring(0, endOffset).split("\n").length - 1;
28 |
29 | const startLinePos = content.substring(0, startOffset).lastIndexOf("\n") + 1;
30 | const startCol = content.substring(startLinePos, startOffset).length;
31 |
32 | const endLinePos = content.substring(0, endOffset).lastIndexOf("\n") + 1;
33 | const endCol = content.substring(endLinePos, endOffset).length;
34 |
35 | return {
36 | position: {
37 | start: { line: startLine, col: startCol, offset: startOffset },
38 | end: { line: endLine, col: endCol, offset: endOffset },
39 | },
40 | };
41 | }
42 |
43 | export function highlightAtPositionWithRecalculatingOffsets(
44 | position: Pos,
45 | containerPosition: Pos,
46 | text: string
47 | ) {
48 | const containerStart = containerPosition.start.offset;
49 | const positionLength = position.end.offset - position.start.offset;
50 |
51 | const startOfPositionInContainer = position.start.offset - containerStart;
52 | const endOfPositionInContainer = positionLength + startOfPositionInContainer;
53 |
54 | const beforeHighlight = text.substring(0, startOfPositionInContainer);
55 | const highlight = text.substring(
56 | startOfPositionInContainer,
57 | endOfPositionInContainer
58 | );
59 | const afterHighlight = text.substring(endOfPositionInContainer);
60 |
61 | return `${beforeHighlight}${highlight}${afterHighlight}`;
62 | }
63 |
--------------------------------------------------------------------------------
/src/ui/solid/plugin-context.tsx:
--------------------------------------------------------------------------------
1 | import { JSX, createContext, useContext } from "solid-js";
2 | import { App, Keymap, MarkdownView, Notice } from "obsidian";
3 | import BetterBacklinksPlugin from "../../plugin";
4 | import { MouseOverEvent } from "../../types";
5 |
6 | interface PluginContextProps {
7 | plugin: BetterBacklinksPlugin;
8 | infinityScroll: any;
9 | children: JSX.Element;
10 | }
11 |
12 | interface PluginContextValue {
13 | handleClick: (
14 | event: MouseEvent,
15 | path: string,
16 | line?: number,
17 | ) => Promise;
18 | handleMouseover: (event: MouseOverEvent, path: string, line?: number) => void;
19 | handleHeightChange: () => void;
20 | plugin: BetterBacklinksPlugin;
21 | app: App;
22 | }
23 |
24 | const PluginContext = createContext();
25 |
26 | export function PluginContextProvider(props: PluginContextProps) {
27 | const handleClick = async (event: MouseEvent, path: string, line: number) => {
28 | if (event.target instanceof HTMLAnchorElement) {
29 | return;
30 | }
31 |
32 | const file = props.plugin.app.metadataCache.getFirstLinkpathDest(
33 | path,
34 | path,
35 | );
36 |
37 | if (!file) {
38 | new Notice(`File ${path} does not exist`);
39 | return;
40 | }
41 |
42 | await props.plugin.app.workspace.getLeaf(false).openFile(file);
43 |
44 | const activeMarkdownView =
45 | props.plugin.app.workspace.getActiveViewOfType(MarkdownView);
46 |
47 | if (!activeMarkdownView) {
48 | new Notice(`Failed to open file ${path}. Can't scroll to line ${line}`);
49 | return;
50 | }
51 |
52 | // Sometimes it works but still throws errors
53 | try {
54 | activeMarkdownView.setEphemeralState({ line });
55 | } catch (error) {
56 | console.error(error);
57 | }
58 | };
59 |
60 | const handleMouseover = (
61 | event: MouseOverEvent,
62 | path: string,
63 | line: number,
64 | ) => {
65 | // @ts-ignore
66 | if (!props.plugin.app.internalPlugins.plugins["page-preview"].enabled) {
67 | return;
68 | }
69 |
70 | if (Keymap.isModifier(event, "Mod")) {
71 | const target = event.target as HTMLElement;
72 | const previewLocation = {
73 | scroll: line,
74 | };
75 | if (path) {
76 | props.plugin.app.workspace.trigger(
77 | "link-hover",
78 | {},
79 | target,
80 | path,
81 | "",
82 | previewLocation,
83 | );
84 | }
85 | }
86 | };
87 |
88 | const handleHeightChange = () => {
89 | props.infinityScroll.invalidateAll();
90 | };
91 |
92 | return (
93 |
102 | {props.children}
103 |
104 | );
105 | }
106 |
107 | export function usePluginContext() {
108 | const pluginContext = useContext(PluginContext);
109 | if (!pluginContext) {
110 | throw new Error("pluginContext must be used inside a provider");
111 | }
112 | return pluginContext;
113 | }
114 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Better Search Views
2 |
3 | > **Warning**
4 | >
5 | > - You need to reload Obsidian after you **install/update/enable/disable** the plugin
6 | > - The plugin reaches into Obsidian's internals, so it may break after an update. If you noticed that, [please create an issue](https://github.com/ivan-lednev/better-search-views/issues)
7 |
8 | ## How to use it
9 |
10 | Just install it and reload Obsidian. Now Obsidian's built-in global search, backlinks and queries should be decorated with breadcrumbs.
11 |
12 | ## What it does
13 |
14 | ### Before 'Better Search Views', search results look like this:
15 |
16 | 
17 |
18 | ### After 'Better Search Views' they look like this:
19 |
20 | 
21 |
22 | ### A closer look
23 |
24 | Let's open one of the files with matches, and see how the hierarchy in the search result matches the file:
25 | 
26 |
27 |
28 | ### But what does it do exactly?
29 |
30 | The plugin brings more outliner goodness into Obsidian: it improves search views to create an outliner-like context around every match.
31 | - **It patches native search, backlinks view, embedded backlinks and embedded queries**
32 | - It renders markdown in the match to HTML
33 | - It builds structural breadcrumbs to the match by chaining all the ancestor headings and list items above
34 | - If the match is in a list item, it displays all the sub-list items below it
35 | - If the match is in a heading, it displays the first section below the heading (you know, for context)
36 |
37 | ### Backlinks in document look like this
38 |
39 | 
40 |
41 | ### Embedded queries look like this
42 |
43 | 
44 |
45 | ### Clicking on breadcrumbs works just as you might expect
46 |
47 | 
48 |
49 | ### Hovering over any element with the control key pressed triggers a pop-up
50 |
51 | 
52 |
53 | ## Contribution
54 |
55 | If you noticed a bug or have an improvement idea, [please create an issue](https://github.com/ivan-lednev/better-search-views/issues).
56 |
57 | Pull-requests are welcome! If you want to contribute but don't know where to start, you can create an issue or write me an email: .
58 |
59 | You can also support the development directly:
60 |
61 |
62 |
63 | # Acknowledgements
64 |
65 | - Thanks to [TFTHacker](https://tfthacker.com/) for [his plugin](https://github.com/TfTHacker/obsidian42-strange-new-worlds), which helped me figure out how to implement a bunch of small improvements
66 | - Thanks to [NothingIsLost](https://github.com/nothingislost) for his awesome plugins, that helped me figure out how to patch Obsidian internals
67 | - Thanks to [PJEby](https://github.com/pjeby) for his [patching library](https://github.com/pjeby/monkey-around)
68 |
--------------------------------------------------------------------------------
/styles.css:
--------------------------------------------------------------------------------
1 | /* TODO: blockquotes need a better fix */
2 | .better-search-views-file-match.markdown-rendered > *,
3 | .better-search-views-file-match.markdown-rendered > blockquote > * {
4 | margin-block-start: 0;
5 | margin-block-end: 0;
6 | white-space: normal;
7 | }
8 |
9 | .better-search-views-file-match.markdown-rendered ul > li,
10 | .better-search-views-file-match.markdown-rendered ol > li {
11 | margin-inline-start: calc(var(--list-indent) * 0.8);
12 | }
13 |
14 | .better-search-views-file-match > blockquote {
15 | margin-inline-start: 0;
16 | margin-inline-end: 0;
17 | }
18 |
19 | .better-search-views-file-match ul {
20 | position: relative;
21 | padding-inline-start: 0;
22 | }
23 |
24 | .better-search-views-file-match a {
25 | cursor: default;
26 | text-decoration: none;
27 | }
28 |
29 | /* Copied from Obsidian */
30 | .better-search-views-file-match li > ul::before {
31 | content: "\200B";
32 |
33 | position: absolute;
34 | top: 0;
35 | bottom: 0;
36 | left: -1em;
37 |
38 | display: block;
39 |
40 | border-right: var(--indentation-guide-width) solid
41 | var(--indentation-guide-color);
42 | }
43 |
44 | .better-search-views-breadcrumbs {
45 | display: flex;
46 | align-items: center;
47 | border-bottom: 1px solid var(--background-modifier-border);
48 | }
49 |
50 | .better-search-views-tree-item-children {
51 | margin-left: 10px;
52 | padding-left: 10px;
53 | border-left: var(--nav-indentation-guide-width) solid
54 | var(--indentation-guide-color);
55 | }
56 |
57 | .better-search-views-tree-item-children:hover {
58 | border-left-color: var(--indentation-guide-color-active);
59 | }
60 |
61 | .better-search-views-breadcrumb-container {
62 | display: flex;
63 | gap: 0.5em;
64 | align-items: flex-start;
65 | }
66 |
67 | .better-search-views-tree .tree-item-inner {
68 | display: flex;
69 | flex-direction: column;
70 | flex-grow: 1;
71 | gap: 2px;
72 |
73 | padding-top: 4px;
74 | padding-bottom: 4px;
75 |
76 | border-radius: var(--radius-s);
77 | }
78 |
79 | .better-search-views-titles-container .tree-item-inner:not(:hover) {
80 | color: var(--text-muted);
81 | }
82 |
83 | .search-result-file-matches:has(.better-search-views-tree) {
84 | overflow: hidden;
85 |
86 | font-size: var(--font-ui-smaller);
87 | line-height: var(--line-height-tight);
88 | color: var(--text-muted);
89 |
90 | background-color: revert;
91 | border-radius: var(--radius-s);
92 | }
93 |
94 | .search-result-file-matches:has(.better-search-views-tree),
95 | .better-search-views-tree .search-result-file-matches {
96 | margin: var(--size-4-1) 0 var(--size-4-1);
97 | }
98 |
99 | .better-search-views-tree .search-result-file-matches {
100 | margin-left: 21px;
101 | }
102 |
103 | .tree-item.search-result
104 | > .search-result-file-matches:has(.better-search-views-tree) {
105 | /* This fixes box shadow in child match boxes */
106 | padding-right: 1px;
107 | box-shadow: none;
108 | }
109 |
110 | .search-result-file-matches:has(.better-search-views-tree)
111 | .better-search-views-file-match:not(:hover) {
112 | background-color: var(--search-result-background);
113 | box-shadow: 0 0 0 1px var(--background-modifier-border);
114 | }
115 |
116 | .better-search-views-icon {
117 | width: var(--icon-xs);
118 | height: var(--icon-xs);
119 | color: var(--text-faint);
120 | }
121 |
122 | .better-search-views-tree blockquote {
123 | padding-left: 10px;
124 | border-left: var(--blockquote-border-thickness) solid
125 | var(--blockquote-border-color);
126 | }
127 |
128 | .better-search-views-tree .tree-item-inner:hover {
129 | background-color: var(--nav-item-background-hover);
130 | }
131 |
132 | .better-search-views-tree .search-result-file-title {
133 | padding-right: 0;
134 |
135 | /* TODO: this is still hardcoded */
136 | padding-left: calc(20px + var(--nav-indentation-guide-width));
137 | }
138 |
139 | body:not(.is-grabbing)
140 | .better-search-views-tree
141 | .tree-item-self.search-result-file-title:hover {
142 | background-color: unset;
143 | }
144 |
145 | .better-search-views-tree .better-search-views-breadcrumb-container {
146 | flex-grow: 1;
147 | padding-right: 2px;
148 | padding-left: 2px;
149 | }
150 |
151 | .better-search-views-tree
152 | .better-search-views-breadcrumb-container:not(:last-child) {
153 | padding-bottom: 2px;
154 | border-bottom: var(--nav-indentation-guide-width) solid
155 | var(--nav-indentation-guide-color);
156 | }
157 |
158 | .better-search-views-breadcrumb-token {
159 | color: var(--text-faint);
160 | display: flex;
161 | align-items: center;
162 | height: calc(1em * var(--line-height-tight));
163 | }
164 |
165 | .better-search-views-tree .collapse-icon {
166 | display: flex;
167 | align-items: center;
168 | align-self: flex-start;
169 |
170 | padding-top: 4px;
171 | padding-bottom: 2px;
172 |
173 | border-radius: var(--radius-s);
174 | }
175 |
176 | .better-search-views-titles-container {
177 | display: flex;
178 | flex-direction: column;
179 | flex-grow: 1;
180 | }
181 |
182 | .markdown-source-view.mod-cm6
183 | .better-search-views-tree
184 | .task-list-item-checkbox {
185 | margin-inline-start: calc(var(--checkbox-size) * -1.5);
186 | }
187 |
188 | .better-search-views-is-hidden {
189 | display: none;
190 | }
191 |
--------------------------------------------------------------------------------
/src/context-tree/create/create-context-tree.ts:
--------------------------------------------------------------------------------
1 | import { CacheItem, FileStats } from "obsidian";
2 | import { ContextTree, createContextTreeProps, TreeType } from "../../types";
3 | import {
4 | getHeadingBreadcrumbs,
5 | getHeadingIndexContaining,
6 | } from "../../metadata-cache-util/heading";
7 | import {
8 | getListBreadcrumbs,
9 | getListItemIndexContaining,
10 | getListItemWithDescendants,
11 | isPositionInList,
12 | } from "../../metadata-cache-util/list";
13 | import {
14 | getFirstSectionUnder,
15 | getSectionContaining,
16 | } from "../../metadata-cache-util/section";
17 | import {
18 | getTextAtPosition,
19 | isSamePosition,
20 | } from "../../metadata-cache-util/position";
21 | import { formatListWithDescendants } from "../../metadata-cache-util/format";
22 |
23 | export function createContextTree({
24 | positions,
25 | fileContents,
26 | stat,
27 | filePath,
28 | listItems = [],
29 | headings = [],
30 | sections = [],
31 | }: createContextTreeProps) {
32 | const positionsWithContext = positions.map((position) => {
33 | return {
34 | headingBreadcrumbs: getHeadingBreadcrumbs(position.position, headings),
35 | listBreadcrumbs: getListBreadcrumbs(position.position, listItems),
36 | sectionCache: getSectionContaining(position.position, sections),
37 | position,
38 | };
39 | });
40 |
41 | // todo: remove cache from file tree
42 | // @ts-ignore
43 | const root = createContextTreeBranch("file", {}, stat, filePath, filePath);
44 |
45 | for (const {
46 | headingBreadcrumbs,
47 | listBreadcrumbs,
48 | sectionCache,
49 | position,
50 | } of positionsWithContext) {
51 | if (!sectionCache) {
52 | // the match is most likely in file name
53 | continue;
54 | }
55 |
56 | let context: ContextTree = root;
57 |
58 | for (const headingCache of headingBreadcrumbs) {
59 | const headingFoundInChildren = context.branches.find((tree) =>
60 | isSamePosition(tree.cacheItem.position, headingCache.position),
61 | );
62 |
63 | if (headingFoundInChildren) {
64 | context = headingFoundInChildren;
65 | } else {
66 | const newContext: ContextTree = createContextTreeBranch(
67 | "heading",
68 | headingCache,
69 | stat,
70 | filePath,
71 | headingCache.heading,
72 | );
73 |
74 | context.branches.push(newContext);
75 | context = newContext;
76 | }
77 | }
78 |
79 | for (const listItemCache of listBreadcrumbs) {
80 | const listItemFoundInChildren = context.branches.find((tree) =>
81 | isSamePosition(tree.cacheItem.position, listItemCache.position),
82 | );
83 |
84 | if (listItemFoundInChildren) {
85 | context = listItemFoundInChildren;
86 | } else {
87 | const newListContext: ContextTree = createContextTreeBranch(
88 | "list",
89 | listItemCache,
90 | stat,
91 | filePath,
92 | getTextAtPosition(fileContents, listItemCache.position),
93 | );
94 |
95 | context.branches.push(newListContext);
96 | context = newListContext;
97 | }
98 | }
99 |
100 | // todo: move to metadata-cache-util
101 | const headingIndexAtPosition = getHeadingIndexContaining(
102 | position.position,
103 | headings,
104 | );
105 | const linkIsInsideHeading = headingIndexAtPosition >= 0;
106 |
107 | if (isPositionInList(position.position, listItems)) {
108 | // todo: optionally grab more context here
109 |
110 | const indexOfListItemContainingLink = getListItemIndexContaining(
111 | position.position,
112 | listItems,
113 | );
114 | const listItemCacheWithDescendants = getListItemWithDescendants(
115 | indexOfListItemContainingLink,
116 | listItems,
117 | );
118 | const text = formatListWithDescendants(
119 | fileContents,
120 | listItemCacheWithDescendants,
121 | );
122 |
123 | context.sectionsWithMatches.push({
124 | // TODO: add type to the cache
125 | // @ts-ignore
126 | cache: listItemCacheWithDescendants[0],
127 | text,
128 | filePath,
129 | });
130 | } else if (linkIsInsideHeading) {
131 | const firstSectionUnderHeading = getFirstSectionUnder(
132 | position.position,
133 | sections,
134 | );
135 |
136 | if (firstSectionUnderHeading) {
137 | context.sectionsWithMatches.push({
138 | cache: firstSectionUnderHeading,
139 | text: getTextAtPosition(
140 | fileContents,
141 | firstSectionUnderHeading.position,
142 | ),
143 | filePath,
144 | });
145 | }
146 | } else {
147 | const sectionText = getTextAtPosition(
148 | fileContents,
149 | sectionCache.position,
150 | );
151 | context.sectionsWithMatches.push({
152 | cache: sectionCache,
153 | text: sectionText,
154 | filePath,
155 | });
156 | }
157 | }
158 |
159 | return root;
160 | }
161 |
162 | function createContextTreeBranch(
163 | type: TreeType,
164 | cacheItem: CacheItem,
165 | stat: FileStats,
166 | filePath: string,
167 | text: string,
168 | ) {
169 | return {
170 | type,
171 | cacheItem,
172 | filePath,
173 | text,
174 | stat,
175 | branches: [],
176 | sectionsWithMatches: [],
177 | };
178 | }
179 |
--------------------------------------------------------------------------------
/src/patcher.ts:
--------------------------------------------------------------------------------
1 | import { Component, Notice } from "obsidian";
2 | import { around } from "monkey-around";
3 | import { createPositionFromOffsets } from "./metadata-cache-util/position";
4 | import { createContextTree } from "./context-tree/create/create-context-tree";
5 | import { renderContextTree } from "./ui/solid/render-context-tree";
6 | import BetterSearchViewsPlugin from "./plugin";
7 | import { wikiLinkBrackets } from "./patterns";
8 | import { DisposerRegistry } from "./disposer-registry";
9 | import { dedupeMatches } from "./context-tree/dedupe/dedupe-matches";
10 |
11 | const errorTimeout = 10000;
12 |
13 | // todo: add types
14 | function getHighlightsFromVChild(vChild: any) {
15 | const { content, matches } = vChild;
16 | const firstMatch = matches[0];
17 | const [start, end] = firstMatch;
18 |
19 | return content
20 | .substring(start, end)
21 | .toLowerCase()
22 | .replace(wikiLinkBrackets, "");
23 | }
24 |
25 | export class Patcher {
26 | private readonly wrappedMatches = new WeakSet();
27 | private readonly wrappedSearchResultItems = new WeakSet();
28 | private currentNotice: Notice;
29 | private triedPatchingSearchResultItem = false;
30 | private triedPatchingRenderContentMatches = false;
31 | private readonly disposerRegistry = new DisposerRegistry();
32 |
33 | constructor(private readonly plugin: BetterSearchViewsPlugin) {}
34 |
35 | patchComponent() {
36 | const patcher = this;
37 | this.plugin.register(
38 | around(Component.prototype, {
39 | addChild(old: Component["addChild"]) {
40 | return function (child: any, ...args: any[]) {
41 | const thisIsSearchView = this.hasOwnProperty("searchQuery");
42 | const hasBacklinks = child?.backlinkDom;
43 |
44 | if (
45 | (thisIsSearchView || hasBacklinks) &&
46 | !patcher.triedPatchingSearchResultItem
47 | ) {
48 | patcher.triedPatchingSearchResultItem = true;
49 | try {
50 | patcher.patchSearchResultDom(child.dom || child.backlinkDom);
51 | } catch (error) {
52 | patcher.reportError(
53 | error,
54 | "Error while patching Obsidian internals",
55 | );
56 | }
57 | }
58 |
59 | return old.call(this, child, ...args);
60 | };
61 | },
62 | }),
63 | );
64 | }
65 |
66 | patchSearchResultDom(searchResultDom: any) {
67 | const patcher = this;
68 | this.plugin.register(
69 | around(searchResultDom.constructor.prototype, {
70 | addResult(old: any) {
71 | return function (...args: any[]) {
72 | patcher.disposerRegistry.onAddResult(this);
73 |
74 | const result = old.call(this, ...args);
75 |
76 | if (!patcher.triedPatchingRenderContentMatches) {
77 | patcher.triedPatchingRenderContentMatches = true;
78 | try {
79 | patcher.patchSearchResultItem(result);
80 | } catch (error) {
81 | patcher.reportError(
82 | error,
83 | "Error while patching Obsidian internals",
84 | );
85 | }
86 | }
87 |
88 | return result;
89 | };
90 | },
91 | emptyResults(old: any) {
92 | return function (...args: any[]) {
93 | patcher.disposerRegistry.onEmptyResults(this);
94 |
95 | return old.call(this, ...args);
96 | };
97 | },
98 | }),
99 | );
100 | }
101 |
102 | patchSearchResultItem(searchResultItem: any) {
103 | const patcher = this;
104 | this.plugin.register(
105 | around(searchResultItem.constructor.prototype, {
106 | renderContentMatches(old: any) {
107 | return function (...args: any[]) {
108 | const result = old.call(this, ...args);
109 |
110 | // todo: clean this up
111 | if (
112 | patcher.wrappedSearchResultItems.has(this) ||
113 | !this.vChildren._children ||
114 | this.vChildren._children.length === 0
115 | ) {
116 | return result;
117 | }
118 |
119 | patcher.wrappedSearchResultItems.add(this);
120 |
121 | try {
122 | let someMatchIsInProperties = false;
123 |
124 | const matchPositions = this.vChildren._children.map(
125 | // todo: works only for one match per block
126 | (child: any) => {
127 | const { content, matches } = child;
128 | const firstMatch = matches[0];
129 |
130 | if (Object.hasOwn(firstMatch, "key")) {
131 | someMatchIsInProperties = true;
132 | return null;
133 | }
134 |
135 | const [start, end] = firstMatch;
136 | return createPositionFromOffsets(content, start, end);
137 | },
138 | );
139 |
140 | if (someMatchIsInProperties) {
141 | return result;
142 | }
143 |
144 | // todo: move out
145 | const highlights: string[] = this.vChildren._children.map(
146 | getHighlightsFromVChild,
147 | );
148 |
149 | const deduped = [...new Set(highlights)];
150 |
151 | const firstMatch = this.vChildren._children[0];
152 | patcher.mountContextTreeOnMatchEl(
153 | this,
154 | firstMatch,
155 | matchPositions,
156 | deduped,
157 | this.parent.infinityScroll,
158 | );
159 |
160 | // we already mounted the whole thing to the first child, so discard the rest
161 | this.vChildren._children = this.vChildren._children.slice(0, 1);
162 | } catch (e) {
163 | patcher.reportError(
164 | e,
165 | `Failed to mount context tree for file path: ${this.file.path}`,
166 | );
167 | }
168 |
169 | return result;
170 | };
171 | },
172 | }),
173 | );
174 | }
175 |
176 | reportError(error: any, message: string) {
177 | this.currentNotice?.hide();
178 | this.currentNotice = new Notice(
179 | `Better Search Views: ${message}. Please report an issue with the details from the console attached.`,
180 | errorTimeout,
181 | );
182 | console.error(`${message}. Reason:`, error);
183 | }
184 |
185 | mountContextTreeOnMatchEl(
186 | container: any,
187 | match: any,
188 | positions: any[],
189 | highlights: string[],
190 | infinityScroll: any,
191 | ) {
192 | if (this.wrappedMatches.has(match)) {
193 | return;
194 | }
195 |
196 | this.wrappedMatches.add(match);
197 |
198 | const { cache, content } = match;
199 | const { file } = container;
200 |
201 | const matchIsOnlyInFileName = !cache.sections || content === "";
202 |
203 | if (file.extension === "canvas" || matchIsOnlyInFileName) {
204 | return;
205 | }
206 |
207 | const contextTree = createContextTree({
208 | positions,
209 | fileContents: content,
210 | stat: file.stat,
211 | filePath: file.path,
212 | ...cache,
213 | });
214 |
215 | const mountPoint = createDiv();
216 |
217 | const dispose = renderContextTree({
218 | highlights,
219 | contextTree: dedupeMatches(contextTree),
220 | el: mountPoint,
221 | plugin: this.plugin,
222 | infinityScroll,
223 | });
224 |
225 | this.disposerRegistry.addOnEmptyResultsCallback(dispose);
226 |
227 | match.el = mountPoint;
228 | }
229 | }
230 |
--------------------------------------------------------------------------------
/src/context-tree/create/create-context-tree.test.js:
--------------------------------------------------------------------------------
1 | import { createContextTree } from "./create-context-tree";
2 |
3 | test("builds a tree with top-level links", () => {
4 | const fileContents = `[[target]]`;
5 |
6 | const linksToTarget = [
7 | {
8 | position: {
9 | start: {
10 | line: 0,
11 | col: 0,
12 | offset: 0,
13 | },
14 | end: {
15 | line: 0,
16 | col: 10,
17 | offset: 10,
18 | },
19 | },
20 | displayText: "target",
21 | },
22 | ];
23 |
24 | const cache = {
25 | sections: [
26 | {
27 | type: "paragraph",
28 | position: {
29 | start: {
30 | line: 0,
31 | col: 0,
32 | offset: 0,
33 | },
34 | end: {
35 | line: 0,
36 | col: 10,
37 | offset: 10,
38 | },
39 | },
40 | },
41 | ],
42 | };
43 |
44 | expect(
45 | createContextTree({
46 | positions: linksToTarget,
47 | fileContents,
48 | ...cache,
49 | })
50 | ).toMatchObject({
51 | sectionsWithMatches: [
52 | {
53 | text: `[[target]]`,
54 | },
55 | ],
56 | branches: [],
57 | });
58 | });
59 |
60 | test("builds a tree with nested headings", () => {
61 | const fileContents = `# H1
62 | ## H2
63 | [[target]]
64 | `;
65 |
66 | const backlinks = [
67 | {
68 | position: {
69 | start: {
70 | line: 2,
71 | col: 0,
72 | offset: 11,
73 | },
74 | end: {
75 | line: 2,
76 | col: 10,
77 | offset: 21,
78 | },
79 | },
80 | displayText: "target",
81 | },
82 | ];
83 |
84 | const cache = {
85 | headings: [
86 | {
87 | position: {
88 | start: {
89 | line: 0,
90 | col: 0,
91 | offset: 0,
92 | },
93 | end: {
94 | line: 0,
95 | col: 4,
96 | offset: 4,
97 | },
98 | },
99 | heading: "H1",
100 | display: "H1",
101 | level: 1,
102 | },
103 | {
104 | position: {
105 | start: {
106 | line: 1,
107 | col: 0,
108 | offset: 5,
109 | },
110 | end: {
111 | line: 1,
112 | col: 5,
113 | offset: 10,
114 | },
115 | },
116 | heading: "H2",
117 | display: "H2",
118 | level: 2,
119 | },
120 | ],
121 | sections: [
122 | {
123 | type: "heading",
124 | position: {
125 | start: {
126 | line: 0,
127 | col: 0,
128 | offset: 0,
129 | },
130 | end: {
131 | line: 0,
132 | col: 4,
133 | offset: 4,
134 | },
135 | },
136 | },
137 | {
138 | type: "heading",
139 | position: {
140 | start: {
141 | line: 1,
142 | col: 0,
143 | offset: 5,
144 | },
145 | end: {
146 | line: 1,
147 | col: 5,
148 | offset: 10,
149 | },
150 | },
151 | },
152 | {
153 | type: "paragraph",
154 | position: {
155 | start: {
156 | line: 2,
157 | col: 0,
158 | offset: 11,
159 | },
160 | end: {
161 | line: 2,
162 | col: 10,
163 | offset: 21,
164 | },
165 | },
166 | },
167 | ],
168 | };
169 |
170 | expect(
171 | createContextTree({
172 | positions: backlinks,
173 | fileContents,
174 | ...cache,
175 | })
176 | ).toMatchObject({
177 | sectionsWithMatches: [],
178 | branches: [
179 | {
180 | type: "heading",
181 | text: "H1",
182 | sectionsWithMatches: [],
183 | branches: [
184 | {
185 | text: "H2",
186 | type: "heading",
187 | sectionsWithMatches: [
188 | {
189 | text: "[[target]]",
190 | },
191 | ],
192 | },
193 | ],
194 | },
195 | ],
196 | });
197 | });
198 |
199 | test("builds a tree with nested lists", () => {
200 | const fileContents = `- l1
201 | - l2
202 | - [[target]]
203 | `;
204 |
205 | const linksToTarget = [
206 | {
207 | position: {
208 | start: {
209 | line: 2,
210 | col: 4,
211 | offset: 15,
212 | },
213 | end: {
214 | line: 2,
215 | col: 14,
216 | offset: 25,
217 | },
218 | },
219 | displayText: "target",
220 | },
221 | ];
222 |
223 | const cache = {
224 | sections: [
225 | {
226 | type: "list",
227 | position: {
228 | start: {
229 | line: 0,
230 | col: 0,
231 | offset: 0,
232 | },
233 | end: {
234 | line: 2,
235 | col: 14,
236 | offset: 25,
237 | },
238 | },
239 | },
240 | ],
241 | listItems: [
242 | {
243 | position: {
244 | start: {
245 | line: 0,
246 | col: 0,
247 | offset: 0,
248 | },
249 | end: {
250 | line: 0,
251 | col: 4,
252 | offset: 4,
253 | },
254 | },
255 | parent: -1,
256 | },
257 | {
258 | position: {
259 | start: {
260 | line: 1,
261 | col: 1,
262 | offset: 6,
263 | },
264 | end: {
265 | line: 1,
266 | col: 5,
267 | offset: 10,
268 | },
269 | },
270 | parent: 0,
271 | },
272 | {
273 | position: {
274 | start: {
275 | line: 2,
276 | col: 2,
277 | offset: 13,
278 | },
279 | end: {
280 | line: 2,
281 | col: 14,
282 | offset: 25,
283 | },
284 | },
285 | parent: 1,
286 | },
287 | ],
288 | };
289 |
290 | expect(
291 | createContextTree({
292 | positions: linksToTarget,
293 | fileContents,
294 | ...cache,
295 | })
296 | ).toMatchObject({
297 | branches: [
298 | {
299 | type: "list",
300 | text: "- l1",
301 | branches: [
302 | {
303 | type: "list",
304 | text: "- l2",
305 | sectionsWithMatches: [
306 | { text: expect.stringContaining("- [[target]]") },
307 | ],
308 | },
309 | ],
310 | },
311 | ],
312 | });
313 | });
314 |
315 | test.todo("builds a tree with headings & lists");
316 |
317 | test("gets only child list items to be displayed in a match section", () => {
318 | const fileContents = `- l1
319 | \t- [[target]]
320 | \t\t- child
321 | `;
322 |
323 | const linksToTarget = [
324 | {
325 | position: {
326 | start: {
327 | line: 1,
328 | col: 3,
329 | offset: 8,
330 | },
331 | end: {
332 | line: 1,
333 | col: 13,
334 | offset: 18,
335 | },
336 | },
337 | displayText: "target",
338 | },
339 | ];
340 |
341 | const cache = {
342 | links: [
343 | {
344 | position: {
345 | start: {
346 | line: 1,
347 | col: 3,
348 | offset: 8,
349 | },
350 | end: {
351 | line: 1,
352 | col: 13,
353 | offset: 18,
354 | },
355 | },
356 | displayText: "target",
357 | },
358 | ],
359 | sections: [
360 | {
361 | type: "list",
362 | position: {
363 | start: {
364 | line: 0,
365 | col: 0,
366 | offset: 0,
367 | },
368 | end: {
369 | line: 2,
370 | col: 9,
371 | offset: 28,
372 | },
373 | },
374 | },
375 | ],
376 | listItems: [
377 | {
378 | position: {
379 | start: {
380 | line: 0,
381 | col: 0,
382 | offset: 0,
383 | },
384 | end: {
385 | line: 0,
386 | col: 4,
387 | offset: 4,
388 | },
389 | },
390 | parent: -1,
391 | },
392 | {
393 | position: {
394 | start: {
395 | line: 1,
396 | col: 1,
397 | offset: 6,
398 | },
399 | end: {
400 | line: 1,
401 | col: 13,
402 | offset: 18,
403 | },
404 | },
405 | parent: 0,
406 | },
407 | {
408 | position: {
409 | start: {
410 | line: 2,
411 | col: 2,
412 | offset: 21,
413 | },
414 | end: {
415 | line: 2,
416 | col: 9,
417 | offset: 28,
418 | },
419 | },
420 | parent: 1,
421 | },
422 | ],
423 | };
424 |
425 | expect(
426 | createContextTree({
427 | positions: linksToTarget,
428 | fileContents,
429 | ...cache,
430 | })
431 | ).toMatchObject({
432 | branches: [
433 | {
434 | type: "list",
435 | text: "- l1",
436 | sectionsWithMatches: [
437 | {
438 | text: `- [[target]]
439 | \t- child`,
440 | },
441 | ],
442 | },
443 | ],
444 | });
445 | });
446 |
447 | test("gets section contents if the link is in a heading", () => {
448 | const fileContents = `# H1
449 | ## H2 [[target]]
450 | this is the water
451 | and this is the well
452 | `;
453 |
454 | const backlinks = [
455 | {
456 | position: {
457 | start: {
458 | line: 1,
459 | col: 6,
460 | offset: 11,
461 | },
462 | end: {
463 | line: 1,
464 | col: 16,
465 | offset: 21,
466 | },
467 | },
468 | displayText: "target",
469 | },
470 | ];
471 |
472 | const cache = {
473 | links: [
474 | {
475 | position: {
476 | start: {
477 | line: 1,
478 | col: 6,
479 | offset: 11,
480 | },
481 | end: {
482 | line: 1,
483 | col: 16,
484 | offset: 21,
485 | },
486 | },
487 | displayText: "target",
488 | },
489 | ],
490 | headings: [
491 | {
492 | position: {
493 | start: {
494 | line: 0,
495 | col: 0,
496 | offset: 0,
497 | },
498 | end: {
499 | line: 0,
500 | col: 4,
501 | offset: 4,
502 | },
503 | },
504 | heading: "H1",
505 | display: "H1",
506 | level: 1,
507 | },
508 | {
509 | position: {
510 | start: {
511 | line: 1,
512 | col: 0,
513 | offset: 5,
514 | },
515 | end: {
516 | line: 1,
517 | col: 16,
518 | offset: 21,
519 | },
520 | },
521 | heading: "H2 [[target]]",
522 | display: "H2 target",
523 | level: 2,
524 | },
525 | ],
526 | sections: [
527 | {
528 | type: "heading",
529 | position: {
530 | start: {
531 | line: 0,
532 | col: 0,
533 | offset: 0,
534 | },
535 | end: {
536 | line: 0,
537 | col: 4,
538 | offset: 4,
539 | },
540 | },
541 | },
542 | {
543 | type: "heading",
544 | position: {
545 | start: {
546 | line: 1,
547 | col: 0,
548 | offset: 5,
549 | },
550 | end: {
551 | line: 1,
552 | col: 16,
553 | offset: 21,
554 | },
555 | },
556 | },
557 | {
558 | type: "paragraph",
559 | position: {
560 | start: {
561 | line: 2,
562 | col: 0,
563 | offset: 22,
564 | },
565 | end: {
566 | line: 3,
567 | col: 20,
568 | offset: 60,
569 | },
570 | },
571 | },
572 | ],
573 | };
574 |
575 | expect(
576 | createContextTree({
577 | positions: backlinks,
578 | fileContents,
579 | ...cache,
580 | })
581 | ).toMatchObject({
582 | sectionsWithMatches: [],
583 | branches: [
584 | {
585 | type: "heading",
586 | text: "H1",
587 | branches: [
588 | {
589 | type: "heading",
590 | text: `H2 [[target]]`,
591 | sectionsWithMatches: [
592 | {
593 | text: `this is the water
594 | and this is the well`,
595 | },
596 | ],
597 | },
598 | ],
599 | },
600 | ],
601 | });
602 | });
603 |
--------------------------------------------------------------------------------