v0.10.0 0f4e9eb

find.ts RawHistoryFolder

1/**
2 * Find in Page Functionality
3 *
4 * Text search and highlighting implementation
5 */
6
7import type { FindMatch, HighlightNode } from "./types";
8import { omniboxStore } from "./stores.svelte.ts";
9
10// Track whether we've injected highlight styles into the main document.
11let findStylesInjected = false;
12
13function ensureFindStyles(): void {
14  if (findStylesInjected) {
15    return;
16  }
17
18  try {
19    const style = document.createElement("style");
20    style.id = "dumber-find-styles";
21    style.textContent = `
22      .find-highlight {
23        background-color: rgb(255 235 59);
24        color: rgb(0 0 0);
25        padding: 0 0.125rem;
26        border-radius: 0.125rem;
27        box-shadow: 0 0 0 1px rgb(200 185 0) inset;
28      }
29
30      .find-active {
31        background-color: rgb(255 152 0) !important;
32        color: rgb(0 0 0);
33        box-shadow: 0 0 0 1px rgb(179 107 0) inset !important;
34      }
35    `;
36
37    (document.head || document.documentElement).appendChild(style);
38    findStylesInjected = true;
39  } catch (error) {
40    console.warn("Failed to inject find styles:", error);
41  }
42}
43
44/**
45 * Find text in the current page and highlight matches
46 */
47export function findInPage(query: string): void {
48  // Clear previous highlights
49  omniboxStore.clearHighlights();
50
51  const trimmedQuery = (query || "").trim();
52  if (!trimmedQuery) {
53    return;
54  }
55
56  const body = document.body;
57  if (!body) {
58    return;
59  }
60
61  ensureFindStyles();
62
63  // Get omnibox root to exclude from search
64  const omniboxRoot = document.getElementById("dumber-omnibox-root");
65  const matches: FindMatch[] = [];
66  const maxMatches = omniboxStore.config.maxMatches;
67
68  // Create tree walker to find text nodes
69  const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, {
70    acceptNode(node: Text): number {
71      // Skip empty or whitespace-only nodes
72      if (!node.nodeValue || !node.nodeValue.trim()) {
73        return NodeFilter.FILTER_REJECT;
74      }
75
76      const parentElement = node.parentElement;
77      if (!parentElement) {
78        return NodeFilter.FILTER_REJECT;
79      }
80
81      // Skip omnibox content
82      if (omniboxRoot && omniboxRoot.contains(parentElement)) {
83        return NodeFilter.FILTER_REJECT;
84      }
85
86      // Skip script, style, and noscript tags
87      const tagName = parentElement.tagName;
88      if (
89        tagName === "SCRIPT" ||
90        tagName === "STYLE" ||
91        tagName === "NOSCRIPT"
92      ) {
93        return NodeFilter.FILTER_REJECT;
94      }
95
96      // Skip hidden elements
97      const computedStyle = getComputedStyle(parentElement);
98      if (
99        computedStyle.visibility === "hidden" ||
100        computedStyle.display === "none"
101      ) {
102        return NodeFilter.FILTER_REJECT;
103      }
104
105      return NodeFilter.FILTER_ACCEPT;
106    },
107  });
108
109  const queryLowerCase = trimmedQuery.toLowerCase();
110
111  // Process each text node
112  while (walker.nextNode() && matches.length < maxMatches) {
113    const textNode = walker.currentNode as Text;
114    let nodeValue = textNode.nodeValue || "";
115    let searchIndex = 0;
116
117    // Find all matches in this text node
118    while (nodeValue && matches.length < maxMatches) {
119      const matchIndex = nodeValue
120        .toLowerCase()
121        .indexOf(queryLowerCase, searchIndex);
122      if (matchIndex === -1) {
123        break;
124      }
125
126      // Create text nodes for before, match, and after
127      const beforeText = nodeValue.slice(0, matchIndex);
128      const matchText = nodeValue.slice(
129        matchIndex,
130        matchIndex + trimmedQuery.length,
131      );
132      const afterText = nodeValue.slice(matchIndex + trimmedQuery.length);
133
134      // Create elements
135      const beforeNode = document.createTextNode(beforeText);
136      const matchTextNode = document.createTextNode(matchText);
137      const highlightSpan = document.createElement("span");
138
139      // Style the highlight span
140      highlightSpan.className = "find-highlight";
141      highlightSpan.appendChild(matchTextNode);
142
143      // Insert into DOM
144      const parent = textNode.parentNode;
145      if (!parent) {
146        break;
147      }
148
149      parent.insertBefore(beforeNode, textNode);
150      parent.insertBefore(highlightSpan, textNode);
151      textNode.nodeValue = afterText;
152
153      // Track highlight node for cleanup
154      const highlightNode: HighlightNode = {
155        span: highlightSpan,
156        text: matchTextNode,
157      };
158      omniboxStore.addHighlightNode(highlightNode);
159
160      // Create match context
161      const leftContext = beforeText.slice(-30);
162      let rightContext = afterText.slice(0, 30);
163
164      // Try to break at word boundaries
165      const boundaryMatch = rightContext.match(/[.,;:-]/);
166      if (boundaryMatch && boundaryMatch.index !== undefined) {
167        rightContext = rightContext.slice(0, boundaryMatch.index + 1);
168      }
169
170      const context = (leftContext + matchText + rightContext)
171        .replace(/\s+/g, " ")
172        .trim();
173
174      // Add to matches
175      const findMatch: FindMatch = {
176        element: highlightSpan,
177        context,
178      };
179      matches.push(findMatch);
180
181      // Continue searching in the remaining text
182      nodeValue = afterText;
183      searchIndex = 0;
184    }
185  }
186
187  // Update store with matches
188  omniboxStore.updateMatches(matches);
189
190  // Reveal first match if any
191  if (matches.length > 0) {
192    revealMatch(0);
193  }
194}
195
196/**
197 * Reveal a specific match by index
198 */
199export function revealMatch(index: number): void {
200  const matches = omniboxStore.matches;
201  if (index < 0 || index >= matches.length) {
202    return;
203  }
204
205  // Remove previous active class
206  const prevActiveIndex = omniboxStore.activeIndex;
207  if (prevActiveIndex >= 0 && matches[prevActiveIndex]) {
208    const prevElement = matches[prevActiveIndex].element;
209    if (prevElement.classList) {
210      prevElement.classList.remove("find-active");
211    }
212  }
213
214  // Add active class to current match
215  const currentMatch = matches[index];
216  if (currentMatch && currentMatch.element.classList) {
217    currentMatch.element.classList.add("find-active");
218    omniboxStore.setActiveIndex(index);
219
220    // Scroll into view
221    try {
222      currentMatch.element.scrollIntoView({
223        block: "center",
224        inline: "nearest",
225      });
226    } catch {
227      currentMatch.element.scrollIntoView();
228    }
229  }
230}
231
232/**
233 * Jump to next/previous match
234 */
235export function jumpToMatch(delta: number): void {
236  const matches = omniboxStore.matches;
237  if (matches.length === 0) {
238    return;
239  }
240
241  const currentIndex = omniboxStore.selectedIndex || 0;
242  const newIndex =
243    (((currentIndex + delta) % matches.length) + matches.length) %
244    matches.length;
245
246  omniboxStore.setSelectedIndex(newIndex);
247  revealMatch(newIndex);
248}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close