diff --git a/.cloudcmd.menu.js b/.cloudcmd.menu.js new file mode 100644 index 00000000..010b6615 --- /dev/null +++ b/.cloudcmd.menu.js @@ -0,0 +1,25 @@ +'use strict'; + +module.exports = { + 'F2 - Rename file': async ({DOM}) => { + await DOM.renameCurrent(); + }, + 'D - Build Dev': async ({CloudCmd}) => { + await CloudCmd.TerminalRun.show({ + command: 'npm run build:client:dev', + autoClose: false, + closeMessage: 'Press any button to close Terminal', + }); + + CloudCmd.refresh(); + }, + 'P - Build Prod': async ({CloudCmd}) => { + await CloudCmd.TerminalRun.show({ + command: 'npm run build:client', + autoClose: true, + }); + + CloudCmd.refresh(); + }, +}; + diff --git a/.webpack/css.js b/.webpack/css.js index f28fa100..2338b53d 100644 --- a/.webpack/css.js +++ b/.webpack/css.js @@ -21,6 +21,7 @@ const cssNames = [ 'view', 'config', 'terminal', + 'user-menu', ...getCSSList('columns'), ]; @@ -35,7 +36,7 @@ const plugins = clean([ const rules = [{ test: /\.css$/, - exclude: /css\/(nojs|view|config|terminal|columns.*)\.css/, + exclude: /css\/(nojs|view|config|terminal|user-menu|columns.*)\.css/, use: extractMain.extract([ 'css-loader', ]), diff --git a/.webpack/js.js b/.webpack/js.js index 7fd45044..e156fbd1 100644 --- a/.webpack/js.js +++ b/.webpack/js.js @@ -32,7 +32,6 @@ const babelDev = { babelrc: false, plugins: [ 'module:babel-plugin-macros', - '@babel/plugin-proposal-object-rest-spread', ], }; @@ -43,7 +42,7 @@ const rules = clean([ loader: 'babel-loader', }, isDev && { - test: /sw\.js$/, + test: /\.js$/, exclude: /node_modules/, loader: 'babel-loader', options: babelDev, @@ -90,7 +89,9 @@ module.exports = { [modules + '/operation']: `${dirModules}/operation/index.js`, [modules + '/konsole']: `${dirModules}/konsole.js`, [modules + '/terminal']: `${dirModules}/terminal.js`, + [modules + '/terminal-run']: `${dirModules}/terminal-run.js`, [modules + '/cloud']: `${dirModules}/cloud.js`, + [modules + '/user-menu']: `${dirModules}/user-menu/index.js`, [modules + '/polyfill']: `${dirModules}/polyfill.js`, }, output: { diff --git a/.yaspellerrc b/.yaspellerrc index c6a7ec3b..ad0eddc1 100644 --- a/.yaspellerrc +++ b/.yaspellerrc @@ -10,6 +10,7 @@ ".md" ], "dictionary":[ + "CloudCmd", "Dev", "Dropbox", "Deepword", @@ -27,12 +28,14 @@ "Zalitok", "WebSocket", "auth", + "binded", "cd", "cloudcmd", "coderaiser", "com", - "dev", "deepword", + "dev", + "destructuring", "dropbox", "dword", "edward", diff --git a/HELP.md b/HELP.md index 5540f599..2ec743a8 100644 --- a/HELP.md +++ b/HELP.md @@ -109,6 +109,7 @@ Cloud Commander supports the following command-line parameters: | `--dropbox` | enable dropbox integration | `--dropbox-token` | set dropbox token | `--log` | enable logging +| `--user-menu` | enable user menu | `--no-show-config` | do not show config values | `--no-server` | do not start server | `--no-auth` | disable authorization @@ -137,6 +138,7 @@ Cloud Commander supports the following command-line parameters: | `--no-dropbox` | disable dropbox integration | `--no-dropbox-token` | unset dropbox token | `--no-log` | disable logging +| `--no-user-menu` | disable user menu For options not specified by command-line parameters, Cloud Commander then reads configuration data from `~/.cloudcmd.json`. It uses port `8000` by default. @@ -162,7 +164,7 @@ Hot keys |Key |Operation |:----------------------|:-------------------------------------------- | `F1` | help -| `F2` | rename +| `F2` | rename or show `user menu` | `F3` | view, change directory | `Shift + F3` | view as markdown | `F4` | edit @@ -421,7 +423,8 @@ Here's a description of all options: "importListen" : false, // listen on config updates "dropbox" : false, // disable dropbox integration "dropboxToken" : "", // unset dropbox token - "log" : true // logging + "log" : true, // logging + "userMenu" : false // do not show user menu } ``` @@ -458,6 +461,52 @@ Some config options can be overridden with environment variables, such as: - `CLOUDCMD_IMPORT_TOKEN` - authorization token used to connect to export server - `CLOUDCMD_IMPORT_URL` - url of an import server - `CLOUDCMD_IMPORT_LISTEN`- enable listen on config updates from import server +- `CLOUDCMD_USER_MENU`- enable `user menu` + +### User Menu + +You can enable `user menu` with help of a flag `--user-menu` (consider that file rename using `F2` will be disabled). +When you press `F2` Cloud Commander will a file `.cloudcmd.menu.js` by walking up parent directories, if can't read it will try to read `~/.cloudcmd.menu.js`. +Let's consider example `user menu` works file: + +```js +module.exports = { + 'F2 - Rename file': async ({DOM}) => { + await DOM.renameCurrent(); + }, + 'D - Build Dev': async ({CloudCmd}) => { + await CloudCmd.TerminalRun.show({ + command: 'npm run build:client:dev', + autoClose: false, + closeMessage: 'Press any button to close Terminal', + }); + + CloudCmd.refresh(); + }, + 'P - Build Prod': async ({CloudCmd}) => { + await CloudCmd.TerminalRun.show({ + command: 'npm run build:client', + autoClose: true, + }); + + CloudCmd.refresh(); + }, +}; +``` + +You will have ability to run one of this 3 commands with help of double click, enter, or binded key (`F2`, `D` or `P` in this example). Also you can run commands in terminal, or execute any built-in function of `Cloud Commander` extended it's interface. + +#### User Menu API + +Here you can find `API` that can be used in **User Menu**. **DOM** and **CloudCmd** to main objects you receive in arguments list using destructuring. + +**DOM** contains all base functions of `Cloud Commander` (rename, remove, download etc); + +- `renameCurrent` - shows renames current file dialog, and does renaming. + +**CloudCmd** contains all modules (`Terminal`, `View`, `Edit`, `Config`, `Console` etc); + +- `TerminalRun` - module that shows `Terminal` with a `command` from options and closes terminal when everything is done. ### Distribute diff --git a/bin/cloudcmd.js b/bin/cloudcmd.js index 95da4136..1e4e3ac5 100755 --- a/bin/cloudcmd.js +++ b/bin/cloudcmd.js @@ -73,6 +73,7 @@ const args = require('minimist')(argv.slice(2), { 'import-listen', 'log', 'dropbox', + 'user-menu', ], default: { server : true, @@ -111,6 +112,7 @@ const args = require('minimist')(argv.slice(2), { 'one-file-panel': choose(env.bool('one_file_panel'), config('oneFilePanel')), 'confirm-copy': choose(env.bool('confirm_copy'), config('confirmCopy')), 'confirm-move': choose(env.bool('confirm_move'), config('confirmMove')), + 'user-menu': choose(env.bool('user_menu'), config('userMenu')), 'keys-panel': env.bool('keys_panel') || config('keysPanel'), 'import-token': env('import_token') || config('importToken'), 'export-token': env('export_token') || config('exportToken'), @@ -180,6 +182,7 @@ function main() { config('importToken', args['import-token']); config('importListen', args['import-listen']); config('importUrl', args['import-url']); + config('userMenu', args['user-menu']); config('dropbox', args['dropbox']); config('dropboxToken', args['dropbox-token'] || ''); diff --git a/client/dom/index.js b/client/dom/index.js index e7623e09..271b563d 100644 --- a/client/dom/index.js +++ b/client/dom/index.js @@ -5,6 +5,8 @@ const itype = require('itype/legacy'); const exec = require('execon'); const jonny = require('jonny/legacy'); +const tryToCatch = require('try-to-catch/legacy'); + const Util = require('../../common/util'); const Images = require('./images'); @@ -746,7 +748,7 @@ function CmdProto() { * * @currentFile */ - this.renameCurrent = (current) => { + this.renameCurrent = async (current) => { const {Dialog} = DOM; if (!DOM.isCurrentFile(current)) @@ -757,30 +759,30 @@ function CmdProto() { if (from === '..') return Dialog.alert.noFiles(); - const cancel = false; + const [e, to] = await tryToCatch(Dialog.prompt, 'Rename', from); - Dialog.prompt('Rename', from, {cancel}).then((to) => { - const isExist = !!DOM.getCurrentByName(to); - const dirPath = DOM.getCurrentDirPath(); - - if (from === to) + if (e) + return; + const isExist = !!DOM.getCurrentByName(to); + const dirPath = DOM.getCurrentDirPath(); + + if (from === to) + return; + + const files = { + from : dirPath + from, + to : dirPath + to, + }; + + RESTful.mv(files, (error) => { + if (error) return; - const files = { - from : dirPath + from, - to : dirPath + to, - }; + DOM.setCurrentName(to, current); + Storage.remove(dirPath); - RESTful.mv(files, (error) => { - if (error) - return; - - DOM.setCurrentName(to, current); - Storage.remove(dirPath); - - if (isExist) - CloudCmd.refresh(); - }); + if (isExist) + CloudCmd.refresh(); }); }; @@ -940,7 +942,7 @@ function CmdProto() { const info = DOM.CurrentInfo; const current = currentFile || DOM.getCurrentFile(); const files = current.parentElement; - const panel = files.parentElement; + const panel = files.parentElement || DOM.getPanel(); const panelPassive = DOM.getPanel({ active: false, diff --git a/client/key/index.js b/client/key/index.js index 6cb11e4d..627400a4 100644 --- a/client/key/index.js +++ b/client/key/index.js @@ -186,6 +186,9 @@ function KeyProto() { break; case Key.F2: + if (CloudCmd.config('userMenu')) + return CloudCmd.UserMenu.show(); + DOM.renameCurrent(current); break; diff --git a/client/listeners/index.js b/client/listeners/index.js index ffc4d8c5..2f8c6771 100644 --- a/client/listeners/index.js +++ b/client/listeners/index.js @@ -123,7 +123,7 @@ module.exports.initKeysPanel = () => { const clickFuncs = { 'f1' : CloudCmd.Help.show, - 'f2' : DOM.renameCurrent, + 'f2' : initF2, 'f3' : CloudCmd.View.show, 'f4' : CloudCmd.EditFile.show, 'f5' : operation('copy'), @@ -141,6 +141,13 @@ module.exports.initKeysPanel = () => { }); }; +function initF2() { + if (CloudCmd.config('userMenu')) + return CloudCmd.UserMenu.show(); + + return DOM.renameCurrent(); +} + const getPanel = (side) => { if (!itype.string(side)) return side; diff --git a/client/modules/operation/set-listeners.js b/client/modules/operation/set-listeners.js index f3414772..71d7b64e 100644 --- a/client/modules/operation/set-listeners.js +++ b/client/modules/operation/set-listeners.js @@ -1,7 +1,6 @@ 'use strict'; /* global DOM */ -/* global CloudCmd */ const { Dialog, diff --git a/client/modules/terminal-run.js b/client/modules/terminal-run.js new file mode 100644 index 00000000..02ce4f28 --- /dev/null +++ b/client/modules/terminal-run.js @@ -0,0 +1,146 @@ +'use strict'; + +/* global CloudCmd, gritty */ + +const {promisify} = require('es6-promisify'); +const tryToCatch = require('try-to-catch/legacy'); + +require('../../css/terminal.css'); + +const exec = require('execon'); +const load = require('load.js'); +const DOM = require('../dom'); +const Images = require('../dom/images'); + +const loadParallel = promisify(load.parallel); + +const {Dialog} = DOM; +const { + Key, + config, +} = CloudCmd; + +CloudCmd.TerminalRun = exports; + +let Loaded; +let Terminal; +let Socket; + +const loadAll = async () => { + const {prefix} = CloudCmd; + + const prefixGritty = getPrefix(); + const js = `${prefixGritty}/gritty.js`; + const css = `${prefix}/dist/terminal.css`; + + const [e] = await tryToCatch(loadParallel, [js, css]); + + if (e) { + const src = e.target.src.replace(window.location.href, ''); + return Dialog.alert(`file ${src} could not be loaded`); + } + + Loaded = true; +}; + +module.exports.init = async () => { + if (!config('terminal')) + return; + + Images.show.load('top'); + + await CloudCmd.View(); + await loadAll(); +}; + +module.exports.show = show; +module.exports.hide = hide; + +function hide () { + CloudCmd.View.hide(); +} + +function getPrefix() { + return CloudCmd.prefix + '/gritty'; +} + +function getPrefixSocket() { + return CloudCmd.prefixSocket + '/gritty'; +} + +function getEnv() { + return { + ACTIVE_DIR: DOM.getCurrentDirPath, + PASSIVE_DIR: DOM.getNotCurrentDirPath, + CURRENT_NAME: DOM.getCurrentName, + CURRENT_PATH: DOM.getCurrentPath, + }; +} + +function create(createOptions) { + const { + command, + autoClose, + closeMessage = 'Press any key to close Terminal...', + } = createOptions; + + const options = { + env: getEnv(), + prefix: getPrefixSocket(), + socketPath: CloudCmd.prefix, + fontFamily: 'Droid Sans Mono', + command, + autoRestart: false, + }; + + let commandExit = false; + + const {socket, terminal} = gritty(document.body, options); + + Socket = socket; + Terminal = terminal; + + Terminal.on('key', (char, {keyCode, shiftKey}) => { + if (commandExit) + hide(); + + if (shiftKey && keyCode === Key.ESC) { + hide(); + } + }); + + Socket.on('exit', () => { + if (autoClose) + return hide(); + + terminal.write(`\n${closeMessage}`); + commandExit = true; + }); + + Socket.on('connect', exec.with(authCheck, socket)); +} + +function authCheck(spawn) { + spawn.emit('auth', config('username'), config('password')); + + spawn.on('reject', () => { + Dialog.alert('Wrong credentials!'); + }); +} + +async function show(options = {}) { + if (!Loaded) + return; + + if (!config('terminal')) + return; + + await create(options); + + CloudCmd.View.show(Terminal.element, { + afterShow: () => { + Terminal.focus(); + }, + }); +} + diff --git a/client/modules/terminal.js b/client/modules/terminal.js index ddb7cd37..ee33f4ae 100644 --- a/client/modules/terminal.js +++ b/client/modules/terminal.js @@ -24,6 +24,7 @@ CloudCmd.Terminal = exports; let Loaded; let Terminal; +let Socket; const loadAll = async () => { const {prefix} = CloudCmd; @@ -84,17 +85,19 @@ function create() { socketPath: CloudCmd.prefix, fontFamily: 'Droid Sans Mono', }; + const {socket, terminal} = gritty(document.body, options); + Socket = socket; Terminal = terminal; - terminal.on('key', (char, {keyCode, shiftKey}) => { + Terminal.on('key', (char, {keyCode, shiftKey}) => { if (shiftKey && keyCode === Key.ESC) { hide(); } }); - socket.on('connect', exec.with(authCheck, socket)); + Socket.on('connect', exec.with(authCheck, socket)); } function authCheck(spawn) { @@ -105,7 +108,7 @@ function authCheck(spawn) { }); } -function show(callback) { +function show() { if (!Loaded) return; @@ -114,10 +117,7 @@ function show(callback) { CloudCmd.View.show(Terminal.element, { afterShow: () => { - if (Terminal) - Terminal.focus(); - - exec(callback); + Terminal.focus(); }, }); } diff --git a/client/modules/user-menu/get-user-menu.js b/client/modules/user-menu/get-user-menu.js new file mode 100644 index 00000000..4b3d8923 --- /dev/null +++ b/client/modules/user-menu/get-user-menu.js @@ -0,0 +1,20 @@ +'use strict'; + +const defaultUserMenu = { + 'F2 - Rename file': async ({DOM}) => { + DOM.renameCurrent(); + }, +}; + +module.exports = (menuFn) => { + if (!menuFn) + return defaultUserMenu; + + const module = {}; + const fn = Function('module', menuFn); + + fn(module); + + return module.exports; +}; + diff --git a/client/modules/user-menu/get-user-menu.spec.js b/client/modules/user-menu/get-user-menu.spec.js new file mode 100644 index 00000000..77ee4fea --- /dev/null +++ b/client/modules/user-menu/get-user-menu.spec.js @@ -0,0 +1,29 @@ +'use strict'; + +const test = require('supertape'); +const getUserMenu = require('./get-user-menu'); + +test('user-menu: getUserMenu', (t) => { + const menu = `module.exports = { + 'F2 - Rename file': ({DOM}) => { + const {element} = DOM.CurrentInfo; + DOM.renameCurrent(element); + } + }`; + + const result = getUserMenu(menu); + + const [key] = Object.keys(result); + + t.equal(key, 'F2 - Rename file', 'should equal'); + t.end(); +}); + +test('user-menu: getUserMenu: no args', (t) => { + const result = getUserMenu(); + const [key] = Object.keys(result); + + t.equal(key, 'F2 - Rename file', 'should equal'); + t.end(); +}); + diff --git a/client/modules/user-menu/index.js b/client/modules/user-menu/index.js new file mode 100644 index 00000000..78e1f5dc --- /dev/null +++ b/client/modules/user-menu/index.js @@ -0,0 +1,112 @@ +'use strict'; + +/* global CloudCmd, DOM */ + +require('../../../css/user-menu.css'); + +const currify = require('currify/legacy'); +const {promisify} = require('es6-promisify'); +const load = require('load.js'); +const createElement = require('@cloudcmd/create-element'); + +const Images = require('../../dom/images'); +const getUserMenu = require('./get-user-menu'); + +const loadCSS = promisify(load.css); + +const Name = 'UserMenu'; +CloudCmd[Name] = module.exports; + +const {Key} = CloudCmd; + +module.exports.init = async () => { + await Promise.all([ + loadCSS(`${CloudCmd.prefix}/dist/user-menu.css`), + CloudCmd.View(), + ]); +}; + +module.exports.show = show; +module.exports.hide = hide; + +const getKey = (a) => a.split(' - ')[0]; +const beginWith = (a) => (b) => !b.indexOf(a); + +const {CurrentInfo} = DOM; + +async function show() { + Images.show.load('top'); + + const {dirPath} = CurrentInfo; + const res = await fetch(`/api/v1/user-menu?dir=${dirPath}`); + const userMenu = getUserMenu(await res.text()); + const options = Object.keys(userMenu); + + const el = createElement('select', { + className: 'cloudcmd-user-menu', + innerHTML: fillTemplate(options), + size: 10, + }); + + const keys = options.map(getKey); + el.addEventListener('keydown', onKeyDown(keys, options, userMenu)); + el.addEventListener('dblclick', onDblClick(options, userMenu)); + + const afterShow = () => el.focus(); + const autoSize = true; + + Images.hide(); + + CloudCmd.View.show(el, { + autoSize, + afterShow, + }); +} + +function fillTemplate(options) { + const result = []; + + for (const option of options) { + result.push(``); + } + + return result.join(''); +} + +function hide() { + CloudCmd.View.hide(); +} + +const onDblClick = currify(async (options, userMenu, e) => { + const {value} = e.target; + await runUserMenu(value, options, userMenu); +}); + +const onKeyDown = currify(async (keys, options, userMenu, e) => { + const {keyCode} = e; + const key = e.key.toUpperCase(); + + let value; + + if (keyCode === Key.ENTER) + ({value} = e.target); + else if (keys.includes(key)) + value = options.find(beginWith(key)); + else + return; + + e.preventDefault(); + e.stopPropagation(); + + await runUserMenu(value, options, userMenu); +}); + +const runUserMenu = async (value, options, userMenu) => { + hide(); + + await userMenu[value]({ + DOM, + CloudCmd, + }); +}; + diff --git a/css/icons.css b/css/icons.css index 4f979db7..253b3593 100644 --- a/css/icons.css +++ b/css/icons.css @@ -132,3 +132,8 @@ font-family : 'Fontello'; content : '\e81b '; } + +.icon-user-menu::before { + font-family : 'Fontello'; + content : '\e81c '; +} diff --git a/css/user-menu.css b/css/user-menu.css new file mode 100644 index 00000000..37f07c14 --- /dev/null +++ b/css/user-menu.css @@ -0,0 +1,14 @@ +.cloudcmd-user-menu { + font-size: 16px; + font-family: 'Droid Sans Mono', 'Ubuntu Mono', 'Consolas', monospace; + width: 400px; +} + +.cloudcmd-user-menu:focus { + outline: 0; +} + +.cloudcmd-user-menu > option:checked { + box-shadow: 20px -20px 0 2px rgba(49, 123, 249) inset; +} + diff --git a/font/fontello.eot b/font/fontello.eot index 4274ddd2..c886783f 100644 Binary files a/font/fontello.eot and b/font/fontello.eot differ diff --git a/font/fontello.json b/font/fontello.json index 61c8d00e..354b93cc 100644 --- a/font/fontello.json +++ b/font/fontello.json @@ -174,6 +174,12 @@ "code": 59413, "src": "entypo" }, + { + "uid": "f805bb95d40c7ef2bc51b3d50d4f2e5c", + "css": "th-list", + "code": 59420, + "src": "fontawesome" + }, { "uid": "60617c8adc1e7eb3c444a5491dd13f57", "css": "attention-circled-1", diff --git a/font/fontello.svg b/font/fontello.svg index afc5a844..ac6e533b 100644 --- a/font/fontello.svg +++ b/font/fontello.svg @@ -1,7 +1,7 @@ \ No newline at end of file diff --git a/font/fontello.ttf b/font/fontello.ttf index 1065b0d3..57d3d331 100644 Binary files a/font/fontello.ttf and b/font/fontello.ttf differ diff --git a/font/fontello.woff b/font/fontello.woff index 9a4a5952..fd4d9d12 100644 Binary files a/font/fontello.woff and b/font/fontello.woff differ diff --git a/font/fontello.woff2 b/font/fontello.woff2 index b8c98b96..dae85710 100644 Binary files a/font/fontello.woff2 and b/font/fontello.woff2 differ diff --git a/html/index.html b/html/index.html index e0fd8595..7f6edac5 100644 --- a/html/index.html +++ b/html/index.html @@ -21,6 +21,7 @@