├── .npmrc ├── .eslintignore ├── versions.json ├── click-demo.gif ├── hover-demo.gif ├── src ├── patterns.ts ├── plugin.ts ├── ui │ └── solid │ │ ├── icons │ │ ├── collapse-icon.tsx │ │ ├── list-icon.tsx │ │ ├── heading-icon.tsx │ │ ├── arrow-right-icon.tsx │ │ ├── circle-icon.tsx │ │ └── sliders-horizontal-icon.tsx │ │ ├── render-context-tree.tsx │ │ ├── tree.tsx │ │ ├── match-section.tsx │ │ ├── branch.tsx │ │ ├── title.tsx │ │ └── plugin-context.tsx ├── metadata-cache-util │ ├── format.ts │ ├── section.ts │ ├── heading.ts │ ├── list.ts │ └── position.ts ├── context-tree │ ├── collapse │ │ ├── collapse-empty-nodes.ts │ │ └── collapse-empty-nodes.test.js │ ├── dedupe │ │ ├── dedupe-matches.ts │ │ └── dedupe-matches.test.js │ └── create │ │ ├── create-context-tree.ts │ │ └── create-context-tree.test.js ├── disposer-registry.ts ├── types.ts └── patcher.ts ├── .stylelintrc.json ├── babel.config.js ├── .editorconfig ├── manifest.json ├── .gitignore ├── tsconfig.json ├── version-bump.mjs ├── .github └── FUNDING.yml ├── .eslintrc ├── LICENCE ├── esbuild.config.mjs ├── package.json ├── README.md └── styles.css /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.2.1": "0.15.0", 3 | "0.2.2": "0.15.0", 4 | "0.3.0": "0.16.0" 5 | } -------------------------------------------------------------------------------- /click-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-lednev/better-search-views/HEAD/click-demo.gif -------------------------------------------------------------------------------- /hover-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivan-lednev/better-search-views/HEAD/hover-demo.gif -------------------------------------------------------------------------------- /src/patterns.ts: -------------------------------------------------------------------------------- 1 | export const wikiLinkBrackets = /\[\[|]]/g; 2 | export const listItemToken = /^-\s+/; 3 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-standard", 4 | "stylelint-config-clean-order" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ["@babel/preset-env", { targets: { node: "current" } }], 4 | "@babel/preset-typescript", 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 2 10 | tab_width = 2 11 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "obsidian"; 2 | import { Patcher } from "./patcher"; 3 | 4 | export default class BetterBacklinksPlugin extends Plugin { 5 | async onload() { 6 | const patcher = new Patcher(this); 7 | patcher.patchComponent(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "better-search-views", 3 | "name": "Better Search Views", 4 | "version": "0.3.0", 5 | "minAppVersion": "0.16.0", 6 | "description": "Outliner-like breadcrumb trees for search, backlinks and embedded queries ", 7 | "author": "ivan-lednev", 8 | "authorUrl": "https://github.com/ivan-lednev", 9 | "fundingUrl": "https://www.buymeacoffee.com/machineelf", 10 | "isDesktopOnly": false 11 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /src/ui/solid/icons/collapse-icon.tsx: -------------------------------------------------------------------------------- 1 | export function CollapseIcon() { 2 | return ( 3 | 15 | 16 | 17 | ); 18 | } -------------------------------------------------------------------------------- /src/ui/solid/icons/list-icon.tsx: -------------------------------------------------------------------------------- 1 | export function ListIcon() { 2 | return ( 3 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/ui/solid/icons/heading-icon.tsx: -------------------------------------------------------------------------------- 1 | export function HeadingIcon() { 2 | return ( 3 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/metadata-cache-util/format.ts: -------------------------------------------------------------------------------- 1 | import { ListItemCache } from "obsidian"; 2 | import { getTextFromLineStartToPositionEnd } from "./position"; 3 | 4 | export const formatListWithDescendants = ( 5 | textInput: string, 6 | listItems: ListItemCache[] 7 | ) => { 8 | const root = listItems[0]; 9 | const leadingSpacesCount = root.position.start.col; 10 | return listItems 11 | .map((itemCache) => 12 | getTextFromLineStartToPositionEnd(textInput, itemCache.position).slice( 13 | leadingSpacesCount 14 | ) 15 | ) 16 | .join("\n"); 17 | }; 18 | -------------------------------------------------------------------------------- /src/ui/solid/icons/arrow-right-icon.tsx: -------------------------------------------------------------------------------- 1 | export function ArrowRightIcon() { 2 | return ( 3 | 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/metadata-cache-util/section.ts: -------------------------------------------------------------------------------- 1 | import { Pos, SectionCache } from "obsidian"; 2 | import { doesPositionIncludeAnother } from "./position"; 3 | 4 | export function getSectionContaining( 5 | searchedForPosition: Pos, 6 | sections: SectionCache[] 7 | ) { 8 | return sections.find(({ position }) => 9 | doesPositionIncludeAnother(position, searchedForPosition) 10 | ); 11 | } 12 | 13 | export function getFirstSectionUnder(position: Pos, sections: SectionCache[]) { 14 | return sections.find( 15 | (section) => section.position.start.line > position.start.line 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/ui/solid/icons/circle-icon.tsx: -------------------------------------------------------------------------------- 1 | export function CircleIcon() { 2 | return ( 3 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7", 19 | "ES2022" 20 | ], 21 | "jsx": "preserve", 22 | "jsxImportSource": "solid-js", 23 | "allowSyntheticDefaultImports": true 24 | }, 25 | "include": [ 26 | "**/*.ts" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: 14 | - https://www.buymeacoffee.com/machineelf 15 | -------------------------------------------------------------------------------- /src/context-tree/collapse/collapse-empty-nodes.ts: -------------------------------------------------------------------------------- 1 | import { CollapsedContextTree } from "../../types"; 2 | 3 | export function collapseEmptyNodes( 4 | tree: CollapsedContextTree 5 | ): CollapsedContextTree { 6 | if (!tree?.sectionsWithMatches?.length && tree.branches.length === 1) { 7 | const firstBranch = tree.branches[0]; 8 | 9 | const breadcrumb = { 10 | text: firstBranch.text, 11 | type: firstBranch.type, 12 | position: firstBranch.cacheItem?.position, 13 | }; 14 | 15 | return collapseEmptyNodes({ 16 | ...tree, 17 | branches: firstBranch.branches, 18 | breadcrumbs: [...(tree.breadcrumbs || []), breadcrumb], 19 | sectionsWithMatches: firstBranch.sectionsWithMatches, 20 | }); 21 | } 22 | 23 | return { ...tree, branches: tree.branches.map(collapseEmptyNodes) }; 24 | } 25 | -------------------------------------------------------------------------------- /src/context-tree/dedupe/dedupe-matches.ts: -------------------------------------------------------------------------------- 1 | import { ContextTree, SectionWithMatch } from "../../types"; 2 | import { isSamePosition } from "../../metadata-cache-util/position"; 3 | 4 | export function dedupeMatches(tree: ContextTree): ContextTree { 5 | return { 6 | ...tree, 7 | sectionsWithMatches: dedupe(tree.sectionsWithMatches), 8 | branches: tree.branches.map((branch) => dedupeMatches(branch)), 9 | }; 10 | } 11 | 12 | function areMatchesInSameSection(a: SectionWithMatch, b: SectionWithMatch) { 13 | return ( 14 | a.text === b.text && isSamePosition(a.cache.position, b.cache.position) 15 | ); 16 | } 17 | 18 | function dedupe(matches: SectionWithMatch[]) { 19 | return matches.filter( 20 | (match: SectionWithMatch, index: number, array: SectionWithMatch[]) => 21 | index === 22 | array.findIndex((inner) => areMatchesInSameSection(inner, match)) 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/ui/solid/render-context-tree.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "solid-js/web"; 2 | import { PluginContextProvider } from "./plugin-context"; 3 | import { Tree } from "./tree"; 4 | import { ContextTree } from "../../types"; 5 | import BetterBacklinksPlugin from "../../plugin"; 6 | 7 | interface RenderContextTreeProps { 8 | contextTree: ContextTree; 9 | highlights: string[]; 10 | el: HTMLElement; 11 | plugin: BetterBacklinksPlugin; 12 | infinityScroll: any; 13 | } 14 | 15 | export function renderContextTree({ 16 | contextTree, 17 | el, 18 | plugin, 19 | highlights, 20 | infinityScroll 21 | }: RenderContextTreeProps) { 22 | return render( 23 | () => ( 24 | 25 | 26 | 27 | ), 28 | el 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { 5 | "node": true 6 | }, 7 | "plugins": [ 8 | "@typescript-eslint" 9 | ], 10 | "extends": [ 11 | "eslint:recommended", 12 | "plugin:@typescript-eslint/eslint-recommended", 13 | "plugin:@typescript-eslint/recommended", 14 | "plugin:jest/recommended" 15 | ], 16 | "parserOptions": { 17 | "sourceType": "module" 18 | }, 19 | "rules": { 20 | "no-unused-vars": "off", 21 | "@typescript-eslint/no-unused-vars": [ 22 | "error", 23 | { 24 | "args": "none" 25 | } 26 | ], 27 | "@typescript-eslint/ban-ts-comment": "off", 28 | "no-prototype-builtins": "off", 29 | "@typescript-eslint/no-empty-function": "off", 30 | "@typescript-eslint/no-this-alias": [ 31 | "error", 32 | { 33 | "allowedNames": [ 34 | "patcher" 35 | ] 36 | } 37 | ] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/ui/solid/icons/sliders-horizontal-icon.tsx: -------------------------------------------------------------------------------- 1 | export function SlidersHorizontalIcon() { 2 | return ( 3 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/ui/solid/tree.tsx: -------------------------------------------------------------------------------- 1 | import { Accessor, createEffect, For } from "solid-js"; 2 | import { Branch } from "./branch"; 3 | import { CollapsedContextTree, ContextTree } from "../../types"; 4 | import { collapseEmptyNodes } from "../../context-tree/collapse/collapse-empty-nodes"; 5 | import Mark from "mark.js"; 6 | 7 | interface TreeProps { 8 | fileContextTree: ContextTree; 9 | highlights: string[]; 10 | } 11 | 12 | export function Tree(props: TreeProps) { 13 | const collapsedTree: Accessor = () => ({ 14 | ...props.fileContextTree, 15 | branches: props.fileContextTree.branches.map(collapseEmptyNodes), 16 | }); 17 | 18 | let markContextRef: HTMLDivElement; 19 | 20 | createEffect(() => { 21 | new Mark(markContextRef).mark(props.highlights, { 22 | element: "span", 23 | className: "search-result-file-matched-text", 24 | separateWordSearch: false, 25 | diacritics: false, 26 | }); 27 | }); 28 | 29 | return ( 30 | // @ts-ignore 31 |
32 | 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Ivan Liadniou 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/disposer-registry.ts: -------------------------------------------------------------------------------- 1 | import { Disposer } from "./types"; 2 | 3 | export class DisposerRegistry { 4 | private contextDom: any; 5 | private domToDisposers: WeakMap> = new WeakMap(); 6 | 7 | /** 8 | * We assume here that match results are going to be added synchronously to the same dom, on which onAddResult is 9 | * called 10 | * @param searchResultDom 11 | */ 12 | onAddResult(searchResultDom: any) { 13 | this.contextDom = searchResultDom; 14 | if (!this.domToDisposers.has(this)) { 15 | this.domToDisposers.set(this, []); 16 | } 17 | } 18 | 19 | addOnEmptyResultsCallback(fn: Disposer) { 20 | if (!this.contextDom) { 21 | throw new Error( 22 | "You rendered a Solid root before you got a reference to the containing searchResultDom" 23 | ); 24 | } 25 | this.domToDisposers.get(this.contextDom)?.push(fn); 26 | } 27 | 28 | /** 29 | * This may be called before any results are added 30 | * @param searchResultDom 31 | */ 32 | onEmptyResults(searchResultDom: any) { 33 | this.domToDisposers.get(searchResultDom)?.forEach((disposer) => disposer()); 34 | this.domToDisposers.set(searchResultDom, []); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | import {solidPlugin} from "esbuild-plugin-solid"; 5 | 6 | const banner = 7 | `/* 8 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 9 | if you want to view the source, please visit the github repository of this plugin 10 | */ 11 | `; 12 | 13 | const prod = (process.argv[2] === "production"); 14 | 15 | const context = await esbuild.context({ 16 | banner: { 17 | js: banner, 18 | }, 19 | entryPoints: ["src/plugin.ts"], 20 | bundle: true, 21 | external: [ 22 | "obsidian", 23 | "electron", 24 | "@codemirror/autocomplete", 25 | "@codemirror/collab", 26 | "@codemirror/commands", 27 | "@codemirror/language", 28 | "@codemirror/lint", 29 | "@codemirror/search", 30 | "@codemirror/state", 31 | "@codemirror/view", 32 | "@lezer/common", 33 | "@lezer/highlight", 34 | "@lezer/lr", 35 | ...builtins], 36 | format: "cjs", 37 | target: "es2018", 38 | logLevel: "info", 39 | sourcemap: prod ? false : "inline", 40 | treeShaking: true, 41 | outfile: "main.js", 42 | plugins: [solidPlugin()] 43 | }); 44 | 45 | if (prod) { 46 | await context.rebuild(); 47 | process.exit(0); 48 | } else { 49 | await context.watch(); 50 | } 51 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CacheItem, 3 | FileStats, 4 | HeadingCache, 5 | LinkCache, 6 | ListItemCache, 7 | Pos, 8 | SectionCache, 9 | } from "obsidian"; 10 | 11 | export interface createContextTreeProps { 12 | // todo: better naming. Separate metadata cache? 13 | positions: LinkCache[]; 14 | stat: FileStats; 15 | fileContents: string; 16 | filePath: string; 17 | listItems?: ListItemCache[]; 18 | headings?: HeadingCache[]; 19 | sections?: SectionCache[]; 20 | } 21 | 22 | export interface SectionWithMatch { 23 | text: string; 24 | cache: SectionCache; 25 | filePath: string; 26 | match?: { position: Pos }; 27 | } 28 | 29 | export type TreeType = "heading" | "list" | "file"; 30 | 31 | export interface ContextTree { 32 | text: string; 33 | filePath: string; 34 | type: TreeType; 35 | branches: ContextTree[]; 36 | sectionsWithMatches: SectionWithMatch[]; 37 | cacheItem: CacheItem; 38 | stat: FileStats; 39 | } 40 | 41 | export interface CollapsedContextTree extends ContextTree { 42 | breadcrumbs?: Breadcrumb[]; 43 | } 44 | 45 | export interface Breadcrumb { 46 | text: string; 47 | type: TreeType; 48 | position: Pos; 49 | } 50 | 51 | export type MouseOverEvent = MouseEvent & { 52 | currentTarget: Element; 53 | target: Element; 54 | }; 55 | 56 | export interface Disposer { 57 | (): void; 58 | } 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "better-search-views", 3 | "version": "0.3.0", 4 | "description": "Outliner-like breadcrumb trees for search, backlinks and embedded queries ", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/node": "^16.11.6", 16 | "@typescript-eslint/eslint-plugin": "5.29.0", 17 | "@typescript-eslint/parser": "5.29.0", 18 | "builtin-modules": "3.3.0", 19 | "esbuild": "0.17.3", 20 | "lodash": "^4.17.21", 21 | "obsidian": "latest", 22 | "stylelint": "^15.10.2", 23 | "stylelint-config-standard": "^34.0.0", 24 | "tslib": "2.4.0", 25 | "typescript": "4.7.4" 26 | }, 27 | "dependencies": { 28 | "@babel/preset-env": "^7.22.9", 29 | "@babel/preset-typescript": "^7.22.5", 30 | "@solid-primitives/pagination": "^0.2.7", 31 | "@types/jest": "^29.5.3", 32 | "@types/mark.js": "^8.11.8", 33 | "babel-jest": "^29.6.1", 34 | "babel-preset-solid": "^1.7.7", 35 | "esbuild-plugin-solid": "^0.5.0", 36 | "eslint-plugin-jest": "^27.2.3", 37 | "jest": "^29.6.1", 38 | "mark.js": "^8.11.1", 39 | "monkey-around": "^2.3.0", 40 | "solid-js": "^1.7.8", 41 | "stylelint-config-clean-order": "^5.0.1" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/ui/solid/match-section.tsx: -------------------------------------------------------------------------------- 1 | import { For, onCleanup, Show } from "solid-js"; 2 | import { Component, MarkdownRenderer } from "obsidian"; 3 | import { usePluginContext } from "./plugin-context"; 4 | import { SectionWithMatch } from "../../types"; 5 | 6 | interface MatchSectionProps { 7 | sectionsWithMatches: SectionWithMatch[]; 8 | } 9 | 10 | export function MatchSection(props: MatchSectionProps) { 11 | const { handleClick, handleMouseover, app } = usePluginContext(); 12 | const matchLifecycleManager = new Component(); 13 | 14 | onCleanup(() => { 15 | matchLifecycleManager.unload(); 16 | }); 17 | 18 | return ( 19 | 0}> 20 |
21 | 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 | </div> 43 | </Show> 44 | <div class={isHidden() ? "better-search-views-is-hidden" : ""}> 45 | <MatchSection 46 | sectionsWithMatches={props.contextTree.sectionsWithMatches} 47 | /> 48 | <div class="better-search-views-tree-item-children"> 49 | <For each={props.contextTree.branches}> 50 | {(branch) => <Branch contextTree={branch} />} 51 | </For> 52 | </div> 53 | </div> 54 | </div> 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 | <div class="better-search-views-titles-container"> 23 | <For each={props.breadcrumbs}> 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 | <div class="tree-item-inner"> 41 | <div 42 | class="better-search-views-breadcrumb-container" 43 | onClick={handleTitleClick} 44 | onMouseOver={handleTitleMouseover} 45 | > 46 | <div class="better-search-views-breadcrumb-token"> 47 | <Switch fallback={<ListIcon />}> 48 | <Match when={breadcrumb.type === "heading"}> 49 | <HeadingIcon /> 50 | </Match> 51 | </Switch> 52 | </div> 53 | <div>{removeListToken(breadcrumb.text)}</div> 54 | </div> 55 | </div> 56 | ); 57 | }} 58 | </For> 59 | </div> 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}<span class="search-result-file-matched-text">${highlight}</span>${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<void>; 18 | handleMouseover: (event: MouseOverEvent, path: string, line?: number) => void; 19 | handleHeightChange: () => void; 20 | plugin: BetterBacklinksPlugin; 21 | app: App; 22 | } 23 | 24 | const PluginContext = createContext<PluginContextValue>(); 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 | <PluginContext.Provider 94 | value={{ 95 | handleClick, 96 | handleMouseover, 97 | handleHeightChange, 98 | plugin: props.plugin, 99 | app: props.plugin.app, 100 | }} 101 | > 102 | {props.children} 103 | </PluginContext.Provider> 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 | ![image](https://github.com/ivan-lednev/better-search-views/assets/41428836/4069c2ef-6ec9-4a87-9881-2d300cddd10e) 17 | 18 | ### After 'Better Search Views' they look like this: 19 | 20 | ![image](https://github.com/ivan-lednev/better-search-views/assets/41428836/b191f14a-b75c-46d9-a19c-a8f91cafcd9f) 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 | ![image](https://github.com/ivan-lednev/better-search-views/assets/41428836/953b2de2-cc9a-496c-ad85-27f0c361424a) 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 | ![image](https://github.com/ivan-lednev/better-search-views/assets/41428836/2f5229bc-8d3d-4027-b01c-fa36d5872716) 40 | 41 | ### Embedded queries look like this 42 | 43 | ![image](https://github.com/ivan-lednev/better-search-views/assets/41428836/bdf7fb5d-dcc2-4067-9abb-9c2064c09a27) 44 | 45 | ### Clicking on breadcrumbs works just as you might expect 46 | 47 | ![image](click-demo.gif) 48 | 49 | ### Hovering over any element with the control key pressed triggers a pop-up 50 | 51 | ![image](hover-demo.gif) 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: <bishop1860@gmail.com>. 58 | 59 | You can also support the development directly: 60 | 61 | <a href="https://www.buymeacoffee.com/machineelf" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a> 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 | --------------------------------------------------------------------------------