From 2012b36215f2dfd41291a0c9354d60f063e8ec13 Mon Sep 17 00:00:00 2001 From: Paul Donald Date: Sun, 6 Jul 2025 04:04:28 +0200 Subject: [PATCH] luci-base: extend FileUpload; create DirectoryPicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FileUpload was extended to accommodate the new features, since it has nearly everything already. Create a DirectoryPicker convenience wrapper. Additions are: -directory creation (dialogue); set directory_create to true -directory select mode (instead of file); set directory_select to true Also fix a bug in the breadcrumb generation which produced: /foo » » bar for /foo/bar if root_directory is not '/', and another bug that merged links together when navigating upward again using the breadcrumbs. Signed-off-by: Paul Donald --- .../htdocs/luci-static/resources/form.js | 200 +++++++++++++++++- .../htdocs/luci-static/resources/ui.js | 88 +++++++- 2 files changed, 278 insertions(+), 10 deletions(-) diff --git a/modules/luci-base/htdocs/luci-static/resources/form.js b/modules/luci-base/htdocs/luci-static/resources/form.js index f2f497a1fa..9c2ea4a184 100644 --- a/modules/luci-base/htdocs/luci-static/resources/form.js +++ b/modules/luci-base/htdocs/luci-static/resources/form.js @@ -4883,13 +4883,13 @@ const CBIHiddenValue = CBIValue.extend(/** @lends LuCI.form.HiddenValue.prototyp * offers the ability to browse, upload and select remote files. * * @param {LuCI.form.Map|LuCI.form.JSONMap} form - * The configuration form to which this section is added to. It is automatically passed + * The configuration form to which this section is added. It is automatically passed * by [option()]{@link LuCI.form.AbstractSection#option} or * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the * option to the section. * * @param {LuCI.form.AbstractSection} section - * The configuration section this option is added to. It is automatically passed + * The configuration section this option is added. It is automatically passed * by [option()]{@link LuCI.form.AbstractSection#option} or * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the * option to the section. @@ -4910,6 +4910,8 @@ const CBIFileUpload = CBIValue.extend(/** @lends LuCI.form.FileUpload.prototype this.super('__init__', args); this.browser = false; + this.directory_create = false; + this.directory_select = false; this.show_hidden = false; this.enable_upload = true; this.enable_remove = true; @@ -4919,7 +4921,8 @@ const CBIFileUpload = CBIValue.extend(/** @lends LuCI.form.FileUpload.prototype /** - * Open in a file browser mode instead of selecting for a file + * Render the widget in browser mode initially instead of a button + * to 'Select File...'. * * @name LuCI.form.FileUpload.prototype#browser * @type boolean @@ -4955,6 +4958,35 @@ const CBIFileUpload = CBIValue.extend(/** @lends LuCI.form.FileUpload.prototype * @default true */ + /** + * Toggle remote directory create functionality. + * + * When set to `true`, the underlying widget provides a button which lets + * the user create directories. Note that this is merely + * a cosmetic feature: remote create permissions are controlled by the + * session ACL rules. + * + * The default of `false` means the directory create button is hidden. + * + * @name LuCI.form.FileUpload.prototype#directory_create + * @type boolean + * @default false + */ + + /** + * Toggle remote directory select functionality. + * + * When set to `true`, the underlying widget changes behaviour to select + * directories instead of files, in effect, becoming a directory + * picker. + * + * The default is `false`. + * + * @name LuCI.form.FileUpload.prototype#directory_select + * @type boolean + * @default false + */ + /** * Toggle remote file delete functionality. * @@ -5001,6 +5033,8 @@ const CBIFileUpload = CBIValue.extend(/** @lends LuCI.form.FileUpload.prototype name: this.cbid(section_id), browser: this.browser, show_hidden: this.show_hidden, + directory_create: this.directory_create, + directory_select: this.directory_select, enable_upload: this.enable_upload, enable_remove: this.enable_remove, enable_download: this.enable_download, @@ -5012,6 +5046,165 @@ const CBIFileUpload = CBIValue.extend(/** @lends LuCI.form.FileUpload.prototype } }); +/** + * @class DirectoryPicker + * @memberof LuCI.form + * @augments LuCI.form.Value + * @hideconstructor + * @classdesc + * + * The `DirectoryPicker` element wraps a {@link LuCI.ui.FileUpload} widget and + * offers the ability to browse, create, delete and select remote directories. + * + * @param {LuCI.form.Map|LuCI.form.JSONMap} form + * The configuration form to which this section is added. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {LuCI.form.AbstractSection} section + * The configuration section this option is added. It is automatically passed + * by [option()]{@link LuCI.form.AbstractSection#option} or + * [taboption()]{@link LuCI.form.AbstractSection#taboption} when adding the + * option to the section. + * + * @param {string} option + * The name of the UCI option to map. + * + * @param {string} [title] + * The title caption of the option element. + * + * @param {string} [description] + * The description text of the option element. + */ +const CBIDirectoryPicker = CBIValue.extend(/** @lends LuCI.form.DirectoryPicker.prototype */ { + __name__: 'CBI.DirectoryPicker', + + __init__(...args) { + this.super('__init__', args); + + this.browser = false; + this.directory_create = false; + this.enable_download = false; + this.enable_remove = false; + this.enable_upload = false; + this.root_directory = '/tmp'; + this.show_hidden = true; + }, + + + /** + * Render the widget in browser mode initially instead of a button + * to 'Select Directory...'. + * + * @name LuCI.form.DirectoryPicker.prototype#browser + * @type boolean + * @default false + */ + + /** + * Toggle remote directory create functionality. + * + * When set to `true`, the underlying widget provides a button which lets + * the user create directories. Note that this is merely + * a cosmetic feature: remote create permissions are controlled by the + * session ACL rules. + * + * The default of `false` means the directory create button is hidden. + * + * @name LuCI.form.DirectoryPicker.prototype#directory_create + * @type boolean + * @default false + */ + + /** + * Toggle download file functionality. + * + * @name LuCI.form.DirectoryPicker.prototype#enable_download + * @type boolean + * @default false + */ + + /** + * Toggle remote file delete functionality. + * + * When set to `true`, the underlying widget provides buttons which let + * the user delete files from remote directories. Note that this is merely + * a cosmetic feature: remote delete permissions are controlled by the + * session ACL rules. + * + * The default is `false`, means file removal buttons are not displayed. + * + * @name LuCI.form.DirectoryPicker.prototype#enable_remove + * @type boolean + * @default false + */ + + /** + * Toggle file upload functionality. + * + * When set to `true`, the underlying widget provides a button which lets + * the user select and upload local files to the remote system. + * Note that this is merely a cosmetic feature: remote upload access is + * controlled by the session ACL rules. + * + * The default of `false` means file upload functionality is disabled. + * + * @name LuCI.form.DirectoryPicker.prototype#enable_upload + * @type boolean + * @default false + */ + + /** + * Specify the root directory for file browsing. + * + * This property defines the topmost directory the file browser widget may + * navigate to. The UI will not allow browsing directories outside this + * prefix. Note that this is merely a cosmetic feature: remote file access + * and directory listing permissions are controlled by the session ACL + * rules. + * + * The default is `/tmp`. + * + * @name LuCI.form.DirectoryPicker.prototype#root_directory + * @type string + * @default /tmp + */ + + /** + * Toggle display of hidden files. + * + * Display hidden files when rendering the remote directory listing. + * Note that this is merely a cosmetic feature: hidden files are always + * included in received remote file listings. + * + * The default of `true` means hidden files are displayed. + * + * @name LuCI.form.DirectoryPicker.prototype#show_hidden + * @type boolean + * @default true + */ + + /** @private */ + renderWidget(section_id, option_index, cfgvalue) { + const browserEl = new ui.FileUpload((cfgvalue != null) ? cfgvalue : this.default, { + id: this.cbid(section_id), + name: this.cbid(section_id), + browser: this.browser, + directory_create: this.directory_create, + directory_select: true, + enable_download: this.enable_download, + enable_remove: this.enable_remove, + enable_upload: this.enable_upload, + root_directory: this.root_directory, + show_hidden: this.show_hidden, + disabled: (this.readonly != null) ? this.readonly : this.map.readonly + }); + + return browserEl.render(); + } +}); + /** * @class SectionValue * @memberof LuCI.form @@ -5202,5 +5395,6 @@ return baseclass.extend(/** @lends LuCI.form.prototype */ { Button: CBIButtonValue, HiddenValue: CBIHiddenValue, FileUpload: CBIFileUpload, + DirectoryPicker: CBIDirectoryPicker, SectionValue: CBISectionValue }); diff --git a/modules/luci-base/htdocs/luci-static/resources/ui.js b/modules/luci-base/htdocs/luci-static/resources/ui.js index b85c0de329..c8857b01c5 100644 --- a/modules/luci-base/htdocs/luci-static/resources/ui.js +++ b/modules/luci-base/htdocs/luci-static/resources/ui.js @@ -2921,6 +2921,12 @@ const UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ * remotely depends on the ACL setup for the current session. This option * merely controls whether the file remove controls are rendered or not. * + * @property {boolean} [directory_create=false] + * Specifies whether the widget allows the user to create directories. + * + * @property {boolean} [directory_select=false] + * Specifies whether the widget shall select directories only instead of files. + * * @property {boolean} [enable_download=false] * Specifies whether the widget allows the user to download files. * @@ -2935,6 +2941,8 @@ const UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ this.value = value; this.options = Object.assign({ browser: false, + directory_create: false, + directory_select: false, show_hidden: false, enable_upload: true, enable_remove: true, @@ -2960,15 +2968,17 @@ const UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ const renderFileBrowser = L.resolveDefault(this.value != null ? fs.stat(this.value) : null).then(L.bind((stat) => { let label; - if (L.isObject(stat) && stat.type != 'directory') + if (L.isObject(stat)) this.stat = stat; - if (this.stat != null) + if (this.stat != null && this.stat.type === 'directory') + label = [ this.iconForType(this.stat.type), ' %s'.format(this.truncatePath(this.stat.path)) ]; + else if (this.stat != null && this.stat.type !== 'directory') label = [ this.iconForType(this.stat.type), ' %s (%1000mB)'.format(this.truncatePath(this.stat.path), this.stat.size) ]; else if (this.value != null) label = [ this.iconForType('file'), ' %s (%s)'.format(this.truncatePath(this.value), _('File not accessible')) ]; else - label = [ _('Select file…') ]; + label = [ this.options.directory_select ? _('Select directory…') : _('Select file…') ]; let btnOpenFileBrowser = E('button', { 'class': 'btn open-file-browser', 'click': UI.prototype.createHandlerFn(this, 'handleFileBrowser'), @@ -3051,13 +3061,65 @@ const UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ if (cpath.length <= croot.length) return [ croot ]; - const parts = cpath.substring(croot.length).split(/\//); + const parts = cpath.substring(croot.length).split(/\//).filter(p => p !== ''); parts.unshift(croot); return parts; }, + /** @private */ + handleCreateDirectory(path, ev) { + const container = E('div', { 'class': 'uci-dialog' }); + + const input = E('input', { + 'type': 'text', + 'placeholder': _('Directory name'), + 'style': 'margin-right: 0.5em' + }); + + const okBtn = E('button', { + 'type': 'button', + 'class': 'btn cbi-button', + 'click': async () => { + var directoryName = input.value.trim(); + if (!directoryName) { + alert(_('Directory name cannot be empty.')); + return; + } + + try { + // Assume current upload path (you may need to retrieve or set this yourself) + var basePath = path || '/tmp'; + var fullPath = basePath + '/' + directoryName; + + await fs.exec('mkdir', ['-p', fullPath]).then(L.bind((path, ev) => { + return this.handleSelect(path, null, ev); + }, this, path, ev)); + } catch (err) { + UI.prototype.addTimeLimitedNotification(_('Error'), E('p', _('Failed to create directory: %s').format(err.message)), 5000, 'error'); + } finally { + UI.prototype.hideModal(); + } + } + }, _('OK')); + + var cancelBtn = E('button', { + 'type': 'button', + 'class': 'btn cbi-button', + 'click': () => UI.prototype.hideModal(), + }, _('Cancel')); + + container.appendChild(input); + container.appendChild(okBtn); + container.appendChild(cancelBtn); + + + UI.prototype.showModal(_('Create Directory'), [ + container + ]); + }, + /** @private */ handleUpload(path, list, ev) { const form = ev.target.parentNode; @@ -3115,7 +3177,7 @@ const UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ const hidden = this.node.lastElementChild; if (path == hidden.value) { - dom.content(button, _('Select file…')); + dom.content(button, this.options.directory_select ? _('Select directory…') : _('Select file…')); hidden.value = ''; } @@ -3196,6 +3258,8 @@ const UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ E('div', { 'class': 'name' }, [ this.iconForType(list[i].type), ' ', + (this.options.directory_select && list[i].type !== 'directory') ? + list[i].name : E('a', { 'href': '#', 'style': selected ? 'font-weight:bold' : null, @@ -3213,6 +3277,11 @@ const UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ mtime.getSeconds()) ]), E('div', [ + (this.options.directory_select && list[i].type === 'directory') ? E('button', { + 'class': 'btn cbi-button', + 'click': UI.prototype.createHandlerFn(this, 'handleSelect', + entrypath, list[i].type === 'directory' ? list[i] : null) + }, [ _('Select') ]) : '', selected ? E('button', { 'class': 'btn', 'click': UI.prototype.createHandlerFn(this, 'handleReset') @@ -3236,7 +3305,7 @@ const UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ let cur = ''; for (let i = 0; i < dirs.length; i++) { - cur += dirs[i]; + cur = (i === 0 || cur === '/') ? cur + dirs[i] : cur + '/' + dirs[i]; dom.append(breadcrumb, [ i ? ' » ' : '', E('a', { @@ -3251,6 +3320,11 @@ const UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ rows, E('div', { 'class': 'right' }, [ this.renderUpload(path, list), + (this.options.directory_create) ? E('a', { + 'href': '#', + 'class': 'btn cbi-button', + 'click': UI.prototype.createHandlerFn(this, 'handleCreateDirectory', path) + }, _('Create')) : '', !this.options.browser ? E('a', { 'href': '#', 'class': 'btn', @@ -3279,7 +3353,7 @@ const UIFileUpload = UIElement.extend(/** @lends LuCI.ui.FileUpload.prototype */ const hidden = this.node.lastElementChild; hidden.value = ''; - dom.content(button, _('Select file…')); + dom.content(button, this.options.directory_select ? _('Select directory…') : _('Select file…')); this.handleCancel(ev); },