/** * sortOrder — THE listing sort: one key, one direction, one comparator, for * every surface that draws a folder's contents. * * ⚠⚠ Why this file exists at all. Before it, the sort lived as two private * refs inside `ListView.vue` and the grid had none, so "sorted by size" was a * fact about one component rather than about the listing: switching grid ⇄ * list re-ordered the rows under the user, and any second control that wanted * to sort would have had to keep its own copy of the answer. That is the exact * shape of filex lesson #67 — the grid and the list each holding a private * copy of "folders first" and drifting apart — one level up. * * ⚠⚠ …and why it is no longer a module-level SINGLETON. A singleton answers * "what is the sort" for the whole bundle, which is the right answer only * while the bundle draws one listing. The split view draws two, side by side, * and the owner's ruling (2026-09-13) is that the second pane is the first * pane: it gets the same crumbs, the same filter row, the same view switcher * and the same sort control. Two sort CONTROLS over one piece of state is a * control that moves the listing the person is not looking at — measured the * same day: with the singleton in place, choosing "Size ↓" in the right pane * re-ordered the left one too. * * So the state is a STORE (`createSortStore`) and there is one per pane, while * everything that is genuinely one answer stays module-level: * * · the RULES (`byActiveKey`, `compareNodes`, folders-first, the two click * vocabularies) — written once, closed over each store; * · the LOCALE the `type` key sorts in — one alphabet for the whole bundle; * · the persisted GLOBAL DEFAULT (`filex.list-sort`) — "the last sort a * person chose", which is one fact no matter which pane they chose it in. * * ⚠ Panes find their store by INJECTION (`provideSortStore` / `useSortStore`), * not by a prop threaded through every view: `ListView` and `FilterBar` are * two levels down and an embedder may mount either on its own. With no * provider — a bare `` in a test, a host that renders one listing — * `useSortStore()` hands back the default store, which IS the old singleton, * so nothing that worked before changes. * * ⚠ Two RULES this file owns, so no caller has to remember them: * * 1. Folders before files, in every key and BOTH directions. `compareNodes` * applies `byFoldersFirst` as the primary comparator and never multiplies * it by the direction — multiplying would make "Name ↓" mean "files * first", i.e. the arrow would silently regroup the listing instead of * reversing it. The key only decides the order WITHIN each group. * 2. One vocabulary. A column header cycles (`chooseSortKey`: a new key * arrives in its default direction, the active one flips) and a menu item * jumps (`setSort`: a named destination never reverses under you). Both * live here, so the two surfaces reach for the same words instead of each * re-deriving what a click means. * * ⚠ The persisted shape is the one `ListView` already wrote (`filex.list-sort`, * `{key, dir}`), so a user who had sorted a column keeps that sort. The old * `key: null` — "leave the backend's order alone" — is NOT a key here; see * `ListingOrder` below for where that state went and why it could not be one. */ import { inject, provide, ref, type InjectionKey, type Ref } from 'vue'; import type { FileNode } from '../types/FileNode'; import type { LocaleCode } from '../types/ExplorerConfig'; import { messages } from '../locales'; import { typeLabelKey } from './fileIcons'; import { byFoldersFirst } from './listing'; export type SortKey = 'name' | 'type' | 'modified' | 'size'; export type SortDir = 'asc' | 'desc'; /** * WHERE A LISTING'S ORDER CAME FROM — the third state the old `key: null` * used to express, back as a mode rather than as a fourth key. * * `sort` — a folder's contents. The key and the direction below decide * the order, which is what every control in the UI drives. * `relevance` — a RANKED answer. The server decided the order and it is the * whole value of the reply; re-applying a key here buries the * row the person came for. * * ⚠⚠ Why a mode and not a key. A key is a preference: it is persisted, it is * named by a button, and the user picks it. "Relevance" is none of those — it * is a FACT about the rows in hand, it must not outlive them, and there is no * meaningful "relevance descending". Making it a fifth member of `SortKey` * would have persisted it into the next folder (where nothing is ranked) and * put a word in the Sort-by menu that does nothing in nine listings out of * ten. * * ⚠⚠ …and why it is an ARGUMENT and not a field on the store. The store is a * pane's preference; this is a property of the ROWS a pane happens to be * holding, and one pane can be showing a search while the other shows a * folder. Same pane, two navigations, two answers — the caller holding the * rows is the only one that knows which it has. * * Measured, 2026-09-13 (qldemo, query `s`, Name ↑): the server ranked * `Documents/server.ts` first and the list drew it fifteenth of seventeen, * behind every alphabetically earlier row. The owner's ruling that day: * ordinary search and ⌘K stay in relevance order. */ export type ListingOrder = 'sort' | 'relevance'; /** The four, in the order the menu draws them. One array, so the menu and * `isSortKey` cannot disagree about how many there are. */ export const SORT_KEYS: readonly SortKey[] = ['name', 'type', 'modified', 'size'] as const; const SORT_LS_KEY = 'filex.list-sort'; /** * The order the listing arrives in when nobody has chosen one. * * ⚠ `name` / ascending, and NOT the reference build's `modified` / descending, * deliberately: the backend answers a listing name-ascending already, so this * default moves nothing on screen for an existing user — and our list view * draws date group headings whenever the key is `modified`, which the * reference has no equivalent of. Making `modified` the default would put a * heading on the first paint of every folder that nobody asked for. Changing * it is this one constant; the consequence is that heading. */ const DEFAULT_KEY: SortKey = 'name'; /** Dates read newest-first; everything else reads A→Z / smallest-first. */ export function defaultSortDir(key: SortKey): SortDir { return key === 'modified' ? 'desc' : 'asc'; } function isSortKey(v: unknown): v is SortKey { return typeof v === 'string' && (SORT_KEYS as readonly string[]).includes(v); } function readStored(): { key: SortKey; dir: SortDir } { const fallback = { key: DEFAULT_KEY, dir: defaultSortDir(DEFAULT_KEY) }; if (typeof localStorage === 'undefined') return fallback; try { const raw = localStorage.getItem(SORT_LS_KEY); if (!raw) return fallback; const p = JSON.parse(raw) as { key?: unknown; dir?: unknown }; const key = isSortKey(p.key) ? p.key : DEFAULT_KEY; const dir = p.dir === 'asc' || p.dir === 'desc' ? p.dir : defaultSortDir(key); return { key, dir }; } catch { return fallback; // private mode / bad JSON } } /** * The active locale, for the one comparison that needs words: `type` sorts by * the label the Type column PRINTS, so "Image" and "Görsel" have to fall in * their own alphabet's order. * * ⚠ Module-level and NOT per store, and that is deliberate even now that the * key and the direction are per pane: two panes of one window are read by one * person in one language. If each store carried its own resolver, the pane * whose host forgot to set it would sort by a different alphabet than the pane * beside it — the drift this file exists to prevent, in the one field where it * would be hardest to see. */ const localeRef = ref('en'); export function setSortLocale(code: LocaleCode): void { localeRef.value = code; } // ── the comparator ──────────────────────────────────────────────────── function modifiedMs(n: FileNode): number | null { const v = n.last_modified; if (!v) return null; return v * (v < 1e12 ? 1000 : 1); } function nameCompare(a: FileNode, b: FileNode): number { return (a.basename || '').localeCompare(b.basename || '', undefined, { numeric: true, sensitivity: 'base', }); } /** The word the Type column prints for this row — the thing a user sorting by * "Type" is looking at. Unmapped kinds fall back to the uppercased extension, * exactly as `typeLabelFor` does, so no row sorts as an empty string. */ function typeLabel(n: FileNode): string { const key = typeLabelKey(n); if (key) { const cat = messages[localeRef.value] ?? messages.en; return cat[key] ?? key; } const ext = (n.extension || '').trim(); return ext ? ext.toUpperCase() : ''; } /** The active key's own order, direction applied. Folders-first is NOT here — * it is the primary comparator in `compareNodes` and must not be reversed. * * ⚠ Written ONCE and closed over each store's refs, rather than copied into * a store factory: the rule is the same in every pane and a per-pane copy of * it is how the grid and the list drifted in the first place. */ function byActiveKey(key: SortKey, dirWord: SortDir, a: FileNode, b: FileNode): number { const dir = dirWord === 'asc' ? 1 : -1; switch (key) { case 'name': return dir * nameCompare(a, b); case 'size': return dir * ((a.size ?? -1) - (b.size ?? -1)); case 'type': { const c = typeLabel(a).localeCompare(typeLabel(b), undefined, { sensitivity: 'base' }); // ⚠ The tiebreak is name-ASCENDING in both directions, on purpose: every // image in a folder shares one label, so without it a Type sort leaves // whole runs in whatever order the backend happened to answer, and // "Type ↓" would scramble them again rather than reverse the groups. return c !== 0 ? dir * c : nameCompare(a, b); } default: { const am = modifiedMs(a); const bm = modifiedMs(b); // Undated rows go last in BOTH directions, so date groups stay clean. if (am == null && bm == null) return 0; if (am == null) return 1; if (bm == null) return -1; return dir * (am - bm); } } } // ── the store ───────────────────────────────────────────────────────── /** * One pane's sort. Everything a surface needs to READ the order, to CHANGE it * from either of the two control vocabularies, and to sort rows by it. * * ⚠ `key` and `dir` are exposed as refs so a `computed` that reads them * subscribes to the next change — the property the module-level singleton had * and the reason every listing re-sorted itself for free. */ export interface SortStore { readonly key: Ref; readonly dir: Ref; /** Set both halves outright. `dir` omitted → the key's own default. */ setSort(key: SortKey, dir?: SortDir): void; /** Restore a remembered sort WITHOUT recording it as a fresh choice. */ applySort(key: SortKey, dir: SortDir): void; /** Same order, other way round. The direction button's whole job. */ toggleSortDir(): void; /** A column header click — see `chooseSortKey` below. */ chooseSortKey(key: SortKey): void; /** Folders first, then the active key. Subscribes the reader. */ compareNodes(a: FileNode, b: FileNode): number; /** THE comparator for a listing, given where its order came from. */ compareInOrder(order: ListingOrder): (a: FileNode, b: FileNode) => number; /** The listing, in its order. A COPY. */ sortListing(files: FileNode[], order?: ListingOrder): FileNode[]; } function persist(key: SortKey, dir: SortDir): void { try { localStorage.setItem(SORT_LS_KEY, JSON.stringify({ key, dir })); } catch { /* quota / private mode — the in-memory refs still moved */ } } /** * A fresh sort store, seeded from the global default. * * ⚠⚠ Every store WRITES the global default when a person chooses in it, and * that is the point rather than an oversight: `filex.list-sort` is defined as * "the last sort a person chose", and a choice made in the right-hand pane is * still a choice. What must not happen — and does not, because the refs are * per store — is the OTHER pane moving when it does. Measured 2026-09-13: * sorting the split pane by Size left the main pane on Name ↑ and made Size * the arrangement the next unconfigured folder opens in. */ export function createSortStore(): SortStore { const stored = readStored(); const key = ref(stored.key); const dir = ref(stored.dir); function setSort(k: SortKey, d?: SortDir): void { key.value = k; dir.value = d ?? defaultSortDir(k); persist(key.value, dir.value); } /** * tablo:t1 — RESTORE a sort that was already the person's, without recording * it as a fresh choice. * * ⚠⚠ The difference from `setSort` is the whole point and it is not * cosmetic. `filex.list-sort` is the GLOBAL default — "what a folder I have * never set up opens as" — and per-folder memory (`lib/viewPrefs`) is * layered on top of it. If applying a folder's remembered sort went through * `setSort`, walking into one folder that is sorted by size would quietly * make Size the default for every folder in the product, and the person * would have no idea which folder did it. Restoring is not choosing; only a * choice writes the default. */ function applySort(k: SortKey, d: SortDir): void { key.value = k; dir.value = d; } function toggleSortDir(): void { dir.value = dir.value === 'asc' ? 'desc' : 'asc'; persist(key.value, dir.value); } /** * A COLUMN HEADER click: a new key arrives in its own default direction, the * key already active flips. * * ⚠ Not what the Sort-by menu calls — see `setSort`. A header is a toggle * (click Name twice and you expect Z→A); a menu item is a destination (pick * Name twice and you expect Name, still). Both meanings are written here so * neither surface has to invent one. */ function chooseSortKey(k: SortKey): void { if (key.value === k) toggleSortDir(); else setSort(k); } function compareNodesFn(a: FileNode, b: FileNode): number { return byFoldersFirst(a, b) || byActiveKey(key.value, dir.value, a, b); } function compareInOrderFn(order: ListingOrder): (a: FileNode, b: FileNode) => number { return order === 'relevance' ? byFoldersFirst : compareNodesFn; } return { key, dir, setSort, applySort, toggleSortDir, chooseSortKey, compareNodes: compareNodesFn, compareInOrder: compareInOrderFn, sortListing: (files: FileNode[], order: ListingOrder = 'sort') => [...(files || [])].sort(compareInOrderFn(order)), }; } /** * The store a surface gets when nobody provided one: a bare `` in a * test, an embedder mounting one listing, and the explorer's own MAIN pane — * which keeps using it so that everything already reading the module-level * functions below (per-folder view memory, `displayFiles`) is talking about * the same state the main pane's controls drive. */ const defaultStore = createSortStore(); export const SORT_STORE_KEY: InjectionKey = Symbol('fe-sort-store'); /** Hand this subtree its own sort. Called by the pane component, once. */ export function provideSortStore(store: SortStore): void { provide(SORT_STORE_KEY, store); } /** * THE store for the surface calling it. * * ⚠ Must be called during `setup()` (it injects). Surfaces hold the result and * read `.key` / `.dir` inside their computeds, which is what subscribes them. */ export function useSortStore(): SortStore { return inject(SORT_STORE_KEY, defaultStore); } // ── the default store's API, as free functions ──────────────────────── // // Everything below is the module-level API this file shipped with, now a thin // pass-through to the default store. Kept — not deprecated — because the sort // is genuinely one answer for a host that draws ONE listing, which is every // embed except the split view, and because the explorer's per-folder view // memory reads and writes exactly this state for its main pane. export function activeSortKey(): SortKey { return defaultStore.key.value; } export function activeSortDir(): SortDir { return defaultStore.dir.value; } export function setSort(key: SortKey, dir?: SortDir): void { defaultStore.setSort(key, dir); } export function applySort(key: SortKey, dir: SortDir): void { defaultStore.applySort(key, dir); } /** * tablo:t1 — THE GLOBAL DEFAULT: the sort a folder nobody has configured opens * in. Re-read from storage rather than mirrored in a second ref, because * `setSort` writes there and `applySort` deliberately does not — so the stored * value IS "the last sort a person chose", which is exactly the definition of * the default, and a mirror could only drift from it. */ export function globalSort(): { key: SortKey; dir: SortDir } { return readStored(); } export function toggleSortDir(): void { defaultStore.toggleSortDir(); } export function chooseSortKey(key: SortKey): void { defaultStore.chooseSortKey(key); } /** * The whole rule, in order: folders before files, then the active key. * * Reading this subscribes the caller to the sort state, so a `computed` built * on it re-runs when the key or the direction changes. */ export function compareNodes(a: FileNode, b: FileNode): number { return defaultStore.compareNodes(a, b); } /** * THE comparator for a listing, given where its order came from. Every * surface that draws rows asks this one question; none of them re-derives the * answer, which is the whole reason this module exists. * * ⚠⚠ `relevance` is `byFoldersFirst` and NOTHING ELSE — deliberately the * exact function `GridView` already sorts by, so the grid needs no knowledge * of relevance at all and cannot drift from the list. `web/tests/lib/ * searchRelevanceOrder.test.ts` asserts that identity; if this ever returns * something else, that test goes red and names GridView as the file that has * to change in the same commit. Teaching one view about relevance and leaving * the other sorting is filex lesson #67 one level up. * * ⚠ Folders still come FIRST in a ranked set, and that is a decision, not an * oversight. Folders-first is not a sort here, it is a GROUPING: the grid * draws it as two labelled sections ("Folders" / "Files"), so a result set * that interleaved them would make the grid's own headings false. The ranking * is kept in full INSIDE each group — `Array#sort` is stable and this * comparator returns 0 for any two rows of the same kind — which is as much * relevance as a surface that groups folders can honestly offer. The cost is * visible and bounded: a folder that ranked seventh is drawn above the file * that ranked first. The alternative costs the grid its sections. */ export function compareInOrder(order: ListingOrder): (a: FileNode, b: FileNode) => number { return defaultStore.compareInOrder(order); } /** * The listing, in its order. A COPY — the caller's array is usually a prop or * a computed's source and sorting it in place mutates somebody else's state. * * `Array#sort` is stable, so rows the comparator calls equal keep the order * they arrived in — which is precisely what carries the server's ranking * through `relevance` mode. * * ⚠ `order` defaults to `'sort'`: an omitted argument is how every caller * holding an ordinary folder listing asks for the active key, and that is the * overwhelming majority of them. */ export function sortListing(files: FileNode[], order: ListingOrder = 'sort'): FileNode[] { return defaultStore.sortListing(files, order); }