├── .editorconfig ├── .eslintrc ├── .gitignore ├── .huskyrc.json ├── .stylelintrc.json ├── .travis.yml ├── LICENSE.md ├── README.md ├── keymaps └── pinned-tabs.json ├── lib ├── pinned-tabs.js ├── state.js └── util.js ├── menus └── pinned-tabs.json ├── package-lock.json ├── package.json ├── spec ├── fixtures │ ├── chicken.md │ └── lorem.txt ├── pinned-tabs-spec.js ├── state-spec.js └── utils.js └── styles ├── animations.less ├── icons.less ├── pin-button.less └── pinned-tabs.less /.editorconfig: -------------------------------------------------------------------------------- 1 | root=true 2 | 3 | [*] 4 | charset=utf-8 5 | indent_style=space 6 | indent_size=2 7 | insert_final_newline=true 8 | max_line_length=120 9 | trim_trailing_whitespace=true 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 2017, 4 | "sourceType": "module" 5 | }, 6 | "rules": { 7 | "comma-dangle": 2, 8 | "comma-spacing": [2, { "before": false, "after": true }], 9 | "consistent-return": 0, 10 | "curly": [2, "multi-line"], 11 | "default-case": 0, 12 | "dot-notation": 2, 13 | "eqeqeq": 2, 14 | "guard-for-in": 0, 15 | "max-len": [2, { "code": 120 }], 16 | "no-alert": 1, 17 | "no-caller": 2, 18 | "no-catch-shadow": 2, 19 | "no-console": 2, 20 | "no-cond-assign": 2, 21 | "no-constant-condition": 2, 22 | "no-control-regex": 2, 23 | "no-delete-var": 2, 24 | "no-div-regex": 2, 25 | "no-dupe-args": 2, 26 | "no-dupe-keys": 2, 27 | "no-duplicate-case": 2, 28 | "no-else-return": 0, 29 | "no-empty": 2, 30 | "no-eq-null": 2, 31 | "no-eval": 2, 32 | "no-extend-native": 0, 33 | "no-extra-bind": 2, 34 | "no-extra-parens": 0, 35 | "no-extra-semi": 2, 36 | "no-fallthrough": 2, 37 | "no-floating-decimal": 1, 38 | "no-func-assign": 2, 39 | "no-implied-eval": 2, 40 | "no-inner-declarations": 2, 41 | "no-invalid-regexp": 2, 42 | "no-irregular-whitespace": 2, 43 | "no-iterator": 2, 44 | "no-labels": 2, 45 | "no-lone-blocks": 2, 46 | "no-loop-func": 2, 47 | "no-multi-spaces": 2, 48 | "no-multi-str": 2, 49 | "no-native-reassign": 2, 50 | "no-new": 0, 51 | "no-new-func": 2, 52 | "no-new-wrappers": 2, 53 | "no-octal": 1, 54 | "no-octal-escape": 2, 55 | "no-process-env": 1, 56 | "no-proto": 2, 57 | "no-regex-spaces": 2, 58 | "no-redeclare": 2, 59 | "no-return-assign": 2, 60 | "no-sparse-arrays": 2, 61 | "no-script-url": 0, 62 | "no-self-compare": 1, 63 | "no-sequences": 2, 64 | "no-unreachable": 2, 65 | "no-var": 2, 66 | "no-shadow": 0, 67 | "no-shadow-restricted-names": 2, 68 | "no-undef": 0, 69 | "no-undef-init": 0, 70 | "no-undefined": 0, 71 | "no-unused-vars": 2, 72 | "no-use-before-define": 0, 73 | "no-unused-expressions": 0, 74 | "no-void": 0, 75 | "no-warning-comments": 0, 76 | "no-with": 2, 77 | "quotes": [2, "single"], 78 | "radix": 2, 79 | "semi": 2, 80 | "strict": 0, 81 | "use-isnan": 2, 82 | "valid-jsdoc": 0, 83 | "valid-typeof": 2, 84 | "vars-on-top": 1, 85 | "wrap-iife": 2, 86 | "yoda": 2 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | npm-debug.log 3 | node_modules/ 4 | coverage/ 5 | .nyc_output/ 6 | -------------------------------------------------------------------------------- /.huskyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "precommit": "npm run lint" 3 | } 4 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard", 3 | "plugins": [ 4 | "stylelint-order" 5 | ], 6 | "rules": { 7 | "font-family-no-missing-generic-family-keyword": null, 8 | "no-descending-specificity": null, 9 | "number-leading-zero": "never", 10 | "order/properties-alphabetical-order": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: generic 2 | os: linux 3 | dist: trusty 4 | 5 | jobs: 6 | include: 7 | - stage: "Lint" 8 | name: "Lint" 9 | os: linux 10 | language: node_js 11 | node_js: 10 12 | before_script: npm install 13 | script: npm run lint 14 | 15 | - stage: "Test" 16 | name: "Linux - Atom Beta" 17 | os: linux 18 | env: ATOM_CHANNEL=beta 19 | script: 20 | - curl -s -O https://raw.githubusercontent.com/atom/ci/master/build-package.sh 21 | - chmod u+x build-package.sh 22 | - ./build-package.sh 23 | - name: "OS X - Atom Beta" 24 | os: osx 25 | env: ATOM_CHANNEL=beta 26 | script: 27 | - curl -s -O https://raw.githubusercontent.com/atom/ci/master/build-package.sh 28 | - chmod u+x build-package.sh 29 | - ./build-package.sh 30 | 31 | - name: "Linux - Atom Stable" 32 | os: linux 33 | env: ATOM_CHANNEL=stable 34 | script: 35 | - curl -s -O https://raw.githubusercontent.com/atom/ci/master/build-package.sh 36 | - chmod u+x build-package.sh 37 | - ./build-package.sh 38 | - name: "OS X - Atom Stable" 39 | os: osx 40 | env: ATOM_CHANNEL=stable 41 | script: 42 | - curl -s -O https://raw.githubusercontent.com/atom/ci/master/build-package.sh 43 | - chmod u+x build-package.sh 44 | - ./build-package.sh 45 | 46 | notifications: 47 | email: 48 | on_success: never 49 | on_failure: change 50 | 51 | addons: 52 | apt: 53 | packages: 54 | - build-essential 55 | - fakeroot 56 | - git 57 | - libsecret-1-dev 58 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2020 Eric Cornelissen 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pinned tabs for Atom 2 | 3 | [![Build Status](https://travis-ci.com/ericcornelissen/pinned-tabs-for-atom.svg?branch=master)](https://travis-ci.com/ericcornelissen/pinned-tabs-for-atom) 4 | [![Maintainability](https://api.codeclimate.com/v1/badges/e7d6b69e47a27a0a6ef0/maintainability)](https://codeclimate.com/github/ericcornelissen/pinned-tabs-for-atom/maintainability) 5 | 6 | A simple package for the Atom text editor from GitHub that allows you to pin tabs. Inspired by the pin tab feature from Browsers, also supports Visual Studio style pinning. 7 | 8 | For the best experience of this package, I recommend using it with the [file-icons](https://atom.io/packages/file-icons) package. 9 | 10 | ![preview gif](http://i.imgur.com/zdzpBnd.gif) 11 | 12 | * * * 13 | 14 | ## Usage 15 | There are three ways to pin/unpin a tab using this package. 16 | - Via the context menu of a tab. 17 | - Via the keyboard shortcut ctrl + alt + p. 18 | - Via the command-palette, by typing `Pin Active`. 19 | 20 | * * * 21 | 22 | ## Installation 23 | Via the Atom Package Manager (APM) 24 | ```bash 25 | $ apm install pinned-tabs 26 | ``` 27 | 28 | Or via Git clone 29 | ```bash 30 | $ cd ~/.atom/packages 31 | $ git clone https://github.com/ericcornelissen/pinned-tabs-for-atom --depth=1 32 | ``` 33 | 34 | * * * 35 | 36 | ## Customization 37 | You can add custom styles for pinned tabs. Use your [Stylesheet](https://flight-manual.atom.io/using-atom/sections/basic-customization/#style-tweaks) and target `.tab.pinned-tab` to tweak a pinned tab. You can consult the [package stylesheet](./styles/pinned-tabs.less) to see what classes are used. 38 | 39 | Below are a few examples of ways to customize the styling of pinned tabs. 40 | 41 | #### Style the active pinned tab 42 | ```css 43 | .tab.pinned-tab.active { 44 | background-color: salmon; 45 | } 46 | 47 | /* Or all non active pinned tabs */ 48 | .tab.pinned-tab:not(.active) { 49 | background-color: olive; 50 | } 51 | ``` 52 | 53 | #### Choose your own icon for pinned tabs 54 | ```css 55 | .tab.pinned-tab > .title::before { 56 | content: '\f135'; 57 | font-family: FontAwesome; 58 | font-size: 18px; 59 | } 60 | ``` 61 | 62 | If you're using file-icons, you can check out its [customization documentation](https://github.com/file-icons/atom#customisation) as well. 63 | 64 | #### Change the pin button on tabs 65 | ```css 66 | .tab > .pin-icon::before { 67 | content: '\f015' !important; 68 | } 69 | ``` 70 | 71 | #### Change the 'pinned' icon for Visual Studio mode 72 | ```css 73 | .tab.pinned-tab > .close-icon::before { 74 | content: '\f276'; 75 | font-family: FontAwesome; 76 | font-size: 12px; 77 | } 78 | ``` 79 | 80 | #### Style tabs that are not pinned 81 | ```css 82 | .tab:not(.pinned-tab):not([data-type="TreeView"]):not([data-type="PanelDock"]):not([data-type="Object"]) { 83 | opacity: 0.5; 84 | } 85 | ``` 86 | 87 | Where the different `:not([data-type])` exclude tabs elsewhere in Atom. 88 | 89 | * * * 90 | 91 | Copyright © Eric Cornelissen | MIT license 92 | -------------------------------------------------------------------------------- /keymaps/pinned-tabs.json: -------------------------------------------------------------------------------- 1 | { 2 | "atom-workspace": { 3 | "ctrl-alt-p": "pinned-tabs:pin-active" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /lib/pinned-tabs.js: -------------------------------------------------------------------------------- 1 | 'use babel'; 2 | 3 | import { CompositeDisposable } from 'atom'; 4 | 5 | import PinnedTabsState from './state.js'; 6 | import { findPaneItem, findTabById, findTabByItem, isAncestor } from './util.js'; 7 | 8 | 9 | class PinnedTabs { 10 | 11 | /** 12 | * Create a new PinnedTabs with the following properties: 13 | * - state: The PinnedTabsState for the instance. 14 | * - subscriptions: An instace of Atom's CompositeDisposable. 15 | * - config: A configuration object for Atom. 16 | */ 17 | constructor() { 18 | this.state = new PinnedTabsState(); 19 | this.subscriptions = new CompositeDisposable(); 20 | 21 | // Object placeholder for when a PaneItem is moved between panes 22 | this.movedItem = { }; 23 | } 24 | 25 | // -- Atom -- // 26 | 27 | /** 28 | * Activate the pinned-tabs package. 29 | * 30 | * @param {Object} state [Optional] A previous state of pinned-tabs for the Atom workspace. 31 | */ 32 | activate(state={}) { 33 | if (state.deserializer === 'PinnedTabsState') { 34 | let oldState = atom.deserializers.deserialize(state); 35 | this.restoreState(oldState); 36 | } 37 | 38 | this.setCommands(); 39 | this.setObservers(); 40 | this.observeConfig(); 41 | 42 | // Add pin button to all initial tabs 43 | let tabs = document.querySelectorAll('.tab:not([data-type="TreeView"])'); 44 | tabs.forEach(this.addPinButtonTo.bind(this)); 45 | } 46 | 47 | /** 48 | * Deactivate the pinned-tabs package. 49 | */ 50 | deactivate() { 51 | this.subscriptions.dispose(); 52 | this.state.forEach(item => item.subscriptions.dispose()); 53 | 54 | // Remove 'pinned' class from tabs 55 | let pinnedTabs = document.querySelectorAll('.tab.pinned-tab'); 56 | pinnedTabs.forEach(tab => tab.classList.remove('pinned-tab')); 57 | 58 | // Remove settings classes 59 | let body = document.querySelector('body'); 60 | body.classList.remove('pinned-tabs-animated', 61 | 'pinned-tabs-modified-always', 62 | 'pinned-tabs-modified-hover', 63 | 'pinned-tabs-visualstudio'); 64 | 65 | // Remove pin button from all tabs 66 | let tabs = document.querySelectorAll('.tab'); 67 | tabs.forEach(this.removePinButtonFrom); 68 | } 69 | 70 | /** 71 | * Serialize the state of the PinnedTabs. 72 | * 73 | * @return {Object} The serialized version of pinned-tabs for the Atom workspace. 74 | */ 75 | serialize() { 76 | return this.state.serialize(); 77 | } 78 | 79 | // -- Activation -- // 80 | 81 | /** 82 | * Initialize the configuration of pinned-tabs. 83 | */ 84 | observeConfig() { 85 | let body = document.querySelector('body'); 86 | 87 | atom.config.observe('pinned-tabs.animated', enable => { 88 | body.classList.toggle('pinned-tabs-animated', enable); 89 | }); 90 | 91 | atom.config.observe('pinned-tabs.closeUnpinned', enable => { 92 | body.classList.toggle('close-unpinned', enable); 93 | }); 94 | 95 | atom.config.observe('pinned-tabs.pinButton', enable => { 96 | body.classList.toggle('pinned-tabs-pin-button', enable); 97 | }); 98 | 99 | atom.config.observe('pinned-tabs.modified', value => { 100 | switch (value) { 101 | case 'dont': 102 | body.classList.remove('pinned-tabs-modified-always'); 103 | body.classList.remove('pinned-tabs-modified-hover'); 104 | break; 105 | case 'hover': 106 | body.classList.remove('pinned-tabs-modified-always'); 107 | body.classList.add('pinned-tabs-modified-hover'); 108 | break; 109 | default: 110 | body.classList.add('pinned-tabs-modified-always'); 111 | body.classList.remove('pinned-tabs-modified-hover'); 112 | } 113 | }); 114 | 115 | atom.config.observe('pinned-tabs.visualstudio.enable', enable => { 116 | body.classList.toggle('pinned-tabs-visualstudio', enable); 117 | }); 118 | 119 | atom.config.observe('pinned-tabs.visualstudio.minimumWidth', newValue => { 120 | let pinnedTabs = document.querySelectorAll('.tab.pinned-tab'); 121 | pinnedTabs.forEach(tab => { tab.style.minWidth = `${newValue}px`; }); 122 | }); 123 | } 124 | 125 | /** 126 | * Restore the state of pinned-tabs. I.e. pin all tabs that should be pinned. 127 | * 128 | * @return {Promise} Resolving into the state when the state is restored. 129 | */ 130 | restoreState(state) { 131 | return new Promise(resolve => { 132 | // Timeout required for Atom to load the DOM 133 | setTimeout(() => { 134 | atom.workspace.getPanes().forEach(pane => { 135 | if (state[pane.id] === undefined) return; 136 | 137 | state[pane.id].forEach(stateItem => { 138 | let tab = findTabById(stateItem.id, pane); 139 | this.pin(tab, true); 140 | }); 141 | }); 142 | 143 | resolve(this.state); 144 | }); 145 | }); 146 | } 147 | 148 | /** 149 | * Initialize the commands for pinned-tabs. 150 | */ 151 | setCommands() { 152 | // Pin active command 153 | this.subscriptions.add( 154 | atom.commands.add( 155 | 'atom-workspace', 156 | 'pinned-tabs:pin-active', 157 | () => this.pinActive() 158 | ) 159 | ); 160 | 161 | // Pin selected command 162 | this.subscriptions.add( 163 | atom.commands.add( 164 | 'atom-workspace', 165 | 'pinned-tabs:pin-selected', 166 | { 167 | didDispatch: event => this.pin(event.target), 168 | hiddenInCommandPalette: true 169 | } 170 | ) 171 | ); 172 | 173 | // Close unpinned tabs command 174 | this.subscriptions.add( 175 | atom.commands.add( 176 | 'atom-workspace', 177 | 'pinned-tabs:close-unpinned', 178 | () => this.closeUnpinned() 179 | ) 180 | ); 181 | } 182 | 183 | /** 184 | * Initialize the observers for pinned-tabs. 185 | */ 186 | setObservers() { 187 | // Initialize a state for every new pane and observe moving items 188 | this.subscriptions.add( 189 | atom.workspace.observePanes(pane => { 190 | this.state.addPane(pane.id); 191 | 192 | // Reorder tabs, if needed, after moving a tab 193 | this.subscriptions.add( 194 | pane.onDidMoveItem(({item, newIndex}) => 195 | // Timeout needed for Atom to fully update its state 196 | setTimeout(() => this.reorderTab(pane, item, newIndex)) 197 | ) 198 | ); 199 | 200 | // Keep track of items being removed from the pane 201 | this.subscriptions.add( 202 | pane.onWillRemoveItem(({item}) => { 203 | let tab = findTabByItem(item, pane); 204 | if (tab !== undefined) { 205 | this.movedItem = { pane: pane, item: item, wasPinned: this.isPinned(tab) }; 206 | } 207 | }) 208 | ); 209 | 210 | // Check if items added to the pane should be moved or pinned 211 | this.subscriptions.add( 212 | pane.onDidAddItem(({item, index}) => 213 | // Timeout needed for Atom to fully update its state 214 | setTimeout(() => { 215 | if (this.movedItem.wasPinned) { 216 | let tab = findTabByItem(item, pane); 217 | this.pin(tab, true); 218 | 219 | // Remvoe the PaneItem from its previous Pane 220 | this.state.removePaneItem(this.movedItem.pane.id, this.movedItem.item); 221 | } else { 222 | // If the tab was not pinned in the previous 223 | // pane, move it after pinned tabs in the pane 224 | this.reorderTab(pane, item, index); 225 | } 226 | 227 | // Reset this.movedItem to avoid unexpected pinning the future 228 | this.movedItem = { }; 229 | }) 230 | ) 231 | ); 232 | 233 | // Add pin button to new tabs 234 | this.subscriptions.add( 235 | pane.onDidAddItem(({item}) => { 236 | let tab = findTabByItem(item, pane); 237 | this.addPinButtonTo(tab); 238 | }) 239 | ); 240 | }) 241 | ); 242 | 243 | // Delete the state of a pane when it is destroyed 244 | this.subscriptions.add( 245 | atom.workspace.onDidDestroyPane(({pane}) => this.state.removePane(pane.id)) 246 | ); 247 | 248 | // Remove a pinned item from the state when the tab is destroyed 249 | this.subscriptions.add( 250 | atom.workspace.onDidDestroyPaneItem(({item, pane}) => { 251 | this.state.removePaneItem(pane.id, item); 252 | 253 | // Reset this.movedItem to avoid unexpected pinning the future 254 | this.movedItem = { }; 255 | }) 256 | ); 257 | } 258 | 259 | // -- Pinning tabs -- // 260 | 261 | /** 262 | * Close all tabs that are not pinned within a pane in the Atom workspace. 263 | */ 264 | closeUnpinned() { 265 | let pane = atom.workspace.getActivePane(); 266 | let tabs = pane.element.querySelectorAll('.tab-bar .tab'); 267 | Array.from(tabs).map((tab, index) => this.isPinned(tab) ? null : pane.itemAtIndex(index)) 268 | .filter(item => item !== null) 269 | .forEach(item => pane.destroyItem(item)); 270 | } 271 | 272 | /** 273 | * Pin the active tab in the Atom workspace. 274 | */ 275 | pinActive() { 276 | let pane = atom.workspace.getActivePane(); 277 | let tab = pane.element.querySelector('.active'); 278 | if (tab !== null) this.pin(tab); 279 | } 280 | 281 | /** 282 | * Pin a tab in the Atom workspace. 283 | * 284 | * @param {Node} tab The DOM node of the tab to pin. 285 | * @param {Boolean} force Force the tab to be pinned (i.e. don't unpin if it was already pinned). 286 | */ 287 | pin(tab, force) { 288 | let pane = atom.workspace.getPanes().find(pane => isAncestor(pane.element, tab)); 289 | if (pane === undefined) return; 290 | 291 | let { item, itemIndex } = findPaneItem(tab, pane); 292 | if (item === undefined) return; 293 | 294 | let pinnedTabsCount = pane.element.querySelectorAll('.tab.pinned-tab').length; 295 | if (!this.isPinned(tab) || force) { 296 | if (itemIndex > pinnedTabsCount) { 297 | pane.moveItem(item, pinnedTabsCount); 298 | } 299 | 300 | let minimumWidth = atom.config.get('pinned-tabs.minimumWidth'); 301 | tab.classList.add('pinned-tab'); 302 | tab.style.minWidth = `${minimumWidth}px`; 303 | this.state.addPaneItem(pane.id, item); 304 | 305 | if (pane.getPendingItem() === item) { 306 | pane.saveItem(item); // Makes sure the item is not pending anymore 307 | } 308 | } else { 309 | pane.moveItem(item, pinnedTabsCount - 1); 310 | 311 | tab.style.minWidth = null; // Removes the min-width rule 312 | tab.classList.remove('pinned-tab'); 313 | this.state.removePaneItem(pane.id, item); 314 | } 315 | } 316 | 317 | // -- Utiliy -- // 318 | 319 | /** 320 | * Find out if a given tab is currently pinned. 321 | * 322 | * @param {Node} tab The tab of the item of interest. 323 | * @return {Boolean} An indication of whether the tab is pinned. 324 | */ 325 | isPinned(tab) { 326 | return tab.classList.contains('pinned-tab'); 327 | } 328 | 329 | /** 330 | * Reorder a tab within a Pane if necessary. 331 | * 332 | * @param {Pane} pane The Pane the `item` is in. 333 | * @param {Object} item The PaneItem to move. 334 | * @param {Number} newIndex The index the item is moved to. 335 | */ 336 | reorderTab(pane, item, newIndex) { 337 | let tab = findTabByItem(item, pane); 338 | if (tab === undefined) return; 339 | 340 | let pinnedTabsCount = pane.element.querySelectorAll('.tab.pinned-tab').length; 341 | if (this.isPinned(tab)) { 342 | if (newIndex > pinnedTabsCount - 1) { 343 | pane.moveItem(item, pinnedTabsCount - 1); 344 | newIndex = pinnedTabsCount - 1; 345 | } 346 | 347 | this.state.movePaneItem(pane.id, item, newIndex); 348 | } else { 349 | if (newIndex < pinnedTabsCount) { 350 | pane.moveItem(item, pinnedTabsCount); 351 | } 352 | } 353 | } 354 | 355 | // -- Pin button -- // 356 | 357 | /** 358 | * Add a button to pin the tab to a tab. 359 | * 360 | * @param {Node} tab The tab of the item of interest. 361 | */ 362 | addPinButtonTo(tab) { 363 | let pinButton = document.createElement('div'); 364 | pinButton.classList.add('pin-icon'); 365 | pinButton.addEventListener('click', () => this.pin(tab, true)); 366 | 367 | tab.appendChild(pinButton); 368 | } 369 | 370 | /** 371 | * Remove the button to pin the tab from a tab. 372 | * 373 | * @param {Node} tab The tab of the item of interest. 374 | */ 375 | removePinButtonFrom(tab) { 376 | let pinButton = tab.querySelector('.pin-icon'); 377 | if (pinButton !== null) tab.removeChild(pinButton); 378 | } 379 | 380 | } 381 | 382 | 383 | export default new PinnedTabs(); 384 | -------------------------------------------------------------------------------- /lib/state.js: -------------------------------------------------------------------------------- 1 | 'use babel'; 2 | 3 | import { CompositeDisposable } from 'atom'; 4 | 5 | import { getPaneItemId } from './util.js'; 6 | 7 | 8 | class PinnedTabsState { 9 | 10 | /** 11 | * Create a new PinnedTabsState. 12 | * 13 | * @param {Object} state [Optional] A serialized version of the PinnedTabsState. 14 | */ 15 | constructor(state={}) { 16 | this.indices = []; 17 | 18 | // Restore a previous state 19 | if (state.version === 1) { 20 | for (let key in state.data) { 21 | this.addPane(key, state.data[key]); 22 | } 23 | } 24 | } 25 | 26 | // -- Atom -- // 27 | 28 | /** 29 | * Serialize the PinnedTabsState. 30 | * 31 | * @return {Object} The serialized version of the PinnedTabsState. 32 | */ 33 | serialize() { 34 | let data = { }; 35 | this.indices.forEach(index => { 36 | data[index] = this[index].map(item => ({ id: item.id })); 37 | }); 38 | 39 | return { 40 | data: data, 41 | deserializer: 'PinnedTabsState', 42 | version: 1 43 | }; 44 | } 45 | 46 | /** 47 | * Deserialize a PinnedTabsState. 48 | * 49 | * @param {Object} serialized The serialized version of the PinnedTabsState. 50 | * @return {PinnedTabsState} The new PinnedTabsState. 51 | */ 52 | static deserialize(serialized) { 53 | return new PinnedTabsState(serialized); 54 | } 55 | 56 | // -- Management -- // 57 | 58 | /** 59 | * Add a new Pane to the PinnedTabsState. 60 | * 61 | * @param {Number} paneId The identifier of the the Pane to add. 62 | * @param {Array} state [Optional] An initial state for the Pane. 63 | */ 64 | addPane(paneId, state=[]) { 65 | if (this[paneId] === undefined || state.length !== 0) this[paneId] = state; 66 | if (!this.indices.includes(paneId)) this.indices.push(paneId); 67 | } 68 | 69 | /** 70 | * Add a new PaneItem to the PinnedTabsState. 71 | * 72 | * @param {Number} paneId The identifier of the the Pane the `item` is in. 73 | * @param {Object} item The PaneItem to add. 74 | */ 75 | addPaneItem(paneId, item) { 76 | this.addPane(paneId); 77 | 78 | let itemId = getPaneItemId(item); 79 | let index = this[paneId].findIndex(item => item.id === itemId); 80 | if (index === -1) { 81 | let stateItem = { id: itemId, subscriptions: new CompositeDisposable() }; 82 | 83 | // Update the state when the title or path changes 84 | if (item.onDidChangeTitle !== undefined) { 85 | stateItem.subscriptions.add( 86 | item.onDidChangeTitle(() => { 87 | let index = this[paneId].findIndex(item => item.id === itemId); 88 | if (index >= 0) { 89 | setTimeout(() => { 90 | this[paneId][index].id = getPaneItemId(item); 91 | }); 92 | } 93 | }) 94 | ); 95 | } 96 | 97 | this[paneId].push(stateItem); 98 | } 99 | } 100 | 101 | /** 102 | * Iterate over each PaneItem (e.g. TextEditor) in the state. 103 | * 104 | * @param {Function} callback Function to execute for each element, taking three arguments: 105 | * - currentValue: The value of the current element being processed in the array. 106 | * - index: The index of the current element being processed in the array. 107 | * - array: The array that forEach() is being applied to. 108 | * @param {Boolean} thisArg Value to use as this when executing callback. 109 | */ 110 | forEach(callback, thisArg) { 111 | for (let i = 0; i < this.indices.length; i++) { 112 | let index = this.indices[i]; 113 | this[index].forEach(callback, thisArg); 114 | } 115 | } 116 | 117 | /** 118 | * Update state when a (pinned) PaneItem (e.g. TextEditor) moved. 119 | * 120 | * @param {Number} paneId The identifier of the the Pane the `item` is in. 121 | * @param {Object} item The PaneItem that is moved. 122 | * @param {Number} newIndex The new index of the `item`. 123 | */ 124 | movePaneItem(paneId, item, newIndex) { 125 | let itemId = getPaneItemId(item); 126 | let index = this[paneId].findIndex(item => item.id === itemId); 127 | if (index === newIndex) return; // No change needed if the index didn't change 128 | 129 | // Remove the stateItem of the moved item 130 | let stateItem = this[paneId].splice(index, 1)[0]; 131 | 132 | // And insert it back at the new index 133 | this[paneId].splice(newIndex, 0, stateItem); 134 | } 135 | 136 | /** 137 | * Remove a Pane from the PinnedTabsState. 138 | * 139 | * @param {Number} paneId The identifier of the Pane to remove. 140 | */ 141 | removePane(paneId) { 142 | if (this[paneId] !== undefined) delete this[paneId]; 143 | let index = this.indices.indexOf(paneId); 144 | if (index >= 0) this.indices.splice(index, 1); 145 | } 146 | 147 | /** 148 | * Remove a PaneItem from the PinnedTabsState. 149 | * 150 | * @param {Number} paneId The identifier of the Pane the `item` is in. 151 | * @param {Object} item The PaneItem to remove. 152 | */ 153 | removePaneItem(paneId, item) { 154 | if (!this[paneId]) return; 155 | 156 | let itemId = getPaneItemId(item); 157 | let index = this[paneId].findIndex(item => item.id === itemId); 158 | if (index >= 0) { 159 | this[paneId][index].subscriptions.dispose(); 160 | this[paneId].splice(index, 1); 161 | } 162 | } 163 | 164 | } 165 | 166 | 167 | atom.deserializers.add(PinnedTabsState); 168 | export default PinnedTabsState; 169 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | 'use babel'; 2 | 3 | /** 4 | * Find the PaneItem (e.g. TextEditor) given its tab Node. 5 | * 6 | * @param {Node} tab The Node of the tab of interested. 7 | * @param {Pane} pane The pane that is the parent of `tab`. 8 | * @return {Object} An object with two properties: 9 | * - item: The PaneItem (e.g. TextEditor). 10 | * - itemIndex: The index of `item` in `pane`. 11 | */ 12 | function findPaneItem(tab, pane) { 13 | let tabs = pane.element.querySelectorAll('.tab-bar .tab'); 14 | let items = Array.from(tabs).map((el, index) => el === tab ? pane.itemAtIndex(index) : null); 15 | return { 16 | item: items.find(item => item !== null), 17 | itemIndex: items.findIndex(item => item !== null) 18 | }; 19 | } 20 | 21 | /** 22 | * Find the tab Node given the identifier of its PaneItem. 23 | * 24 | * @param {String} id The identifier of the PaneItem of the tab. 25 | * @param {Pane} pane The pane in which the PaneItem can be found. 26 | * @return {Node} The tab node of for the identifier. 27 | */ 28 | function findTabById(id, pane) { 29 | let index = pane.getItems().findIndex(item => getPaneItemId(item) === id); 30 | let tabs = pane.element.querySelectorAll('.tab-bar .tab'); 31 | return tabs[index]; 32 | } 33 | 34 | /** 35 | * Find the tab Node given a PaneItem. 36 | * 37 | * @param {Object} item The PaneItem of the tab. 38 | * @param {Pane} pane The pane in which the PaneItem can be found. 39 | * @return {Node} The tab node of for the identifier. 40 | */ 41 | function findTabByItem(item, pane) { 42 | let id = getPaneItemId(item); 43 | return findTabById(id, pane); 44 | } 45 | 46 | /** 47 | * Get the identifier of a PaneItem. 48 | * 49 | * @param {Object} item The PaneItem of interest. 50 | * @return {String} The identifier for `PaneItem`. 51 | */ 52 | function getPaneItemId(item) { 53 | if (item.getURI && item.getURI()) { 54 | return item.getURI(); 55 | } else if (item.id) { 56 | return item.id; 57 | } else { 58 | return item.getTitle(); 59 | } 60 | } 61 | 62 | /** 63 | * Find out if an element is the ancestor of another element. 64 | * 65 | * @param {Node} ancestor The element that is expected to be the ancestor. 66 | * @param {Node} descendant The element that is expected to be the descendant. 67 | * @return {Boolean} An indication of whether `ancestor` is an ancestor of `descendant`. 68 | */ 69 | function isAncestor(ancestor, descendant) { 70 | let found = false; 71 | while (descendant) { 72 | descendant = descendant.parentNode; 73 | if (descendant === ancestor) { 74 | found = true; 75 | break; 76 | } 77 | } 78 | 79 | return found; 80 | } 81 | 82 | 83 | export { findPaneItem, findTabById, findTabByItem, getPaneItemId, isAncestor }; 84 | -------------------------------------------------------------------------------- /menus/pinned-tabs.json: -------------------------------------------------------------------------------- 1 | { 2 | "context-menu": { 3 | ".tab:not(.pinned-tab):not([data-type=\"TreeView\"]):not([data-type=\"PanelDock\"])": [{ 4 | "label": "Pin tab", 5 | "command": "pinned-tabs:pin-selected" 6 | }], 7 | ".tab.pinned-tab": [{ 8 | "label": "Unpin tab", 9 | "command": "pinned-tabs:pin-selected" 10 | }], 11 | "body.close-unpinned .tab:not([data-type=\"TreeView\"]):not([data-type=\"PanelDock\"])": [{ 12 | "label": "Close Unpinned Tabs", 13 | "command": "pinned-tabs:close-unpinned" 14 | }] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pinned-tabs", 3 | "version": "2.3.1", 4 | "description": "An Atom package that allows you to pin tabs", 5 | "license": "MIT", 6 | "main": "./lib/pinned-tabs", 7 | "repository": "git@github.com:ericcornelissen/pinned-tabs-for-atom.git", 8 | "keywords": [ 9 | "pinned", 10 | "pin", 11 | "tabs" 12 | ], 13 | "engines": { 14 | "atom": ">=1.0.0 <2.0.0" 15 | }, 16 | "devDependencies": { 17 | "atom-mocha": "^2.2.1", 18 | "eslint": "^6.8.0", 19 | "husky": "^4.2.3", 20 | "rimraf": "^2.6.2", 21 | "sinon": "^6.3.5", 22 | "sinon-chai": "^3.5.0", 23 | "stylelint": "^13.2.0", 24 | "stylelint-config-standard": "^20.0.0", 25 | "stylelint-order": "^4.0.0" 26 | }, 27 | "scripts": { 28 | "lint": "eslint lib/*.js spec/*.js && stylelint styles/*.less", 29 | "test": "atom --test spec" 30 | }, 31 | "atomTestRunner": "atom-mocha", 32 | "configSchema": { 33 | "animated": { 34 | "type": "boolean", 35 | "default": true, 36 | "order": 1, 37 | "title": "Enable animations", 38 | "description": "Tick this to enable all animation related to pinned tabs" 39 | }, 40 | "closeUnpinned": { 41 | "type": "boolean", 42 | "default": false, 43 | "order": 2, 44 | "title": "Enable the 'Close Unpinned Tabs' option", 45 | "description": "Tick this to show the 'Close Unpinned Tabs' from the context menu" 46 | }, 47 | "pinButton": { 48 | "type": "boolean", 49 | "default": false, 50 | "order": 3, 51 | "title": "Enable pin button on tabs", 52 | "description": "Tick this to add a button to unpinned tabs to pin them" 53 | }, 54 | "modified": { 55 | "type": "string", 56 | "default": "always", 57 | "order": 4, 58 | "title": "Modified indicator", 59 | "enum": [ 60 | { 61 | "value": "dont", 62 | "description": "Don't use this feature" 63 | }, 64 | { 65 | "value": "hover", 66 | "description": "Only show this when I hover over the tab" 67 | }, 68 | { 69 | "value": "always", 70 | "description": "Always show this when a tab is modified" 71 | } 72 | ] 73 | }, 74 | "visualstudio": { 75 | "type": "object", 76 | "title": "Visual Studio style pinning", 77 | "description": "When _Visual Studio style pinning_ is enabled pinned tabs wno't shrink. Instead, the closing button is replaced by an indicator that the tab is now pinned.", 78 | "order": 5, 79 | "properties": { 80 | "enable": { 81 | "type": "boolean", 82 | "default": false, 83 | "order": 1, 84 | "title": "Enable VS style pinning" 85 | }, 86 | "minimumWidth": { 87 | "type": "integer", 88 | "default": 180, 89 | "minimum": 0, 90 | "order": 2, 91 | "title": "Minimum width for pinned tabs" 92 | } 93 | } 94 | } 95 | }, 96 | "activationHooks": [ 97 | "core:loaded-shell-environment" 98 | ] 99 | } 100 | -------------------------------------------------------------------------------- /spec/fixtures/chicken.md: -------------------------------------------------------------------------------- 1 | # Chicken Chicken Chicken: Chicken Chicken 2 | 3 | # Chicken 4 | Chicken chicken chicken chicken chicken chicken chicken chicken 5 | chicken chicken chicken chicken chicken chicken chicken chicken 6 | chicken. Chicken chicken chicken chicken chicken chicken chicken 7 | chicken. Chicken, chicken chicken chicken, chicken chicken, chicken 8 | chicken chicken “chicken chicken” chicken “chicken chicken” 9 | chicken. Chicken, chicken chicken chicken chicken chicken chicken 10 | (chicken chicken) chicken chicken chicken chicken chicken, 11 | chicken chicken chicken chicken chicken chicken chicken chicken 12 | chicken chicken chicken chicken chicken. Chicken chicken chicken 13 | chicken chicken chicken chicken chicken chicken, chicken chicken 14 | chicken, chicken chicken chicken chicken chicken chicken chicken. 15 | -------------------------------------------------------------------------------- /spec/fixtures/lorem.txt: -------------------------------------------------------------------------------- 1 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur ultricies nulla id nibh aliquam, vitae euismod ipsum scelerisque. Vestibulum vulputate facilisis nisi, eu rhoncus turpis pretium ut. Curabitur facilisis urna in diam efficitur, vel maximus tellus consectetur. Suspendisse pulvinar felis sed metus tristique, a posuere dui suscipit. Ut vehicula, tellus ac blandit consequat, libero dui hendrerit elit, non pretium metus odio sed dolor. Vivamus quis volutpat ipsum. In convallis magna nec nunc tristique malesuada. Sed sed hendrerit lacus. Etiam arcu dui, consequat vel neque vitae, iaculis egestas justo. Donec lacinia odio nulla, condimentum porta erat accumsan at. Nunc vulputate nulla vel nunc fermentum egestas. 2 | Duis ultricies libero elit, nec facilisis mi rhoncus ornare. Aliquam aliquet libero vitae arcu porttitor mattis. Vestibulum ultricies consectetur arcu, non gravida magna eleifend vel. Phasellus varius mattis ultricies. Vestibulum placerat lacus non consectetur fringilla. Duis congue, arcu iaculis vehicula hendrerit, purus odio faucibus ipsum, et fermentum massa tellus euismod nulla. Vivamus pellentesque blandit massa, sit amet hendrerit turpis congue eu. Suspendisse diam dui, vestibulum nec semper varius, maximus eu nunc. Vivamus facilisis pulvinar viverra. Praesent luctus lectus id est porttitor volutpat. Suspendisse est augue, mattis a tincidunt id, condimentum in turpis. Curabitur at erat commodo orci interdum tincidunt. Sed sodales elit odio, a placerat ipsum luctus nec. Sed maximus, justo ut pharetra pellentesque, orci mi faucibus enim, quis viverra arcu dui sed nisl. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Praesent quis velit libero. 3 | Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Phasellus a rutrum tortor. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Fusce bibendum odio et neque vestibulum rutrum. Vestibulum commodo, nibh non sodales lobortis, dui ex consectetur leo, a finibus libero lectus ac diam. Etiam dui nunc, bibendum a tempor vel, vestibulum lacinia neque. Mauris consectetur odio sit amet maximus pretium. Sed rutrum nunc at ante ullamcorper fermentum. Proin at quam a mauris pellentesque viverra. Nunc pretium pulvinar ipsum. Vestibulum eu nibh ut ex gravida tempus. Praesent ut elit ut ligula tristique dapibus ut sit amet leo. Proin non molestie erat. 4 | -------------------------------------------------------------------------------- /spec/pinned-tabs-spec.js: -------------------------------------------------------------------------------- 1 | 'use babel'; 2 | 3 | import { CompositeDisposable } from 'atom'; 4 | import fs from 'fs'; 5 | import * as path from 'path'; 6 | import { match as matchers, spy, stub } from 'sinon'; 7 | import sinonChai from 'sinon-chai'; 8 | 9 | import { simulateClick, sleep } from './utils.js'; 10 | 11 | import PinnedTabs from '../lib/pinned-tabs.js'; 12 | import PinnedTabsState from '../lib/state.js'; 13 | 14 | 15 | Chai.use(sinonChai); 16 | 17 | describe('PinnedTabs', () => { 18 | 19 | const outputPath = path.resolve(__dirname, './temporary-file'); 20 | let workspaceElement; 21 | 22 | before(async () => { 23 | // The "tabs" package is required for pinned-tabs 24 | await atom.packages.activatePackage('tabs'); 25 | 26 | // The "settings-view" package is used for testing purposes 27 | await atom.packages.activatePackage('settings-view'); 28 | }); 29 | 30 | beforeEach(async () => { 31 | // Attach the workspace to the DOM 32 | workspaceElement = atom.views.getView(atom.workspace); 33 | attachToDOM(workspaceElement); 34 | 35 | // Make sure the state is clean 36 | PinnedTabs.state = new PinnedTabsState(); 37 | }); 38 | 39 | it('has a "state" variable', () => { 40 | expect(PinnedTabs.state).to.exist; 41 | }); 42 | 43 | describe('::activate()', () => { 44 | 45 | it('initializes the package commands', () => { 46 | spy(PinnedTabs, 'setCommands'); 47 | 48 | PinnedTabs.activate(); 49 | expect(PinnedTabs.setCommands).to.have.been.called; 50 | 51 | PinnedTabs.setCommands.restore(); 52 | }); 53 | 54 | it('initializes the package configuration', () => { 55 | spy(PinnedTabs, 'observeConfig'); 56 | 57 | PinnedTabs.activate(); 58 | expect(PinnedTabs.observeConfig).to.have.been.called; 59 | 60 | PinnedTabs.observeConfig.restore(); 61 | }); 62 | 63 | it('initializes the package observers', () => { 64 | spy(PinnedTabs, 'setObservers'); 65 | 66 | PinnedTabs.activate(); 67 | expect(PinnedTabs.setObservers).to.have.been.called; 68 | 69 | PinnedTabs.setObservers.restore(); 70 | }); 71 | 72 | it('does not restore a state without a previous state', () => { 73 | spy(PinnedTabs, 'restoreState'); 74 | 75 | PinnedTabs.activate(); 76 | expect(PinnedTabs.restoreState).not.to.have.been.called; 77 | 78 | PinnedTabs.restoreState.restore(); 79 | }); 80 | 81 | it('restores the state from a previous state', () => { 82 | spy(PinnedTabs, 'restoreState'); 83 | 84 | let state = new PinnedTabsState(); 85 | PinnedTabs.activate(state.serialize()); 86 | expect(PinnedTabs.restoreState).to.have.been.called; 87 | 88 | PinnedTabs.restoreState.restore(); 89 | }); 90 | 91 | it('adds the pin button to all tabs', async () => { 92 | const chickenFileName = 'chicken.md'; 93 | const chickenFilePath = path.resolve(__dirname, './fixtures', chickenFileName); 94 | await atom.workspace.open(chickenFilePath); 95 | 96 | spy(PinnedTabs, 'addPinButtonTo'); 97 | PinnedTabs.activate(); 98 | 99 | expect(PinnedTabs.addPinButtonTo).to.have.been.called; 100 | 101 | PinnedTabs.addPinButtonTo.restore(); 102 | }); 103 | 104 | }); 105 | 106 | describe('::deactivate()', () => { 107 | 108 | const fileName = 'chicken.md'; 109 | const filePath = path.resolve(__dirname, './fixtures', fileName); 110 | 111 | let itemId, paneId; 112 | 113 | beforeEach(async () => { 114 | let editor = await atom.workspace.open(filePath); 115 | itemId = editor.getURI(); 116 | paneId = atom.workspace.getPanes().find(pane => pane.getItems().includes(editor)).id; 117 | }); 118 | 119 | it('disposes subscriptions', () => { 120 | spy(PinnedTabs.subscriptions, 'dispose'); 121 | 122 | let itemSubscriptions = new CompositeDisposable(); 123 | spy(itemSubscriptions, 'dispose'); 124 | PinnedTabs.state.addPane(paneId, [{ id: itemId, subscriptions: itemSubscriptions }]); 125 | 126 | PinnedTabs.deactivate(); 127 | expect(PinnedTabs.subscriptions.dispose).to.have.been.called; 128 | expect(itemSubscriptions.dispose).to.have.been.called; 129 | 130 | PinnedTabs.subscriptions.dispose.restore(); 131 | }); 132 | 133 | it('removes all \'.pinned-tab\' classes', () => { 134 | let tab = workspaceElement.querySelector(`.tab .title[data-name="${fileName}"]`).parentNode; 135 | tab.classList.add('pinned-tab'); 136 | 137 | PinnedTabs.deactivate(); 138 | expect(tab.classList.contains('pinned-tab')).to.be.false; 139 | }); 140 | 141 | it('removes all configuration classes', () => { 142 | let body = document.querySelector('body'); 143 | body.classList.add('pinned-tabs-animated'); 144 | body.classList.add('pinned-tabs-modified-always'); 145 | body.classList.add('pinned-tabs-modified-hover'); 146 | body.classList.add('pinned-tabs-visualstudio'); 147 | 148 | PinnedTabs.deactivate(); 149 | expect(body.classList.contains('pinned-tabs-animated')).to.be.false; 150 | expect(body.classList.contains('pinned-tabs-modified-always')).to.be.false; 151 | expect(body.classList.contains('pinned-tabs-modified-hover')).to.be.false; 152 | expect(body.classList.contains('pinned-tabs-visualstudio')).to.be.false; 153 | }); 154 | 155 | it('removes the pin button from all tabs', async () => { 156 | const chickenFileName = 'chicken.md'; 157 | const chickenFilePath = path.resolve(__dirname, './fixtures', chickenFileName); 158 | await atom.workspace.open(chickenFilePath); 159 | 160 | spy(PinnedTabs, 'removePinButtonFrom'); 161 | PinnedTabs.deactivate(); 162 | 163 | expect(PinnedTabs.removePinButtonFrom).to.have.been.called; 164 | 165 | PinnedTabs.removePinButtonFrom.restore(); 166 | }); 167 | 168 | }); 169 | 170 | describe('::serialize()', () => { 171 | 172 | it('serializes the state', () => { 173 | spy(PinnedTabs.state, 'serialize'); 174 | 175 | PinnedTabs.serialize(); 176 | expect(PinnedTabs.state.serialize).to.have.been.called; 177 | 178 | PinnedTabs.state.serialize.restore(); 179 | }); 180 | 181 | }); 182 | 183 | describe('::observeConfig()', () => { 184 | 185 | beforeEach(() => { 186 | spy(atom.config, 'observe'); 187 | }); 188 | 189 | it('observes the "animated" configuration variable', () => { 190 | PinnedTabs.observeConfig(); 191 | expect(atom.config.observe).to.have.been.calledWith('pinned-tabs.animated', matchers.func); 192 | }); 193 | 194 | it('observes the "closeUnpinned" configuration variable', () => { 195 | PinnedTabs.observeConfig(); 196 | expect(atom.config.observe).to.have.been.calledWith('pinned-tabs.closeUnpinned', matchers.func); 197 | }); 198 | 199 | it('observes the "modified" configuration variable', () => { 200 | PinnedTabs.observeConfig(); 201 | expect(atom.config.observe).to.have.been.calledWith('pinned-tabs.modified', matchers.func); 202 | }); 203 | 204 | it('observes the "visualStudio" configuration variable', () => { 205 | PinnedTabs.observeConfig(); 206 | expect(atom.config.observe).to.have.been.calledWith('pinned-tabs.visualstudio.enable', matchers.func); 207 | }); 208 | 209 | it('observes the "minimumWidth" configuration variable', () => { 210 | PinnedTabs.observeConfig(); 211 | expect(atom.config.observe).to.have.been.calledWith('pinned-tabs.visualstudio.minimumWidth', matchers.func); 212 | }); 213 | 214 | afterEach(() => { 215 | atom.config.observe.restore(); 216 | }); 217 | 218 | }); 219 | 220 | describe('::restoreState()', () => { 221 | 222 | const chickenFileName = 'chicken.md'; 223 | const loremFileName = 'lorem.txt'; 224 | const chickenFilePath = path.resolve(__dirname, './fixtures', chickenFileName); 225 | const loremFilePath = path.resolve(__dirname, './fixtures', loremFileName); 226 | 227 | let chickenId, paneId, state; 228 | 229 | beforeEach(async () => { 230 | spy(PinnedTabs, 'pin'); 231 | 232 | // Initialize a state to work with 233 | state = new PinnedTabsState(); 234 | 235 | // Open two files in the workspace to work with 236 | let editor = await atom.workspace.open(chickenFilePath); 237 | chickenId = editor.getURI(); 238 | paneId = atom.workspace.getPanes().find(pane => pane.getItems().includes(editor)).id; 239 | 240 | await atom.workspace.open(loremFilePath); 241 | }); 242 | 243 | it('does nothing when the state specifies no tabs', async () => { 244 | await PinnedTabs.restoreState(state); 245 | expect(PinnedTabs.pin).not.to.have.been.called; 246 | }); 247 | 248 | it('pins tabs that are specified in the state', async () => { 249 | state[paneId] = [{ id: chickenId }]; 250 | await PinnedTabs.restoreState(state); 251 | let tab = workspaceElement.querySelector(`.tab .title[data-name="${chickenFileName}"]`).parentNode; 252 | expect(PinnedTabs.pin).to.have.been.calledWith(tab, true); 253 | }); 254 | 255 | afterEach(() => { 256 | PinnedTabs.pin.restore(); 257 | }); 258 | 259 | }); 260 | 261 | describe('::setCommands()', () => { 262 | 263 | it('initalizes the "pinned-tabs:pin-active" command', () => { 264 | spy(PinnedTabs, 'pinActive'); 265 | PinnedTabs.setCommands(); 266 | 267 | let workspace = document.createElement('atom-workspace'); 268 | atom.commands.dispatch(workspace, 'pinned-tabs:pin-active'); 269 | 270 | expect(PinnedTabs.pinActive).to.have.been.called; 271 | 272 | PinnedTabs.pinActive.restore(); 273 | }); 274 | 275 | it('initalizes the "pinned-tabs:pin-selected" command', () => { 276 | spy(PinnedTabs, 'pin'); 277 | PinnedTabs.setCommands(); 278 | 279 | let workspace = document.createElement('atom-workspace'); 280 | atom.commands.dispatch(workspace, 'pinned-tabs:pin-selected'); 281 | 282 | expect(PinnedTabs.pin).to.have.been.called; 283 | 284 | PinnedTabs.pin.restore(); 285 | }); 286 | 287 | it('initalizes the "pinned-tabs:close-unpinned" command', () => { 288 | spy(PinnedTabs, 'closeUnpinned'); 289 | PinnedTabs.setCommands(); 290 | 291 | let workspace = document.createElement('atom-workspace'); 292 | atom.commands.dispatch(workspace, 'pinned-tabs:close-unpinned'); 293 | 294 | expect(PinnedTabs.closeUnpinned).to.have.been.called; 295 | 296 | PinnedTabs.closeUnpinned.restore(); 297 | }); 298 | 299 | }); 300 | 301 | describe('::setObservers()', () => { 302 | 303 | it('should start observing opening new Panes', () => { 304 | stub(atom.workspace, 'observePanes').returns(new CompositeDisposable()); 305 | 306 | PinnedTabs.setObservers(); 307 | expect(atom.workspace.observePanes).to.have.been.called; 308 | 309 | atom.workspace.observePanes.restore(); 310 | }); 311 | 312 | it('should start observing destroying Panes', () => { 313 | stub(atom.workspace, 'onDidDestroyPane').returns(new CompositeDisposable()); 314 | 315 | PinnedTabs.setObservers(); 316 | expect(atom.workspace.onDidDestroyPane).to.have.been.called; 317 | 318 | atom.workspace.onDidDestroyPane.restore(); 319 | }); 320 | 321 | it('should start observing removing PaneItems', () => { 322 | stub(atom.workspace, 'onDidDestroyPaneItem').returns(new CompositeDisposable()); 323 | 324 | PinnedTabs.setObservers(); 325 | expect(atom.workspace.onDidDestroyPaneItem).to.have.been.called; 326 | 327 | atom.workspace.onDidDestroyPaneItem.restore(); 328 | }); 329 | 330 | }); 331 | 332 | describe('::closeUnpinned()', () => { 333 | 334 | const chickenFileName = 'chicken.md'; 335 | const loremFileName = 'lorem.txt'; 336 | const chickenFilePath = path.resolve(__dirname, './fixtures', chickenFileName); 337 | const loremFilePath = path.resolve(__dirname, './fixtures', loremFileName); 338 | 339 | beforeEach(async () => { 340 | await atom.workspace.open(chickenFilePath); 341 | await atom.workspace.open(loremFilePath); 342 | }); 343 | 344 | it('closes all unpinned tabs', () => { 345 | let pane = atom.workspace.getActivePane(); 346 | 347 | PinnedTabs.closeUnpinned(); 348 | expect(pane.getItems().length).to.equal(0); 349 | }); 350 | 351 | it('doesn\'t close pinned tabs', () => { 352 | let pane = atom.workspace.getActivePane(); 353 | 354 | let tab = workspaceElement.querySelector(`.tab .title[data-name="${chickenFileName}"]`).parentNode; 355 | tab.classList.add('pinned-tab'); 356 | 357 | PinnedTabs.closeUnpinned(); 358 | expect(pane.getItems().length).to.equal(1); 359 | }); 360 | 361 | }); 362 | 363 | describe('::pinActive()', () => { 364 | 365 | const fileName = 'lorem.txt'; 366 | const filePath = path.resolve(__dirname, './fixtures', fileName); 367 | 368 | beforeEach(async () => { 369 | await atom.workspace.open(filePath); 370 | }); 371 | 372 | it('calls ::pin() with the active item', () => { 373 | stub(PinnedTabs, 'pin'); 374 | 375 | let tab = workspaceElement.querySelector(`.tab .title[data-name="${fileName}"]`).parentNode; 376 | PinnedTabs.pinActive(); 377 | expect(PinnedTabs.pin).to.have.been.calledWith(tab); 378 | 379 | PinnedTabs.pin.restore(); 380 | }); 381 | 382 | }); 383 | 384 | describe('::pin()', () => { 385 | 386 | const chickenFileName = 'chicken.md'; 387 | const loremFileName = 'lorem.txt'; 388 | const chickenFilePath = path.resolve(__dirname, './fixtures', chickenFileName); 389 | const loremFilePath = path.resolve(__dirname, './fixtures', loremFileName); 390 | 391 | let itemEditor, itemId, itemPane; 392 | 393 | beforeEach(async () => { 394 | PinnedTabs.activate(); 395 | 396 | // Open a file in the workspace to work with 397 | let editor = await atom.workspace.open(chickenFilePath); 398 | itemEditor = editor; 399 | itemId = editor.getURI(); 400 | itemPane = atom.workspace.getPanes().find(pane => pane.getItems().includes(editor)); 401 | 402 | await atom.workspace.open(loremFilePath); 403 | }); 404 | 405 | it('pins an unpinned TextEditor', () => { 406 | let tab = workspaceElement.querySelector(`.tab .title[data-name="${chickenFileName}"]`).parentNode; 407 | PinnedTabs.pin(tab); 408 | expect(tab.classList.contains('pinned-tab')).to.be.true; 409 | }); 410 | 411 | it('unpins a pinned TextEditor', () => { 412 | let subscriptions = new CompositeDisposable(); 413 | spy(subscriptions, 'dispose'); 414 | 415 | PinnedTabs.state[itemPane.id] = [ 416 | { id: itemId, subscriptions: subscriptions } 417 | ]; 418 | 419 | let tab = workspaceElement.querySelector(`.tab .title[data-name="${chickenFileName}"]`).parentNode; 420 | tab.classList.add('pinned-tab'); 421 | 422 | PinnedTabs.pin(tab); 423 | expect(tab.classList.contains('pinned-tab')).to.be.false; 424 | expect(subscriptions.dispose).to.have.been.called; 425 | }); 426 | 427 | it('updates the state when a tab is pinned', () => { 428 | let tab = workspaceElement.querySelector(`.tab .title[data-name="${chickenFileName}"]`).parentNode; 429 | PinnedTabs.pin(tab); 430 | expect(PinnedTabs.state[itemPane.id][0].id).to.contain('chicken.md'); 431 | }); 432 | 433 | it('updates the state when a tab is unpinned', () => { 434 | PinnedTabs.state[itemPane.id] = [ 435 | { id: itemId, subscriptions: new CompositeDisposable() } 436 | ]; 437 | 438 | let tab = workspaceElement.querySelector(`.tab .title[data-name="${chickenFileName}"]`).parentNode; 439 | tab.classList.add('pinned-tab'); 440 | 441 | PinnedTabs.pin(tab); 442 | expect(PinnedTabs.state[itemPane.id].length).to.equal(0); 443 | }); 444 | 445 | it('is possible to pin new (unsaved) editors', async () => { 446 | await atom.workspace.open(''); 447 | let tab = workspaceElement.querySelector('.tab .title:not([data-name])').parentNode; 448 | PinnedTabs.pin(tab); 449 | expect(tab.classList.contains('pinned-tab')).to.be.true; 450 | }); 451 | 452 | it('is possible to pin multiple new (unsaved) editors', async () => { 453 | spy(PinnedTabs.state, 'movePaneItem'); 454 | 455 | let item0 = await atom.workspace.open(''); 456 | let item1 = await atom.workspace.open(''); 457 | 458 | let tabs = workspaceElement.querySelectorAll('.tab .title:not([data-name])'); 459 | expect(tabs).to.have.lengthOf(2); 460 | 461 | let tab0 = tabs[0].parentNode; 462 | let tab1 = tabs[1].parentNode; 463 | 464 | PinnedTabs.pin(tab0); 465 | expect(tab0.classList.contains('pinned-tab')).to.be.true; 466 | 467 | await sleep(10); 468 | 469 | PinnedTabs.pin(tab1); 470 | expect(tab0.classList.contains('pinned-tab')).to.be.true; 471 | 472 | await sleep(10); 473 | PinnedTabs.state.movePaneItem.resetHistory(); 474 | 475 | PinnedTabs.pin(tab0); 476 | expect(tab0.classList.contains('pinned-tab')).to.be.false; 477 | 478 | await sleep(100); 479 | expect(PinnedTabs.state.movePaneItem).not.to.have.been.calledWith(matchers.any, item0, matchers.any); 480 | expect(PinnedTabs.state.movePaneItem).not.to.have.been.calledWith(matchers.any, item1, matchers.any); 481 | 482 | PinnedTabs.state.movePaneItem.restore(); 483 | }); 484 | 485 | it('pins the settings tab', function(done) { 486 | this.timeout(5000); // Opening the settings view can take some time 487 | 488 | atom.commands.dispatch(workspaceElement, 'settings-view:open'); 489 | 490 | // Opening the settings view takes some time 491 | setTimeout(() => { 492 | let tab = workspaceElement.querySelector('.tab[data-type="SettingsView"]'); 493 | PinnedTabs.pin(tab); 494 | expect(tab.classList.contains('pinned-tab')).to.be.true; 495 | done(); 496 | }); 497 | }); 498 | 499 | // it('pins the about tab'); // Opens in a separate window, therefor hard to test 500 | 501 | it('calls ::onDidChangeTitle() when a pinned tab\'s name is changed', async () => { 502 | stub(itemEditor, 'onDidChangeTitle').returns(new CompositeDisposable()); 503 | 504 | let tab = workspaceElement.querySelector(`.tab .title[data-name="${chickenFileName}"]`).parentNode; 505 | PinnedTabs.pin(tab); 506 | 507 | await itemEditor.saveAs(outputPath); 508 | expect(itemEditor.onDidChangeTitle).to.have.been.called; 509 | }); 510 | 511 | it('updates the state when a pinned tab\'s name is changed', done => { 512 | let tab = workspaceElement.querySelector(`.tab .title[data-name="${loremFileName}"]`).parentNode; 513 | PinnedTabs.pin(tab); 514 | 515 | itemEditor.saveAs(outputPath); 516 | setTimeout(() => { 517 | expect(PinnedTabs.state[itemPane.id][0].id).not.to.equal(itemId); // The new path depends on the system 518 | done(); 519 | }); 520 | }); 521 | 522 | it('updates the state if a pinned tab is closed', async () => { 523 | let tab = workspaceElement.querySelector(`.tab .title[data-name="${chickenFileName}"]`).parentNode; 524 | PinnedTabs.pin(tab); 525 | 526 | await itemPane.destroyItem(itemEditor, true); 527 | expect(PinnedTabs.state[itemPane.id].length).to.equal(0); 528 | }); 529 | 530 | }); 531 | 532 | describe('::isPinned()', () => { 533 | 534 | it('returns true if the tab is pinned', () => { 535 | let tab = document.createElement('li'); 536 | tab.classList.add('pinned-tab'); 537 | expect(PinnedTabs.isPinned(tab)).to.be.true; 538 | }); 539 | 540 | it('returns false if the tab isn\'t pinned', () => { 541 | let tab = document.createElement('li'); 542 | expect(PinnedTabs.isPinned(tab)).to.be.false; 543 | }); 544 | 545 | }); 546 | 547 | describe('::reorderTab()', () => { 548 | 549 | const chickenFileName = 'chicken.md'; 550 | const loremFileName = 'lorem.txt'; 551 | const chickenFilePath = path.resolve(__dirname, './fixtures', chickenFileName); 552 | const loremFilePath = path.resolve(__dirname, './fixtures', loremFileName); 553 | 554 | let pane, chickenEditor, loremEditor; 555 | 556 | beforeEach(async () => { 557 | chickenEditor = await atom.workspace.open(chickenFilePath); 558 | loremEditor = await atom.workspace.open(loremFilePath); 559 | 560 | pane = atom.workspace.getPanes().find(pane => pane.getItems().includes(chickenEditor)); 561 | spy(pane, 'moveItem'); 562 | 563 | PinnedTabs.state[pane.id] = []; 564 | }); 565 | 566 | it('does nothing if an unpinned tab is moved to a valid index', () => { 567 | PinnedTabs.reorderTab(pane, loremEditor, 0); 568 | expect(pane.moveItem).not.to.have.been.called; 569 | }); 570 | 571 | it('does not reorder if a pinned tab is moved to a valid index', () => { 572 | let chickenTab = workspaceElement.querySelector(`.tab .title[data-name="${chickenFileName}"]`).parentNode; 573 | chickenTab.classList.add('pinned-tab'); 574 | let loremTab = workspaceElement.querySelector(`.tab .title[data-name="${loremFileName}"]`).parentNode; 575 | loremTab.classList.add('pinned-tab'); 576 | 577 | PinnedTabs.reorderTab(pane, loremEditor, 0); 578 | expect(pane.moveItem).not.to.have.been.called; 579 | }); 580 | 581 | it('moves unpinned tabs after pinned tabs', () => { 582 | let tab = workspaceElement.querySelector(`.tab .title[data-name="${chickenFileName}"]`).parentNode; 583 | tab.classList.add('pinned-tab'); 584 | 585 | PinnedTabs.state[pane.id] = [{ id: chickenEditor.getURI() }]; 586 | 587 | PinnedTabs.reorderTab(pane, loremEditor, 0); 588 | expect(pane.moveItem).to.have.been.calledWith(loremEditor, 1); 589 | }); 590 | 591 | it('moves pinned tabs before unpinned tabs', () => { 592 | let tab = workspaceElement.querySelector(`.tab .title[data-name="${chickenFileName}"]`).parentNode; 593 | tab.classList.add('pinned-tab'); 594 | 595 | PinnedTabs.state[pane.id].push({id: chickenEditor.getURI()}); 596 | 597 | PinnedTabs.reorderTab(pane, chickenEditor, 1); 598 | expect(pane.moveItem).to.have.been.calledWith(chickenEditor, 0); 599 | }); 600 | 601 | it('updates the state if a pinned tab is moved', () => { 602 | spy(PinnedTabs.state, 'movePaneItem'); 603 | 604 | let chickenTab = workspaceElement.querySelector(`.tab .title[data-name="${chickenFileName}"]`).parentNode; 605 | chickenTab.classList.add('pinned-tab'); 606 | let loremTab = workspaceElement.querySelector(`.tab .title[data-name="${loremFileName}"]`).parentNode; 607 | loremTab.classList.add('pinned-tab'); 608 | 609 | PinnedTabs.state[pane.id] = [ 610 | { id: chickenEditor.getURI() }, 611 | { id: loremEditor.getURI() } 612 | ]; 613 | 614 | PinnedTabs.reorderTab(pane, chickenEditor, 1); 615 | expect(PinnedTabs.state.movePaneItem).to.have.been.called; 616 | 617 | PinnedTabs.state.movePaneItem.restore(); 618 | }); 619 | 620 | afterEach(() => { 621 | pane.moveItem.restore(); 622 | }); 623 | 624 | }); 625 | 626 | describe('::addPinButtonTo()', () => { 627 | 628 | const chickenFileName = 'chicken.md'; 629 | const loremFileName = 'lorem.txt'; 630 | const chickenFilePath = path.resolve(__dirname, './fixtures', chickenFileName); 631 | const loremFilePath = path.resolve(__dirname, './fixtures', loremFileName); 632 | 633 | beforeEach(async () => { 634 | spy(PinnedTabs, 'pin'); 635 | 636 | // Open a file in the workspace to work with 637 | await atom.workspace.open(chickenFilePath); 638 | }); 639 | 640 | it('adds a pin button to a tab', () => { 641 | let tab = workspaceElement.querySelector(`.tab .title[data-name="${chickenFileName}"]`).parentNode; 642 | PinnedTabs.addPinButtonTo(tab); 643 | expect(tab.querySelector('.pin-icon')).not.to.be.null; 644 | }); 645 | 646 | it('the pin button on a tab pins the tab when clicked', () => { 647 | let tab = workspaceElement.querySelector(`.tab .title[data-name="${chickenFileName}"]`).parentNode; 648 | PinnedTabs.addPinButtonTo(tab); 649 | 650 | let pinButton = tab.querySelector('.pin-icon'); 651 | expect(pinButton).not.to.be.null; 652 | 653 | simulateClick(pinButton); 654 | expect(PinnedTabs.pin).to.have.been.called; 655 | }); 656 | 657 | it('is called when a new tab is opened', async () => { 658 | spy(PinnedTabs, 'addPinButtonTo'); 659 | 660 | await atom.workspace.open(loremFilePath); 661 | expect(PinnedTabs.addPinButtonTo).to.have.been.called; 662 | 663 | PinnedTabs.addPinButtonTo.restore(); 664 | }); 665 | 666 | afterEach(() => { 667 | PinnedTabs.pin.restore(); 668 | }); 669 | 670 | }); 671 | 672 | describe('::removePinButtonFrom()', () => { 673 | 674 | const chickenFileName = 'chicken.md'; 675 | const chickenFilePath = path.resolve(__dirname, './fixtures', chickenFileName); 676 | 677 | beforeEach(async () => { 678 | // Open a file in the workspace to work with 679 | await atom.workspace.open(chickenFilePath); 680 | }); 681 | 682 | it('removes te pin button from a tab', () => { 683 | const pinButton = document.createElement('div'); 684 | pinButton.classList.add('pin-icon'); 685 | 686 | let tab = workspaceElement.querySelector(`.tab .title[data-name="${chickenFileName}"]`).parentNode; 687 | tab.appendChild(pinButton); 688 | 689 | PinnedTabs.removePinButtonFrom(tab); 690 | expect(tab.querySelector('.pin-icon')).not.to.null; 691 | }); 692 | 693 | it('does nothing if there is no pin button on the tab', () => { 694 | let tab = workspaceElement.querySelector(`.tab .title[data-name="${chickenFileName}"]`).parentNode; 695 | spy(tab, 'removeChild'); 696 | 697 | expect(() => PinnedTabs.removePinButtonFrom(tab)).not.to.throw; 698 | expect(tab.removeChild).not.to.have.been.called; 699 | }); 700 | 701 | }); 702 | 703 | afterEach(() => { 704 | let pane = atom.workspace.getActivePane(); 705 | pane.destroyItems(); 706 | 707 | if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath); 708 | }); 709 | 710 | }); 711 | -------------------------------------------------------------------------------- /spec/state-spec.js: -------------------------------------------------------------------------------- 1 | 'use babel'; 2 | 3 | import { spy } from 'sinon'; 4 | import sinonChai from 'sinon-chai'; 5 | 6 | import PinnedTabsState from '../lib/state.js'; 7 | 8 | 9 | Chai.use(sinonChai); 10 | 11 | describe('PinnedTabsState', () => { 12 | 13 | it('can be initialized without a previous state', () => { 14 | let state = new PinnedTabsState(); 15 | expect(state).to.have.property('indices'); 16 | expect(state.indices).to.be.an('array').that.is.empty; 17 | }); 18 | 19 | it('can be initialized with a previous state', () => { 20 | let state = new PinnedTabsState({ 21 | data: { 22 | 1: [{id: 'foo'}, {id: 'bar'}] 23 | }, 24 | deserializer: 'PinnedTabsState', 25 | version: 1 26 | }); 27 | 28 | expect(state).to.have.property('indices'); 29 | expect(state.indices).to.be.an('array').that.has.length(1); 30 | expect(state).to.have.property('1'); 31 | }); 32 | 33 | it('is backwards compatible with older state objects', () => { 34 | let state = new PinnedTabsState({ 35 | data: { }, 36 | deserializer: 'PinnedTabsState', 37 | version: 0.1 38 | }); 39 | 40 | expect(state).to.have.property('indices'); 41 | expect(state.indices).to.be.an('array').that.is.empty; 42 | }); 43 | 44 | describe('::serialize()', () => { 45 | 46 | let state; 47 | 48 | beforeEach(() => { 49 | state = new PinnedTabsState(); 50 | }); 51 | 52 | it('serializes an empty state', () => { 53 | let serializedState = state.serialize(); 54 | expect(serializedState).to.exist; 55 | }); 56 | 57 | it('has the necessary properties', () => { 58 | let serializedState = state.serialize(); 59 | expect(serializedState).to.have.property('data'); 60 | expect(serializedState).to.have.property('deserializer'); 61 | expect(serializedState).to.have.property('version'); 62 | }); 63 | 64 | it('serializes a non-emtpy state', () => { 65 | let index = 1; 66 | state.indices.push(index); 67 | state[index] = [{id: 'foo'}, {id: 'bar'}]; 68 | 69 | let serializedState = state.serialize(); 70 | expect(serializedState).to.have.property('data'); 71 | expect(serializedState.data).to.have.property(index); 72 | expect(serializedState.data[index]).to.have.length(2); 73 | }); 74 | 75 | }); 76 | 77 | describe('::deserialize()', () => { 78 | 79 | it('deserializes an empty state', () => { 80 | let deserializedState = PinnedTabsState.deserialize({}); 81 | expect(deserializedState).to.be.an.instanceOf(PinnedTabsState); 82 | }); 83 | 84 | it('deserializes a non-empty state', () => { 85 | let deserializedState = PinnedTabsState.deserialize({ 86 | data: { 87 | 1: [{id: 'foo'}, {id: 'bar'}] 88 | }, 89 | deserializer: 'PinnedTabsState', 90 | version: 1 91 | }); 92 | expect(deserializedState).to.have.property('1'); 93 | expect(deserializedState[1]).to.have.length(2); 94 | }); 95 | 96 | }); 97 | 98 | describe('::addPane()', () => { 99 | 100 | let state; 101 | 102 | beforeEach(() => { 103 | state = new PinnedTabsState(); 104 | }); 105 | 106 | it('adds a new pane to the state', () => { 107 | state.addPane(42); 108 | expect(state).to.have.property(42); 109 | expect(state.indices).to.include(42); 110 | }); 111 | 112 | it('does nothing if the pane is already in the state', () => { 113 | state[36] = []; 114 | state.indices = [36]; 115 | 116 | state.addPane(36); 117 | expect(state).to.have.property(36); 118 | expect(state.indices).to.have.length(1); 119 | }); 120 | 121 | }); 122 | 123 | describe('::addPaneItem()', () => { 124 | 125 | let state; 126 | 127 | beforeEach(() => { 128 | state = new PinnedTabsState(); 129 | }); 130 | 131 | it('adds a new pane item to the state', () => { 132 | state.addPaneItem(42, {getTitle: () => 'foobar'}); 133 | expect(state).to.have.property(42); 134 | expect(state[42]).to.have.length(1); 135 | }); 136 | 137 | it('does nothing if the pane item is already in the state', () => { 138 | state[36] = [{id: 'Hello world!'}]; 139 | 140 | state.addPaneItem(34, {getTitle: () => 'Hello world!'}); 141 | expect(state[36]).to.have.length(1); 142 | }); 143 | 144 | }); 145 | 146 | describe('::forEach()', () => { 147 | 148 | let callback; 149 | 150 | beforeEach(() => { 151 | callback = spy(); 152 | }); 153 | 154 | it('does nothing if there are no pinned items', () => { 155 | let state = new PinnedTabsState(); 156 | 157 | state.forEach(callback); 158 | expect(callback).not.to.be.called; 159 | }); 160 | 161 | it('iterates over each item in one pane', () => { 162 | let state = new PinnedTabsState(); 163 | state.indices = [1]; 164 | state[1] = [{id: 'foo'}]; 165 | 166 | state.forEach(callback); 167 | expect(callback).to.be.calledOnce; 168 | }); 169 | 170 | it('iterates over each item in all panes', () => { 171 | let state = new PinnedTabsState(); 172 | state.indices = [1, 2]; 173 | state[1] = [{id: 'foo'}]; 174 | state[2] = [{id: 'bar'}]; 175 | 176 | state.forEach(callback); 177 | expect(callback).to.be.calledTwice; 178 | }); 179 | 180 | }); 181 | 182 | describe('::movePaneItem()', () => { 183 | 184 | let state; 185 | 186 | beforeEach(() => { 187 | state = new PinnedTabsState(); 188 | }); 189 | 190 | it('updates the state if a pinned pane item moved', () => { 191 | state[42] = [{id: 'foo'}, {id: 'bar'}]; 192 | 193 | state.movePaneItem(42, {getTitle: () => 'foo'}, 1); 194 | expect(state[42]).to.deep.equal([{id: 'bar'}, {id: 'foo'}]); 195 | }); 196 | 197 | it('does nothing if the item was not actually moved', () => { 198 | state[36] = [{id: 'foo'}, {id: 'bar'}]; 199 | 200 | state.movePaneItem(36, {getTitle: () => 'foo'}, 0); 201 | expect(state[36]).to.deep.equal([{id: 'foo'}, {id: 'bar'}]); 202 | }); 203 | 204 | }); 205 | 206 | describe('::removePane()', () => { 207 | 208 | let state; 209 | 210 | beforeEach(() => { 211 | state = new PinnedTabsState(); 212 | }); 213 | 214 | it('removes a pane from the state', () => { 215 | state.indices = [42]; 216 | state[42] = [{id: 'foobar'}]; 217 | 218 | state.removePane(42); 219 | expect(state).not.to.have.property(42); 220 | expect(state.indices).to.have.length(0); 221 | }); 222 | 223 | it('does nothing if the pane was not in the state', () => { 224 | state.indices = [42]; 225 | state[42] = [{id: 'foobar'}]; 226 | 227 | state.removePane(36); 228 | expect(state).to.have.property(42); 229 | expect(state.indices).to.deep.equal([42]); 230 | }); 231 | 232 | }); 233 | 234 | describe('::removePaneItem()', () => { 235 | 236 | let state; 237 | 238 | beforeEach(() => { 239 | state = new PinnedTabsState(); 240 | }); 241 | 242 | it('removes a pane item from the state', () => { 243 | state[42] = [{id: 'foo', subscriptions: {dispose: spy()}}]; 244 | 245 | state.removePaneItem(42, {getTitle: () => 'foo'}); 246 | expect(state[42]).to.be.empty; 247 | }); 248 | 249 | it('does nothing if the pane item was not in the state', () => { 250 | state.removePaneItem(42, 'foo'); 251 | expect(state[42]).not.to.exist; 252 | }); 253 | 254 | it('disposes the item descriptions', () => { 255 | let subscriptionsDisposeSpy = spy(); 256 | state[42] = [{id: 'foo', subscriptions: {dispose: subscriptionsDisposeSpy}}]; 257 | 258 | state.removePaneItem(42, {getTitle: () => 'foo'}); 259 | expect(subscriptionsDisposeSpy).to.have.been.called; 260 | }); 261 | 262 | }); 263 | 264 | }); 265 | -------------------------------------------------------------------------------- /spec/utils.js: -------------------------------------------------------------------------------- 1 | 'use babel'; 2 | 3 | export function simulateClick(el) { 4 | let event = new MouseEvent('click', { 5 | view: window, 6 | bubbles: true, 7 | cancelable: true 8 | }); 9 | 10 | el.dispatchEvent(event); 11 | } 12 | 13 | export function sleep(timeout) { 14 | return new Promise(resolve => setTimeout(resolve, timeout)); 15 | } 16 | -------------------------------------------------------------------------------- /styles/animations.less: -------------------------------------------------------------------------------- 1 | @keyframes icon-in { 2 | 0% { left: -76px; } 3 | 100% { left: -38px; } 4 | } 5 | -------------------------------------------------------------------------------- /styles/icons.less: -------------------------------------------------------------------------------- 1 | // Set a default icon for pinned tabs (when file-icons is not active) 2 | .tab.pinned-tab[data-type="Object"] > :not([class*="icon"])::before, 3 | .tab.pinned-tab[data-type="ImageEditor"] > :not([class*="-icon"])::before, 4 | .tab.pinned-tab[data-type="TextEditor"] > :not([class*="-icon"])::before, { 5 | display: inline-block; 6 | font-family: 'Octicons Regular'; 7 | font-size: medium; 8 | font-style: normal !important; 9 | left: -76px; 10 | overflow: hidden; 11 | position: absolute; 12 | width: 0; 13 | } 14 | 15 | .tab.pinned-tab[data-type="Object"] > :not([class*="icon"])::before { 16 | content: '\f011'; // https://octicons.github.com/icon/file/ 17 | } 18 | 19 | .tab.pinned-tab[data-type="ImageEditor"] > :not([class*="-icon"])::before { 20 | content: '\f012'; // https://octicons.github.com/icon/file-media/ 21 | } 22 | 23 | .tab.pinned-tab[data-type="TextEditor"] > :not([class*="-icon"])::before { 24 | content: '\f011'; // https://octicons.github.com/icon/file/ 25 | } 26 | -------------------------------------------------------------------------------- /styles/pin-button.less: -------------------------------------------------------------------------------- 1 | // Pin button on tabs 2 | body.pinned-tabs-pin-button { 3 | .tab-bar .tab:hover > .title { 4 | padding-right: 24px; 5 | } 6 | 7 | .tab:not(.pinned-tab):hover { 8 | .pin-icon { 9 | transform: scale(1); 10 | } 11 | } 12 | 13 | .pin-icon { 14 | color: inherit; 15 | cursor: pointer; 16 | height: 100%; 17 | position: absolute; 18 | right: 28px; 19 | text-align: right; 20 | top: 1px; 21 | transform: scale(0); 22 | transition: transform 250ms cubic-bezier(.4, 0, .2, 1); 23 | width: 12px; 24 | 25 | &::before { 26 | content: '\f041'; 27 | display: inline-block; 28 | font-family: 'Octicons Regular'; 29 | font-size: .9em; 30 | -webkit-font-smoothing: antialiased; 31 | font-style: normal !important; 32 | height: 12px; 33 | line-height: 1; 34 | opacity: .65; 35 | text-decoration: none; 36 | width: 12px; 37 | } 38 | 39 | &:hover::before { 40 | opacity: 1; 41 | } 42 | 43 | .tab.pinned-tab > & { 44 | display: none; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /styles/pinned-tabs.less: -------------------------------------------------------------------------------- 1 | // Prevent invocations on the .title element 2 | .tab > .title { 3 | pointer-events: none !important; 4 | } 5 | 6 | // Default pinned tab styling 7 | body:not(.pinned-tabs-visualstudio) { 8 | // Make pinned tabs a fixed width 9 | .tab.pinned-tab { 10 | max-width: 38px !important; 11 | min-width: 38px !important; 12 | padding: 0 !important; 13 | } 14 | 15 | // Visually hide the close-icon 16 | .tab.pinned-tab > .close-icon { 17 | transform: scale(0) !important; 18 | } 19 | 20 | // Hide the title of the pinned tab 21 | .tab.pinned-tab > .title { 22 | margin: 0; 23 | -webkit-mask: none !important; 24 | overflow: hidden; 25 | padding: 0 0 0 38px !important; 26 | text-overflow: clip; 27 | } 28 | 29 | // Position the icon for the pinned tab 30 | .tab.pinned-tab > .title::before { 31 | border-width: 0 !important; 32 | left: -38px; 33 | position: relative; 34 | text-align: center !important; 35 | width: 38px !important; 36 | } 37 | 38 | // Hide any content added ::after the title 39 | .tab.pinned-tab > .title::after { 40 | left: 100%; 41 | margin: 0; 42 | padding: 0; 43 | position: absolute; 44 | } 45 | 46 | // Set animation transitions 47 | &.pinned-tabs-animated .tab { 48 | transition: 49 | max-width .3s ease, 50 | min-width .3s ease, 51 | width .3s ease; 52 | } 53 | 54 | &.pinned-tabs-animated .tab.pinned-tab > .title:not(.icon)::before { 55 | animation: icon-in .3s; 56 | } 57 | 58 | &.pinned-tabs-animated.pinned-tabs-modified-always .tab.pinned-tab > .title::before, 59 | &.pinned-tabs-animated.pinned-tabs-modified-hover .tab.pinned-tab > .title::before { 60 | transition: 61 | border 0s ease .1s, 62 | font-size .2s ease 0s; 63 | } 64 | 65 | &.pinned-tabs-animated.pinned-tabs-modified-always .tab.pinned-tab.modified > .title::before, 66 | &.pinned-tabs-animated.pinned-tabs-modified-hover .tab.pinned-tab.modified:hover > .title::before { 67 | top: 50%; 68 | transform: translateY(-50%); 69 | } 70 | 71 | // Style ::before of the title for modified tabs 72 | &.pinned-tabs-modified-always .tab.pinned-tab.modified > .title::before, 73 | &.pinned-tabs-modified-hover .tab.pinned-tab.modified:hover > .title::before { 74 | background-color: transparent; 75 | border: 2px solid !important; 76 | border-radius: 50%; 77 | font-size: 0; // Hides the actual icon 78 | height: 10px; 79 | left: 0; 80 | margin: 0 14px; 81 | max-width: 10px; 82 | position: absolute; 83 | top: 50%; 84 | } 85 | } 86 | 87 | // Visual studio pinned tab styling 88 | body.pinned-tabs-visualstudio { 89 | .tab.pinned-tab > .close-icon { 90 | pointer-events: none !important; // Makes the close icon non-functional when used as pinned icon 91 | transform: scale(1) !important; // Force show the pinned icon 92 | 93 | &::before { 94 | content: '\f041'; // Change close icon into a "pinned" icon 95 | } 96 | } 97 | } 98 | --------------------------------------------------------------------------------