/** * listing.ts — shared listing helpers used by BOTH the main panel * (FileExplorer.load) and the split-view secondary panel * (SecondaryPane.loadPane). SINGLE SOURCE so the two panes can never * drift apart: the internal-entry filter and the virtual `.trash` row * must be identical in both, otherwise split view shows mismatched rows * (the trash row missing on one side → visible row-offset). */ import type { TagKind } from './tags'; import { ref } from 'vue'; import type { FileNode } from '../types/FileNode'; import { E2E_MARKER_NAME } from './e2ecrypto'; import { isInternalName, isInternalPath } from './internalPaths'; /** Adapter-strip: `s3-test://fileman/x` → `fileman/x`. */ export function stripAdapter(p: string): string { const idx = p.indexOf('://'); return idx === -1 ? p : p.slice(idx + 3); } /** * The folder a row sits in, without its storage: `My files://Photos/a.jpg` → * `Photos`, and `''` for a row at a storage's root. * * ⚠⚠ Split on the FIRST `://` (`stripAdapter`), never with a URL-scheme * pattern. The list, the grid and the gallery each carried their own copy that * stripped `[a-z][a-z0-9+.-]*` followed by `://` — the RFC 3986 shape of a SCHEME, which a * storage name is not. A name with a space in it (`My files`, the one the * screenshot fixtures use, and a perfectly ordinary thing to call a drive) * does not match it, so nothing was stripped, the "folder" became * `My files://Photos`, and the Location column printed * `My files/My files://Photos` — the storage twice and the scheme on show. * Measured on Starred, 2026-09-14. An underscore or a leading digit broke it * the same way. */ export function parentDirOf(path: string): string { const rel = stripAdapter(String(path ?? '')); const idx = rel.lastIndexOf('/'); return idx === -1 ? '' : rel.slice(0, idx); } /** * The folder a row sits in, AS SHOWN: the plaintext path for a row inside an * encrypted-names folder (set by the explorer's name view, * composables/useE2eNames), `parentDirOf(path)` for everything else. Every * view's Location line reads this, so none of them prints ciphertext. */ export function displayParentDirOf(n: FileNode): string { return typeof n.e2e_display_dir === 'string' ? n.e2e_display_dir : parentDirOf(n.path); } /** * Hide system/internal entries the user must never see as files: filex's own * directories and the keep marker (lib/internalPaths — the one client list, * held equal to the server's by a test), the virtual `.trash` row, and the E2E * marker. Shared by both panes' listing filters. * * ⚠ Absolute, not a "hidden files" preference: a user who has switched hidden * files on is asking to see their OWN dotfiles, not ours. The server already * leaves these out of every listing; this is the seatbelt for an embed talking * to an older server — which is also why it judges the whole PATH, so a search * hit such a server returns from inside `.filex-open` is dropped too. */ export function filterInternalEntries(files: FileNode[]): FileNode[] { return (files || []).filter((f) => { if (isInternalName(f.basename) || isInternalPath(f.path)) return false; if (f.basename === '.trash') return false; if (f.basename === E2E_MARKER_NAME) return false; return true; }); } const SHOW_HIDDEN_LS_KEY = 'filex.showHidden'; function readShowHidden(): boolean { try { return localStorage.getItem(SHOW_HIDDEN_LS_KEY) === '1'; } catch { return false; // private mode / no storage → hidden, the safe default } } /** * Whether dot-prefixed entries are listed. Off by default, like every other * file manager. Shared by both panes so split view can't disagree with itself. */ export const showHiddenFiles = ref(readShowHidden()); export function setShowHiddenFiles(v: boolean): void { showHiddenFiles.value = v; try { localStorage.setItem(SHOW_HIDDEN_LS_KEY, v ? '1' : '0'); } catch { /* preference just won't persist */ } } /** * Hide dot-prefixed entries unless the user asked to see them. * * These are mostly not the user's files at all: a Mac writing over WebDAV * leaves a `.DS_Store` in every folder it opens and an AppleDouble `._name` * beside every file carrying extended attributes. Finder hides its own litter * locally, so seeing it reappear in the web UI reads as corruption. * * A toggle rather than a blocklist, because they ARE real files: hiding them * outright would leave the ones already uploaded unreachable and undeletable. */ export function filterHiddenEntries(files: FileNode[], showHidden: boolean): FileNode[] { if (showHidden) return files || []; return (files || []).filter((f) => !(f.basename || '').startsWith('.')); } /** filterInternalEntries + filterHiddenEntries — what both panes render. */ export function filterListing(files: FileNode[]): FileNode[] { return filterHiddenEntries(filterInternalEntries(files), showHiddenFiles.value); } /** True when `dirname` (adapter-qualified) is the storage root, where the * virtual trash row belongs. */ export function isStorageRootDir(dirname: string): boolean { const rel = stripAdapter(dirname); return rel === 'fileman' || rel === ''; } /** True when the listing IS the trash view (don't inject the row into itself). */ export function isTrashListing(dirname: string): boolean { return stripAdapter(dirname).startsWith('fileman/.trash'); } /** The synthetic `.trash` row shown at storage root — rendered as * "Çöp Kutusu" / "Trash" via the `.trash` basename → locale mapping. */ export function makeTrashRow(adapter: string): FileNode { return { type: 'dir', path: `${adapter}://fileman/.trash`, basename: '.trash', extension: '', storage: adapter, visibility: 'private', size: 0, file_size: 0, mime_type: 'inode/directory', extra_metadata: {}, } as unknown as FileNode; } /** * Inject the virtual `.trash` row at the front of a root listing when * enabled. Returns true if a row was added (so the caller can hydrate * it). Mutates `files` in place. Single source for both panes. * * ⚠ `isSearchResult` is not optional politeness — it is the one thing * `dirname` cannot tell you. A search answers with the SCOPE it searched, so * `?action=search&path=main://&filter=brief` comes back with * `dirname: "main://"`, identical to the folder listing of the same path * (measured). Without this flag the sentinel is unshifted into the search * results, and a search for "brief" answers with `brief.md` and a folder * called Trash that is not a folder, is not a hit, and cannot be searched for. * Same family as the `.trash` / `.shared` sentinel bugs fixed in v0.31.0: a * virtual row rendered somewhere it has no meaning. * * Both flags default to `false`, i.e. to the historical behaviour: a caller * that never searches and has no panel — which is what an embed with * `sideNav: false` is — keeps the row it has always had by saying nothing. */ export interface TrashRowContext { /** * This listing is the answer to a SEARCH, not the contents of a folder. * * ⚠ The one thing `dirname` cannot tell you: a search answers with the scope * it searched, so `?action=search&path=main://&filter=notes` comes back * carrying `dirname: "main://"`, byte-identical to that folder's listing * (measured). Without this the sentinel lands among the hits. */ isSearchResult?: boolean; /** * The navigation panel is already offering Trash as a destination. * * ⚠ This row only ever existed because a listing had no other way into the * bin. Once the panel carries a Trash entry that reason is gone and the row * is a second door to the same place — drawn as a 0-byte folder that is not * a folder, sitting among real ones, while the panel's own Trash entry is * three inches to its left. Owner's decision, this release: do not offer the * same door twice. * * ⚠ It is a question about the PANEL, not about the profile. The duplication * is exactly as wrong in `standard` with the panel on as it is in `drive`; * keying it to `uiProfile` would be a rule about the wrong thing. */ navOffersTrash?: boolean; } export function injectTrashRow( files: FileNode[], adapter: string, dirname: string, trashVisible: boolean, ctx: TrashRowContext = {}, ): boolean { // The host's own switch, and still the outer gate: `trashVisible: false` // means no Trash anywhere — no row here, and no entry in the panel either, // so this must be answered before anything about the panel is considered. if (!trashVisible) return false; if (ctx.navOffersTrash) return false; if (ctx.isSearchResult) return false; if (isTrashListing(dirname)) return false; if (!isStorageRootDir(dirname)) return false; files.unshift(makeTrashRow(adapter)); return true; } /** Best-effort fill of the trash row's size (total bytes) + date (newest * deletion), so it reads like a real folder instead of "— / —". * Non-blocking; mutates the row in place. * * ⚠ The numbers are the SERVER's, for this storage (finding D2, 0.54): the * row used to sum the entries of one listing page, asked with a `storage=` * the server did not read - the newest 50 deletions of EVERY storage, shown * as this one's size and date. The listing now answers `storages`, one * summary per storage over every entry the caller may see; one row of it * (`limit=1`) is all this needs. */ export async function hydrateTrashRow( files: FileNode[], storage: string, api: { listTrash: ( s?: string, page?: { limit?: number; offset?: number }, ) => Promise<{ storages?: Array<{ storage_name?: string; bytes: number; newest_deleted_at: string | null }> }>; }, ): Promise { try { const { storages } = await api.listTrash(storage, { limit: 1 }); const row = files.find((f) => f.basename === '.trash'); if (!row) return; const mine = (storages ?? []).find((s) => s.storage_name === storage); row.size = mine?.bytes ?? 0; const newest = mine?.newest_deleted_at ? Date.parse(mine.newest_deleted_at) : NaN; if (!Number.isNaN(newest)) row.last_modified = newest; } catch { /* keep the bare row */ } } /** * Sentinel path segment → locale key, for the virtual views the explorer parks * in `dirname` (trash, recent, starred, shared). None has a real folder behind * it, so anything that renders a path segment has to translate them or it * prints the sentinel. * * ⚠ ONE map, deliberately. `.trash` predates the other three and its * translation was written twice — once in the breadcrumb, once in the tab * label. When recent/starred/shared arrived, only the breadcrumb copy was * extended, so the tab strip read ".shared" in front of users (reported * 2026-09-04). A second copy of a mapping is a second chance to forget it. */ export const VIRTUAL_SEGMENTS: Record = { /* gorunum:v3-shell — Home is a destination like the rest, so it gets a sentinel like the rest: a restored tab, a reload and a pasted `#.home` all have to reopen the overview instead of asking the backend for a folder called `.home` and landing on "not found". */ '.home': 'node.home', '.trash': 'node.trash', '.recent': 'node.recent', '.starred': 'node.starred', '.shared': 'node.shared', /* Drafts (issue #71): a view like the rest — a reload on `#.drafts` opens it, instead of asking the backend for a folder called `.drafts`. */ '.drafts': 'node.drafts', }; /** * The tag view's sentinel: `.tag~`, ONE segment, e.g. `.tag~invoices`. * * Every other virtual view has a fixed label, so a segment→locale-key map is * enough for them. A tag's label is the tag itself, so it cannot live in that * map — but it must not become a SECOND place that knows about sentinels * either, which is exactly how the tab strip came to print `.shared`. Hence * `virtualSegmentLabel()` below: the map keeps the static views, this prefix * keeps the dynamic one, and every surface that renders a path segment calls * the one function that knows both. * * ⚠ `~` rather than `:` or `/`. `writePersistedPath` runs each segment through * `encodeURIComponent`, which leaves `~ - _ . ! * ' ( )` alone and escapes `:` * — so `#.tag~invoices` stays readable in the address bar while `.tag:` would * show as `#.tag%3Ainvoices`. And one segment rather than two (`.tag/name`) * because a two-segment path gives the breadcrumb a clickable `.tag` parent * crumb that leads nowhere. */ export const TAG_SEGMENT_PREFIX = '.tag~'; /** * etiket:k2 (v0.43.0) — the KIND of a tag view. A person can have a personal * "rapor" and their team a "rapor"; the navigation panel lists them apart, so * each opens its own view: `.mytag~rapor` (personal), `.teamtag~rapor` (team). * The plain `.tag~rapor` stays and means BOTH — every link and restored tab * from before kinds existed keeps opening what it opened then. * * ⚠ Listed longest-first is not needed (no prefix is a prefix of another), * but every reader goes through `tagViewOfSegment`, never a `startsWith` of * its own, so a fourth kind would be one line here. */ const TAG_KIND_PREFIXES: ReadonlyArray = [ ['.mytag~', 'personal'], ['.teamtag~', 'team'], [TAG_SEGMENT_PREFIX, ''], ]; /** `invoices` → `.tag~invoices`; with a kind, `.mytag~` / `.teamtag~`. */ export function makeTagSegment(tag: string, kind: TagKind | '' = ''): string { const prefix = TAG_KIND_PREFIXES.find(([, k]) => k === kind)?.[0] ?? TAG_SEGMENT_PREFIX; return `${prefix}${tag}`; } /** A tag-view segment's name and kind ('' = both), or null for anything else * (a bare `.tag~` included). */ export function tagViewOfSegment(segment: string): { tag: string; kind: TagKind | '' } | null { for (const [prefix, kind] of TAG_KIND_PREFIXES) { if (segment.startsWith(prefix)) { const tag = segment.slice(prefix.length); return tag ? { tag, kind } : null; } } return null; } /** `.tag~invoices` (or `.mytag~` / `.teamtag~`) → `invoices`; '' otherwise. */ export function tagOfSegment(segment: string): string { return tagViewOfSegment(segment)?.tag ?? ''; } /** The kind of a tag-view path: 'personal', 'team', or '' (both / not a tag view). */ export function tagKindOfPath(path: string): TagKind | '' { const clean = (path || '').replace(/^\/+|\/+$/g, ''); return tagViewOfSegment(clean.split('/').pop() || clean)?.kind ?? ''; } /** True when `path` (user-facing form) IS a tag listing. */ export function tagOfPath(path: string): string { const clean = (path || '').replace(/^\/+|\/+$/g, ''); return tagOfSegment(clean.split('/').pop() || clean); } /** The locale key for a STATIC sentinel segment, or '' otherwise. Prefer * `virtualSegmentLabel` — a tag segment has no locale key to return. */ export function virtualSegmentKey(segment: string): string { return VIRTUAL_SEGMENTS[segment] ?? ''; } /** * What a path segment READS AS: the translated view name for a static * sentinel, `#` for a tag view, '' when it is an ordinary folder (the * caller then shows the segment itself). * * `#` and not the bare name: a lone `invoices` crumb between `/` and nothing * is indistinguishable from a folder called invoices. The glyph needs no * translation and carries the tag's own name verbatim, which is the point. */ export function virtualSegmentLabel(segment: string, t: (key: string) => string): string { const key = VIRTUAL_SEGMENTS[segment]; if (key) return t(key); const view = tagViewOfSegment(segment); if (!view) return ''; // etiket:k2 — the crumb says whose tag this is: "#rapor · Kişisel". return view.kind ? `#${view.tag} · ${t(`tags.kind.${view.kind}`)}` : `#${view.tag}`; } /** * True when a USER PATH is one of the virtual views rather than a folder that * lives in a storage — `.trash`, `.recent`, `.starred`, `.shared`, `.home`, * `.tag~`. * * ⚠⚠ Why a predicate and not just "call `virtualSegmentLabel` and see". The * caller that needs this is deciding whether to QUALIFY the path, and * qualifying a sentinel destroys it: in multi-storage mode `qualify('.starred')` * splits the first segment off as the adapter and answers `.starred://`, so * from there on the sentinel is a STORAGE NAME. A storage name is not a path * segment, `virtualSegmentLabel` is never asked about it, and the breadcrumb * prints `.starred` — which is exactly what the owner saw on 2026-09-13, in * every virtual view at once, the night the pane was extracted into * `FilePane.vue` and each pane began deriving its own crumb inputs. The * resolver was being called the whole time, with an argument that could not * match. * * ⚠ One definition, here beside the map and the tag prefix it asks about, so * "is this a view or a folder?" cannot be answered two ways: `FileExplorer`'s * `virtualViewOf()` (which also names the KIND) is built on this one. */ export function isVirtualViewPath(path: string): boolean { const clean = String(path ?? '').replace(/^\/+|\/+$/g, ''); if (!clean) return false; return !!VIRTUAL_SEGMENTS[clean] || !!tagOfSegment(clean); } /** * gorunum:v1 — folders before files, whatever the sort column is. * * Every file manager a person has used puts directories first, and the reason * is not taste: a directory is a place and a file is a thing, and a listing * that interleaves them makes the reader check the icon of every row to find * out where they can go next. Sorting by size or date interleaves them worst * of all, because a folder has neither. * * Applied as the PRIMARY comparator by both views, so it cannot mean one * thing in the list and another in the grid — the two sorts are separate code * and this is the rule they have to share (filex lesson #67). */ export function byFoldersFirst(a: { type?: string }, b: { type?: string }): number { const ad = a.type === 'dir' ? 0 : 1; const bd = b.type === 'dir' ? 0 : 1; return ad - bd; }