9 Commits

Author SHA1 Message Date
nick comer
6dc2074870 fix: little bit of code cleanup 2024-06-14 13:57:35 -04:00
nick comer
0ce204a948 fix: unused parameter on favicon function 2024-06-14 13:48:03 -04:00
nick comer
4f2db92534 feat: experiment with more structured query pattern
fix: change to more robust "autofocus" mechanism to fix issue where popup would
show and not be automatically focused on the input
2024-05-10 10:09:51 -04:00
nick comer
3463d25fd0 fix regression in dark mode styling 2024-05-05 21:15:27 -04:00
nick comer
adc84cf5db new feature: close tabs from list 2024-05-05 20:38:01 -04:00
nick comer
d390feeab7 whoops missed en 2024-05-02 20:06:20 -04:00
nick comer
510b98edcc mess with i18n a little bit 2024-05-02 20:03:18 -04:00
nick comer
78dbf9eaab release process 2024-05-02 19:25:26 -04:00
nick comer
b826ce9c63 dark mode + a bit more polish 2024-05-01 21:54:52 -04:00
12 changed files with 740 additions and 364 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
dist
node_modules
*.map
.release-*/
HyperTab-*.zip

24
_locales/de/messages.json Normal file
View File

@@ -0,0 +1,24 @@
{
"extension_name": {
"message": "HyperTab",
"description": "The display name for the extension."
},
"extension_description": {
"message": "Schnelle Suche und Wechsel von Registerkarten",
"description": "Description of what the extension does."
},
"ui_open_tabs": {
"message": "Öffnen Sie Registerkarten ($COUNT$)",
"description": "Text denoting the section in the UI of open tabs",
"placeholders": {
"count": {
"content": "$1",
"example": "82"
}
}
},
"ui_search_tabs": {
"message": "Suchregisterkarten",
"description": "Placeholder text in the tab search box"
}
}

View File

@@ -1,10 +1,24 @@
{
"extension_name": {
"message": "HyperTab",
"description": "The display name for the extension."
},
"extension_description": {
"message": "Quick Tab Search and Switch for Safari",
"description": "Description of what the extension does."
"extension_name": {
"message": "HyperTab",
"description": "The display name for the extension."
},
"extension_description": {
"message": "Quick Tab Search and Switch",
"description": "Description of what the extension does."
},
"ui_open_tabs": {
"message": "Open Tabs ($COUNT$)",
"description": "Text denoting the section in the UI of open tabs",
"placeholders": {
"count": {
"content": "$1",
"example": "82"
}
}
},
"ui_search_tabs": {
"message": "Search Tabs",
"description": "Placeholder text in the tab search box"
}
}

View File

@@ -2,9 +2,9 @@
"manifest_version": 2,
"default_locale": "en",
"name": "HyperTab",
"description": "Quick tabs search and switch",
"version": "0.1",
"name": "__MSG_extension_name__",
"description": "__MSG_extension_description__",
"version": "@@replaced_by_package_sh",
"icons": {
"48": "images/icon-48.png",

326
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,23 +5,31 @@
"dev:bg": "esbuild --bundle ./src/background/main.ts --outdir=dist/bg --sourcemap --watch",
"dev:popup": "esbuild --bundle ./src/popup/main.tsx --outdir=dist/popup --sourcemap --watch",
"build": "npm run clean && run-p build:**",
"build:bg": "esbuild --bundle ./src/background/main.ts --outdir=dist/bg --minify",
"build:popup": "esbuild --bundle ./src/popup/main.tsx --outdir=dist/popup --minify"
},
"devDependencies": {
"@types/chrome": "^0.0.251",
"esbuild": "^0.19.5",
"npm-run-all": "^4.1.5",
"typescript": "^5.2.2"
"build:bg": "env NODE_ENV=production esbuild --bundle ./src/background/main.ts --outdir=dist/bg --minify",
"build:popup": "env NODE_ENV=production esbuild --bundle ./src/popup/main.tsx --outdir=dist/popup --minify",
"release": "npm run build && ./package.sh"
},
"releaseArtifacts": [
"popup.css",
"popup.html",
"manifest.json",
"images",
"dist",
"_locales"
],
"dependencies": {
"@types/chrome": "^0.0.251",
"@types/lodash.uniq": "^4.5.9",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"esbuild": "^0.19.5",
"fuse.js": "^7.0.0",
"lodash.uniq": "^4.5.0",
"npm-run-all": "^4.1.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.4.1"
"react-hotkeys-hook": "^4.4.1",
"styled-components": "^6.1.9",
"typescript": "^5.2.2"
}
}

27
package.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
set -euxo pipefail
release_version="$(git describe --tags)"
release_folder=".release-HyperTab-$release_version"
rm -rf .release-* HyperTab-*.zip
mkdir "$release_folder"
# shellcheck disable=SC2046
cp -rv $(jq -r '.releaseArtifacts[]' package.json) "$release_folder/"
jq \
--arg newVersion "$release_version" \
'.version = $newVersion' \
"$release_folder/manifest.json" > \
"$release_folder/manifest.json.tmp"
rm -vf "$release_folder/manifest.json"
mv -v "$release_folder/manifest.json.tmp" \
"$release_folder/manifest.json"
(
cd "$release_folder" &&
zip -r9 "../HyperTab-$release_version.zip" ./*
)

View File

@@ -132,14 +132,34 @@ table {
color-scheme: light dark;
}
html {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
*,
*:before,
*:after {
-webkit-box-sizing: inherit;
-moz-box-sizing: inherit;
box-sizing: inherit;
}
body {
width: 500px;
color: black;
font-family: system-ui;
font-size: 12px;
background-color: white;
}
@media (prefers-color-scheme: dark) {
/* Dark Mode styles go here. */
html {
background-color: black;
}
body {
background-color: #2b2b2b;
color: #f2f2f2;
}
}

33
privacy-policy.txt Normal file
View File

@@ -0,0 +1,33 @@
Privacy Policy for HyperTab
This Privacy Policy explains how we handle your information when you use this
browser extension, HyperTab (the "Extension").
Information We Collect
We do not collect any personal information or user data when you use the
Extension. All data and information processed by the Extension remain securely
within your own browser and are not transmitted to our servers or any third
parties.
Cookies
The Extension does not use cookies or similar tracking technologies.
Third-Party Services
The Extension does not integrate with any third-party services that would
require sharing user data.
Security
We take the security of your information seriously. Since no user data is
collected or transmitted by the Extension, there are no risks associated with
data breaches or unauthorized access to user data through the Extension.
Changes to This Privacy Policy
We reserve the right to update or change our Privacy Policy at any time. Any
changes will be posted on this page with a revised effective date. Your
continued use of the Extension after any such changes constitutes your
acceptance of the new Privacy Policy.

View File

@@ -35,55 +35,65 @@ setInterval(() => {
tabSwitches = uniq(tabSwitches);
}, 1000);
async function listTabs(): Promise<chrome.tabs.Tab[]> {
const _tabs = await browser.tabs.query({});
// filter out file:///... things, safari does not really have
// safari://... things like chrome
const 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);
}
return resultTabs;
}
try {
browser.runtime.onConnect.addListener((port) => {
port.onMessage.addListener((message) => {
port.onMessage.addListener(async (message) => {
switch (message.rpc) {
case "closeTab":
await browser.tabs.remove(message.args.tabID);
port.postMessage({
result: await listTabs(),
id: message.id,
});
return;
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}`);
try {
const resultTabs = await listTabs();
port.postMessage({
result: resultTabs,
id: message.id,
});
} finally {
console.timeEnd(`rpc:listTabs:${message.id}`);
}
return;
default:
port.postMessage({

View File

@@ -9,8 +9,16 @@ import React, {
} 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);
@@ -24,6 +32,7 @@ const browser = chrome;
interface BackgroundPage {
listTabs(): Promise<chrome.tabs.Tab[]>;
closeTab(tabID: number): Promise<chrome.tabs.Tab[]>;
}
function useBackgroundPage(): BackgroundPage {
@@ -33,7 +42,7 @@ function useBackgroundPage(): BackgroundPage {
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: any) => void;
};
const waiter = new Map<number, PromiseFinishers<unknown>>();
const waiter = useRef(new Map<number, PromiseFinishers<unknown>>());
useEffect(() => {
const msgListener: Parameters<
typeof port.current.onMessage.addListener
@@ -41,11 +50,11 @@ function useBackgroundPage(): BackgroundPage {
if (!("id" in message) || typeof message.id !== "number") {
return;
}
const promfinishers = waiter.get(message.id);
const promfinishers = waiter.current.get(message.id);
if (!promfinishers) {
return;
}
waiter.delete(message.id);
waiter.current.delete(message.id);
if (message.error) {
promfinishers.reject(message.error);
} else {
@@ -64,17 +73,27 @@ function useBackgroundPage(): BackgroundPage {
listTabs() {
return new Promise((resolve, reject) => {
const id = ++msgId.current;
waiter.set(id, {
waiter.current.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 });
});
},
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 } });
});
},
};
}
@@ -86,11 +105,14 @@ const focusTab = (tabId: number, windowId: number): void => {
const HighlightMatches: FunctionComponent<{
text: string;
match?: FuseResultMatch;
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 = "";
@@ -125,61 +147,207 @@ const HighlightMatches: FunctionComponent<{
);
};
// <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="
);
}
function faviconURL(t: chrome.tabs.Tab, size: number): string | undefined {
if (t.favIconUrl) {
return t.favIconUrl;
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")};
}
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 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;
}
console.log({ tabs });
}, [tabs]);
useEffect(() => {
@@ -187,7 +355,15 @@ const Popup: FunctionComponent = () => {
}, [setTabSelector, searchQuery]);
const searchIndex = useMemo(() => {
const result = new Fuse(tabs, {
keys: ["title", "url"],
keys: [
"title",
{
name: "hostname",
getFn(obj) {
return obj.url ? hostname(obj.url) : "";
},
},
],
includeMatches: true,
});
return result;
@@ -201,6 +377,24 @@ const Popup: FunctionComponent = () => {
})
);
}
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]);
@@ -246,18 +440,15 @@ const Popup: FunctionComponent = () => {
const bgpage = useBackgroundPage();
useEffect(() => {
console.time("queryTabs");
bgpage
.listTabs()
.then((returnedTabs) => {
if (returnedTabs.find((t) => !!t.favIconUrl)) {
setEnabledFavicons(FAVICON_SUPPORTED_VIA_TAB_DATA);
}
// if (returnedTabs.find((t) => !!t.favIconUrl)) {
// setEnabledFavicons(FAVICON_SUPPORTED_VIA_TAB_DATA);
// }
setTabs(returnedTabs);
})
.finally(() => {
console.timeEnd("queryTabs");
});
.finally(() => {});
}, []);
const selectedTabEle = useRef<HTMLDivElement>(null);
@@ -271,114 +462,125 @@ const Popup: FunctionComponent = () => {
(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>
<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",
}}
<SearchContainer>
<SearchIconLeftContainer>
<svg
xmlns="http://www.w3.org/2000/svg"
width={SEARCH_ICON_SIZE}
height={SEARCH_ICON_SIZE}
viewBox="0 0 24 24"
>
No Results Found
</div>
<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 = enableFavicons !== 0 ? faviconURL(t.item, 32) : null;
const favicURL = faviconURL(t.item);
const showCloseAction = tabHover === t.item.id! && !t.item.pinned;
return (
<div
<TabItem
key={t.item.id}
className="ht-tab"
$dark={darkMode}
$selected={i === selectedTab}
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" }}>
<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} />
</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",
}}
>
)}
</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) : ""}
</div>
</div>
</div>
</TabItemMainHostname>
</TabItemMain>
</TabItem>
);
})}
</div>
</TabList>
</div>
);
};

14
tag.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
set -euo pipefail
[ -n "${TRACE:-}" ] && set -x
current_tag="$(git describe --tags --abbrev=0)"
next_tag="$(datever increment "$current_tag")"
if [[ "$(git describe --exact-match --tags 2>/dev/null)" != "" ]]; then
echo "Current commit is already tagged; quiting..."
exit 1
fi
git tag "$next_tag"