Ver Fonte

Add async zip tasks and file UI refinements

Implements a new `tmpzipAsync` flow in `zipHandler` that runs archive creation in the background, reports progress via ongoing file-operation tasks, supports cancellation, and returns task metadata (`oprid`/dest) to the client. It also adds task-finalization coverage with new `finishTmpZipTask` tests.

On the frontend, folder/multi-file downloads now track server-side zip progress in the transfer panel, show a progress-ring summary, and expose a “Download again” link for blocked auto-downloads. The Open With UI was redesigned to prioritize suggested WebApps and fold others, file-selector got a resizable sidebar and improved localized selection titles, context/menu spacing and icon behavior were polished, and Musicify now guards against duplicate auto-skip on playback errors.
Toby Chui há 3 semanas atrás
pai
commit
8c0a883438

+ 72 - 0
src/file_system.go

@@ -3331,6 +3331,30 @@ func system_fs_zipHandler(w http.ResponseWriter, r *http.Request) {
 		zipDestFsh = nil
 	}
 
+	if opr == "tmpzipAsync" {
+		/*
+			Same as tmpzip, but the zipping runs in the background and reports
+			its progress into the file operation task list, so the caller can
+			follow it on /system/file_system/ongoing instead of staring at a
+			request that returns nothing until the archive is finished.
+		*/
+		if filename == "" {
+			utils.SendErrorResponse(w, "tmpzipAsync does not support a custom destination")
+			return
+		}
+
+		vzipPath := "tmp:/" + filename
+		task := NewOngoingFileOperation(userinfo, "zip", virtualSourcePaths, vzipPath)
+		go runTmpZipTask(task.ID, sourceFshs, realSourcePaths, zipDestFsh, zipOutput, destFsh, rdest)
+
+		js, _ := json.Marshal(map[string]string{
+			"oprid": task.ID,
+			"dest":  vzipPath,
+		})
+		utils.SendJSONResponse(w, string(js))
+		return
+	}
+
 	if opr == "zip" {
 		//Check if destination location exists
 		if rdest == "" || !destFshAbs.FileExists(filepath.Dir(zipOutput)) {
@@ -3368,6 +3392,54 @@ func system_fs_zipHandler(w http.ResponseWriter, r *http.Request) {
 	cleanFsBufferFileFromList(realSourcePaths)
 }
 
+/*
+runTmpZipTask builds a temporary zip in the background, keeping the file
+operation record of oprId up to date so the front end can poll its progress.
+
+The progress handler returns the task's control signal, which is what lets a
+user cancel the archive from the file operation list: ArozZipFileWithProgress
+stops and reports the cancellation as an error, and finishTmpZipTask turns that
+back into a cancelled record.
+*/
+func runTmpZipTask(oprId string, sourceFshs []*filesystem.FileSystemHandler, realSourcePaths []string, zipDestFsh *filesystem.FileSystemHandler, zipOutput string, destFsh *filesystem.FileSystemHandler, rdest string) {
+	err := filesystem.ArozZipFileWithProgress(sourceFshs, realSourcePaths, zipDestFsh, zipOutput, false, func(currentFilename string, _ int, _ int, progress float64) int {
+		sig, _ := UpdateOngoingFileOperation(oprId, currentFilename, math.Ceil(progress))
+		return sig
+	})
+
+	if err != nil {
+		systemWideLogger.PrintAndLog("File System", "Zipping request failed: "+err.Error(), err)
+		finishTmpZipTask(oprId, err)
+		return
+	}
+
+	if destFsh.RequireBuffer {
+		//Write the buffer zip file to destination
+		f, _ := os.Open(zipOutput)
+		err = destFsh.FileSystemAbstraction.WriteStream(rdest, f, 0775)
+		f.Close()
+		os.Remove(zipOutput)
+		if err != nil {
+			systemWideLogger.PrintAndLog("File System", "Zip write to remote file system with driver "+destFsh.Filesystem+" failed", err)
+			finishTmpZipTask(oprId, err)
+			return
+		}
+	}
+
+	cleanFsBufferFileFromList(realSourcePaths)
+	finishTmpZipTask(oprId, nil)
+}
+
+// finishTmpZipTask closes off the task record of a background tmp zip.
+// Pass in the error the zipping ended with, or nil when it completed.
+func finishTmpZipTask(oprId string, err error) {
+	if err != nil {
+		SetFileOperationTaskEnded(oprId, filesystem.FsOpr_Error, err.Error())
+		return
+	}
+	SetFileOperationTaskEnded(oprId, filesystem.FsOpr_Continue, "")
+}
+
 // Manage file version history
 func system_fs_FileVersionHistory(w http.ResponseWriter, r *http.Request) {
 	userinfo, err := userHandler.GetUserInfoFromRequest(w, r)

+ 37 - 0
src/file_system_task_test.go

@@ -1,6 +1,7 @@
 package main
 
 import (
+	"errors"
 	"strconv"
 	"testing"
 	"time"
@@ -418,3 +419,39 @@ func TestClearFinishedFileOperationForUser(t *testing.T) {
 		t.Error("the ongoing task should not have been cleared")
 	}
 }
+
+func TestFinishTmpZipTask(t *testing.T) {
+	tests := []struct {
+		name       string
+		zipErr     error
+		cancelled  bool
+		wantStatus string
+		wantErrMsg string
+	}{
+		{"a completed archive ends as completed", nil, false, FsTask_Completed, ""},
+		{"a failed archive keeps its error", errors.New("disk full"), false, FsTask_Error, "disk full"},
+		{"a cancelled archive is not a failure", errors.New("Operation cancelled by user"), true, FsTask_Cancelled, ""},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			task := newTestFileOperationTask(t, "tmpzip_"+strconv.Itoa(int(time.Now().UnixNano())), []int64{100})
+			task.Operation = "zip"
+			if tt.cancelled {
+				task.FileOperationSignal = filesystem.FsOpr_Cancel
+			}
+
+			finishTmpZipTask(task.ID, tt.zipErr)
+
+			if task.Status != tt.wantStatus {
+				t.Errorf("status = %s, want %s", task.Status, tt.wantStatus)
+			}
+			if task.Error != tt.wantErrMsg {
+				t.Errorf("error = %q, want %q", task.Error, tt.wantErrMsg)
+			}
+			if task.EndTime == 0 {
+				t.Errorf("EndTime not set on a finished task")
+			}
+		})
+	}
+}

+ 35 - 2
src/web/Musicify/musicify.js

@@ -106,6 +106,8 @@ function musicifyApp() {
 
         // ── Internal playback guard ──────────────────────────────────────────
         _suppressEnded: false,  // true while a new track is loading (prevents double-skip)
+        _skipTimer: null,       // pending "skip after playback error" timeout
+        _skipTrack: null,       // the track that failed and armed _skipTimer
 
         // ── Helpers (accessible from Alpine template expressions) ─────────────
         // A "desktop" sidebar is a static, always-visible column. Mobile browsers
@@ -207,7 +209,7 @@ function musicifyApp() {
                 if (MUSICIFY_DEBUG) console.log('[Musicify] audio waiting – pos:', (self._audio.currentTime + self._transcodeSeekOffset).toFixed(2), '/ dur:', self.duration.toFixed(2), '| transcoded:', self._currentTrackTranscoded);
             });
             this._audio.addEventListener('play',  () => {
-                self.isPlaying = true; self._suppressEnded = false; self._fullBufferLoading = false; self._updateMediaSession();
+                self.isPlaying = true; self._suppressEnded = false; self._fullBufferLoading = false; self._cancelErrorSkip(); self._updateMediaSession();
                 if (self._audioCtx && self._audioCtx.state === 'suspended') self._audioCtx.resume().catch(() => {});
             });
             this._audio.addEventListener('pause', () => { self.isPlaying = false; self._updateMediaSession(); });
@@ -1004,6 +1006,7 @@ function musicifyApp() {
                 clearTimeout(this._transcodeEndFallbackTimer);
                 this._transcodeEndFallbackTimer = null;
             }
+            this._cancelErrorSkip();
             this.currentTrack = song;
             this._updateVizColor(song);
             this.coverError = false;
@@ -1406,8 +1409,38 @@ function musicifyApp() {
         },
 
         _onError() {
+            var err = this._audio.error;
+            // MEDIA_ERR_ABORTED is not a playback failure: swapping the source while a
+            // fetch is still in flight raises one on Safari, and acting on it would skip
+            // a track that never even got a chance to play.
+            if (err && err.code === 1) return;
+            if (!this._audio.getAttribute('src')) return;
+            // Only one skip may be queued at a time. A single unplayable file can emit
+            // more than one error event (Safari does this when the failed source is torn
+            // down as the next track loads); without this guard the extra event queues a
+            // second skip and two tracks get passed over instead of one.
+            if (this._skipTimer) return;
+            var self = this;
+            var failed = this.currentTrack;
+            this._skipTrack = failed;
             this._showToast('Playback error – skipping', 'error');
-            setTimeout(() => { this.nextTrack(); }, 1500);
+            this._skipTimer = setTimeout(function() {
+                self._skipTimer = null;
+                self._skipTrack = null;
+                // The user (or another code path) may have moved on during the countdown
+                if (failed && (!self.currentTrack || self.currentTrack.filepath !== failed.filepath)) return;
+                self.nextTrack();
+            }, 1500);
+        },
+
+        // Drop a queued error-skip — called whenever a new track starts loading or
+        // playback recovers, so a stale timer can't skip the track that follows.
+        _cancelErrorSkip() {
+            if (this._skipTimer) {
+                clearTimeout(this._skipTimer);
+                this._skipTimer = null;
+            }
+            this._skipTrack = null;
         },
 
         isCurrentTrack(song) {

+ 4 - 4
src/web/SystemAO/file_system/defaultOpener.html

@@ -37,7 +37,7 @@
 
                 <div class="dlgDivider"></div>
                 <div class="dlgListRow" id="showAllRow" onclick="toggleAllModules();">
-                    <div class="rowIcon"><i id="showAllIcon" class="plus circle outline icon"></i></div>
+                    <div class="rowIcon"><i id="showAllIcon" class="caret right icon"></i></div>
                     <div class="rowText"><div class="rowTitle" id="showAllLabel" locale="button/showAll">Other applications...</div></div>
                 </div>
                 <div class="dlgList" id="otherList" style="display:none;"></div>
@@ -121,7 +121,7 @@
                         $("#otherList").html(others).show();
                         showingAll = true;
                         $("#showAllLabel").text(applocale.getString("button/showLess", "Hide other applications"));
-                        $("#showAllIcon").attr("class", "minus circle outline icon");
+                        $("#showAllIcon").attr("class", "caret down icon");
                     }else{
                         $("#suggestedList").html(suggested);
                         $("#otherList").html(others);
@@ -147,12 +147,12 @@
                     showingAll = false;
                     $("#otherList").slideUp("fast");
                     $("#showAllLabel").text(applocale.getString("button/showAll", "Other applications..."));
-                    $("#showAllIcon").attr("class", "plus circle outline icon");
+                    $("#showAllIcon").attr("class", "caret right icon");
                 }else{
                     showingAll = true;
                     $("#otherList").slideDown("fast");
                     $("#showAllLabel").text(applocale.getString("button/showLess", "Hide other applications"));
-                    $("#showAllIcon").attr("class", "minus circle outline icon");
+                    $("#showAllIcon").attr("class", "caret down icon");
                 }
             }
 

+ 148 - 19
src/web/SystemAO/file_system/file_explorer.css

@@ -66,6 +66,7 @@ body.whiteTheme {
     --popup_btn_negative_bg_hover: #f57676;
 
     --modulelist_bg: white;
+    --modulelist_hover: rgba(35, 35, 35, 0.06);
 
     --contextmenu_background: #ffffff;
     --contextmenu_hover: #f9f9f9;
@@ -1009,7 +1010,7 @@ body.fmResizing *{
     position: fixed;
     display: none;
     width: 200px;
-    padding: 6px;
+    padding: 4px;
     background-color: var(--contextmenu_background);
     border: 1px solid var(--divider);
     border-radius: 10px;
@@ -1028,8 +1029,8 @@ body.fmResizing *{
 .contextmenu .item{
     display: flex;
     align-items: center;
-    gap: 10px;
-    padding: 7px 10px;
+    gap: 7px;
+    padding: 5px 7px;
     border-radius: 6px;
     cursor: pointer;
     font-size: 13.5px;
@@ -1082,7 +1083,7 @@ body.fmResizing *{
 .contextmenu .divider{
     width: auto;
     height: 1px;
-    margin: 5px 4px;
+    margin: 3px;
     background-color: var(--fs-line-soft);
     border-bottom: 0px;
 }
@@ -1414,24 +1415,72 @@ body.fmResizing *{
     explains itself.
 */
 
+/*
+    The Open With picker. Laid out like the standalone opener dialog
+    (SystemAO/file_system/defaultOpener.html): the WebApps that registered the
+    selected file's extension are listed first and everything else stays folded
+    behind the toggle row, so a long module list no longer buries the two apps
+    that can actually open the file.
+
+    The box grows with its content up to a cap rather than always standing 50vh
+    tall - a two app suggestion list used to leave most of the card empty.
+*/
 #openWithModuleList{
-    height:50vh;
+    max-height:42vh;
     overflow-y:auto;
+    overflow-x:hidden;
+    scrollbar-width: thin;
+    padding: 4px;
     background-color: var(--modulelist_bg);
     border: 1px solid var(--divider);
-    border-radius: 0;
+    border-radius: 8px;
 }
 
 #openWithModuleList .ui.avatar.image, #openWithModuleList .ui.avatar.image img, #openWithModuleList .ui.avatar.image svg, #openWithModuleList .ui.avatar.images .image, #openWithModuleList .ui.avatar.images img, #openWithModuleList .ui.avatar.images svg{
+    flex: 0 0 auto;
+    width: 28px;
+    height: 28px;
+    margin: 0px;
     border-radius: 0;
+    object-fit: contain;
+}
+
+.openWithSectionLabel{
+    padding: 6px 8px 4px 8px;
+    color: var(--fs-text-dim);
+    font-size: 12.5px;
 }
 
 #openWithModuleList .item{
-    padding:8px;
-    padding-left:22px;
-    border-radius: 2px;
+    padding: 8px 10px;
+    border-radius: 6px;
     display: flex;
-    flex-wrap: wrap;
+    align-items: center;
+    gap: 10px;
+}
+
+#openWithModuleList .item .content{
+    flex: 1 1 auto;
+    min-width: 0px;
+}
+
+#openWithModuleList .item .header{
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+/* Hidden until the row is picked, so the list does not look pre-ticked */
+#openWithModuleList .item .tick{
+    flex: 0 0 auto;
+    width: 20px;
+    text-align: center;
+    color: inherit;
+    visibility: hidden;
+}
+
+#openWithModuleList .item.selected .tick{
+    visibility: visible;
 }
 
 #openWithModuleList .item:hover{
@@ -1447,6 +1496,38 @@ body.fmResizing *{
     background-color:var(--open_with_item_hover_background);
 }
 
+/* The one row that unfolds the WebApps which did not register this file type */
+.openWithToggleRow{
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 8px 10px;
+    margin-top: 4px;
+    border-top: 1px solid var(--divider);
+    border-radius: 6px;
+    color: var(--fs-text-dim);
+    cursor: pointer;
+    user-select: none;
+}
+
+.openWithToggleRow:hover{
+    background-color: var(--open_with_item_hover_background);
+}
+
+.openWithToggleRow i.icon{
+    flex: 0 0 auto;
+    width: 20px;
+    margin: 0px;
+    text-align: center;
+}
+
+.openWithEmptyNote{
+    padding: 14px 10px;
+    text-align: center;
+    color: var(--fs-text-dim);
+    font-size: 13px;
+}
+
 /*
     New Item List
 */
@@ -1579,6 +1660,15 @@ body.fmResizing *{
     color: var(--fs-text-dim);
 }
 
+/*
+    A row with no byte counter has an empty size cell, and the flex gap after it
+    would indent whatever comes next - which is what the download link on a
+    finished download would otherwise be, sitting a notch right of the task name
+*/
+#uploadTab .uploadTaskSize:empty{
+    display: none;
+}
+
 /*
     Byte counters update several times a second, so every glyph has to keep its
     x position or the text is unreadable. Two things are needed: tabular figures
@@ -1624,8 +1714,15 @@ body.fmResizing *{
     text-align: center;
 }
 
+/*
+    Always the right hand end of the status line, whatever is to its left: a
+    byte counter, the download link of a finished download, or nothing at all.
+    An auto margin rather than the row's space-between, which only reaches the
+    edge when there happen to be two items on the line.
+*/
 #uploadTab .uploadTaskStatus{
     flex: 0 0 auto;
+    margin-left: auto;
     white-space: nowrap;
 }
 
@@ -1638,7 +1735,12 @@ body.fmResizing *{
 #uploadTab .uploadTaskActions{
     grid-column: 2;
     grid-row: 1 / span 2;
-    align-self: center;
+    /*
+        Bottom rather than centre: the column spans the name and the status
+        line, and centring it hung the buttons between the two with a gap under
+        them. Ending the column lines their base up with the status text.
+    */
+    align-self: end;
     display: flex;
     align-items: center;
     gap: 2px;
@@ -1672,6 +1774,34 @@ body.fmResizing *{
     pointer-events: none;
 }
 
+/*
+    The "Download again" link on a finished download.
+
+    A completed download row shows this instead of the tick: the browser may
+    have blocked the download that started on its own, and a tick would then be
+    the only thing on screen while nothing had actually been saved.
+
+    It lives in the status line rather than the button column, so it reads as
+    one row with the label opposite it - the link at the left of the line, and
+    "Completed" pinned to its right hand end.
+*/
+#uploadTab .uploadTaskDoneLink{
+    display: none;
+    white-space: nowrap;
+    color: var(--upload_progressbar);
+    cursor: pointer;
+}
+#uploadTab .uploadTask.withDoneLink .uploadTaskDoneLink{
+    display: inline-block;
+}
+#uploadTab .uploadTaskDoneLink:hover{
+    text-decoration: underline;
+}
+/* No control on a finished row - see renderUploadTask() */
+#uploadTab .uploadTask.done .uploadTaskAction{
+    display: none;
+}
+
 #uploadTab .uploadTaskAction{
     width: 30px;
     height: 30px;
@@ -1695,19 +1825,12 @@ body.fmResizing *{
     */
     pointer-events: none;
 }
-#uploadTab .uploadTask.done .uploadTaskAction{
-    color: var(--upload_progress_succ);
-    cursor: default;
-}
 #uploadTab .uploadTask.failed .uploadTaskAction{
     color: var(--upload_progress_failed);
 }
 #uploadTab .uploadTaskAction:hover{
     background-color: var(--fs-fill-hover);
 }
-#uploadTab .uploadTask.done .uploadTaskAction:hover{
-    background-color: transparent;
-}
 
 /* ---------- Progress bar ---------- */
 #uploadTab .uploadTaskBar{
@@ -1769,6 +1892,12 @@ body.fmResizing *{
     display: block;
 }
 
+/* The filled part of the ring, sized by setUploadSummaryRing() */
+#uploadTab .uploadSummaryIcon .taskProgressArc{
+    color: var(--upload_progressbar);
+    transition: stroke-dasharray 0.25s linear;
+}
+
 #uploadTab .uploadSummaryText{
     flex: 1 1 auto;
     min-width: 0px;
@@ -3318,7 +3447,7 @@ i.blue{
 
 /* Greyed section label above the options */
 .fsMenuHeader {
-    padding: 4px 9px 6px 9px;
+    padding: 3px 6px 4px 6px;
     font-size: 12px;
     color: var(--fs-text-faint);
     cursor: default;

+ 20 - 11
src/web/SystemAO/file_system/file_explorer.html

@@ -398,18 +398,26 @@
         <div id="openWith" class="popup" style="display:none;">
             <div class="popupheader">
                 <i class="external icon"></i> <span locale="opr/openwith/title">Open File With</span>
-                <div class="popupcloser" onclick="hideAllPopupWindows();">
+                <div class="popupcloser desktopOnly" onclick="hideAllPopupWindows();">
                     <i class="remove icon"></i>
                 </div>
             </div>
             <div class="popupcontent">
                <p locale="opr/openwith/desc">Select a module from the list below:</p>
-                <div id="openWithModuleList" class="ui segment" style="padding:0px;">
-                    <div class="item selectable">
-                        <div class="content" style="padding-left:12px;">
-                            <div class="header" locale="opr/openwith/loading">Loading...</div>
+                <div id="openWithModuleList">
+                    <div class="openWithSectionLabel" id="openWithSuggestedLabel" locale="opr/openwith/suggested">Suggested WebApps</div>
+                    <div id="openWithSuggestedList">
+                        <div class="item">
+                            <div class="content">
+                                <div class="header" locale="opr/openwith/loading">Loading...</div>
+                            </div>
                         </div>
                     </div>
+                    <div class="openWithToggleRow" id="openWithToggleRow" style="display:none;" onclick="toggleOpenWithOtherModules();">
+                        <i class="caret right icon" id="openWithToggleIcon"></i>
+                        <span id="openWithToggleLabel" locale="opr/openwith/showAll">Other WebApps...</span>
+                    </div>
+                    <div id="openWithOtherList" style="display:none;"></div>
                 </div>
                 <div class="openWithModule popupbuttons allowHover primary" onclick="openWithSelectedModule(this);";>
                     <i class="external icon"></i> <span locale="opr/openwith/openWithWebApp">Open with Selected Module</span>
@@ -420,7 +428,7 @@
                 <div class="popupbuttons allowHover vdonly" onclick="openRawFileInFloatWindow(this);">
                     <i class="window restore outline icon"></i> <span locale="opr/openwith/openInNewWindow">Open File directly in floatWindow</span>
                 </div>
-                <div class="popupbuttons allowHover" onclick="hideAllPopupWindows();">
+                <div class="popupbuttons allowHover mobileOnly" onclick="hideAllPopupWindows();">
                     <i class="remove icon"></i> <span locale="opr/openwith/cancel">Cancel</span>
                 </div>
             </div>
@@ -525,13 +533,14 @@
         </div>
 
         <!--
-            Transfer panel. Shows one row per upload (and per zip-preparation
-            task started from download.js). Rendering lives in
-            js/explorer/uploadui.js - the markup below is only the shell.
+            Transfer panel. Shows one row per task - an upload, or the server
+            side zipping a folder download starts in download.js - so its title
+            and summary icon are deliberately generic. Rendering lives in
+            js/explorer/uploadui.js; the markup below is only the shell.
         -->
         <div id="uploadTab" style="display:none;">
             <div class="uploadHeader">
-                <span class="uploadHeaderTitle" id="uploadHeaderTitle">Uploading</span>
+                <span class="uploadHeaderTitle" id="uploadHeaderTitle">Tasks</span>
                 <div class="uploadHeaderBtn" onclick="toggleUploadMinimize();" title="Minimize">
                     <span data-fsicon="minus"></span>
                 </div>
@@ -543,7 +552,7 @@
 
             </div>
             <div class="uploadSummary">
-                <span class="uploadSummaryIcon" data-fsicon="cloudUpload"></span>
+                <span class="uploadSummaryIcon" id="uploadSummaryIcon" data-fsicon="taskProgress"></span>
                 <span class="uploadSummaryText" id="uploadSummaryText"></span>
             </div>
             <div class="uploadActions">

+ 309 - 24
src/web/SystemAO/file_system/file_selector.html

@@ -192,6 +192,7 @@
 
             /* ---------- Sidebar ---------- */
             .fsSidebar{
+                flex: 0 0 auto;
                 width: 200px;
                 min-width: 200px;
                 padding: 10px 8px 10px 10px;
@@ -229,6 +230,45 @@
                 text-overflow: ellipsis;
             }
 
+            /* ---------- Sidebar splitter ---------- */
+            /*
+                Drag handle on the sidebar / file list boundary. It is pulled back
+                over the sidebar border with negative margins, so widening the grab
+                area does not push the panes apart.
+            */
+            .fsSplitter{
+                flex: 0 0 auto;
+                align-self: stretch;
+                width: 7px;
+                margin-left: -4px;
+                margin-right: -3px;
+                position: relative;
+                z-index: 100;
+                cursor: col-resize;
+                background: transparent;
+                transition: background-color 0.12s ease;
+            }
+            .fsSplitter:hover,
+            .fsSplitter.dragging{
+                background: var(--fs-accent);
+                opacity: 0.5;
+            }
+            /*
+                While dragging, the pointer keeps crossing the panes either side of
+                the handle. Without this the cursor flickers back to the default and
+                stray text gets selected on the way past.
+            */
+            body.fsResizing{
+                cursor: col-resize;
+                user-select: none;
+                -webkit-user-select: none;
+            }
+            body.fsResizing *{
+                cursor: col-resize !important;
+                user-select: none;
+                -webkit-user-select: none;
+            }
+
             /* ---------- Main column ---------- */
             .fsMain{
                 flex-grow: 1;
@@ -711,6 +751,41 @@
                 cursor: pointer;
             }
 
+            /* ---------- Right click menu ---------- */
+            /*
+                Matches the File Manager's #contextmenu (file_explorer.css): the label
+                carries the row and a muted Semantic UI glyph sits at the trailing edge,
+                with no glyph at all on the rows the File Manager leaves bare.
+
+                Scoped to the context menu, so the toolbar dropdown (#moreMenu) keeps
+                its leading icons - the same split the File Manager draws between its
+                right click menu and its "more" menu.
+            */
+            #fsRoot #contextMenu{ min-width: 250px; }
+            #fsRoot #contextMenu .fsMenuItem{
+                gap: 7px;
+                padding: 5px 7px;
+                color: var(--fs-text);
+            }
+            /* Label takes up the slack, so the trailing glyph is always right aligned */
+            #fsRoot #contextMenu .fsMenuItem > span{ flex-grow: 1; }
+            /*
+                The colour needs !important: ao.css styles every <i> under a themed
+                body with a blanket rule whose :not() chain scores higher than any
+                reasonable selector here, so it would otherwise repaint the glyph to
+                the label colour and flatten the two-tone look.
+            */
+            #fsRoot #contextMenu .fsMenuItem > i.icon{
+                width: 16px;
+                min-width: 16px;
+                height: auto;
+                margin: 0 0 0 auto;
+                font-size: 14px;
+                text-align: center;
+                color: var(--fs-icon) !important;
+                opacity: 0.6;
+            }
+
             /* ---------- Sidebar overlay (narrow windows) ---------- */
             .fsScrim{
                 position: absolute;
@@ -747,6 +822,8 @@
                     display: none;
                 }
                 .fsSidebar.open{ display: block; }
+                /* The drawer floats over the file list, so there is no edge to drag */
+                .fsSplitter{ display: none !important; }
                 .fsFooter{ padding: 10px 12px; gap: 8px; }
                 .fsBtn{ min-width: 78px; padding: 0 12px; }
                 .fsListHead{ padding-left: 14px; padding-right: 12px; }
@@ -815,6 +892,8 @@
                     <div class="fsSideTitle" locale="sidebar/devices">Devices</div>
                     <div id="storagelist"></div>
                 </div>
+                <!-- Drag handle between the sidebar and the file list, desktop layout only -->
+                <div class="fsSplitter" id="sidebarSplitter" title="Resize"></div>
 
                 <div class="fsMain">
                     <div class="fsToolbar">
@@ -977,9 +1056,6 @@
                 info:       STROKE + '<circle cx="12" cy="12" r="9"/><path d="M12 16v-5M12 8h.01"/></svg>',
                 eye:        STROKE + '<path d="M1.8 12S5.4 5 12 5s10.2 7 10.2 7-3.6 7-10.2 7S1.8 12 1.8 12z"/><circle cx="12" cy="12" r="3"/></svg>',
                 folderPlus: STROKE + '<path d="M3 7a2 2 0 0 1 2-2h4l2 3h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M12 12v5M9.5 14.5h5"/></svg>',
-                open:       STROKE + '<path d="M5 12h14M13 6l6 6-6 6"/></svg>',
-                externalLink: STROKE + '<path d="M15 3h6v6"/><path d="M10.5 13.5L21 3"/><path d="M18 13.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h5.5"/></svg>',
-                pencil:     STROKE + '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>',
                 folderOpen: STROKE + '<path d="M3 7a2 2 0 0 1 2-2h4l2 3h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
                 clock:      STROKE + '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.2 2"/></svg>',
                 download:   STROKE + '<path d="M12 3v12M7 11l5 5 5-5"/><path d="M4 20h16"/></svg>',
@@ -1984,22 +2060,46 @@
                 updateDetailsFor(selected.toArray());
             }
 
+            /*
+                applocale sets the window title to the localized "fwtitle" once the
+                locale file lands, and this runs again on every selection change - so
+                the whole string is rebuilt from the locale, base word included, or the
+                title would drop back to English the moment something is selected.
+            */
             function setWindowTitleForSelection(objectCount){
+                //Same string as the in-dialog title bar, and the fwtitle applocale sets
+                var baseTitle = applocale.getString("title", "Open");
                 if (objectCount == 0){
-                    ao_module_setWindowTitle("Open");
+                    ao_module_setWindowTitle(baseTitle);
                     return;
                 }
-                var typeName = "object";
+
+                /*
+                    Singular and plural are separate keys rather than one string with
+                    the count spliced in: languages that do not inflect simply carry the
+                    same text in both, while English still reads "1 file selected".
+                */
+                var countKey = (objectCount > 1) ? "title/itemsSelected" : "title/itemSelected";
+                var fallbackPost = (objectCount > 1) ? " items selected" : " item selected";
                 if (type == "file"){
-                    typeName = "file";
+                    countKey = (objectCount > 1) ? "title/filesSelected" : "title/fileSelected";
+                    fallbackPost = (objectCount > 1) ? " files selected" : " file selected";
                 }else if (type == "folder"){
-                    typeName = "folder";
-                }
-                var desc = typeName + " selected";
-                if (objectCount > 1){
-                    desc = typeName + "s selected";
+                    countKey = (objectCount > 1) ? "title/foldersSelected" : "title/folderSelected";
+                    fallbackPost = (objectCount > 1) ? " folders selected" : " folder selected";
                 }
-                ao_module_setWindowTitle("Open (" + objectCount + " " + desc + ")");
+
+                /*
+                    The phrase is split around the digit - "<pre>2<post>" - because the
+                    count does not sit in the same place in every language: Chinese puts
+                    it in the middle ("已選取 2 個資料夾") where English trails it
+                    ("2 files selected"). Each half carries its own spacing, so a
+                    language that wants the digit against the following word can have
+                    that too ("파일 2개 선택됨").
+                */
+                var pre = applocale.getString(countKey + "/pre", "");
+                var post = applocale.getString(countKey + "/post", fallbackPost);
+                ao_module_setWindowTitle(baseTitle + " (" + pre + objectCount + post + ")");
             }
 
             function confirmSelection(){
@@ -2382,6 +2482,174 @@
                 $("#sidebarScrim").removeClass("show");
             }
 
+            /* ------------------------------------------------------------------ */
+            /*  Sidebar resizing                                                   */
+            /* ------------------------------------------------------------------ */
+            /*
+                Same behaviour as the file manager's pane splitter
+                (js/explorer/splitter.js): drag the sidebar edge to resize it and the
+                width is remembered between visits. Remembered per browser rather than
+                per account, as it describes this window on this screen.
+            */
+            var FS_SIDEBAR_WIDTH_KEY = "file_selector/sidebarWidth";
+            var FS_SIDEBAR_DEFAULT_WIDTH = 200; //must match the width in the stylesheet
+            var FS_SIDEBAR_MIN_WIDTH = 150;     //below this the root names are unreadable
+            var FS_SIDEBAR_MAX_WIDTH = 420;
+            var FS_MAIN_MIN_WIDTH = 280;        //the file list never shrinks past this
+            var FS_SIDEBAR_DRAWER_WIDTH = 560;  //matches the overlay media query
+            /*
+                The width the user asked for, which is not always the width on screen:
+                a narrow window can clamp it down. Kept unclamped so the sidebar springs
+                back to the chosen size once there is room for it again.
+            */
+            var sidebarPreferredWidth = FS_SIDEBAR_DEFAULT_WIDTH;
+
+            //Below this width the sidebar is a drawer over the file list, not a column
+            function sidebarIsDrawer(){
+                return window.innerWidth < FS_SIDEBAR_DRAWER_WIDTH;
+            }
+
+            /*
+                How wide the sidebar may get right now. The ceiling is not a fixed
+                number: it also has to leave FS_MAIN_MIN_WIDTH for the file list, so in
+                a small float window it comes down rather than letting a drag squeeze
+                the listing away.
+            */
+            function sidebarWidthLimits(){
+                var available = $(".fsBody").width() - FS_MAIN_MIN_WIDTH;
+                return {
+                    min: FS_SIDEBAR_MIN_WIDTH,
+                    max: Math.min(FS_SIDEBAR_MAX_WIDTH, available)
+                };
+            }
+
+            function clampSidebarWidth(width){
+                var limits = sidebarWidthLimits();
+                var applied = Math.round(width);
+                if (isNaN(applied)){
+                    applied = FS_SIDEBAR_DEFAULT_WIDTH;
+                }
+                if (limits.max < limits.min){
+                    //Window too narrow to honour both bounds: the floor wins, otherwise
+                    //the sidebar would collapse to nothing
+                    return limits.min;
+                }
+                return Math.max(limits.min, Math.min(limits.max, applied));
+            }
+
+            /*
+                Writes width and min-width together: the sidebar carries a min-width in
+                the stylesheet to stop flexbox shrinking it, and leaving that behind
+                would silently pin the pane at its old size on the way down.
+            */
+            function writeSidebarWidth(applied){
+                $("#sidebar").css({
+                    "width": applied + "px",
+                    "min-width": applied + "px"
+                });
+            }
+
+            //The drag path: what the user drags to becomes the remembered width
+            function setSidebarWidth(width){
+                sidebarPreferredWidth = clampSidebarWidth(width);
+                writeSidebarWidth(sidebarPreferredWidth);
+                return sidebarPreferredWidth;
+            }
+
+            /*
+                Re-applies the preferred width to the DOM. Also the window resize path: a
+                width that was legal in a bigger window can be over the ceiling in a
+                smaller one - and back under it when the window grows again, which is why
+                this clamps the preference rather than overwriting it. In the drawer
+                layout the inline width is dropped entirely, so the overlay keeps the
+                size the stylesheet gives it.
+            */
+            function applySidebarWidth(){
+                if (sidebarIsDrawer()){
+                    $("#sidebar").css({ "width": "", "min-width": "" });
+                    $("#sidebarSplitter").hide();
+                    return;
+                }
+                $("#sidebarSplitter").show();
+                writeSidebarWidth(clampSidebarWidth(sidebarPreferredWidth));
+            }
+
+            function saveSidebarWidth(width){
+                try {
+                    localStorage.setItem(FS_SIDEBAR_WIDTH_KEY, String(Math.round(width)));
+                } catch (e){
+                    //Private browsing or a full store - the width simply is not remembered
+                }
+            }
+
+            function initSidebarSplitter(){
+                var stored = null;
+                try {
+                    stored = localStorage.getItem(FS_SIDEBAR_WIDTH_KEY);
+                } catch (e){
+                    stored = null;
+                }
+                if (stored != null && stored != ""){
+                    var restored = parseInt(stored);
+                    if (!isNaN(restored) && restored > 0){
+                        sidebarPreferredWidth = restored;
+                    }
+                }
+                //Clamped on the way in, so a width saved on a wider screen still fits here
+                applySidebarWidth();
+
+                var handle = document.getElementById("sidebarSplitter");
+                if (handle == null){
+                    return;
+                }
+
+                /*
+                    Pointer events rather than mousedown/mousemove: setPointerCapture
+                    keeps the drag alive when the cursor leaves the window, which a
+                    document level mousemove listener does not.
+                */
+                handle.addEventListener("pointerdown", function(evt){
+                    if (evt.button != 0 || sidebarIsDrawer()){
+                        return;
+                    }
+                    //Stops the drag from selecting text across the panes
+                    evt.preventDefault();
+
+                    var startX = evt.clientX;
+                    var startWidth = $("#sidebar").outerWidth();
+                    /*
+                        Capture is an optimisation, not a requirement - the listeners
+                        below are on the handle itself. It throws if the pointer is no
+                        longer active by the time this runs, which must not abort the drag.
+                    */
+                    try { handle.setPointerCapture(evt.pointerId); } catch(e) {}
+                    handle.classList.add("dragging");
+                    //Suppresses hover effects and text selection everywhere for the drag
+                    $("body").addClass("fsResizing");
+
+                    function onMove(e){
+                        setSidebarWidth(startWidth + (e.clientX - startX));
+                    }
+
+                    function onEnd(){
+                        try { handle.releasePointerCapture(evt.pointerId); } catch(e) {}
+                        handle.removeEventListener("pointermove", onMove);
+                        handle.removeEventListener("pointerup", onEnd);
+                        handle.removeEventListener("pointercancel", onEnd);
+                        handle.classList.remove("dragging");
+                        $("body").removeClass("fsResizing");
+                        //Saved on release rather than on every move, so a drag writes once
+                        saveSidebarWidth(sidebarPreferredWidth);
+                        //The grid view fits its tiles to the width of the file area
+                        updateFileAreaDensity();
+                    }
+
+                    handle.addEventListener("pointermove", onMove);
+                    handle.addEventListener("pointerup", onEnd);
+                    handle.addEventListener("pointercancel", onEnd);
+                });
+            }
+
             /* ------------------------------------------------------------------ */
             /*  New folder                                                         */
             /* ------------------------------------------------------------------ */
@@ -2654,10 +2922,10 @@
 
             function buildFolderContextMenuItems(){
                 var items = [];
-                items.push(contextMenuItem("newfolder", ICONS.folderPlus,
+                items.push(contextMenuItem("newfolder", "folder outline",
                     applocale.getString("ctx/newfolder", "New Folder")));
                 items.push('<div class="fsMenuSep"></div>');
-                items.push(contextMenuItem("folderproperties", ICONS.info,
+                items.push(contextMenuItem("folderproperties", "",
                     applocale.getString("ctx/folderproperties", "Folder Properties")));
                 return items.join("");
             }
@@ -2677,28 +2945,39 @@
                 menu.css({left: left + "px", top: top + "px"});
             }
 
+            /*
+                Glyph per row is taken from the File Manager's context menu so the two
+                menus read the same: Open, Rename and Properties carry none there, and
+                both "open elsewhere" rows share the external link arrow.
+            */
             function buildContextMenuItems(isDir){
                 var items = [];
-                items.push(contextMenuItem("open", ICONS.open, applocale.getString("ctx/open", "Open")));
+                items.push(contextMenuItem("open", "", applocale.getString("ctx/open", "Open")));
                 if (!isDir){
-                    items.push(contextMenuItem("openwith", ICONS.viewGrid, applocale.getString("ctx/openwith", "Open With")));
+                    items.push(contextMenuItem("openwith", "external", applocale.getString("ctx/openwith", "Open With")));
                 }else{
-                    items.push(contextMenuItem("opennewwindow", ICONS.externalLink,
+                    items.push(contextMenuItem("opennewwindow", "external",
                         applocale.getString("ctx/opennewwindow", "Open in New Window")));
                 }
                 items.push('<div class="fsMenuSep"></div>');
-                items.push(contextMenuItem("rename", ICONS.pencil, applocale.getString("ctx/rename", "Rename")));
-                items.push(contextMenuItem("delete", ICONS.trash, applocale.getString("ctx/delete", "Delete")));
-                items.push(contextMenuItem("download", ICONS.download, applocale.getString("ctx/download", "Download")));
+                items.push(contextMenuItem("rename", "", applocale.getString("ctx/rename", "Rename")));
+                items.push(contextMenuItem("delete", "trash", applocale.getString("ctx/delete", "Delete")));
+                items.push(contextMenuItem("download", "download", applocale.getString("ctx/download", "Download")));
                 items.push('<div class="fsMenuSep"></div>');
-                items.push(contextMenuItem("properties", ICONS.info, applocale.getString("ctx/properties", "Properties")));
+                items.push(contextMenuItem("properties", "", applocale.getString("ctx/properties", "Properties")));
                 return items.join("");
             }
 
-            function contextMenuItem(action, iconSVG, label){
+            /*
+                iconClass is a Semantic UI icon name without the trailing "icon", or an
+                empty string for the rows the File Manager draws without a glyph
+                (Open, Rename, Properties).
+            */
+            function contextMenuItem(action, iconClass, label){
                 return '<div class="fsMenuItem" onclick="onContextAction(\'' + action + '\');">' +
-                    '<span class="fsMenuIcon">' + iconSVG + '</span>' +
-                    '<span>' + escapeHTML(label) + '</span></div>';
+                    '<span>' + escapeHTML(label) + '</span>' +
+                    (iconClass == "" ? "" : '<i class="' + iconClass + ' icon"></i>') +
+                    '</div>';
             }
 
             function closeContextMenu(){
@@ -2943,10 +3222,16 @@
                 }
             });
 
+            //Runs here rather than with the other init calls at the top of this
+            //script: the FS_SIDEBAR_* values above are var declarations, so they
+            //only hold their values once execution has reached them
+            initSidebarSplitter();
+
             $(window).on("resize", function(){
                 if (window.innerWidth >= 560){
                     closeSidebar();
                 }
+                applySidebarWidth();
                 updateFileAreaDensity();
             });
         </script>

+ 24 - 0
src/web/SystemAO/file_system/js/explorer/clipboard.js

@@ -29,6 +29,30 @@ function changeOverwriteRule(mode, doPasteAgain=false){
 }
 
 
+/*
+    Whether there is anything to paste.
+
+    Not the same question as "is this page's clipboard array empty": a copy made
+    in another file manager window is handed over through localStorage, and
+    paste() below prefers that one, so a page that has copied nothing itself can
+    still have something to paste.
+*/
+function clipboardHasContent(){
+    if (typeof clipboard != "undefined" && clipboard.length > 0){
+        return true;
+    }
+    if (typeof useLocalstorage != "undefined" && useLocalstorage){
+        try{
+            let stored = localStorage.getItem("ao/file_system/clipboard");
+            return stored != null && stored != "" && JSON.parse(stored).length > 0;
+        }catch(ex){
+            //Unreadable or malformed - treat it as nothing to paste
+            return false;
+        }
+    }
+    return false;
+}
+
 function copy(){
     cutMode = false;
     clipboard = [];

+ 121 - 9
src/web/SystemAO/file_system/js/explorer/download.js

@@ -3,10 +3,27 @@
 
     Download selected files, zipping folders and multi-selections first.
 
+    The zipping runs server side as a background file operation task, so this
+    file starts it, follows its progress into the transfer panel by polling
+    /system/file_system/ongoing, and hands the finished archive to the browser.
+
     Part of the ArozOS File Manager. Loaded as a plain script from
     file_explorer.html - see the <script> block at the end of that file.
 */
 
+//How often a running zip task is asked how far it has got, in ms
+const ZIP_PROGRESS_POLL_INTERVAL = 1000;
+
+/*
+    How many polls in a row may come back without the task record before it is
+    taken as finished.
+
+    The server keeps a task record for a few seconds after it ends and only
+    drops it once it has finished without error (fileOprFinishedRecordTTL in
+    file_system.go), so a record that has gone missing is a completed one.
+*/
+const ZIP_PROGRESS_MAX_MISSES = 3;
+
 //Download the selected files.
 function downloadFile(){
     var fileList = [];
@@ -31,15 +48,23 @@ function downloadFile(){
             displayString = applocale.getString("opr/zip/zipping", "Zipping ") + $(".fileObject.selected").attr("filename");
         }
 
-        //The zip size is not known until the server has built it, so this task
-        //rides the transfer panel in its indeterminate state
+        //The zip has no byte total to count towards, so the panel follows it as
+        //a percentage instead - see setUploadTaskPercentage in uploadui.js
         var taskUUID = appendUploadFileItem(displayString, -1);
         setUploadTaskState(taskUUID, "processing");
+        setUploadTaskPercentage(taskUUID, 0);
 
-        //Zip the file or folder
+        //The name the archive is saved under, which is nicer than the
+        //timestamp the server names its temporary file with
+        var zipFilename = "download.zip";
+        if (fileCount == 1){
+            zipFilename = $(".fileObject.selected").attr("filename").split("/").pop() + ".zip";
+        }
+
+        //Zip the file or folder in the background
         $.ajax({
             url: "../../system/file_system/zipHandler",
-            data: {opr: "tmpzip", src: JSON.stringify(fileList), dest: ""},
+            data: {opr: "tmpzipAsync", src: JSON.stringify(fileList), dest: ""},
             method: "POST",
             success: function(data){
                 if (data.error !== undefined){
@@ -47,11 +72,7 @@ function downloadFile(){
                     setUploadTaskState(taskUUID, "failed");
                     msgbox("red remove",applocale.getString("message/" + data.error,data.error));
                 }else{
-                    //Zip completed.
-                    setUploadTaskState(taskUUID, "done");
-
-                    //Open the zip file
-                    window.open("../../media/download?file=" + data);
+                    followZipDownloadTask(taskUUID, data.oprid, data.dest, zipFilename);
                 }
             },
             error: function(){
@@ -68,6 +89,97 @@ function downloadFile(){
 }
 
 
+
+/*
+    Follow a server side zip task until it ends.
+
+    The task record carries the progress of the whole operation, so one poll a
+    second is enough to keep the row moving; the row's cancel button stops the
+    archive on the server rather than only hiding the row.
+*/
+function followZipDownloadTask(taskUUID, oprid, vzipPath, zipFilename){
+    var misses = 0;
+    var timer = setInterval(function(){
+        $.ajax({
+            url: "../../system/file_system/ongoing",
+            data: {all: "true"},
+            success: function(data){
+                if (!Array.isArray(data)){
+                    //Session lost, or the endpoint refused: there is nothing to
+                    //read here, but the zip itself may still be running
+                    return;
+                }
+
+                var task = null;
+                for (var i = 0; i < data.length; i++){
+                    if (data[i].ID == oprid){
+                        task = data[i];
+                        break;
+                    }
+                }
+
+                if (task == null){
+                    misses++;
+                    if (misses >= ZIP_PROGRESS_MAX_MISSES){
+                        stopFollowing();
+                        completeZipDownloadTask(taskUUID, vzipPath, zipFilename);
+                    }
+                    return;
+                }
+
+                misses = 0;
+                if (task.Status == "completed"){
+                    stopFollowing();
+                    completeZipDownloadTask(taskUUID, vzipPath, zipFilename);
+                }else if (task.Status == "error"){
+                    stopFollowing();
+                    setUploadTaskState(taskUUID, "failed");
+                    msgbox("red remove", task.Error != "" ? task.Error :
+                        applocale.getString("message/zip/fail", "Zipping failed due to unknown reason"));
+                }else if (task.Status == "cancelled"){
+                    //Cancelled from elsewhere - the file operation dialog also
+                    //lists this task and can stop it
+                    stopFollowing();
+                    cancelUploadTask(taskUUID);
+                }else{
+                    setUploadTaskPercentage(taskUUID, task.Progress);
+                }
+            }
+            //A failed poll is deliberately not a failed zip: the next one runs
+        });
+    }, ZIP_PROGRESS_POLL_INTERVAL);
+
+    function stopFollowing(){
+        clearInterval(timer);
+        unregisterUploadTransfer(taskUUID);
+    }
+
+    //Makes the row's cancel button stop the zipping instead of just dropping
+    //the row while the server carries on packing
+    registerUploadTransfer(taskUUID, {
+        abort: function(){
+            clearInterval(timer);
+            $.get("../../system/file_system/ongoing", {flag: "cancel", oprid: oprid});
+        }
+    });
+}
+
+/*
+    Hand the finished archive to the browser.
+
+    The download is also left on the row as a link: this click is not one the
+    user made, so a browser that blocks automatic downloads will drop it, and
+    the link is then the way to get the file.
+*/
+function completeZipDownloadTask(taskUUID, vzipPath, zipFilename){
+    var downloadURL = "../../media/download?file=" + encodeURIComponent(vzipPath);
+    setUploadTaskState(taskUUID, "done");
+    setUploadTaskDoneLink(taskUUID, applocale.getString("upload/downloadAgain", "Download again"),
+        downloadURL, zipFilename);
+    generateDownloadFromURL(downloadURL, zipFilename);
+}
+
+
 /*
     Reachable from outside this file: inline on* attributes in the markup,
     handlers generated in template strings, or another frame. Renaming any

+ 140 - 23
src/web/SystemAO/file_system/js/explorer/openwith.js

@@ -7,40 +7,34 @@
     file_explorer.html - see the <script> block at the end of that file.
 */
 
+//Whether the WebApps that did not register the selected file type are unfolded
+var openWithShowingAll = false;
+
 //OpenWith dialog
 function openWith(){
     if ($(".selected.fileObject").length == 0){
         msgbox("question",applocale.getString("message/nofileSelected", "No file selected"));
         return;
     }
+
+    //Group the module list by the extensions of what is selected, the same way
+    //the standalone opener (defaultOpener.html) does
+    var selectedExts = getSelectedFileExtensions();
+    openWithShowingAll = false;
+    $("#openWithSuggestedList").html(`<div class="item">
+        <div class="content">
+            <div class="header">${applocale.getString("opr/openwith/loading", "Loading...")}</div>
+        </div>
+    </div>`);
+    $("#openWithOtherList").html("").hide();
+    $("#openWithToggleRow").hide();
+
     //Get a list of modules and append it into the selection list
     $.get("../../system/modules/list",function(data){
         if (data.error !== undefined){
             console.log(data.error);
         }else{
-            $("#openWithModuleList").html("");
-            for (var i =0; i < data.length; i++){
-                var thisModule = data[i];
-                var supportFW = `<div class="ui horizontal mini icon label"><i class="window restore outline icon"></i> ${applocale.getString("opr/openwith/floatWindow", "Floating Window")}</div>`;
-                var supportEmb = `<div class="ui horizontal mini icon label"><i class="folder open icon"></i> ${applocale.getString("opr/openwith/embedded", "File Input")}</div>`;
-                if (!thisModule.SupportFW){
-                    supportFW = "";
-                }
-                if (!thisModule.LaunchEmb){
-                    supportEmb = "";
-                }
-                var launchInfo = encodeURIComponent(JSON.stringify(thisModule));
-                $("#openWithModuleList").append(`<div class="item selectable openWithModule" launchInfo="${launchInfo}" onclick="selectOpenWithModule(this, event);" ondblclick="selectOpenWithModule(this, event); openWithSelectedModule();">
-                    <img class="ui avatar image" src="../../${thisModule.IconPath}">
-                    <div class="content" style="padding-left:12px;">
-                        <div class="header">${thisModule.Name}</div>
-                        <div class="description">
-                            ${supportFW}
-                            ${supportEmb}
-                        </div>
-                    </div>
-                </div>`);
-            }
+            renderOpenWithModuleList(data, selectedExts);
         }
     });
     $(".openWithModule.popupbuttons").addClass("disabled");
@@ -54,6 +48,128 @@ function openWith(){
 
 }
 
+//The extensions of everything currently selected, lowercased and deduplicated.
+//Folders and extension-less files contribute nothing, so they simply end up
+//with no suggested WebApp.
+function getSelectedFileExtensions(){
+    var exts = [];
+    $(".selected.fileObject").each(function(){
+        var filename = $(this).attr("filename");
+        if (filename == undefined || filename.indexOf(".") < 0){
+            return;
+        }
+        var thisExt = ("." + filename.split(".").pop()).toLowerCase();
+        if (exts.indexOf(thisExt) < 0){
+            exts.push(thisExt);
+        }
+    });
+    return exts;
+}
+
+//A module is "suggested" when its init.agi registered any of these extensions
+function moduleSupportsAnyExt(thisModule, exts){
+    var supportedExt = thisModule.SupportedExt;
+    if (exts.length == 0 || supportedExt == null || supportedExt.length == 0){
+        return false;
+    }
+    for (var i = 0; i < supportedExt.length; i++){
+        if (exts.indexOf(String(supportedExt[i]).toLowerCase()) >= 0){
+            return true;
+        }
+    }
+    return false;
+}
+
+//Module names and icon paths come from the server, so escape before html
+function escapeOpenWithText(text){
+    return String(text == undefined ? "" : text)
+        .split("&").join("&amp;")
+        .split("<").join("&lt;")
+        .split(">").join("&gt;")
+        .split('"').join("&quot;");
+}
+
+function renderOpenWithModuleRow(thisModule){
+    var supportFW = `<div class="ui horizontal mini icon label"><i class="window restore outline icon"></i> ${applocale.getString("opr/openwith/floatWindow", "Floating Window")}</div>`;
+    var supportEmb = `<div class="ui horizontal mini icon label"><i class="folder open icon"></i> ${applocale.getString("opr/openwith/embedded", "File Input")}</div>`;
+    if (!thisModule.SupportFW){
+        supportFW = "";
+    }
+    if (!thisModule.LaunchEmb){
+        supportEmb = "";
+    }
+    var description = "";
+    if (supportFW != "" || supportEmb != ""){
+        description = `<div class="description">
+                            ${supportFW}
+                            ${supportEmb}
+                        </div>`;
+    }
+    var launchInfo = encodeURIComponent(JSON.stringify(thisModule));
+    return `<div class="item selectable openWithModule" launchInfo="${launchInfo}" onclick="selectOpenWithModule(this, event);" ondblclick="selectOpenWithModule(this, event); openWithSelectedModule();">
+                    <img class="ui avatar image" src="../../${escapeOpenWithText(thisModule.IconPath)}">
+                    <div class="content">
+                        <div class="header">${escapeOpenWithText(thisModule.Name)}</div>
+                        ${description}
+                    </div>
+                    <div class="tick"><i class="check circle icon"></i></div>
+                </div>`;
+}
+
+function renderOpenWithModuleList(moduleList, selectedExts){
+    var suggested = "";
+    var others = "";
+    for (var i = 0; i < moduleList.length; i++){
+        var thisModule = moduleList[i];
+        var thisRow = renderOpenWithModuleRow(thisModule);
+        if (moduleSupportsAnyExt(thisModule, selectedExts)){
+            suggested += thisRow;
+        }else{
+            others += thisRow;
+        }
+    }
+
+    $("#openWithSuggestedList").html(suggested);
+    $("#openWithOtherList").html(others);
+    $("#openWithToggleRow").toggle(others != "");
+
+    if (suggested == ""){
+        //Nothing registered this file type, so folding the rest away would
+        //leave an empty dialog: show everything straight away instead
+        $("#openWithSuggestedLabel").hide();
+        $("#openWithSuggestedList").html(`<div class="openWithEmptyNote">${applocale.getString("opr/openwith/noSuggestion", "No WebApp is registered for this file type")}</div>`);
+        $("#openWithOtherList").show();
+        setOpenWithShowingAll(true);
+    }else{
+        $("#openWithSuggestedLabel").show();
+        $("#openWithOtherList").hide();
+        setOpenWithShowingAll(false);
+    }
+}
+
+//Only tracks the state and the toggle row wording: the caller decides whether
+//the list appears at once or slides, so this never fights the animation
+function setOpenWithShowingAll(showAll){
+    openWithShowingAll = showAll;
+    if (showAll){
+        $("#openWithToggleLabel").text(applocale.getString("opr/openwith/showLess", "Hide other WebApps"));
+        $("#openWithToggleIcon").attr("class", "caret down icon");
+    }else{
+        $("#openWithToggleLabel").text(applocale.getString("opr/openwith/showAll", "Other WebApps..."));
+        $("#openWithToggleIcon").attr("class", "caret right icon");
+    }
+}
+
+function toggleOpenWithOtherModules(){
+    if (openWithShowingAll){
+        $("#openWithOtherList").slideUp("fast");
+        setOpenWithShowingAll(false);
+    }else{
+        $("#openWithOtherList").slideDown("fast");
+        setOpenWithShowingAll(true);
+    }
+}
+
 //Functions for handling open with module selection and file opening
 function selectOpenWithModule(object, event){
     event.preventDefault();
@@ -195,3 +311,4 @@ window.openRawFileInFloatWindow = openRawFileInFloatWindow;
 window.openWith = openWith;
 window.openWithSelectedModule = openWithSelectedModule;
 window.selectOpenWithModule = selectOpenWithModule;
+window.toggleOpenWithOtherModules = toggleOpenWithOtherModules;

+ 12 - 3
src/web/SystemAO/file_system/js/explorer/ui.js

@@ -448,7 +448,8 @@ $(window).on("resize", function(){
 
 /*
     Hide menu entries that cannot act right now: most operations need a
-    selection, and Paste needs something on the clipboard.
+    selection, and Paste needs something on the clipboard - unless the large
+    toolbar is hidden, in which case Paste always stays.
 
     This applies to the overflow menu only. The large toolbar buttons keep their
     old behaviour of staying put and doing nothing, because a toolbar that
@@ -460,7 +461,15 @@ var OPR_NEEDS_SELECTION = ["open", "openwith", "copy", "cut", "rename", "delete"
 
 function updateOprMenuRelevance(){
     let hasSelection = $(".fileObject.selected").length > 0;
-    let hasClipboard = (typeof clipboard != "undefined") && clipboard.length > 0;
+    let hasClipboard = clipboardHasContent();
+
+    /*
+        With the large toolbar hidden this menu is the only place Paste could be
+        reached from, so it stays listed there whatever the clipboard looks like
+        - what this page knows about the clipboard is not the whole story, see
+        clipboardHasContent() in clipboard.js.
+    */
+    let oprBarHidden = !$("#fileOprBar").is(":visible");
 
     $("#fmMoreMenu [data-opr]").each(function(){
         let opr = $(this).attr("data-opr");
@@ -468,7 +477,7 @@ function updateOprMenuRelevance(){
         if (OPR_NEEDS_SELECTION.indexOf(opr) >= 0){
             usable = hasSelection;
         }else if (opr == "paste"){
-            usable = hasClipboard;
+            usable = hasClipboard || oprBarHidden;
         }
         $(this).toggleClass("fmOprUnusable", !usable);
     });

+ 146 - 21
src/web/SystemAO/file_system/js/explorer/uploadui.js

@@ -13,6 +13,12 @@
         setUploadTaskState(uuid, state)    -> pending|uploading|processing|
                                               paused|done|failed
         setUploadTaskStatusText(uuid, s)   -> override the right hand label
+        setUploadTaskPercentage(uuid, p)   -> progress of a task that reports a
+                                              percentage rather than bytes
+                                              (server side zipping)
+        setUploadTaskDoneLink(uuid, ...)   -> put a text link on the finished
+                                              row, for a download the browser
+                                              may have blocked
 
     Part of the ArozOS File Manager. Loaded as a plain script from
     file_explorer.html - see the <script> block at the end of that file.
@@ -48,13 +54,18 @@ function appendUploadFileItem(filename, filesize){
         speed: 0,
         lastLoaded: 0,
         lastTime: Date.now(),
-        statusOverride: null
+        statusOverride: null,
+        //Set instead of size/loaded by a task the server reports in percent
+        percent: null,
+        //{label, url, filename} shown in place of the completed tick
+        doneLink: null
     });
 
     $("#uploadProgressList").append(`<div class="uploadTask pending" taskID="${newuuid}">
         <div class="uploadTaskName"></div>
         <div class="uploadTaskMeta">
             <span class="uploadTaskSize"></span>
+            <span class="uploadTaskDoneLink" onclick="onUploadTaskDoneLink('${newuuid}');"></span>
             <span class="uploadTaskStatus"></span>
         </div>
         <div class="uploadTaskActions">
@@ -159,6 +170,52 @@ function setUploadTaskStatusText(taskUUID, text){
     renderUploadTask(taskUUID);
 }
 
+/*
+    Progress of a task whose total is not counted in bytes.
+
+    Server side zipping reports how far it is as a percentage of the files it
+    has packed, with no byte figure that would mean anything to the panel, so
+    such a task carries a percentage instead of a loaded/total pair.
+*/
+function setUploadTaskPercentage(taskUUID, percent){
+    let info = uploadTaskInfo.get(taskUUID);
+    if (info === undefined){
+        return;
+    }
+    if (!isFinite(percent)){
+        return;
+    }
+    info.percent = Math.max(0, Math.min(100, percent));
+    renderUploadTask(taskUUID);
+    updateUploadSummary();
+}
+
+/*
+    Put a text link on a completed task, in place of its tick.
+
+    A download that starts on its own is exactly what pop-up blockers stop, and
+    when they do the row would otherwise only say "Completed" with no way to get
+    at the file. The link is that way out - it runs from a real click, which no
+    browser blocks. It sits at the left of the status line, opposite the label,
+    rather than in the button column where it would read as a stray word.
+*/
+function setUploadTaskDoneLink(taskUUID, label, url, filename){
+    let info = uploadTaskInfo.get(taskUUID);
+    if (info === undefined){
+        return;
+    }
+    info.doneLink = {label: label, url: url, filename: filename};
+    renderUploadTask(taskUUID);
+}
+
+function onUploadTaskDoneLink(taskUUID){
+    let info = uploadTaskInfo.get(taskUUID);
+    if (info === undefined || !info.doneLink){
+        return;
+    }
+    generateDownloadFromURL(info.doneLink.url, info.doneLink.filename);
+}
+
 /*
     Rendering
 */
@@ -213,6 +270,26 @@ function formatUploadEta(seconds){
     return applocale.getString("upload/eta/hours", "%d hr left").replace("%d", Math.round(seconds / 3600));
 }
 
+/*
+    How far along a task is, in percent.
+
+    Bytes where they are known, the server reported percentage otherwise, and a
+    finished task is always a full bar - a zip whose last file was packed after
+    the final progress report would otherwise stop just short of the end.
+*/
+function uploadTaskPercentage(info){
+    if (info.state == "done"){
+        return 100;
+    }
+    if (info.size > 0){
+        return Math.min(100, info.loaded / info.size * 100);
+    }
+    if (info.percent !== null && info.percent !== undefined){
+        return info.percent;
+    }
+    return 0;
+}
+
 function renderUploadTask(taskUUID){
     let info = uploadTaskInfo.get(taskUUID);
     let row = getUploadTaskByID(taskUUID);
@@ -221,17 +298,16 @@ function renderUploadTask(taskUUID){
     }
 
     let knownSize = info.size > 0;
-    let percentage = 0;
-    if (info.state == "done"){
-        percentage = 100;
-    }else if (knownSize){
-        percentage = Math.min(100, info.loaded / info.size * 100);
-    }
-
-    //A task with no announced total (zip preparation) has no percentage to
-    //show, so its bar sweeps instead of filling
-    let indeterminate = !knownSize && info.state != "done" && info.state != "failed";
-    row.attr("class", "uploadTask " + info.state + (indeterminate ? " indeterminate" : ""));
+    let hasPercent = info.percent !== null && info.percent !== undefined;
+    let percentage = uploadTaskPercentage(info);
+
+    //Only a task that reports neither bytes nor a percentage has nothing to
+    //fill a bar with, and it sweeps instead
+    let indeterminate = !knownSize && !hasPercent && info.state != "done" && info.state != "failed";
+    let showDoneLink = info.state == "done" && info.doneLink != null;
+    row.attr("class", "uploadTask " + info.state +
+        (indeterminate ? " indeterminate" : "") +
+        (showDoneLink ? " withDoneLink" : ""));
     row.find(".uploadTaskBarFill").css("width", indeterminate ? "" : percentage + "%");
 
     //Byte counter. Without a known total there is nothing meaningful to divide by.
@@ -258,21 +334,36 @@ function renderUploadTask(taskUUID){
     }else if (info.state == "pending"){
         statusText = applocale.getString("upload/waiting", "Waiting");
     }else if (info.state == "processing"){
-        statusText = applocale.getString("upload/processing", "Processing");
+        //"45%" says more than "Processing" once the server reports progress
+        statusText = hasPercent ? Math.round(info.percent) + "%" :
+                     applocale.getString("upload/processing", "Processing");
     }else if (knownSize && info.speed > 0){
         statusText = formatUploadEta((info.size - info.loaded) / info.speed);
     }
     row.find(".uploadTaskStatus").text(statusText);
 
-    //Round button on the right. Completed rows show a status glyph instead of
-    //a control, which is why the icon is chosen from the state, not the handle.
+    //The other half of that line: the download link of a finished download.
+    //Written only when it changes, for the same reason the glyph below is.
+    let linkEl = row.find(".uploadTaskDoneLink");
+    if (showDoneLink && linkEl.text() != info.doneLink.label){
+        linkEl.text(info.doneLink.label);
+    }
+
+    /*
+        Round button on the right, chosen from the state rather than the handle.
+
+        A finished row has no control left to offer, and the tick that used to
+        stand there said nothing the row's own label does not already say, so
+        CSS hides the button on .done and nothing is drawn into it.
+    */
+    if (info.state == "done"){
+        return;
+    }
+
     let handle = uploadTransferMap.get(taskUUID);
     let iconName = "closeCircle";
     let btnTitle = applocale.getString("upload/cancel", "Cancel");
-    if (info.state == "done"){
-        iconName = "checkCircle";
-        btnTitle = applocale.getString("upload/completed", "Completed");
-    }else if (info.state == "failed"){
+    if (info.state == "failed"){
         iconName = "refresh";
         btnTitle = applocale.getString("upload/retry", "Retry");
     }else if (info.state == "paused"){
@@ -311,6 +402,8 @@ function updateUploadSummary(){
     let loaded = 0;
     let total = 0;
     let hasUnknown = false;
+    let percentSum = 0;
+    let taskCount = 0;
     uploadTaskInfo.forEach(function(info){
         loaded += info.loaded;
         if (info.size > 0){
@@ -318,8 +411,15 @@ function updateUploadSummary(){
         }else{
             hasUnknown = true;
         }
+        percentSum += uploadTaskPercentage(info);
+        taskCount++;
     });
 
+    //Every task counts the same towards the ring. Weighting them by size would
+    //need a size for each, which is exactly what a zip task cannot give.
+    let overall = taskCount > 0 ? percentSum / taskCount : 0;
+    setUploadSummaryRing(overall);
+
     //The %s slots get the same boxed number markup as the rows. The literal
     //text around them comes from the locale file and never changes width.
     let html = "";
@@ -329,10 +429,32 @@ function updateUploadSummary(){
                 .replace("%s", formatUploadBytesHTML(total));
     }else if (loaded > 0){
         html = formatUploadBytesHTML(loaded);
+    }else if (taskCount > 0){
+        //Nothing to count in bytes - a zip task only ever reports a percentage
+        html = Math.round(overall) + "%";
     }
     $("#uploadSummaryText").html(html);
 }
 
+/*
+    The ring in the summary row.
+
+    It replaces what used to be a cloud upload glyph: the panel carries server
+    side tasks as well as uploads now, so the icon says how far the work is
+    rather than what kind of work it is. The arc is the second circle of the
+    taskProgress icon in shared/fsicons.js, drawn from its 12 o'clock position.
+*/
+function setUploadSummaryRing(percent){
+    let arc = document.querySelector("#uploadSummaryIcon .taskProgressArc");
+    if (arc == null){
+        return;
+    }
+    //Circumference of the icon's r=9 circle in its 24x24 box
+    let circumference = 56.5;
+    let filled = Math.max(0, Math.min(100, percent)) / 100 * circumference;
+    arc.setAttribute("stroke-dasharray", filled.toFixed(1) + " " + circumference);
+}
+
 function updateUploadFileCount(){
     let active = 0;
     uploadTaskInfo.forEach(function(info){
@@ -341,11 +463,13 @@ function updateUploadFileCount(){
         }
     });
 
+    //Uploads are not the only thing that rides this panel - a folder download
+    //zips server side first - so the title names tasks rather than uploads
     let title = "";
     if (active > 0){
-        title = applocale.getString("upload/title", "Uploading %d items").replace("%d", active);
+        title = applocale.getString("upload/title", "%d tasks in progress").replace("%d", active);
     }else{
-        title = applocale.getString("upload/titleDone", "%d items completed").replace("%d", $(".uploadTask").length);
+        title = applocale.getString("upload/titleDone", "%d tasks completed").replace("%d", $(".uploadTask").length);
     }
     $("#uploadHeaderTitle").text(title);
 
@@ -651,4 +775,5 @@ window.closeUploadTab = closeUploadTab;
 window.clearCompletedUploads = clearCompletedUploads;
 window.cancelAllUploads = cancelAllUploads;
 window.onUploadTaskButton = onUploadTaskButton;
+window.onUploadTaskDoneLink = onUploadTaskDoneLink;   // the "Download again" link
 window.cancelUploadTask = cancelUploadTask;   // the paused row's cancel button

+ 5 - 5
src/web/SystemAO/file_system/shared/fs-components.css

@@ -93,7 +93,7 @@ body.darkTheme .fsBigIcon svg path[fill="#ffffff"] { fill: #e4e6ea; }
     border: 1px solid var(--fs-line);
     border-radius: 10px;
     box-shadow: 0 8px 28px var(--fs-shadow);
-    padding: 6px;
+    padding: 4px;
     display: none;
 }
 .fsMenu.open { display: block; }
@@ -101,8 +101,8 @@ body.darkTheme .fsBigIcon svg path[fill="#ffffff"] { fill: #e4e6ea; }
 .fsMenuItem {
     display: flex;
     align-items: center;
-    gap: 9px;
-    padding: 6px 9px;
+    gap: 6px;
+    padding: 4px 6px;
     border-radius: 6px;
     cursor: pointer;
     font-size: 13.5px;
@@ -136,7 +136,7 @@ body.darkTheme .fsBigIcon svg path[fill="#ffffff"] { fill: #e4e6ea; }
 /* Right-aligned keyboard shortcut hint */
 .fsMenuShortcut {
     margin-left: auto;
-    padding-left: 18px;
+    padding-left: 12px;
     color: var(--fs-text-faint);
     font-size: 12px;
 }
@@ -144,7 +144,7 @@ body.darkTheme .fsBigIcon svg path[fill="#ffffff"] { fill: #e4e6ea; }
 .fsMenuSep {
     height: 1px;
     background: var(--fs-line-soft);
-    margin: 5px 4px;
+    margin: 3px;
 }
 
 /* A menu positioned against the viewport rather than an offset parent */

+ 7 - 0
src/web/SystemAO/file_system/shared/fsicons.js

@@ -81,6 +81,13 @@
         chevronDown: S + '<path d="M6 9.5l6 6 6-6"/></svg>',
         chevronUp:   S + '<path d="M6 14.5l6-6 6 6"/></svg>',
         cloudUpload: S + '<path d="M6.5 19a4.5 4.5 0 0 1-.6-8.96A6 6 0 0 1 17.7 9.2 3.9 3.9 0 0 1 17.5 19z"/><path d="M12 18v-7M9.2 13.3L12 10.5l2.8 2.8"/></svg>',
+        /*
+            Generic progress ring for the transfer panel summary, which carries
+            server side tasks as well as uploads. The second circle is the arc:
+            uploadui.js sets its stroke-dasharray to fill it, and it is rotated
+            so the fill starts at 12 o'clock. r=9, circumference 56.5.
+        */
+        taskProgress: S + '<circle cx="12" cy="12" r="9" opacity="0.3"/><circle class="taskProgressArc" cx="12" cy="12" r="9" stroke-dasharray="0 56.5" transform="rotate(-90 12 12)"/></svg>',
         pause:       S + '<path d="M9.5 5.5v13M14.5 5.5v13"/></svg>',
         play:        S + '<path d="M8 5.4l10 6.6-10 6.6z"/></svg>',
         pauseCircle: S + '<circle cx="12" cy="12" r="9"/><path d="M10.1 9.2v5.6M13.9 9.2v5.6"/></svg>',

+ 44 - 8
src/web/SystemAO/locale/file_explorer.json

@@ -144,6 +144,10 @@
                 "opr/share/ok": "完成",
                 "opr/openwith/title": "使用 … 開啟",
                 "opr/openwith/desc": "選擇一個 WebApp 以開啟此檔案",
+                "opr/openwith/suggested": "建議的 WebApp",
+                "opr/openwith/showAll": "其他 WebApp…",
+                "opr/openwith/showLess": "隱藏其他 WebApp",
+                "opr/openwith/noSuggestion": "沒有 WebApp 登記支援此檔案類型",
                 "opr/openwith/loading": "載入中…",
                 "opr/openwith/openWithWebApp": "使用所選的 WebApp 開啟檔案",
                 "opr/openwith/openWithWebAppInNewWindow": "使用所選的 WebApp 在新視窗開啟",
@@ -235,8 +239,9 @@
                 "message/uploadFailed": "載案上載失敗:檔案太大或目標儲存裝置已滿",
                 "message/newFilenameIdentical": "重新命名失敗:新舊檔案名稱相同",
                 "message/illegalCharacters": "名稱包含非法字元(不可使用:/ \\ ? % * : | \" < >)",
-                "upload/title": "上傳中 %d 個項目",
+                "upload/title": "處理中 %d 個項目",
                 "upload/titleDone": "已完成 %d 個項目",
+                "upload/downloadAgain": "再次下載",
                 "upload/completed": "已完成",
                 "upload/failed": "上傳失敗",
                 "upload/waiting": "等待中",
@@ -354,6 +359,7 @@
                 "Upload List": "上傳清單"
             },
             "placeholder": {
+                "Search": "搜尋",
                 "New Filename": "在此輸入新檔案名稱",
                 "New Folder Name": "在此輸入新資料夾名稱",
                 "File Search (Start wildcard search with / , e.g. /*.mp3)": "檔案搜尋(如要使用萬用字元,請先輸入 / 號,例如: /*.mp3)"
@@ -501,6 +507,10 @@
                 "opr/share/ok": "完成",
                 "opr/openwith/title": "使用 … 開啟",
                 "opr/openwith/desc": "選擇一個 WebApp 以開啟此檔案",
+                "opr/openwith/suggested": "建議的 WebApp",
+                "opr/openwith/showAll": "其他 WebApp…",
+                "opr/openwith/showLess": "隱藏其他 WebApp",
+                "opr/openwith/noSuggestion": "沒有 WebApp 登記支援此檔案類型",
                 "opr/openwith/loading": "載入中…",
                 "opr/openwith/openWithWebApp": "使用所選的 WebApp 開啟檔案",
                 "opr/openwith/openWithWebAppInNewWindow": "使用所選的 WebApp 在新視窗開啟",
@@ -592,8 +602,9 @@
                 "message/illegalCharacters": "名稱包含非法字元(不可使用:/ \\ ? % * : | \" < >)",
                 "message/folderCannotOpen": "無法開啟此資料夾",
                 "message/folderCannotOpen/codedesc": "伺服器端回傳以下錯誤訊息: ",
-                "upload/title": "上傳中 %d 個項目",
+                "upload/title": "處理中 %d 個項目",
                 "upload/titleDone": "已完成 %d 個項目",
+                "upload/downloadAgain": "再次下載",
                 "upload/completed": "已完成",
                 "upload/failed": "上傳失敗",
                 "upload/waiting": "等待中",
@@ -711,6 +722,7 @@
                 "Upload List": "上傳清單"
             },
             "placeholder": {
+                "Search": "搜尋",
                 "New Filename": "在此輸入新檔案名稱",
                 "New Folder Name": "在此輸入新資料夾名稱",
                 "File Search (Start wildcard search with / , e.g. /*.mp3)": "檔案搜尋(如要使用萬用字元,請先輸入 / 號,例如: /*.mp3)"
@@ -858,6 +870,10 @@
                 "opr/share/ok": "完成",
                 "opr/openwith/title": "使用 … 打开",
                 "opr/openwith/desc": "选择一个 WebApp 以打开此文件",
+                "opr/openwith/suggested": "建议的 WebApp",
+                "opr/openwith/showAll": "其他 WebApp…",
+                "opr/openwith/showLess": "隐藏其他 WebApp",
+                "opr/openwith/noSuggestion": "没有 WebApp 登记支持此文件类型",
                 "opr/openwith/loading": "载入中…",
                 "opr/openwith/openWithWebApp": "使用所选的 WebApp 打开文件",
                 "opr/openwith/openWithWebAppInNewWindow": "使用所选的 WebApp 在新窗口中打开",
@@ -950,8 +966,9 @@
                 "message/No file selected": "没有选择文件",
                 "message/folderCannotOpen": "无法打开此文件夹",
                 "message/folderCannotOpen/codedesc": "服务器端返回以下错误信息: ",
-                "upload/title": "上传中 %d 个项目",
+                "upload/title": "处理中 %d 个项目",
                 "upload/titleDone": "已完成 %d 个项目",
+                "upload/downloadAgain": "再次下载",
                 "upload/completed": "已完成",
                 "upload/failed": "上传失败",
                 "upload/waiting": "等待中",
@@ -1069,6 +1086,7 @@
                 "Upload List": "上传列表"
             },
             "placeholder": {
+                "Search": "搜索",
                 "New Filename": "在此输入新文件名称",
                 "New Folder Name": "在此输入新文件夹名称",
                 "File Search (Start wildcard search with / , e.g. /*.mp3)": "文件搜索(如要使用通配符,请先输入 / 号,例如:/*.mp3)"
@@ -1216,6 +1234,10 @@
                 "opr/share/ok": "Done",
                 "opr/openwith/title": "Open with...",
                 "opr/openwith/desc": "Select a web app to open this file",
+                "opr/openwith/suggested": "Suggested WebApps",
+                "opr/openwith/showAll": "Other WebApps...",
+                "opr/openwith/showLess": "Hide other WebApps",
+                "opr/openwith/noSuggestion": "No WebApp is registered for this file type",
                 "opr/openwith/loading": "Loading...",
                 "opr/openwith/openWithWebApp": "Open the file with the selected web app",
                 "opr/openwith/openWithWebAppInNewWindow": "Open the file with the selected web app in a new window",
@@ -1309,8 +1331,9 @@
                 "message/No file selected": "No file selected",
                 "message/folderCannotOpen": "Cannot open this folder",
                 "message/folderCannotOpen/codedesc": "The server returned the following error message: ",
-                "upload/title": "Uploading %d items",
-                "upload/titleDone": "%d items completed",
+                "upload/title": "%d tasks in progress",
+                "upload/titleDone": "%d tasks completed",
+                "upload/downloadAgain": "Download again",
                 "upload/completed": "Completed",
                 "upload/failed": "Failed",
                 "upload/waiting": "Waiting",
@@ -1428,6 +1451,7 @@
                 "Upload List": "Upload List"
             },
             "placeholder": {
+                "Search": "Search",
                 "New Filename": "Enter new file name here",
                 "New Folder Name": "Enter new folder name here",
                 "File Search (Start wildcard search with / , e.g. /*.mp3)": "File search (start with / for wildcard search, e.g. / *.mp3)"
@@ -1575,6 +1599,10 @@
                 "opr/share/ok": "完了",
                 "opr/openwith/title": "アプリで開く...",
                 "opr/openwith/desc": "このファイルを開くWebアプリを選択してください。",
+                "opr/openwith/suggested": "推奨されるWebアプリ",
+                "opr/openwith/showAll": "その他のWebアプリ...",
+                "opr/openwith/showLess": "その他のWebアプリを隠す",
+                "opr/openwith/noSuggestion": "このファイル形式に対応するWebアプリはありません",
                 "opr/openwith/loading": "読み込み中...",
                 "opr/openwith/openWithWebApp": "選択したWebアプリでファイルを開く",
                 "opr/openwith/openWithWebAppInNewWindow": "選択したWebアプリでファイルを新しいウィンドウで開く",
@@ -1668,8 +1696,9 @@
                 "message/No file selected": "ファイルが選択されていません",
                 "message/folderCannotOpen": "このフォルダを開けません",
                 "message/folderCannotOpen/codedesc": "サーバーから次のエラーメッセージが返されました: ",
-                "upload/title": "%d 件をアップロード中",
+                "upload/title": "%d 件を処理中",
                 "upload/titleDone": "%d 件完了",
+                "upload/downloadAgain": "再ダウンロード",
                 "upload/completed": "完了",
                 "upload/failed": "失敗",
                 "upload/waiting": "待機中",
@@ -1787,6 +1816,7 @@
                 "Upload List": "アップロード一覧"
             },
             "placeholder": {
+                "Search": "検索",
                 "New Filename": "ここに新しいファイル名を入力してください",
                 "New Folder Name": "ここに新しいフォルダ名を入力してください",
                 "File Search (Start wildcard search with / , e.g. /.mp3)": "ファイル検索 (/ を付けてワイルドカード検索を開始、例:/.mp3)",
@@ -1935,6 +1965,10 @@
                 "opr/share/ok": "완료",
                 "opr/openwith/title": "… 사용하여 열기",
                 "opr/openwith/desc": "이 파일을 열기 위해 웹 앱을 선택하세요",
+                "opr/openwith/suggested": "추천 웹 앱",
+                "opr/openwith/showAll": "다른 웹 앱...",
+                "opr/openwith/showLess": "다른 웹 앱 숨기기",
+                "opr/openwith/noSuggestion": "이 파일 형식에 등록된 웹 앱이 없습니다",
                 "opr/openwith/loading": "로딩 중...",
                 "opr/openwith/openWithWebApp": "선택한 웹 앱으로 파일 열기",
                 "opr/openwith/openWithWebAppInNewWindow": "새 창에서 선택한 웹 앱으로 파일 열기",
@@ -2029,8 +2063,9 @@
                 "opr/shortcut/error": "바탕화면 바로가기 생성에 실패했습니다",
                 "opr/shortcut/title": "바탕 화면에 바로 가기 만들기",
                 "opr/shortcut/ok": "바로 가기가 생성되었습니다",
-                "upload/title": "%d개 항목 업로드 중",
+                "upload/title": "%d개 항목 처리 중",
                 "upload/titleDone": "%d개 항목 완료",
+                "upload/downloadAgain": "다시 다운로드",
                 "upload/completed": "완료됨",
                 "upload/failed": "실패",
                 "upload/waiting": "대기 중",
@@ -2148,10 +2183,11 @@
                 "Upload List": "업로드 목록"
             },
             "placeholder": {
+                "Search": "검색",
                 "New Filename": "새 파일 이름 입력",
                 "New Folder Name": "새 폴더 이름 입력",
                 "File Search (Start wildcard search with / , e.g. /*.mp3)": "파일 검색 (슬래시 (/)로 와일드카드 검색 시작, 예: /*.mp3)"
             }
         }
     }
-}
+}

+ 72 - 0
src/web/SystemAO/locale/file_selector.json

@@ -7,6 +7,18 @@
             "fontFamily": "\"Microsoft JhengHei\",\"SimHei\", \"Apple LiGothic Medium\", \"STHeiti\"",
             "strings": {
                 "title": "選擇檔案",
+                "title/fileSelected/pre": "已選取 ",
+                "title/fileSelected/post": " 個檔案",
+                "title/filesSelected/pre": "已選取 ",
+                "title/filesSelected/post": " 個檔案",
+                "title/folderSelected/pre": "已選取 ",
+                "title/folderSelected/post": " 個資料夾",
+                "title/foldersSelected/pre": "已選取 ",
+                "title/foldersSelected/post": " 個資料夾",
+                "title/itemSelected/pre": "已選取 ",
+                "title/itemSelected/post": " 個項目",
+                "title/itemsSelected/pre": "已選取 ",
+                "title/itemsSelected/post": " 個項目",
                 "roots/user": "使用者",
                 "roots/storage": "虛擬儲存裝置",
                 "message/loading": "載入中…",
@@ -101,6 +113,18 @@
             "fontFamily": "\"Microsoft JhengHei\",\"SimHei\", \"Apple LiGothic Medium\", \"STHeiti\"",
             "strings": {
                 "title": "選擇檔案",
+                "title/fileSelected/pre": "已選取 ",
+                "title/fileSelected/post": " 個檔案",
+                "title/filesSelected/pre": "已選取 ",
+                "title/filesSelected/post": " 個檔案",
+                "title/folderSelected/pre": "已選取 ",
+                "title/folderSelected/post": " 個資料夾",
+                "title/foldersSelected/pre": "已選取 ",
+                "title/foldersSelected/post": " 個資料夾",
+                "title/itemSelected/pre": "已選取 ",
+                "title/itemSelected/post": " 個項目",
+                "title/itemsSelected/pre": "已選取 ",
+                "title/itemsSelected/post": " 個項目",
                 "roots/user": "使用者",
                 "roots/storage": "虛擬儲存裝置",
                 "message/loading": "載入中…",
@@ -195,6 +219,18 @@
             "fontFamily": "\"Microsoft JhengHei\",\"SimHei\", \"Apple LiGothic Medium\", \"STHeiti\"",
             "strings": {
                 "title": "选择文件",
+                "title/fileSelected/pre": "已选中 ",
+                "title/fileSelected/post": " 个文件",
+                "title/filesSelected/pre": "已选中 ",
+                "title/filesSelected/post": " 个文件",
+                "title/folderSelected/pre": "已选中 ",
+                "title/folderSelected/post": " 个文件夹",
+                "title/foldersSelected/pre": "已选中 ",
+                "title/foldersSelected/post": " 个文件夹",
+                "title/itemSelected/pre": "已选中 ",
+                "title/itemSelected/post": " 个项目",
+                "title/itemsSelected/pre": "已选中 ",
+                "title/itemsSelected/post": " 个项目",
                 "roots/user": "使用者",
                 "roots/storage": "虚拟存储设备",
                 "message/loading": "载入中…",
@@ -289,6 +325,18 @@
             "fontFamily": "",
             "strings": {
                 "title": "Choose Files",
+                "title/fileSelected/pre": "",
+                "title/fileSelected/post": " file selected",
+                "title/filesSelected/pre": "",
+                "title/filesSelected/post": " files selected",
+                "title/folderSelected/pre": "",
+                "title/folderSelected/post": " folder selected",
+                "title/foldersSelected/pre": "",
+                "title/foldersSelected/post": " folders selected",
+                "title/itemSelected/pre": "",
+                "title/itemSelected/post": " item selected",
+                "title/itemsSelected/pre": "",
+                "title/itemsSelected/post": " items selected",
                 "roots/user": "User",
                 "roots/storage": "Storage",
                 "message/loading": "Loading...",
@@ -383,6 +431,18 @@
             "fontFamily": "\"Meiryo UI\", \"Arial Unicode MS\", \"Hiragino Kaku Gothic Pro\"",
             "strings": {
                 "title": "ファイル選択",
+                "title/fileSelected/pre": "ファイルを ",
+                "title/fileSelected/post": " 個選択中",
+                "title/filesSelected/pre": "ファイルを ",
+                "title/filesSelected/post": " 個選択中",
+                "title/folderSelected/pre": "フォルダを ",
+                "title/folderSelected/post": " 個選択中",
+                "title/foldersSelected/pre": "フォルダを ",
+                "title/foldersSelected/post": " 個選択中",
+                "title/itemSelected/pre": "項目を ",
+                "title/itemSelected/post": " 個選択中",
+                "title/itemsSelected/pre": "項目を ",
+                "title/itemsSelected/post": " 個選択中",
                 "roots/user": "ユーザー",
                 "roots/storage": "ストレージデバイス",
                 "message/loading": "読み込み中...",
@@ -477,6 +537,18 @@
             "fontFamily": "",
             "strings": {
                 "title": "파일 선택",
+                "title/fileSelected/pre": "파일 ",
+                "title/fileSelected/post": "개 선택됨",
+                "title/filesSelected/pre": "파일 ",
+                "title/filesSelected/post": "개 선택됨",
+                "title/folderSelected/pre": "폴더 ",
+                "title/folderSelected/post": "개 선택됨",
+                "title/foldersSelected/pre": "폴더 ",
+                "title/foldersSelected/post": "개 선택됨",
+                "title/itemSelected/pre": "항목 ",
+                "title/itemSelected/post": "개 선택됨",
+                "title/itemsSelected/pre": "항목 ",
+                "title/itemsSelected/post": "개 선택됨",
                 "roots/user": "사용자",
                 "roots/storage": "스토리지",
                 "message/loading": "로딩 중...",