diff --git a/CliClient/tests/pathUtils.js b/CliClient/tests/pathUtils.js
index 9114bf1fe..bd38729ef 100644
--- a/CliClient/tests/pathUtils.js
+++ b/CliClient/tests/pathUtils.js
@@ -1,6 +1,6 @@
require('app-module-path').addPath(__dirname);
-const { friendlySafeFilename } = require('lib/path-utils.js');
+const { extractExecutablePath, quotePath, unquotePath, friendlySafeFilename } = require('lib/path-utils.js');
const { fileContentEqual, setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync } = require('test-utils.js');
process.on('unhandledRejection', (reason, p) => {
@@ -36,4 +36,41 @@ describe('pathUtils', function() {
done();
});
+ it('should quote and unquote paths', async (done) => {
+ const testCases = [
+ ['', ''],
+ ['/my/path', '/my/path'],
+ ['/my/path with spaces', '"/my/path with spaces"'],
+ ['/my/weird"path', '"/my/weird\\"path"'],
+ ['c:\\Windows\\test.dll', 'c:\\Windows\\test.dll'],
+ ['c:\\Windows\\test test.dll', '"c:\\Windows\\test test.dll"'],
+ ];
+
+ for (let i = 0; i < testCases.length; i++) {
+ const t = testCases[i];
+ expect(quotePath(t[0])).toBe(t[1]);
+ expect(unquotePath(quotePath(t[0]))).toBe(t[0]);
+ }
+
+ done();
+ });
+
+ it('should extract executable path from command', async (done) => {
+ const testCases = [
+ ['', ''],
+ ['/my/cmd -some -args', '/my/cmd'],
+ ['"/my/cmd" -some -args', '"/my/cmd"'],
+ ['"/my/cmd"', '"/my/cmd"'],
+ ['"/my/cmd and space" -some -flags', '"/my/cmd and space"'],
+ ['"" -some -flags', '""'],
+ ];
+
+ for (let i = 0; i < testCases.length; i++) {
+ const t = testCases[i];
+ expect(extractExecutablePath(t[0])).toBe(t[1]);
+ }
+
+ done();
+ });
+
});
\ No newline at end of file
diff --git a/ElectronClient/app/gui/ConfigScreen.jsx b/ElectronClient/app/gui/ConfigScreen.jsx
index 8cbe53c7a..be2a6d93b 100644
--- a/ElectronClient/app/gui/ConfigScreen.jsx
+++ b/ElectronClient/app/gui/ConfigScreen.jsx
@@ -7,6 +7,7 @@ const { Header } = require('./Header.min.js');
const { themeStyle } = require('../theme.js');
const pathUtils = require('lib/path-utils.js');
const { _ } = require('lib/locale.js');
+const { commandArgumentsToString } = require('lib/string-utils');
const SyncTargetRegistry = require('lib/SyncTargetRegistry');
const shared = require('lib/components/shared/config-shared.js');
@@ -95,6 +96,15 @@ class ConfigScreenComponent extends React.Component {
color: theme.color,
});
+ const subLabel = Object.assign({}, labelStyle, {
+ opacity: 0.7,
+ marginBottom: Math.round(rowStyle.marginBottom * 0.7),
+ });
+
+ const invisibleLabel = Object.assign({}, labelStyle, {
+ opacity: 0,
+ });
+
const controlStyle = {
display: 'inline-block',
color: theme.color,
@@ -109,6 +119,7 @@ class ConfigScreenComponent extends React.Component {
});
const updateSettingValue = (key, value) => {
+ // console.info(key + ' = ' + value);
return shared.updateSettingValue(this, key, value);
}
@@ -158,23 +169,93 @@ class ConfigScreenComponent extends React.Component {
);
} else if (md.type === Setting.TYPE_STRING) {
- const onTextChange = (event) => {
- updateSettingValue(key, event.target.value);
- }
-
const inputStyle = Object.assign({}, controlStyle, {
width: '50%',
minWidth: '20em',
border: '1px solid' });
const inputType = md.secure === true ? 'password' : 'text';
- return (
-
-
-
{onTextChange(event)}} />
- { descriptionComp }
-
- );
+ if (md.subType === 'file_path_and_args') {
+ inputStyle.marginBottom = subLabel.marginBottom;
+
+ const splitCmd = cmdString => {
+ const path = pathUtils.extractExecutablePath(cmdString);
+ const args = cmdString.substr(path.length + 1);
+ return [pathUtils.unquotePath(path), args];
+ }
+
+ const joinCmd = cmdArray => {
+ if (!cmdArray[0] && !cmdArray[1]) return '';
+ let cmdString = pathUtils.quotePath(cmdArray[0]);
+ if (!cmdString) cmdString = '""';
+ if (cmdArray[1]) cmdString += ' ' + cmdArray[1];
+ return cmdString;
+ }
+
+ const onPathChange = event => {
+ const cmd = splitCmd(this.state.settings[key]);
+ cmd[0] = event.target.value;
+ updateSettingValue(key, joinCmd(cmd));
+ }
+
+ const onArgsChange = event => {
+ const cmd = splitCmd(this.state.settings[key]);
+ cmd[1] = event.target.value;
+ updateSettingValue(key, joinCmd(cmd));
+ }
+
+ const browseButtonClick = () => {
+ const paths = bridge().showOpenDialog();
+ if (!paths || !paths.length) return;
+ const cmd = splitCmd(this.state.settings[key]);
+ cmd[0] = paths[0]
+ updateSettingValue(key, joinCmd(cmd));
+ }
+
+ const cmd = splitCmd(this.state.settings[key]);
+
+ return (
+
+
+
+
+
+
+ { descriptionComp }
+
+
+
+ );
+ } else {
+ const onTextChange = (event) => {
+ updateSettingValue(key, event.target.value);
+ }
+
+ return (
+
+
+
{onTextChange(event)}} />
+ { descriptionComp }
+
+ );
+ }
} else if (md.type === Setting.TYPE_INT) {
const onNumChange = (event) => {
updateSettingValue(key, event.target.value);
diff --git a/ReactNativeClient/lib/models/Setting.js b/ReactNativeClient/lib/models/Setting.js
index bde6651c5..bf353dd3f 100644
--- a/ReactNativeClient/lib/models/Setting.js
+++ b/ReactNativeClient/lib/models/Setting.js
@@ -122,7 +122,7 @@ class Setting extends BaseModel {
'sidebarVisibility': { value: true, type: Setting.TYPE_BOOL, public: false, appTypes: ['desktop'] },
'tagHeaderIsExpanded': { value: true, type: Setting.TYPE_BOOL, public: false, appTypes: ['desktop'] },
'folderHeaderIsExpanded': { value: true, type: Setting.TYPE_BOOL, public: false, appTypes: ['desktop'] },
- 'editor': { value: '', type: Setting.TYPE_STRING, public: true, appTypes: ['cli', 'desktop'], label: () => _('Text editor command'), description: () => _('The editor command (may include arguments) that will be used to open a note. If none is provided it will try to auto-detect the default editor.') },
+ 'editor': { value: '', type: Setting.TYPE_STRING, subType: 'file_path_and_args', public: true, appTypes: ['cli', 'desktop'], label: () => _('Text editor command'), description: () => _('The editor command (may include arguments) that will be used to open a note. If none is provided it will try to auto-detect the default editor.') },
'showAdvancedOptions': { value: false, type: Setting.TYPE_BOOL, public: true, appTypes: ['mobile' ], label: () => _('Show advanced options') },
'sync.target': { value: SyncTargetRegistry.nameToId('dropbox'), type: Setting.TYPE_INT, isEnum: true, public: true, section:'sync', label: () => _('Synchronisation target'), description: (appType) => { return appType !== 'cli' ? null : _('The target to synchonise to. Each sync target may have additional parameters which are named as `sync.NUM.NAME` (all documented below).') }, options: () => {
return SyncTargetRegistry.idAndLabelPlainObject();
diff --git a/ReactNativeClient/lib/path-utils.js b/ReactNativeClient/lib/path-utils.js
index 7bc0646f7..bbb4903b3 100644
--- a/ReactNativeClient/lib/path-utils.js
+++ b/ReactNativeClient/lib/path-utils.js
@@ -114,4 +114,43 @@ function ltrimSlashes(path) {
return path.replace(/^\/+/, '');
}
-module.exports = { basename, dirname, filename, isHidden, fileExtension, safeFilename, friendlySafeFilename, safeFileExtension, toSystemSlashes, rtrimSlashes, ltrimSlashes };
\ No newline at end of file
+function quotePath(path) {
+ if (!path) return '';
+ if (path.indexOf('"') < 0 && path.indexOf(' ') < 0) return path;
+ path = path.replace(/"/, '\\"');
+ return '"' + path + '"';
+}
+
+function unquotePath(path) {
+ if (!path.length) return '';
+ if (path.length && path[0] === '"') {
+ path = path.substr(1, path.length - 2);
+ }
+ path = path.replace(/\\"/, '"');
+ return path;
+}
+
+function extractExecutablePath(cmd) {
+ if (!cmd.length) return '';
+
+ const quoteType = ['"', "'"].indexOf(cmd[0]) >= 0 ? cmd[0] : '';
+
+ let output = '';
+ for (let i = 0; i < cmd.length; i++) {
+ const c = cmd[i];
+ if (quoteType) {
+ if (i > 0 && c === quoteType) {
+ output += c;
+ break;
+ }
+ } else {
+ if (c === ' ') break;
+ }
+
+ output += c;
+ }
+
+ return output;
+}
+
+module.exports = { extractExecutablePath, basename, dirname, filename, isHidden, fileExtension, safeFilename, friendlySafeFilename, safeFileExtension, toSystemSlashes, rtrimSlashes, ltrimSlashes, quotePath, unquotePath };
\ No newline at end of file
diff --git a/ReactNativeClient/lib/string-utils.js b/ReactNativeClient/lib/string-utils.js
index 099e7f4ea..1488626ab 100644
--- a/ReactNativeClient/lib/string-utils.js
+++ b/ReactNativeClient/lib/string-utils.js
@@ -123,6 +123,19 @@ function wrap(text, indent, width) {
});
}
+function commandArgumentsToString(args) {
+ let output = [];
+ for (let i = 0; i < args.length; i++) {
+ let arg = args[i];
+ const quote = arg.indexOf('"') >= 0 ? "'" : '"';
+ if (arg.indexOf(' ') >= 0) {
+ arg = quote + arg + quote;
+ }
+ output.push(arg);
+ }
+ return output.join(' ');;
+}
+
function splitCommandString(command, options = null) {
options = options || {};
if (!('handleEscape' in options)) {
@@ -252,6 +265,6 @@ function scriptType(s) {
}
module.exports = Object.assign(
- { removeDiacritics, escapeFilename, wrap, splitCommandString, padLeft, toTitleCase, urlDecode, escapeHtml, surroundKeywords, scriptType },
+ { removeDiacritics, escapeFilename, wrap, splitCommandString, padLeft, toTitleCase, urlDecode, escapeHtml, surroundKeywords, scriptType, commandArgumentsToString },
stringUtilsCommon,
);