├── .npmrc ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── 5-question.yml │ └── 4-feature-request.yml └── workflows │ ├── test-release-build.yml │ └── ci.yml ├── docs ├── screenshot.png ├── _config.yml ├── Gemfile ├── _layouts │ └── default.html ├── index.md ├── privacy.md ├── favicon.svg └── support.md ├── mocha-setup.mjs ├── .prettierrc.json ├── .vscode ├── extensions.json └── settings.json ├── .mocharc.json ├── .c8rc.json ├── styles ├── metrics │ ├── narrow.less │ ├── index.less │ ├── compact.less │ └── normal.less ├── view-tab.less ├── themes │ ├── index.less │ ├── dark.less │ ├── icons.less │ └── light.less ├── mods-whats-new.less ├── mods-flat.less ├── spinner.less └── mods-options.less ├── src ├── components │ ├── button-box.vue │ ├── modal-backdrop.vue │ ├── show-filtered-item.vue │ ├── progress-item.vue │ ├── notification.vue │ ├── progress-dialog.vue │ ├── dnd-directives.ts │ ├── dialog.vue │ ├── item-icon.vue │ ├── confirm-dialog.vue │ ├── async-text-input.vue │ └── search-input.vue ├── datastore │ └── kvs │ │ ├── memory.test.ts │ │ └── memory.ts ├── setup │ └── index.ts ├── stash-list │ ├── index.ts │ ├── select-folder.vue │ └── dnd-proto.ts ├── options │ ├── index.ts │ └── feature-flag.vue ├── deleted-items │ ├── index.ts │ └── schema.ts ├── mock │ ├── browser │ │ ├── index.ts │ │ ├── containers.ts │ │ └── sessions.ts │ └── index.ts ├── stash-list.html ├── setup.html ├── restore.html ├── restore │ ├── index.ts │ └── index.vue ├── whats-new.html ├── options.html ├── util │ ├── random.ts │ ├── wiring.ts │ ├── nanoservice │ │ ├── proto.ts │ │ └── index.ts │ ├── oops.ts │ ├── debug.ts │ └── event.ts ├── deleted-items.html ├── whats-new │ ├── index.ts │ ├── version.vue │ └── item.vue ├── tasks │ └── export │ │ ├── one-tab.ts │ │ ├── html-links.ts │ │ ├── url-list.ts │ │ ├── helpers.ts │ │ └── markdown.ts ├── model │ ├── bookmark-metadata.ts │ ├── bookmark-metadata.test.ts │ ├── tree-filter.ts │ └── tree-filter.test.ts ├── service-model.ts └── launch-vue.ts ├── tsconfig.test.json ├── tsconfig.json ├── vite.config.html.ts ├── .gitignore ├── vite.config.lib.ts ├── resize-ff-devedition-for-screenshots.sh ├── issues-top-10.bash ├── CODE_OF_CONDUCT.md ├── package.json ├── SECURITY.md ├── chrome-manifest.patch ├── vite.config.base.ts ├── test-detect-leaks.mjs ├── icons ├── cancel.svg ├── collapse-open.svg ├── delete.svg ├── collapse-closed.svg ├── back.svg ├── select.svg ├── tab.svg ├── item-menu.svg ├── rename.svg ├── delete-stashed.svg ├── delete-opened.svg ├── select-selected.svg ├── mainmenu.svg ├── restore-del.svg ├── stash-one.svg ├── pop-out.svg ├── pop-in.svg ├── move-menu.svg ├── restore.svg ├── export.svg ├── import.svg ├── sort.svg ├── stash.svg └── logo.svg ├── install-deps.sh └── assets └── manifest.json /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: josh-berry 2 | -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josh-berry/tab-stash/HEAD/docs/screenshot.png -------------------------------------------------------------------------------- /mocha-setup.mjs: -------------------------------------------------------------------------------- 1 | import {register} from "ts-node"; 2 | register({project: "tsconfig.test.json"}); 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": false, 3 | "arrowParens": "avoid", 4 | "endOfLine": "lf", 5 | "trailingComma": "all" 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "Vue.volar", 5 | "Vue.vscode-typescript-vue-plugin" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extension": ["ts"], 3 | "spec": "src/**/*.test.ts", 4 | "loader": "ts-node/esm", 5 | "require": ["./mocha-setup.mjs", "./src/mock/index.ts"] 6 | } 7 | -------------------------------------------------------------------------------- /.c8rc.json: -------------------------------------------------------------------------------- 1 | { 2 | "all": true, 3 | "skip-full": true, 4 | "include": ["src/**"], 5 | "exclude": ["assets", "dist", "docs", "node_modules"], 6 | "reporter": ["lcov", "text", "html"] 7 | } 8 | -------------------------------------------------------------------------------- /styles/metrics/narrow.less: -------------------------------------------------------------------------------- 1 | html { 2 | --dialog-pw: calc(var(--page-pw) / 2); 3 | 4 | // Center the icons for children underneath the item gap of the parent 5 | --item-indent-w: calc(var(--icon-btn-size) / 2); 6 | } 7 | -------------------------------------------------------------------------------- /src/components/button-box.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/datastore/kvs/memory.test.ts: -------------------------------------------------------------------------------- 1 | import {tests} from "./index.test.js"; 2 | import MemoryKVS from "./memory.js"; 3 | 4 | const factory = async () => new MemoryKVS("test"); 5 | 6 | describe("datastore/kvs/memory", () => tests(factory)); 7 | -------------------------------------------------------------------------------- /src/setup/index.ts: -------------------------------------------------------------------------------- 1 | /* c8 ignore start -- launcher shim for the live UI */ 2 | 3 | import launch from "../launch-vue.js"; 4 | 5 | import Main from "./index.vue"; 6 | 7 | launch(Main, async () => { 8 | return { 9 | propsData: {}, 10 | }; 11 | }); 12 | -------------------------------------------------------------------------------- /src/stash-list/index.ts: -------------------------------------------------------------------------------- 1 | /* c8 ignore start -- launcher shim for the live UI */ 2 | 3 | import launch from "../launch-vue.js"; 4 | 5 | import Main from "./index.vue"; 6 | 7 | launch(Main, async () => { 8 | return { 9 | propsData: {}, 10 | }; 11 | }); 12 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | 4 | "compilerOptions": { 5 | "removeComments": false, 6 | "verbatimModuleSyntax": false, 7 | 8 | "types": ["@types/mocha"], 9 | 10 | "outDir": "build.test" 11 | }, 12 | 13 | "include": ["./src/**/*.test.ts"] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "compilerOptions": { 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | 7 | "baseUrl": ".", 8 | 9 | "sourceMap": true, 10 | "noUnusedLocals": true 11 | }, 12 | "include": ["./src/**/*"] 13 | } 14 | -------------------------------------------------------------------------------- /src/options/index.ts: -------------------------------------------------------------------------------- 1 | /* c8 ignore start -- launcher shim for the live UI */ 2 | 3 | import launch from "../launch-vue.js"; 4 | 5 | import the from "../globals-ui.js"; 6 | import Main from "./index.vue"; 7 | 8 | launch(Main, async () => { 9 | return { 10 | propsData: { 11 | model: the.model.options, 12 | }, 13 | }; 14 | }); 15 | -------------------------------------------------------------------------------- /src/deleted-items/index.ts: -------------------------------------------------------------------------------- 1 | /* c8 ignore start -- launcher shim for the live UI */ 2 | 3 | import the from "../globals-ui.js"; 4 | import launch from "../launch-vue.js"; 5 | 6 | import Main from "./index.vue"; 7 | 8 | launch(Main, async () => { 9 | return { 10 | propsData: { 11 | state: the.model.deleted_items.state, 12 | }, 13 | }; 14 | }); 15 | -------------------------------------------------------------------------------- /src/mock/browser/index.ts: -------------------------------------------------------------------------------- 1 | import bookmarks from "./bookmarks.js"; 2 | import containers from "./containers.js"; 3 | import runtime from "./runtime.js"; 4 | import sessions from "./sessions.js"; 5 | import storage from "./storage.js"; 6 | import tabs_and_windows from "./tabs-and-windows.js"; 7 | 8 | export {bookmarks, containers, runtime, sessions, storage, tabs_and_windows}; 9 | -------------------------------------------------------------------------------- /src/stash-list.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tab Stash 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /vite.config.html.ts: -------------------------------------------------------------------------------- 1 | import base from "./vite.config.base"; 2 | 3 | base.build!.rollupOptions!.input = { 4 | "deleted-items": "src/deleted-items.html", 5 | restore: "src/restore.html", 6 | setup: "src/setup.html", 7 | "stash-list": "src/stash-list.html", 8 | options: "src/options.html", 9 | "whats-new": "src/whats-new.html", 10 | }; 11 | 12 | export default base; 13 | -------------------------------------------------------------------------------- /src/setup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Welcome to Tab Stash! 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/restore.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Restore Privileged Tab 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Large image files for the AMO listing 2 | marketing 3 | 4 | # Website 5 | #docs/.bundle 6 | docs/_site 7 | docs/vendor 8 | 9 | # Generated during build 10 | node_modules 11 | dist 12 | dist-chrome 13 | coverage 14 | 15 | # Release engineering 16 | releases/tab-stash-src-* 17 | releases/tab-stash-*.zip 18 | releases/tab-stash-src-*.tar.gz 19 | 20 | # Data related to issues on GitHub 21 | issues 22 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | title: Tab Stash 2 | description: Clear your tabs, clear your mind. Only for Firefox. 3 | theme: jekyll-theme-minimal 4 | include: 5 | - index.md 6 | - contributing.md 7 | plugins: 8 | - jekyll-default-layout 9 | - jekyll-gist 10 | - jekyll-optional-front-matter 11 | - jekyll-paginate 12 | #- jekyll-readme-index 13 | - jekyll-titles-from-headings 14 | - jekyll-relative-links 15 | -------------------------------------------------------------------------------- /src/restore/index.ts: -------------------------------------------------------------------------------- 1 | /* c8 ignore start -- launcher shim for the live UI */ 2 | 3 | import launch from "../launch-vue.js"; 4 | import Main from "./index.vue"; 5 | 6 | launch(Main, async () => { 7 | const my_url = new URL(document.location.href); 8 | 9 | const url = my_url.searchParams.get("url"); 10 | if (url) document.title = url; 11 | 12 | return { 13 | propsData: {url}, 14 | }; 15 | }); 16 | -------------------------------------------------------------------------------- /src/whats-new.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | What's New in Tab Stash 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tab Stash: Options 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/util/random.ts: -------------------------------------------------------------------------------- 1 | /** Generates some random bytes and turns them into a string. This is 2 | * surprisingly not a one-liner in JS, and it used to be different between the 3 | * browser and Node, so it's done once here. */ 4 | export function makeRandomString(bytes: number): string { 5 | const a = new Uint8Array(bytes); 6 | crypto.getRandomValues(a); 7 | return btoa(String.fromCharCode(...a)).replace(/=+$/, ""); 8 | } 9 | -------------------------------------------------------------------------------- /src/deleted-items.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Deleted Items — Tab Stash 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/deleted-items/schema.ts: -------------------------------------------------------------------------------- 1 | /* c8 ignore start -- file contains only types */ 2 | 3 | import type * as DI from "../model/deleted-items.js"; 4 | 5 | export type RecordGroup = {title: string; records: FilteredDeletion[]}; 6 | export type FilteredDeletion = DI.Deletion & {item: FilteredDeletedItem}; 7 | export type FilteredDeletedItem = FilteredCount; 8 | 9 | export type FilteredCount = F & {filtered_count?: number}; 10 | -------------------------------------------------------------------------------- /vite.config.lib.ts: -------------------------------------------------------------------------------- 1 | import base from "./vite.config.base"; 2 | 3 | // In library mode (enabled by setting .lib below), Vite will not replace 4 | // process.env, and so this will leak into code that runs in the browser. 5 | base.define = { 6 | "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV), 7 | }; 8 | 9 | base.build!.lib = { 10 | entry: "index.ts", 11 | fileName: "index", 12 | formats: ["es"], 13 | }; 14 | 15 | export default base; 16 | -------------------------------------------------------------------------------- /styles/metrics/index.less: -------------------------------------------------------------------------------- 1 | // 2 | // METRICS - WIDTHS, HEIGHTS AND OTHER MEASUREMENTS 3 | // 4 | // Suffixes: *-[mpg][whlrtb] 5 | // mpg - margin/padding/gap[within grid] 6 | // whlrtb - width/height/left/right/top/bottom 7 | // -border - a full border: definition (including a theme color) 8 | // 9 | 10 | @use-narrow-metrics: 30rem; 11 | 12 | @import "normal.less"; 13 | @media (max-width: @use-narrow-metrics) { 14 | @import "narrow.less"; 15 | } 16 | html[data-metrics="compact"] { 17 | @import "compact.less"; 18 | } 19 | -------------------------------------------------------------------------------- /resize-ff-devedition-for-screenshots.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | #osascript -e 'set the bounds of the first window of application "Firefox Developer Edition" to {0, 0, 1280, 800}' 4 | 5 | # Reduce size for the artificial shadow added by the preview snapshots 6 | #osascript -e 'set the bounds of the first window of application "Firefox Developer Edition" to {0, 0, 1168, 688}' 7 | 8 | # HiDPI - based on new guidance from AMO, with subtractions to account for 9 | # window shadow 10 | osascript -e 'set the bounds of the first window of application "Firefox Developer Edition" to {0, 0, 1200-112, 900-112}' 11 | -------------------------------------------------------------------------------- /styles/view-tab.less: -------------------------------------------------------------------------------- 1 | // Mods for when in tab view -- uses a multi-column display format 2 | 3 | .forest { 4 | display: flex; 5 | flex-direction: row; 6 | flex-wrap: wrap; 7 | align-items: stretch; 8 | 9 | & > li { 10 | flex: 1 1 25rem; 11 | margin: 0; // Handled by row/column gap 12 | } 13 | 14 | &.one-column { 15 | display: grid; 16 | grid-template-columns: 1fr minmax(0, 40rem) 1fr; 17 | column-gap: 0; 18 | 19 | & > li { 20 | grid-column: 2; 21 | flex-basis: 40rem; 22 | } 23 | } 24 | } 25 | 26 | // Don't show the "stash this tab" button on folders when we are the 27 | // current tab (and not the sidebar) 28 | .action.stash.one.here { 29 | display: none; 30 | } 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/5-question.yml: -------------------------------------------------------------------------------- 1 | name: Ask a Question 2 | description: Use this if no other template seems to fit. 3 | labels: ["i-support request"] 4 | body: 5 | - type: textarea 6 | attributes: 7 | id: question 8 | label: Question 9 | description: | 10 | Type your question here. The more context and detail you provide up-front, the easier it will be to respond quickly and accurately. 11 | 12 | [**PLEASE NOTE**: If you're using this form just to avoid having to answer the more detailed questions in the other forms, that won't work. I'm just going to ask you those questions anyway, and it'll drag the whole process out. You're not gonna upset me or anything, you can use this form if you want, I just thought you should know. :smiley:] 13 | -------------------------------------------------------------------------------- /src/components/modal-backdrop.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 21 | 22 | 40 | -------------------------------------------------------------------------------- /src/components/show-filtered-item.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 30 | -------------------------------------------------------------------------------- /src/components/progress-item.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 28 | -------------------------------------------------------------------------------- /issues-top-10.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | gen() { 4 | echo '' 5 | 6 | gh issue list -R josh-berry/tab-stash -L 1000 \ 7 | --json url,title,reactionGroups,createdAt \ 8 | |jq -r ' 9 | map( 10 | .reactionGroups |= map(select(.content=="THUMBS_UP"))[0] 11 | |{ 12 | url: .url, 13 | title: .title, 14 | age: ((now - (.createdAt|fromdate)) / (24*60*60)), 15 | votes: ((.reactionGroups?.users?.totalCount // 0) + 1) 16 | } 17 | |select(.votes >= 3) 18 | |(.voteVelocity = .votes / .age) 19 | ) 20 | |sort_by(-.voteVelocity) 21 | |.[] 22 | |"" 23 | ' |head -n 10 24 | echo '
\(.votes)v/\(.age|floor)d\(.title)
' 25 | } 26 | 27 | open -a firefox "data:text/html;base64,$(gen|base64)" 28 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Tab Stash Code of Conduct 2 | 3 | Tab Stash has adopted the [Contributor Covenant Code of Conduct][conduct]. 4 | Anyone participating in the Tab Stash community, whether they are writing code, 5 | filing bugs, or simply posting questions, is asked to follow these standards. 6 | 7 | [conduct]: https://www.contributor-covenant.org/version/2/1/code_of_conduct/ 8 | 9 | In short, before posting please ask yourself: "If this were directed at me, how 10 | would it make me feel?" If the answer is negative, think carefully about how to 11 | re-frame your post---try to focus on specific, observable facts, avoid 12 | generalizing, and replace emotional language ("this was a huge problem") with 13 | concrete details ("it took me an hour to recover my data because..."). 14 | 15 | Any concerns may be reported privately by email to `tab-stash@condordes.net`. 16 | Please put `[Tab Stash Conduct]` (including the brackets) at the beginning of 17 | the subject. 18 | -------------------------------------------------------------------------------- /src/whats-new/index.ts: -------------------------------------------------------------------------------- 1 | /* c8 ignore start -- launcher shim for the live UI */ 2 | 3 | import browser from "webextension-polyfill"; 4 | 5 | import launch from "../launch-vue.js"; 6 | import {resolveNamed} from "../util/index.js"; 7 | 8 | import * as Options from "../model/options.js"; 9 | 10 | import Main from "./index.vue"; 11 | 12 | launch(Main, async () => { 13 | const r = await resolveNamed({ 14 | options: Options.Model.live(), 15 | extn: browser.management.getSelf(), 16 | }); 17 | (globalThis).options = r.options; 18 | 19 | // If we are caught up to the current version, just show everything. 20 | const version = 21 | r.options.local.state.last_notified_version == r.extn.version 22 | ? undefined 23 | : r.options.local.state.last_notified_version; 24 | 25 | r.options.local.set({last_notified_version: r.extn.version}); 26 | 27 | return { 28 | provide: { 29 | last_notified_version: version, 30 | }, 31 | propsData: {}, 32 | }; 33 | }); 34 | -------------------------------------------------------------------------------- /src/tasks/export/one-tab.ts: -------------------------------------------------------------------------------- 1 | import {defineComponent, h, type PropType, type VNode} from "vue"; 2 | 3 | import type {StashItem, StashLeaf} from "../../model/index.js"; 4 | import {br, splitItems} from "./helpers.js"; 5 | import {delimit, required} from "../../util/index.js"; 6 | 7 | function renderItems(items: (StashItem | undefined)[]): VNode { 8 | const {leaves, parents} = splitItems(items); 9 | return h("div", [ 10 | ...leaves.map(renderBookmark), 11 | ...(parents.length > 0 && leaves.length > 0 ? [br()] : []), 12 | ...delimit( 13 | br, 14 | parents.map(p => renderItems(p.children)), 15 | ), 16 | ]); 17 | } 18 | 19 | function renderBookmark(node: StashLeaf): VNode { 20 | return h("div", {}, [ 21 | h("a", {href: node.url}, [node.url]), 22 | ` | ${node.title}`, 23 | ]); 24 | } 25 | 26 | export default defineComponent( 27 | (props: {items: StashItem[]}) => { 28 | return () => renderItems(props.items); 29 | }, 30 | {props: {items: required(Array as PropType)}}, 31 | ); 32 | -------------------------------------------------------------------------------- /src/components/notification.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/tasks/export/html-links.ts: -------------------------------------------------------------------------------- 1 | import {defineComponent, h, type PropType, type VNode} from "vue"; 2 | 3 | import { 4 | type StashItem, 5 | type StashLeaf, 6 | type StashParent, 7 | } from "../../model/index.js"; 8 | import {getParentInfo, renderItems} from "./helpers.js"; 9 | import {required} from "../../util/index.js"; 10 | 11 | function renderParent(level: number, folder: StashParent): VNode { 12 | const {title, leaves, parents} = getParentInfo(folder); 13 | return h("section", {}, [ 14 | h(`h${level}`, {}, [title]), 15 | h("ul", {}, leaves.map(renderLeaf)), 16 | ...parents.map(f => renderParent(Math.min(level + 1, 5), f)), 17 | ]); 18 | } 19 | 20 | function renderLeaf(node: StashLeaf): VNode { 21 | return h("li", {}, [h("a", {href: node.url}, [node.title])]); 22 | } 23 | 24 | export default defineComponent( 25 | (props: {items: StashItem[]}) => { 26 | return () => 27 | renderItems(props.items, { 28 | parent: p => [renderParent(2, p)], 29 | leaf: l => [renderLeaf(l)], 30 | }); 31 | }, 32 | {props: {items: required(Array as PropType)}}, 33 | ); // TODO: add type for items 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tab-stash", 3 | "version": "0.1.0", 4 | "description": "Tab Stash", 5 | "author": "Joshua J. Berry ", 6 | "repository": "https://github.com/josh-berry/tab-stash", 7 | "license": "MPL-2.0", 8 | "type": "module", 9 | "engines": { 10 | "npm": ">=9.0.0", 11 | "node": ">=20.0.0" 12 | }, 13 | "devDependencies": { 14 | "@sinonjs/fake-timers": "^8.1", 15 | "@types/chai": "^4.3", 16 | "@types/mocha": "^10.0", 17 | "@types/sinonjs__fake-timers": "^8.1", 18 | "@types/webextension-polyfill": "^0.10", 19 | "@vitejs/plugin-vue": "^5.0", 20 | "@vue/tsconfig": "^0.5", 21 | "c8": "^10.1.2", 22 | "chai": "^4.3", 23 | "copyfiles": "^2.4", 24 | "fake-indexeddb": "^6.0", 25 | "idb": "^8.0", 26 | "less": "^4.1", 27 | "mocha": "^10.0", 28 | "prettier": "^3.3", 29 | "prettier-plugin-organize-imports": "^4.0", 30 | "ts-node": "^10", 31 | "typescript": "^5.5", 32 | "vite": "^6.3", 33 | "vue": "^3.4", 34 | "vue-tsc": "^2.0", 35 | "web-ext": "^8.2", 36 | "webextension-polyfill": "^0.10" 37 | }, 38 | "scripts": {} 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/test-release-build.yml: -------------------------------------------------------------------------------- 1 | name: test-release-build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: 20.x 19 | - name: Install Dependencies 20 | run: sh ./install-deps.sh 21 | - name: Make a Temporary Version Number (if needed) 22 | run: | 23 | if [ -z "$(git tag --points-at=HEAD)" ]; then 24 | node -e "x=`cat assets/manifest.json`; x.version='9999.99.9999'; console.log(JSON.stringify(x))" >assets/manifest.json.new 25 | mv assets/manifest.json.new assets/manifest.json 26 | make fix-style 27 | git config --global user.name "GitHub Actions" 28 | git config --global user.email "nobody@example.com" 29 | git commit -m 'Make a temporary version number' assets/manifest.json 30 | fi 31 | - name: Try Building Release Artifacts 32 | run: make rel 33 | -------------------------------------------------------------------------------- /docs/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Hello! This is where you manage which Jekyll version is used to run. 4 | # When you want to use a different version, change it below, save the 5 | # file and run `bundle install`. Run Jekyll with `bundle exec`, like so: 6 | # 7 | # bundle exec jekyll serve 8 | # 9 | # This will help ensure the proper Jekyll version is running. 10 | # Happy Jekylling! 11 | # gem "jekyll", "~> 4.3" 12 | 13 | # This is the default theme for new Jekyll sites. You may change this to anything you like. 14 | #gem "jekyll-theme-slate", "~> 0.1" 15 | 16 | # If you want to use GitHub Pages, remove the "gem "jekyll"" above and 17 | # uncomment the line below. To upgrade, run `bundle update github-pages`. 18 | gem "github-pages", "~> 232", group: :jekyll_plugins 19 | 20 | # If you have any plugins, put them here! 21 | group :jekyll_plugins do 22 | gem "jekyll-feed", "~> 0.17" 23 | end 24 | 25 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem 26 | gem "tzinfo-data", platforms: [:mingw, :mswin, :x64_mingw, :jruby] 27 | 28 | # Performance-booster for watching directories on Windows 29 | # gem "wdm", "~> 0.1.0" if Gem.win_platform? 30 | -------------------------------------------------------------------------------- /src/components/progress-dialog.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 48 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.insertSpaces": true, 3 | "editor.rulers": [80], 4 | "editor.tabSize": 2, 5 | "editor.formatOnSave": true, 6 | "editor.formatOnSaveMode": "file", 7 | "editor.defaultFormatter": "esbenp.prettier-vscode", 8 | "files.insertFinalNewline": true, 9 | "files.eol": "\n", 10 | "files.trimFinalNewlines": true, 11 | "files.trimTrailingWhitespace": true, 12 | "githubIssues.queries": [ 13 | { 14 | "label": "My Issues", 15 | "query": "default" 16 | }, 17 | { 18 | "label": "My Created Issues", 19 | "query": "author:${user} state:open repo:${owner}/${repository} sort:created-desc" 20 | }, 21 | { 22 | "label": "Bugs", 23 | "query": "state:open repo:${owner}/${repository} label:i-bug" 24 | }, 25 | { 26 | "label": "Open Issues by Votes", 27 | "query": "state:open repo:${owner}/${repository} sort:reactions-+1-desc" 28 | }, 29 | { 30 | "label": "All Issues", 31 | "query": "repo:${owner}/${repository}" 32 | } 33 | ], 34 | "html.format.wrapAttributes": "aligned-multiple", 35 | "html.format.templating": true, 36 | "javascript.preferences.importModuleSpecifier": "relative", 37 | "typescript.preferences.importModuleSpecifier": "relative" 38 | } 39 | -------------------------------------------------------------------------------- /src/options/feature-flag.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 49 | -------------------------------------------------------------------------------- /src/util/wiring.ts: -------------------------------------------------------------------------------- 1 | import type {EventSource} from "./event.js"; 2 | import {logError} from "./oops.js"; 3 | 4 | export type EventWiringOptions = { 5 | onFired(): void; 6 | onError(e: unknown): void; 7 | }; 8 | 9 | export class EventWiring { 10 | readonly model: M; 11 | readonly options: EventWiringOptions; 12 | 13 | constructor(model: M, options: EventWiringOptions) { 14 | this.model = model; 15 | this.options = options; 16 | } 17 | 18 | listen( 19 | ev: EventSource<(...args: A) => R>, 20 | fn: (this: M, ...args: A) => R, 21 | ): (...args: A) => R { 22 | // CAST: Work around https://github.com/microsoft/TypeScript/issues/42700 23 | const f: (...args: A) => R = fn.bind(this.model as any); 24 | 25 | const handler = (...args: A) => { 26 | try { 27 | this.options.onFired(); 28 | return f(...args); 29 | } catch (e) /* c8 ignore start -- bug-checking */ { 30 | // This is a safety net; unhandled exceptions generally should 31 | // not happen inside event handlers. 32 | logError(e); 33 | this.options.onError(e); 34 | throw e; 35 | } 36 | /* c8 ignore stop */ 37 | }; 38 | 39 | ev.addListener(handler); 40 | return handler; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Tab Stash Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Only the latest release is supported (regardless of distribution channel). 6 | Supported distribution channels include: 7 | 8 | - The official listing on [addons.mozilla.org][amo]. 9 | 10 | - Re-packaged/re-distributed copies of the latest version are supported, so long 11 | as they are not patched and were built following the "release version" build 12 | instructions in the README file. 13 | 14 | [amo]: https://addons.mozilla.org/firefox/addon/tab-stash/ 15 | 16 | ## Reporting a Vulnerability 17 | 18 | Vulnerabilities may be reported in confidence via email to 19 | `tab-stash@condordes.net`. Please put `[Tab Stash Security]` (including the 20 | brackets) at the beginning of the subject. 21 | 22 | When reporting a vulnerability, please provide as much detail as possible, and 23 | include steps to reproduce the issue (just as if you were filing a normal bug 24 | report). 25 | 26 | Please note that because Tab Stash is a spare-time/"for fun" project: 27 | 28 | - I cannot promise a fix by any particular time, though I will strive to address 29 | any confirmed vulnerabilities as quickly as possible. 30 | 31 | - I do not offer bug bounties or rewards/fees/etc. of any kind (other than my 32 | sincere thanks). 33 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: 20.x 19 | - name: Install Dependencies 20 | run: sh ./install-deps.sh 21 | - name: Install Node Modules 22 | run: make node_modules 23 | - name: Check Types 24 | run: make check-types 25 | - name: Check Style 26 | run: make check-style 27 | - name: Build 28 | run: make build-dbg build-chrome-dbg 29 | - name: Test 30 | run: make check-tests 31 | - name: Codecov 32 | uses: codecov/codecov-action@v4 33 | with: 34 | file: ./coverage/lcov.info 35 | env: 36 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 37 | - name: Make Firefox Package (Debug) 38 | uses: actions/upload-artifact@v4 39 | with: 40 | name: firefox 41 | path: dist/**/* 42 | - name: Make Chrome Package (Debug) 43 | uses: actions/upload-artifact@v4 44 | with: 45 | name: chrome 46 | path: dist-chrome/**/* 47 | -------------------------------------------------------------------------------- /src/util/nanoservice/proto.ts: -------------------------------------------------------------------------------- 1 | /* c8 ignore start -- file contains only types */ 2 | 3 | // Send must be serializable to JSON. As a small concession to JS, we allow 4 | // undefined inside objects... but callers should be aware that any explicit 5 | // undefined will be omitted when sent. 6 | // 7 | // Note that we do not allow "top-level" Send objects to be null, because that 8 | // would break the underlying nanoservice protocol, which expects to be able to 9 | // assign Send values to specific fields inside the messages it sends. 10 | // Assigning `undefined` to such a field would make it disappear. 11 | export type Send = 12 | | null 13 | | boolean 14 | | number 15 | | string 16 | | {[k: string]: Send | undefined} 17 | | Send[]; 18 | 19 | // The NanoService communication protocol. 20 | export type Envelope = 21 | | NotifyEnvelope 22 | | RequestEnvelope 23 | | ResponseEnvelope; 24 | 25 | export type NotifyEnvelope = {notify: M}; 26 | 27 | export type RequestEnvelope = {tag: string; request: M}; 28 | 29 | export type ResponseEnvelope = {tag: string} & Response; 30 | export type Response = {response: M} | {error: ErrorResponse}; 31 | 32 | export type ErrorResponse = { 33 | name: string; 34 | message?: string; 35 | stack?: string; 36 | data?: Send; 37 | }; 38 | -------------------------------------------------------------------------------- /src/tasks/export/url-list.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 | function renderParent(level: number, folder: StashParent): VNode { 8 | const {title, leaves, parents} = getParentInfo(folder); 9 | return h("div", {}, [ 10 | h("div", {}, [`${"".padStart(level, "#")} ${title}`]), 11 | ...leaves.map(renderLeaf), 12 | ...(leaves.length > 0 && parents.length > 0 ? [br()] : []), 13 | ...delimit( 14 | br, 15 | parents.map(f => renderParent(level + 1, f)), 16 | ), 17 | ]); 18 | } 19 | 20 | function renderLeaf(node: StashLeaf): VNode { 21 | return h("div", {}, [h("a", {href: node.url}, [node.url])]); 22 | } 23 | 24 | export default defineComponent( 25 | (props: {items: StashItem[]}) => { 26 | return () => { 27 | const {leaves, parents} = splitItems(props.items); 28 | return [ 29 | ...leaves.map(renderLeaf), 30 | ...(parents.length > 0 && leaves.length > 0 ? [br()] : []), 31 | ...delimit( 32 | br, 33 | parents.map(p => renderParent(2, p)), 34 | ), 35 | ]; 36 | }; 37 | }, 38 | {props: {items: required(Array as PropType)}}, 39 | ); 40 | -------------------------------------------------------------------------------- /styles/themes/index.less: -------------------------------------------------------------------------------- 1 | // 2 | // COLOR AND ICON DEFINITIONS 3 | // 4 | // "Themes" are colors and icons all defined as CSS vars. 5 | // 6 | // Suffixes: 7 | // *-fg Foreground 8 | // *-bg Background 9 | // 10 | 11 | @import "icons.less"; 12 | 13 | html[data-theme="system"] { 14 | @import (multiple) "light.less"; 15 | @media (prefers-color-scheme: dark) { 16 | @import (multiple) "dark.less"; 17 | } 18 | } 19 | html[data-theme="light"] { 20 | @import (multiple) "light.less"; 21 | } 22 | html[data-theme="dark"] { 23 | // We import BOTH themes here so that dark variables override light 24 | // variables, just like in theme-system. 25 | @import (multiple) "light.less"; 26 | @import (multiple) "dark.less"; 27 | } 28 | 29 | // Colors common to both themes (mainly accents like shadows/dividers): 30 | html { 31 | --item-hover-bg: var(--button-bg); 32 | --item-active-bg: var(--button-hover-bg); 33 | 34 | // We are technically violating the rules by defining shadow widths here 35 | // instead of in metrics/, but it's a small thing because shadow metrics never 36 | // change and they're closely related to the color (lighter shadows are less 37 | // noticeable than darker shadows at the same size). 38 | --shadow: 2px 2px 6px rgba(0, 0, 0, 0.23); 39 | --shadow-heavy: 4px 4px 8px rgba(0, 0, 0, 0.29); 40 | 41 | --icon-select-disclosure: var(--icon-collapse-open); 42 | } 43 | -------------------------------------------------------------------------------- /styles/mods-whats-new.less: -------------------------------------------------------------------------------- 1 | // A few small tweaks for the whats-new page so it has the same spacing as the 2 | // stash list. 3 | 4 | main { 5 | -moz-user-select: text; 6 | user-select: text; 7 | 8 | & > header > h1 { 9 | grid-column: 2 / 4; 10 | font-weight: bold; 11 | font-size: var(--group-header-font-size); 12 | margin: 0; 13 | } 14 | } 15 | 16 | .version-changes { 17 | margin: var(--page-ph) var(--page-pw) !important; 18 | padding-left: var(--page-pw); 19 | & > li { 20 | display: list-item; 21 | list-style: disc; 22 | margin: var(--ctrl-mh) 0; 23 | } 24 | } 25 | 26 | span { 27 | &.new, 28 | &.added, 29 | &.new-experiment, 30 | &.experiment-released, 31 | &.improved, 32 | &.fixed, 33 | &.removed { 34 | font-variant: small-caps; 35 | font-weight: bold; 36 | border-radius: var(--input-text-border-radius); 37 | padding: 0 var(--icon-p); 38 | } 39 | 40 | &.new, 41 | &.added, 42 | &.new-experiment, 43 | &.experiment-released { 44 | background-color: var(--read-bg); 45 | } 46 | &.improved { 47 | background-color: var(--create-bg); 48 | } 49 | &.fixed { 50 | background-color: var(--update-bg); 51 | } 52 | &.removed { 53 | background-color: var(--delete-bg); 54 | } 55 | } 56 | 57 | img.inl { 58 | height: var(--icon-size); 59 | vertical-align: bottom; 60 | } 61 | 62 | .issue { 63 | .status-text(); 64 | } 65 | -------------------------------------------------------------------------------- /src/whats-new/version.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /src/components/dnd-directives.ts: -------------------------------------------------------------------------------- 1 | import type {Directive} from "vue"; 2 | 3 | import { 4 | makeDraggable, 5 | makeDroppable, 6 | type DNDLifecycle, 7 | type DraggableOptions, 8 | type DroppableOptions, 9 | } from "./dnd.js"; 10 | 11 | const draggableElements = new WeakMap< 12 | HTMLElement, 13 | DNDLifecycle 14 | >(); 15 | const droppableElements = new WeakMap< 16 | HTMLElement, 17 | DNDLifecycle 18 | >(); 19 | 20 | export const vDraggable: Directive< 21 | HTMLElement, 22 | DraggableOptions, 23 | never, 24 | never 25 | > = { 26 | mounted(el, binding) { 27 | draggableElements.set(el, makeDraggable(el, binding.value)); 28 | }, 29 | updated(el, binding) { 30 | const l = draggableElements.get(el); 31 | if (l) l.update(binding.value); 32 | }, 33 | unmounted(el) { 34 | const l = draggableElements.get(el); 35 | if (l) l.cancel(); 36 | draggableElements.delete(el); 37 | }, 38 | }; 39 | 40 | export const vDroppable: Directive< 41 | HTMLElement, 42 | DroppableOptions, 43 | never, 44 | never 45 | > = { 46 | mounted(el, binding) { 47 | droppableElements.set(el, makeDroppable(el, binding.value)); 48 | }, 49 | updated(el, binding) { 50 | const l = droppableElements.get(el); 51 | if (l) l.update(binding.value); 52 | }, 53 | unmounted(el) { 54 | const l = droppableElements.get(el); 55 | if (l) l.cancel(); 56 | droppableElements.delete(el); 57 | }, 58 | }; 59 | -------------------------------------------------------------------------------- /src/components/dialog.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 56 | -------------------------------------------------------------------------------- /chrome-manifest.patch: -------------------------------------------------------------------------------- 1 | --- a/assets/manifest.json 2022-12-10 17:52:31 2 | +++ b/assets/manifest-chrome.json 2022-12-10 17:54:08 3 | @@ -34,9 +34,9 @@ 4 | "browser_action": { 5 | "default_title": "Tab Stash", 6 | "default_icon": { 7 | - "16": "icons/light/logo-16.png", 8 | - "32": "icons/light/logo-32.png", 9 | - "64": "icons/light/logo-64.png" 10 | + "16": "icons/logo-16.png", 11 | + "32": "icons/logo-32.png", 12 | + "64": "icons/logo-64.png" 13 | }, 14 | "theme_icons": [ 15 | { 16 | @@ -50,33 +50,6 @@ 17 | } 18 | ], 19 | "browser_style": false 20 | - }, 21 | - "page_action": { 22 | - "default_title": "Stash this tab", 23 | - "default_icon": { 24 | - "16": "icons/light/stash-one.svg", 25 | - "32": "icons/light/stash-one.svg", 26 | - "64": "icons/light/stash-one.svg" 27 | - }, 28 | - "theme_icons": [ 29 | - { 30 | - "light": "icons/dark/stash-one.svg", 31 | - "dark": "icons/light/stash-one.svg", 32 | - "size": 16 33 | - }, 34 | - { 35 | - "light": "icons/dark/stash-one.svg", 36 | - "dark": "icons/light/stash-one.svg", 37 | - "size": 32 38 | - }, 39 | - { 40 | - "light": "icons/dark/stash-one.svg", 41 | - "dark": "icons/light/stash-one.svg", 42 | - "size": 64 43 | - } 44 | - ], 45 | - "browser_style": false, 46 | - "show_matches": [""] 47 | }, 48 | "sidebar_action": { 49 | "default_title": "Tab Stash", 50 | -------------------------------------------------------------------------------- /docs/_layouts/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{page.title}} - {{site.title}} 9 | 10 | 11 |
12 |
13 |

14 | 15 |

16 |

{{ site.description }}

17 |
18 | 32 |
33 | 34 |
35 |
{{ content }}
36 |
37 | 38 |
39 |
40 |

41 | Tab Stash is a spare-time project by 42 | Josh Berry 43 |

44 |
45 |
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 | 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 | Screenshot 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 | 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 | 17 | 40 | 47 | 48 | 50 | 51 | 53 | image/svg+xml 54 | 56 | 57 | 58 | 59 | 61 | 67 | 68 | -------------------------------------------------------------------------------- /icons/collapse-open.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 30 | 54 | 60 | 61 | 68 | 69 | -------------------------------------------------------------------------------- /icons/delete.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 40 | 47 | 48 | 50 | 51 | 53 | image/svg+xml 54 | 56 | 57 | 58 | 59 | 60 | 62 | 69 | 70 | -------------------------------------------------------------------------------- /icons/collapse-closed.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 30 | 54 | 60 | 61 | 68 | 69 | -------------------------------------------------------------------------------- /icons/back.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 29 | 31 | 55 | 61 | 62 | 69 | 70 | -------------------------------------------------------------------------------- /src/restore/index.vue: -------------------------------------------------------------------------------- 1 | 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 | 20 | 43 | 49 | 50 | 52 | 53 | 55 | image/svg+xml 56 | 58 | 59 | 60 | 61 | 62 | 64 | 69 | 70 | -------------------------------------------------------------------------------- /icons/tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 45 | 51 | 52 | 54 | 55 | 57 | image/svg+xml 58 | 60 | 61 | 62 | 63 | 65 | 70 | 71 | -------------------------------------------------------------------------------- /icons/item-menu.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 40 | 47 | 48 | 50 | 51 | 53 | image/svg+xml 54 | 56 | 57 | 58 | 59 | 60 | 62 | 67 | 72 | 77 | 78 | -------------------------------------------------------------------------------- /icons/rename.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 45 | 51 | 52 | 54 | 55 | 57 | image/svg+xml 58 | 60 | 61 | 62 | 63 | 65 | 70 | 71 | -------------------------------------------------------------------------------- /icons/delete-stashed.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 40 | 47 | 48 | 50 | 51 | 53 | image/svg+xml 54 | 56 | 57 | 58 | 59 | 61 | 68 | 75 | 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 | 17 | 40 | 47 | 48 | 50 | 51 | 53 | image/svg+xml 54 | 56 | 57 | 58 | 59 | 60 | 62 | 69 | 76 | 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

(); 30 | 31 | constructor( 32 | isParent: IsParentFn, 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 | 20 | 43 | 49 | 50 | 52 | 53 | 55 | image/svg+xml 56 | 58 | 59 | 60 | 61 | 63 | 68 | 73 | 74 | -------------------------------------------------------------------------------- /icons/mainmenu.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 45 | 51 | 52 | 54 | 55 | 57 | image/svg+xml 58 | 60 | 61 | 62 | 63 | 64 | 66 | 73 | 80 | 87 | 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 | 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 | 17 | 40 | 47 | 48 | 50 | 51 | 53 | image/svg+xml 54 | 56 | 57 | 58 | 59 | 60 | 62 | 67 | 71 | 76 | 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 | 20 | 21 | 102 | -------------------------------------------------------------------------------- /icons/stash-one.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 40 | 47 | 48 | 50 | 51 | 53 | image/svg+xml 54 | 56 | 57 | 58 | 59 | 61 | 68 | 75 | 78 | 82 | 83 | -------------------------------------------------------------------------------- /icons/pop-out.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 40 | 47 | 48 | 50 | 51 | 53 | image/svg+xml 54 | 56 | 57 | 58 | 59 | 61 | 68 | 71 | 76 | 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 | 17 | 40 | 47 | 48 | 50 | 51 | 53 | image/svg+xml 54 | 56 | 57 | 58 | 59 | 61 | 68 | 71 | 76 | 77 | -------------------------------------------------------------------------------- /src/components/search-input.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 32 | 33 | 100 | -------------------------------------------------------------------------------- /icons/move-menu.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 41 | 47 | 48 | 50 | 51 | 53 | image/svg+xml 54 | 56 | 57 | 58 | 59 | 60 | 62 | 66 | 70 | 74 | 75 | -------------------------------------------------------------------------------- /src/datastore/kvs/memory.ts: -------------------------------------------------------------------------------- 1 | import type {Entry, Key, KeyValueStore, MaybeEntry, Value} from "./index.js"; 2 | 3 | import event, {type Event} from "../../util/event.js"; 4 | 5 | const copy = (x: any) => JSON.parse(JSON.stringify(x)); 6 | 7 | const byKey = ([k1, v1]: [any, any], [k2, v2]: [any, any]) => 8 | k1 < k2 9 | ? -1 10 | : k1 > k2 11 | ? 1 12 | : /* c8 ignore next -- because k1 != k2 always */ 0; 13 | 14 | // XXX optimize me if performance ever becomes important 15 | export default class MemoryKVS< 16 | K extends Key, 17 | V extends Value, 18 | > implements KeyValueStore { 19 | readonly name: string; 20 | readonly onSet: Event<(entries: MaybeEntry[]) => void>; 21 | readonly onSyncLost: Event<() => void>; 22 | 23 | constructor(name: string) { 24 | this.name = name; 25 | this.onSet = event("KVS.Memory.onSet", name); 26 | this.onSyncLost = event("KVS.Memory.onSyncLost", name); 27 | } 28 | 29 | /** The in-memory KVS data, exposed here for readers to be able to inspect 30 | * the KVS directly without having to go thru async methods. 31 | * 32 | * This should mainly be used for testing purposes; if you modify it 33 | * directly, onSet events will not be fired. (You should not modify it 34 | * directly under normal circumstances.) */ 35 | readonly data = new Map(); 36 | 37 | async get(keys: K[]): Promise[]> { 38 | return keys 39 | .map(k => ({key: k, value: this.data.get(k)})) 40 | .filter(e => e.value !== undefined) as Entry[]; 41 | } 42 | 43 | async getStartingFrom( 44 | bound: K | undefined, 45 | limit: number, 46 | ): Promise[]> { 47 | let keys = Array.from(this.data.keys()).sort(); 48 | if (bound !== undefined) keys = keys.filter(x => x > bound!); 49 | return keys 50 | .slice(0, limit) 51 | .map(key => ({key, value: copy(this.data.get(key))})); 52 | } 53 | 54 | async getEndingAt( 55 | bound: K | undefined, 56 | limit: number, 57 | ): Promise[]> { 58 | let rkeys = Array.from(this.data.keys()).sort().reverse(); 59 | if (bound !== undefined) rkeys = rkeys.filter(x => x < bound!); 60 | return rkeys 61 | .slice(0, limit) 62 | .map(key => ({key, value: copy(this.data.get(key))})); 63 | } 64 | 65 | async *list(): AsyncIterable> { 66 | for (const [key, value] of Array.from(this.data.entries()).sort(byKey)) { 67 | yield {key, value: copy(value)}; 68 | } 69 | } 70 | 71 | async *listReverse(): AsyncIterable> { 72 | for (const [key, value] of Array.from(this.data.entries()) 73 | .sort(byKey) 74 | .reverse()) { 75 | yield {key, value: copy(value)}; 76 | } 77 | } 78 | 79 | async set(entries: MaybeEntry[]): Promise { 80 | const ev = entries.map(({key, value}) => { 81 | if (value === undefined) { 82 | this.data.delete(key); 83 | return {key}; 84 | } else { 85 | this.data.set(key, copy(value)); 86 | return {key, value: copy(value)}; 87 | } 88 | }); 89 | if (ev.length > 0) this.onSet.send(ev); 90 | } 91 | 92 | async deleteAll(): Promise { 93 | await this.set(Array.from(this.data.keys()).map(key => ({key}))); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /icons/restore.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 40 | 47 | 48 | 50 | 51 | 53 | image/svg+xml 54 | 56 | 57 | 58 | 59 | 60 | 62 | 66 | 70 | 75 | 76 | -------------------------------------------------------------------------------- /styles/themes/light.less: -------------------------------------------------------------------------------- 1 | & { 2 | --modal-bg: hsla(240deg, 4%, 5%, 30%); 3 | 4 | --page-bg: hsl(240deg, 1%, 100%); 5 | --page-fg: hsl(240deg, 4%, 5%); 6 | --disabled-fg: hsl(0deg, 0%, 45%); 7 | --userlink-fg: hsl(214deg, 100%, 44%); 8 | --userlink-hover-fg: hsl(214deg, 100%, 60%); 9 | --userlink-hover-bg: var(--button-bg); 10 | --userlink-active-fg: hsl(214deg, 100%, 70%); 11 | --userlink-active-bg: var(--button-hover-bg); 12 | 13 | --selected-bg: hsl(214deg, 100%, 90%); 14 | --selected-hover-bg: hsl(214deg, 100%, 80%); 15 | 16 | --ctrl-bg: hsl(240deg, 1%, 100%); 17 | --ctrl-border-clr: hsla(240deg, 4%, 5%, 25%); 18 | --button-bg: hsla(240deg, 4%, 5%, 10%); 19 | --button-hover-bg: hsla(240deg, 4%, 5%, 20%); 20 | --button-active-bg: hsla(240deg, 4%, 5%, 30%); 21 | 22 | --menu-bg: var(--page-bg); 23 | --menu-item-hover-bg: var(--button-bg); 24 | --menu-item-active-bg: var(--button-hover-bg); 25 | 26 | --ephemeral-hover-shadow-clr: hsla(240deg, 4%, 5%, 15%); 27 | 28 | --group-bg: hsl(240deg, 1%, 97%); 29 | --group-border-clr: hsl(240deg, 1%, 80%); 30 | 31 | --indent-guide-border-clr: hsla(240deg, 1%, 80%, 60%); 32 | 33 | --action-bg-alpha: 40%; 34 | --action-hover-bg-alpha: 60%; 35 | --action-active-bg-alpha: 90%; 36 | 37 | /* CRUD colors */ 38 | --create-bg: hsla(214deg, 100%, 80%, var(--action-bg-alpha)); 39 | --create-hover-bg: hsla(214deg, 100%, 75%, var(--action-hover-bg-alpha)); 40 | --create-active-bg: hsla(214deg, 100%, 70%, var(--action-active-bg-alpha)); 41 | --read-bg: hsla(154deg, 100%, 50%, var(--action-bg-alpha)); 42 | --read-hover-bg: hsla(154deg, 100%, 45%, var(--action-hover-bg-alpha)); 43 | --read-active-bg: hsla(154deg, 100%, 40%, var(--action-active-bg-alpha)); 44 | --update-bg: hsla(54deg, 100%, 45%, var(--action-bg-alpha)); 45 | --update-hover-bg: hsla(54deg, 100%, 40%, var(--action-hover-bg-alpha)); 46 | --update-active-bg: hsla(54deg, 100%, 35%, var(--action-active-bg-alpha)); 47 | --delete-bg: hsla(354deg, 100%, 80%, var(--action-bg-alpha)); 48 | --delete-hover-bg: hsla(354deg, 100%, 75%, var(--action-hover-bg-alpha)); 49 | --delete-active-bg: hsla(354deg, 100%, 70%, var(--action-active-bg-alpha)); 50 | 51 | // Spinner colors (which should be more saturated CRUD colors) 52 | --spinner-gradient: conic-gradient( 53 | hsl(354, 100%, 80%), 54 | hsl(54, 100%, 45%), 55 | hsl(154, 100%, 50%), 56 | hsl(214, 100%, 80%), 57 | hsl(354, 100%, 80%) 58 | ); 59 | 60 | // "Advanced" options background 61 | --advanced-bg: hsla(354deg, 100%, 80%, 25%); 62 | 63 | // Special styling for the active tab 64 | --active-tab-bg: hsl(240deg, 0%, 100%); 65 | --active-tab-shadow: var(--active-tab-shadow-metrics) 66 | hsla(240deg, 0%, 30%, 20%); 67 | 68 | // Container colors and styles. Colors are chosen to be the same as 69 | // Firefox's to align with FF's visual style. 70 | --container-color-blue: #37adff; 71 | --container-color-turquoise: #00c79a; 72 | --container-color-green: #51cd00; 73 | --container-color-yellow: #ffcb00; 74 | --container-color-orange: #ff9f00; 75 | --container-color-red: #ff613d; 76 | --container-color-pink: #ff4bda; 77 | --container-color-purple: #af51f5; 78 | --container-color-toolbar: #c0c0c0; 79 | } 80 | 81 | .icon-vars(@theme: light, @inverse: dark); 82 | -------------------------------------------------------------------------------- /styles/mods-options.less: -------------------------------------------------------------------------------- 1 | &[data-theme="system"], 2 | &[data-theme="light"] { 3 | --page-bg: #ffffff; 4 | } 5 | 6 | &[data-theme="dark"] { 7 | --page-bg: #202023; 8 | --page-fg: #f9f9fa; 9 | --scroll-bar: #48484e; 10 | --scroll-handle: #202023; 11 | } 12 | 13 | @media (prefers-color-scheme: dark) { 14 | &[data-theme="system"] { 15 | --page-bg: #202023; 16 | --page-fg: #f9f9fa; 17 | --scroll-bar: #48484e; 18 | --scroll-handle: #202023; 19 | } 20 | } 21 | 22 | // Allow the Firefox options page to dictate the size of the element. 23 | // Without this, we'll get a scroll bar within the options section of the page 24 | // rather than on the page as a whole. 25 | & { 26 | overflow: auto; 27 | } 28 | body, 29 | main { 30 | overflow: unset; 31 | } 32 | 33 | // Add a bit of padding around the inside of the

to allow for the 34 | // advanced boxes to overflow their bounds a little bit (for corner rounding) 35 | main { 36 | box-sizing: border-box; 37 | padding: var(--ctrl-border-radius) var(--ctrl-border-radius); 38 | } 39 | 40 | section { 41 | margin: calc(3 * var(--ctrl-mh)) 0; 42 | } 43 | 44 | hr { 45 | margin-top: calc(3 * var(--ctrl-mh)) 0; 46 | margin-bottom: var(--ctrl-mh); 47 | border: var(--divider-border); 48 | } 49 | 50 | h4 { 51 | font-weight: bold; 52 | font-size: calc(var(--font-size) + 2pt); 53 | margin-top: var(--ctrl-mh); 54 | margin-bottom: calc(3 * var(--ctrl-mh)); 55 | } 56 | h5 { 57 | font-weight: bold; 58 | font-size: var(--font-size); 59 | margin: 0 0; 60 | } 61 | 62 | ul { 63 | list-style: none; 64 | margin: 0 0 0 var(--text-list-indent-w); 65 | padding: 0; 66 | } 67 | li { 68 | margin: var(--ctrl-mh) 0; 69 | } 70 | 71 | input[type="number"] { 72 | max-width: 5em; 73 | } 74 | main:not(.show-advanced) .advanced { 75 | display: none; 76 | } 77 | .advanced.show-advanced { 78 | display: block !important; 79 | margin-top: var(--ctrl-mh); 80 | } 81 | .advanced { 82 | background-color: var(--advanced-bg); 83 | border-radius: calc(var(--ctrl-border-radius) / 2); 84 | box-shadow: 0 0 0 var(--ctrl-border-radius) var(--advanced-bg); 85 | margin-top: calc(var(--ctrl-mh) + var(--ctrl-border-radius)); 86 | margin-bottom: calc(var(--ctrl-mh) + var(--ctrl-border-radius)); 87 | } 88 | section.advanced { 89 | margin-top: calc(3 * var(--ctrl-mh) + var(--ctrl-border-radius)); 90 | margin-bottom: calc(3 * var(--ctrl-mh) + var(--ctrl-border-radius)); 91 | } 92 | 93 | .two-col { 94 | display: grid; 95 | width: max-content; 96 | 97 | grid-template-columns: 0fr 1fr; 98 | row-gap: var(--ctrl-mh); 99 | column-gap: var(--ctrl-mw); 100 | 101 | & > label { 102 | white-space: nowrap; 103 | } 104 | } 105 | 106 | .feature-flag { 107 | display: grid; 108 | grid-template-columns: 1fr 0fr 0fr; 109 | align-items: center; 110 | column-gap: var(--ctrl-mw); 111 | 112 | & > label { 113 | grid-row: 1; 114 | grid-column: 1; 115 | } 116 | & > input { 117 | grid-row: 1; 118 | grid-column: 2; 119 | } 120 | & > button { 121 | grid-row: 1; 122 | grid-column: 3; 123 | } 124 | & > div { 125 | grid-row: 2; 126 | grid-column: 1; 127 | } 128 | } 129 | 130 | .issue { 131 | .status-text(); 132 | } 133 | -------------------------------------------------------------------------------- /icons/export.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 40 | 50 | 51 | 53 | 54 | 56 | image/svg+xml 57 | 59 | 60 | 61 | 62 | 64 | 71 | 74 | 79 | 80 | -------------------------------------------------------------------------------- /icons/import.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 40 | 50 | 51 | 53 | 54 | 56 | image/svg+xml 57 | 59 | 60 | 61 | 62 | 64 | 71 | 74 | 79 | 80 | -------------------------------------------------------------------------------- /src/whats-new/item.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 115 | 116 | 117 | -------------------------------------------------------------------------------- /src/mock/browser/sessions.ts: -------------------------------------------------------------------------------- 1 | import type {Sessions, Tabs as T, Windows as W} from "webextension-polyfill"; 2 | 3 | import * as events from "../events.js"; 4 | import type {State} from "./tabs-and-windows.js"; 5 | import tabs_and_windows from "./tabs-and-windows.js"; 6 | 7 | type Metadata = Map; 8 | 9 | class MockSessions implements Sessions.Static { 10 | readonly MAX_SESSION_RESULTS = 25; 11 | 12 | readonly onChanged: events.MockEvent<() => void> = new events.MockEvent( 13 | "browser.sessions.onChanged", 14 | ); 15 | 16 | private readonly _state: State; 17 | private readonly _windows = new WeakMap(); 18 | private readonly _tabs = new WeakMap(); 19 | 20 | constructor(state: State) { 21 | this._state = state; 22 | } 23 | 24 | /* c8 ignore start -- not implemented */ 25 | forgetClosedTab(windowId: number, sessionId: string): Promise { 26 | throw new Error("Method not implemented."); 27 | } 28 | forgetClosedWindow(sessionId: string): Promise { 29 | throw new Error("Method not implemented."); 30 | } 31 | getRecentlyClosed( 32 | filter?: Sessions.Filter | undefined, 33 | ): Promise { 34 | throw new Error("Method not implemented."); 35 | } 36 | restore(sessionId?: string | undefined): Promise { 37 | throw new Error("Method not implemented."); 38 | } 39 | /* c8 ignore stop */ 40 | 41 | async setTabValue(tabId: number, key: string, value: any): Promise { 42 | const md = this._tab(tabId); 43 | md.set(key, JSON.stringify(value)); 44 | } 45 | async getTabValue(tabId: number, key: string): Promise { 46 | const md = this._tab(tabId); 47 | const val = md.get(key); 48 | if (val === undefined) return undefined; 49 | return JSON.parse(val); 50 | } 51 | async removeTabValue(tabId: number, key: string): Promise { 52 | const md = this._tab(tabId); 53 | md.delete(key); 54 | } 55 | 56 | async setWindowValue( 57 | windowId: number, 58 | key: string, 59 | value: any, 60 | ): Promise { 61 | const md = this._window(windowId); 62 | md.set(key, JSON.stringify(value)); 63 | } 64 | async getWindowValue(windowId: number, key: string): Promise { 65 | const md = this._window(windowId); 66 | const val = md.get(key); 67 | if (val === undefined) return undefined; 68 | return JSON.parse(val); 69 | } 70 | async removeWindowValue(windowId: number, key: string): Promise { 71 | const md = this._window(windowId); 72 | md.delete(key); 73 | } 74 | 75 | private _window(id: number): Metadata { 76 | const win = this._state.win(id); 77 | let md = this._windows.get(win); 78 | if (!md) { 79 | md = new Map(); 80 | this._windows.set(win, md); 81 | } 82 | return md; 83 | } 84 | 85 | private _tab(id: number): Metadata { 86 | const tab = this._state.tab(id); 87 | let md = this._tabs.get(tab); 88 | if (!md) { 89 | md = new Map(); 90 | this._tabs.set(tab, md); 91 | } 92 | return md; 93 | } 94 | } 95 | 96 | export default (() => { 97 | const exports = { 98 | sessions: new MockSessions(tabs_and_windows.state), 99 | reset() { 100 | exports.sessions = new MockSessions(tabs_and_windows.state); 101 | (globalThis).browser.sessions = exports.sessions; 102 | }, 103 | }; 104 | 105 | exports.reset(); 106 | 107 | return exports; 108 | })(); 109 | -------------------------------------------------------------------------------- /icons/sort.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 40 | 50 | 51 | 53 | 54 | 56 | image/svg+xml 57 | 59 | 60 | 61 | 62 | 64 | 67 | 72 | 77 | 78 | -------------------------------------------------------------------------------- /assets/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Tab Stash", 4 | "version": "3.4", 5 | "description": "A no-fuss way to save and restore batches of tabs as bookmarks.", 6 | "homepage_url": "https://josh-berry.github.io/tab-stash/", 7 | "icons": { 8 | "16": "icons/logo-16.png", 9 | "32": "icons/logo-32.png", 10 | "48": "icons/logo-48.png", 11 | "64": "icons/logo-64.png", 12 | "96": "icons/logo-96.png", 13 | "128": "icons/logo-128.png" 14 | }, 15 | "browser_specific_settings": { 16 | "gecko": { 17 | "id": "tab-stash@condordes.net", 18 | "strict_min_version": "78.0" 19 | } 20 | }, 21 | "permissions": [ 22 | "sessions", 23 | "tabs", 24 | "tabHide", 25 | "bookmarks", 26 | "contextMenus", 27 | "browserSettings", 28 | "storage", 29 | "unlimitedStorage", 30 | "contextualIdentities", 31 | "cookies" 32 | ], 33 | "content_security_policy": "script-src 'self'; object-src 'self';", 34 | "background": { 35 | "scripts": ["index.js"] 36 | }, 37 | "browser_action": { 38 | "default_title": "Tab Stash", 39 | "default_icon": { 40 | "16": "icons/light/logo-16.png", 41 | "32": "icons/light/logo-32.png", 42 | "64": "icons/light/logo-64.png" 43 | }, 44 | "theme_icons": [ 45 | { 46 | "light": "icons/dark/logo-16.png", 47 | "dark": "icons/light/logo-16.png", 48 | "size": 16 49 | }, 50 | { 51 | "light": "icons/dark/logo-32.png", 52 | "dark": "icons/light/logo-32.png", 53 | "size": 32 54 | }, 55 | { 56 | "light": "icons/dark/logo-64.png", 57 | "dark": "icons/light/logo-64.png", 58 | "size": 64 59 | } 60 | ], 61 | "browser_style": false 62 | }, 63 | "page_action": { 64 | "default_title": "Stash this tab", 65 | "default_icon": { 66 | "16": "icons/light/stash-one.svg", 67 | "32": "icons/light/stash-one.svg", 68 | "64": "icons/light/stash-one.svg" 69 | }, 70 | "theme_icons": [ 71 | { 72 | "light": "icons/dark/stash-one.svg", 73 | "dark": "icons/light/stash-one.svg", 74 | "size": 16 75 | }, 76 | { 77 | "light": "icons/dark/stash-one.svg", 78 | "dark": "icons/light/stash-one.svg", 79 | "size": 32 80 | }, 81 | { 82 | "light": "icons/dark/stash-one.svg", 83 | "dark": "icons/light/stash-one.svg", 84 | "size": 64 85 | } 86 | ], 87 | "browser_style": false, 88 | "show_matches": [""] 89 | }, 90 | "sidebar_action": { 91 | "default_title": "Tab Stash", 92 | "default_icon": { 93 | "16": "icons/logo.svg", 94 | "32": "icons/logo.svg", 95 | "64": "icons/logo.svg" 96 | }, 97 | "default_panel": "stash-list.html?view=sidebar", 98 | "browser_style": false 99 | }, 100 | "options_ui": { 101 | "page": "options.html", 102 | "browser_style": true 103 | }, 104 | "commands": { 105 | "_execute_browser_action": { 106 | "suggested_key": { 107 | "default": "Ctrl+Alt+T", 108 | "mac": "MacCtrl+Shift+T" 109 | } 110 | }, 111 | "_execute_sidebar_action": { 112 | "suggested_key": { 113 | "default": "Ctrl+Alt+S", 114 | "mac": "MacCtrl+Shift+S" 115 | } 116 | }, 117 | "_execute_page_action": { 118 | "suggested_key": { 119 | "default": "Ctrl+Alt+W", 120 | "mac": "MacCtrl+Shift+W" 121 | } 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /docs/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 43 | 49 | 50 | 52 | 53 | 55 | image/svg+xml 56 | 58 | 59 | 60 | 61 | 62 | 64 | 71 | 78 | 85 | 92 | 97 | 98 | -------------------------------------------------------------------------------- /icons/stash.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 40 | 47 | 48 | 50 | 51 | 53 | image/svg+xml 54 | 56 | 57 | 58 | 59 | 60 | 62 | 69 | 76 | 83 | 90 | 93 | 97 | 98 | -------------------------------------------------------------------------------- /docs/support.md: -------------------------------------------------------------------------------- 1 | # Help! What do I do? 2 | 3 | ## I Have a Problem 4 | 5 | First off, I'm sorry to hear that! Here are a few things to try, in order from 6 | easy to hard: 7 | 8 | 1. If the problem is with the sidebar or stash list, close and open the sidebar 9 | (or Tab Stash tab). This often resolves temporary UI issues and glitches. 10 | 2. Check the [list of extensions that are known to have problems working with 11 | Tab Stash](https://github.com/josh-berry/tab-stash/wiki/Known-Incompatibilities-with-Other-Extensions) 12 | to make sure the issue is not caused by another extension. 13 | 3. Restart your browser. 14 | 4. Restart your browser, discarding any saved session data. 15 | ([Here's how.](https://github.com/josh-berry/tab-stash/wiki/Restart-Firefox-Without-Saved-Session-Data)) 16 | 5. Make sure your Firefox is up to date. 17 | ([Here's how.](https://support.mozilla.org/en-US/kb/update-firefox-latest-release?redirectlocale=en-US&redirectslug=update-firefox-latest-version)) 18 | 6. Make sure your Tab Stash is up to date. 19 | ([Here's how.](https://support.mozilla.org/en-US/kb/how-update-add-ons)) 20 | 21 | If none of the above helps, or if your problem keeps recurring, you have a couple options: 22 | 23 | 1. [Search GitHub](https://github.com/josh-berry/tab-stash/issues?utf8=%E2%9C%93&q=is%3Aissue) 24 | to see if someone else has run into your problem, and if it's been solved 25 | already. The most 26 | [frequently-asked questions](https://github.com/josh-berry/tab-stash/issues?q=label%3AA-FAQ) 27 | are tagged so they can be found easily. 28 | 2. [Open a new issue](https://github.com/josh-berry/tab-stash/issues/new/choose) 29 | on GitHub. While I can't promise to get to your issue in any particular 30 | timeframe, I do my best to respond quickly, especially for issues which look 31 | like they may be more serious. 32 | 33 | If you're technically savvy and you'd like to try troubleshooting the issue 34 | further on your own, 35 | [here's how to collect error logs](https://github.com/josh-berry/tab-stash/wiki/Collect-Error-Logs). 36 | Depending on the problem, including error logs with your report may help solve 37 | it more quickly. 38 | 39 | ## I Have a Suggestion 40 | 41 | I love to hear your suggestions because it helps me decide what to work on next! 42 | Before you submit a new suggestion, please make sure someone else hasn't 43 | submitted it already by 44 | [checking GitHub](https://github.com/josh-berry/tab-stash/issues?q=is%3Aopen+label%3Ai-enhancement+sort%3Areactions-%2B1-desc). 45 | 46 | If your idea is already filed as an issue, feel free to participate in the 47 | discussion, and **be sure to up-vote it** by leaving a thumbs-up reaction on the 48 | _original post_ in the issue. (Leaving a reaction on a comment doesn't count.) 49 | When I'm planning future releases, I look at which issues have seen the most 50 | up-votes to help me learn what people are most interested in. 51 | 52 | If you don't see your idea in the list, 53 | [file a new feature request](https://github.com/josh-berry/tab-stash/issues/new/choose). 54 | 55 | ## I Have a Question 56 | 57 | Here are a few ways to get your question answered: 58 | 59 | 1. Check out [the tips page](tips.md). 60 | 2. [Check the wiki](https://github.com/josh-berry/tab-stash/wiki) to see if it's 61 | a frequently-asked question. 62 | 3. [Check GitHub](https://github.com/josh-berry/tab-stash/issues?utf8=%E2%9C%93&q=is%3Aissue) 63 | to see if someone else has asked your question, and if it's been solved 64 | already. 65 | 4. [Open a new issue](https://github.com/josh-berry/tab-stash/issues/new/choose) 66 | on GitHub. While I can't promise to get to your issue in any particular 67 | timeframe, I do my best to respond quickly, especially for issues which look 68 | like they may be more serious. 69 | -------------------------------------------------------------------------------- /icons/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 43 | 49 | 50 | 52 | 53 | 55 | image/svg+xml 56 | 58 | 59 | 60 | 61 | 62 | 64 | 71 | 78 | 85 | 92 | 97 | 98 | --------------------------------------------------------------------------------