feat: fuzzy search engine and Spotlight-style UI

Custom fzy algorithm with fulltext and prefix modes, boxy dark-aware UI
with keyboard navigation, mode switching, match highlighting, and
auto-resizing iframe overlay.

Assisted-by: Claude Code
Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
This commit is contained in:
2026-04-20 17:15:23 +05:30
parent eae5309843
commit 4f69f3b3f4
5 changed files with 778 additions and 0 deletions
+268
View File
@@ -0,0 +1,268 @@
import { search } from "./search";
import type { SearchItem, SearchMode, SearchMethod, SearchResult, Message } from "./types";
const MODES: SearchMode[] = ["tabs", "history", "bookmarks", "closed", "all"];
const MODE_LABELS: Record<SearchMode, string> = {
tabs: "Tabs",
history: "History",
bookmarks: "Bookmarks",
closed: "Closed",
all: "All",
};
const METHODS: SearchMethod[] = ["fuzzy", "fulltext", "prefix"];
let currentMode: SearchMode = "tabs";
let currentMethod: SearchMethod = "fuzzy";
let results: SearchResult[] = [];
let selectedIndex = 0;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const input = document.getElementById("search-input") as HTMLInputElement;
const resultsContainer = document.getElementById("results") as HTMLDivElement;
const modeBar = document.getElementById("mode-bar") as HTMLDivElement;
const methodBadge = document.getElementById("method-badge") as HTMLSpanElement;
const root = document.getElementById("sciezka-root") as HTMLDivElement;
function notifyResize(): void {
const height = Math.min(root.scrollHeight, 520);
window.parent.postMessage({ type: "resize", height }, "*");
}
function sendMessage(msg: Message): Promise<unknown> {
return new Promise((resolve) => {
window.parent.postMessage(msg, "*");
const handler = (event: MessageEvent) => {
if (event.source === window.parent) {
window.removeEventListener("message", handler);
resolve(event.data);
}
};
window.addEventListener("message", handler);
});
}
function renderModeBar(): void {
modeBar.innerHTML = "";
for (let i = 0; i < MODES.length; i++) {
const mode = MODES[i];
const btn = document.createElement("button");
btn.className = `mode-btn${mode === currentMode ? " active" : ""}`;
btn.innerHTML = `<span class="mode-label">${MODE_LABELS[mode]}</span><kbd>${i + 1}</kbd>`;
btn.addEventListener("click", () => {
currentMode = mode;
renderModeBar();
doSearch();
});
modeBar.appendChild(btn);
}
}
function renderMethodBadge(): void {
methodBadge.textContent = currentMethod;
}
function highlightText(text: string, positions: number[], offset: number): string {
const chars = text.split("");
const posSet = new Set(positions.map((p) => p - offset).filter((p) => p >= 0 && p < text.length));
return chars
.map((c, i) => (posSet.has(i) ? `<mark>${escapeHtml(c)}</mark>` : escapeHtml(c)))
.join("");
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function typeIcon(type: SearchMode): string {
switch (type) {
case "tabs": return "&#x1F4C4;";
case "history": return "&#x1F552;";
case "bookmarks": return "&#x2B50;";
case "closed": return "&#x1F6AA;";
default: return "";
}
}
function renderResults(): void {
resultsContainer.innerHTML = "";
if (results.length === 0 && input.value) {
resultsContainer.innerHTML = `<div class="no-results">No results found</div>`;
notifyResize();
return;
}
if (results.length === 0 && !input.value) {
notifyResize();
return;
}
const visible = results.slice(0, 50);
for (let i = 0; i < visible.length; i++) {
const { item, positions } = visible[i];
const row = document.createElement("div");
row.className = `result-row${i === selectedIndex ? " selected" : ""}`;
row.dataset.index = String(i);
const titleHtml = highlightText(item.title, positions, 0);
const urlHtml = highlightText(item.url, positions, item.title.length + 1);
row.innerHTML = `
<span class="result-icon">${item.favIconUrl ? `<img src="${escapeHtml(item.favIconUrl)}" width="16" height="16" alt="">` : typeIcon(item.type)}</span>
<span class="result-text">
<span class="result-title">${titleHtml}</span>
<span class="result-url">${urlHtml}</span>
</span>
<span class="result-badge badge-${item.type}">${MODE_LABELS[item.type]}</span>
`;
row.addEventListener("click", () => activateResult(i));
row.addEventListener("mouseenter", () => {
selectedIndex = i;
updateSelection();
});
resultsContainer.appendChild(row);
}
scrollToSelected();
notifyResize();
}
function updateSelection(): void {
const rows = resultsContainer.querySelectorAll(".result-row");
rows.forEach((row, i) => {
row.classList.toggle("selected", i === selectedIndex);
});
}
function scrollToSelected(): void {
const selected = resultsContainer.querySelector(".selected");
selected?.scrollIntoView({ block: "nearest" });
}
function activateResult(index: number): void {
const result = results[index];
if (!result) return;
const { item } = result;
let action: string;
if (item.type === "tabs") {
action = "switch";
} else if (item.type === "closed") {
action = "restore";
} else {
action = "open";
}
const msg: Message = item.type === "tabs" || item.type === "closed"
? { type: "action", action: action as "switch" | "restore", id: item.id }
: { type: "action", action: "open", id: item.url };
sendMessage(msg);
window.parent.postMessage({ type: "closeSaka" }, "*");
}
async function doSearch(): Promise<void> {
const query = input.value;
const response = await sendMessage({
type: "search",
query,
mode: currentMode,
method: currentMethod,
} satisfies Message);
const data = response as { type: string; results: SearchItem[] };
const items = data.results ?? [];
results = search(items, query, currentMethod);
selectedIndex = 0;
renderResults();
}
input.addEventListener("input", () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(doSearch, 50);
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
window.parent.postMessage({ type: "closeSaka" }, "*");
return;
}
if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "j")) {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
updateSelection();
scrollToSelected();
return;
}
if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "k")) {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
updateSelection();
scrollToSelected();
return;
}
if (e.key === "Enter") {
e.preventDefault();
if (e.ctrlKey && e.shiftKey) {
const result = results[selectedIndex];
if (result && result.item.type !== "tabs") {
sendMessage({ type: "action", action: "open", id: result.item.url, newTab: true });
window.parent.postMessage({ type: "closeSaka" }, "*");
}
} else {
activateResult(selectedIndex);
}
return;
}
if (e.key === "Tab") {
e.preventDefault();
const dir = e.shiftKey ? -1 : 1;
const idx = MODES.indexOf(currentMode);
currentMode = MODES[(idx + dir + MODES.length) % MODES.length];
renderModeBar();
doSearch();
return;
}
if (e.ctrlKey && e.key === "f") {
e.preventDefault();
const idx = METHODS.indexOf(currentMethod);
currentMethod = METHODS[(idx + 1) % METHODS.length];
renderMethodBadge();
doSearch();
return;
}
if (e.ctrlKey && e.key === "d") {
e.preventDefault();
const result = results[selectedIndex];
if (result && result.item.type === "tabs") {
sendMessage({ type: "action", action: "close", id: result.item.id });
results.splice(selectedIndex, 1);
if (selectedIndex >= results.length) selectedIndex = Math.max(results.length - 1, 0);
renderResults();
}
return;
}
if (e.ctrlKey && e.key >= "1" && e.key <= "5") {
e.preventDefault();
const modeIdx = parseInt(e.key, 10) - 1;
if (modeIdx < MODES.length) {
currentMode = MODES[modeIdx];
renderModeBar();
doSearch();
}
return;
}
});
document.addEventListener("DOMContentLoaded", () => {
renderModeBar();
renderMethodBadge();
input.focus();
doSearch();
});
+143
View File
@@ -0,0 +1,143 @@
import type { SearchItem, SearchMethod, SearchResult } from "./types";
const SCORE_GAP_LEADING = -0.005;
const SCORE_GAP_TRAILING = -0.005;
const SCORE_GAP_INNER = -0.01;
const SCORE_MATCH_CONSECUTIVE = 1.0;
const SCORE_MATCH_SLASH = 0.9;
const SCORE_MATCH_WORD = 0.8;
const SCORE_MATCH_CAPITAL = 0.7;
const SCORE_MATCH_DOT = 0.6;
const BONUS_FIRST_CHAR = 0.6;
function isSlash(c: string): boolean {
return c === "/" || c === "\\";
}
function computeBonus(prev: string, curr: string): number {
if (isSlash(prev)) return SCORE_MATCH_SLASH;
if (prev === "_" || prev === "-" || prev === " ") return SCORE_MATCH_WORD;
if (prev === ".") return SCORE_MATCH_DOT;
if (prev === prev.toLowerCase() && curr === curr.toUpperCase()) return SCORE_MATCH_CAPITAL;
return 0;
}
function fuzzyMatch(needle: string, haystack: string): { score: number; positions: number[] } | null {
const n = needle.length;
const m = haystack.length;
if (n === 0) return { score: 0, positions: [] };
if (n > m) return null;
const needleLower = needle.toLowerCase();
const haystackLower = haystack.toLowerCase();
let ni = 0;
for (let hi = 0; hi < m; hi++) {
if (needleLower[ni] === haystackLower[hi]) ni++;
if (ni === n) break;
}
if (ni < n) return null;
const D: number[][] = Array.from({ length: n }, () => new Array(m).fill(0));
const M: number[][] = Array.from({ length: n }, () => new Array(m).fill(0));
for (let i = 0; i < n; i++) {
let prevScore = -Infinity;
let gapScore = i === n - 1 ? SCORE_GAP_TRAILING : SCORE_GAP_INNER;
for (let j = 0; j < m; j++) {
if (needleLower[i] === haystackLower[j]) {
let score = 0;
if (i === 0) {
score = j * SCORE_GAP_LEADING;
if (j === 0) score += BONUS_FIRST_CHAR;
else score += computeBonus(haystack[j - 1], haystack[j]);
} else if (j > 0) {
const bonus = computeBonus(haystack[j - 1], haystack[j]);
score = Math.max(
M[i - 1][j - 1] + bonus,
D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE
);
}
D[i][j] = score;
M[i][j] = Math.max(score, prevScore + gapScore);
} else {
D[i][j] = -Infinity;
M[i][j] = prevScore + gapScore;
}
prevScore = M[i][j];
}
}
let bestJ = 0;
for (let j = 1; j < m; j++) {
if (M[n - 1][j] > M[n - 1][bestJ]) bestJ = j;
}
const positions: number[] = new Array(n);
let i = n - 1;
let j = bestJ;
while (i >= 0 && j >= 0) {
if (D[i][j] !== -Infinity && (i === 0 || j === 0 || D[i][j] >= M[i][j])) {
positions[i] = j;
i--;
j--;
} else {
j--;
}
}
return { score: M[n - 1][bestJ], positions };
}
function fulltextMatch(needle: string, haystack: string): { score: number; positions: number[] } | null {
const idx = haystack.toLowerCase().indexOf(needle.toLowerCase());
if (idx === -1) return null;
const positions = Array.from({ length: needle.length }, (_, i) => idx + i);
let score = 1.0;
if (idx === 0) score += 0.5;
score -= idx * 0.01;
return { score, positions };
}
function prefixMatch(needle: string, haystack: string): { score: number; positions: number[] } | null {
const words = haystack.split(/[\s\-_/.:]+/);
const needleLower = needle.toLowerCase();
let offset = 0;
for (const word of words) {
if (word.toLowerCase().startsWith(needleLower)) {
const start = haystack.toLowerCase().indexOf(word.toLowerCase(), offset);
const positions = Array.from({ length: needle.length }, (_, i) => start + i);
let score = 1.0;
if (start === 0) score += 0.5;
return { score, positions };
}
offset += word.length + 1;
}
return null;
}
export function search(items: SearchItem[], query: string, method: SearchMethod): SearchResult[] {
if (!query) {
return items.map((item) => ({ item, score: 0, positions: [] }));
}
const matchFn = method === "fuzzy" ? fuzzyMatch
: method === "fulltext" ? fulltextMatch
: prefixMatch;
const results: SearchResult[] = [];
for (const item of items) {
const haystack = `${item.title} ${item.url}`;
const match = matchFn(query, haystack);
if (match) {
results.push({ item, score: match.score, positions: match.positions });
}
}
results.sort((a, b) => b.score - a.score);
return results;
}