Skip to content

Commit

Permalink
feat: Automatically close duplicate tabs
Browse files Browse the repository at this point in the history
  • Loading branch information
vladbulyukhin committed Oct 7, 2024
1 parent 5430314 commit d6ba41e
Show file tree
Hide file tree
Showing 13 changed files with 221 additions and 14 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@ organized browsing experience.
- **Auto-Tab Management**: Set your preferences in the settings page of the extension's popup window, including auto-close timing for inactive tabs and exclusion for pinned, grouped, or audible tabs.
- **Temporary Disable**: Easily disable Tab Reaper for the active tab directly from the main page of the extension's popup window, perfect for ensuring important tabs remain open.
- **Session History**: View a list of recently closed tabs on the main page of the extension's popup window, allowing for quick recovery of closed sessions.
- **Automatic Duplication Remover**: Automatically remove duplicate tabs when a new tab is opened or an existing tab changes its URL.

18 changes: 10 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "tab-reaper",
"private": true,
"version": "0.1.1",
"version": "0.2.1",
"description": "Automatically closes idle tabs, freeing up clutter and helping you stay focused on what matters most.",
"source": "src/manifest.json",
"type": "module",
Expand Down
1 change: 1 addition & 0 deletions src/api/IBrowserApiProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export interface IBrowserTabAPI {
get(tabId: number): Promise<chrome.tabs.Tab>;
onActivated: chrome.tabs.TabActivatedEvent;
onCreated: chrome.tabs.TabCreatedEvent;
onUpdated: chrome.tabs.TabUpdatedEvent;
onRemoved: chrome.tabs.TabRemovedEvent;
query(
queryInfo: chrome.tabs.QueryInfo,
Expand Down
1 change: 1 addition & 0 deletions src/api/chromeApiProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const chromeTabAPI: IBrowserTabAPI = {
get: chrome.tabs.get,
onActivated: chrome.tabs.onActivated,
onCreated: chrome.tabs.onCreated,
onUpdated: chrome.tabs.onUpdated,
onRemoved: chrome.tabs.onRemoved,
query: chrome.tabs.query,
remove: chrome.tabs.remove,
Expand Down
56 changes: 55 additions & 1 deletion src/background/managers/OpenedTabManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,14 +127,68 @@ describe("OpenedTabManager", () => {
});
});

describe("onTabUpdated", () => {
it("should remove duplicate tabs when feature is enabled", async () => {
configurationManager.get.mockReturnValue(
Promise.resolve({ ...emptyConfiguration, removeExactDuplicates: true }),
);

const originalTab = createTestTab();
originalTab.url = "https://example.com?foo=bar";
const copyTab = structuredClone(originalTab);

const newTab = createTestTab();
newTab.url = "https://example.com?foo=bar";

browserApiProvider.tab.query.mockReturnValue(
Promise.resolve([originalTab, copyTab]),
);

const listener =
browserApiProvider.tab.onUpdated.addListener.mock.calls[0][0];
await listener(newTab.id, { url: newTab.url }, newTab);

expect(browserApiProvider.tab.remove).toHaveBeenCalledWith(
originalTab.id,
);
expect(browserApiProvider.tab.remove).toHaveBeenCalledWith(copyTab.id);
});

it("shouldn't remove duplicate tabs when feature is disabled", async () => {
configurationManager.get.mockReturnValue(
Promise.resolve({
...emptyConfiguration,
removeExactDuplicates: false,
}),
);

const originalTab = createTestTab();
originalTab.url = "https://example.com?foo=bar";
const copyTab = structuredClone(originalTab);

const newTab = createTestTab();
newTab.url = "https://example.com?foo=bar";

browserApiProvider.tab.query.mockReturnValue(
Promise.resolve([originalTab, copyTab]),
);

const listener =
browserApiProvider.tab.onUpdated.addListener.mock.calls[0][0];
await listener(newTab.id, { url: newTab.url }, newTab);

expect(browserApiProvider.tab.remove).not.toHaveBeenCalled();
});
});

function createTestTab(): chrome.tabs.Tab & { id: number } {
return {
active: false,
autoDiscardable: false,
discarded: false,
groupId: 0,
highlighted: false,
id: 1,
id: Math.floor(Math.random() * 1000),
incognito: false,
index: 0,
pinned: false,
Expand Down
39 changes: 39 additions & 0 deletions src/background/managers/OpenedTabManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export class OpenedTabManager implements IOpenedTabManager {
this.registerOpenTabs = this.registerOpenTabs.bind(this);
this.onTabActivated = this.onTabActivated.bind(this);
this.onTabCreated = this.onTabCreated.bind(this);
this.onTabUpdated = this.onTabUpdated.bind(this);
this.onTabRemoved = this.onTabRemoved.bind(this);

this.tabAlarmManager.onAlarm(this.removeTab.bind(this));
Expand All @@ -70,6 +71,7 @@ export class OpenedTabManager implements IOpenedTabManager {

this.browserApiProvider.tab.onActivated.addListener(this.onTabActivated);
this.browserApiProvider.tab.onCreated.addListener(this.onTabCreated);
this.browserApiProvider.tab.onUpdated.addListener(this.onTabUpdated);
this.browserApiProvider.tab.onRemoved.addListener(this.onTabRemoved);
}

Expand All @@ -80,6 +82,43 @@ export class OpenedTabManager implements IOpenedTabManager {

private async onTabCreated(tab: chrome.tabs.Tab): Promise<void> {
await this.scheduleTabRemoval(tab.id);

if (tab.id) {
await this.removeDuplicatesIfNecessary(tab.id, tab.url ?? null);
}
}

private async onTabUpdated(
tabId: TabId,
tabUpdateInfo: chrome.tabs.TabChangeInfo,
tab: chrome.tabs.Tab,
) {
await this.removeDuplicatesIfNecessary(tabId, tabUpdateInfo.url ?? null);
}

private async removeDuplicatesIfNecessary(
tabId: TabId,
url: string | null,
): Promise<void> {
if (!url) {
return;
}

const config = await this.configurationManager.get();
if (!config?.removeExactDuplicates) {
return;
}

const duplicates = await this.browserApiProvider.tab.query({
active: false,
url,
});

for (const tab of duplicates) {
if (tab.id && tab.id !== tabId) {
await this.browserApiProvider.tab.remove(tab.id);
}
}
}

private async onTabActivated(
Expand Down
6 changes: 5 additions & 1 deletion src/common/models/Configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export interface IConfiguration {
readonly keepAudibleTabs: boolean;
readonly keepGroupedTabs: boolean;
readonly keepPinnedTabs: boolean;
readonly removeExactDuplicates: boolean;
readonly tabRemovalDelayMin: number;
readonly version: number;
}
Expand All @@ -10,8 +11,9 @@ export const emptyConfiguration: IConfiguration = {
keepAudibleTabs: true,
keepGroupedTabs: true,
keepPinnedTabs: true,
removeExactDuplicates: false,
tabRemovalDelayMin: 30,
version: 2,
version: 3,
};

export const toConfiguration = (
Expand All @@ -25,4 +27,6 @@ export const toConfiguration = (
keepPinnedTabs: obj.keepPinnedTabs ?? emptyConfiguration.keepPinnedTabs,
keepGroupedTabs: obj.keepGroupedTabs ?? emptyConfiguration.keepGroupedTabs,
keepAudibleTabs: obj.keepAudibleTabs ?? emptyConfiguration.keepAudibleTabs,
removeExactDuplicates:
obj.removeExactDuplicates ?? emptyConfiguration.removeExactDuplicates,
});
2 changes: 1 addition & 1 deletion src/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Tab Reaper: Idle Tab Collector",
"description": "Automatically closes idle tabs, freeing up clutter and helping you stay focused on what matters most.",
"version": "0.1.1",
"version": "0.2.1",
"permissions": ["alarms", "tabs", "storage"],
"icons": {
"16": "icons/icon-active-16.png",
Expand Down
36 changes: 36 additions & 0 deletions src/popup/components/Badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { type VariantProps, cva } from "class-variance-authority";
import type * as React from "react";

import { cn } from "@/utils";

const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);

export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}

function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}

export { Badge, badgeVariants };
28 changes: 28 additions & 0 deletions src/popup/pages/Settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useCallback, useContext, useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { emptyConfiguration } from "../../../common/models/Configuration";
import { Badge } from "../../components/Badge";
import {
Form,
FormControl,
Expand All @@ -21,6 +22,7 @@ const formSchema = z.object({
keepAudibleTabs: z.boolean(),
keepGroupedTabs: z.boolean(),
keepPinnedTabs: z.boolean(),
removeExactDuplicates: z.boolean(),
tabRemovalDelayMin: z.coerce.number().positive(),
});

Expand Down Expand Up @@ -145,6 +147,32 @@ export const Settings: React.FC = () => {
</FormItem>
)}
/>

<FormField
control={form.control}
name="removeExactDuplicates"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between gap-20">
<div className="flex flex-col gap-1">
<FormLabel className="flex items-center gap-1">
Remove duplicates
<Badge variant="outline">EXPERIMENTAL</Badge>
</FormLabel>
<FormDescription>
Automatically close a tab when a new tab with the same URL is
opened.
</FormDescription>
</div>
<FormControl>
<Switch
data-testid="removeExactDuplicates"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</form>
</Form>
);
Expand Down
35 changes: 33 additions & 2 deletions tests/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { expect, test } from "./helpers/fixtures";
import { SettingsPage } from "./pages/OptionsPage";
import { TestHelperExtensionPage } from "./pages/TestHelperExtensionPage";

const waitFor = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));

test("should close idle tabs after specified inactivity time", async ({
page,
context,
Expand Down Expand Up @@ -51,8 +54,36 @@ test("should keep pinned and grouped tabs based on user settings", async ({

await page.bringToFront();

await new Promise((resolve) => setTimeout(resolve, 15_000));
const finalPageCount = await context.pages().length;
await waitFor(15_000);
const finalPageCount = (await context.pages()).length;

expect(finalPageCount).toBe(8);
});

test("[EXPERIMENTAL] should close duplicate tabs based on user settings", async ({
page,
context,
extensionId,
helperExtensionId,
}) => {
const optionsPage = new SettingsPage(page, extensionId);

await optionsPage.goto();
await optionsPage.setRemoveExactDuplicates(true);

const helperPage = new TestHelperExtensionPage(
await context.newPage(),
helperExtensionId,
);

await helperPage.goto();
await helperPage.createPinnedTab();
await helperPage.createPinnedTab();
await helperPage.createGroupedTabs();
await helperPage.createGroupedTabs();

await waitFor(5_000);
const finalPageCount = (await context.pages()).length;

expect(finalPageCount).toBe(4); // main extension + helper extension + example.com + example.org
});
9 changes: 9 additions & 0 deletions tests/pages/OptionsPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,13 @@ export class SettingsPage {
await input.fill(minutes.toString());
await input.blur();
}

async setRemoveExactDuplicates(enabled: boolean) {
const input = this.page.getByTestId("removeExactDuplicates");
if (enabled) {
await input.check();
} else {
await input.uncheck();
}
}
}

0 comments on commit d6ba41e

Please sign in to comment.