├── .babelrc ├── .eslintrc.json ├── .gitignore ├── README.md ├── analytics.js ├── docs ├── favicon.ico └── index.html ├── icon.png ├── license ├── manifest.json ├── package.json ├── popup.html ├── src ├── actions │ └── index.js ├── chromeStorage.js ├── components │ ├── Button.jsx │ ├── TabGroupDetailsItem.jsx │ ├── TabGroupDetailsView.jsx │ ├── TabGroupList.jsx │ ├── TabGroupListControls.jsx │ ├── TabGroupListEmpty.jsx │ ├── TabGroupListItem.jsx │ └── TabGroupListView.jsx ├── containers │ ├── TabGroupDetailsItem.js │ ├── TabGroupDetailsView.js │ ├── TabGroupList.js │ ├── TabGroupListControls.js │ └── TabGroupListItem.js ├── index.jsx ├── middleware │ ├── analytics.js │ ├── chromeStorage.js │ └── tabManager.js ├── reducers │ ├── index.js │ ├── tabGroupList.js │ └── tabGroupListControls.js ├── styles │ ├── button.js │ ├── colors.js │ ├── input.js │ ├── link.js │ ├── list.js │ └── title.js └── tabManager.js ├── store ├── 1400 x 560.png ├── 440 x 280.png ├── 920 x 680.png ├── icon.png └── screenshot.png └── zip.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react", "es2015", "stage-0"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "parser": "babel-eslint", 4 | "plugins": [ 5 | "react" 6 | ], 7 | "env": { 8 | "browser": true 9 | }, 10 | "globals": { 11 | "chrome": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | bundle.js 4 | tabbie-*.zip 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tabbie 2 | 3 | The missing tab manager for Chrome. 4 | 5 | Friday evening comes along and you want to save your chrome tabs from the week so you can pick em back up on Monday. 6 | 7 | Chrome Web Store: https://chrome.google.com/webstore/detail/tabbie/aingjdeimmekeknhjcbnigfbfbboffeo 8 | 9 | ![screenshot](store/screenshot.png) 10 | 11 | ## Usage 12 | - Use-case 1: You are leaving work on Friday and you would like to save all the tabs in this group. Click on Tabbie, populate the `New Tab Group Name`, click `Save & Close All Tabs`, go home, open different tabs, go to work on Monday, find the `Saved Tab Group` that you need, click `Open` 13 | - Use-case 2: You are at work on Tuesday and have started to look at some docs (which may be unrealated to the tabs currently open) and you would like to save a selection of the tabs for this group. If your tabs are contiguous, click on the head tab, shift click the tail tab/or command/ctrl click for fragmented tabs, go to Tabbie, check `Only Save Selected Tabs`, and either `Save Selected Tabs` or `Save & Close Selected Tabs` 14 | 15 | ## Release Notes 16 | 17 | **0.4.1** 18 | 19 | - Implement remove tab 20 | 21 | **0.3.7** 22 | 23 | - Implement Tab Group Details view 24 | 25 | **0.3.6** 26 | 27 | - Track hover state with [@bufferapp/redux-hover](https://www.npmjs.com/package/@bufferapp/redux-hover) 28 | 29 | **0.3.5** 30 | 31 | - Show a tab count in tab group items 32 | - Fixes #10 33 | 34 | **0.3.4** 35 | 36 | - Re-implemented with [React](https://facebook.github.io/react/) 37 | 38 | **0.3.3** 39 | 40 | - Preserve pinned tabs (fixes #13) 41 | 42 | **0.3.2** 43 | 44 | - Show invalid state on input when no value is set (fixes #8) 45 | 46 | **0.3.1** 47 | 48 | - Form items get focus before tab groups (fixes #7) 49 | 50 | **0.3.0** 51 | 52 | - Default to save all chrome tabs in the current window 53 | - Toggle checkbox to save selected tabs 54 | - a11y fixes (Thanks [Rahul](https://github.com/Primigenus)!) 55 | - Display extension icon in Chrome extensions manager 56 | 57 | ## Google Analytics 58 | 59 | Google Analytics is connected to make informed decisions about which features should be default, which features to remove and to prioritize which languages to translate. The goal here is to be 100% transparent about what is being collected and to not personally identifiable information. It's the patterns that emerge from the group that are in important, not so much the individual. 60 | 61 | The following events are tracked in Google analytics: 62 | 63 | **save** 64 | 65 | Someone clicks on the "Save All Tabs" or "Save Selected Tabs" button. 66 | 67 | value: number of tabs in the group 68 | 69 | **saveAndClose** 70 | 71 | Someone clicks on the "Save & Close All Tabs" or "Save & Close Selected Tabs" button. 72 | 73 | value: number of tabs in the group 74 | 75 | **remove** 76 | 77 | Someone removes a tab group. 78 | 79 | value: number of tabs in the group 80 | 81 | **open** 82 | 83 | Someone opens a tab group. 84 | 85 | value: number of tabs in the group 86 | 87 | **pageview** 88 | 89 | Someone opens the Tabbie chrome extension 90 | 91 | ## Contributing 92 | 93 | Pull requests welcome! 94 | -------------------------------------------------------------------------------- /analytics.js: -------------------------------------------------------------------------------- 1 | // Standard Google Universal Analytics code 2 | (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ 3 | (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), 4 | m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) 5 | })(window,document,'script','https://www.google-analytics.com/analytics.js','ga'); 6 | 7 | ga('create', 'UA-89553254-1', 'auto'); 8 | ga('set', 'checkProtocolTask', function(){}); // Removes failing protocol check. @see: http://stackoverflow.com/a/22152353/1958200 9 | ga('require', 'displayfeatures'); 10 | ga('send', 'pageview', '/'); 11 | -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hharnisc/tabbie/f6d489d94385b99d4a0366d4bd7d37b889299138/docs/favicon.ico -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tabbie 6 | 7 | 19 | 20 | 21 | tabbie 22 | 23 | 24 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hharnisc/tabbie/f6d489d94385b99d4a0366d4bd7d37b889299138/icon.png -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Harrison Harnisch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Tabbie", 4 | "short_name": "Tabbie", 5 | "description": "The missing tab manager for Chrome", 6 | "version": "0.4.1", 7 | "icons": { 8 | "128": "icon.png", 9 | "16": "icon.png", 10 | "48": "icon.png" 11 | }, 12 | "browser_action": { 13 | "default_icon": "icon.png", 14 | "default_popup": "popup.html" 15 | }, 16 | "permissions": [ 17 | "tabs", 18 | "storage" 19 | ], 20 | "content_security_policy": "script-src 'self' https://www.google-analytics.com; object-src 'self'" 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Tabbie", 3 | "description": "The missing tab manager for Chrome", 4 | "private": true, 5 | "main": "index.js", 6 | "dependencies": { 7 | "@bufferapp/redux-hover": "0.0.5", 8 | "babel-eslint": "^7.1.1", 9 | "babel-preset-es2015": "^6.6.0", 10 | "babel-preset-react": "^6.5.0", 11 | "babel-preset-stage-0": "^6.22.0", 12 | "babelify": "^7.3.0", 13 | "browserify": "^13.0.0", 14 | "eslint": "^3.13.0", 15 | "eslint-config-airbnb": "^13.0.0", 16 | "eslint-plugin-import": "^2.2.0", 17 | "eslint-plugin-jsx-a11y": "^2.2.3", 18 | "eslint-plugin-react": "^6.8.0", 19 | "glob": "^7.1.1", 20 | "node-zip": "^1.1.1", 21 | "react": "^15.0.2", 22 | "react-dom": "^15.0.2", 23 | "react-redux": "^5.0.1", 24 | "react-router": "^3.0.2", 25 | "redux": "^3.6.0", 26 | "redux-thunk": "^2.1.0", 27 | "uglify-js": "^2.7.5", 28 | "url-parse": "^1.1.8", 29 | "watchify": "^3.7.0" 30 | }, 31 | "scripts": { 32 | "build": "NODE_ENV=production browserify ./src/index.jsx --extension=jsx -t babelify | uglifyjs -c > bundle.js", 33 | "start": "watchify ./src/index.jsx --extension=jsx -v -t babelify -o bundle.js", 34 | "zip": "npm run build && node zip" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tabbie 6 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /src/actions/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | unhover, 3 | } from '@bufferapp/redux-hover'; 4 | import { 5 | getSelectedTabs, 6 | getAllTabs, 7 | } from '../tabManager'; 8 | 9 | export const ADD_TAB_GROUP = 'ADD_TAB_GROUP'; 10 | export const CLOSE_TAB_GROUP = 'CLOSE_TAB_GROUP'; 11 | export const OPEN_TAB_GROUP = 'OPEN_TAB_GROUP'; 12 | export const SET_SAVE_SELECTED = 'SET_SAVE_SELECTED'; 13 | export const REMOVE_TAB_GROUP = 'REMOVE_TAB_GROUP'; 14 | export const SET_TAB_GROUP_NAME = 'SET_TAB_GROUP_NAME'; 15 | export const SET_TAB_GROUP_ERROR = 'SET_TAB_GROUP_ERROR'; 16 | export const RESYNC_TAB_GROUPS = 'RESYNC_TAB_GROUPS'; 17 | export const SCREEN_VIEW = 'SCREEN_VIEW'; 18 | export const REMOVE_TAB = 'REMOVE_TAB'; 19 | 20 | const setTabGroupError = tabGroupError => ({ 21 | type: SET_TAB_GROUP_ERROR, 22 | tabGroupError, 23 | }); 24 | 25 | const setTabGroupName = tabGroupName => ({ 26 | type: SET_TAB_GROUP_NAME, 27 | tabGroupName, 28 | }); 29 | 30 | const closeTabGroup = tabIds => ({ 31 | type: CLOSE_TAB_GROUP, 32 | tabIds, 33 | }); 34 | 35 | export const screenView = screen => ({ 36 | type: SCREEN_VIEW, 37 | screen, 38 | }); 39 | 40 | export const resyncTabGroups = tabGroups => ({ 41 | type: RESYNC_TAB_GROUPS, 42 | tabGroups, 43 | }); 44 | 45 | export const addTabGroup = ({ close, name, sync, tabs }) => ({ 46 | type: ADD_TAB_GROUP, 47 | close, 48 | name, 49 | sync, 50 | tabs, 51 | }); 52 | 53 | export const setSaveSelected = ({ saveSelected, sync }) => ({ 54 | type: SET_SAVE_SELECTED, 55 | saveSelected, 56 | sync, 57 | }); 58 | 59 | export const openTabGroup = tabs => ({ 60 | type: OPEN_TAB_GROUP, 61 | tabs, 62 | }); 63 | 64 | export const removeTabGroup = ({ tabGroupKey, sync }) => dispatch => 65 | Promise.all([ 66 | dispatch({ 67 | type: REMOVE_TAB_GROUP, 68 | tabGroupKey, 69 | sync, 70 | }), 71 | dispatch(unhover(`tab-group-list-item/remove-${tabGroupKey}`)), 72 | ]); 73 | 74 | export const tabGroupNameChange = tabGroupName => dispatch => 75 | Promise.all([ 76 | dispatch(setTabGroupError(false)), 77 | dispatch(setTabGroupName(tabGroupName)), 78 | ]); 79 | 80 | const cleanTabs = tabs => tabs.map(tab => ({ 81 | url: tab.url, 82 | pinned: tab.pinned, 83 | })); 84 | 85 | export const saveTabGroup = ({ tabGroupName, close, saveSelected }) => (dispatch) => { 86 | if (!tabGroupName) { 87 | dispatch(setTabGroupError(true)); 88 | } else { 89 | const tabSelectFunction = saveSelected ? getSelectedTabs : getAllTabs; 90 | tabSelectFunction() 91 | .then(tabs => Promise.all([ 92 | dispatch(addTabGroup({ 93 | close, 94 | name: tabGroupName, 95 | sync: true, 96 | tabs: cleanTabs(tabs), 97 | })), 98 | close ? dispatch(closeTabGroup(tabs.map(tab => tab.id))) : null, 99 | dispatch(setTabGroupName('')), 100 | ])); 101 | } 102 | }; 103 | 104 | export const removeTab = ({ tabKey, tabGroupKey, sync }) => dispatch => 105 | Promise.all([ 106 | dispatch({ 107 | type: REMOVE_TAB, 108 | tabKey, 109 | tabGroupKey, 110 | sync, 111 | }), 112 | dispatch(unhover(`tab-group-details-item/remove-${tabKey}`)), 113 | ]); 114 | -------------------------------------------------------------------------------- /src/chromeStorage.js: -------------------------------------------------------------------------------- 1 | export const setState = state => new Promise((resolve, reject) => { 2 | chrome.storage.sync.set(state, () => { 3 | if (chrome.runtime.error) { 4 | reject(chrome.runtime.error); 5 | } else { 6 | resolve(); 7 | } 8 | }); 9 | }); 10 | 11 | export const getState = () => new Promise((resolve, reject) => { 12 | chrome.storage.sync.get(null, (state) => { 13 | if (chrome.runtime.error) { 14 | reject(chrome.runtime.error); 15 | } else { 16 | resolve(state); 17 | } 18 | }); 19 | }); 20 | 21 | export const onChange = callback => chrome.storage.onChanged.addListener(callback); 22 | -------------------------------------------------------------------------------- /src/components/Button.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { connectHoverable } from '@bufferapp/redux-hover'; 3 | import { 4 | primaryButtonStyle, 5 | buttonStyle, 6 | warningButtonStyle, 7 | hoveredButton, 8 | hoveredWarningButtonStyle, 9 | fullWidth as fullWidthStyle, 10 | } from '../styles/button'; 11 | 12 | const calcColor = ({ type, hovered }) => { 13 | if (type === 'primary') { 14 | return primaryButtonStyle; 15 | } else if (type === 'secondary' && hovered) { 16 | return hoveredButton; 17 | } else if (type === 'secondary') { 18 | return buttonStyle; 19 | } else if (type === 'warning' && hovered) { 20 | return hoveredWarningButtonStyle; 21 | } 22 | return warningButtonStyle; 23 | }; 24 | 25 | const calcStyle = ({ type, hovered, fullWidth }) => { 26 | const colorStyle = calcColor({ type, hovered }); 27 | const dimensionStyle = fullWidth ? fullWidthStyle : {}; 28 | return { ...colorStyle, ...dimensionStyle }; 29 | }; 30 | 31 | const Button = ({ 32 | children, 33 | hovered, 34 | onMouseEnter, 35 | onMouseLeave, 36 | onClick, 37 | type, 38 | fullWidth, 39 | }) => 40 | ; 48 | 49 | Button.propTypes = { 50 | children: PropTypes.node, 51 | fullWidth: PropTypes.bool, 52 | hovered: PropTypes.bool, 53 | onMouseEnter: PropTypes.func, 54 | onMouseLeave: PropTypes.func, 55 | onClick: PropTypes.func, 56 | type: PropTypes.oneOf(['secondary', 'warning', 'primary']), 57 | }; 58 | 59 | Button.defaultProps = { 60 | type: 'secondary', 61 | }; 62 | 63 | export default connectHoverable(Button); 64 | -------------------------------------------------------------------------------- /src/components/TabGroupDetailsItem.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import parse from 'url-parse'; 3 | import Button from './Button'; 4 | import { listItemStyle } from '../styles/list'; 5 | import { grey } from '../styles/colors'; 6 | 7 | const urlStyle = { 8 | flexGrow: 1, 9 | marginRight: '1rem', 10 | }; 11 | 12 | const customListStyle = { 13 | alignItems: 'center', 14 | }; 15 | 16 | const ellipsisStyle = { 17 | whiteSpace: 'nowrap', 18 | overflow: 'hidden', 19 | textOverflow: 'ellipsis', 20 | }; 21 | 22 | const urlPathStyle = { 23 | color: grey, 24 | }; 25 | 26 | const pinnedStyle = { 27 | marginRight: '1rem', 28 | }; 29 | 30 | const renderUrl = (url) => { 31 | const parsedUrl = parse(url); 32 | const pathAndHash = `${parsedUrl.pathname}${parsedUrl.hash}` !== '/' ? 33 | (
34 | {parsedUrl.pathname}{parsedUrl.hash} 35 |
) : null; 36 | return ( 37 |
38 |
{parsedUrl.protocol}{'//'}{parsedUrl.auth}{parsedUrl.host}
39 | {pathAndHash} 40 |
41 | ); 42 | }; 43 | 44 | const renderPinned = () => 45 |
46 | 47 |
; 48 | 49 | /* eslint-disable react/prop-types */ 50 | 51 | const renderRemove = ({ tabKey, tabGroupKey, onRemoveClick }) => 52 |
53 | 60 |
; 61 | 62 | /* eslint-enable react/prop-types */ 63 | 64 | const TabGroupDetailsItem = ({ 65 | pinned, 66 | url, 67 | tabKey, 68 | tabGroupKey, 69 | onRemoveClick, 70 | }) => 71 |
  • 72 |
    73 | {renderUrl(url, pinned)} 74 |
    75 | {pinned ? renderPinned() : null} 76 | {renderRemove({ tabKey, tabGroupKey, onRemoveClick })} 77 |
  • ; 78 | 79 | TabGroupDetailsItem.propTypes = { 80 | tabKey: PropTypes.number, 81 | tabGroupKey: PropTypes.number, 82 | onRemoveClick: PropTypes.func, 83 | pinned: PropTypes.bool, 84 | url: PropTypes.string, 85 | }; 86 | 87 | export default TabGroupDetailsItem; 88 | -------------------------------------------------------------------------------- /src/components/TabGroupDetailsView.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { Link } from 'react-router'; 3 | import TabGroupDetailsItem from '../containers/TabGroupDetailsItem'; 4 | import titleStyle from '../styles/title'; 5 | import { listStyle } from '../styles/list'; 6 | import linkStyle from '../styles/link'; 7 | 8 | const titleBarStyle = { 9 | display: 'flex', 10 | alignItems: 'baseline', 11 | marginBottom: '0.5em', 12 | }; 13 | 14 | const titleCustomStyle = { 15 | flexGrow: 1, 16 | whiteSpace: 'nowrap', 17 | overflow: 'hidden', 18 | textOverflow: 'ellipsis', 19 | marginRight: '1em', 20 | }; 21 | 22 | const wrapperStyle = { 23 | height: '100%', 24 | display: 'flex', 25 | flexDirection: 'column', 26 | }; 27 | 28 | const customLinkStyle = { 29 | whiteSpace: 'nowrap', 30 | }; 31 | 32 | const listWrapper = { 33 | overflowY: 'auto', 34 | }; 35 | 36 | const TabGroupDetailsView = ({ 37 | tabGroup, 38 | tabGroupKey, 39 | }) => 40 |
    41 |
    42 |

    43 | {tabGroup.name} 44 |

    45 | « back 46 |
    47 |
    48 | 58 |
    59 |
    ; 60 | 61 | TabGroupDetailsView.propTypes = { 62 | tabGroup: PropTypes.shape({ 63 | name: PropTypes.string, 64 | tabs: PropTypes.arrayOf( 65 | PropTypes.shape({ 66 | pinned: PropTypes.bool, 67 | url: PropTypes.string, 68 | }), 69 | ), 70 | }), 71 | tabGroupKey: PropTypes.number, 72 | }; 73 | 74 | export default TabGroupDetailsView; 75 | -------------------------------------------------------------------------------- /src/components/TabGroupList.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import TabGroupListItem from '../containers/TabGroupListItem'; 3 | import TabGroupListEmpty from './TabGroupListEmpty'; 4 | import { listStyle } from '../styles/list'; 5 | 6 | const TabGroupList = ({ tabGroups }) => 7 | ; 15 | 16 | TabGroupList.propTypes = { 17 | tabGroups: PropTypes.arrayOf( 18 | PropTypes.shape({ 19 | name: PropTypes.string, 20 | }), 21 | ), 22 | }; 23 | 24 | export default TabGroupList; 25 | -------------------------------------------------------------------------------- /src/components/TabGroupListControls.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import Button from './Button'; 3 | import { 4 | inputStyle, 5 | inputErrorStyle, 6 | } from '../styles/input'; 7 | 8 | const formStyle = { 9 | display: 'flex', 10 | flexDirection: 'column', 11 | }; 12 | 13 | const formInputStyle = { 14 | margin: '1em 0', 15 | }; 16 | 17 | const formButtonStyle = { 18 | marginTop: '1em', 19 | }; 20 | 21 | const formCheckboxStyle = { 22 | marginTop: '1em', 23 | }; 24 | 25 | const ListControls = ({ 26 | onClickSetSaveSelected, 27 | onTabGroupNameChange, 28 | onSaveTabGroupClick, 29 | saveSelected, 30 | tabGroupError, 31 | tabGroupName, 32 | }) => 33 |
    34 | 37 | 47 | 48 | 58 | 59 | 60 | 70 | 71 | 83 |
    ; 84 | 85 | ListControls.propTypes = { 86 | onTabGroupNameChange: PropTypes.func, 87 | onClickSetSaveSelected: PropTypes.func.isRequired, 88 | onSaveTabGroupClick: PropTypes.func, 89 | saveSelected: PropTypes.bool.isRequired, 90 | tabGroupError: PropTypes.bool, 91 | tabGroupName: PropTypes.string, 92 | }; 93 | 94 | export default ListControls; 95 | -------------------------------------------------------------------------------- /src/components/TabGroupListEmpty.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default () =>
    No saved tab groups... yet
    ; 4 | -------------------------------------------------------------------------------- /src/components/TabGroupListItem.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { Link } from 'react-router'; 3 | import Button from './Button'; 4 | import { listItemStyle } from '../styles/list'; 5 | import linkStyle from '../styles/link'; 6 | 7 | const listItemNameStyle = { 8 | flexGrow: 1, 9 | }; 10 | 11 | const TabGroupListItem = ({ 12 | tabGroupKey, 13 | name, 14 | onOpenClick, 15 | onRemoveClick, 16 | tabs, 17 | }) => 18 |
  • 19 | 20 | { `${name} ` } 21 | 22 | {`(${tabs.length} Tabs)`} 23 | 24 | 25 | 26 | 32 | 33 | 34 | 41 | 42 |
  • ; 43 | 44 | TabGroupListItem.propTypes = { 45 | tabGroupKey: PropTypes.number, 46 | name: PropTypes.string, 47 | onOpenClick: PropTypes.func, 48 | onRemoveClick: PropTypes.func, 49 | tabs: PropTypes.arrayOf( 50 | PropTypes.shape({ 51 | pinned: PropTypes.bool, 52 | url: PropTypes.string, 53 | }), 54 | ), 55 | }; 56 | 57 | export default TabGroupListItem; 58 | -------------------------------------------------------------------------------- /src/components/TabGroupListView.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TabGroupList from '../containers/TabGroupList'; 3 | import TabGroupListControls from '../containers/TabGroupListControls'; 4 | import titleStyle from '../styles/title'; 5 | 6 | const tabGroupListStyle = { 7 | flexGrow: 1, 8 | maxHeight: '17em', 9 | overflowY: 'auto', 10 | }; 11 | 12 | const wrapperStyle = { 13 | height: '100%', 14 | display: 'flex', 15 | flexDirection: 'column', 16 | }; 17 | 18 | const TabGroupListView = () => 19 |
    20 |

    21 | Saved Tab Groups 22 |

    23 |
    24 | 25 |
    26 | 27 |
    ; 28 | 29 | export default TabGroupListView; 30 | -------------------------------------------------------------------------------- /src/containers/TabGroupDetailsItem.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import TabGroupDetailsItem from '../components/TabGroupDetailsItem'; 3 | import { 4 | removeTab, 5 | } from '../actions'; 6 | 7 | const mapDispatchToProps = dispatch => ({ 8 | onRemoveClick: ({ tabKey, tabGroupKey }) => 9 | dispatch(removeTab({ tabKey, tabGroupKey, sync: true })), 10 | }); 11 | 12 | export default connect( 13 | () => ({}), 14 | mapDispatchToProps, 15 | )(TabGroupDetailsItem); 16 | -------------------------------------------------------------------------------- /src/containers/TabGroupDetailsView.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import TabGroupDetailsView from '../components/TabGroupDetailsView'; 3 | 4 | const mapStateToProps = (state, ownProps) => ({ 5 | tabGroup: state.tabGroupList.tabGroups[ownProps.params.tabGroupKey], 6 | tabGroupKey: parseInt(ownProps.params.tabGroupKey, 10), 7 | }); 8 | 9 | export default connect( 10 | mapStateToProps, 11 | )(TabGroupDetailsView); 12 | -------------------------------------------------------------------------------- /src/containers/TabGroupList.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import TabGroupList from '../components/TabGroupList'; 3 | 4 | const mapStateToProps = state => state.tabGroupList; 5 | 6 | export default connect( 7 | mapStateToProps, 8 | )(TabGroupList); 9 | -------------------------------------------------------------------------------- /src/containers/TabGroupListControls.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import TabGroupListControls from '../components/TabGroupListControls'; 3 | import { 4 | setSaveSelected, 5 | tabGroupNameChange, 6 | saveTabGroup, 7 | } from '../actions'; 8 | 9 | const mapStateToProps = state => state.tabGroupListControls; 10 | const mapDispatchToProps = dispatch => ({ 11 | onClickSetSaveSelected: saveSelected => dispatch(setSaveSelected({ saveSelected, sync: true })), 12 | onTabGroupNameChange: e => dispatch(tabGroupNameChange(e.target.value)), 13 | onSaveTabGroupClick: ({ tabGroupName, close, saveSelected }) => 14 | dispatch(saveTabGroup({ tabGroupName, close, saveSelected })), 15 | }); 16 | 17 | export default connect( 18 | mapStateToProps, 19 | mapDispatchToProps, 20 | )(TabGroupListControls); 21 | -------------------------------------------------------------------------------- /src/containers/TabGroupListItem.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import TabGroupListItem from '../components/TabGroupListItem'; 3 | import { 4 | openTabGroup, 5 | removeTabGroup, 6 | } from '../actions'; 7 | 8 | const mapDispatchToProps = dispatch => ({ 9 | onRemoveClick: tabGroupKey => dispatch(removeTabGroup({ tabGroupKey, sync: true })), 10 | onOpenClick: tabs => dispatch(openTabGroup(tabs)), 11 | }); 12 | 13 | export default connect( 14 | () => ({}), 15 | mapDispatchToProps, 16 | )(TabGroupListItem); 17 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import { Router, Route, browserHistory } from 'react-router'; 4 | import { createStore, applyMiddleware } from 'redux'; 5 | import thunk from 'redux-thunk'; 6 | import { Provider } from 'react-redux'; 7 | import TabGroupListView from './components/TabGroupListView'; 8 | import TabGroupDetailsView from './containers/TabGroupDetailsView'; 9 | import tabbieApp from './reducers'; 10 | import analytics from './middleware/analytics'; 11 | import chromeStorage from './middleware/chromeStorage'; 12 | import tabManager from './middleware/tabManager'; 13 | import { screenView } from './actions'; 14 | 15 | const appStyle = { 16 | padding: '2em', 17 | height: '40em', 18 | width: '30em', 19 | boxSizing: 'border-box', 20 | }; 21 | 22 | document.addEventListener('DOMContentLoaded', () => { 23 | const store = createStore(tabbieApp, applyMiddleware( 24 | thunk, 25 | analytics, 26 | chromeStorage, 27 | tabManager, 28 | )); 29 | render( 30 | 31 |
    32 | 33 | store.dispatch(screenView('TabGroupDetails'))} 37 | /> 38 | store.dispatch(screenView('TabGroupList'))} 42 | /> 43 | 44 |
    45 |
    , 46 | document.getElementById('container'), 47 | ); 48 | }); 49 | -------------------------------------------------------------------------------- /src/middleware/analytics.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef*/ 2 | // use global scoped google analytics since it doesn't mark every user as new 3 | import { 4 | ADD_TAB_GROUP, 5 | OPEN_TAB_GROUP, 6 | REMOVE_TAB_GROUP, 7 | SCREEN_VIEW, 8 | } from '../actions'; 9 | 10 | const analytics = () => next => (action) => { 11 | switch (action.type) { 12 | case ADD_TAB_GROUP: 13 | ga('send', { 14 | hitType: 'event', 15 | eventCategory: 'TabGroup', 16 | eventAction: `save${action.close ? 'AndClose' : ''}`, 17 | eventValue: action.tabs.length, 18 | }); 19 | break; 20 | case OPEN_TAB_GROUP: 21 | ga('send', { 22 | hitType: 'event', 23 | eventCategory: 'TabGroup', 24 | eventAction: 'open', 25 | eventValue: action.tabs.length, 26 | }); 27 | break; 28 | case REMOVE_TAB_GROUP: 29 | ga('send', { 30 | hitType: 'event', 31 | eventCategory: 'TabGroup', 32 | eventAction: 'remove', 33 | }); 34 | break; 35 | case SCREEN_VIEW: 36 | ga('send', { 37 | hitType: 'screenView', 38 | appName: 'Tabbie', 39 | appVersion: chrome.runtime.getManifest().version, 40 | screenName: action.screen, 41 | }); 42 | break; 43 | default: 44 | break; 45 | } 46 | return next(action); 47 | }; 48 | 49 | 50 | export default analytics; 51 | -------------------------------------------------------------------------------- /src/middleware/chromeStorage.js: -------------------------------------------------------------------------------- 1 | import { 2 | setState, 3 | getState, 4 | onChange, 5 | } from '../chromeStorage'; 6 | import { 7 | ADD_TAB_GROUP, 8 | REMOVE_TAB_GROUP, 9 | SET_SAVE_SELECTED, 10 | REMOVE_TAB, 11 | resyncTabGroups, 12 | setSaveSelected, 13 | addTabGroup, 14 | } from '../actions'; 15 | 16 | const chromeStorage = (store) => { 17 | onChange((changes) => { 18 | const state = store.getState(); 19 | const { tabGroups } = state.tabGroupList; 20 | const { saveSelected } = state.tabGroupListControls; 21 | if (changes.tabGroups && tabGroups !== changes.tabGroups.newValue) { 22 | store.dispatch(resyncTabGroups(changes.tabGroups.newValue)); 23 | } 24 | if (changes.saveSelected && saveSelected !== changes.saveSelected.newValue) { 25 | store.dispatch(setSaveSelected({ saveSelected: changes.saveSelected.newValue })); 26 | } 27 | }); 28 | getState() 29 | .then((state) => { 30 | store.dispatch(setSaveSelected({ saveSelected: state.saveSelected || false })); 31 | if (state.tabGroups) { 32 | state.tabGroups.forEach((tabGroup) => { 33 | store.dispatch(addTabGroup({ 34 | name: tabGroup.name, 35 | tabs: tabGroup.tabs, 36 | })); 37 | }); 38 | } 39 | }); 40 | return next => (action) => { 41 | if (action.sync) { 42 | switch (action.type) { 43 | case ADD_TAB_GROUP: 44 | setState({ 45 | tabGroups: [ 46 | ...store.getState().tabGroupList.tabGroups, 47 | { name: action.name, tabs: action.tabs }, 48 | ], 49 | }); 50 | break; 51 | case REMOVE_TAB_GROUP: 52 | setState({ 53 | tabGroups: 54 | store.getState().tabGroupList.tabGroups 55 | .filter((tabGroup, i) => i !== action.tabGroupKey), 56 | }); 57 | break; 58 | case SET_SAVE_SELECTED: 59 | setState({ saveSelected: action.saveSelected }); 60 | break; 61 | case REMOVE_TAB: 62 | setState({ 63 | tabGroups: store.getState().tabGroupList.tabGroups 64 | .map((tabGroup, tabGroupKey) => { 65 | if (tabGroupKey === action.tabGroupKey) { 66 | return { 67 | name: tabGroup.name, 68 | tabs: tabGroup.tabs.filter((tab, tabKey) => tabKey !== action.tabKey), 69 | }; 70 | } 71 | return tabGroup; 72 | }), 73 | }); 74 | break; 75 | default: 76 | break; 77 | } 78 | } 79 | return next(action); 80 | }; 81 | }; 82 | 83 | export default chromeStorage; 84 | -------------------------------------------------------------------------------- /src/middleware/tabManager.js: -------------------------------------------------------------------------------- 1 | import { 2 | createTabs, 3 | closeTabsWithIds, 4 | } from '../tabManager'; 5 | import { 6 | CLOSE_TAB_GROUP, 7 | OPEN_TAB_GROUP, 8 | } from '../actions'; 9 | 10 | const tabManager = () => next => (action) => { 11 | switch (action.type) { 12 | case CLOSE_TAB_GROUP: 13 | closeTabsWithIds(action.tabIds); 14 | break; 15 | case OPEN_TAB_GROUP: 16 | createTabs(action.tabs); 17 | break; 18 | default: 19 | break; 20 | } 21 | return next(action); 22 | }; 23 | 24 | 25 | export default tabManager; 26 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { reducer as hover } from '@bufferapp/redux-hover'; 3 | import tabGroupList from './tabGroupList'; 4 | import tabGroupListControls from './tabGroupListControls'; 5 | 6 | const tabbieApp = combineReducers({ 7 | hover, 8 | tabGroupList, 9 | tabGroupListControls, 10 | }); 11 | 12 | export default tabbieApp; 13 | -------------------------------------------------------------------------------- /src/reducers/tabGroupList.js: -------------------------------------------------------------------------------- 1 | import { 2 | REMOVE_TAB_GROUP, 3 | ADD_TAB_GROUP, 4 | RESYNC_TAB_GROUPS, 5 | REMOVE_TAB, 6 | } from '../actions'; 7 | 8 | const tabGroupList = (state = { tabGroups: [] }, action) => { 9 | switch (action.type) { 10 | case RESYNC_TAB_GROUPS: 11 | return { 12 | ...state, 13 | tabGroups: action.tabGroups, 14 | }; 15 | case REMOVE_TAB_GROUP: { 16 | return { 17 | ...state, 18 | tabGroups: state.tabGroups.filter((tabGroup, i) => i !== action.tabGroupKey), 19 | }; 20 | } 21 | case ADD_TAB_GROUP: 22 | return { 23 | ...state, 24 | tabGroups: [...state.tabGroups, { name: action.name, tabs: action.tabs }], 25 | }; 26 | case REMOVE_TAB: 27 | return { 28 | ...state, 29 | tabGroups: state.tabGroups.map((tabGroup, tabGroupKey) => { 30 | if (tabGroupKey === action.tabGroupKey) { 31 | return { 32 | name: tabGroup.name, 33 | tabs: tabGroup.tabs.filter((tab, tabKey) => tabKey !== action.tabKey), 34 | }; 35 | } 36 | return tabGroup; 37 | }), 38 | }; 39 | default: 40 | return state; 41 | } 42 | }; 43 | 44 | export default tabGroupList; 45 | -------------------------------------------------------------------------------- /src/reducers/tabGroupListControls.js: -------------------------------------------------------------------------------- 1 | import { 2 | SET_SAVE_SELECTED, 3 | SET_TAB_GROUP_NAME, 4 | SET_TAB_GROUP_ERROR, 5 | } from '../actions'; 6 | 7 | const tabGroupListControls = (state = { 8 | saveSelected: false, 9 | tabGroupName: '', 10 | tabGroupError: false, 11 | }, action) => { 12 | switch (action.type) { 13 | case SET_SAVE_SELECTED: 14 | return { ...state, saveSelected: action.saveSelected }; 15 | case SET_TAB_GROUP_NAME: 16 | return { ...state, tabGroupName: action.tabGroupName }; 17 | case SET_TAB_GROUP_ERROR: 18 | return { ...state, tabGroupError: action.tabGroupError }; 19 | default: 20 | return state; 21 | } 22 | }; 23 | 24 | export default tabGroupListControls; 25 | -------------------------------------------------------------------------------- /src/styles/button.js: -------------------------------------------------------------------------------- 1 | import { 2 | red, 3 | blue, 4 | white, 5 | } from './colors'; 6 | 7 | export const buttonStyle = { 8 | background: white, 9 | borderRadius: '3px', 10 | border: 'none', 11 | color: blue, 12 | fontSize: '0.9em', 13 | padding: '0.5em', 14 | }; 15 | 16 | export const warningButtonStyle = { 17 | ...buttonStyle, 18 | color: red, 19 | }; 20 | 21 | export const primaryButtonStyle = { 22 | ...buttonStyle, 23 | background: blue, 24 | color: white, 25 | }; 26 | 27 | export const hoveredButton = { ...primaryButtonStyle }; 28 | 29 | export const hoveredWarningButtonStyle = { 30 | ...buttonStyle, 31 | background: red, 32 | color: white, 33 | }; 34 | 35 | export const fullWidth = { 36 | width: '100%', 37 | padding: '1em', 38 | }; 39 | -------------------------------------------------------------------------------- /src/styles/colors.js: -------------------------------------------------------------------------------- 1 | export const red = '#ea4335'; 2 | export const blue = '#4285f4'; 3 | export const white = '#ffffff'; 4 | export const grey = '#666666'; 5 | -------------------------------------------------------------------------------- /src/styles/input.js: -------------------------------------------------------------------------------- 1 | import { 2 | red, 3 | } from './colors'; 4 | 5 | export const inputStyle = { 6 | border: '1px solid #808080', 7 | boxSizing: 'border-box', 8 | margin: 0, 9 | fontSize: '0.9em', 10 | padding: '1em', 11 | }; 12 | 13 | export const inputErrorStyle = { 14 | ...inputStyle, 15 | border: `1px solid ${red}`, 16 | }; 17 | -------------------------------------------------------------------------------- /src/styles/link.js: -------------------------------------------------------------------------------- 1 | import { grey } from './colors'; 2 | 3 | const linkStyle = { 4 | color: grey, 5 | }; 6 | 7 | export default linkStyle; 8 | -------------------------------------------------------------------------------- /src/styles/list.js: -------------------------------------------------------------------------------- 1 | export const listStyle = { 2 | listStyleType: 'none', 3 | margin: 0, 4 | padding: 0, 5 | }; 6 | 7 | export const listItemStyle = { 8 | display: 'flex', 9 | padding: '0.5em 0', 10 | }; 11 | -------------------------------------------------------------------------------- /src/styles/title.js: -------------------------------------------------------------------------------- 1 | const titleStyle = { 2 | marginTop: 0, 3 | marginBottom: '0.5em', 4 | }; 5 | 6 | export default titleStyle; 7 | -------------------------------------------------------------------------------- /src/tabManager.js: -------------------------------------------------------------------------------- 1 | const createWindow = urls => new Promise((resolve) => { 2 | chrome.windows.create({ url: urls }, window => resolve(window)); 3 | }); 4 | 5 | export const createTabs = tabs => 6 | createWindow(tabs.map(tab => (tab.url ? tab.url : tab))) 7 | .then(window => tabs.map((tab, i) => 8 | new Promise((resolve) => { 9 | if (tab.pinned) { 10 | chrome.tabs.update(window.tabs[i].id, { pinned: true }, () => resolve()); 11 | } else { 12 | resolve(); 13 | } 14 | }), 15 | )) 16 | .then(tabPromises => Promise.all(tabPromises)); 17 | 18 | export const getSelectedTabs = () => new Promise((resolve) => { 19 | chrome.tabs.query({ 20 | highlighted: true, 21 | lastFocusedWindow: true, 22 | }, (tabs) => { 23 | resolve(tabs); 24 | }); 25 | }); 26 | 27 | export const getAllTabs = () => new Promise((resolve) => { 28 | chrome.tabs.query({ 29 | lastFocusedWindow: true, 30 | }, (tabs) => { 31 | resolve(tabs); 32 | }); 33 | }); 34 | 35 | export const closeTabsWithIds = tabIds => new Promise((resolve) => { 36 | chrome.tabs.remove(tabIds, () => resolve()); 37 | }); 38 | -------------------------------------------------------------------------------- /store/1400 x 560.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hharnisc/tabbie/f6d489d94385b99d4a0366d4bd7d37b889299138/store/1400 x 560.png -------------------------------------------------------------------------------- /store/440 x 280.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hharnisc/tabbie/f6d489d94385b99d4a0366d4bd7d37b889299138/store/440 x 280.png -------------------------------------------------------------------------------- /store/920 x 680.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hharnisc/tabbie/f6d489d94385b99d4a0366d4bd7d37b889299138/store/920 x 680.png -------------------------------------------------------------------------------- /store/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hharnisc/tabbie/f6d489d94385b99d4a0366d4bd7d37b889299138/store/icon.png -------------------------------------------------------------------------------- /store/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hharnisc/tabbie/f6d489d94385b99d4a0366d4bd7d37b889299138/store/screenshot.png -------------------------------------------------------------------------------- /zip.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const NodeZip = require('node-zip'); 3 | const glob = require('glob'); 4 | 5 | const zip = new NodeZip(); 6 | const version = JSON.parse(fs.readFileSync('manifest.json')).version; 7 | 8 | const options = { 9 | ignore: [ 10 | 'node_modules/**', 11 | 'docs/**', 12 | 'store/**', 13 | 'src/**', 14 | 'zip.js', 15 | 'package.json', 16 | ], 17 | }; 18 | 19 | glob('**/*', options, (err, files) => { 20 | files.forEach((file) => { 21 | zip.file(file, fs.readFileSync(file), { binary: true }); 22 | }); 23 | const data = zip.generate({ base64: false, compression: 'DEFLATE' }); 24 | fs.writeFileSync(`tabbie-${version}.zip`, data, 'binary'); 25 | }); 26 | --------------------------------------------------------------------------------