From 8336805ee27d89bbb73647fc70703cc74875f4fa Mon Sep 17 00:00:00 2001 From: Adam Lamers Date: Mon, 4 May 2026 16:44:05 -0400 Subject: [PATCH] natural sort for filebrowser --- .../file-browser/FileBrowser.svelte | 24 +++++-- frontend/src/lib/utils.ts | 67 +++++++++++++++++++ 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/file-browser/FileBrowser.svelte b/frontend/src/lib/components/file-browser/FileBrowser.svelte index 36b3e56..a534758 100644 --- a/frontend/src/lib/components/file-browser/FileBrowser.svelte +++ b/frontend/src/lib/components/file-browser/FileBrowser.svelte @@ -18,7 +18,7 @@ import FileBrowserTreeItem from "./FileBrowserTreeItem.svelte"; import FileBrowserRowItem from "./FileBrowserRowItem.svelte"; import type { FileItem, TreeNode, Breadcrumb } from "$lib/types"; - import { cn } from "$lib/utils"; + import { cn, naturalSortCompare } from "$lib/utils"; import { getSystemTreeSystemTreeGet, getArchiveTreeInventoryTreeGet, @@ -264,12 +264,24 @@ }); result.sort((a: FileItem, b: FileItem) => { - const valA = sortColumn === "type" ? a.type : a[sortColumn as keyof FileItem] || 0; - const valB = sortColumn === "type" ? b.type : b[sortColumn as keyof FileItem] || 0; + let cmp = 0; - if (valA < (valB as any)) return sortDirection === "asc" ? -1 : 1; - if (valA > (valB as any)) return sortDirection === "asc" ? 1 : -1; - return 0; + if (sortColumn === "name") { + // Directories always sort before files, then natural sort by name + if (a.type !== b.type) { + cmp = a.type === "directory" ? -1 : 1; + } else { + cmp = naturalSortCompare(a.name, b.name); + } + } else { + const valA = sortColumn === "type" ? a.type : a[sortColumn as keyof FileItem] || 0; + const valB = sortColumn === "type" ? b.type : b[sortColumn as keyof FileItem] || 0; + + if (valA < (valB as any)) cmp = -1; + else if (valA > (valB as any)) cmp = 1; + } + + return sortDirection === "asc" ? cmp : -cmp; }); return result; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 26d4775..13c1f41 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -55,3 +55,70 @@ export function formatSize(bytes: number | null | undefined): string { } return `${size.toFixed(1)} ${units[unitIndex]}`; } + +/** + * Natural sort comparator mimicking Windows Explorer's StrCmpLogicalW. + * + * Rules: + * 1. Directories always sort before files. + * 2. Case-insensitive alphanumeric comparison. + * 3. Multi-digit numbers are compared as whole integers (1, 2, 10 not 1, 10, 2). + * 4. Falls back to locale-aware comparison for non-ASCII characters. + */ +export function naturalSortCompare(aName: string, bName: string): number { + const aLower = aName.toLowerCase(); + const bLower = bName.toLowerCase(); + const len = Math.min(aLower.length, bLower.length); + + let i = 0; + while (i < len) { + const aChar = aLower[i]; + const bChar = bLower[i]; + + // If both are digits, extract the full number and compare numerically + if (isDigit(aChar) && isDigit(bChar)) { + let aNum = 0; + let bNum = 0; + let j = i; + + while (j < aLower.length && isDigit(aLower[j])) { + aNum = aNum * 10 + (aLower.charCodeAt(j) - 48); + j++; + } + const aEnd = j; + + j = i; + while (j < bLower.length && isDigit(bLower[j])) { + bNum = bNum * 10 + (bLower.charCodeAt(j) - 48); + j++; + } + const bEnd = j; + + if (aNum !== bNum) { + return aNum - bNum; + } + + // Numbers are equal but one may have leading zeros; shorter run first + if (aEnd !== bEnd) { + return aEnd - bEnd; + } + + i = aEnd; + continue; + } + + // Simple character comparison (locale-aware fallback for non-ASCII) + if (aChar !== bChar) { + return aChar.localeCompare(bChar); + } + + i++; + } + + return aLower.length - bLower.length; +} + +function isDigit(c: string): boolean { + const code = c.charCodeAt(0); + return code >= 48 && code <= 57; +}