luci-base: extend FileUpload; create DirectoryPicker
authorPaul Donald <newtwen+github@gmail.com>
Sun, 6 Jul 2025 02:04:28 +0000 (04:04 +0200)
committerPaul Donald <newtwen+github@gmail.com>
Sun, 18 Jan 2026 17:05:21 +0000 (18:05 +0100)
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 <newtwen+github@gmail.com>
modules/luci-base/htdocs/luci-static/resources/form.js
modules/luci-base/htdocs/luci-static/resources/ui.js

index f2f497a1fa0704ffb8b61b092493701558cb0464..9c2ea4a184ece137d929369fe9d801325b1f017c 100644 (file)
@@ -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
 });
index b85c0de32997913137a68800456fc412e3ea3ccd..c8857b01c5d895817bdfa4e402ba5974a12ea554 100644 (file)
@@ -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);
        },