mvp working

This commit is contained in:
nick comer
2023-11-19 17:11:19 -05:00
committed by nick comer
parent d5c4e18a17
commit 92e99149c1
66 changed files with 2694 additions and 1735 deletions

132
src/background/main.ts Normal file
View File

@@ -0,0 +1,132 @@
declare const browser: typeof chrome;
// tabSwitches is a stack of all tab activations. every time a tab becomes
// active it is prepended (unshift) to the front of the array. this means that
// there will be duplicate IDs in the array.
//
// this is handled in 2 ways:
// 1. when the popup opens and asks for a list of tabs, we will only insert the tab
// in the result when it is _first_ seen and ignored thereafter. however, over
// time this will lead to a lot of wasted work since most of the iteration will
// be just skipping elements in this array.
//
// 2. the array is periodically "compacted" by simply running it through a uniq()
// operation, therefore reducing most wasted work most of the time.
let tabSwitches: number[] = [];
function uniq<T = any>(array: T[]): T[] {
if (array.length <= 1) {
return array;
}
const seen = new Set<T>();
const result = [];
for (let ele of array) {
if (seen.has(ele)) {
continue;
}
seen.add(ele);
result.push(ele);
}
return result;
}
setInterval(() => {
// periodically compact tabSwitches
tabSwitches = uniq(tabSwitches);
}, 1000);
try {
browser.runtime.onConnect.addListener((port) => {
port.onMessage.addListener((message) => {
switch (message.rpc) {
case "listTabs":
console.time(`rpc:listTabs:${message.id}`);
browser.tabs
.query({})
.then((tabs) => {
// filter out file:///... things, safari does not really have
// safari://... things like chrome
tabs = tabs.filter((t) => t.url || t.title);
const hit = new Map<number, boolean>(
tabs.filter((t) => !!t.id).map((t) => [t.id!, false])
);
const tabsById = new Map<number, chrome.tabs.Tab>(
tabs.filter((t) => !!t.id).map((t) => [t.id!, t])
);
const resultTabs = [];
for (let tabId of tabSwitches.slice(1)) {
if (hit.get(tabId)) {
continue;
}
hit.set(tabId, true);
const tab = tabsById.get(tabId);
if (!tab) {
continue;
}
resultTabs.push(tab);
}
for (let [tabId, didHit] of hit.entries()) {
if (didHit) {
continue;
}
const tab = tabsById.get(tabId);
if (!tab) {
continue;
}
resultTabs.push(tab);
}
port.postMessage({
result: resultTabs,
id: message.id,
});
})
.finally(() => {
console.timeEnd(`rpc:listTabs:${message.id}`);
});
return;
default:
port.postMessage({
error: `unknown rpc method: ${message.rpc}`,
id: message.id,
});
}
});
});
type Command = () => Promise<void> | void;
browser.tabs.onActivated.addListener((activeInfo) => {
if (activeInfo.tabId) {
tabSwitches.unshift(activeInfo.tabId);
}
});
const commands = new Map<string, Command>([
[
"openTabSwitcher",
async () => {
console.log("open that tab switcher!");
await (browser as any).browserAction.openPopup();
// await browser.action.openPopup();
},
],
]);
browser.commands.onCommand.addListener(async (command) => {
console.log(`received keyboard shortcut: ${command}`);
const fn = commands.get(command);
if (!fn) {
throw new Error(`unmapped command: ${command}`);
}
try {
await Promise.resolve(fn());
} catch (e) {
console.error(`command function failed: ${e}`);
}
});
} catch (e) {
// wrapping everything in a try/catch because F-ING SAFARI refuses to
// help you understand why background scripts fail. (https://developer.apple.com/forums/thread/705321)
console.error(`background startup failure: ${e}`);
}

387
src/popup/main.tsx Normal file
View File

@@ -0,0 +1,387 @@
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 "./scrollIntoViewIfNeededPolyfill";
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[]>;
}
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 = 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.get(message.id);
if (!promfinishers) {
return;
}
waiter.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.set(id, {
reject,
resolve(value) {
console.timeEnd(`bgpage:rpc:listTabs:${id}`);
resolve(value as chrome.tabs.Tab[]);
},
});
console.time(`bgpage:rpc:listTabs:${id}`);
port.current.postMessage({ rpc: "listTabs", id });
});
},
};
}
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?: FuseResultMatch;
}> = ({ text, match }) => {
if (!match) {
return <>{text}</>;
}
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, size: number): string | undefined {
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 Popup: FunctionComponent = () => {
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;
}
console.log({ tabs });
}, [tabs]);
useEffect(() => {
setTabSelector(0);
}, [setTabSelector, searchQuery]);
const searchIndex = useMemo(() => {
const result = new Fuse(tabs, {
keys: ["title", "url"],
includeMatches: true,
});
return result;
}, [tabs]);
const searchResults = useMemo(() => {
if (!searchQuery) {
return tabs.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(() => {
console.time("queryTabs");
bgpage
.listTabs()
.then((returnedTabs) => {
if (returnedTabs.find((t) => !!t.favIconUrl)) {
setEnabledFavicons(FAVICON_SUPPORTED_VIA_TAB_DATA);
}
setTabs(returnedTabs);
})
.finally(() => {
console.timeEnd("queryTabs");
});
}, []);
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]);
return (
<div>
<div style={{ padding: "1em" }}>
<input
type="text"
autoFocus
value={searchQuery}
style={{
width: "100%",
outline: "none",
border: "none",
fontSize: "1.1em",
}}
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);
}}
/>
</div>
<hr style={{ opacity: "0.3", marginTop: "0px" }} />
<div
style={{
maxHeight: "500px",
overflow: "scroll",
// minHeight: "300px"
}}
>
{searchResults.length === 0 ? (
<div
style={{
padding: "1.5em",
fontSize: "1.1em",
display: "flex",
justifyContent: "center",
}}
>
No Results Found
</div>
) : null}
{searchResults.map((t, i) => {
const favicURL = enableFavicons !== 0 ? faviconURL(t.item, 32) : null;
return (
<div
key={t.item.id}
className="ht-tab"
onClick={() => {
focusTab(t.item.id!, t.item.windowId);
}}
ref={i === tabSelector ? selectedTabEle : undefined}
style={{
padding: "10px",
backgroundColor: i === selectedTab ? "#e9e9e9" : undefined,
// favicon support
...(enableFavicons
? {
display: "flex",
alignItems: "center",
}
: {}),
}}
>
{favicURL && (
<div className="ht-tab-favicon" style={{ marginRight: "1em" }}>
<img width={16} height={16} src={favicURL} />
</div>
)}
<div>
<div
className="ht-tab-title"
style={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
fontSize: "1.1em",
marginBottom: "6px",
}}
>
{
<HighlightMatches
text={t.item.title ?? ""}
match={t.matches?.find((m) => m.key === "title")}
/>
}
</div>
<div
className="ht-tab-location"
style={{
color: "#6e6e6e",
}}
>
{t.item.url ? hostname(t.item.url) : ""}
</div>
</div>
</div>
);
})}
</div>
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById("main")!);
root.render(<Popup />);

View File

@@ -0,0 +1,54 @@
if (!(Element as any).prototype.scrollIntoViewIfNeeded) {
console.log("scrollIntoViewIfNeeded polyfill installing...");
(Element.prototype as any).scrollIntoViewIfNeeded = function (
centerIfNeeded: boolean
) {
centerIfNeeded = arguments.length === 0 ? true : !!centerIfNeeded;
/** @this {Element} */
var parent = this.parentNode,
parentComputedStyle = window.getComputedStyle(parent, null),
parentBorderTopWidth = parseInt(
parentComputedStyle.getPropertyValue("border-top-width")
),
parentBorderLeftWidth = parseInt(
parentComputedStyle.getPropertyValue("border-left-width")
),
overTop = this.offsetTop - parent.offsetTop < parent.scrollTop,
overBottom =
this.offsetTop -
parent.offsetTop +
this.clientHeight -
parentBorderTopWidth >
parent.scrollTop + parent.clientHeight,
overLeft = this.offsetLeft - parent.offsetLeft < parent.scrollLeft,
overRight =
this.offsetLeft -
parent.offsetLeft +
this.clientWidth -
parentBorderLeftWidth >
parent.scrollLeft + parent.clientWidth,
alignWithTop = overTop && !overBottom;
if ((overTop || overBottom) && centerIfNeeded) {
parent.scrollTop =
this.offsetTop -
parent.offsetTop -
parent.clientHeight / 2 -
parentBorderTopWidth +
this.clientHeight / 2;
}
if ((overLeft || overRight) && centerIfNeeded) {
parent.scrollLeft =
this.offsetLeft -
parent.offsetLeft -
parent.clientWidth / 2 -
parentBorderLeftWidth +
this.clientWidth / 2;
}
if ((overTop || overBottom || overLeft || overRight) && !centerIfNeeded) {
this.scrollIntoView(alignWithTop);
}
};
}