diff --git a/HELP.md b/HELP.md index 7c023761fb..1b56ebb055 100644 --- a/HELP.md +++ b/HELP.md @@ -89,18 +89,20 @@ Cloud Commander supports command line parameters: | `--console` | enable console | `--terminal` | enable terminal | `--terminal-path` | set terminal path +| `--vim` | enable vim hot keys | `--no-server` | do not start server | `--no-auth` | disable authorization | `--no-online` | load scripts from local server | `--no-open` | do not open web browser when server started +| `--no-name` | set empty tab name in web browser +| `--no-one-panel-mode` | unset one panel mode | `--no-progress` | do not show progress of file operations | `--no-html-dialogs` | do not use html dialogs -| `--no-one-panel-mode` | unset one panel mode | `--no-contact` | disable contact | `--no-config-dialog` | disable config dialog | `--no-console` | disable console | `--no-terminal` | disable terminal -| `--no-name` | set empty tab name in web browser +| `--no-vim` | disable vim hot keys If no parameters given Cloud Commander reads information from `~/.cloudcmd.json` and use @@ -174,6 +176,25 @@ Hot keys | `~` | console | `Ctrl + Click` | open file on new tab +### Vim + +When `--vim` option provided, or configuration parameter `vim` enabled next hot keys become available: + +|Key |Operation +|:----------------------|:-------------------------------------------- +| `j` | navigate to next file +| `k` | navigate to previous file +| `dd` | remove current file +| `G` | navigate to bottom file +| `gg` | navigate to top file +| `v` | visual mode +| `Esc` | unselect all + +Commands can be joined, for example: +- `5j` will navigate `5` files below current; +- `d5j` will remove next `5` files; +- `dG` will remove all files from current to bottom; + View --------------- ![View](/img/screen/view.png "View") @@ -333,6 +354,7 @@ Here is description of options: "console" : true, /* enable console */ "terminal" : false, /* disable terminal */ "terminalPath" : '', /* path of a terminal */ + "vim" : false, /* disable vim hot keys */ } ``` diff --git a/client/dom/index.js b/client/dom/index.js index 2c972186e7..f709c87c96 100644 --- a/client/dom/index.js +++ b/client/dom/index.js @@ -533,6 +533,14 @@ function CmdProto() { return Cmd; }; + this.unselectFile = (currentFile) => { + const current = currentFile || DOM.getCurrentFile(); + + current.classList.remove(SELECTED_FILE); + + return Cmd; + }; + this.toggleSelectedFile = (currentFile) => { const current = currentFile || DOM.getCurrentFile(); const name = DOM.getCurrentName(current); diff --git a/client/key/index.js b/client/key/index.js index 8d1bdfab95..6b82d4e874 100644 --- a/client/key/index.js +++ b/client/key/index.js @@ -9,6 +9,7 @@ const exec = require('execon'); const Events = require('../dom/events'); const Buffer = require('../dom/buffer'); const KEY = require('./key'); +const vim = require('./vim'); const setCurrentByChar = require('./set-current-by-char'); const fullstore = require('fullstore/legacy'); const Chars = fullstore(); @@ -71,15 +72,19 @@ function KeyProto() { char = isSymbol; } - /* in case buttons can be processed */ if (!Key.isBind()) return; - if (!isNumpad && !alt && !ctrl && !meta && (isBetween || isSymbol)) + const isVim = CloudCmd.config('vim'); + + if (!isVim && !isNumpad && !alt && !ctrl && !meta && (isBetween || isSymbol)) return setCurrentByChar(char, Chars); Chars([]); switchKey(event); + + if (isVim) + vim(char, event); } function getSymbol(shift, keyCode) { @@ -106,6 +111,8 @@ function KeyProto() { function switchKey(event) { let i, isSelected, prev, next; let current = Info.element; + let dataName; + const name = Info.name; const {Operation} = CloudCmd; @@ -301,9 +308,9 @@ function KeyProto() { event.preventDefault(); - const attr = Info.panel.getAttribute('data-name'); + dataName = Info.panel.getAttribute('data-name'); - if (attr === 'js-right') + if (dataName === 'js-right') DOM.duplicatePanel(); break; @@ -314,9 +321,9 @@ function KeyProto() { event.preventDefault(); - name = Info.panel.getAttribute('data-name'); + dataName = Info.panel.getAttribute('data-name'); - if (name === 'js-left') + if (dataName === 'js-left') DOM.duplicatePanel(); break; diff --git a/client/key/key.js b/client/key/key.js index 7f6423b053..81b2c3edf3 100644 --- a/client/key/key.js +++ b/client/key/key.js @@ -1,3 +1,5 @@ +'use strict'; + module.exports = { BACKSPACE : 8, TAB : 9, @@ -20,6 +22,10 @@ module.exports = { ZERO : 48, + SEMICOLON : 52, + + COLON : 54, + A : 65, C : 67, @@ -27,6 +33,9 @@ module.exports = { G : 71, + J : 74, + K : 75, + M : 77, O : 79, diff --git a/client/key/set-current-by-char.js b/client/key/set-current-by-char.js index 9a2428bd44..d010fa8655 100644 --- a/client/key/set-current-by-char.js +++ b/client/key/set-current-by-char.js @@ -3,7 +3,6 @@ 'use strict'; const Info = DOM.CurrentInfo; - const {escapeRegExp} = require('../../common/util'); module.exports = function setCurrentByChar(char, charStore) { @@ -56,5 +55,5 @@ module.exports = function setCurrentByChar(char, charStore) { DOM.setCurrentFile(firstByName); charStore([char]); } -} +}; diff --git a/client/key/vim.js b/client/key/vim.js new file mode 100644 index 0000000000..f280c9c7f0 --- /dev/null +++ b/client/key/vim.js @@ -0,0 +1,172 @@ +'use strict'; +/* global CloudCmd, DOM */ + +const Info = DOM.CurrentInfo; +const KEY = require('./key'); + +const fullstore = require('fullstore/legacy'); +const store = fullstore(''); +const visual = fullstore(false); + +const stopVisual = () => { + visual(false); +}; + +const end = () => { + store(''); +}; + +const rmFirst = (a) => { + return a + .split('') + .slice(1) + .join(''); +}; + +module.exports = (key, event) => { + const current = Info.element; + const keyCode = event.keyCode; + const prevStore = store(); + + const value = store(prevStore.concat(key)); + + if (keyCode === KEY.ENTER) + return end(); + + if (keyCode === KEY.ESC) { + DOM.unselectFiles(); + visual(false); + return end(); + } + + if (key === 'j') { + move('next', { + prevStore, + current, + }); + + return end(); + } + + if (key === 'k') { + move('previous', { + prevStore, + current, + }); + + return end(); + } + + if (/gg/.test(value)) { + move('previous', { + current, + prevStore, + max: Infinity, + }); + + return end(); + } + + if (key === 'd' && (visual() || prevStore === 'd')) { + CloudCmd.Operation.show('delete'); + stopVisual(); + return end(); + } + + if (key === 'G') { + move('next', { + current, + prevStore, + max: Infinity, + }); + + return end(); + } + + if (key === 'y') { + if (!visual()) + return end(); + + DOM.Buffer.copy(); + stopVisual(); + DOM.unselectFiles(); + return end(); + } + + if (/^p$/i.test(key)) { + DOM.Buffer.paste(); + return end(); + } + + if (/^v$/i.test(key)) { + DOM.toggleSelectedFile(current); + visual(!visual()); + + return end(); + } +}; + +module.exports.selectFile = selectFile; + +function move(sibling, {max, current, prevStore}) { + const isDelete = prevStore[0] === 'd'; + + if (isDelete) { + visual(true); + prevStore = rmFirst(prevStore); + } + + const n = max || getNumber(prevStore); + + if (isNaN(n)) + return; + + setCurrent({ + n, + current, + sibling, + visual: visual(), + }); + + if (isDelete) + CloudCmd.Operation.show('delete'); +} + +function getNumber(value) { + if (!value) + return 1; + + if (value === 'g') + return 1; + + return parseInt(value); +} + +function selectFile(current) { + const name = DOM.getCurrentName(current); + + if (name === '..') + return; + + DOM.selectFile(current); +} + +function setCurrent({n, current, visual, sibling}) { + const select = visual ? selectFile : DOM.unselectFile; + + select(current); + + const position = `${sibling}Sibling`; + for (let i = 0; i < n; i++) { + const next = current[position]; + + if (!next) + break; + + current = next; + select(current); + } + + DOM.setCurrentFile(current); +} + diff --git a/json/config.json b/json/config.json index ef533f22ec..1b034f8505 100644 --- a/json/config.json +++ b/json/config.json @@ -27,6 +27,7 @@ "console": true, "terminal": false, "terminalPath": "", - "showConfig": "false" + "showConfig": "false", + "vim": "true" } diff --git a/json/help.json b/json/help.json index 5b3eeb1e41..8c1f27af52 100644 --- a/json/help.json +++ b/json/help.json @@ -15,24 +15,26 @@ "--port ": "set port number", "--progress ": "show progress of file operations", "--html-dialogs ": "use html dialogs", + "--open ": "open web browser when server started", + "--name ": "set tab name in web browser", "--one-panel-mode ": "set one panel mode", "--config-dialog ": "enable config dialog", "--console ": "enable console", "--contact ": "enable contact", "--terminal ": "enable terminal", "--terminal-path ": "set terminal path", - "--open ": "open web browser when server started", - "--name ": "set tab name in web browser", + "--vim ": "enable vim hot keys", "--no-server ": "do not start server", "--no-auth ": "disable authorization", "--no-online ": "load scripts from local server", "--no-open ": "do not open web browser when server started", + "--no-name ": "set default tab name in web browser", + "--no-one-panel-mode ": "unset one panel mode", "--no-progress ": "do not show progress of file operations", "--no-html-dialogs ": "do not use html dialogs", - "--no-one-panel-mode ": "unset one panel mode", "--no-config-dialog ": "disable config dialog", "--no-console ": "disable console", "--no-contact ": "disable contact", "--no-terminal ": "disable terminal", - "--no-name ": "set default tab name in web browser" + "--no-vim ": "disable vim hot keys" } diff --git a/man/cloudcmd.1 b/man/cloudcmd.1 index bab5a9d41d..831a8778c5 100644 --- a/man/cloudcmd.1 +++ b/man/cloudcmd.1 @@ -38,26 +38,28 @@ programs in browser from any computer, mobile or tablet device. --port set port number --progress show progress of file operations --html-dialogs use html dialogs + --open open web browser when server started + --name set tab name in web browser --one-panel-mode set one panel mode --contact enable contact --config-dialog enable config dialog --console enable console --terminal enable terminal --terminal-path set terminal path - --open open web browser when server started - --name set tab name in web browser + --vim enable vim hot keys --no-auth disable authorization --no-server do not start server --no-online load scripts from local server --no-open do not open web browser when server started + --no-name set default tab name in web browser + --no-one-panel-mode unset one panel mode --no-progress do not show progress of file operations --no-html-dialogs do not use html dialogs - --no-one-panel-mode unset one panel mode --no-contact disable contact --no-config-dialog disable config dialog --no-console disable console --no-terminal disable terminal - --no-name set default tab name in web browser + --no-vim disable vim hot keys .SH RESOURCES AND DOCUMENTATION diff --git a/package.json b/package.json index 9be72203ca..4e4e9c0678 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "fix:js:eslint:client": "redrun eslint:client -- --fix", "fix:js:eslint:server": "redrun eslint:server -- --fix", "test": "tape 'test/**/*.js'", + "test:client": "tape 'test/client/**/*.js'", "spell": "yaspeller .", "wisdom": "redrun build", "wisdom:type": "bin/release.js", @@ -91,7 +92,8 @@ "watch:server": "nodemon bin/cloudcmd.js", "watch:lint": "nodemon -w client -w server -w webpack.config.js -x 'redrun lint:js'", "watch:lint:client": "nodemon -w client -w webpack.config.js -x 'redrun lint:js:eslint:client'", - "watch:test": "nodemon -w server -w test -w common -x \"npm run test\"", + "watch:test": "nodemon -w server -w test -w common -x \"npm test\"", + "watch:test:client": "nodemon -w client -w test/client -x \"npm run test:client\"", "watch:coverage": "nodemon -w server -w test -w common -x \"npm run coverage\"", "w:c": "redrun watch:client", "w:c:d": "redrun watch:client:dev", diff --git a/test/client/key/vim.js b/test/client/key/vim.js new file mode 100644 index 0000000000..ceb8ff9b50 --- /dev/null +++ b/test/client/key/vim.js @@ -0,0 +1,394 @@ +'use strict'; + +const test = require('tape'); +const diff = require('sinon-called-with-diff'); +const sinon = diff(require('sinon')); +const dir = '../../../client/key/'; +const KEY = require(dir + 'key'); + +initGlobals(); + +const DOM = global.DOM; +const Buffer = DOM.Buffer; + +const vim = require(dir + 'vim'); + +test('cloudcmd: client: key: set next file: no', (t) => { + const element = { + }; + + const setCurrentFile = sinon.stub(); + + global.DOM.CurrentInfo.element = element; + global.DOM.setCurrentFile = setCurrentFile; + + vim('j', {}); + + t.ok(setCurrentFile.calledWith(element), 'should set next file'); + + t.end(); +}); + +test('cloudcmd: client: key: set next file current', (t) => { + const nextSibling = 'hello'; + const element = { + nextSibling + }; + + const setCurrentFile = sinon.stub(); + + global.DOM.CurrentInfo.element = element; + global.DOM.setCurrentFile = setCurrentFile; + + vim('j', {}); + + t.ok(setCurrentFile.calledWith(nextSibling), 'should set next file'); + + t.end(); +}); + +test('cloudcmd: client: key: set next file current', (t) => { + const nextSibling = 'hello'; + const element = { + nextSibling + }; + + const setCurrentFile = sinon.stub(); + + global.DOM.CurrentInfo.element = element; + global.DOM.setCurrentFile = setCurrentFile; + + vim('m', {}); + vim('j', {}); + vim('j', {}); + + t.ok(setCurrentFile.calledWith(nextSibling), 'should set next file'); + + t.end(); +}); + +test('cloudcmd: client: key: set next file current: g', (t) => { + const nextSibling = 'hello'; + const element = { + nextSibling + }; + + const setCurrentFile = sinon.stub(); + + global.DOM.CurrentInfo.element = element; + global.DOM.setCurrentFile = setCurrentFile; + + vim('g', {}); + vim('j', {}); + + t.ok(setCurrentFile.calledWith(nextSibling), 'should ignore g'); + + t.end(); +}); + +test('cloudcmd: client: key: set +2 file current', (t) => { + const last = {}; + const nextSibling = { + nextSibling: last + }; + const element = { + nextSibling + }; + + const setCurrentFile = sinon.stub(); + + global.DOM.CurrentInfo.element = element; + global.DOM.setCurrentFile = setCurrentFile; + + const event = {}; + + vim('2', event); + vim('j', event); + + t.ok(setCurrentFile.calledWith(last), 'should set next file'); + + t.end(); +}); + +test('cloudcmd: client: key: select +2 files from current before delete', (t) => { + const last = {}; + const nextSibling = { + nextSibling: last + }; + const element = { + nextSibling + }; + + const setCurrentFile = sinon.stub(); + + global.DOM.CurrentInfo.element = element; + global.DOM.setCurrentFile = setCurrentFile; + global.DOM.selectFile = sinon.stub(); + global.DOM.getCurrentName = () => false; + global.CloudCmd.Operation.show = sinon.stub(); + + const event = {}; + + vim('d', event); + vim('2', event); + vim('j', event); + + t.ok(setCurrentFile.calledWith(last), 'should set next file'); + + t.end(); +}); + +test('cloudcmd: client: key: delete +2 files from current', (t) => { + const last = {}; + const nextSibling = { + nextSibling: last + }; + const element = { + nextSibling + }; + + const setCurrentFile = sinon.stub(); + const show = sinon.stub(); + + global.DOM.CurrentInfo.element = element; + global.DOM.setCurrentFile = setCurrentFile; + global.DOM.selectFile = sinon.stub(); + global.DOM.getCurrentName = () => false; + global.CloudCmd.Operation.show = show; + + const event = {}; + + vim('d', event); + vim('2', event); + vim('j', event); + + t.ok(show.calledWith('delete'), 'should call delete'); + + t.end(); +}); + +test('cloudcmd: client: key: set previous file current', (t) => { + const previousSibling = 'hello'; + const element = { + previousSibling + }; + + const setCurrentFile = sinon.stub(); + + global.DOM.CurrentInfo.element = element; + global.DOM.setCurrentFile = setCurrentFile; + + vim('k', {}); + + t.ok(setCurrentFile.calledWith(previousSibling), 'should set previous file'); + + t.end(); +}); + +test('cloudcmd: client: key: copy: no', (t) => { + const copy = sinon.stub(); + + Buffer.copy = copy; + + vim('y', {}); + + t.notOk(copy.called, 'should not copy files'); + + t.end(); +}); + +test('cloudcmd: client: key: copy', (t) => { + const copy = sinon.stub(); + + Buffer.copy = copy; + + vim('v', {}); + vim('y', {}); + + t.ok(copy.calledWith(), 'should copy files'); + + t.end(); +}); + +test('cloudcmd: client: key: copy: unselectFiles', (t) => { + const unselectFiles = sinon.stub(); + + DOM.unselectFiles = unselectFiles; + + vim('v', {}); + vim('y', {}); + + t.ok(unselectFiles.calledWith(), 'should unselect files'); + + t.end(); +}); + +test('cloudcmd: client: key: paste', (t) => { + const paste = sinon.stub(); + + Buffer.paste = paste; + + vim('p', {}); + + t.ok(paste.calledWith(), 'should paste files'); + + t.end(); +}); + +test('cloudcmd: client: key: selectFile: ..', (t) => { + const selectFile = sinon.stub(); + const getCurrentName = sinon.stub(); + + DOM.selectFile = selectFile; + DOM.getCurrentName = () => '..'; + + const current = {}; + vim.selectFile(current); + + t.notOk(getCurrentName.called, 'should not call selectFile'); + t.end(); +}); + +test('cloudcmd: client: key: selectFile', (t) => { + const selectFile = sinon.stub(); + + DOM.selectFile = selectFile; + DOM.getCurrentName = (a) => a.name; + + const current = {}; + + vim.selectFile(current); + + t.ok(selectFile.calledWith(current), 'should call selectFile'); + t.end(); +}); + +test('cloudcmd: client: key: set last file current', (t) => { + const last = 'last'; + const nextSibling = { + nextSibling: last + }; + const element = { + nextSibling + }; + + const setCurrentFile = sinon.stub(); + + global.DOM.CurrentInfo.element = element; + global.DOM.setCurrentFile = setCurrentFile; + + vim('G', {}); + + t.ok(setCurrentFile.calledWith(last), 'should set last file'); + + t.end(); +}); + +test('cloudcmd: client: key: set first file current', (t) => { + const first = 'first'; + const previousSibling= { + previousSibling: first + }; + + const element = { + previousSibling + }; + + const setCurrentFile = sinon.stub(); + + global.DOM.CurrentInfo.element = element; + global.DOM.setCurrentFile = setCurrentFile; + + vim('g', {}); + vim('g', {}); + + t.ok(setCurrentFile.calledWith(first), 'should set first file'); + + t.end(); +}); + +test('cloudcmd: client: key: visual', (t) => { + const element = { + }; + + const toggleSelectedFile = sinon.stub(); + + global.DOM.CurrentInfo.element = element; + global.DOM.toggleSelectedFile = toggleSelectedFile; + + vim('v', {}); + + t.ok(toggleSelectedFile.calledWith(element), 'should toggle selection'); + + t.end(); +}); + +test('cloudcmd: client: key: ESC', (t) => { + const element = { + }; + + const unselectFiles = sinon.stub(); + + global.DOM.CurrentInfo.element = element; + global.DOM.unselectFiles = unselectFiles ; + + vim('', { + keyCode: KEY.ESC + }); + + t.ok(unselectFiles.calledWith(), 'should toggle selection'); + + t.end(); +}); + +test('cloudcmd: client: key: Enter', (t) => { + const nextSibling = 'hello'; + const element = { + nextSibling + }; + + const setCurrentFile = sinon.stub(); + + global.DOM.CurrentInfo.element = element; + global.DOM.setCurrentFile = setCurrentFile; + + vim('', { + keyCode: KEY.ENTER + }); + + vim('j', {}); + + t.ok(setCurrentFile.calledWith(nextSibling), 'should set next file'); + + t.end(); +}); + +function initGlobals() { + const CurrentInfo = { + element: {}, + }; + + const noop = () => {}; + const Buffer = { + copy: noop, + }; + + global.DOM = { + Buffer, + CurrentInfo, + selectFile: noop, + unselectFile: noop, + unselectFiles: noop, + setCurrentFile: noop, + toggleSelectedFile: noop, + }; + + const show = () => {}; + + global.CloudCmd = { + Operation: { + show + } + }; +} + diff --git a/tmpl/config.hbs b/tmpl/config.hbs index 1fee58f24e..02f49b39b2 100644 --- a/tmpl/config.hbs +++ b/tmpl/config.hbs @@ -48,6 +48,15 @@ +
  • + +
  • -