feat: project infrastructure and MV3 extension setup

TypeScript + esbuild build system, Manifest V3 with background script,
content script for iframe overlay injection, and typed message contracts
between extension contexts.

Assisted-by: Claude Code
Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
This commit is contained in:
2026-04-20 17:14:35 +05:30
parent 912f5c9ca8
commit eae5309843
9 changed files with 962 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
import type { Message, SearchRequest, ActionRequest, SearchItem } from "./types";
async function getOpenTabs(): Promise<SearchItem[]> {
const tabs = await chrome.tabs.query({});
return tabs.map((tab) => ({
id: `tab-${tab.id}`,
title: tab.title ?? "",
url: tab.url ?? "",
type: "tabs" as const,
favIconUrl: tab.favIconUrl,
}));
}
async function getHistory(query: string): Promise<SearchItem[]> {
const results = await chrome.history.search({
text: query,
maxResults: 50,
startTime: 0,
});
return results.map((item) => ({
id: `history-${item.id}`,
title: item.title ?? "",
url: item.url ?? "",
type: "history" as const,
}));
}
async function getBookmarks(query: string): Promise<SearchItem[]> {
const results = await chrome.bookmarks.search(query || " ");
return results
.filter((b) => b.url)
.map((item) => ({
id: `bookmark-${item.id}`,
title: item.title ?? "",
url: item.url!,
type: "bookmarks" as const,
}));
}
async function getRecentlyClosed(): Promise<SearchItem[]> {
const sessions = await chrome.sessions.getRecentlyClosed({ maxResults: 25 });
return sessions
.filter((s) => s.tab)
.map((session) => ({
id: `closed-${session.tab!.sessionId}`,
title: session.tab!.title ?? "",
url: session.tab!.url ?? "",
type: "closed" as const,
favIconUrl: session.tab!.favIconUrl,
}));
}
async function handleSearch(request: SearchRequest): Promise<SearchItem[]> {
const { query, mode } = request;
switch (mode) {
case "tabs":
return getOpenTabs();
case "history":
return getHistory(query);
case "bookmarks":
return getBookmarks(query);
case "closed":
return getRecentlyClosed();
case "all": {
const [tabs, history, bookmarks, closed] = await Promise.all([
getOpenTabs(),
getHistory(query),
getBookmarks(query),
getRecentlyClosed(),
]);
return [...tabs, ...history, ...bookmarks, ...closed];
}
}
}
async function handleAction(request: ActionRequest): Promise<void> {
const rawId = request.id.replace(/^(tab|history|bookmark|closed)-/, "");
switch (request.action) {
case "switch": {
const tabId = parseInt(rawId, 10);
await chrome.tabs.update(tabId, { active: true });
const tab = await chrome.tabs.get(tabId);
if (tab.windowId != null) {
await chrome.windows.update(tab.windowId, { focused: true });
}
break;
}
case "open": {
if (request.newTab) {
await chrome.tabs.create({ url: request.id });
} else {
const [activeTab] = await chrome.tabs.query({
active: true,
currentWindow: true,
});
if (activeTab?.id != null) {
await chrome.tabs.update(activeTab.id, { url: request.id });
}
}
break;
}
case "close": {
const tabId = parseInt(rawId, 10);
await chrome.tabs.remove(tabId);
break;
}
case "restore": {
await chrome.sessions.restore(rawId);
break;
}
}
}
chrome.commands.onCommand.addListener(async (command) => {
if (command === "toggle-sciezka") {
const [tab] = await chrome.tabs.query({
active: true,
currentWindow: true,
});
if (tab?.id != null) {
chrome.tabs.sendMessage(tab.id, { type: "toggle" } satisfies Message);
}
}
});
chrome.runtime.onMessage.addListener(
(msg: unknown, _sender, sendResponse) => {
const message = msg as Message;
if (message.type === "search") {
handleSearch(message).then((items) => {
sendResponse({ type: "searchResults", results: items });
});
return true;
}
if (message.type === "action") {
handleAction(message).then(() => sendResponse({ ok: true }));
return true;
}
}
);
+78
View File
@@ -0,0 +1,78 @@
declare namespace chrome {
namespace tabs {
interface Tab {
id?: number;
windowId?: number;
title?: string;
url?: string;
favIconUrl?: string;
active?: boolean;
}
function query(queryInfo: Record<string, unknown>): Promise<Tab[]>;
function get(tabId: number): Promise<Tab>;
function create(createProperties: { url?: string; active?: boolean }): Promise<Tab>;
function update(tabId: number, updateProperties: { active?: boolean; url?: string }): Promise<Tab>;
function remove(tabId: number | number[]): Promise<void>;
function sendMessage(tabId: number, message: unknown): void;
}
namespace windows {
function update(windowId: number, updateInfo: { focused?: boolean }): Promise<void>;
}
namespace history {
interface HistoryItem {
id: string;
title?: string;
url?: string;
lastVisitTime?: number;
}
function search(query: { text: string; maxResults?: number; startTime?: number }): Promise<HistoryItem[]>;
}
namespace bookmarks {
interface BookmarkTreeNode {
id: string;
title: string;
url?: string;
children?: BookmarkTreeNode[];
}
function search(query: string): Promise<BookmarkTreeNode[]>;
}
namespace sessions {
interface Session {
tab?: tabs.Tab & { sessionId?: string };
window?: { sessionId?: string };
}
function getRecentlyClosed(filter?: { maxResults?: number }): Promise<Session[]>;
function restore(sessionId: string): Promise<Session>;
}
namespace commands {
const onCommand: {
addListener(callback: (command: string) => void): void;
};
}
namespace runtime {
function getURL(path: string): string;
const onMessage: {
addListener(
callback: (
message: unknown,
sender: { tab?: tabs.Tab; id?: string },
sendResponse: (response?: unknown) => void
) => boolean | void
): void;
};
function sendMessage(message: unknown, responseCallback?: (response: unknown) => void): void;
}
namespace storage {
const sync: {
get(keys: string | string[]): Promise<Record<string, unknown>>;
set(items: Record<string, unknown>): Promise<void>;
};
}
}
+101
View File
@@ -0,0 +1,101 @@
import type { Message } from "./types";
let overlay: HTMLDivElement | null = null;
let iframe: HTMLIFrameElement | null = null;
function createOverlay(): void {
overlay = document.createElement("div");
overlay.id = "sciezka-overlay";
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0);
z-index: 2147483647;
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 12vh;
transition: background 0.15s ease;
`;
iframe = document.createElement("iframe");
iframe.src = chrome.runtime.getURL("sciezka.html");
iframe.style.cssText = `
width: 620px;
height: 60px;
border: none;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
background: transparent;
opacity: 0;
transform: translateY(-8px);
transition: opacity 0.15s ease, transform 0.15s ease, height 0.1s ease;
`;
overlay.appendChild(iframe);
document.body.appendChild(overlay);
requestAnimationFrame(() => {
overlay!.style.background = "rgba(0, 0, 0, 0.4)";
iframe!.style.opacity = "1";
iframe!.style.transform = "translateY(0)";
});
overlay.addEventListener("click", (e) => {
if (e.target === overlay) {
removeOverlay();
}
});
}
function removeOverlay(): void {
if (overlay) {
overlay.remove();
overlay = null;
iframe = null;
}
}
function toggle(): void {
if (overlay) {
removeOverlay();
} else {
createOverlay();
}
}
chrome.runtime.onMessage.addListener((msg: unknown) => {
const message = msg as Message;
if (message.type === "toggle") {
toggle();
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && overlay) {
removeOverlay();
}
});
window.addEventListener("message", (event) => {
if (event.source !== iframe?.contentWindow) return;
const data = event.data as Message;
if (data.type === "closeSaka") {
removeOverlay();
return;
}
if (data.type === "resize") {
if (iframe) {
iframe.style.height = `${(data as { height: number }).height}px`;
}
return;
}
if (data.type === "search" || data.type === "action") {
chrome.runtime.sendMessage(data, (response) => {
iframe?.contentWindow?.postMessage(response, "*");
});
}
});
+56
View File
@@ -0,0 +1,56 @@
export type SearchMode = "tabs" | "history" | "bookmarks" | "closed" | "all";
export type SearchMethod = "fuzzy" | "fulltext" | "prefix";
export interface SearchItem {
id: string;
title: string;
url: string;
type: SearchMode;
favIconUrl?: string;
}
export interface SearchResult {
item: SearchItem;
score: number;
positions: number[];
}
export interface SearchRequest {
type: "search";
query: string;
mode: SearchMode;
method: SearchMethod;
}
export interface SearchResponse {
type: "searchResults";
results: SearchResult[];
}
export interface ActionRequest {
type: "action";
action: "switch" | "open" | "close" | "restore";
id: string;
newTab?: boolean;
}
export interface ToggleMessage {
type: "toggle";
}
export interface CloseMessage {
type: "closeSaka";
}
export interface ResizeMessage {
type: "resize";
height: number;
}
export type Message =
| SearchRequest
| SearchResponse
| ActionRequest
| ToggleMessage
| CloseMessage
| ResizeMessage;