36 |
37 |
38 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/vite.config.base.ts:
--------------------------------------------------------------------------------
1 | import {resolve} from "path";
2 |
3 | import vue from "@vitejs/plugin-vue";
4 | import {type UserConfig} from "vite";
5 |
6 | const rel = (p: string) => resolve(__dirname, p);
7 |
8 | const prod = process.env.NODE_ENV === "production";
9 |
10 | // https://vitejs.dev/config/
11 | export default {
12 | root: "src",
13 | publicDir: "assets",
14 |
15 | clearScreen: false,
16 |
17 | plugins: [vue({isProduction: prod})],
18 |
19 | resolve: {
20 | // Disable extension resolution since it's not what ES modules do
21 | extensions: [],
22 | },
23 |
24 | build: {
25 | outDir: rel("dist"),
26 | emptyOutDir: false,
27 |
28 | // We don't emit source maps in production to reduce build size, and because
29 | // they are often not reliable for reasons I'm never able to figure out.
30 | sourcemap: !prod,
31 |
32 | // We never minify (even in production) because it produces more
33 | // reliable stack traces with actual names for functions.
34 | minify: false,
35 |
36 | // Remove the hash from the generated file names, because it (and ONLY it;
37 | // the content is the same even though it's supposedly a "content hash")
38 | // seems to be inconsistent from build to build depending on the path to the
39 | // build tree.
40 | rollupOptions: {
41 | output: {
42 | assetFileNames: "assets/[name].[ext]",
43 | chunkFileNames: "assets/[name].js",
44 | entryFileNames: "[name].js",
45 | },
46 | },
47 | },
48 | } as UserConfig;
49 |
--------------------------------------------------------------------------------
/src/tasks/export/helpers.ts:
--------------------------------------------------------------------------------
1 | import {h, type VNode} from "vue";
2 |
3 | import {
4 | isLeaf,
5 | isParent,
6 | type StashItem,
7 | type StashLeaf,
8 | type StashParent,
9 | } from "../../model/index.js";
10 | import {filterMap} from "../../util/index.js";
11 | import {friendlyFolderName} from "../../model/bookmarks.js";
12 |
13 | export interface Renderers {
14 | parent: (item: StashParent) => VNode[];
15 | leaf: (item: StashLeaf) => VNode[];
16 | }
17 |
18 | export function renderItems(items: StashItem[], renderers: Renderers): VNode[] {
19 | const {leaves, parents} = splitItems(items);
20 | return [
21 | ...leaves.flatMap(i => renderers.leaf(i)),
22 | ...parents.flatMap(i => renderers.parent(i)),
23 | ];
24 | }
25 |
26 | export function getParentInfo(folder: StashParent): {
27 | title: string;
28 | leaves: StashLeaf[];
29 | parents: StashParent[];
30 | } {
31 | const title =
32 | "title" in folder ? friendlyFolderName(folder.title) : "Untitled";
33 | const {leaves, parents} = splitItems(folder.children);
34 | return {title, leaves, parents};
35 | }
36 |
37 | export function splitItems(items: readonly (StashItem | undefined)[]): {
38 | leaves: StashLeaf[];
39 | parents: StashParent[];
40 | } {
41 | const leaves = filterMap(items, c => {
42 | if (c && isLeaf(c)) return c;
43 | return undefined;
44 | });
45 | const parents = filterMap(items, c => {
46 | if (c && isParent(c)) return c;
47 | return undefined;
48 | });
49 | return {leaves, parents};
50 | }
51 |
52 | export function br(): VNode {
53 | return h("div", {}, [h("br")]);
54 | }
55 |
--------------------------------------------------------------------------------
/src/stash-list/select-folder.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
11 | emit('select', ev, folder)"
17 | />
18 |
19 |
20 |
21 |
22 |
32 |
33 |
55 |
--------------------------------------------------------------------------------
/styles/metrics/compact.less:
--------------------------------------------------------------------------------
1 | // A "compact" style especially for sidebar/panel views.
2 |
3 | & {
4 | --page-pw: 12px;
5 | --page-ph: 8px;
6 |
7 | --group-ph: 4px;
8 | --group-border-radius: 6px;
9 |
10 | --icon-p: 3px;
11 |
12 | // Ugh, need to duplicate this here or Firefox won't recalculate it... maybe
13 | // there's a nicer way to do this in less. TODO figure this out.
14 | --icon-btn-size: calc(var(--icon-size) + 2 * var(--icon-p));
15 |
16 | --item-h: var(--icon-btn-size); /* must match because items have favicons */
17 | }
18 |
19 | // TODO: Variable-ize as much of this as possible so that metrics/ ONLY sets CSS
20 | // variables (and then those variables just get applied in a consistent way
21 | // elsewhere).
22 |
23 | &,
24 | & > body {
25 | font: small-caption;
26 | font-weight: normal;
27 | }
28 |
29 | &[data-browser="chrome"],
30 | &[data-browser="chrome"] > body {
31 | font-size: 8.5pt;
32 | }
33 |
34 | & .forest > li > .forest-item > .forest-title {
35 | // NOTE: The font and other sizes here are designed to make the heading
36 | // height the same as the icon toolbar height, which is the minimum height
37 | // we can support without things looking weird when toolbar buttons appear
38 | // and disappear on hover.
39 | font-size: var(--icon-size);
40 |
41 | height: calc(var(--icon-btn-size));
42 |
43 | // The line-height is needed to correct for oddities when this folder name
44 | // is an ephemeral text box--without it, switching between the and
45 | // the produces some vertical displacement.
46 | line-height: calc(var(--icon-btn-size));
47 | }
48 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | # Tab Stash
2 |
3 | Can't keep all your tabs straight? Need to clear your plate, but want to come
4 | back to your tabs later?
5 |
6 | Tab Stash is a no-fuss way to save and organize batches of tabs as bookmarks.
7 | Sweep your browser clean with one click of the Tab Stash icon (if configured).
8 | Your open tabs will be stashed away in your bookmarks, conveniently organized
9 | into groups. When it's time to pick up where you left off, open Tab Stash and
10 | restore just the tabs or groups you want.
11 |
12 | Because Tab Stash stores your tabs as bookmarks, they will even sync to your
13 | other computers or mobile devices. Uses Firefox Sync, if configured---no need
14 | to keep track of yet another account.
15 |
16 |
17 |
18 | ## Features
19 |
20 | - Stash all your open tabs with the Tab Stash toolbar button (if configured), or
21 | individual tabs with a button in the address bar
22 | - View your stash in the Firefox sidebar, a popup, or a full-browser tab view
23 | - Restore individual tabs, or whole groups of tabs, with a single click
24 | - Search your stash with the quick-search bar
25 | - Organize your stash into groups and sub-groups
26 | - Recover recently-deleted items
27 | - Drag and drop items to re-organize them (multi-select supported)
28 | - Import and export your stash in rich text, Markdown, OneTab and more
29 | - Customize the behavior of Tab Stash's toolbar button
30 | - Dark mode
31 |
32 | ## Want to give it a try?
33 |
34 | Install Tab Stash from [Mozilla Add-Ons][amo]!
35 |
36 | [amo]: https://addons.mozilla.org/en-US/firefox/addon/tab-stash/
37 |
--------------------------------------------------------------------------------
/styles/themes/dark.less:
--------------------------------------------------------------------------------
1 | /* This is basically a set of modifications to the light theme, so best to start
2 | there first. */
3 |
4 | & {
5 | --modal-bg: hsla(250deg, 0%, 0%, 50%);
6 | --page-bg: hsl(250deg, 10%, 11.66%);
7 | --page-fg: hsl(0deg, 0%, 93%);
8 | --disabled-fg: hsl(0deg, 0%, 66%);
9 | --userlink-fg: hsl(188deg, 100%, 50%);
10 | --userlink-hover-fg: hsl(188deg, 100%, 70%);
11 | --userlink-active-fg: hsl(188deg, 100%, 90%);
12 | --selected-bg: hsl(188deg, 100%, 20%);
13 | --selected-hover-bg: hsl(188deg, 100%, 30%);
14 |
15 | --ctrl-bg: hsl(250deg, 10%, 12%);
16 | --ctrl-border-clr: hsla(0deg, 0%, 93%, 34%);
17 | --button-bg: hsl(250deg, 10%, 31%);
18 | --button-hover-bg: hsl(250deg, 10%, 41%);
19 | --button-active-bg: hsl(250deg, 10%, 51%);
20 |
21 | --menu-bg: hsl(245deg, 8%, 28%);
22 | --menu-item-hover-bg: hsl(245deg, 5%, 38%);
23 | --menu-item-active-bg: hsl(245deg, 5%, 48%);
24 |
25 | --ephemeral-hover-shadow-clr: hsla(0, 0%, 93%, 15%);
26 |
27 | --group-bg: hsl(250deg, 10%, 18.25%);
28 | --group-border-clr: hsl(250deg, 1%, 35%);
29 |
30 | --indent-guide-border-clr: hsla(250deg, 1%, 35%, 60%);
31 |
32 | --active-tab-bg: hsl(250deg, 10%, 36%);
33 | --active-tab-shadow: var(--active-tab-shadow-metrics)
34 | hsla(250deg, 0%, 0%, 30%);
35 | }
36 |
37 | &[data-view="popup"] {
38 | // NOTE: These colors should align with the --menu-* colors above.
39 | --page-bg: hsl(245deg, 8%, 28%);
40 | --group-bg: hsl(245deg, 8%, 22%);
41 |
42 | --button-bg: hsl(245deg, 5%, 38%);
43 | --button-hover-bg: hsl(245deg, 5%, 48%);
44 | --button-active-bg: hsl(245deg, 5%, 58%);
45 | }
46 |
47 | .icon-vars(@theme: dark, @inverse: light);
48 |
--------------------------------------------------------------------------------
/src/mock/index.ts:
--------------------------------------------------------------------------------
1 | // This file is auto-loaded so that it mocks up various global browser
2 | // facilities BEFORE tests are run, and resets/verifies sanity AFTER tests are
3 | // complete.
4 |
5 | import type {RootHookObject} from "mocha";
6 |
7 | // Setup globals before importing, so that webextension-polyfill etc. see what
8 | // they expect.
9 | (globalThis).mock = {
10 | indexedDB: true,
11 | browser: true,
12 | events: true,
13 | };
14 | (globalThis).browser = {
15 | // Keep the polyfill happy
16 | runtime: {
17 | id: "mock",
18 | },
19 | };
20 | /* c8 ignore next 3 -- covering for differences in Node versions */
21 | if (!(globalThis).navigator) {
22 | (globalThis).navigator = {hardwareConcurrency: 4};
23 | }
24 |
25 | // Keep the polyfill happy
26 | (globalThis).chrome = (globalThis).browser;
27 |
28 | // Mock indexedDB.* APIs
29 | import "fake-indexeddb/auto";
30 |
31 | // Mock WebExtension APIs
32 | import * as events from "./events.js";
33 |
34 | await import("webextension-polyfill");
35 | const mock_browser = await import("./browser/index.js");
36 |
37 | // Reset the mocks before each test, and make sure all events have drained after
38 | // each test.
39 | export const mochaHooks: RootHookObject = {
40 | beforeEach() {
41 | (globalThis).indexedDB = new IDBFactory();
42 | events.beforeTest();
43 | mock_browser.runtime.reset();
44 | mock_browser.storage.reset();
45 | mock_browser.bookmarks.reset();
46 | mock_browser.tabs_and_windows.reset();
47 | mock_browser.sessions.reset();
48 | mock_browser.containers.reset();
49 | },
50 | async afterEach() {
51 | await events.afterTest();
52 | },
53 | };
54 |
--------------------------------------------------------------------------------
/src/model/bookmark-metadata.ts:
--------------------------------------------------------------------------------
1 | import type {KVSCache, MaybeEntry} from "../datastore/kvs/index.js";
2 |
3 | /** The key is the bookmark ID, and the value is the metadata. */
4 | export type BookmarkMetadataEntry = MaybeEntry;
5 |
6 | /** Metadata stored locally (i.e. not synced) about a particular bookmark. */
7 | export type BookmarkMetadata = {
8 | /** For folders, should the folder be shown as collapsed in the UI? */
9 | collapsed?: boolean;
10 | };
11 |
12 | /** The ID we use for storing metadata about the current window (i.e. not a
13 | * bookmark at all). */
14 | export const CUR_WINDOW_MD_ID = "";
15 |
16 | /** Keeps track of bookmark metadata in local storage. Right now this just
17 | * tracks whether folders should be shown as collapsed or expanded, but more
18 | * could be added later if needed. */
19 | export class Model {
20 | private readonly _kvc: KVSCache;
21 |
22 | constructor(kvc: KVSCache) {
23 | this._kvc = kvc;
24 | }
25 |
26 | get(id: string): BookmarkMetadataEntry {
27 | return this._kvc.get(id);
28 | }
29 |
30 | set(id: string, metadata: BookmarkMetadata): BookmarkMetadataEntry {
31 | return this._kvc.set(id, metadata);
32 | }
33 |
34 | setCollapsed(id: string, collapsed: boolean) {
35 | this.set(id, {...(this.get(id).value || {}), collapsed});
36 | }
37 |
38 | /** Remove metadata for bookmarks for whom `keep(id)` returns false. */
39 | async gc(keep: (id: string) => boolean) {
40 | const toDelete = [];
41 | for await (const ent of this._kvc.kvs.list()) {
42 | if (keep(ent.key)) continue;
43 | toDelete.push({key: ent.key});
44 | }
45 |
46 | await this._kvc.kvs.set(toDelete);
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/styles/mods-flat.less:
--------------------------------------------------------------------------------
1 | main {
2 | box-sizing: border-box;
3 | padding-top: calc(2 * var(--page-ph));
4 | padding-bottom: calc(4 * var(--page-ph));
5 | padding-left: calc(2 * var(--page-pw));
6 | padding-right: calc(2 * var(--page-pw));
7 |
8 | display: grid;
9 | grid-template-columns: 1fr minmax(0, 40rem) 1fr;
10 | align-content: start;
11 | justify-items: center;
12 | gap: 1em;
13 |
14 | -moz-user-select: text;
15 | user-select: text;
16 |
17 | & > * {
18 | grid-column: 2;
19 | }
20 | }
21 |
22 | h1 {
23 | margin: var(--page-ph) 0;
24 | font-size: 24pt;
25 | text-align: center;
26 | }
27 |
28 | section {
29 | display: flex;
30 | flex-direction: column;
31 | gap: 1em;
32 | width: 100%;
33 | }
34 |
35 | hr {
36 | border: var(--divider-border);
37 | margin-top: 3em;
38 | margin-bottom: 1em;
39 | width: 50%;
40 | }
41 |
42 | p {
43 | margin: 0;
44 | font-size: 11pt;
45 |
46 | &.note {
47 | margin-left: 2em;
48 | border-radius: calc(var(--ctrl-border-radius) / 2);
49 | // border: var(--ctrl-border-radius) solid var(--group-bg);
50 | box-shadow: 0 0 0 var(--ctrl-border-radius) var(--group-bg);
51 | background-color: var(--group-bg);
52 | }
53 | }
54 |
55 | span.icon {
56 | vertical-align: bottom;
57 | }
58 |
59 | .flat-heading-icon {
60 | width: 192px;
61 | height: 192px;
62 | opacity: 50%;
63 | background-position: center;
64 | background-repeat: no-repeat;
65 | background-size: 192px 192px;
66 | }
67 |
68 | a.unsafe-url {
69 | display: block;
70 |
71 | padding: var(--ctrl-mh) var(--ctrl-mw);
72 | background-color: var(--group-bg);
73 | border: var(--group-border);
74 |
75 | font-family: monospace;
76 | font-size: 11pt;
77 | text-decoration: dotted underline;
78 | }
79 |
--------------------------------------------------------------------------------
/src/service-model.ts:
--------------------------------------------------------------------------------
1 | /* c8 ignore start -- live model creation */
2 |
3 | //
4 | // The model--a centralized place for all Tab Stash data.
5 | //
6 |
7 | import {KVSCache} from "./datastore/kvs/index.js";
8 | import KVSService from "./datastore/kvs/service.js";
9 | import {resolveNamed} from "./util/index.js";
10 | import {listen} from "./util/nanoservice/index.js";
11 |
12 | import * as M from "./model/index.js";
13 |
14 | export default async function (): Promise {
15 | const kvs = await resolveNamed({
16 | deleted_items: KVSService.open(
17 | "deleted_items",
18 | "deleted_items",
19 | ),
20 | favicons: KVSService.open(
21 | "favicons",
22 | "favicons",
23 | ),
24 | bookmark_metadata: KVSService.open<
25 | string,
26 | M.BookmarkMetadata.BookmarkMetadata
27 | >("bookmark-metadata", "bookmark-metadata"),
28 | });
29 |
30 | const sources = await resolveNamed({
31 | browser_settings: M.BrowserSettings.Model.live(),
32 | options: M.Options.Model.live(),
33 | tabs: M.Tabs.Model.from_browser("background"),
34 | containers: M.Containers.Model.from_browser(),
35 | bookmarks: M.Bookmarks.Model.from_browser(),
36 | deleted_items: new M.DeletedItems.Model(kvs.deleted_items),
37 | });
38 |
39 | listen("deleted_items", kvs.deleted_items);
40 | listen("favicons", kvs.favicons);
41 | listen("bookmark-metadata", kvs.bookmark_metadata);
42 |
43 | const model = new M.Model({
44 | ...sources,
45 | favicons: new M.Favicons.Model(new KVSCache(kvs.favicons)),
46 | bookmark_metadata: new M.BookmarkMetadata.Model(
47 | new KVSCache(kvs.bookmark_metadata),
48 | ),
49 | });
50 | (globalThis).model = model;
51 | return model;
52 | }
53 |
--------------------------------------------------------------------------------
/test-detect-leaks.mjs:
--------------------------------------------------------------------------------
1 | // https://gist.github.com/boneskull/7fe75b63d613fa940db7ec990a5f5843
2 |
3 | import {createHook} from "async_hooks";
4 | import {stackTraceFilter} from "mocha/lib/utils.js";
5 |
6 | const allResources = new Map();
7 |
8 | // this will pull Mocha internals out of the stacks
9 | const filterStack = stackTraceFilter();
10 |
11 | const hook = createHook({
12 | init(asyncId, type, triggerAsyncId) {
13 | const parent = allResources.get(triggerAsyncId);
14 | const r = {
15 | type,
16 | asyncId,
17 | stack: filterStack(
18 | new Error(`${type} ${asyncId} triggered by ${triggerAsyncId}`).stack,
19 | ),
20 | parent,
21 | children: [],
22 | };
23 | allResources.set(asyncId, r);
24 |
25 | if (parent) parent.children.push(r);
26 | },
27 | destroy(asyncId) {
28 | allResources.delete(asyncId);
29 | },
30 | }).enable();
31 |
32 | const asyncDump = () => {
33 | function print(r) {
34 | let dots = false;
35 | console.error(r.stack);
36 | r = r.parent;
37 | while (r) {
38 | if (r.parent && r.children.length <= 1) {
39 | if (!dots) {
40 | console.error("...");
41 | dots = true;
42 | }
43 | r = r.parent;
44 | continue;
45 | }
46 | print(r);
47 | r = r.parent;
48 | }
49 | }
50 |
51 | hook.disable();
52 |
53 | console.error(`
54 | STUFF STILL IN THE EVENT LOOP:`);
55 | allResources.forEach(value => {
56 | if (value.children.length !== 0) return;
57 | if (value.type === "Immediate") return;
58 | if (value.type === "PROMISE") return;
59 | // print(value);
60 | console.error(value.stack);
61 | console.error("");
62 | });
63 | };
64 |
65 | export const mochaHooks = {
66 | afterAll() {
67 | asyncDump();
68 | },
69 | };
70 |
--------------------------------------------------------------------------------
/src/model/bookmark-metadata.test.ts:
--------------------------------------------------------------------------------
1 | import {expect} from "chai";
2 |
3 | import * as events from "../mock/events.js";
4 |
5 | import {KVSCache} from "../datastore/kvs/index.js";
6 | import MemoryKVS from "../datastore/kvs/memory.js";
7 | import type {BookmarkMetadata} from "./bookmark-metadata.js";
8 | import {Model} from "./bookmark-metadata.js";
9 |
10 | describe("model/bookmark-metadata", () => {
11 | let kvc: KVSCache;
12 | let model: Model;
13 |
14 | beforeEach(() => {
15 | kvc = new KVSCache(new MemoryKVS("bookmark_metadata"));
16 | model = new Model(kvc);
17 | events.ignore([kvc.kvs.onSet]);
18 | });
19 |
20 | it("collapses bookmarks", () => {
21 | model.setCollapsed("foo", true);
22 | expect(kvc.get("foo").value).to.deep.equal({collapsed: true});
23 | });
24 |
25 | it("expands bookmarks", () => {
26 | model.setCollapsed("foo", true);
27 | expect(kvc.get("foo").value).to.deep.equal({collapsed: true});
28 | model.setCollapsed("foo", false);
29 | expect(kvc.get("foo").value).to.deep.equal({collapsed: false});
30 | });
31 |
32 | it("garbage-collects unused bookmarks", async () => {
33 | model.setCollapsed("foo", true);
34 | model.setCollapsed("bar", false);
35 | expect(kvc.get("foo").value).to.deep.equal({collapsed: true});
36 | expect(kvc.get("bar").value).to.deep.equal({collapsed: false});
37 |
38 | await kvc.sync();
39 | expect(await kvc.kvs.get(["foo", "bar"])).to.deep.equal([
40 | {key: "foo", value: {collapsed: true}},
41 | {key: "bar", value: {collapsed: false}},
42 | ]);
43 |
44 | // now try to GC
45 | expect(await model.gc(id => id === "foo"));
46 | expect(await kvc.kvs.get(["foo", "bar"])).to.deep.equal([
47 | {key: "foo", value: {collapsed: true}},
48 | ]);
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/src/util/oops.ts:
--------------------------------------------------------------------------------
1 | import {shallowReactive} from "vue";
2 |
3 | export type LoggedError = {
4 | summary: string;
5 | details: string;
6 | error: unknown;
7 | };
8 |
9 | /** The maximum size of the error log. */
10 | export const ERROR_LOG_SIZE = 10;
11 |
12 | /** The error log itself, which is reactive (even though its elements aren't). */
13 | export const errorLog = shallowReactive([] as LoggedError[]);
14 | (globalThis).error_log = errorLog;
15 |
16 | /** Calls the async function and returns its result. If the function throws an
17 | * exception, the exception is logged and re-thrown to the caller. The logs can
18 | * later be read by other code. */
19 | export async function logErrorsFrom(f: () => Promise): Promise {
20 | try {
21 | return await f();
22 | } catch (e) {
23 | logError(e);
24 | throw e;
25 | }
26 | }
27 |
28 | /** Add an error to the log. */
29 | export function logError(error: unknown) {
30 | console.error(error);
31 |
32 | if (error instanceof Error) {
33 | errorLog.push({
34 | error,
35 | summary: error.message,
36 | details: error.stack || error.message,
37 | });
38 | } else if (typeof error === "object") {
39 | const obj: any = error;
40 | errorLog.push({
41 | error,
42 | summary: String(error),
43 | details:
44 | "stack" in obj && typeof obj.stack === "string"
45 | ? obj.stack
46 | : String(error),
47 | });
48 | } else {
49 | errorLog.push({error, summary: String(error), details: String(error)});
50 | }
51 |
52 | while (errorLog.length > ERROR_LOG_SIZE) errorLog.shift();
53 | }
54 |
55 | /** Clears the error log (usually done at the request of the user). */
56 | export function clearErrorLog() {
57 | errorLog.splice(0, errorLog.length);
58 | }
59 |
60 | /** An error that's actually the user's fault. */
61 | export class UserError extends Error {}
62 |
--------------------------------------------------------------------------------
/styles/spinner.less:
--------------------------------------------------------------------------------
1 | .spinner {
2 | display: inline-block;
3 | &.size-icon {
4 | width: var(--icon-size);
5 | height: var(--icon-size);
6 | mask-image: radial-gradient(
7 | closest-side,
8 | rgba(0, 0, 0, 0%) 0%,
9 | rgba(0, 0, 0, 0%) calc(100% - var(--icon-p)),
10 | rgba(0, 0, 0, 100%) calc(100% - var(--icon-p)),
11 | rgba(0, 0, 0, 100%) 100%
12 | );
13 | -webkit-mask-image: radial-gradient(
14 | closest-side,
15 | rgba(0, 0, 0, 0%) 0%,
16 | rgba(0, 0, 0, 0%) calc(100% - var(--icon-p)),
17 | rgba(0, 0, 0, 100%) calc(100% - var(--icon-p)),
18 | rgba(0, 0, 0, 100%) 100%
19 | );
20 | }
21 |
22 | &.size-2x-icon {
23 | width: calc(var(--icon-size) * 2);
24 | height: calc(var(--icon-size) * 2);
25 | mask-image: radial-gradient(
26 | closest-side,
27 | rgba(0, 0, 0, 0%) 0%,
28 | rgba(0, 0, 0, 0%) calc(100% - calc(var(--icon-p) * 2)),
29 | rgba(0, 0, 0, 100%) calc(100% - calc(var(--icon-p) * 2)),
30 | rgba(0, 0, 0, 100%) 100%
31 | );
32 | -webkit-mask-image: radial-gradient(
33 | closest-side,
34 | rgba(0, 0, 0, 0%) 0%,
35 | rgba(0, 0, 0, 0%) calc(100% - calc(var(--icon-p) * 2)),
36 | rgba(0, 0, 0, 100%) calc(100% - calc(var(--icon-p) * 2)),
37 | rgba(0, 0, 0, 100%) 100%
38 | );
39 | }
40 |
41 | animation-name: spinner;
42 | animation-duration: 1s;
43 | animation-iteration-count: infinite;
44 | animation-timing-function: linear;
45 |
46 | border-radius: 50%;
47 | background-image: var(--spinner-gradient);
48 |
49 | mask-position: 50% 50%;
50 | mask-size: cover;
51 | mask-type: alpha;
52 | -webkit-mask-position: 50% 50%;
53 | -webkit-mask-size: cover;
54 | -webkit-mask-type: alpha;
55 | }
56 |
57 | @keyframes spinner {
58 | from {
59 | transform: rotate(0);
60 | }
61 | to {
62 | transform: rotate(1turn);
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/util/debug.ts:
--------------------------------------------------------------------------------
1 | /* c8 ignore start -- this is just a fancy console printer */
2 |
3 | // Debug facilities for tracing various things at runtime, in production, with
4 | // low overhead.
5 |
6 | export type TracerFn = {
7 | (...args: any[]): void;
8 | readonly tag: string;
9 | };
10 |
11 | const tracers: Record = {};
12 | export const tracersEnabled: Record = {};
13 |
14 | (globalThis).trace = tracersEnabled;
15 |
16 | /** Return a "tracer" function that just calls `console.log()`, but only if
17 | * `trace[tag]` is true. Logs are always emitted prefixed with `[tag]`, so it's
18 | * possible to tell where the log is coming from.
19 | *
20 | * If `context` arguments are specified, those arguments are inserted into every
21 | * call to `console.log()`, after the `[tag]` but before the arguments to the
22 | * tracer function. */
23 | export function trace_fn(tag: string, ...context: any[]): TracerFn {
24 | if (tracers[tag]) return tracers[tag];
25 | if (!(tag in tracersEnabled)) tracersEnabled[tag] = false;
26 | const log_tag = `[${tag}]`;
27 |
28 | const f =
29 | context.length > 0
30 | ? (...args: any[]) => {
31 | if (!tracersEnabled[tag]) return;
32 | console.log(log_tag, ...context, ...args);
33 | if (tracersEnabled[tag] === "stack") {
34 | try {
35 | throw new Error("stack trace");
36 | } catch (e) {
37 | console.log(e);
38 | }
39 | }
40 | }
41 | : (...args: any[]) => {
42 | if (!tracersEnabled[tag]) return;
43 | console.log(log_tag, ...args);
44 | if (tracersEnabled[tag] === "stack") {
45 | try {
46 | throw new Error("stack trace");
47 | } catch (e) {
48 | console.log(e);
49 | }
50 | }
51 | };
52 | (f as any).tag = tag;
53 | return f as TracerFn;
54 | }
55 |
--------------------------------------------------------------------------------
/src/components/item-icon.vue:
--------------------------------------------------------------------------------
1 |
23 |
24 |
29 |
37 |
38 |
39 |
40 |
41 |
49 |
--------------------------------------------------------------------------------
/src/mock/browser/containers.ts:
--------------------------------------------------------------------------------
1 | import type {ContextualIdentities as CI} from "webextension-polyfill";
2 |
3 | import * as events from "../events.js";
4 |
5 | class MockContainers implements CI.Static {
6 | readonly onCreated: events.MockEvent<
7 | (createInfo: CI.OnCreatedChangeInfoType) => void
8 | > = new events.MockEvent("browser.contextualIdentities.onCreated");
9 | readonly onRemoved: events.MockEvent<
10 | (removeInfo: CI.OnRemovedChangeInfoType) => void
11 | > = new events.MockEvent("browser.contextualIdentities.onRemoved");
12 | readonly onUpdated: events.MockEvent<
13 | (changeInfo: CI.OnUpdatedChangeInfoType) => void
14 | > = new events.MockEvent("browser.contextualIdentities.onUpdated");
15 |
16 | constructor() {
17 | return;
18 | }
19 |
20 | /* c8 ignore start -- not implemented */
21 | async get(cookieStoreId: string): Promise {
22 | throw new Error("Method not implemented.");
23 | }
24 |
25 | async query(details: CI.QueryDetailsType): Promise {
26 | throw new Error("Method not implemented.");
27 | }
28 |
29 | async create(details: CI.CreateDetailsType): Promise {
30 | throw new Error("Method not implemented.");
31 | }
32 |
33 | async update(
34 | cookieStoreId: string,
35 | details: CI.UpdateDetailsType,
36 | ): Promise {
37 | throw new Error("Method not implemented.");
38 | }
39 |
40 | async remove(cookieStoreId: string): Promise {
41 | throw new Error("Method not implemented.");
42 | }
43 | /* c8 ignore stop */
44 | }
45 |
46 | export default (() => {
47 | const exports = {
48 | contextualIdentities: new MockContainers(),
49 |
50 | reset() {
51 | exports.contextualIdentities = new MockContainers();
52 | (globalThis).browser.contextualIdentities =
53 | exports.contextualIdentities;
54 | },
55 | };
56 |
57 | exports.reset();
58 |
59 | return exports;
60 | })();
61 |
--------------------------------------------------------------------------------
/styles/metrics/normal.less:
--------------------------------------------------------------------------------
1 | html {
2 | --page-pw: 12px;
3 | --page-ph: 12px;
4 |
5 | --icon-size: 16px;
6 | --icon-p: 4px;
7 | --icon-btn-size: calc(var(--icon-size) + 2 * var(--icon-p));
8 | --collapse-btn-size: calc(var(--icon-size) + var(--icon-p));
9 |
10 | --focus-shadow: 0 0 2px 2px highlight;
11 | --active-tab-shadow-metrics: 1px 1px 2px 1px;
12 | --ephemeral-hover-shadow-metrics: 0 0 1px 1px;
13 |
14 | --notification-mw: var(--page-ph);
15 | --notification-mh: var(--page-ph);
16 | --notification-fade-time: 200ms;
17 |
18 | --modal-fade-time: 100ms;
19 |
20 | --group-border: 0.5px solid var(--group-border-clr);
21 | --group-border-radius: 9px;
22 | --group-ph: 6px;
23 | --group-header-font-weight: 550;
24 | --group-header-font-size: 13pt;
25 |
26 | --divider-border: 1px solid var(--group-border-clr);
27 |
28 | --ctrl-border: 1px solid var(--ctrl-border-clr);
29 | --ctrl-border-radius: 5px;
30 | --ctrl-pw: 6px;
31 | --ctrl-ph: 3px;
32 | --ctrl-mw: 8px;
33 | --ctrl-mh: 6px;
34 |
35 | --dialog-pw: var(--page-pw);
36 | --dialog-ph: var(--page-ph);
37 |
38 | --menu-mw: 12px;
39 | --menu-mh: var(--ctrl-mh);
40 |
41 | --input-text-pw: 3px;
42 | --input-text-ph: 2px;
43 | --input-text-border-radius: 3px;
44 |
45 | // The drag-and-drop ghost
46 | --ghost-border-width: 3px;
47 |
48 | // Width of various markers used on tabs.
49 | --container-indicator-bw: 4px;
50 |
51 | // Used only for text lists; lists with icons should be indented according
52 | // to the icon size.
53 | --text-list-indent-w: var(--icon-btn-size);
54 |
55 | --item-h: var(--icon-btn-size); /* must match because items have favicons */
56 | --item-gap-w: var(--ctrl-pw);
57 |
58 | // Nested children should be indented such that the left side of the icon
59 | // lines up with the beginning of the title in the container.
60 | --item-indent-w: calc(
61 | var(--icon-btn-size) + var(--item-gap-w) - var(--icon-p) -
62 | var(--indent-guide-w)
63 | );
64 |
65 | --indent-guide-w: 1px;
66 | }
67 |
--------------------------------------------------------------------------------
/src/tasks/export/markdown.ts:
--------------------------------------------------------------------------------
1 | import {defineComponent, h, type PropType, type VNode} from "vue";
2 |
3 | import {delimit, required} from "../../util/index.js";
4 | import type {StashItem, StashLeaf, StashParent} from "../../model/index.js";
5 | import {br, getParentInfo, splitItems} from "./helpers.js";
6 |
7 | const MD_LINK_QUOTABLES_RE = /\\|\[|\]|\&|\<|\>/g;
8 | const MD_URL_QUOTABLES_RE = /\\|\)/g;
9 |
10 | function renderParent(level: number, folder: StashParent): VNode {
11 | const {title, leaves, parents} = getParentInfo(folder);
12 |
13 | return h("div", {}, [
14 | h("div", {}, [`${"".padStart(level, "#")} ${quote_title(title)}`]),
15 | ...leaves.map(renderLeaf),
16 | ...(parents.length > 0 ? [br()] : []),
17 | ...delimit(
18 | br,
19 | parents.map(f => renderParent(level + 1, f)),
20 | ),
21 | ]);
22 | }
23 |
24 | function renderLeaf(node: StashLeaf): VNode {
25 | return h("div", {}, [
26 | `- [${quote_title(node.title || node.url)}](`,
27 | h("a", {href: node.url}, [quote_url(node.url)]),
28 | `)`,
29 | ]);
30 | }
31 |
32 | function quote_emphasis(text: string): string {
33 | return text
34 | .replace(
35 | /(^|\s)([*_]+)(\S)/g,
36 | (str, ws, emph, rest) => `${ws}${emph.replace(/./g, "\\$&")}${rest}`,
37 | )
38 | .replace(
39 | /(\S)([*_]+)(\s|$)/g,
40 | (str, rest, emph, ws) => `${rest}${emph.replace(/./g, "\\$&")}${ws}`,
41 | );
42 | }
43 | function quote_title(text: string): string {
44 | return quote_emphasis(text.replace(MD_LINK_QUOTABLES_RE, x => `\\${x}`));
45 | }
46 | function quote_url(url: string): string {
47 | return url.replace(MD_URL_QUOTABLES_RE, x => `\\${x}`);
48 | }
49 |
50 | export default defineComponent(
51 | (props: {items: StashItem[]}) => {
52 | return () => {
53 | const {leaves, parents} = splitItems(props.items);
54 | return [
55 | ...leaves.map(renderLeaf),
56 | ...(parents.length > 0 && leaves.length > 0 ? [br()] : []),
57 | ...delimit(
58 | br,
59 | parents.map(p => renderParent(2, p)),
60 | ),
61 | ];
62 | };
63 | },
64 | {props: {items: required(Array as PropType)}},
65 | );
66 |
--------------------------------------------------------------------------------
/icons/cancel.svg:
--------------------------------------------------------------------------------
1 |
2 |
68 |
--------------------------------------------------------------------------------
/icons/collapse-open.svg:
--------------------------------------------------------------------------------
1 |
2 |
69 |
--------------------------------------------------------------------------------
/icons/delete.svg:
--------------------------------------------------------------------------------
1 |
2 |
70 |
--------------------------------------------------------------------------------
/icons/collapse-closed.svg:
--------------------------------------------------------------------------------
1 |
2 |
69 |
--------------------------------------------------------------------------------
/icons/back.svg:
--------------------------------------------------------------------------------
1 |
2 |
70 |
--------------------------------------------------------------------------------
/src/restore/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 | Copied
10 |
11 |
12 |
13 |
14 |
Restoring a Local File
15 |
Suspicious Stashed Tab
16 |
17 |
18 | For security reasons, your browser won't allow Tab Stash to restore tabs
19 | that show files on your computer without your intervention.
20 |
21 |
22 |
23 | For security reasons, your browser won't allow Tab Stash to restore
24 | privileged tabs without your intervention.
25 |
26 |
27 |
28 | Please make sure this URL looks right. If it looks okay,
29 | you can restore the tab by copying and pasting the URL into the address
30 | bar:
31 |
32 |
33 | {{
34 | url
35 | }}
36 |
37 |
38 |
39 |
87 |
--------------------------------------------------------------------------------
/src/launch-vue.ts:
--------------------------------------------------------------------------------
1 | /* c8 ignore start -- common entry point for UI pages */
2 |
3 | // An easy way to launch a Vue application, which also applies some CSS classes
4 | // common to every UI in Tab Stash.
5 |
6 | import type {ExtractPropTypes, MethodOptions} from "vue";
7 | import {createApp} from "vue";
8 |
9 | import {asyncEvent} from "./util/index.js";
10 | import the, {initTheGlobals} from "./globals-ui.js";
11 |
12 | import * as Options from "./model/options.js";
13 |
14 | export default function launch<
15 | C extends {props?: object; provide?: object; methods?: MethodOptions},
16 | >(
17 | component: C,
18 | options: () => Promise<{
19 | propsData: Readonly>;
20 | provide?: {[k: string]: any};
21 | methods?: MethodOptions & Partial;
22 | }>,
23 | ): void {
24 | const loc = new URL(document.location.href);
25 |
26 | // Enable tracing at load time if needed
27 | const trace = (loc.searchParams.get("trace") ?? "").split(",");
28 | for (const t of trace) {
29 | (globalThis).trace[t] = true;
30 | }
31 |
32 | const loader = async function () {
33 | await initTheGlobals();
34 |
35 | document.documentElement.dataset!.view = the.view;
36 | document.documentElement.dataset!.browser = the.browser;
37 | document.documentElement.dataset!.os = the.os;
38 |
39 | function updateStyle(opts: Options.SyncModel) {
40 | document.documentElement.dataset!.metrics = opts.state.ui_metrics;
41 | document.documentElement.dataset!.theme = opts.state.ui_theme;
42 | }
43 | updateStyle(the.model.options.sync);
44 | the.model.options.sync.onChanged.addListener(updateStyle);
45 |
46 | const opts = await options();
47 | const app = createApp(
48 | {
49 | ...component,
50 | provide: {
51 | ...(component.provide ?? {}),
52 | ...(opts.provide ?? {}),
53 | },
54 | methods: {
55 | ...(component.methods ?? {}),
56 | ...(opts.methods ?? {}),
57 | },
58 | },
59 | opts.propsData,
60 | );
61 | Object.assign(globalThis, {app, app_options: opts});
62 | app.mount("body");
63 | };
64 | window.addEventListener("load", asyncEvent(loader));
65 | }
66 |
67 | // Small helper function to pass our search parameters along to another sibling
68 | // page in this extension, so the sibling page knows what environment it's in.
69 | export function pageref(path: string): string {
70 | return `${path}${window.location.search}`;
71 | }
72 |
--------------------------------------------------------------------------------
/src/util/event.ts:
--------------------------------------------------------------------------------
1 | import type {Events} from "webextension-polyfill";
2 |
3 | import type {Args} from "./index.js";
4 |
5 | export type EventSource any> = Events.Event;
6 |
7 | /** An event. Events have listeners which are managed through the usual
8 | * add/has/removeListener() methods. A message can be broadcast to all
9 | * listeners using the send() method. */
10 | export interface Event<
11 | L extends (...args: any[]) => any,
12 | > extends EventSource {
13 | /** Send a message to all listeners. send() will arrange for each listener
14 | * to be called with the arguments provided after send() returns. */
15 | send(...args: Args): void;
16 | }
17 |
18 | let eventClass: {new (name: string, instance?: string): Event};
19 |
20 | /* c8 ignore start -- tests are always run in a mock environment */
21 | if ((globalThis).mock?.events) {
22 | // We are running in a mock environment. Use the MockEventDispatcher
23 | // instead, which allows for snooping on events.
24 | eventClass = (globalThis).MockEvent;
25 | } else {
26 | eventClass = class Event<
27 | L extends (...args: any[]) => any,
28 | > implements Event {
29 | private _listeners: Set = new Set();
30 |
31 | addListener(l: L) {
32 | this._listeners.add(l);
33 | }
34 |
35 | removeListener(l: L) {
36 | this._listeners.delete(l);
37 | }
38 |
39 | hasListener(l: L) {
40 | return this._listeners.has(l);
41 | }
42 |
43 | hasListeners() {
44 | return this._listeners.size > 0;
45 | }
46 |
47 | send(...args: Args) {
48 | // This executes more quickly than setTimeout(), which is what we
49 | // want since setTimeout() is often used to wait for events to be
50 | // delivered (and immediate timeouts are not always run in the order
51 | // they are scheduled...).
52 | Promise.resolve().then(() => this.sendSync(...args));
53 | }
54 |
55 | private sendSync(...args: Args) {
56 | for (const fn of this._listeners) {
57 | try {
58 | fn(...args);
59 | } catch (e) {
60 | console.error(e);
61 | }
62 | }
63 | }
64 | };
65 | }
66 | /* c8 ignore stop */
67 |
68 | /** Constructs and returns an event. In unit tests, this is a mock which must
69 | * be explicitly controlled by the calling test. */
70 | export default function any>(
71 | name: string,
72 | instance?: string,
73 | ): Event {
74 | return new eventClass(name, instance);
75 | }
76 |
--------------------------------------------------------------------------------
/icons/select.svg:
--------------------------------------------------------------------------------
1 |
2 |
70 |
--------------------------------------------------------------------------------
/icons/tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
71 |
--------------------------------------------------------------------------------
/icons/item-menu.svg:
--------------------------------------------------------------------------------
1 |
2 |
78 |
--------------------------------------------------------------------------------
/icons/rename.svg:
--------------------------------------------------------------------------------
1 |
2 |
71 |
--------------------------------------------------------------------------------
/icons/delete-stashed.svg:
--------------------------------------------------------------------------------
1 |
2 |
76 |
--------------------------------------------------------------------------------
/src/util/nanoservice/index.ts:
--------------------------------------------------------------------------------
1 | /* c8 ignore start -- error classes and live NanoPort creation */
2 |
3 | import * as Live from "./live.js";
4 | import type * as Proto from "./proto.js";
5 |
6 | export type Send = Proto.Send;
7 | export const registry = Live.registry;
8 |
9 | export function listen(
10 | name: string,
11 | svc: NanoService,
12 | ) {
13 | Live.registry.register(name, svc as unknown as NanoService);
14 | }
15 |
16 | export function connect(
17 | name: string,
18 | ): NanoPort {
19 | return Live.Port.connect(name);
20 | }
21 |
22 | export interface NanoService {
23 | onConnect?: (port: NanoPort) => void;
24 | onDisconnect?: (port: NanoPort) => void;
25 | onRequest?: (port: NanoPort, msg: C) => Promise;
26 | onNotify?: (port: NanoPort, msg: C) => void;
27 | }
28 |
29 | export interface NanoPort {
30 | readonly name: string;
31 |
32 | defaultTimeoutMS?: number;
33 |
34 | onDisconnect?: (port: NanoPort) => void;
35 | onRequest?: (msg: R) => Promise;
36 | onNotify?: (msg: R) => void;
37 |
38 | readonly error?: {message?: string};
39 |
40 | request(msg: S, options?: {timeout_ms?: number}): Promise;
41 | notify(msg: S): void;
42 | disconnect(): void;
43 | }
44 |
45 | export class RemoteNanoError extends Error {
46 | private readonly remote: Proto.ErrorResponse;
47 |
48 | constructor(remote: Proto.ErrorResponse) {
49 | super(remote.message);
50 | this.remote = remote;
51 | }
52 |
53 | get name(): string {
54 | return this.remote.name;
55 | }
56 | get stack(): string | undefined {
57 | return `[remote stack] ${this.remote.stack}`;
58 | }
59 | get data(): Send | undefined {
60 | return this.remote.data;
61 | }
62 | }
63 |
64 | export class NanoPortError extends Error {}
65 |
66 | export class NanoTimeoutError extends NanoPortError {
67 | readonly portName: string;
68 | readonly request: Send;
69 | readonly tag: string;
70 | constructor(portName: string, request: Send, tag: string) {
71 | super(`${portName}: Request timed out`);
72 | this.portName = portName;
73 | this.name = "NanoTimeoutError";
74 | this.request = request;
75 | this.tag = tag;
76 | }
77 | }
78 |
79 | export class NanoDisconnectedError extends NanoPortError {
80 | readonly portName: string;
81 | readonly tag: string;
82 | constructor(portName: string, tag: string) {
83 | super(`${portName}: Port was disconnected while waiting for response`);
84 | this.portName = portName;
85 | this.name = "NanoDisconnectedError";
86 | this.tag = tag;
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/icons/delete-opened.svg:
--------------------------------------------------------------------------------
1 |
2 |
77 |
--------------------------------------------------------------------------------
/src/model/tree-filter.ts:
--------------------------------------------------------------------------------
1 | import {computed, reactive, type Ref} from "vue";
2 |
3 | import type {IsParentFn, TreeNode, TreeParent} from "./tree.js";
4 |
5 | export interface FilterInfo {
6 | /** Does this node match the predicate function? */
7 | readonly isMatching: boolean;
8 |
9 | /** Do any nodes in this node's sub-tree match the predicate? (Excludes the
10 | * node itself.) If the node is not a parent, this is always false. */
11 | readonly hasMatchInSubtree: boolean;
12 |
13 | /** How many direct child nodes do NOT have a match in their subtree? (This is
14 | * useful for showing a "+ N filtered" number to users to indicate how many
15 | * items are hidden in the UI.) */
16 | readonly nonMatchingCount: number;
17 | }
18 |
19 | /** A Tree whose nodes have been filtered by a predicate function. */
20 | export class TreeFilter
, N extends TreeNode
> {
21 | /** Check if a particular node is a parent node or not. */
22 | readonly isParent: IsParentFn
;
23 |
24 | /** The predicate function used to determine whether a node `isMatching` or
25 | * not. Updating this ref will update the `.isMatching` property on every
26 | * node. */
27 | readonly predicate: Ref<(node: P | N) => boolean>;
28 |
29 | private readonly nodes = new WeakMap
,
33 | predicate: Ref<(node: P | N) => boolean>,
34 | ) {
35 | this.isParent = isParent;
36 | this.predicate = predicate;
37 | }
38 |
39 | /** Returns a FilterInfo object describing whether this node (and/or its
40 | * sub-tree) matches the predicate or not. */
41 | info(node: P | N): FilterInfo {
42 | const n = this.nodes.get(node);
43 | if (n) return n;
44 |
45 | const isParent = this.isParent(node);
46 |
47 | const isMatching = computed(() => this.predicate.value(node));
48 |
49 | const hasMatchInSubtree = isParent
50 | ? computed(() => {
51 | for (const c of node.children) {
52 | if (!c) continue;
53 | const i = this.info(c);
54 | if (i.isMatching || i.hasMatchInSubtree) return true;
55 | }
56 | return false;
57 | })
58 | : computed(() => false);
59 |
60 | const nonMatchingCount = isParent
61 | ? computed(() => {
62 | let count = 0;
63 | for (const c of node.children) {
64 | if (!c) continue;
65 | const i = this.info(c);
66 | if (!i.isMatching && !i.hasMatchInSubtree) ++count;
67 | }
68 | return count;
69 | })
70 | : 0;
71 |
72 | const i: FilterInfo = reactive({
73 | isMatching,
74 | hasMatchInSubtree,
75 | nonMatchingCount,
76 | });
77 |
78 | this.nodes.set(node, i);
79 | return i;
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/icons/select-selected.svg:
--------------------------------------------------------------------------------
1 |
2 |
74 |
--------------------------------------------------------------------------------
/icons/mainmenu.svg:
--------------------------------------------------------------------------------
1 |
2 |
88 |
--------------------------------------------------------------------------------
/install-deps.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -ex
4 |
5 | NODE_VERSION=20
6 |
7 | deps_apt() {
8 | # Check for old Ubuntus
9 | if [ "$(lsb_release -i |awk '{print $3}')" = "Ubuntu" ]; then
10 | if [ "$(lsb_release -r |awk '{print $2}' |cut -d. -f1)" -lt 22 ]; then
11 | die "Sorry, your Ubuntu is too old. Please use 22.04 or newer."
12 | fi
13 | fi
14 |
15 | # Make sure we have the latest packages
16 | sudo apt-get update
17 |
18 | # Inkscape
19 | if type snap; then
20 | if snap list inkscape 2>/dev/null; then
21 | # The snap version of inkscape messes up the path of passed-in
22 | # arguments, so doing normal things like running inkscape on a file
23 | # using a relative path just doesn't work...
24 | die "Inkscape is known to be broken when installed via snap." \
25 | "Please install it with apt-get instead."
26 | fi
27 | fi
28 | if ! type inkscape; then
29 | if type add-apt-repository; then
30 | sudo add-apt-repository universe
31 | sudo apt-get update
32 | fi
33 | sudo apt-get install -y inkscape
34 | fi
35 |
36 | # Build tools
37 | sudo apt-get install -y make git diffutils patch rsync zip ca-certificates curl gnupg
38 |
39 | if ! type node; then
40 | sudo mkdir -p /etc/apt/keyrings
41 | curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
42 |
43 | echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
44 |
45 | sudo apt-get update
46 | sudo apt-get install -y nodejs
47 |
48 | elif [ "$(node --version |cut -c2-3)" -lt $NODE_VERSION ]; then
49 | die "Please upgrade Node.js to v$NODE_VERSION or later (you have $(node --version))."
50 | fi
51 | }
52 |
53 | deps_brew() {
54 | # We need Homebrew (which should install developer tools)
55 | if ! type brew; then
56 | die "Please install Homebrew first, which you can do by running:" \
57 | "/bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""
58 | fi
59 |
60 | type inkscape || brew install --cask inkscape
61 | type node || brew install node
62 | type npm || brew install npm
63 | }
64 |
65 | die() {
66 | set +ex
67 | echo "" >&2
68 | while [ $# -gt 0 ]; do
69 | echo "!!! $1" >&2
70 | shift
71 | done
72 | echo "" >&2
73 | exit 1
74 | }
75 |
76 | if type apt-get; then
77 | deps_apt
78 |
79 | elif type pkgutil && [ "$(uname)" = "Darwin" ]; then
80 | deps_brew
81 |
82 | else
83 | die "Don't know how to check/install dependencies on this platform."
84 | fi
85 |
86 | set +ex
87 | echo
88 | echo ">>> All done! You're ready to build Tab Stash."
89 | echo
90 |
--------------------------------------------------------------------------------
/src/components/confirm-dialog.vue:
--------------------------------------------------------------------------------
1 |
2 |
43 |
44 |
45 |
58 |
59 |
96 |
--------------------------------------------------------------------------------
/src/model/tree-filter.test.ts:
--------------------------------------------------------------------------------
1 | import {expect} from "chai";
2 | import {nextTick, ref, type Ref} from "vue";
3 |
4 | import {TreeFilter} from "./tree-filter.js";
5 |
6 | import {
7 | isTestParent,
8 | makeDefaultTree,
9 | type TestNode,
10 | type TestParent,
11 | } from "./tree.test.js";
12 |
13 | type Parent = TestParent;
14 | type Child = TestNode;
15 |
16 | describe("model/tree-filter", () => {
17 | const [root, _parents, nodes] = makeDefaultTree();
18 |
19 | let treeFilter: TreeFilter;
20 | /* c8 ignore next -- default impl is always overridden by tests */
21 | const predicate: Ref<(n: Parent | Child) => boolean> = ref(_ => false);
22 |
23 | function checkFilterInvariants() {
24 | const visit = (n: Parent | Child) => {
25 | const i = treeFilter.info(n);
26 | expect(i.isMatching).to.equal(
27 | predicate.value(n),
28 | `${n.name}: Predicate does not match`,
29 | );
30 |
31 | if (!("children" in n)) return;
32 | let hasMatchInSubtree = false;
33 | let nonMatchingCount = 0;
34 | for (const c of n.children) {
35 | if (!c) continue;
36 | const ci = treeFilter.info(c);
37 | visit(c);
38 | if (ci.isMatching || ci.hasMatchInSubtree) hasMatchInSubtree = true;
39 | if (!ci.isMatching && !ci.hasMatchInSubtree) ++nonMatchingCount;
40 | }
41 | expect(i.hasMatchInSubtree).to.equal(
42 | hasMatchInSubtree,
43 | `${n.name}: Incorrect hasMatchingChildren`,
44 | );
45 | expect(i.nonMatchingCount).to.equal(
46 | nonMatchingCount,
47 | `${n.name}: Incorrect filteredCount`,
48 | );
49 | };
50 | visit(root);
51 | }
52 |
53 | beforeEach(() => {
54 | /* c8 ignore next -- default impl is always overridden by tests */
55 | predicate.value = _ => false;
56 | treeFilter = new TreeFilter(isTestParent, predicate);
57 | });
58 |
59 | it("reports when nothing matches the filter", () => {
60 | predicate.value = _ => false;
61 | for (const v in nodes) {
62 | const f = treeFilter.info(nodes[v]);
63 | expect(f.isMatching).to.be.false;
64 | }
65 | checkFilterInvariants();
66 | });
67 |
68 | it("reports when everything matches the filter", async () => {
69 | predicate.value = _ => true;
70 | await nextTick();
71 |
72 | for (const v in nodes) {
73 | const f = treeFilter.info(nodes[v]);
74 | expect(f.isMatching).to.be.true;
75 | }
76 | checkFilterInvariants();
77 | });
78 |
79 | it("reports when some things match the filter", async () => {
80 | predicate.value = n => n.name.endsWith("2");
81 | await nextTick();
82 |
83 | for (const [id, val] of [
84 | ["a", false],
85 | ["b2", true],
86 | ["c2", true],
87 | ["c2b2", true],
88 | ["c2b4", false],
89 | ["e", false],
90 | ["e2", true],
91 | ] as const) {
92 | expect(treeFilter.info(nodes[id]).isMatching).to.equal(val);
93 | }
94 |
95 | checkFilterInvariants();
96 | });
97 | });
98 |
--------------------------------------------------------------------------------
/src/stash-list/dnd-proto.ts:
--------------------------------------------------------------------------------
1 | import {filterMap} from "../util/index.js";
2 |
3 | import type * as BM from "../model/bookmarks.js";
4 | import {
5 | copying,
6 | isFolder,
7 | isNode,
8 | isTab,
9 | isWindow,
10 | type ModelItem,
11 | type StashItem,
12 | } from "../model/index.js";
13 | import type * as T from "../model/tabs.js";
14 |
15 | const MIXED_TYPE = "application/x-tab-stash-dnd-mixed";
16 | const ONLY_FOLDERS_TYPE = "application/x-tab-stash-dnd-folders";
17 | const ONLY_LEAVES_TYPE = "application/x-tab-stash-dnd-leaves";
18 |
19 | type DNDItem = DNDWindow | DNDTab | DNDBookmarkNode | DNDBookmarkFolder;
20 |
21 | type DNDWindow = {window: T.WindowID};
22 | type DNDTab = {tab: T.TabID};
23 | type DNDBookmarkNode = {node: BM.NodeID};
24 | type DNDBookmarkFolder = {folder: BM.NodeID};
25 |
26 | export function sendDragData(dt: DataTransfer, items: ModelItem[]) {
27 | const data: DNDItem[] = items.map(i => {
28 | if (isFolder(i)) return {folder: i.id};
29 | if (isNode(i)) return {node: i.id};
30 | if (isTab(i)) return {tab: i.id};
31 | if (isWindow(i)) return {window: i.id};
32 | throw new Error(`Trying to drag unrecognized model item: ${i}`);
33 | });
34 |
35 | if (data.every(i => "folder" in i)) {
36 | dt.setData(ONLY_FOLDERS_TYPE, JSON.stringify(data));
37 | } else if (data.every(i => "node" in i || "tab" in i)) {
38 | dt.setData(ONLY_LEAVES_TYPE, JSON.stringify(data));
39 | } else {
40 | dt.setData(MIXED_TYPE, JSON.stringify(data));
41 | }
42 |
43 | dt.effectAllowed = "copyMove";
44 | }
45 |
46 | export function dragDataType(
47 | dt: DataTransfer,
48 | ): "folders" | "items" | "mixed" | undefined {
49 | if (dt.types.includes(ONLY_FOLDERS_TYPE)) return "folders";
50 | if (dt.types.includes(ONLY_LEAVES_TYPE)) return "items";
51 | if (dt.types.includes(MIXED_TYPE)) return "mixed";
52 | return undefined;
53 | }
54 |
55 | export function recvDragData(
56 | dt: DataTransfer,
57 | model: {bookmarks: BM.Model; tabs: T.Model},
58 | ): StashItem[] {
59 | let blob = dt.getData(MIXED_TYPE);
60 | if (!blob) blob = dt.getData(ONLY_FOLDERS_TYPE);
61 | if (!blob) blob = dt.getData(ONLY_LEAVES_TYPE);
62 |
63 | let data: DNDItem[];
64 | try {
65 | data = JSON.parse(blob) as DNDItem[];
66 | if (!(data instanceof Array)) return [];
67 | } catch (e) {
68 | return [];
69 | }
70 |
71 | const ret: StashItem[] = filterMap(data, i => {
72 | if (typeof i !== "object" || i === null) return undefined;
73 | if ("folder" in i && typeof i.folder === "string") {
74 | return model.bookmarks.node(i.folder);
75 | }
76 | if ("node" in i && typeof i.node === "string") {
77 | return model.bookmarks.node(i.node);
78 | }
79 | if ("window" in i && typeof i.window === "number") {
80 | return model.tabs.window(i.window);
81 | }
82 | if ("tab" in i && typeof i.tab === "number") {
83 | return model.tabs.tab(i.tab);
84 | }
85 | return undefined;
86 | });
87 |
88 | if (dt.dropEffect === "copy") return copying(ret);
89 | return ret;
90 | }
91 |
--------------------------------------------------------------------------------
/icons/restore-del.svg:
--------------------------------------------------------------------------------
1 |
2 |
77 |
--------------------------------------------------------------------------------
/styles/themes/icons.less:
--------------------------------------------------------------------------------
1 | .item-icon {
2 | .icon-wrapper();
3 | .icon-background-setup();
4 |
5 | & > img,
6 | & > span {
7 | .icon();
8 | }
9 | }
10 |
11 | .icon-wrapper {
12 | display: inline-block;
13 | box-sizing: border-box;
14 | width: var(--icon-btn-size);
15 | height: var(--icon-btn-size);
16 | padding: var(--icon-p);
17 | text-align: center;
18 | vertical-align: middle;
19 | border: none;
20 | border-radius: var(--ctrl-border-radius);
21 | }
22 |
23 | .icon-background-setup {
24 | background-size: var(--icon-size) var(--icon-size);
25 | background-position: center;
26 | background-repeat: no-repeat;
27 | background-color: transparent;
28 | }
29 |
30 | .icon {
31 | display: inline-block;
32 | box-sizing: border-box;
33 | width: var(--icon-size);
34 | height: var(--icon-size);
35 | object-fit: fill;
36 |
37 | .icon-background-setup();
38 |
39 | border: none;
40 | }
41 |
42 | // Function to define the set of icons used in each theme. Must be called from
43 | // the theme-* files for themes which have their own icons.
44 | .icon-vars(@theme, @inverse) {
45 | .icon(@id) {
46 | --icon-@{id}: url("icons/@{theme}/@{id}.svg");
47 | --icon-@{id}-inverse: url("icons/@{inverse}/@{id}.svg");
48 | }
49 | & {
50 | .icon(back);
51 | .icon(cancel);
52 | .icon(collapse-closed);
53 | .icon(collapse-open);
54 | .icon(delete-opened);
55 | .icon(delete-stashed);
56 | .icon(delete);
57 | .icon(export);
58 | .icon(filtered-hidden);
59 | .icon(filtered-visible);
60 | .icon(folder);
61 | .icon(import);
62 | .icon(item-menu);
63 | .icon(logo);
64 | .icon(mainmenu);
65 | .icon(move-menu);
66 | .icon(new-empty-group);
67 | .icon(pop-in);
68 | .icon(pop-out);
69 | .icon(rename);
70 | .icon(restore-del);
71 | .icon(restore);
72 | .icon(select);
73 | .icon(select-selected);
74 | .icon(sort);
75 | .icon(stash-one);
76 | .icon(stash);
77 | .icon(stashed);
78 | .icon(tab);
79 | .icon(warning);
80 | }
81 | }
82 |
83 | // Define CSS for particular icons.
84 | .def-icon(@id) {
85 | .icon-@{id} {
86 | background-image: var(e("--icon-@{id}"));
87 | }
88 | }
89 |
90 | // Vanilla icons (separate from actions, which are handled in action.less)
91 | .def-icon(delete);
92 | .def-icon(delete-opened);
93 | .def-icon(delete-stashed);
94 | .def-icon(export);
95 | .def-icon(filtered-hidden);
96 | .def-icon(filtered-visible);
97 | .def-icon(folder);
98 | .def-icon(import);
99 | .def-icon(item-menu);
100 | .def-icon(logo);
101 | .def-icon(move-menu-inverse);
102 | .def-icon(new-empty-group);
103 | .def-icon(pop-in);
104 | .def-icon(pop-out);
105 | .def-icon(restore-del);
106 | .def-icon(restore);
107 | .def-icon(select);
108 | .def-icon(select-selected);
109 | .def-icon(select-selected-inverse);
110 | .def-icon(sort);
111 | .def-icon(stash);
112 | .def-icon(stash-one);
113 | .def-icon(stashed);
114 | .def-icon(tab);
115 | .def-icon(warning);
116 |
--------------------------------------------------------------------------------
/docs/privacy.md:
--------------------------------------------------------------------------------
1 | # Privacy Policy
2 |
3 | Tab Stash does not share any of your information with the developers, or with
4 | any third party, except as noted below.
5 |
6 | ## Bookmarks and Firefox Sync
7 |
8 | Tab Stash uses bookmarks to store all your stashed tabs. Your bookmarks are
9 | synced using the Firefox Sync service (if configured), so your stashed tabs will
10 | appear on all computers linked to your Firefox Sync account.
11 |
12 | If you wish to stop using Tab Stash entirely, you can still retrieve your
13 | stashed tabs in the "Tab Stash" folder of your bookmarks.
14 |
15 | ## Extension Permissions
16 |
17 | When you first install it, Tab Stash will ask for the following permissions.
18 | Here's why we need each of them:
19 |
20 | - **Access browser tabs**: Used to save and restore tabs to the stash.
21 | (Honestly, we'd all be surprised if an extension with a name like "Tab Stash"
22 | _didn't_ have this permission.)
23 |
24 | - **Access recently closed tabs**: When restoring a stashed tab, Tab Stash will
25 | look thru recently-closed tabs to see if any of them have matching URLs, and
26 | restore the closed tab rather than creating a new one. This will restore
27 | additional state for that tab, such as navigation history.
28 |
29 | - **Hide and show browser tabs**: Used to hide stashed tabs instead of closing
30 | them outright, so they can be restored more quickly later (and preserve useful
31 | tab state such as navigation history, or that half-written blog post about
32 | last night's dinner you were in the middle of when your boss walked by...).
33 |
34 | - **Read and modify bookmarks**: Used to create and delete bookmarks in the "Tab
35 | Stash" folder which represent your stashed tabs.
36 |
37 | - **Read and modify browser settings**: Read-only; used to determine the new-tab
38 | and Home pages, so Tab Stash can tell if you're looking at a new tab, and
39 | automatically close it if it's not needed. Tab Stash does not modify your
40 | browser settings. (Although, if we _did_, we'd probably change your homepage
41 | to be a picture of a kitten. Because who doesn't like kittens?)
42 |
43 | - **Store client-side data**: Tab Stash stores your preferences (such as whether
44 | to open the stash in the sidebar or a new tab) in the browser's local and
45 | synced storage.
46 |
47 | - **Store unlimited amount of client-side data**: Tab Stash keeps a cache of
48 | website icons on your local computer, so they do not have to be fetched from
49 | the Internet (which can be a very slow process, depending on the website). To
50 | accommodate users whose stashes may grow very large, we ask to store lots of
51 | data so the cache can hold all the icons. Icons are removed from the cache
52 | automatically once they're no longer needed.
53 |
54 | - **Containers (contextual identities)** and **Cookies**: If you use Firefox's
55 | containers feature, these permissions are used to identify which container
56 | each tab belongs to and show an indicator in the Tab Stash UI.
57 |
58 | - **Menus**: Used to provide additional options for Tab Stash in the right-click
59 | menu of a page and the tab bar.
60 |
--------------------------------------------------------------------------------
/src/components/async-text-input.vue:
--------------------------------------------------------------------------------
1 |
2 |
19 |
20 |
21 |
102 |
--------------------------------------------------------------------------------
/icons/stash-one.svg:
--------------------------------------------------------------------------------
1 |
2 |
83 |
--------------------------------------------------------------------------------
/icons/pop-out.svg:
--------------------------------------------------------------------------------
1 |
2 |
77 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/4-feature-request.yml:
--------------------------------------------------------------------------------
1 | name: Feature Request / Missing Functionality
2 | description: You have an idea for improving Tab Stash, or you think something is missing that should be there.
3 | labels: ["i-enhancement"]
4 | body:
5 | - type: markdown
6 | attributes:
7 | value: |
8 | Thanks for taking the time to suggest an improvement to Tab Stash!
9 |
10 | To keep things organized, please put only ONE feature per request. If you're not sure whether your idea would be one or multiple feature requests, it's better to err on the side of opening separate requests, since it's much easier to close duplicates than split a single request into multiple requests.
11 |
12 | - type: textarea
13 | attributes:
14 | id: problem-stmt
15 | label: Problem Statement
16 | description: |
17 | What problem are you trying to solve that this feature could help with? Why is this feature important to you? Be as concrete, specific and detailed as possible.
18 |
19 | For example, "I have over 1,000,000 tabs stashed across 100,000 groups, and I can never find anything because scrolling takes a long time" clearly illustrates the scale and scope of the problem. Whereas, "Missing search feature is a huge problem" is likely to be ignored because it doesn't provide any detail about WHY the lack of a search feature is a huge problem.
20 | placeholder: I'm having trouble organizing my life because...
21 | validations:
22 | required: true
23 |
24 | - type: textarea
25 | attributes:
26 | id: preferred-solution
27 | label: Preferred Solution(s)
28 | description: Describe your ideal solution. What is different from today? What would the solution look like? How would you use it to solve your problem?
29 | placeholder: To help me organize my life better, I would like Tab Stash to...
30 | validations:
31 | required: true
32 |
33 | - type: textarea
34 | attributes:
35 | id: alt-solution
36 | label: Alternative Solution(s)
37 | description: Are there any other alternatives that would also solve your problem? What would those alternatives look like?
38 | validations:
39 | required: false
40 |
41 | - type: textarea
42 | attributes:
43 | id: details
44 | label: Additional Context
45 | description: Provide any other context/detail you think might be useful.
46 | validations:
47 | required: false
48 |
49 | - type: checkboxes
50 | id: voting
51 | attributes:
52 | label: Vote for This Issue
53 | description: Please check the box below so GitHub will tell everyone how to vote for your issue—sorry, I know it's an unnecessary step, but that's the only way GitHub will allow me to include this message in the issue itself.
54 | options:
55 | - label: |
56 | _Readers: If you are also interested in seeing this feature be developed, please vote for it by giving the ORIGINAL POST a thumbs-up using the :smiley: button below. You are welcome to leave comments and discuss the feature request, but "Me too!" comments are not counted by the voting system._
57 | required: true
58 | validations:
59 | required: true
60 |
--------------------------------------------------------------------------------
/icons/pop-in.svg:
--------------------------------------------------------------------------------
1 |
2 |
77 |
--------------------------------------------------------------------------------
/src/components/search-input.vue:
--------------------------------------------------------------------------------
1 |
2 |