mirror of
https://github.com/nkcmr/HyperTab.git
synced 2026-03-29 13:52:09 +02:00
632 lines
18 KiB
TypeScript
632 lines
18 KiB
TypeScript
import Fuse, { FuseResult, FuseResultMatch, RangeTuple } from "fuse.js";
|
|
import React, {
|
|
FunctionComponent,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import ReactDOM from "react-dom/client";
|
|
import { useHotkeys } from "react-hotkeys-hook";
|
|
import { styled } from "styled-components";
|
|
import "./scrollIntoViewIfNeededPolyfill";
|
|
|
|
function t(
|
|
messageName: string,
|
|
substitutions?: string | string[] | undefined
|
|
): string {
|
|
return browser.i18n.getMessage(messageName, substitutions);
|
|
}
|
|
|
|
function hostname(url: string): string {
|
|
try {
|
|
const u = new URL(url);
|
|
return u.hostname;
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
const browser = chrome;
|
|
|
|
interface BackgroundPage {
|
|
listTabs(): Promise<chrome.tabs.Tab[]>;
|
|
closeTab(tabID: number): Promise<chrome.tabs.Tab[]>;
|
|
}
|
|
|
|
function useBackgroundPage(): BackgroundPage {
|
|
const msgId = useRef<number>(1);
|
|
const port = useRef<chrome.runtime.Port>(browser.runtime.connect());
|
|
type PromiseFinishers<T> = {
|
|
resolve: (value: T | PromiseLike<T>) => void;
|
|
reject: (reason?: any) => void;
|
|
};
|
|
const waiter = useRef(new Map<number, PromiseFinishers<unknown>>());
|
|
useEffect(() => {
|
|
const msgListener: Parameters<
|
|
typeof port.current.onMessage.addListener
|
|
>["0"] = (message) => {
|
|
if (!("id" in message) || typeof message.id !== "number") {
|
|
return;
|
|
}
|
|
const promfinishers = waiter.current.get(message.id);
|
|
if (!promfinishers) {
|
|
return;
|
|
}
|
|
waiter.current.delete(message.id);
|
|
if (message.error) {
|
|
promfinishers.reject(message.error);
|
|
} else {
|
|
promfinishers.resolve(message.result);
|
|
}
|
|
};
|
|
|
|
port.current.onMessage.addListener(msgListener);
|
|
return () => {
|
|
port.current.onMessage.removeListener(msgListener);
|
|
port.current.disconnect();
|
|
};
|
|
}, []);
|
|
|
|
return {
|
|
listTabs() {
|
|
return new Promise((resolve, reject) => {
|
|
const id = ++msgId.current;
|
|
waiter.current.set(id, {
|
|
reject,
|
|
resolve(value) {
|
|
resolve(value as chrome.tabs.Tab[]);
|
|
},
|
|
});
|
|
port.current.postMessage({ rpc: "listTabs", id });
|
|
});
|
|
},
|
|
closeTab(tabID) {
|
|
return new Promise((resolve, reject) => {
|
|
const id = ++msgId.current;
|
|
waiter.current.set(id, {
|
|
reject,
|
|
resolve(value) {
|
|
resolve(value as chrome.tabs.Tab[]);
|
|
},
|
|
});
|
|
port.current.postMessage({ rpc: "closeTab", id, args: { tabID } });
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
const focusTab = (tabId: number, windowId: number): void => {
|
|
browser.tabs.update(tabId, { active: true });
|
|
browser.windows.update(windowId, { focused: true });
|
|
window.close();
|
|
};
|
|
|
|
const HighlightMatches: FunctionComponent<{
|
|
text: string;
|
|
match?: Omit<FuseResultMatch, "key">;
|
|
}> = ({ text, match }) => {
|
|
if (!match) {
|
|
return <>{text}</>;
|
|
}
|
|
if (text.toLowerCase().includes("spec.matrix")) {
|
|
console.log({ text, match });
|
|
}
|
|
const parts: JSX.Element[] = [];
|
|
const indicies = structuredClone(match.indices) as RangeTuple[];
|
|
let currentPart = "";
|
|
let currentMatchIndicy: RangeTuple | undefined;
|
|
for (let i = 0; i < text.length; i++) {
|
|
if (indicies.length > 0 && indicies[0][0] === i) {
|
|
currentMatchIndicy = indicies.shift();
|
|
if (currentPart.length > 0) {
|
|
parts.push(<>{currentPart}</>);
|
|
currentPart = "";
|
|
}
|
|
}
|
|
currentPart += text[i];
|
|
if (
|
|
currentMatchIndicy &&
|
|
(currentMatchIndicy[1] === i || i === text.length - 1)
|
|
) {
|
|
currentMatchIndicy = undefined;
|
|
parts.push(<b style={{ fontWeight: "bold" }}>{currentPart}</b>);
|
|
currentPart = "";
|
|
}
|
|
}
|
|
if (currentPart) {
|
|
parts.push(<>{currentPart}</>);
|
|
}
|
|
return (
|
|
<>
|
|
{parts.map((p, i) => (
|
|
<React.Fragment key={i}>{p}</React.Fragment>
|
|
))}
|
|
</>
|
|
);
|
|
};
|
|
|
|
// // <big_sigh> ...
|
|
// function faviconsWork(tabURL: string, size: number): Promise<boolean> {
|
|
// return new Promise((resolve) => {
|
|
// const hiddenDiv = document.createElement("div", {});
|
|
// hiddenDiv.setAttribute("style", "display:none;");
|
|
// const testImg = document.createElement("img");
|
|
// testImg.src = faviconURL({ url: tabURL } as chrome.tabs.Tab, 32)!;
|
|
// testImg.onerror = () => {
|
|
// document.body.removeChild(hiddenDiv);
|
|
// resolve(false);
|
|
// };
|
|
// testImg.onload = () => {
|
|
// document.body.removeChild(hiddenDiv);
|
|
// resolve(true);
|
|
// };
|
|
// hiddenDiv.appendChild(testImg);
|
|
// document.body.appendChild(hiddenDiv);
|
|
// });
|
|
// }
|
|
|
|
function faviconURL(t: chrome.tabs.Tab): string | undefined {
|
|
return (
|
|
t.favIconUrl ??
|
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
|
|
);
|
|
// if (t.favIconUrl) {
|
|
// return t.favIconUrl;
|
|
// }
|
|
// if (!t.url) {
|
|
// return;
|
|
// }
|
|
// const url = new URL(browser.runtime.getURL("/_favicon/"));
|
|
// url.searchParams.set("pageUrl", t.url);
|
|
// url.searchParams.set("size", `${size}`);
|
|
// return url.toString();
|
|
}
|
|
|
|
const prefersDarkMode = (): boolean => {
|
|
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
};
|
|
|
|
const useDarkMode = (): boolean => {
|
|
const [dm, setdm] = useState(() => {
|
|
return prefersDarkMode();
|
|
});
|
|
useEffect(() => {
|
|
const mql = window.matchMedia("(prefers-color-scheme: dark)");
|
|
const onChange = (ev: MediaQueryListEvent) => {
|
|
setdm(ev.matches);
|
|
};
|
|
mql.addEventListener("change", onChange);
|
|
return () => {
|
|
mql.removeEventListener("change", onChange);
|
|
};
|
|
}, []);
|
|
return dm;
|
|
};
|
|
|
|
const POPUP_WIDTH = 500;
|
|
|
|
const TAB_ITEM_WIDTH = POPUP_WIDTH;
|
|
const TAB_ITEM_PADDING_PX = 15;
|
|
const TAB_ITEM_FAVICON_SIZE = 30;
|
|
const TAB_ITEM_FLEX_GAP = 15;
|
|
const TAB_ITEM_MAIN_WIDTH =
|
|
TAB_ITEM_WIDTH -
|
|
TAB_ITEM_PADDING_PX * 2 -
|
|
TAB_ITEM_FAVICON_SIZE -
|
|
TAB_ITEM_FLEX_GAP;
|
|
|
|
const SEARCH_CONTAINER_WIDTH = POPUP_WIDTH;
|
|
const SEARCH_ICON_SIZE = 24;
|
|
const SEARCH_ICON_CONTAINER_SIZE = 30;
|
|
const SEARCH_ICON_PADDING = (SEARCH_ICON_CONTAINER_SIZE - SEARCH_ICON_SIZE) / 2;
|
|
const SEARCH_CONTAINER_FLEX_GAP = TAB_ITEM_FLEX_GAP;
|
|
const SEARCH_CONTAINER_PADDING = TAB_ITEM_PADDING_PX;
|
|
const SEARCH_INPUT_WIDTH =
|
|
SEARCH_CONTAINER_WIDTH -
|
|
SEARCH_CONTAINER_PADDING * 2 -
|
|
SEARCH_ICON_CONTAINER_SIZE -
|
|
SEARCH_CONTAINER_FLEX_GAP;
|
|
|
|
const TabList = styled.div`
|
|
max-height: 500px;
|
|
overflow: scroll;
|
|
`;
|
|
const TabListEmpty = styled.div`
|
|
padding: 1.5em;
|
|
font-size: 1.1em;
|
|
display: flex;
|
|
justify-content: center;
|
|
`;
|
|
const TabItem = styled.div<{ $selected: boolean; $dark: boolean }>`
|
|
width: ${TAB_ITEM_WIDTH}px;
|
|
padding: ${TAB_ITEM_PADDING_PX}px;
|
|
cursor: pointer;
|
|
background-color: ${(props) =>
|
|
props.$selected ? (props.$dark ? "#535353" : "#e9e9e9") : "inherit"};
|
|
display: flex;
|
|
gap: ${TAB_ITEM_FLEX_GAP}px;
|
|
align-items: center;
|
|
&:hover {
|
|
background-color: ${(props) => (props.$dark ? "#4e4e4e" : "#efefef")};
|
|
}
|
|
`;
|
|
const TabItemFavicon = styled.div<{ $dark: boolean }>`
|
|
background-color: ${(props) => (props.$dark ? "#6e6e6e" : "#d9d9d9")};
|
|
border-radius: 5px;
|
|
padding: 0.5em;
|
|
width: ${TAB_ITEM_FAVICON_SIZE}px;
|
|
height: ${TAB_ITEM_FAVICON_SIZE}px;
|
|
padding: 7px;
|
|
flex-shrink: 0;
|
|
`;
|
|
const TabItemCloseBox = styled.div`
|
|
cursor: pointer;
|
|
`;
|
|
const TabItemMain = styled.div`
|
|
width: ${TAB_ITEM_MAIN_WIDTH}px;
|
|
`;
|
|
const TabItemMainTitle = styled.div<{ $dark: boolean }>`
|
|
color: ${(props) => (props.$dark ? "#e9e9e9" : "#2b2b2b")};
|
|
font-size: 1.1em;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
`;
|
|
const TabItemMainHostname = styled.div<{ $dark: boolean }>`
|
|
color: ${(props) => (props.$dark ? "#a9a9a9" : "#6e6e6e")};
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
`;
|
|
|
|
const SearchContainer = styled.div`
|
|
display: flex;
|
|
width: ${SEARCH_CONTAINER_WIDTH}px;
|
|
gap: ${TAB_ITEM_FLEX_GAP}px;
|
|
padding: ${TAB_ITEM_PADDING_PX}px;
|
|
align-items: center;
|
|
`;
|
|
|
|
const SearchIconLeftContainer = styled.div`
|
|
padding: ${SEARCH_ICON_PADDING}px;
|
|
`;
|
|
|
|
const SearchInputRightContainer = styled.div``;
|
|
const SearchInput = styled.input`
|
|
width: ${SEARCH_INPUT_WIDTH}px;
|
|
outline: none;
|
|
border: none;
|
|
font-size: 1.2em;
|
|
`;
|
|
|
|
type searchField = {
|
|
aliases?: string[];
|
|
filterValues: (value: string | null) => boolean;
|
|
evaluate: (t: chrome.tabs.Tab, value: string | null) => boolean;
|
|
};
|
|
|
|
const searchFields: Record<string, searchField> = {
|
|
sys: {
|
|
filterValues(value) {
|
|
return ["true", "false"].includes(value?.toLowerCase() ?? "");
|
|
},
|
|
evaluate(t, value) {
|
|
if (!t.url) {
|
|
return value === "true";
|
|
}
|
|
const ishttp = !!t.url.match(/^https?:\/\//i);
|
|
return ishttp !== (value === "true");
|
|
},
|
|
},
|
|
pinned: {
|
|
filterValues(value) {
|
|
return ["true", "false"].includes(value?.toLowerCase() ?? "");
|
|
},
|
|
evaluate(t, value) {
|
|
return t.pinned === (value === "true");
|
|
},
|
|
},
|
|
hostname: {
|
|
aliases: ["domain"],
|
|
filterValues(value) {
|
|
return true;
|
|
},
|
|
evaluate(t, value) {
|
|
if (!t.url) {
|
|
return false;
|
|
}
|
|
return hostname(t.url).includes(value ?? "");
|
|
},
|
|
},
|
|
} as const;
|
|
|
|
function getSearchField(key: string): searchField | null {
|
|
return key in searchFields
|
|
? searchFields[key]
|
|
: Object.values(searchFields).find((sf) => {
|
|
return sf.aliases?.includes(key) ?? false;
|
|
}) ?? null;
|
|
}
|
|
|
|
function structuredQuery(query: string): Map<string, string | null> {
|
|
return new Map<string, string | null>(
|
|
query
|
|
.split(/\s+/g)
|
|
.map((pair): [string, string | null] => {
|
|
const [key, value] = pair.split(":", 2);
|
|
return [key, value ?? null];
|
|
})
|
|
.filter(([key, value]) => {
|
|
const sf = getSearchField(key);
|
|
if (!sf) {
|
|
return false;
|
|
}
|
|
return sf.filterValues(value);
|
|
})
|
|
);
|
|
}
|
|
|
|
const Popup: FunctionComponent = () => {
|
|
const darkMode = useDarkMode();
|
|
const [tabSelector, setTabSelector] = useState(0);
|
|
const [tabs, setTabs] = useState<chrome.tabs.Tab[]>([]);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
|
|
// const FAVICON_NOT_SUPPORTED = 0;
|
|
// const FAVICON_SUPPORTED_VIA_EXT_URL = 1;
|
|
// const FAVICON_SUPPORTED_VIA_TAB_DATA = 2;
|
|
// const [enableFavicons, setEnabledFavicons] = useState(FAVICON_NOT_SUPPORTED);
|
|
// useEffect(() => {
|
|
// faviconsWork("https://www.google.com", 32).then((ok) => {
|
|
// if (enableFavicons === 0) {
|
|
// setEnabledFavicons(FAVICON_SUPPORTED_VIA_EXT_URL);
|
|
// }
|
|
// });
|
|
// }, []);
|
|
|
|
useEffect(() => {
|
|
if (tabs.length === 0) {
|
|
return;
|
|
}
|
|
}, [tabs]);
|
|
|
|
useEffect(() => {
|
|
setTabSelector(0);
|
|
}, [setTabSelector, searchQuery]);
|
|
const searchIndex = useMemo(() => {
|
|
const result = new Fuse(tabs, {
|
|
keys: [
|
|
"title",
|
|
{
|
|
name: "hostname",
|
|
getFn(obj) {
|
|
return obj.url ? hostname(obj.url) : "";
|
|
},
|
|
},
|
|
],
|
|
includeMatches: true,
|
|
});
|
|
return result;
|
|
}, [tabs]);
|
|
const searchResults = useMemo(() => {
|
|
if (!searchQuery) {
|
|
return tabs.map(
|
|
(t, i): FuseResult<chrome.tabs.Tab> => ({
|
|
item: t,
|
|
refIndex: i,
|
|
})
|
|
);
|
|
}
|
|
const sq = structuredQuery(searchQuery);
|
|
if (sq.size > 0) {
|
|
return tabs
|
|
.filter((tab) => {
|
|
for (let [key, value] of sq.entries()) {
|
|
if (!getSearchField(key)!.evaluate(tab, value)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
})
|
|
.map(
|
|
(t, i): FuseResult<chrome.tabs.Tab> => ({
|
|
item: t,
|
|
refIndex: i,
|
|
})
|
|
);
|
|
}
|
|
return searchIndex.search(searchQuery);
|
|
}, [tabs, searchIndex, searchQuery]);
|
|
|
|
const selectedTab = Math.max(
|
|
0,
|
|
Math.min(searchResults.length - 1, tabSelector)
|
|
);
|
|
|
|
const selectNext = useCallback(() => {
|
|
setTabSelector((n) => Math.min(searchResults.length - 1, n + 1));
|
|
}, [searchResults]);
|
|
const selectPrev = useCallback(() => {
|
|
setTabSelector((n) => Math.max(0, n - 1));
|
|
}, [setTabSelector]);
|
|
|
|
const goToTab = useCallback(() => {
|
|
focusTab(
|
|
searchResults[tabSelector].item.id!,
|
|
searchResults[tabSelector].item.windowId
|
|
);
|
|
}, [searchResults, tabSelector]);
|
|
useHotkeys(
|
|
"Down",
|
|
() => {
|
|
selectNext();
|
|
},
|
|
[selectNext]
|
|
);
|
|
useHotkeys(
|
|
"Up",
|
|
() => {
|
|
selectPrev();
|
|
},
|
|
[selectPrev]
|
|
);
|
|
useHotkeys(
|
|
"Enter",
|
|
() => {
|
|
goToTab();
|
|
},
|
|
[goToTab]
|
|
);
|
|
|
|
const bgpage = useBackgroundPage();
|
|
useEffect(() => {
|
|
bgpage
|
|
.listTabs()
|
|
.then((returnedTabs) => {
|
|
// if (returnedTabs.find((t) => !!t.favIconUrl)) {
|
|
// setEnabledFavicons(FAVICON_SUPPORTED_VIA_TAB_DATA);
|
|
// }
|
|
setTabs(returnedTabs);
|
|
})
|
|
.finally(() => {});
|
|
}, []);
|
|
|
|
const selectedTabEle = useRef<HTMLDivElement>(null);
|
|
useEffect(() => {
|
|
if (!selectedTabEle.current) {
|
|
return;
|
|
}
|
|
|
|
// scrollIntoViewIfNeeded is non standard but for just safari it works
|
|
// great!
|
|
(selectedTabEle as any).current.scrollIntoViewIfNeeded(false);
|
|
}, [selectedTabEle.current]);
|
|
|
|
const [tabHover, setTabHover] = useState<number | null>(null);
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
useEffect(() => {
|
|
requestIdleCallback(() => {
|
|
// this is more reliable than autoFocus attribute. sometimes autoFocus
|
|
// would let the popup open and then input would not be focused.
|
|
inputRef.current?.focus();
|
|
});
|
|
}, [inputRef.current]);
|
|
return (
|
|
<div>
|
|
<SearchContainer>
|
|
<SearchIconLeftContainer>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width={SEARCH_ICON_SIZE}
|
|
height={SEARCH_ICON_SIZE}
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
fill={darkMode ? "#e9e9e9" : "#636363"}
|
|
d="M23.809 21.646l-6.205-6.205c1.167-1.605 1.857-3.579 1.857-5.711 0-5.365-4.365-9.73-9.731-9.73-5.365 0-9.73 4.365-9.73 9.73 0 5.366 4.365 9.73 9.73 9.73 2.034 0 3.923-.627 5.487-1.698l6.238 6.238 2.354-2.354zm-20.955-11.916c0-3.792 3.085-6.877 6.877-6.877s6.877 3.085 6.877 6.877-3.085 6.877-6.877 6.877c-3.793 0-6.877-3.085-6.877-6.877z"
|
|
/>
|
|
</svg>
|
|
</SearchIconLeftContainer>
|
|
<SearchInputRightContainer>
|
|
<SearchInput
|
|
ref={inputRef}
|
|
type="text"
|
|
placeholder={t("ui_search_tabs")}
|
|
value={searchQuery}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "ArrowDown") {
|
|
selectNext();
|
|
} else if (e.key === "ArrowUp") {
|
|
selectPrev();
|
|
} else if (e.key === "Enter") {
|
|
goToTab();
|
|
}
|
|
}}
|
|
spellCheck="false"
|
|
autoCorrect="false"
|
|
onChange={(e) => {
|
|
setSearchQuery(e.target.value);
|
|
}}
|
|
/>
|
|
</SearchInputRightContainer>
|
|
</SearchContainer>
|
|
<hr style={{ opacity: "0.3", marginTop: "0px" }} />
|
|
<div style={{ padding: "1em", fontWeight: "bold" }}>
|
|
{t("ui_open_tabs", `${tabs.length}`)}
|
|
</div>
|
|
<TabList>
|
|
{searchResults.length === 0 ? (
|
|
<TabListEmpty>No Results Found</TabListEmpty>
|
|
) : null}
|
|
{searchResults.map((t, i) => {
|
|
const favicURL = faviconURL(t.item);
|
|
const showCloseAction = tabHover === t.item.id! && !t.item.pinned;
|
|
return (
|
|
<TabItem
|
|
key={t.item.id}
|
|
$dark={darkMode}
|
|
$selected={i === selectedTab}
|
|
onClick={() => {
|
|
focusTab(t.item.id!, t.item.windowId);
|
|
}}
|
|
ref={i === tabSelector ? selectedTabEle : undefined}
|
|
>
|
|
<TabItemFavicon
|
|
$dark={darkMode}
|
|
onMouseEnter={() => {
|
|
setTabHover(t.item.id!);
|
|
}}
|
|
onMouseLeave={() => {
|
|
setTabHover(null);
|
|
}}
|
|
>
|
|
{showCloseAction ? (
|
|
<TabItemCloseBox
|
|
title="Close Tab"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
bgpage.closeTab(t.item.id!).then((newtabs) => {
|
|
if (i < selectedTab) {
|
|
setTabSelector((n) => n - 1);
|
|
}
|
|
setTabs(newtabs);
|
|
});
|
|
}}
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width={16}
|
|
height={16}
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path d="M12 0c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm4.151 17.943l-4.143-4.102-4.117 4.159-1.833-1.833 4.104-4.157-4.162-4.119 1.833-1.833 4.155 4.102 4.106-4.16 1.849 1.849-4.1 4.141 4.157 4.104-1.849 1.849z" />
|
|
</svg>
|
|
</TabItemCloseBox>
|
|
) : (
|
|
<img width={16} height={16} src={favicURL} />
|
|
)}
|
|
</TabItemFavicon>
|
|
<TabItemMain>
|
|
<TabItemMainTitle $dark={darkMode}>
|
|
<HighlightMatches
|
|
text={t.item.title ?? ""}
|
|
match={t.matches?.find((m) => m.key === "title")}
|
|
/>
|
|
</TabItemMainTitle>
|
|
<TabItemMainHostname $dark={darkMode}>
|
|
{t.item.url ? hostname(t.item.url) : ""}
|
|
</TabItemMainHostname>
|
|
</TabItemMain>
|
|
</TabItem>
|
|
);
|
|
})}
|
|
</TabList>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const root = ReactDOM.createRoot(document.getElementById("main")!);
|
|
root.render(<Popup />);
|