Browse Source

Add trash bin view with retention and quota

This adds first-class trash management to File Manager with a dedicated trash special view, sidebar entry, sorting/searching, restore/delete actions, and detail dialogs. On the backend, it introduces per-user trash settings (`/system/file_system/trashSettings`) for retention days and quota bytes, enforces quota checks before recycle operations, and adds nightly cleanup of expired trash items. It also adds a new System Settings page for trash controls, new trash-related locale strings/icons, and bumps the internal version to 3.0.3.
Toby Chui 21 hours ago
parent
commit
60517d17e8

+ 227 - 0
src/file_system.go

@@ -152,6 +152,7 @@ func FileSystemInit() {
 	router.HandleFunc("/system/file_system/listTrash", system_fs_scanTrashBin)
 	router.HandleFunc("/system/file_system/ws/listTrash", system_fs_WebSocketScanTrashBin)
 	router.HandleFunc("/system/file_system/clearTrash", system_fs_clearTrashBin)
+	router.HandleFunc("/system/file_system/trashSettings", system_fs_handleTrashSettings)
 	router.HandleFunc("/system/file_system/restoreTrash", system_fs_restoreFile)
 	router.HandleFunc("/system/file_system/zipHandler", system_fs_zipHandler)
 	router.HandleFunc("/system/file_system/getProperties", system_fs_getFileProperties)
@@ -274,6 +275,15 @@ func FileSystemInit() {
 		the arozos file system when no one is using the system
 	*/
 
+	//Trash bin retention and size limit, per user
+	registerSetting(settingModule{
+		Name:     "File Manager",
+		Desc:     "Trash Bin Retention and Size",
+		IconPath: "SystemAO/file_system/trashbin_img/small_icon.png",
+		Group:    "Disk",
+		StartDir: "SystemAO/disk/filemanager/trashsettings.html",
+	})
+
 	//Clear tmp folder if files is placed here too long
 	nightlyManager.RegisterNightlyTask(system_fs_clearOldTmpFiles)
 
@@ -291,6 +301,9 @@ func FileSystemInit() {
 	systemWideLogger.PrintAndLog("File System", "Started File Version History Cleaning in background", nil)
 
 	nightlyManager.RegisterNightlyTask(system_fs_clearVersionHistories)
+
+	//Purge trashed files older than each user's retention setting
+	nightlyManager.RegisterNightlyTask(system_fs_clearExpiredTrash)
 }
 
 /*
@@ -1119,6 +1132,202 @@ func system_fs_validateFileOpr(w http.ResponseWriter, r *http.Request) {
 	utils.SendJSONResponse(w, string(jsonString))
 }
 
+/*
+Trash bin settings
+
+Stored per user in the same preference table the File Manager already uses,
+so they follow the account rather than the browser.
+
+Both settings use zero as "no limit", which is also what an account that has
+never opened the settings page reads back:
+
+	trash/retentionDays   0 = never auto remove,  otherwise 1..365
+	trash/quotaBytes      0 = unlimited
+*/
+const (
+	trashRetentionPrefKey = "trash/retentionDays"
+	trashQuotaPrefKey     = "trash/quotaBytes"
+	trashMaxRetentionDays = 365
+
+	//What an account that has never opened the settings page holds trash for.
+	//An explicitly stored zero still means "never remove" - only the absence
+	//of a stored value falls back to this.
+	trashDefaultRetentionDays = 30
+)
+
+func system_fs_getTrashRetentionDays(username string) int {
+	result := ""
+	err := sysdb.Read("fs", "pref/"+trashRetentionPrefKey+"/"+username, &result)
+	if err != nil {
+		//Never set for this account
+		return trashDefaultRetentionDays
+	}
+	days, err := strconv.Atoi(result)
+	if err != nil || days < 0 {
+		//Stored but unreadable - the default is a safer answer than "never"
+		return trashDefaultRetentionDays
+	}
+	if days > trashMaxRetentionDays {
+		days = trashMaxRetentionDays
+	}
+	return days
+}
+
+func system_fs_getTrashQuotaBytes(username string) int64 {
+	result := ""
+	err := sysdb.Read("fs", "pref/"+trashQuotaPrefKey+"/"+username, &result)
+	if err != nil {
+		return 0
+	}
+	quota, err := utils.StringToInt64(result)
+	if err != nil || quota < 0 {
+		return 0
+	}
+	return quota
+}
+
+/*
+Space a single trashed entry takes up
+
+A folder counts for everything inside it, not the zero bytes its own entry
+reports - otherwise a user could fill the bin with folders and never reach
+the quota. Hidden files are included because they were moved along with the
+rest, and the trash itself lives inside a hidden folder.
+*/
+func system_fs_getTrashEntrySize(fsh *filesystem.FileSystemHandler, rpath string) int64 {
+	if fsh.FileSystemAbstraction.IsDir(rpath) {
+		size, _ := fsh.GetDirctorySizeFromRealPath(rpath, true)
+		return size
+	}
+	return int64(fsh.FileSystemAbstraction.GetFileSize(rpath))
+}
+
+// Total bytes currently sitting in a user's trash across every file system
+func system_fs_getTrashUsage(username string) int64 {
+	files, fshs, err := system_fs_listTrash(username)
+	if err != nil {
+		return 0
+	}
+	total := int64(0)
+	for c, file := range files {
+		total += system_fs_getTrashEntrySize(fshs[c], file)
+	}
+	return total
+}
+
+/*
+Read or write the trash settings for the logged in user.
+
+GET  with no value  -> {"RetentionDays":n,"QuotaBytes":n,"MaxRetentionDays":365,"UsedBytes":n}
+POST retentionDays / quotaBytes -> stores them
+*/
+func system_fs_handleTrashSettings(w http.ResponseWriter, r *http.Request) {
+	username, err := authAgent.GetUserName(w, r)
+	if err != nil {
+		utils.SendErrorResponse(w, "User not logged in")
+		return
+	}
+
+	retention, retentionSet := utils.PostPara(r, "retentionDays")
+	quota, quotaSet := utils.PostPara(r, "quotaBytes")
+
+	if retentionSet == nil && retention != "" {
+		days, err := strconv.Atoi(retention)
+		if err != nil || days < 0 || days > trashMaxRetentionDays {
+			utils.SendErrorResponse(w, "Invalid retention day given")
+			return
+		}
+		sysdb.Write("fs", "pref/"+trashRetentionPrefKey+"/"+username, strconv.Itoa(days))
+	}
+
+	if quotaSet == nil && quota != "" {
+		bytes, err := utils.StringToInt64(quota)
+		if err != nil || bytes < 0 {
+			utils.SendErrorResponse(w, "Invalid quota given")
+			return
+		}
+		sysdb.Write("fs", "pref/"+trashQuotaPrefKey+"/"+username, utils.Int64ToString(bytes))
+	}
+
+	type trashSettings struct {
+		RetentionDays    int
+		QuotaBytes       int64
+		MaxRetentionDays int
+		UsedBytes        int64
+	}
+	js, _ := json.Marshal(trashSettings{
+		RetentionDays:    system_fs_getTrashRetentionDays(username),
+		QuotaBytes:       system_fs_getTrashQuotaBytes(username),
+		MaxRetentionDays: trashMaxRetentionDays,
+		UsedBytes:        system_fs_getTrashUsage(username),
+	})
+	utils.SendJSONResponse(w, string(js))
+}
+
+/*
+Nightly cleanup
+
+Removes trashed files past their owner's retention window. Users who have
+not set a retention (or set it to zero) keep their trash indefinitely, which
+is the behaviour every account had before this setting existed.
+*/
+func system_fs_clearExpiredTrash() {
+	for _, username := range authAgent.ListUsers() {
+		retentionDays := system_fs_getTrashRetentionDays(username)
+		if retentionDays <= 0 {
+			//Auto removal disabled for this user
+			continue
+		}
+
+		userinfo, err := userHandler.GetUserInfoFromUsername(username)
+		if err != nil {
+			continue
+		}
+
+		cutoff := time.Now().Unix() - int64(retentionDays)*86400
+		files, fshs, err := system_fs_listTrash(username)
+		if err != nil {
+			continue
+		}
+
+		removed := 0
+		for c, file := range files {
+			/*
+				The removal time is stored as the file extension when the file
+				was recycled. Anything without a parsable timestamp is left
+				alone rather than guessed at.
+			*/
+			ext := filepath.Ext(file)
+			if len(ext) < 2 {
+				continue
+			}
+			timestamp, err := utils.StringToInt64(ext[1:])
+			if err != nil || timestamp > cutoff {
+				continue
+			}
+
+			fshAbs := fshs[c].FileSystemAbstraction
+			fileVpath, err := fshAbs.RealPathToVirtualPath(file, username)
+			if err == nil && userinfo.IsOwnerOfFile(fshs[c], fileVpath) {
+				userinfo.RemoveOwnershipFromFile(fshs[c], fileVpath)
+			}
+			fshAbs.RemoveAll(file)
+
+			//Drop the .trash folder too once nothing is left in it
+			remaining, _ := fshAbs.Glob(filepath.Dir(file) + "/*")
+			if len(remaining) == 0 {
+				fshAbs.Remove(filepath.Dir(file))
+			}
+			removed++
+		}
+
+		if removed > 0 {
+			systemWideLogger.PrintAndLog("File System", "Removed "+strconv.Itoa(removed)+
+				" expired trash item(s) for user "+username, nil)
+		}
+	}
+}
+
 // Scan all directory and get trash file and send back results with WebSocket
 func system_fs_WebSocketScanTrashBin(w http.ResponseWriter, r *http.Request) {
 	//Get and check user permission
@@ -2382,6 +2591,24 @@ func system_fs_handleOpr(w http.ResponseWriter, r *http.Request) {
 					srcFshAbs.Remove(filepath.ToSlash(filepath.Dir(rsrcFile)) + "/.metadata/.cache/")
 				}
 
+				/*
+					Trash quota
+
+					Checked before the move, not after: once the file is inside
+					.trash it has already left the tree the user was looking at,
+					and undoing that cleanly is harder than refusing up front.
+					The client recognises this error code and offers to empty
+					the bin, delete outright, or do nothing.
+				*/
+				userTrashQuota := system_fs_getTrashQuotaBytes(userinfo.Username)
+				if userTrashQuota > 0 {
+					incomingSize := system_fs_getTrashEntrySize(srcFsh, rsrcFile)
+					if system_fs_getTrashUsage(userinfo.Username)+incomingSize > userTrashQuota {
+						utils.SendErrorResponse(w, "TRASH_QUOTA_EXCEEDED")
+						return
+					}
+				}
+
 				//Create a trash directory for this folder
 				trashDir := filepath.ToSlash(filepath.Dir(rsrcFile)) + "/.metadata/.trash/"
 				srcFshAbs.MkdirAll(trashDir, 0755)

+ 1 - 1
src/flags.go

@@ -35,7 +35,7 @@ var subserviceBasePort = 12810            //Next subservice port
 
 // =========== SYSTEM BUILD INFORMATION ==============
 var build_version = "development"                      //System build flag, this can be either {development / production / stable}
-var internal_version = "3.0.1"                         //Internal build version, [fork_id].[major_release_no].[minor_release_no]
+var internal_version = "3.0.3"                         //Internal build version, [fork_id].[major_release_no].[minor_release_no]
 var deviceUUID string                                  //The device uuid of this host
 var deviceVendor = "IMUSLAB.INC"                       //Vendor of the system
 var deviceVendorURL = "http://imuslab.com"             //Vendor contact information

+ 294 - 0
src/web/SystemAO/disk/filemanager/trashsettings.html

@@ -0,0 +1,294 @@
+<style>
+#tsRoot {
+    --ts-bg:      #ffffff;
+    --ts-border:  #e5e5e5;
+    --ts-text:    #1d1d1f;
+    --ts-dim:     #6e6e73;
+    --ts-soft:    #f5f5f7;
+    --ts-accent:  #0071e3;
+    --ts-divider: #e0e0e0;
+
+    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
+    font-size: 14px;
+    -webkit-font-smoothing: antialiased;
+    color: var(--ts-text);
+    background: var(--ts-bg);
+    border: 1px solid var(--ts-border);
+    border-radius: 12px;
+    padding: 20px;
+    margin-bottom: 4px;
+}
+
+#tsRoot.dark {
+    --ts-bg:      #2b2b2b;
+    --ts-border:  #3b3b3b;
+    --ts-text:    #ececec;
+    --ts-dim:     #aaaaaa;
+    --ts-soft:    #333333;
+    --ts-divider: #3f3f3f;
+}
+
+#tsRoot h3 {
+    margin: 0 0 4px 0;
+    font-size: 17px;
+    font-weight: 600;
+}
+#tsRoot .tsSub {
+    color: var(--ts-dim);
+    margin-bottom: 20px;
+    line-height: 1.5;
+}
+
+#tsRoot .tsSection {
+    padding: 16px 0;
+    border-top: 1px solid var(--ts-divider);
+}
+#tsRoot .tsSectionTitle {
+    font-weight: 600;
+    margin-bottom: 3px;
+}
+#tsRoot .tsSectionDesc {
+    color: var(--ts-dim);
+    font-size: 13px;
+    margin-bottom: 12px;
+    line-height: 1.5;
+}
+
+#tsRoot .tsRow {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    flex-wrap: wrap;
+}
+#tsRoot select,
+#tsRoot input[type="number"] {
+    padding: 7px 10px;
+    border: 1px solid var(--ts-border);
+    border-radius: 8px;
+    background: var(--ts-bg);
+    color: var(--ts-text);
+    font-size: 13.5px;
+    min-width: 150px;
+}
+#tsRoot input[type="number"] { width: 120px; min-width: 0; }
+
+/*
+    The size inputs are switched off while the bin is unlimited. The rules
+    above paint their own background and colour, which is what a browser would
+    otherwise dim on its own, so the disabled look has to be spelled out.
+*/
+#tsRoot select:disabled,
+#tsRoot input[type="number"]:disabled {
+    background: var(--ts-soft);
+    color: var(--ts-dim);
+    opacity: 0.6;
+    cursor: not-allowed;
+}
+
+#tsRoot .tsUsage {
+    margin-top: 10px;
+    font-size: 13px;
+    color: var(--ts-dim);
+}
+
+#tsRoot .tsActions {
+    padding-top: 18px;
+    border-top: 1px solid var(--ts-divider);
+    display: flex;
+    align-items: center;
+    gap: 10px;
+}
+#tsRoot button.tsSave {
+    padding: 8px 18px;
+    border: none;
+    border-radius: 8px;
+    background: var(--ts-accent);
+    color: #ffffff;
+    font-size: 13.5px;
+    cursor: pointer;
+}
+#tsRoot button.tsSave:disabled { opacity: 0.5; cursor: default; }
+#tsRoot .tsStatus { font-size: 13px; color: var(--ts-dim); }
+</style>
+
+<div id="tsRoot">
+    <h3 locale="ts/title">File Manager</h3>
+    <div class="tsSub" locale="ts/subtitle">Control how long deleted files are kept in the trash bin and how much space they may use.</div>
+
+    <div class="tsSection">
+        <div class="tsSectionTitle" locale="ts/retention/title">Automatic Removal</div>
+        <div class="tsSectionDesc" locale="ts/retention/desc">Files in the trash bin are permanently removed once they are older than this. Turn it off to keep them until you empty the bin yourself.</div>
+        <div class="tsRow">
+            <select id="tsRetention"></select>
+        </div>
+    </div>
+
+    <div class="tsSection">
+        <div class="tsSectionTitle" locale="ts/quota/title">Trash Bin Size Limit</div>
+        <div class="tsSectionDesc" locale="ts/quota/desc">When the limit is reached, deleting a file will ask you whether to empty the bin first, delete the file outright, or cancel.</div>
+        <div class="tsRow">
+            <select id="tsQuotaMode">
+                <option value="unlimited" locale="ts/quota/unlimited">No limit</option>
+                <option value="limited" locale="ts/quota/limited">Limit to</option>
+            </select>
+            <input type="number" id="tsQuotaValue" min="1" step="1" value="5">
+            <select id="tsQuotaUnit">
+                <option value="1048576">MB</option>
+                <option value="1073741824" selected>GB</option>
+            </select>
+        </div>
+        <div class="tsUsage" id="tsUsage"></div>
+    </div>
+
+    <div class="tsActions">
+        <button class="tsSave" id="tsSave" locale="ts/save">Save</button>
+        <span class="tsStatus" id="tsStatus"></span>
+    </div>
+</div>
+
+<script>
+(function () {
+    /*
+        Trash bin settings.
+
+        Both values live in the per user preference store the File Manager
+        already uses, and both treat zero as "no limit" - which is also what an
+        account that has never opened this page reads back, so the behaviour
+        before these settings existed is preserved.
+    */
+    /*
+        Own locale instance rather than the shared global one: this page is
+        loaded as a fragment into System Settings, which keeps its own strings
+        on the global applocale and re-reads them whenever the nav redraws.
+        Initialising the global here would replace them.
+    */
+    var tsLocale = (typeof NewAppLocale === "function") ? NewAppLocale() : {
+        init: function (f, cb) { cb(); },
+        translate: function () {},
+        getString: function (k, fallback) { return fallback; }
+    };
+
+    function tsStr(key, fallback) {
+        return (tsLocale && tsLocale.getString) ? tsLocale.getString(key, fallback) : fallback;
+    }
+
+    function tsApplyTheme() {
+        var root = document.getElementById("tsRoot");
+        if (!root) return;
+        try {
+            if (typeof preferredTheme !== "undefined") {
+                root.classList.toggle("dark", preferredTheme === "dark" || preferredTheme === "darkTheme");
+            } else if (typeof ao_module_getSystemThemeColor === "function") {
+                ao_module_getSystemThemeColor(function (c) {
+                    root.classList.toggle("dark", c !== "whiteTheme");
+                });
+            }
+        } catch (e) {}
+    }
+
+    //Offered retention windows, capped at a year as the longest sensible hold
+    var TS_RETENTION_CHOICES = [0, 7, 14, 30, 60, 90, 180, 365];
+
+    function tsBuildRetentionOptions(){
+        var sel = document.getElementById("tsRetention");
+        sel.innerHTML = "";
+        TS_RETENTION_CHOICES.forEach(function (days) {
+            var opt = document.createElement("option");
+            opt.value = String(days);
+            opt.textContent = days === 0
+                ? tsStr("ts/retention/off", "Never remove automatically")
+                : tsStr("ts/retention/days", "%d days").replace("%d", days);
+            sel.appendChild(opt);
+        });
+    }
+
+    function tsFormatBytes(bytes) {
+        if (!(bytes > 0)) return "0 B";
+        var units = ["B", "KB", "MB", "GB", "TB"];
+        var i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
+        return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
+    }
+
+    function tsSyncQuotaInputs() {
+        var limited = document.getElementById("tsQuotaMode").value === "limited";
+        document.getElementById("tsQuotaValue").disabled = !limited;
+        document.getElementById("tsQuotaUnit").disabled = !limited;
+    }
+
+    function tsLoad() {
+        $.get("../../../system/file_system/trashSettings", function (data) {
+            if (data == null || data.error !== undefined) {
+                return;
+            }
+            document.getElementById("tsRetention").value = String(data.RetentionDays || 0);
+
+            if (data.QuotaBytes > 0) {
+                document.getElementById("tsQuotaMode").value = "limited";
+                //Show whole GB where it divides evenly, otherwise fall back to MB
+                if (data.QuotaBytes % 1073741824 === 0) {
+                    document.getElementById("tsQuotaUnit").value = "1073741824";
+                    document.getElementById("tsQuotaValue").value = data.QuotaBytes / 1073741824;
+                } else {
+                    document.getElementById("tsQuotaUnit").value = "1048576";
+                    document.getElementById("tsQuotaValue").value = Math.max(1, Math.round(data.QuotaBytes / 1048576));
+                }
+            } else {
+                document.getElementById("tsQuotaMode").value = "unlimited";
+            }
+            tsSyncQuotaInputs();
+
+            document.getElementById("tsUsage").textContent =
+                tsStr("ts/quota/inuse", "Currently using %s").replace("%s", tsFormatBytes(data.UsedBytes));
+        });
+    }
+
+    function tsSave() {
+        var btn = document.getElementById("tsSave");
+        var status = document.getElementById("tsStatus");
+        var retention = document.getElementById("tsRetention").value;
+
+        var quotaBytes = 0;
+        if (document.getElementById("tsQuotaMode").value === "limited") {
+            var amount = parseFloat(document.getElementById("tsQuotaValue").value);
+            var unit = parseInt(document.getElementById("tsQuotaUnit").value);
+            if (!(amount > 0)) {
+                status.textContent = tsStr("ts/invalid", "Enter a size larger than zero");
+                return;
+            }
+            quotaBytes = Math.round(amount * unit);
+        }
+
+        btn.disabled = true;
+        status.textContent = "";
+        $.post("../../../system/file_system/trashSettings",
+            { retentionDays: retention, quotaBytes: String(quotaBytes) },
+            function (data) {
+                btn.disabled = false;
+                if (data != null && data.error !== undefined) {
+                    status.textContent = data.error;
+                    return;
+                }
+                status.textContent = tsStr("ts/saved", "Saved");
+                setTimeout(function () { status.textContent = ""; }, 2500);
+            }).fail(function () {
+                btn.disabled = false;
+                status.textContent = tsStr("ts/savefailed", "Could not save");
+            });
+    }
+
+    /* Boot */
+    tsApplyTheme();
+    window.detailPageThemeCallback = function (isDark) {
+        var el = document.getElementById("tsRoot");
+        if (el) el.classList.toggle("dark", isDark);
+    };
+    document.getElementById("tsQuotaMode").addEventListener("change", tsSyncQuotaInputs);
+    document.getElementById("tsSave").addEventListener("click", tsSave);
+
+    tsLocale.init("../locale/disk/trashsettings.json", function () {
+        tsBuildRetentionOptions();
+        tsLocale.translate();
+        tsLoad();
+    });
+})();
+</script>

+ 316 - 0
src/web/SystemAO/file_system/file_explorer.css

@@ -2447,6 +2447,322 @@ i.blue{
 }
 .fmGroup { display: block; }
 
+/* ======================================================================
+   Trash bin view
+
+   Rendered into #folderList by js/explorer/trash.js when the path is the
+   %trashbin% sentinel. It replaces the file listing entirely, so it owns the
+   full width of the file area rather than sitting alongside file objects.
+   ====================================================================== */
+.fmTrashLoading{
+    padding: 28px 4px;
+    color: var(--fs-text-dim);
+}
+
+.fmTrashHeader{
+    position: relative;
+    display: flex;
+    align-items: center;
+    gap: 16px;
+    padding: 4px 4px 20px 4px;
+}
+
+.fmTrashIcon{
+    flex: 0 0 auto;
+    width: 56px;
+    height: 56px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 50%;
+    background: var(--fs-fill);
+    color: var(--fs-text-dim);
+}
+.fmTrashIcon svg{
+    width: 28px;
+    height: 28px;
+    display: block;
+}
+
+.fmTrashHeadText{
+    flex: 1 1 auto;
+    min-width: 0px;
+}
+.fmTrashTitle{
+    font-size: 20px;
+    color: var(--fs-text);
+    margin-bottom: 3px;
+}
+.fmTrashDesc{
+    font-size: 12.5px;
+    color: var(--fs-text-dim);
+    line-height: 1.5;
+}
+
+/* Usage readout. Its numbers are placeholders until retention lands. */
+.fmTrashUsage{
+    flex: 0 0 auto;
+    width: 190px;
+}
+.fmTrashUsageLabel{
+    font-size: 12px;
+    color: var(--fs-text-dim);
+}
+.fmTrashUsageValue{
+    font-size: 19px;
+    color: var(--fs-accent);
+    margin: 2px 0px 6px 0px;
+}
+.fmTrashUsageBar{
+    height: 6px;
+    border-radius: 999px;
+    background: var(--fs-line);
+    overflow: hidden;
+}
+.fmTrashUsageFill{
+    height: 100%;
+    border-radius: 999px;
+    background: var(--fs-accent);
+}
+.fmTrashUsageTotal{
+    margin-top: 5px;
+    font-size: 12px;
+    color: var(--fs-text-dim);
+}
+
+.fmTrashEmptyBtn{
+    flex: 0 0 auto;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    padding: 10px 16px;
+    border: none;
+    border-radius: 9px;
+    cursor: pointer;
+    font-size: 13.5px;
+    white-space: nowrap;
+    background: var(--upload_progress_failed);
+    color: #ffffff;
+}
+.fmTrashEmptyBtn:hover{
+    opacity: 0.9;
+}
+.fmTrashBtnIcon svg{
+    width: 16px;
+    height: 16px;
+    display: block;
+}
+
+.fmTrashMoreBtn{
+    flex: 0 0 auto;
+    width: 30px;
+    height: 30px;
+    padding: 6px;
+    border: none;
+    background: transparent;
+    border-radius: 6px;
+    cursor: pointer;
+    color: var(--fs-icon);
+}
+.fmTrashMoreBtn:hover{ background: var(--fs-fill); }
+.fmTrashMoreBtn svg{ width: 100%; height: 100%; display: block; }
+
+.fmTrashBulkMenu{
+    position: absolute;
+    top: 44px;
+    right: 0px;
+    min-width: 220px;
+}
+
+/* ---------- Listing ---------- */
+.fmTrashTable{
+    width: 100%;
+    border-collapse: collapse;
+    font-size: 13px;
+}
+.fmTrashTable th{
+    padding: 10px 10px;
+    text-align: left;
+    font-weight: 400;
+    color: var(--fs-text-dim) !important;
+    border-top: 1px solid var(--fs-line);
+    border-bottom: 1px solid var(--fs-line);
+    white-space: nowrap;
+}
+.fmTrashTable td{
+    padding: 9px 10px;
+    border-bottom: 1px solid var(--fs-line-soft);
+    color: var(--fs-text) !important;
+    vertical-align: middle;
+}
+.fmTrashRow:hover{ background: var(--fs-row-hover); }
+.fmTrashRow.selected{ background: var(--fs-selected); }
+
+.fmTrashDim{ color: var(--fs-text-dim) !important; white-space: nowrap; }
+
+/*
+    The flex row lives on a span inside the cell, never on the <td> itself.
+    display:flex on a table cell drops it out of the table formatting context,
+    which collapses the column and leaves its bottom border out of line with
+    every other cell in the row.
+*/
+.fmTrashName{
+    display: flex;
+    align-items: center;
+    gap: 9px;
+    min-width: 0px;
+}
+.fmTrashNameText{
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+/* ---------- Sortable headers ---------- */
+.fmTrashSortable{ cursor: pointer; }
+.fmTrashSortable:hover{ background: var(--fs-fill); }
+.fmTrashTable th.sorted{ color: var(--fs-text) !important; }
+.fmTrashSortMark{
+    display: inline-block;
+    width: 12px;
+    margin-left: 3px;
+    font-size: 11px;
+}
+
+/* ---------- Details dialog ---------- */
+.fmTrashDetailTable{
+    width: 100%;
+    border-collapse: collapse;
+    font-size: 13px;
+    margin-bottom: 14px;
+}
+.fmTrashDetailTable td{
+    padding: 7px 0px;
+    border-bottom: 1px solid var(--fs-line-soft);
+    word-break: break-all;
+    color: var(--normal_object_txt) !important;
+}
+.fmTrashDetailKey{
+    width: 38%;
+    color: var(--fs-text-dim) !important;
+    white-space: nowrap;
+}
+
+/* ---------- Special view root chip in the address bar ---------- */
+.fmSpecialRoot{
+    display: inline-flex !important;
+    align-items: center;
+    gap: 7px;
+}
+.fmSpecialRootIcon{
+    width: 16px;
+    height: 16px;
+    color: var(--fs-icon);
+}
+.fmSpecialRootIcon svg{ width: 100%; height: 100%; display: block; }
+.fmTrashRowIcon{
+    flex: 0 0 auto;
+    width: 20px;
+    height: 20px;
+    color: var(--fs-icon);
+}
+.fmTrashRowIcon svg{ width: 100%; height: 100%; display: block; }
+
+.fmTrashColCheck{ width: 34px; }
+
+/* Drawn rather than an <input>, to match the check styling used elsewhere */
+.fmTrashCheck{
+    display: inline-block;
+    width: 16px;
+    height: 16px;
+    border: 1.5px solid var(--fs-line);
+    border-radius: 4px;
+    background: var(--fs-surface);
+    cursor: pointer;
+    position: relative;
+    vertical-align: middle;
+}
+.fmTrashCheck.checked{
+    background: var(--fs-accent);
+    border-color: var(--fs-accent);
+}
+.fmTrashCheck.checked::after{
+    content: "";
+    position: absolute;
+    left: 4.5px;
+    top: 1px;
+    width: 4px;
+    height: 8px;
+    border: solid var(--fs-primary-fg);
+    border-width: 0px 2px 2px 0px;
+    transform: rotate(45deg);
+}
+
+.fmTrashActionCell{ width: 1%; white-space: nowrap; text-align: right; }
+
+.fmTrashRestoreBtn{
+    padding: 6px 14px;
+    border: 1px solid var(--popup_btn_border);
+    border-radius: 7px;
+    background: transparent;
+    cursor: pointer;
+    font-size: 12.5px;
+    color: var(--fs-text);
+}
+.fmTrashRestoreBtn:hover{ background: var(--fs-fill-hover); }
+
+.fmTrashRowMore{
+    width: 26px;
+    height: 26px;
+    padding: 4px;
+    border: none;
+    background: transparent;
+    border-radius: 6px;
+    cursor: pointer;
+    color: var(--fs-icon);
+}
+.fmTrashRowMore:hover{ background: var(--fs-fill); }
+.fmTrashRowMore svg{ width: 100%; height: 100%; display: block; }
+
+.fmTrashRowMenu{
+    position: fixed;
+    min-width: 210px;
+    z-index: 610;
+}
+
+.fmTrashEmpty{
+    padding: 40px 10px !important;
+    text-align: center;
+    color: var(--fs-text-dim) !important;
+}
+
+.fmTrashFootnote{
+    display: flex;
+    align-items: center;
+    gap: 9px;
+    margin-top: 16px;
+    padding: 12px 14px;
+    border-radius: 9px;
+    background: var(--fs-fill);
+    font-size: 12.5px;
+    color: var(--fs-text-dim);
+}
+.fmTrashFootIcon{
+    flex: 0 0 auto;
+    width: 17px;
+    height: 17px;
+    color: var(--fs-accent);
+}
+.fmTrashFootIcon svg{ width: 100%; height: 100%; display: block; }
+
+@media only screen and (max-width: 720px) {
+    /* The usage readout is the first thing to go when space is tight */
+    .fmTrashHeader{ flex-wrap: wrap; }
+    .fmTrashUsage{ display: none; }
+    .fmTrashTable th:nth-child(3), .fmTrashTable td:nth-child(3),
+    .fmTrashTable th:nth-child(4), .fmTrashTable td:nth-child(4){ display: none; }
+}
+
 /* ---------- Status bar ---------- */
 #fmStatusBar {
     display: flex;

+ 45 - 0
src/web/SystemAO/file_system/file_explorer.html

@@ -554,6 +554,49 @@
             </div>
         </div>
 
+        <!-- Trash bin full. Raised when the server refuses a recycle because the
+             user's trash size limit would be exceeded; nothing has moved yet. -->
+        <div id="trashFullBox" class="popup" style="display:none;">
+            <div class="popupheader">
+                <i class="trash icon"></i> <span locale="trash/full/title">Trash Bin Full</span>
+                <div class="popupcloser" onclick="hideAllPopupWindows();">
+                    <i class="remove icon"></i>
+                </div>
+            </div>
+            <div class="popupcontent">
+                <p><span locale="trash/full/desc">The trash bin has reached its size limit, so these files cannot be moved into it.</span></p>
+                <div class="popupbuttons allowHover primary" onclick="trashFullClearAndRecycle();">
+                    <i class="recycle icon"></i> <span locale="trash/full/clearfirst">Empty the trash bin and move them there</span>
+                </div>
+                <div class="popupbuttons allowHover negative" onclick="trashFullDeleteDirectly();">
+                    <i class="trash icon"></i> <span locale="trash/full/deletenow">Delete them permanently instead</span>
+                </div>
+                <div class="popupbuttons allowHover" onclick="hideAllPopupWindows();">
+                    <i class="remove icon"></i> <span locale="trash/full/cancel">Do nothing</span>
+                </div>
+            </div>
+        </div>
+
+        <!-- Trashed item details. Filled by js/explorer/trash.js; this is how
+             the columns the narrow layout drops stay reachable. -->
+        <div id="trashDetailsBox" class="popup" style="display:none;">
+            <div class="popupheader">
+                <i class="trash icon"></i> <span locale="trash/details">Details</span>
+                <div class="popupcloser" onclick="hideAllPopupWindows();">
+                    <i class="remove icon"></i>
+                </div>
+            </div>
+            <div class="popupcontent">
+                <table class="fmTrashDetailTable"></table>
+                <div class="popupbuttons allowHover primary fmTrashDetailRestore">
+                    <i class="checkmark icon"></i> <span locale="trash/restore">Restore</span>
+                </div>
+                <div class="popupbuttons allowHover" onclick="hideAllPopupWindows();">
+                    <i class="remove icon"></i> <span locale="opr/rename/cancel">Cancel</span>
+                </div>
+            </div>
+        </div>
+
         <!-- Confirm Exit -->
          <!-- Open With Dialog-->
          <div id="confirmExit" class="popup" style="display:none;">
@@ -597,6 +640,8 @@
         <script type="text/javascript" src="js/explorer/sidebar.js"></script>
         <script type="text/javascript" src="js/explorer/viewmode.js"></script>
         <script type="text/javascript" src="js/explorer/splitter.js"></script>
+        <script type="text/javascript" src="js/explorer/specialview.js"></script>
+        <script type="text/javascript" src="js/explorer/trash.js"></script>
         <script type="text/javascript" src="js/explorer/search.js"></script>
         <script type="text/javascript" src="js/explorer/uploadui.js"></script>
         <script type="text/javascript" src="js/explorer/upload.js"></script>

+ 97 - 1
src/web/SystemAO/file_system/js/explorer/delete.js

@@ -73,13 +73,25 @@ function deleteFile(confirmed = false){
     }else{
         //Continue to delete files
         let fdlistLength = deleteFileList.length;
+        /*
+            Kept aside before the list is cleared below, so the trash full
+            dialog still knows what the user was trying to delete.
+        */
+        let pendingRecycleList = deleteFileList.slice();
         requestCSRFToken(function(token){
             $.ajax({
                 url: "../../system/file_system/fileOpr",
                 method:"POST",
                 data: {opr: "recycle", src: JSON.stringify(deleteFileList), csrft: token},
                 success: function(data){
-                    if (data.error !== undefined){
+                    if (data.error == "TRASH_QUOTA_EXCEEDED"){
+                        /*
+                            The bin is at its size limit. The server refused
+                            before moving anything, so the files are untouched
+                            and the user gets to choose what happens next.
+                        */
+                        showTrashFullDialog(pendingRecycleList);
+                    }else if (data.error !== undefined){
                         msgbox("red remove",applocale.getString("message/" + data.error,data.error));
                     }else{
                         refreshList();
@@ -110,6 +122,87 @@ function cancelForceDelete(){
 }
 
 
+/*
+    Trash bin full
+
+    Offered when the server refuses a recycle because the user's trash size
+    limit would be exceeded. Nothing has moved at this point, so all three
+    outcomes are still open:
+
+        1. empty the bin, then move these files into it
+        2. delete them outright, skipping the bin
+        3. do nothing
+
+    The pending list is held here rather than on the dialog, so a stale dialog
+    cannot act on a previous selection.
+*/
+let trashFullPendingList = [];
+
+function showTrashFullDialog(filepaths){
+    trashFullPendingList = (filepaths == undefined) ? [] : filepaths.slice();
+    $("#trashFullBox").find(".trashFullCount").text(trashFullPendingList.length);
+    showPopupWrapper();
+    $("#trashFullBox").transition("slide left in");
+}
+
+//Option 1: make room by emptying the bin, then retry the move
+function trashFullClearAndRecycle(){
+    hideAllPopupWindows();
+    let retryList = trashFullPendingList.slice();
+    if (retryList.length == 0){
+        return;
+    }
+    $.get("../../system/file_system/clearTrash", function(data){
+        if (data !== null && data !== undefined && data.error !== undefined){
+            msgbox("red remove", data.error);
+            return;
+        }
+        requestCSRFToken(function(token){
+            $.ajax({
+                url: "../../system/file_system/fileOpr",
+                method: "POST",
+                data: {opr: "recycle", src: JSON.stringify(retryList), csrft: token},
+                success: function(data){
+                    if (data.error !== undefined){
+                        msgbox("red remove", applocale.getString("message/" + data.error, data.error));
+                    }else{
+                        refreshList();
+                        msgbox("checkmark", retryList.length +
+                            applocale.getString("message/recycle/success", " objects moved to trash bin."));
+                    }
+                }
+            });
+        });
+    });
+}
+
+//Option 2: skip the bin entirely
+function trashFullDeleteDirectly(){
+    hideAllPopupWindows();
+    let targets = trashFullPendingList.slice();
+    if (targets.length == 0){
+        return;
+    }
+    requestCSRFToken(function(token){
+        $.ajax({
+            url: "../../system/file_system/fileOpr",
+            method: "POST",
+            data: {opr: "delete", src: JSON.stringify(targets), csrft: token},
+            success: function(data){
+                if (data.error !== undefined){
+                    msgbox("red remove", applocale.getString("message/" + data.error, data.error));
+                }else{
+                    refreshList();
+                    msgbox("checkmark", applocale.getString("trash/deleted", "Deleted permanently"));
+                }
+            }
+        });
+    });
+}
+
+//Option 3 needs no handler beyond closing the dialog
+
+
 /*
     Reachable from outside this file: inline on* attributes in the markup,
     handlers generated in template strings, or another frame. Renaming any
@@ -118,4 +211,7 @@ function cancelForceDelete(){
 window.cancelDelete = cancelDelete;
 window.cancelForceDelete = cancelForceDelete;
 window.deleteFile = deleteFile;
+window.showTrashFullDialog = showTrashFullDialog;
+window.trashFullClearAndRecycle = trashFullClearAndRecycle;
+window.trashFullDeleteDirectly = trashFullDeleteDirectly;
 window.forceDelete = forceDelete;

+ 17 - 0
src/web/SystemAO/file_system/js/explorer/listing.js

@@ -111,6 +111,23 @@ function listDirectory(path, callback=undefined, recordUndo=true){
     //Highlight new path coot
     highlightCurrentRoot();
     
+    /*
+        Special views (the trash bin, and anything registered alongside it) are
+        not directories the server can list. Everything above still applies to
+        them - history, path bar, nav button states - so the hand off happens
+        here rather than at the top of the function.
+    */
+    let specialView = getSpecialView(currentPath);
+    applySpecialViewChrome(specialView);
+    if (specialView != null){
+        if (ao_module_virtualDesktop){
+            ao_module_setWindowTitle(applocale.getString("title/title", "File Manager") +
+                " - " + applocale.getString(specialView.labelKey, specialView.labelFallback));
+        }
+        specialView.render(callback);
+        return;
+    }
+
     //Get sort mode from server side
     let loadStartPath = currentPath;
     $.ajax({

+ 22 - 0
src/web/SystemAO/file_system/js/explorer/pathbar.js

@@ -10,6 +10,11 @@
 // ============================== PATH SHORTCUT RESOLUTION ====================
 var pathShortcuts = {
     "%appdata%": "user:/.appdata/",
+    /*
+        Not a real path. listDirectory() recognises this sentinel and renders
+        the trash view instead of listing it - see js/explorer/trash.js.
+    */
+    "%trashbin%": "%trashbin%",
 };
 
 function resolvePathShortcut(path){
@@ -139,6 +144,23 @@ function openHomeDir(){
 }
 
 function updatePathDisplay(path){
+    /*
+        A special view has no directory tree to walk. Its sentinel (%trashbin%)
+        exists so the address bar can navigate to it, but showing that raw
+        keyword back once the view has loaded is noise - the registered icon and
+        localised name are what the user should read.
+    */
+    let specialView = getSpecialView(path);
+    if (specialView != null){
+        $(".pathDisplay").html("");
+        $(".pathDisplay").append('<div class="section fmSpecialRoot"><span class="fmSpecialRootIcon">' +
+            (FSIcons[specialView.icon] || "") + '</span><span>' +
+            applocale.getString(specialView.labelKey, specialView.labelFallback) + '</span></div>');
+        //The input keeps the sentinel, since that is what you would type
+        $("#pathInputField").find("input").val(path);
+        return;
+    }
+
     var pathInfo = path.split("/");
     var vdID = pathInfo[0];
     //As path always end with /, pop the empty pathinfo from array

+ 14 - 0
src/web/SystemAO/file_system/js/explorer/search.js

@@ -119,6 +119,20 @@ function handleHotSearch(starting, offset){
 
 function handleSearch(){
     var keyword = $("#searchInput").val();
+
+    /*
+        A special view holds its own rows - the trash bin's come from the trash
+        API rather than from a directory listing, so /system/file_system/search
+        knows nothing about them. Views that can be searched register a handler
+        and we hand the keyword over; filtering, drawing and the empty state
+        are all theirs to deal with.
+    */
+    let specialView = (typeof getSpecialView === "function") ? getSpecialView(currentPath) : null;
+    if (specialView != null && typeof specialView.search === "function"){
+        specialView.search(keyword, searchCaseSensitive);
+        return;
+    }
+
     $("#folderList").html(`<div class="ui basic segment">
         <i class="loading spinner icon"></i> <span>Searching</span>
     </div>`);

+ 1 - 7
src/web/SystemAO/file_system/js/explorer/share.js

@@ -11,13 +11,7 @@ function handleShareFilebuttonClick(event, object){
     event.preventDefault(); 
     event.stopImmediatePropagation();
     $(".fileObject.selected").removeClass("selected");
-    if (viewMode == "list"){
-        $(object).parent().parent().addClass("selected");
-    }else if (viewMode == "grid"){
-        $(object).parent().parent().parent().addClass("selected");
-    }else if (viewMode == "details"){
-        $(object).parent().parent().addClass("selected");
-    }
+    $(object).closest(".fileObject").addClass("selected");
     
     shareFile();
 }

+ 16 - 0
src/web/SystemAO/file_system/js/explorer/sidebar.js

@@ -51,6 +51,16 @@ function initRootDirs(){
                 var rootPath = thisRoot.RootPath;
                 $("#storageroot").append(`<div class="dir item vroot fsSideItem" filepath="${rootPath}" type="folder" rootname="${displayName}" onclick="openthis(this);"><span class="fsSideIcon" style="color:var(--fs-icon)">${FSIcons.drive}</span><span class="fsSideLabel">${displayName} (${rootPath})</span></div>`);
             }
+            /*
+                The trash bin sits with the devices but is not one of them: it
+                is a view, not a mounted root, so it is appended here rather
+                than coming back from listRoots.
+            */
+            $("#storageroot").append('<div class="dir item vroot fsSideItem fmTrashSideItem" filepath="' +
+                TRASH_VPATH + '" type="trash" onclick="openTrashBin();">' +
+                '<span class="fsSideIcon">' + FSIcons.trash + '</span>' +
+                '<span class="fsSideLabel">' + applocale.getString("trash/title", "Trash Bin") + '</span></div>');
+
             highlightCurrentRoot();
         }
     });
@@ -59,6 +69,12 @@ function initRootDirs(){
 function highlightCurrentRoot(){
     //Highlight the target vroot name on the side bar
     $(".vroot.active").removeClass("active");
+
+    //The trash view has no root path to match on, so it is handled up front
+    if (isTrashPath(currentPath)){
+        $(".fmTrashSideItem").addClass("active");
+        return;
+    }
     $(".vroot").each(function(){
         let rootname = $(this).attr("filepath");
         if ((currentPath.toLowerCase()).startsWith((rootname.toLowerCase()))){

+ 106 - 0
src/web/SystemAO/file_system/js/explorer/specialview.js

@@ -0,0 +1,106 @@
+/*
+    specialview.js
+
+    Registry for views that occupy the file area but are not directory
+    listings - the trash bin is the first, and anything else that wants a
+    sentinel path plus its own renderer registers here rather than adding
+    another branch to listDirectory().
+
+    A view registers itself at load time:
+
+        registerSpecialView("%trashbin%", {
+            icon: "trash",                  //FSIcons key drawn in the path bar
+            labelKey: "trash/title",        //applocale key for the root label
+            labelFallback: "Trash Bin",
+            hideViewModes: true,            //grid/list/details make no sense here
+            hidePropertiesPane: true,       //neither does the properties pane
+            render: function(callback){ ... },
+            search: function(keyword, caseSensitive){ ... }   //optional
+        });
+
+    A view that leaves out "search" simply keeps whatever it last drew when the
+    user presses Enter in the search box, since handleSearch() has nothing to
+    hand the keyword to and the server-side search cannot see these rows.
+
+    listDirectory() then does the lookup and hands over, and updatePathDisplay()
+    uses the icon and label instead of showing the raw sentinel.
+
+    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.
+*/
+
+let fmSpecialViews = {};
+
+/*
+    Whether the properties pane was open before a special view hid it, so
+    leaving the view can put it back rather than silently turning off something
+    the user had switched on.
+*/
+let propertiesPaneHiddenBySpecialView = false;
+
+function registerSpecialView(sentinelPath, config){
+    fmSpecialViews[String(sentinelPath).toLowerCase()] = config;
+}
+
+/*
+    Look up the view for a path. listDirectory pads a trailing slash onto
+    everything it navigates to, so that is stripped before matching.
+*/
+function getSpecialView(path){
+    if (path == undefined || path == null){
+        return null;
+    }
+    let cleaned = String(path).trim().replace(/\/+$/, "").toLowerCase();
+    let view = fmSpecialViews[cleaned];
+    return view == undefined ? null : view;
+}
+
+function isSpecialViewPath(path){
+    return getSpecialView(path) != null;
+}
+
+/*
+    Chrome that only makes sense over a real directory listing. Called on every
+    navigation, so entering and leaving are both handled from one place.
+*/
+function applySpecialViewChrome(view){
+    let hideViewModes = view != null && view.hideViewModes === true;
+    $(".fsViewToggle.fmStatusToggle").toggle(!hideViewModes);
+    $("#fmSortBtn").toggle(!hideViewModes);
+
+    //The tile size slider belongs to the grid view, which is gone too.
+    //updateZoomControlVisibility() checks the special view itself, so it stays
+    //hidden when the resize handler or a view mode change calls back into it.
+    updateZoomControlVisibility();
+
+    let hideProperties = view != null && view.hidePropertiesPane === true;
+    $("#togglePropertiesViewBtn").toggle(!hideProperties);
+
+    if (hideProperties){
+        if (propertiesView){
+            //Remember that this was the user's setting, not ours, so it can be
+            //restored when they navigate back out
+            propertiesPaneHiddenBySpecialView = true;
+            togglePropertiesView($("#togglePropertiesViewBtn"));
+        }
+    }else if (propertiesPaneHiddenBySpecialView){
+        propertiesPaneHiddenBySpecialView = false;
+        if (!propertiesView){
+            togglePropertiesView($("#togglePropertiesViewBtn"));
+        }
+    }
+
+    updateSplitterVisibility();
+    initWindowSizes(false);
+}
+
+
+/*
+    Reachable from outside this file: inline on* attributes in the markup,
+    handlers generated in template strings, or another frame. Renaming any
+    of these means updating those call sites too.
+*/
+window.registerSpecialView = registerSpecialView;
+window.getSpecialView = getSpecialView;
+window.isSpecialViewPath = isSpecialViewPath;
+window.applySpecialViewChrome = applySpecialViewChrome;

+ 651 - 0
src/web/SystemAO/file_system/js/explorer/trash.js

@@ -0,0 +1,651 @@
+/*
+    trash.js
+
+    The trash bin, rendered inside the File Manager instead of as its own app.
+
+    There is no ".trash" directory the server will list, so this is not a real
+    path: %trashbin% is a sentinel that listDirectory() recognises and hands to
+    renderTrashView() rather than to listDir. Everything here comes from the
+    same trash API the standalone Trash Bin app used:
+
+        GET  /system/file_system/listTrash      -> [trashedFile]
+        POST /system/file_system/restoreTrash   {src}
+        GET  /system/file_system/clearTrash
+        POST /system/file_system/fileOpr        {opr:"delete"} to purge entries
+
+    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.
+*/
+
+//The sentinel the path bar shows and the %trashbin% shortcut resolves to
+const TRASH_VPATH = "%trashbin%";
+
+/*
+    Retention and quota now come from the server, set per user under
+    System Settings -> Disk and Storage -> File Manager. Zero means "no limit"
+    for both, which is what an account that never touched those settings reads
+    back - so the default is the unlimited, never-expiring behaviour the trash
+    bin had before they existed.
+
+    These are seeded with the defaults and replaced by loadTrashSettings()
+    before the view draws, so a slow settings request cannot leave the header
+    blank.
+*/
+let trashRetentionDays = 30;    //0 = never auto remove
+let trashQuotaBytes = 0;        //0 = unlimited
+
+let trashItems = [];            //Last listing, what the row actions act on
+let trashSelection = {};        //Encoded filepath -> true for the checked rows
+let trashSortKey = "deleted";   //name | origin | deleted | size | remaining
+let trashSortAsc = false;       //Newest deletions first, as the old app did
+let trashSearchKeyword = "";    //"" = no filter, the whole bin is on show
+
+function isTrashPath(path){
+    if (path == undefined || path == null){
+        return false;
+    }
+    //listDirectory pads a trailing slash onto everything it navigates to
+    let cleaned = String(path).trim().replace(/\/+$/, "").toLowerCase();
+    return cleaned == TRASH_VPATH;
+}
+
+//The sidebar entry and the path shortcut both come through here
+function openTrashBin(){
+    listDirectory(TRASH_VPATH);
+}
+
+/*
+    Listing
+*/
+function renderTrashView(callback){
+    //The column header belongs to the details view, not to this one
+    $("#fmListHeader").hide();
+    $("#fileList").html("");
+    $("#folderList").show().html('<div class="fmTrashLoading">' +
+        applocale.getString("message/loading", "Loading") + '</div>');
+
+    trashSelection = {};
+    //Navigating in is a fresh look at the whole bin, not a continuation of
+    //whatever was last searched for
+    trashSearchKeyword = "";
+
+    //Settings first, so the header renders with the real limits rather than
+    //flashing the defaults and correcting itself a moment later
+    $.get("../../system/file_system/trashSettings", function(settings){
+        if (settings != null && settings.error === undefined){
+            trashRetentionDays = settings.RetentionDays || 0;
+            trashQuotaBytes = settings.QuotaBytes || 0;
+        }
+    }).always(function(){
+        loadTrashListing(callback);
+    });
+}
+
+function loadTrashListing(callback){
+    $.get("../../system/file_system/listTrash", function(data){
+        trashItems = (data == null || data.error !== undefined) ? [] : data;
+        applyTrashSort();
+        drawTrashView();
+        if (callback !== undefined){
+            callback();
+        }
+    }).fail(function(){
+        trashItems = [];
+        drawTrashView();
+        if (callback !== undefined){
+            callback();
+        }
+    });
+}
+
+function drawTrashView(){
+    let usedBytes = 0;
+    for (let i = 0; i < trashItems.length; i++){
+        if (!trashItems[i].IsDir){
+            usedBytes += trashItems[i].Filesize;
+        }
+    }
+
+    /*
+        The quota header always describes the whole bin - a search narrows what
+        is listed, not what is stored - so only the table works off the filter.
+    */
+    let visibleItems = visibleTrashItems();
+    let rows = visibleItems.map(renderTrashRow).join("");
+    if (visibleItems.length == 0){
+        rows = '<tr><td colspan="8" class="fmTrashEmpty">' +
+            (trashSearchKeyword == ""
+                ? applocale.getString("trash/empty", "The trash bin is empty")
+                : applocale.getString("trash/noMatch", "No items match your search")) + '</td></tr>';
+    }
+
+    $("#folderList").html(
+        renderTrashHeader(usedBytes) +
+        '<table class="fmTrashTable"><thead><tr>' +
+            '<th class="fmTrashColCheck"><span class="fmTrashCheck" id="fmTrashSelectAll" onclick="toggleTrashSelectAll();"></span></th>' +
+            trashHeaderCell("name",      applocale.getString("trash/col/name", "Name")) +
+            trashHeaderCell("origin",    applocale.getString("trash/col/origin", "Original Location")) +
+            trashHeaderCell("deleted",   applocale.getString("trash/col/deleted", "Deleted")) +
+            trashHeaderCell("size",      applocale.getString("trash/col/size", "Size")) +
+            trashHeaderCell("remaining", applocale.getString("trash/col/remaining", "Time Left")) +
+            '<th colspan="2"></th>' +
+        '</tr></thead><tbody>' + rows + '</tbody></table>' +
+        '<div class="fmTrashFootnote"><span class="fmTrashFootIcon">' + FSIcons.info + '</span><span>' +
+            (trashRetentionDays > 0
+                ? applocale.getString("trash/footnote",
+                    "Files are permanently removed after %d days. You can also empty the bin yourself.")
+                    .replace("%d", trashRetentionDays)
+                : applocale.getString("trash/footnoteNoExpiry",
+                    "Files are kept until you empty the trash bin. Automatic removal can be turned on in System Settings.")) +
+        '</span></div>');
+
+    updateTrashSelectionState();
+    $("#selectInfo").text(applocale.getString("message/itemCount", "%d items")
+        .replace("%d", visibleItems.length));
+}
+
+function renderTrashHeader(usedBytes){
+    /*
+        With no quota set there is nothing to fill, so the bar is dropped rather
+        than shown permanently empty - the used figure still tells the whole
+        story on its own.
+    */
+    let hasQuota = trashQuotaBytes > 0;
+    let percent = hasQuota ? Math.min(100, (usedBytes / trashQuotaBytes) * 100) : 0;
+    return '<div class="fmTrashHeader">' +
+        '<div class="fmTrashIcon">' + FSIcons.trashBig + '</div>' +
+        '<div class="fmTrashHeadText">' +
+            '<div class="fmTrashTitle">' + applocale.getString("trash/title", "Trash Bin") + '</div>' +
+            '<div class="fmTrashDesc">' + (trashRetentionDays > 0
+                ? applocale.getString("trash/desc",
+                    "These files have been deleted and will be removed permanently after %d days.")
+                    .replace("%d", trashRetentionDays)
+                : applocale.getString("trash/descNoExpiry",
+                    "These files have been deleted. They are kept until you empty the trash bin.")) + '</div>' +
+        '</div>' +
+        '<div class="fmTrashUsage">' +
+            '<div class="fmTrashUsageLabel">' + applocale.getString("trash/used", "Space Used") + '</div>' +
+            '<div class="fmTrashUsageValue">' + bytesToSize(usedBytes) + '</div>' +
+            (hasQuota ? '<div class="fmTrashUsageBar"><div class="fmTrashUsageFill" style="width: ' +
+                percent.toFixed(1) + '%;"></div></div>' : '') +
+            '<div class="fmTrashUsageTotal">' + (hasQuota
+                ? applocale.getString("trash/quota", "of %s").replace("%s", bytesToSize(trashQuotaBytes))
+                : applocale.getString("trash/nolimit", "No size limit")) + '</div>' +
+        '</div>' +
+        '<button class="fmTrashEmptyBtn" onclick="emptyTrashBin();">' +
+            '<span class="fmTrashBtnIcon">' + FSIcons.trash + '</span>' +
+            '<span>' + applocale.getString("trash/emptybtn", "Empty Trash Bin") + '</span>' +
+        '</button>' +
+        '<button class="fmTrashMoreBtn" title="More" onclick="toggleTrashBulkMenu(event);">' + FSIcons.more + '</button>' +
+        '<div class="fsMenu fmTrashBulkMenu" id="fmTrashBulkMenu">' +
+            '<div class="fsMenuItem" onclick="restoreSelectedTrash();">' +
+                '<span class="fsMenuIcon">' + FSIcons.restore + '</span><span>' +
+                applocale.getString("trash/restoreSelected", "Restore Selected") + '</span></div>' +
+            '<div class="fsMenuItem" onclick="deleteSelectedTrash();">' +
+                '<span class="fsMenuIcon">' + FSIcons.trash + '</span><span>' +
+                applocale.getString("trash/deleteSelected", "Delete Selected Permanently") + '</span></div>' +
+        '</div>' +
+    '</div>';
+}
+
+function renderTrashRow(item){
+    /*
+        The stored filename carries the removal timestamp as its extension, so
+        the readable name is the one the listing reports separately.
+    */
+    let displayName = item.OriginalFilename;
+    let icon = item.IsDir ? FSIcons.folder : FileThumb.smallGlyph(displayName, false);
+    let size = item.IsDir ? "--" : bytesToSize(item.Filesize);
+    let key = encodeURIComponent(item.Filepath);
+
+    return '<tr class="fmTrashRow" data-key="' + key + '">' +
+        '<td class="fmTrashColCheck"><span class="fmTrashCheck" onclick="toggleTrashRow(&quot;' + key + '&quot;);"></span></td>' +
+        '<td><span class="fmTrashName"><span class="fmTrashRowIcon">' + icon +
+            '</span><span class="fmTrashNameText">' + escapeTrashText(displayName) + '</span></span></td>' +
+        '<td class="fmTrashDim">' + escapeTrashText(item.OriginalPath) + '</td>' +
+        '<td class="fmTrashDim">' + escapeTrashText(item.RemoveDate) + '</td>' +
+        '<td class="fmTrashDim">' + size + '</td>' +
+        '<td class="fmTrashDim">' + formatTrashRemaining(item.RemoveTimestamp) + '</td>' +
+        '<td class="fmTrashActionCell"><button class="fmTrashRestoreBtn" onclick="restoreTrashItem(&quot;' + key + '&quot;);">' +
+            applocale.getString("trash/restore", "Restore") + '</button></td>' +
+        '<td class="fmTrashActionCell"><button class="fmTrashRowMore" title="More" onclick="toggleTrashRowMenu(event, &quot;' + key + '&quot;);">' +
+            FSIcons.more + '</button></td>' +
+    '</tr>';
+}
+
+/*
+    Remaining time is derived here rather than read from the server, because
+    nothing expires trashed files yet - it is the retention constant counting
+    down from the removal timestamp, and becomes real once the server enforces
+    a retention policy.
+*/
+function formatTrashRemaining(removeTimestamp){
+    if (trashRetentionDays <= 0){
+        //Nothing expires, so there is no countdown to show
+        return applocale.getString("trash/keptForever", "Kept");
+    }
+    let elapsedDays = (Date.now() / 1000 - removeTimestamp) / 86400;
+    let left = Math.ceil(trashRetentionDays - elapsedDays);
+    if (left <= 0){
+        return applocale.getString("trash/expiring", "Today");
+    }
+    return applocale.getString("trash/daysLeft", "%d days").replace("%d", left);
+}
+
+//Names and paths come from the file system, so escape before they go into html
+function escapeTrashText(text){
+    return String(text == undefined ? "" : text)
+        .split("&").join("&amp;")
+        .split("<").join("&lt;")
+        .split(">").join("&gt;");
+}
+
+/*
+    Search
+
+    The bin's rows come from the trash API, not from a directory listing, so
+    the server side search cannot see them. The File Manager hands the keyword
+    over here instead (see the "search" entry in the registration at the bottom
+    of this file) and the filtering is done against the listing already held in
+    trashItems - no second request, and the quota header stays accurate.
+
+    The two keyword shapes match what the normal search accepts, so the box
+    behaves the same wherever the user happens to be:
+
+        report          plain substring of the original file name
+        /*.mp3          wildcard pattern, * and ? supported
+*/
+function trashSearchMatcher(keyword, caseSensitive){
+    let pattern = String(keyword);
+    let isWildcard = pattern.substr(0, 1) == "/";
+    if (isWildcard){
+        pattern = pattern.substr(1);
+    }
+    if (!caseSensitive){
+        pattern = pattern.toLowerCase();
+    }
+
+    if (!isWildcard){
+        return function(name){
+            return (caseSensitive ? name : name.toLowerCase()).indexOf(pattern) != -1;
+        };
+    }
+
+    /*
+        Everything the regex engine treats specially is escaped except * and ?,
+        which are then translated - so a name with brackets or a dot in it
+        cannot turn into a pattern of its own.
+    */
+    let expanded = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&")
+                          .replace(/\*/g, ".*")
+                          .replace(/\?/g, ".");
+    let matcher = new RegExp("^" + expanded + "$");
+    return function(name){
+        return matcher.test(caseSensitive ? name : name.toLowerCase());
+    };
+}
+
+//The rows the current keyword leaves on screen
+function visibleTrashItems(){
+    if (trashSearchKeyword == ""){
+        return trashItems;
+    }
+    let matches = trashSearchMatcher(trashSearchKeyword, searchCaseSensitive);
+    return trashItems.filter(function(item){
+        return matches(String(item.OriginalFilename == undefined ? "" : item.OriginalFilename));
+    });
+}
+
+/*
+    Entry point registered on the special view. An empty keyword drops the
+    filter rather than matching nothing, which is what leaving the search box
+    empty means everywhere else.
+*/
+function searchTrashBin(keyword){
+    trashSearchKeyword = String(keyword == undefined ? "" : keyword).trim();
+    //A row the filter hides must not stay selected behind it, or Restore and
+    //Delete would act on something the user can no longer see
+    trashSelection = {};
+
+    if (trashItems.length == 0){
+        //Searching before the listing arrived - fetch it, the filter is applied
+        //when it draws
+        loadTrashListing();
+        return;
+    }
+    drawTrashView();
+}
+
+/*
+    Selection
+*/
+function toggleTrashRow(key){
+    if (trashSelection[key]){
+        delete trashSelection[key];
+    }else{
+        trashSelection[key] = true;
+    }
+    updateTrashSelectionState();
+}
+
+function toggleTrashSelectAll(){
+    //Only the rows a search leaves on screen - selecting hidden ones would be
+    //acting on files the user cannot see
+    let visibleItems = visibleTrashItems();
+    let allSelected = visibleItems.length > 0 &&
+                      Object.keys(trashSelection).length == visibleItems.length;
+    trashSelection = {};
+    if (!allSelected){
+        visibleItems.forEach(function(item){
+            trashSelection[encodeURIComponent(item.Filepath)] = true;
+        });
+    }
+    updateTrashSelectionState();
+}
+
+function updateTrashSelectionState(){
+    $(".fmTrashRow").each(function(){
+        let on = trashSelection[$(this).attr("data-key")] === true;
+        $(this).toggleClass("selected", on);
+        $(this).find(".fmTrashCheck").toggleClass("checked", on);
+    });
+    let count = Object.keys(trashSelection).length;
+    $("#fmTrashSelectAll").toggleClass("checked", count > 0 && count == visibleTrashItems().length);
+    $("#fmSelectionSize").text(count > 0 ?
+        applocale.getString("message/selectedCount", "%d selected").replace("%d", count) : "");
+}
+
+function selectedTrashPaths(){
+    return Object.keys(trashSelection).map(decodeURIComponent);
+}
+
+/*
+    Actions
+*/
+function restoreTrashItem(key){
+    restoreTrashPaths([decodeURIComponent(key)]);
+}
+
+/*
+    Restores run one after another rather than in parallel: each moves a file
+    back into the tree, and the listing is only correct once they have all
+    finished. A single failure is reported but does not stop the rest.
+*/
+function restoreTrashPaths(paths){
+    if (paths.length == 0){
+        return;
+    }
+    let remaining = paths.slice();
+    function next(){
+        if (remaining.length == 0){
+            msgbox("checkmark", applocale.getString("trash/restored", "Restored"));
+            renderTrashView();
+            return;
+        }
+        $.ajax({
+            url: "../../system/file_system/restoreTrash",
+            method: "POST",
+            data: {src: remaining.shift()},
+            success: function(data){
+                if (data.error !== undefined){
+                    msgbox("red remove", data.error);
+                }
+                next();
+            },
+            error: function(){
+                msgbox("red remove", applocale.getString("trash/restoreFailed", "Restore failed"));
+                next();
+            }
+        });
+    }
+    next();
+}
+
+function restoreSelectedTrash(){
+    closeTrashMenus();
+    let paths = selectedTrashPaths();
+    if (paths.length == 0){
+        msgbox("question", applocale.getString("message/No file selected", "No file selected"));
+        return;
+    }
+    restoreTrashPaths(paths);
+}
+
+function deleteTrashPaths(paths){
+    if (paths.length == 0){
+        return;
+    }
+    requestCSRFToken(function(token){
+        $.ajax({
+            url: "../../system/file_system/fileOpr",
+            method: "POST",
+            data: {opr: "delete", src: JSON.stringify(paths), csrft: token},
+            success: function(data){
+                if (data.error !== undefined){
+                    msgbox("red remove", data.error);
+                }else{
+                    msgbox("checkmark", applocale.getString("trash/deleted", "Deleted permanently"));
+                }
+                renderTrashView();
+            },
+            error: function(){
+                msgbox("red remove", applocale.getString("trash/deleteFailed", "Delete failed"));
+                renderTrashView();
+            }
+        });
+    });
+}
+
+function deleteSelectedTrash(){
+    closeTrashMenus();
+    let paths = selectedTrashPaths();
+    if (paths.length == 0){
+        msgbox("question", applocale.getString("message/No file selected", "No file selected"));
+        return;
+    }
+    deleteTrashPaths(paths);
+}
+
+function emptyTrashBin(){
+    if (trashItems.length == 0){
+        return;
+    }
+    $.get("../../system/file_system/clearTrash", function(data){
+        if (data !== null && data !== undefined && data.error !== undefined){
+            msgbox("red remove", data.error);
+        }else{
+            msgbox("checkmark", applocale.getString("trash/emptied", "Trash bin emptied"));
+        }
+        renderTrashView();
+    });
+}
+
+/*
+    Row and bulk menus
+*/
+function toggleTrashRowMenu(event, key){
+    event.stopPropagation();
+    closeTrashMenus();
+    let menu = $('<div class="fsMenu fmTrashRowMenu open">' +
+        '<div class="fsMenuItem" onclick="restoreTrashItem(&quot;' + key + '&quot;); closeTrashMenus();">' +
+            '<span class="fsMenuIcon">' + FSIcons.restore + '</span><span>' +
+            applocale.getString("trash/restore", "Restore") + '</span></div>' +
+        '<div class="fsMenuItem" onclick="showTrashItemDetails(&quot;' + key + '&quot;);">' +
+            '<span class="fsMenuIcon">' + FSIcons.info + '</span><span>' +
+            applocale.getString("trash/details", "Details") + '</span></div>' +
+        '<div class="fsMenuSep"></div>' +
+        '<div class="fsMenuItem" onclick="deleteTrashPaths([decodeURIComponent(&quot;' + key + '&quot;)]); closeTrashMenus();">' +
+            '<span class="fsMenuIcon">' + FSIcons.trash + '</span><span>' +
+            applocale.getString("trash/deleteOne", "Delete Permanently") + '</span></div>' +
+    '</div>');
+    $("body").append(menu);
+
+    //Clamp to the viewport, the same rule the file context menu uses
+    let width = menu.outerWidth();
+    let height = menu.outerHeight();
+    let left = Math.min(event.clientX, window.innerWidth - width - 6);
+    let top = Math.min(event.clientY, window.innerHeight - height - 6);
+    menu.css({left: Math.max(6, left) + "px", top: Math.max(6, top) + "px"});
+}
+
+function toggleTrashBulkMenu(event){
+    event.stopPropagation();
+    let menu = $("#fmTrashBulkMenu");
+    let wasOpen = menu.hasClass("open");
+    closeTrashMenus();
+    if (!wasOpen){
+        menu.addClass("open");
+    }
+}
+
+function closeTrashMenus(){
+    $("#fmTrashBulkMenu").removeClass("open");
+    $(".fmTrashRowMenu").remove();
+}
+
+$(document).on("click", function(event){
+    if ($(event.target).closest("#fmTrashBulkMenu, .fmTrashRowMenu, .fmTrashMoreBtn, .fmTrashRowMore").length == 0){
+        closeTrashMenus();
+    }
+});
+
+
+/*
+    Column sorting
+
+    Sorted here rather than server side: the listing arrives whole and is small,
+    and the sort mode the nav bar stores is per folder, which this view has none
+    of.
+*/
+function trashHeaderCell(key, label){
+    let active = trashSortKey == key;
+    let mark = active ? (trashSortAsc ? "&#8593;" : "&#8595;") : "";
+    return '<th class="fmTrashSortable' + (active ? " sorted" : "") +
+        '" onclick="sortTrashBy(&quot;' + key + '&quot;);">' + label +
+        '<span class="fmTrashSortMark">' + mark + '</span></th>';
+}
+
+function sortTrashBy(key){
+    if (trashSortKey == key){
+        trashSortAsc = !trashSortAsc;
+    }else{
+        trashSortKey = key;
+        //Names read best A-Z, but times and sizes are most useful largest first
+        trashSortAsc = (key == "name" || key == "origin");
+    }
+    applyTrashSort();
+    drawTrashView();
+}
+
+function applyTrashSort(){
+    let dir = trashSortAsc ? 1 : -1;
+    trashItems.sort(function(a, b){
+        let result = 0;
+        if (trashSortKey == "name"){
+            result = String(a.OriginalFilename).localeCompare(String(b.OriginalFilename));
+        }else if (trashSortKey == "origin"){
+            result = String(a.OriginalPath).localeCompare(String(b.OriginalPath));
+        }else if (trashSortKey == "size"){
+            //Folders report no size, so they sort as zero rather than drifting
+            result = (a.IsDir ? 0 : a.Filesize) - (b.IsDir ? 0 : b.Filesize);
+        }else{
+            /*
+                "deleted" and "remaining" are the same underlying number: time
+                left is the retention window measured from the removal
+                timestamp, so sorting either sorts both.
+            */
+            result = a.RemoveTimestamp - b.RemoveTimestamp;
+        }
+        if (result == 0){
+            //Stable enough to stop equal timestamps shuffling between redraws
+            result = String(a.OriginalFilename).localeCompare(String(b.OriginalFilename));
+        }
+        return result * dir;
+    });
+}
+
+/*
+    Per row details
+
+    The narrow layout drops the origin and deleted columns, so this is how those
+    stay reachable on a phone. It reuses the file operation dialog markup, which
+    brings the card styling and the scrim close behaviour with it.
+*/
+function showTrashItemDetails(key){
+    closeTrashMenus();
+    let filepath = decodeURIComponent(key);
+    let item = null;
+    for (let i = 0; i < trashItems.length; i++){
+        if (trashItems[i].Filepath == filepath){
+            item = trashItems[i];
+            break;
+        }
+    }
+    if (item == null){
+        return;
+    }
+
+    let rows = [
+        [applocale.getString("trash/col/name", "Name"), escapeTrashText(item.OriginalFilename)],
+        [applocale.getString("trash/col/origin", "Original Location"), escapeTrashText(item.OriginalPath)],
+        [applocale.getString("trash/col/deleted", "Deleted"), escapeTrashText(item.RemoveDate)],
+        [applocale.getString("trash/col/size", "Size"), item.IsDir ? "--" : bytesToSize(item.Filesize)],
+        [applocale.getString("trash/col/remaining", "Time Left"), formatTrashRemaining(item.RemoveTimestamp)]
+    ];
+    let html = rows.map(function(r){
+        return '<tr><td class="fmTrashDetailKey">' + r[0] + '</td><td>' + r[1] + '</td></tr>';
+    }).join("");
+
+    $("#trashDetailsBox").find(".fmTrashDetailTable").html(html);
+    //Rebound per item rather than read from a global, so a stale dialog cannot
+    //restore the wrong file
+    $("#trashDetailsBox").find(".fmTrashDetailRestore").off("click").on("click", function(){
+        hideAllPopupWindows();
+        restoreTrashItem(key);
+    });
+    showPopupWrapper();
+    $("#trashDetailsBox").transition("slide left in");
+}
+
+/*
+    Registration
+
+    Done at load time so listDirectory() and the path bar can find this view
+    without either of them needing to know what a trash bin is.
+*/
+registerSpecialView(TRASH_VPATH, {
+    icon: "trash",
+    labelKey: "trash/title",
+    labelFallback: "Trash Bin",
+    hideViewModes: true,
+    hidePropertiesPane: true,
+    render: function(callback){
+        renderTrashView(callback);
+    },
+    //Searching the bin is this file's business, not the File Manager's
+    search: function(keyword){
+        searchTrashBin(keyword);
+    }
+});
+
+
+/*
+    Reachable from outside this file: inline on* attributes in the markup,
+    handlers generated in template strings, or another frame. Renaming any
+    of these means updating those call sites too.
+*/
+window.openTrashBin = openTrashBin;
+window.emptyTrashBin = emptyTrashBin;
+window.restoreTrashItem = restoreTrashItem;
+window.deleteTrashPaths = deleteTrashPaths;
+window.restoreSelectedTrash = restoreSelectedTrash;
+window.deleteSelectedTrash = deleteSelectedTrash;
+window.toggleTrashRow = toggleTrashRow;
+window.toggleTrashSelectAll = toggleTrashSelectAll;
+window.toggleTrashRowMenu = toggleTrashRowMenu;
+window.toggleTrashBulkMenu = toggleTrashBulkMenu;
+window.closeTrashMenus = closeTrashMenus;
+window.sortTrashBy = sortTrashBy;
+window.showTrashItemDetails = showTrashItemDetails;
+window.loadTrashListing = loadTrashListing;
+window.searchTrashBin = searchTrashBin;

+ 9 - 1
src/web/SystemAO/file_system/js/explorer/viewmode.js

@@ -53,7 +53,15 @@ function updateZoomControlVisibility(){
         control needs - the icon then has no flex context to size it and the
         bare <svg> falls back to its 300px intrinsic size.
     */
-    $("#fmZoom").css("display", viewMode == "grid" ? "flex" : "none");
+    /*
+        A special view has no tiles to resize whatever view mode it was entered
+        from, and the resize handler and updateViewmodeButtons() both come
+        through here - so the test lives here rather than in the one-shot hide
+        applySpecialViewChrome() used to do, which either of them undid.
+    */
+    let specialView = (typeof getSpecialView === "function") ? getSpecialView(currentPath) : null;
+    let hidden = (specialView != null && specialView.hideViewModes === true) || viewMode != "grid";
+    $("#fmZoom").css("display", hidden ? "none" : "flex");
     initWindowSizes(false);
 }
 

+ 5 - 1
src/web/SystemAO/file_system/shared/fsicons.js

@@ -86,7 +86,11 @@
         pauseCircle: S + '<circle cx="12" cy="12" r="9"/><path d="M10.1 9.2v5.6M13.9 9.2v5.6"/></svg>',
         playCircle:  S + '<circle cx="12" cy="12" r="9"/><path d="M10.2 8.6l5.2 3.4-5.2 3.4z"/></svg>',
         checkCircle: S + '<circle cx="12" cy="12" r="9"/><path d="M8 12.3l2.7 2.7L16 9.7"/></svg>',
-        closeCircle: S + '<circle cx="12" cy="12" r="9"/><path d="M9.2 9.2l5.6 5.6M14.8 9.2l-5.6 5.6"/></svg>'
+        closeCircle: S + '<circle cx="12" cy="12" r="9"/><path d="M9.2 9.2l5.6 5.6M14.8 9.2l-5.6 5.6"/></svg>',
+
+        /* Trash bin view */
+        restore:     S + '<path d="M3 12a9 9 0 1 0 3.2-6.9"/><path d="M3 4v5h5"/></svg>',
+        trashBig:    S + '<path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2M6 7l1 13h10l1-13"/><path d="M10 11v6M14 11v6"/></svg>'
     };
 
     //Fill every element carrying data-fsicon="<name>" with its glyph

+ 126 - 0
src/web/SystemAO/locale/disk/trashsettings.json

@@ -0,0 +1,126 @@
+{
+    "author": "tobychui",
+    "version": "1.0",
+    "keys": {
+        "zh-tw": {
+            "name": "繁體中文",
+            "strings": {
+                "ts/title": "檔案管理員",
+                "ts/subtitle": "設定刪除的檔案在垃圾箱中保留多久,以及可佔用多少空間。",
+                "ts/retention/title": "自動移除",
+                "ts/retention/desc": "垃圾箱中超過此時間的檔案會被永久刪除。關閉後將保留至您手動清空為止。",
+                "ts/retention/off": "永不自動移除",
+                "ts/retention/days": "%d 天",
+                "ts/quota/title": "垃圾箱大小限制",
+                "ts/quota/desc": "達到上限後,刪除檔案時會詢問您要先清空垃圾箱、直接刪除,還是取消。",
+                "ts/quota/unlimited": "不限制",
+                "ts/quota/limited": "限制為",
+                "ts/quota/inuse": "目前已使用 %s",
+                "ts/save": "儲存",
+                "ts/saved": "已儲存",
+                "ts/savefailed": "無法儲存",
+                "ts/invalid": "請輸入大於零的大小"
+            }
+        },
+        "zh-hk": {
+            "name": "繁體中文",
+            "strings": {
+                "ts/title": "檔案管理員",
+                "ts/subtitle": "設定刪除的檔案在垃圾箱中保留多久,以及可佔用多少空間。",
+                "ts/retention/title": "自動移除",
+                "ts/retention/desc": "垃圾箱中超過此時間的檔案會被永久刪除。關閉後將保留至您手動清空為止。",
+                "ts/retention/off": "永不自動移除",
+                "ts/retention/days": "%d 天",
+                "ts/quota/title": "垃圾箱大小限制",
+                "ts/quota/desc": "達到上限後,刪除檔案時會詢問您要先清空垃圾箱、直接刪除,還是取消。",
+                "ts/quota/unlimited": "不限制",
+                "ts/quota/limited": "限制為",
+                "ts/quota/inuse": "目前已使用 %s",
+                "ts/save": "儲存",
+                "ts/saved": "已儲存",
+                "ts/savefailed": "無法儲存",
+                "ts/invalid": "請輸入大於零的大小"
+            }
+        },
+        "zh-cn": {
+            "name": "简体中文",
+            "strings": {
+                "ts/title": "文件管理器",
+                "ts/subtitle": "设置删除的文件在回收站中保留多久,以及可占用多少空间。",
+                "ts/retention/title": "自动移除",
+                "ts/retention/desc": "回收站中超过此时间的文件将被永久删除。关闭后将保留至您手动清空为止。",
+                "ts/retention/off": "永不自动移除",
+                "ts/retention/days": "%d 天",
+                "ts/quota/title": "回收站大小限制",
+                "ts/quota/desc": "达到上限后,删除文件时会询问您要先清空回收站、直接删除,还是取消。",
+                "ts/quota/unlimited": "不限制",
+                "ts/quota/limited": "限制为",
+                "ts/quota/inuse": "当前已使用 %s",
+                "ts/save": "保存",
+                "ts/saved": "已保存",
+                "ts/savefailed": "无法保存",
+                "ts/invalid": "请输入大于零的大小"
+            }
+        },
+        "en-us": {
+            "name": "English",
+            "strings": {
+                "ts/title": "File Manager",
+                "ts/subtitle": "Control how long deleted files are kept in the trash bin and how much space they may use.",
+                "ts/retention/title": "Automatic Removal",
+                "ts/retention/desc": "Files in the trash bin are permanently removed once they are older than this. Turn it off to keep them until you empty the bin yourself.",
+                "ts/retention/off": "Never remove automatically",
+                "ts/retention/days": "%d days",
+                "ts/quota/title": "Trash Bin Size Limit",
+                "ts/quota/desc": "When the limit is reached, deleting a file will ask you whether to empty the bin first, delete the file outright, or cancel.",
+                "ts/quota/unlimited": "No limit",
+                "ts/quota/limited": "Limit to",
+                "ts/quota/inuse": "Currently using %s",
+                "ts/save": "Save",
+                "ts/saved": "Saved",
+                "ts/savefailed": "Could not save",
+                "ts/invalid": "Enter a size larger than zero"
+            }
+        },
+        "ja-jp": {
+            "name": "日本語",
+            "strings": {
+                "ts/title": "ファイルマネージャー",
+                "ts/subtitle": "削除したファイルをごみ箱に保持する期間と、使用できる容量を設定します。",
+                "ts/retention/title": "自動削除",
+                "ts/retention/desc": "この期間を過ぎたごみ箱内のファイルは完全に削除されます。オフにすると手動で空にするまで保持されます。",
+                "ts/retention/off": "自動削除しない",
+                "ts/retention/days": "%d 日",
+                "ts/quota/title": "ごみ箱の容量制限",
+                "ts/quota/desc": "上限に達すると、ファイル削除時にごみ箱を空にするか、直接削除するか、中止するかを確認します。",
+                "ts/quota/unlimited": "制限なし",
+                "ts/quota/limited": "上限",
+                "ts/quota/inuse": "現在 %s 使用中",
+                "ts/save": "保存",
+                "ts/saved": "保存しました",
+                "ts/savefailed": "保存できませんでした",
+                "ts/invalid": "0 より大きいサイズを入力してください"
+            }
+        },
+        "ko-kr": {
+            "name": "한국어",
+            "strings": {
+                "ts/title": "파일 관리자",
+                "ts/subtitle": "삭제된 파일을 휴지통에 보관하는 기간과 사용할 수 있는 공간을 설정합니다.",
+                "ts/retention/title": "자동 삭제",
+                "ts/retention/desc": "이 기간이 지난 휴지통의 파일은 영구 삭제됩니다. 끄면 직접 비울 때까지 보관됩니다.",
+                "ts/retention/off": "자동으로 삭제하지 않음",
+                "ts/retention/days": "%d일",
+                "ts/quota/title": "휴지통 크기 제한",
+                "ts/quota/desc": "한도에 도달하면 파일 삭제 시 휴지통을 비울지, 바로 삭제할지, 취소할지 묻습니다.",
+                "ts/quota/unlimited": "제한 없음",
+                "ts/quota/limited": "제한",
+                "ts/quota/inuse": "현재 %s 사용 중",
+                "ts/save": "저장",
+                "ts/saved": "저장됨",
+                "ts/savefailed": "저장할 수 없습니다",
+                "ts/invalid": "0보다 큰 크기를 입력하세요"
+            }
+        }
+    }
+}

+ 222 - 6
src/web/SystemAO/locale/file_explorer.json

@@ -259,7 +259,43 @@
                 "sidebar/properties/permRead": "讀取",
                 "sidebar/properties/permWrite": "寫入",
                 "sidebar/properties/permExec": "執行",
-                "fileopr/Hidden Files": "顯示隱藏檔案"
+                "fileopr/Hidden Files": "顯示隱藏檔案",
+                "trash/title": "垃圾箱",
+                "trash/desc": "這些檔案已被刪除,會在 %d 天後自動永久刪除",
+                "trash/used": "已使用空間",
+                "trash/quota": "總共 %s",
+                "trash/emptybtn": "清空垃圾箱",
+                "trash/col/name": "名稱",
+                "trash/col/origin": "原始位置",
+                "trash/col/deleted": "刪除時間",
+                "trash/col/size": "大小",
+                "trash/col/remaining": "剩餘時間",
+                "trash/restore": "還原",
+                "trash/deleteOne": "永久刪除",
+                "trash/restoreSelected": "還原所選",
+                "trash/deleteSelected": "永久刪除所選",
+                "trash/daysLeft": "%d 天",
+                "trash/expiring": "今天",
+                "trash/empty": "垃圾箱是空的",
+                "trash/footnote": "檔案會在 %d 天後自動永久刪除,您也可以手動清空垃圾箱。",
+                "trash/restored": "已還原",
+                "trash/restoreFailed": "還原失敗",
+                "trash/deleted": "已永久刪除",
+                "trash/deleteFailed": "刪除失敗",
+                "trash/emptied": "垃圾箱已清空",
+                "message/itemCount": "%d 個項目",
+                "message/selectedCount": "已選 %d 個",
+                "trash/details": "詳細資訊",
+                "trash/nolimit": "不限大小",
+                "trash/descNoExpiry": "這些檔案已被刪除,會保留至您清空垃圾箱為止。",
+                "trash/footnoteNoExpiry": "檔案會保留至您清空垃圾箱為止,可於系統設定中開啟自動移除。",
+                "trash/keptForever": "保留中",
+                "trash/full/title": "垃圾箱已滿",
+                "trash/full/desc": "垃圾箱已達大小上限,無法將這些檔案移入。",
+                "trash/full/clearfirst": "清空垃圾箱並移入這些檔案",
+                "trash/full/deletenow": "改為永久刪除這些檔案",
+                "trash/full/cancel": "不做任何動作",
+                "trash/noMatch": "沒有符合搜尋條件的項目"
             },
             "titles": {
                 "Back": "上一頁",
@@ -564,7 +600,43 @@
                 "sidebar/properties/permRead": "讀取",
                 "sidebar/properties/permWrite": "寫入",
                 "sidebar/properties/permExec": "執行",
-                "fileopr/Hidden Files": "顯示隱藏檔案"
+                "fileopr/Hidden Files": "顯示隱藏檔案",
+                "trash/title": "垃圾箱",
+                "trash/desc": "這些檔案已被刪除,會在 %d 天後自動永久刪除",
+                "trash/used": "已使用空間",
+                "trash/quota": "總共 %s",
+                "trash/emptybtn": "清空垃圾箱",
+                "trash/col/name": "名稱",
+                "trash/col/origin": "原始位置",
+                "trash/col/deleted": "刪除時間",
+                "trash/col/size": "大小",
+                "trash/col/remaining": "剩餘時間",
+                "trash/restore": "還原",
+                "trash/deleteOne": "永久刪除",
+                "trash/restoreSelected": "還原所選",
+                "trash/deleteSelected": "永久刪除所選",
+                "trash/daysLeft": "%d 天",
+                "trash/expiring": "今天",
+                "trash/empty": "垃圾箱是空的",
+                "trash/footnote": "檔案會在 %d 天後自動永久刪除,您也可以手動清空垃圾箱。",
+                "trash/restored": "已還原",
+                "trash/restoreFailed": "還原失敗",
+                "trash/deleted": "已永久刪除",
+                "trash/deleteFailed": "刪除失敗",
+                "trash/emptied": "垃圾箱已清空",
+                "message/itemCount": "%d 個項目",
+                "message/selectedCount": "已選 %d 個",
+                "trash/details": "詳細資訊",
+                "trash/nolimit": "不限大小",
+                "trash/descNoExpiry": "這些檔案已被刪除,會保留至您清空垃圾箱為止。",
+                "trash/footnoteNoExpiry": "檔案會保留至您清空垃圾箱為止,可於系統設定中開啟自動移除。",
+                "trash/keptForever": "保留中",
+                "trash/full/title": "垃圾箱已滿",
+                "trash/full/desc": "垃圾箱已達大小上限,無法將這些檔案移入。",
+                "trash/full/clearfirst": "清空垃圾箱並移入這些檔案",
+                "trash/full/deletenow": "改為永久刪除這些檔案",
+                "trash/full/cancel": "不做任何動作",
+                "trash/noMatch": "沒有符合搜尋條件的項目"
             },
             "titles": {
                 "Back": "上一頁",
@@ -870,7 +942,43 @@
                 "sidebar/properties/permRead": "读取",
                 "sidebar/properties/permWrite": "写入",
                 "sidebar/properties/permExec": "执行",
-                "fileopr/Hidden Files": "显示隐藏文件"
+                "fileopr/Hidden Files": "显示隐藏文件",
+                "trash/title": "回收站",
+                "trash/desc": "这些文件已被删除,将在 %d 天后自动永久删除",
+                "trash/used": "已使用空间",
+                "trash/quota": "共 %s",
+                "trash/emptybtn": "清空回收站",
+                "trash/col/name": "名称",
+                "trash/col/origin": "原始位置",
+                "trash/col/deleted": "删除时间",
+                "trash/col/size": "大小",
+                "trash/col/remaining": "剩余时间",
+                "trash/restore": "还原",
+                "trash/deleteOne": "永久删除",
+                "trash/restoreSelected": "还原所选",
+                "trash/deleteSelected": "永久删除所选",
+                "trash/daysLeft": "%d 天",
+                "trash/expiring": "今天",
+                "trash/empty": "回收站是空的",
+                "trash/footnote": "文件将在 %d 天后自动永久删除,您也可以手动清空回收站。",
+                "trash/restored": "已还原",
+                "trash/restoreFailed": "还原失败",
+                "trash/deleted": "已永久删除",
+                "trash/deleteFailed": "删除失败",
+                "trash/emptied": "回收站已清空",
+                "message/itemCount": "%d 个项目",
+                "message/selectedCount": "已选 %d 个",
+                "trash/details": "详细信息",
+                "trash/nolimit": "不限大小",
+                "trash/descNoExpiry": "这些文件已被删除,将保留至您清空回收站为止。",
+                "trash/footnoteNoExpiry": "文件将保留至您清空回收站为止,可在系统设置中开启自动移除。",
+                "trash/keptForever": "保留中",
+                "trash/full/title": "回收站已满",
+                "trash/full/desc": "回收站已达大小上限,无法将这些文件移入。",
+                "trash/full/clearfirst": "清空回收站并移入这些文件",
+                "trash/full/deletenow": "改为永久删除这些文件",
+                "trash/full/cancel": "不做任何操作",
+                "trash/noMatch": "没有符合搜索条件的项目"
             },
             "titles": {
                 "Back": "上一页",
@@ -1177,7 +1285,43 @@
                 "sidebar/properties/permRead": "Read",
                 "sidebar/properties/permWrite": "Write",
                 "sidebar/properties/permExec": "Exec",
-                "fileopr/Hidden Files": "Show Hidden Files"
+                "fileopr/Hidden Files": "Show Hidden Files",
+                "trash/title": "Trash Bin",
+                "trash/desc": "These files have been deleted and will be removed permanently after %d days.",
+                "trash/used": "Space Used",
+                "trash/quota": "of %s",
+                "trash/emptybtn": "Empty Trash Bin",
+                "trash/col/name": "Name",
+                "trash/col/origin": "Original Location",
+                "trash/col/deleted": "Deleted",
+                "trash/col/size": "Size",
+                "trash/col/remaining": "Time Left",
+                "trash/restore": "Restore",
+                "trash/deleteOne": "Delete Permanently",
+                "trash/restoreSelected": "Restore Selected",
+                "trash/deleteSelected": "Delete Selected Permanently",
+                "trash/daysLeft": "%d days",
+                "trash/expiring": "Today",
+                "trash/empty": "The trash bin is empty",
+                "trash/footnote": "Files are permanently removed after %d days. You can also empty the bin yourself.",
+                "trash/restored": "Restored",
+                "trash/restoreFailed": "Restore failed",
+                "trash/deleted": "Deleted permanently",
+                "trash/deleteFailed": "Delete failed",
+                "trash/emptied": "Trash bin emptied",
+                "message/itemCount": "%d items",
+                "message/selectedCount": "%d selected",
+                "trash/details": "Details",
+                "trash/nolimit": "No size limit",
+                "trash/descNoExpiry": "These files have been deleted. They are kept until you empty the trash bin.",
+                "trash/footnoteNoExpiry": "Files are kept until you empty the trash bin. Automatic removal can be turned on in System Settings.",
+                "trash/keptForever": "Kept",
+                "trash/full/title": "Trash Bin Full",
+                "trash/full/desc": "The trash bin has reached its size limit, so these files cannot be moved into it.",
+                "trash/full/clearfirst": "Empty the trash bin and move them there",
+                "trash/full/deletenow": "Delete them permanently instead",
+                "trash/full/cancel": "Do nothing",
+                "trash/noMatch": "No items match your search"
             },
             "titles": {
                 "Back": "Back",
@@ -1484,7 +1628,43 @@
                 "sidebar/properties/permRead": "読取",
                 "sidebar/properties/permWrite": "書込",
                 "sidebar/properties/permExec": "実行",
-                "fileopr/Hidden Files": "隠しファイルを表示"
+                "fileopr/Hidden Files": "隠しファイルを表示",
+                "trash/title": "ごみ箱",
+                "trash/desc": "これらのファイルは削除済みで、%d 日後に完全に削除されます。",
+                "trash/used": "使用容量",
+                "trash/quota": "合計 %s",
+                "trash/emptybtn": "ごみ箱を空にする",
+                "trash/col/name": "名前",
+                "trash/col/origin": "元の場所",
+                "trash/col/deleted": "削除日時",
+                "trash/col/size": "サイズ",
+                "trash/col/remaining": "残り時間",
+                "trash/restore": "復元",
+                "trash/deleteOne": "完全に削除",
+                "trash/restoreSelected": "選択項目を復元",
+                "trash/deleteSelected": "選択項目を完全に削除",
+                "trash/daysLeft": "残り %d 日",
+                "trash/expiring": "本日",
+                "trash/empty": "ごみ箱は空です",
+                "trash/footnote": "ファイルは %d 日後に完全に削除されます。手動で空にすることもできます。",
+                "trash/restored": "復元しました",
+                "trash/restoreFailed": "復元に失敗しました",
+                "trash/deleted": "完全に削除しました",
+                "trash/deleteFailed": "削除に失敗しました",
+                "trash/emptied": "ごみ箱を空にしました",
+                "message/itemCount": "%d 件",
+                "message/selectedCount": "%d 件選択",
+                "trash/details": "詳細",
+                "trash/nolimit": "容量制限なし",
+                "trash/descNoExpiry": "これらのファイルは削除済みです。ごみ箱を空にするまで保持されます。",
+                "trash/footnoteNoExpiry": "ファイルはごみ箱を空にするまで保持されます。自動削除はシステム設定で有効にできます。",
+                "trash/keptForever": "保持中",
+                "trash/full/title": "ごみ箱がいっぱいです",
+                "trash/full/desc": "ごみ箱が容量上限に達しているため、これらのファイルを移動できません。",
+                "trash/full/clearfirst": "ごみ箱を空にして移動する",
+                "trash/full/deletenow": "代わりに完全に削除する",
+                "trash/full/cancel": "何もしない",
+                "trash/noMatch": "検索条件に一致する項目はありません"
             },
             "titles": {
                 "Back": "戻る",
@@ -1793,7 +1973,43 @@
                 "sidebar/properties/permRead": "읽기",
                 "sidebar/properties/permWrite": "쓰기",
                 "sidebar/properties/permExec": "실행",
-                "fileopr/Hidden Files": "숨김 파일 표시"
+                "fileopr/Hidden Files": "숨김 파일 표시",
+                "trash/title": "휴지통",
+                "trash/desc": "이 파일들은 삭제되었으며 %d일 후 영구 삭제됩니다.",
+                "trash/used": "사용된 공간",
+                "trash/quota": "전체 %s",
+                "trash/emptybtn": "휴지통 비우기",
+                "trash/col/name": "이름",
+                "trash/col/origin": "원래 위치",
+                "trash/col/deleted": "삭제 시간",
+                "trash/col/size": "크기",
+                "trash/col/remaining": "남은 시간",
+                "trash/restore": "복원",
+                "trash/deleteOne": "영구 삭제",
+                "trash/restoreSelected": "선택 항목 복원",
+                "trash/deleteSelected": "선택 항목 영구 삭제",
+                "trash/daysLeft": "%d일",
+                "trash/expiring": "오늘",
+                "trash/empty": "휴지통이 비어 있습니다",
+                "trash/footnote": "파일은 %d일 후 영구 삭제됩니다. 직접 비울 수도 있습니다.",
+                "trash/restored": "복원됨",
+                "trash/restoreFailed": "복원 실패",
+                "trash/deleted": "영구 삭제됨",
+                "trash/deleteFailed": "삭제 실패",
+                "trash/emptied": "휴지통을 비웠습니다",
+                "message/itemCount": "%d개 항목",
+                "message/selectedCount": "%d개 선택",
+                "trash/details": "세부 정보",
+                "trash/nolimit": "용량 제한 없음",
+                "trash/descNoExpiry": "이 파일들은 삭제되었습니다. 휴지통을 비울 때까지 보관됩니다.",
+                "trash/footnoteNoExpiry": "파일은 휴지통을 비울 때까지 보관됩니다. 자동 삭제는 시스템 설정에서 켤 수 있습니다.",
+                "trash/keptForever": "보관 중",
+                "trash/full/title": "휴지통이 가득 참",
+                "trash/full/desc": "휴지통이 용량 한도에 도달하여 이 파일들을 옮길 수 없습니다.",
+                "trash/full/clearfirst": "휴지통을 비우고 옮기기",
+                "trash/full/deletenow": "대신 영구 삭제하기",
+                "trash/full/cancel": "아무것도 하지 않기",
+                "trash/noMatch": "검색과 일치하는 항목이 없습니다"
             },
             "titles": {
                 "Back": "뒤로",