├── .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 | [](https://travis-ci.com/ericcornelissen/pinned-tabs-for-atom)
4 | [](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 | 
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 |
--------------------------------------------------------------------------------