diff --git a/lib/command-palette-package.js b/lib/command-palette-package.js index 3d81c29..050babb 100644 --- a/lib/command-palette-package.js +++ b/lib/command-palette-package.js @@ -21,6 +21,9 @@ class CommandPalettePackage { this.disposables.add(atom.config.observe('command-palette.preserveLastSearch', (newValue) => { this.commandPaletteView.update({preserveLastSearch: newValue}) })) + this.disposables.add(atom.config.observe('command-palette.numberOfRecentlyConfirmedCommandsShowsAtTop', (newValue) => { + this.commandPaletteView.update({numberOfRecentlyConfirmedCommandsShowsAtTop: newValue}) + })) return this.commandPaletteView.show() } diff --git a/lib/command-palette-view.js b/lib/command-palette-view.js index 7a94637..2ba324f 100644 --- a/lib/command-palette-view.js +++ b/lib/command-palette-view.js @@ -5,9 +5,16 @@ import {humanizeKeystroke} from 'underscore-plus' import fuzzaldrin from 'fuzzaldrin' import fuzzaldrinPlus from 'fuzzaldrin-plus' +function removeItemBy (list, fn) { + const index = list.findIndex(fn) + if (index !== -1) list.splice(index, 1) +} + export default class CommandPaletteView { constructor (initiallyVisibleItemCount = 10) { this.keyBindingsForActiveElement = [] + this.recentlyConfirmedCommands = [] + this.numberOfRecentlyConfirmedCommandsShowsAtTop = 0 this.elementCache = new WeakMap() this.selectListView = new SelectListView({ initiallyVisibleItemCount: initiallyVisibleItemCount, // just for being able to disable visible-on-render in spec @@ -85,6 +92,9 @@ export default class CommandPaletteView { return li }, didConfirmSelection: (keyBinding) => { + if (this.numberOfRecentlyConfirmedCommandsShowsAtTop > 0) { + this.updateRecentlyConfirmedCommands(keyBinding) + } this.hide() const event = new CustomEvent(keyBinding.name, {bubbles: true, cancelable: true}) this.activeElement.dispatchEvent(event) @@ -126,7 +136,21 @@ export default class CommandPaletteView { .findCommands({target: this.activeElement}) .filter(command => showHiddenCommands === !!command.hiddenInCommandPalette) commandsForActiveElement.sort((a, b) => a.displayName.localeCompare(b.displayName)) - await this.selectListView.update({items: commandsForActiveElement}) + + if (this.numberOfRecentlyConfirmedCommandsShowsAtTop > 0) { + for (const command of this.recentlyConfirmedCommands) { + // NOTE: items lifted to top should not be returned from cache. + // Since cached element which already attached to DOM need to be stable. + this.elementCache.delete(command) + // When package have activationCommands, `command` object is replaced after initial invocation. + // So we can't simply use `commandsForActiveElement.indexOf(command)`. + removeItemBy(commandsForActiveElement, item => item.name === command.name) + } + await this.selectListView.update({items: [...this.recentlyConfirmedCommands, ...commandsForActiveElement]}) + } else { + await this.selectListView.update({items: commandsForActiveElement}) + } + this.previouslyFocusedElement = document.activeElement this.panel.show() @@ -149,6 +173,11 @@ export default class CommandPaletteView { if (props.hasOwnProperty('useAlternateScoring')) { this.useAlternateScoring = props.useAlternateScoring } + + if (props.hasOwnProperty('numberOfRecentlyConfirmedCommandsShowsAtTop')) { + this.numberOfRecentlyConfirmedCommandsShowsAtTop = props.numberOfRecentlyConfirmedCommandsShowsAtTop + this.updateRecentlyConfirmedCommands() + } } get fuzz () { @@ -267,4 +296,12 @@ export default class CommandPaletteView { }) return tagsEl } + + updateRecentlyConfirmedCommands (command) { + if (command) { + removeItemBy(this.recentlyConfirmedCommands, item => item.name === command.name) + this.recentlyConfirmedCommands = [command, ...this.recentlyConfirmedCommands] + } + this.recentlyConfirmedCommands.splice(this.numberOfRecentlyConfirmedCommandsShowsAtTop) + } } diff --git a/package.json b/package.json index bf033aa..06c740b 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,12 @@ "type": "boolean", "default": false, "description": "Preserve the last search when reopening the command palette." + }, + "numberOfRecentlyConfirmedCommandsShowsAtTop": { + "type": "integer", + "default": 0, + "minimum": 0, + "description": "Specified number of recently confirmed commands appears at top of command list." } } } diff --git a/test/command-palette-view.test.js b/test/command-palette-view.test.js index 5550829..9049ffd 100644 --- a/test/command-palette-view.test.js +++ b/test/command-palette-view.test.js @@ -351,4 +351,125 @@ describe('CommandPaletteView', () => { }) }) }) + + describe('numberOfRecentlyConfirmedCommandsShowsAtTop', () => { + let commandPalette, disposable, testCommands + + const selectAndConfirmItem = async (item) => { + await commandPalette.selectListView.selectItem(item) + const originalDidConfirmSelection = commandPalette.selectListView.props.didConfirmSelection + await new Promise(resolve => { + const stub = sinon.stub(commandPalette.selectListView.props, "didConfirmSelection").callsFake((...args) => { + originalDidConfirmSelection(...args) + resolve() + stub.restore() + }) + commandPalette.selectListView.confirmSelection() + }) + } + + beforeEach(async () => { + commandPalette = new CommandPaletteView() + disposable = atom.commands.add('*', { + 'xxxxx:0': () => {}, + 'xxxxx:1': () => {}, + 'xxxxx:2': () => {}, + 'xxxxx:3': () => {}, + 'xxxxx:4': () => {}, + }) + await commandPalette.show() + testCommands = commandPalette.selectListView.items.filter(item => item.name.startsWith('xxxxx:')) + + assert.equal(testCommands[0].name, "xxxxx:0") + assert.equal(testCommands[1].name, "xxxxx:1") + assert.equal(testCommands[2].name, "xxxxx:2") + assert.equal(testCommands[3].name, "xxxxx:3") + assert.equal(testCommands[4].name, "xxxxx:4") + assert.equal(testCommands.length, 5) + + await commandPalette.hide() + }) + + afterEach(() => { + disposable.dispose() + }) + + it('keep specified nubmer of recentlyConfirmedCommands and show at top', async () => { + const withItemElements = fn => fn(Array.from(selectListView.element.querySelectorAll('li'))) + + const {selectListView} = commandPalette + await commandPalette.update({numberOfRecentlyConfirmedCommandsShowsAtTop: 3}) + + await commandPalette.show() + await selectAndConfirmItem(testCommands[0]) + await commandPalette.show() + await withItemElements(elements => { + assert.equal(elements[0].textContent, testCommands[0].displayName) + }) + + await selectAndConfirmItem(testCommands[1]) + await commandPalette.show() + await withItemElements(elements => { + assert.equal(elements[0].textContent, testCommands[1].displayName) + assert.equal(elements[1].textContent, testCommands[0].displayName) + }) + + await selectAndConfirmItem(testCommands[2]) + await commandPalette.show() + await withItemElements(elements => { + assert.equal(elements[0].textContent, testCommands[2].displayName) + assert.equal(elements[1].textContent, testCommands[1].displayName) + assert.equal(elements[2].textContent, testCommands[0].displayName) + }) + + await selectAndConfirmItem(testCommands[3]) + await commandPalette.show() + await withItemElements(elements => { + assert.equal(elements[0].textContent, testCommands[3].displayName) + assert.equal(elements[1].textContent, testCommands[2].displayName) + assert.equal(elements[2].textContent, testCommands[1].displayName) + assert.notEqual(elements[3].textContent, testCommands[0].displayName) + }) + + await selectAndConfirmItem(testCommands[2]) + await commandPalette.show() + await withItemElements(elements => { + assert.equal(elements[0].textContent, testCommands[2].displayName) + assert.equal(elements[1].textContent, testCommands[3].displayName) + assert.equal(elements[2].textContent, testCommands[1].displayName) + assert.notEqual(elements[3].textContent, testCommands[0].displayName) + }) + + commandPalette.selectListView.refs.queryEditor.setText('xxxxx') + await commandPalette.selectListView.update() + withItemElements(elements => { + elements = Array.from(selectListView.element.querySelectorAll('li')) + const restElements = elements.slice(3) + assert.equal(elements[0].textContent, testCommands[2].displayName) + assert.equal(elements[1].textContent, testCommands[3].displayName) + assert.equal(elements[2].textContent, testCommands[1].displayName) + assert(!restElements.find(element => element.textContent === testCommands[2].displayName)) + assert(!restElements.find(element => element.textContent === testCommands[3].displayName)) + assert(!restElements.find(element => element.textContent === testCommands[1].displayName)) + assert(restElements.find(element => element.textContent === testCommands[0].displayName)) + }) + }) + + it('immediately update size of recentlyConfirmedCommands', async () => { + await commandPalette.update({numberOfRecentlyConfirmedCommandsShowsAtTop: 3}) + + await commandPalette.show() + await selectAndConfirmItem(testCommands[0]) + await commandPalette.show() + await selectAndConfirmItem(testCommands[1]) + await commandPalette.show() + await selectAndConfirmItem(testCommands[2]) + + assert.deepEqual(commandPalette.recentlyConfirmedCommands, [testCommands[2], testCommands[1], testCommands[0]]) + await commandPalette.update({numberOfRecentlyConfirmedCommandsShowsAtTop: 2}) + assert.deepEqual(commandPalette.recentlyConfirmedCommands, [testCommands[2], testCommands[1]]) + await commandPalette.update({numberOfRecentlyConfirmedCommandsShowsAtTop: 0}) + assert.deepEqual(commandPalette.recentlyConfirmedCommands, []) + }) + }) })