From 4f69f3b3f4cd8a273c9fb19f8580536aeb519ecb Mon Sep 17 00:00:00 2001 From: Avinal Kumar Date: Mon, 20 Apr 2026 17:15:23 +0530 Subject: [PATCH] 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 --- icons/border-48.png | Bin 0 -> 225 bytes sciezka.css | 339 ++++++++++++++++++++++++++++++++++++++++++++ sciezka.html | 28 ++++ src/sciezka.ts | 268 ++++++++++++++++++++++++++++++++++ src/search.ts | 143 +++++++++++++++++++ 5 files changed, 778 insertions(+) create mode 100644 icons/border-48.png create mode 100644 sciezka.css create mode 100644 sciezka.html create mode 100644 src/sciezka.ts create mode 100644 src/search.ts diff --git a/icons/border-48.png b/icons/border-48.png new file mode 100644 index 0000000000000000000000000000000000000000..90687de26d71e91b7c82565772a7df470ae277a6 GIT binary patch literal 225 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTCmSQK*5Dp-y;YjHK@;M7UB8wRq zxP?KOkzv*x37}xJr;B4qM&sM7j(iOY0?rpNR{Ym~eNUieh4I>d+mEvHuIy!K@bZ41 zJ}N$e^&*#q7kxbW`Aeg?)>n&l0$ z8xrIlb~3+dVExT-N;ZLA=LS%o!8+lf-GRA$F@Klex9jiV-^0Mj@Zdh*s& + + + + + + +
+ +
+
+ +
+ + + diff --git a/src/sciezka.ts b/src/sciezka.ts new file mode 100644 index 0000000..0b6aed9 --- /dev/null +++ b/src/sciezka.ts @@ -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 = { + 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 | 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 { + 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 = `${MODE_LABELS[mode]}${i + 1}`; + 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) ? `${escapeHtml(c)}` : escapeHtml(c))) + .join(""); +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function typeIcon(type: SearchMode): string { + switch (type) { + case "tabs": return "📄"; + case "history": return "🕒"; + case "bookmarks": return "⭐"; + case "closed": return "🚪"; + default: return ""; + } +} + +function renderResults(): void { + resultsContainer.innerHTML = ""; + if (results.length === 0 && input.value) { + resultsContainer.innerHTML = `
No results found
`; + 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 = ` + ${item.favIconUrl ? `` : typeIcon(item.type)} + + ${titleHtml} + ${urlHtml} + + ${MODE_LABELS[item.type]} + `; + + 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 { + 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(); +}); diff --git a/src/search.ts b/src/search.ts new file mode 100644 index 0000000..623a4dd --- /dev/null +++ b/src/search.ts @@ -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; +}