@brftech/filex-core 0.39.1 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +32 -25
  2. package/dist/ArchiveViewer-BNqukFg8.js +124 -0
  3. package/dist/ArchiveViewer-BNqukFg8.js.map +1 -0
  4. package/dist/CsvViewer-ii_-MgmG.js +141 -0
  5. package/dist/CsvViewer-ii_-MgmG.js.map +1 -0
  6. package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-B2tuu4rX.js} +48 -42
  7. package/dist/DrawioViewer-B2tuu4rX.js.map +1 -0
  8. package/dist/EpubViewer-p4B8iiWb.js +163 -0
  9. package/dist/EpubViewer-p4B8iiWb.js.map +1 -0
  10. package/dist/IpynbViewer-D_qYRJhu.js +184 -0
  11. package/dist/IpynbViewer-D_qYRJhu.js.map +1 -0
  12. package/dist/MermaidViewer-3ZkfNM8v.js +137 -0
  13. package/dist/MermaidViewer-3ZkfNM8v.js.map +1 -0
  14. package/dist/PsdViewer-CuV-OPSd.js +122 -0
  15. package/dist/PsdViewer-CuV-OPSd.js.map +1 -0
  16. package/dist/TiffViewer-BLeoF4Be.js +142 -0
  17. package/dist/TiffViewer-BLeoF4Be.js.map +1 -0
  18. package/dist/Viewer3D-DHhhU76E.js +75 -0
  19. package/dist/Viewer3D-DHhhU76E.js.map +1 -0
  20. package/dist/filex-core.js +217 -20077
  21. package/dist/filex-core.js.map +1 -1
  22. package/dist/filex-core.umd.cjs +61 -62
  23. package/dist/filex-core.umd.cjs.map +1 -1
  24. package/dist/index-BraG7Cz4.js +26664 -0
  25. package/dist/index-BraG7Cz4.js.map +1 -0
  26. package/dist/index.d.ts +4276 -851
  27. package/dist/style.css +1 -1
  28. package/package.json +1 -1
  29. package/src/FileExplorer.vue +2170 -492
  30. package/src/components/AdvancedSearch.vue +870 -0
  31. package/src/components/Breadcrumb.vue +191 -19
  32. package/src/components/CommandPalette.vue +66 -22
  33. package/src/components/ConnectionsPanel.vue +6 -2
  34. package/src/components/ContextMenu.vue +57 -2
  35. package/src/components/EncryptedFolderModal.vue +31 -25
  36. package/src/components/FilePane.vue +939 -0
  37. package/src/components/FilterBar.vue +568 -72
  38. package/src/components/GalleryView.vue +162 -30
  39. package/src/components/GridView.vue +322 -58
  40. package/src/components/HomeView.vue +331 -0
  41. package/src/components/InspectorPanel.vue +296 -53
  42. package/src/components/ListView.vue +1158 -167
  43. package/src/components/NFSExportsPanel.vue +7 -4
  44. package/src/components/OnboardingTour.vue +156 -33
  45. package/src/components/QuickLook.vue +34 -5
  46. package/src/components/RecentlyOpened.vue +17 -2
  47. package/src/components/S3KeysPanel.vue +7 -4
  48. package/src/components/SSHKeysPanel.vue +7 -4
  49. package/src/components/ShortcutSettings.vue +43 -2
  50. package/src/components/ShortcutsHelp.vue +4 -1
  51. package/src/components/SideNav.vue +206 -140
  52. package/src/components/StorageFields.vue +19 -1
  53. package/src/components/TabBar.vue +87 -1
  54. package/src/components/TagPicker.vue +77 -6
  55. package/src/components/ThemeGallery.vue +13 -198
  56. package/src/components/ThemePalette.vue +244 -0
  57. package/src/components/TimeZoneDialog.vue +127 -0
  58. package/src/components/TimeZonePicker.vue +696 -0
  59. package/src/components/TokensPanel.vue +16 -4
  60. package/src/components/Toolbar.vue +965 -469
  61. package/src/components/ViewSwitcher.vue +9 -4
  62. package/src/composables/useConnections.ts +35 -4
  63. package/src/composables/useExplorerTimeZone.ts +85 -0
  64. package/src/composables/useFileApi.ts +53 -4
  65. package/src/composables/useKeyboardShortcuts.ts +122 -0
  66. package/src/composables/useLocale.ts +365 -25
  67. package/src/index.ts +182 -3
  68. package/src/lib/actionIcons.ts +330 -0
  69. package/src/lib/advSearch.ts +162 -0
  70. package/src/lib/dateGroups.ts +197 -0
  71. package/src/lib/destinationTree.ts +235 -0
  72. package/src/lib/downloadSelection.ts +154 -0
  73. package/src/lib/fileFilters.ts +319 -12
  74. package/src/lib/fileIcons.ts +299 -5
  75. package/src/lib/filePreview.ts +605 -0
  76. package/src/lib/listing.ts +72 -0
  77. package/src/lib/shareTtl.ts +11 -1
  78. package/src/lib/sortOrder.ts +459 -0
  79. package/src/lib/themes.ts +152 -23
  80. package/src/lib/timezone.ts +456 -0
  81. package/src/lib/uiProfile.ts +82 -0
  82. package/src/lib/viewPrefs.ts +1036 -0
  83. package/src/locales/en.ts +432 -25
  84. package/src/locales/tr.ts +425 -25
  85. package/src/modals/ConvertModal.vue +23 -3
  86. package/src/modals/DestinationPickerModal.vue +341 -0
  87. package/src/modals/Modal.vue +18 -4
  88. package/src/modals/NewDocumentModal.vue +525 -0
  89. package/src/modals/NewFolderModal.vue +15 -9
  90. package/src/modals/PermissionsModal.vue +543 -473
  91. package/src/modals/PreviewModal.vue +516 -156
  92. package/src/styles/base.css +5435 -8
  93. package/src/styles/variables.css +140 -60
  94. package/src/types/Connections.ts +9 -0
  95. package/src/types/ExplorerConfig.ts +144 -39
  96. package/src/types/FileNode.ts +28 -0
  97. package/src/viewers/ArchiveViewer.vue +46 -8
  98. package/src/viewers/CsvViewer.vue +19 -2
  99. package/src/viewers/DrawioViewer.vue +12 -1
  100. package/src/viewers/EpubViewer.vue +50 -6
  101. package/src/viewers/IpynbViewer.vue +19 -2
  102. package/src/viewers/MermaidViewer.vue +20 -3
  103. package/src/viewers/PdfViewer.vue +24 -4
  104. package/src/viewers/PsdViewer.vue +28 -4
  105. package/src/viewers/TiffViewer.vue +19 -2
  106. package/src/viewers/Viewer3D.vue +36 -2
  107. package/dist/ArchiveViewer-Cdfv9LFC.js +0 -99
  108. package/dist/ArchiveViewer-Cdfv9LFC.js.map +0 -1
  109. package/dist/CsvViewer-CqWeV8VO.js +0 -131
  110. package/dist/CsvViewer-CqWeV8VO.js.map +0 -1
  111. package/dist/DrawioViewer-BNALOB04.js.map +0 -1
  112. package/dist/EpubViewer-BPmlImG7.js +0 -145
  113. package/dist/EpubViewer-BPmlImG7.js.map +0 -1
  114. package/dist/IpynbViewer-CDlQmuCV.js +0 -175
  115. package/dist/IpynbViewer-CDlQmuCV.js.map +0 -1
  116. package/dist/MermaidViewer-sfZWdR-d.js +0 -127
  117. package/dist/MermaidViewer-sfZWdR-d.js.map +0 -1
  118. package/dist/PsdViewer-DwuySZsb.js +0 -112
  119. package/dist/PsdViewer-DwuySZsb.js.map +0 -1
  120. package/dist/TiffViewer-BDQ3d3CQ.js +0 -132
  121. package/dist/TiffViewer-BDQ3d3CQ.js.map +0 -1
  122. package/dist/Viewer3D-CJJJgkuv.js +0 -60
  123. package/dist/Viewer3D-CJJJgkuv.js.map +0 -1
  124. package/src/components/SecondaryPane.vue +0 -447
  125. package/src/modals/ShareModal.vue +0 -139
@@ -14,10 +14,20 @@
14
14
  *
15
15
  * Because the endpoint has no `limit`/`offset` either, the listing in hand IS
16
16
  * the folder — so filtering it client-side is complete for the folder, not a
17
- * filter over "the first page". That is exactly why these three are honest and
18
- * a People/owner filter is not: `nodes.owner_id` exists for quota accounting,
19
- * is nil for anything a sync discovered, and is serialized by nothing
20
- * (handlers/shared.go: "There is no per-node owner").
17
+ * filter over "the first page".
18
+ *
19
+ * This file used to end "…and a People/owner filter is not, because
20
+ * `nodes.owner_id` exists for quota accounting and is serialized by nothing".
21
+ * That was true and is no longer: migration 00038 made ownership a real fact
22
+ * on the row (who put it here, who touched it last, whether it arrived through
23
+ * an anonymous drop link), and the listing projection now carries `owner_id`,
24
+ * `owner_name` and `owner_self` on every row that has an owner. The People
25
+ * filter therefore answers from a field the row ALREADY carries, exactly like
26
+ * the other four — the rule did not bend, the data arrived.
27
+ *
28
+ * ⚠ A row with NO owner key is SYSTEM: nobody put it there through filex. That
29
+ * is the honest word for ownerless and it is a real choice in the pill, not a
30
+ * blank.
21
31
  */
22
32
  import type { FileNode } from '../types/FileNode';
23
33
  import { iconFamilyFor, type IconFamily } from './fileIcons';
@@ -35,24 +45,153 @@ export type TypeFilter =
35
45
  | 'archive'
36
46
  | 'code';
37
47
 
38
- export type ModifiedFilter = 'any' | 'today' | '7d' | '30d' | 'year';
48
+ export type ModifiedFilter = 'any' | 'today' | '7d' | '30d' | 'year' | 'around';
49
+
50
+ export type SizeFilter = 'any' | 'lt1' | '1to10' | '10to100' | 'gt100' | 'range';
51
+
52
+ /** gorunum:v1-advsearch — how wide "around" reaches on each side of the anchor. */
53
+ export type AroundSpan = 'h1' | 'd1' | 'w1';
39
54
 
40
- export type SizeFilter = 'any' | 'lt1' | '1to10' | '10to100' | 'gt100';
55
+ /** gorunum:v1-advsearch what the path choice does to a row outside `pathBase`. */
56
+ export type PathMode = 'any' | 'here' | 'skip';
57
+
58
+ /**
59
+ * Who a row belongs to. `any` is the neutral member every pill group needs;
60
+ * `me` is the signed-in account, `system` is an ownerless row, and `u:<id>`
61
+ * names one of the other accounts that actually appear in this listing.
62
+ *
63
+ * ⚠ The account members are NOT a fixed list. Offering every account on the
64
+ * install would put names in the menu that cannot narrow anything here and
65
+ * would leak the user directory into a folder view; `peopleOptions` derives
66
+ * the choices from the rows in hand.
67
+ */
68
+ export type PeopleFilter = 'any' | 'me' | 'system' | `u:${number}`;
41
69
 
42
70
  export interface DriveFilters {
43
71
  type: TypeFilter;
44
72
  modified: ModifiedFilter;
45
73
  size: SizeFilter;
74
+ /**
75
+ * gorunum:v1 — "Filter in this folder…", the input the filter row carries
76
+ * beside the chips. A substring of the NAME, matched over the rows in hand.
77
+ *
78
+ * ⚠ Not a second search box. The header field runs `action=search` on the
79
+ * server and REPLACES the listing with hits (`isSearchResult`, results carry
80
+ * their parent path and can come from subfolders); this one never leaves the
81
+ * folder you are looking at and never issues a request — it narrows what is
82
+ * already on screen, the same rows the chips narrow, which is why it belongs
83
+ * to the same model and not to `searchQuery`.
84
+ *
85
+ * Optional so every existing `{ type, modified, size }` literal — embedders
86
+ * included — still type-checks and behaves exactly as before.
87
+ */
88
+ name?: string;
89
+
90
+ /* ── gorunum:v1-advsearch — the three the advanced-search dialog adds ─────
91
+ *
92
+ * All optional, so every existing `{ type, modified, size }` literal still
93
+ * type-checks and behaves exactly as it did — the same contract `name` was
94
+ * added under. They live HERE and not in a second model because the filter
95
+ * row and the dialog narrow the same rows with the same predicate: two
96
+ * definitions of "modified in the last 7 days" is two products.
97
+ *
98
+ * ⚠ They are client-side for the same reason the three above are, and the
99
+ * reason is stated at the top of this file: the listing and search endpoints
100
+ * read no date, size or path parameter. What makes them honest is that the
101
+ * rows in hand are the complete answer for a FOLDER listing; over a SEARCH
102
+ * result they narrow the hits the server returned, which is capped
103
+ * (`/api/files/search` limit, the manager search's 250) — so a size range
104
+ * over a truncated hit list is a filter over what came back, not over the
105
+ * storage. The dialog says so rather than implying a full scan. */
106
+
107
+ /** Anchor for `modified: 'around'`. `YYYY-MM-DD` or `YYYY-MM-DDTHH:mm`,
108
+ * read as LOCAL time (a date alone means that day's 00:00 local). */
109
+ aroundDate?: string;
110
+ /** Half-width of the `around` window. */
111
+ aroundSpan?: AroundSpan;
112
+ /** Inclusive floor for `size: 'range'`, in BYTES. null = open-ended. */
113
+ sizeMin?: number | null;
114
+ /** Inclusive ceiling for `size: 'range'`, in BYTES. null = open-ended. */
115
+ sizeMax?: number | null;
116
+ /** `here` keeps only rows under `pathBase`; `skip` keeps only rows outside it. */
117
+ pathMode?: PathMode;
118
+ /**
119
+ * Who the row belongs to. Optional, under the same contract as `name` and
120
+ * the advanced-search three: every existing `{ type, modified, size }`
121
+ * literal still type-checks and behaves exactly as before.
122
+ */
123
+ people?: PeopleFilter;
124
+
125
+ /** Adapter-qualified folder the path choice is measured against, e.g.
126
+ * `qldemo://Documents`. Captured when the dialog opens, because a search
127
+ * rebases the explorer to the storage root and the folder the user meant
128
+ * would otherwise be gone by the time the filter runs. */
129
+ pathBase?: string;
130
+ }
131
+
132
+ export const EMPTY_FILTERS: DriveFilters = {
133
+ type: 'any',
134
+ modified: 'any',
135
+ size: 'any',
136
+ name: '',
137
+ aroundDate: '',
138
+ aroundSpan: 'd1',
139
+ sizeMin: null,
140
+ sizeMax: null,
141
+ pathMode: 'any',
142
+ pathBase: '',
143
+ people: 'any',
144
+ };
145
+
146
+ /** Case- and accent-folded, so "İstanbul" answers to "ist" and "Ödev" to "od".
147
+ * ⚠ `toLowerCase()` alone maps `İ` to `i` + a combining dot, which then
148
+ * matches nothing the user typed; stripping the marks is what makes the two
149
+ * sides comparable. */
150
+ function fold(s: string): string {
151
+ return s
152
+ .normalize('NFD')
153
+ .replace(/\p{M}+/gu, '')
154
+ .toLowerCase();
155
+ }
156
+
157
+ /** The trimmed needle, or '' when the input is empty/whitespace. */
158
+ export function nameNeedle(f: DriveFilters): string {
159
+ return (f.name ?? '').trim();
160
+ }
161
+
162
+ /** gorunum:v1-advsearch — `here`/`skip` only mean something with a base to
163
+ * measure against; without one the choice is inert and must not count as an
164
+ * active filter, or the empty state would blame a filter that filters nothing. */
165
+ function pathActive(f: DriveFilters): boolean {
166
+ return (f.pathMode ?? 'any') !== 'any' && !!(f.pathBase ?? '');
46
167
  }
47
168
 
48
- export const EMPTY_FILTERS: DriveFilters = { type: 'any', modified: 'any', size: 'any' };
169
+ /** The People choice, defaulted. Kept in one place so the four call sites
170
+ * below cannot disagree about what "no choice" means. */
171
+ function peopleOf(f: DriveFilters): PeopleFilter {
172
+ return f.people ?? 'any';
173
+ }
49
174
 
50
175
  export function filtersActive(f: DriveFilters): boolean {
51
- return f.type !== 'any' || f.modified !== 'any' || f.size !== 'any';
176
+ return (
177
+ f.type !== 'any' ||
178
+ f.modified !== 'any' ||
179
+ f.size !== 'any' ||
180
+ nameNeedle(f) !== '' ||
181
+ pathActive(f) ||
182
+ peopleOf(f) !== 'any'
183
+ );
52
184
  }
53
185
 
54
186
  export function activeFilterCount(f: DriveFilters): number {
55
- return (f.type !== 'any' ? 1 : 0) + (f.modified !== 'any' ? 1 : 0) + (f.size !== 'any' ? 1 : 0);
187
+ return (
188
+ (f.type !== 'any' ? 1 : 0) +
189
+ (f.modified !== 'any' ? 1 : 0) +
190
+ (f.size !== 'any' ? 1 : 0) +
191
+ (nameNeedle(f) !== '' ? 1 : 0) +
192
+ (pathActive(f) ? 1 : 0) +
193
+ (peopleOf(f) !== 'any' ? 1 : 0)
194
+ );
56
195
  }
57
196
 
58
197
  /** Families a type choice accepts. `iconFamilyFor` is the taxonomy the icons
@@ -93,13 +232,46 @@ function matchesType(n: FileNode, t: TypeFilter): boolean {
93
232
  }
94
233
 
95
234
  /** `now` is a parameter so a test can pin the clock instead of sleeping. */
96
- function matchesModified(n: FileNode, f: ModifiedFilter, now: number): boolean {
235
+ /** gorunum:v1-advsearch half-width of each "around" window, in ms. */
236
+ const AROUND_MS: Record<AroundSpan, number> = {
237
+ h1: 3_600_000,
238
+ d1: 86_400_000,
239
+ w1: 7 * 86_400_000,
240
+ };
241
+
242
+ /**
243
+ * gorunum:v1-advsearch — read the anchor as LOCAL time.
244
+ *
245
+ * ⚠ `Date.parse('2026-09-12')` is UTC by spec while
246
+ * `Date.parse('2026-09-12T00:00')` is local, so a bare date would slide the
247
+ * whole window by the viewer's offset — in Istanbul (UTC+3) a ±1 hour search
248
+ * "around the 12th" would have covered 03:00, not midnight. Appending the time
249
+ * makes both forms mean the same clock the user is reading.
250
+ */
251
+ function parseAnchor(raw: string | undefined): number | null {
252
+ const s = (raw ?? '').trim();
253
+ if (!s) return null;
254
+ const ms = Date.parse(s.includes('T') ? s : `${s}T00:00`);
255
+ return Number.isNaN(ms) ? null : ms;
256
+ }
257
+
258
+ function matchesModified(n: FileNode, g: DriveFilters, now: number): boolean {
259
+ const f = g.modified;
97
260
  if (f === 'any') return true;
98
261
  const ms = typeof n.last_modified === 'number' ? n.last_modified : 0;
99
262
  // No timestamp = no answer. Dropping the row would hide files whose driver
100
263
  // gave us nothing; keeping it would put them in "Today". Hiding is the
101
264
  // honest one: the row does not satisfy "modified today", it is unknown.
102
265
  if (!ms) return false;
266
+ if (f === 'around') {
267
+ const anchor = parseAnchor(g.aroundDate);
268
+ // No anchor typed yet: "around nothing" is not a window, so the choice is
269
+ // inert rather than empty. Narrowing to zero rows the moment the user
270
+ // picks the mode — before they have picked a date — reads as a broken
271
+ // search, not as an unfinished one.
272
+ if (anchor === null) return true;
273
+ return Math.abs(ms - anchor) <= AROUND_MS[g.aroundSpan ?? 'd1'];
274
+ }
103
275
  if (f === 'year') {
104
276
  return new Date(ms).getFullYear() === new Date(now).getFullYear();
105
277
  }
@@ -114,7 +286,8 @@ function matchesModified(n: FileNode, f: ModifiedFilter, now: number): boolean {
114
286
 
115
287
  const MB = 1024 * 1024;
116
288
 
117
- function matchesSize(n: FileNode, f: SizeFilter): boolean {
289
+ function matchesSize(n: FileNode, g: DriveFilters): boolean {
290
+ const f = g.size;
118
291
  if (f === 'any') return true;
119
292
  // ⚠ Folders drop out of every size choice rather than passing through. A
120
293
  // directory row's `size` is 0 from the projector, so "under 1 MB" would
@@ -122,12 +295,140 @@ function matchesSize(n: FileNode, f: SizeFilter): boolean {
122
295
  // measurement and is not one.
123
296
  if (n.type === 'dir') return false;
124
297
  const s = typeof n.size === 'number' ? n.size : 0;
298
+ if (f === 'range') {
299
+ // Either end may be left open. Both open = "any size" wearing another
300
+ // name, which is exactly what an untouched custom range is.
301
+ const lo = typeof g.sizeMin === 'number' ? g.sizeMin : null;
302
+ const hi = typeof g.sizeMax === 'number' ? g.sizeMax : null;
303
+ if (lo !== null && s < lo) return false;
304
+ if (hi !== null && s > hi) return false;
305
+ return true;
306
+ }
125
307
  if (f === 'lt1') return s < MB;
126
308
  if (f === '1to10') return s >= MB && s < 10 * MB;
127
309
  if (f === '10to100') return s >= 10 * MB && s < 100 * MB;
128
310
  return s >= 100 * MB;
129
311
  }
130
312
 
313
+ /**
314
+ * gorunum:v1 — the name input's predicate, over a bare STRING.
315
+ *
316
+ * ⚠ Exported over a string rather than over a `FileNode`, because the name box
317
+ * now narrows things that are not nodes: `surucu:d1-scope` puts the same box on
318
+ * the multi-storage root (rows ARE nodes there, synthesized ones) and on Home,
319
+ * whose storage cards are `HomeStorage` records with a `label`. Case- and
320
+ * accent-folding is the part that must not be re-typed — a second `toLowerCase`
321
+ * somewhere else is how "Ödev" stops answering to "od" on one surface and keeps
322
+ * answering on another.
323
+ */
324
+ export function nameMatches(name: string, needle: string): boolean {
325
+ if (!needle) return true;
326
+ return fold(name || '').includes(fold(needle));
327
+ }
328
+
329
+ /** The same predicate over a listing row. Folders take part like any other row:
330
+ * hiding the folder whose name you just typed would be the one result you
331
+ * meant. */
332
+ function matchesName(n: FileNode, needle: string): boolean {
333
+ return nameMatches(n.basename || '', needle);
334
+ }
335
+
336
+ /**
337
+ * gorunum:v1-advsearch — "only in this folder" / "skip this folder".
338
+ *
339
+ * Both sides are answered from `path`, which every search hit already carries
340
+ * adapter-qualified (`qldemo://Documents/report.sql`). The base is compared
341
+ * with a trailing slash so `Documents` cannot claim `Documents-old`, and the
342
+ * folder row itself counts as inside it.
343
+ */
344
+ function matchesPath(n: FileNode, g: DriveFilters): boolean {
345
+ const mode = g.pathMode ?? 'any';
346
+ const base = (g.pathBase ?? '').replace(/\/+$/, '');
347
+ if (mode === 'any' || !base) return true;
348
+ const p = n.path || '';
349
+ const inside = p === base || p.startsWith(`${base}/`);
350
+ return mode === 'here' ? inside : !inside;
351
+ }
352
+
353
+ /* ── Ownership, read off the row ──────────────────────────────────────────
354
+ *
355
+ * The backend omits every owner key it has nothing to say about, so a SYSTEM
356
+ * row simply has no `owner_id` — which is why these read through the FileNode
357
+ * index signature rather than named fields, the same way `snippet` and
358
+ * `matched` are read in ListView. */
359
+
360
+ /** The row's owner id, or null for a SYSTEM row (nobody put it here). */
361
+ export function ownerIdOf(n: FileNode): number | null {
362
+ const raw = (n as Record<string, unknown>).owner_id;
363
+ return typeof raw === 'number' && raw > 0 ? raw : null;
364
+ }
365
+
366
+ /** True when the signed-in account owns this row. Answered by the SERVER
367
+ * (`owner_self`), because the embeddable core has no idea which filex account
368
+ * the host's session belongs to. */
369
+ export function ownedByViewer(n: FileNode): boolean {
370
+ return (n as Record<string, unknown>).owner_self === true;
371
+ }
372
+
373
+ /** The owner's display name, or '' for a system row / a name the server could
374
+ * not resolve. The caller decides what to show instead. */
375
+ export function ownerNameOf(n: FileNode): string {
376
+ const raw = (n as Record<string, unknown>).owner_name;
377
+ return typeof raw === 'string' ? raw : '';
378
+ }
379
+
380
+ /** True when the row arrived through an anonymous drop link. The OWNER is
381
+ * still the person who created the link — this only says they did not put it
382
+ * there themselves. */
383
+ export function arrivedFromOutside(n: FileNode): boolean {
384
+ return (n as Record<string, unknown>).external_upload === true;
385
+ }
386
+
387
+ /** One entry of the People pill. `name` is present only for the `u:<id>`
388
+ * members — the three fixed ones are named by the locale, not by data. */
389
+ export interface PeopleOption {
390
+ value: PeopleFilter;
391
+ name?: string;
392
+ }
393
+
394
+ /**
395
+ * The People pill's menu for a given listing: `any`, `me`, `system`, and then
396
+ * every OTHER account that actually owns something on screen, by name.
397
+ *
398
+ * ⚠ `me` and `system` are offered unconditionally, and that is deliberate:
399
+ * a menu whose entries appear and disappear as you navigate is a menu you
400
+ * cannot learn. The per-account entries are the ones that would be inert
401
+ * elsewhere, so they are the ones derived from the rows.
402
+ */
403
+ export function peopleOptions(files: FileNode[]): PeopleOption[] {
404
+ const out: PeopleOption[] = [{ value: 'any' }, { value: 'me' }, { value: 'system' }];
405
+ const seen = new Set<number>();
406
+ for (const n of files) {
407
+ const id = ownerIdOf(n);
408
+ if (id === null || ownedByViewer(n) || seen.has(id)) continue;
409
+ seen.add(id);
410
+ out.push({ value: `u:${id}`, name: ownerNameOf(n) });
411
+ }
412
+ // Stable, readable order for the derived half; the fixed three stay put.
413
+ const fixed = out.slice(0, 3);
414
+ const rest = out
415
+ .slice(3)
416
+ .sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '', undefined, { sensitivity: 'base' }));
417
+ return [...fixed, ...rest];
418
+ }
419
+
420
+ /** Rows belonging to the chosen person. A folder takes part like any other
421
+ * row: somebody made it, and "show me what Ada put here" that silently keeps
422
+ * everyone's folders would be answering a different question. */
423
+ function matchesPeople(n: FileNode, f: DriveFilters): boolean {
424
+ const want = peopleOf(f);
425
+ if (want === 'any') return true;
426
+ if (want === 'me') return ownedByViewer(n);
427
+ if (want === 'system') return ownerIdOf(n) === null;
428
+ const id = Number(want.slice(2));
429
+ return Number.isFinite(id) && ownerIdOf(n) === id;
430
+ }
431
+
131
432
  export function applyFilters(
132
433
  files: FileNode[],
133
434
  f: DriveFilters,
@@ -136,8 +437,14 @@ export function applyFilters(
136
437
  // No active filter → the SAME array reference, so an unfiltered explorer
137
438
  // renders exactly what it rendered before this file existed.
138
439
  if (!filtersActive(f)) return files;
440
+ const needle = nameNeedle(f);
139
441
  return files.filter(
140
442
  (n) =>
141
- matchesType(n, f.type) && matchesModified(n, f.modified, now) && matchesSize(n, f.size),
443
+ matchesType(n, f.type) &&
444
+ matchesModified(n, f, now) &&
445
+ matchesSize(n, f) &&
446
+ matchesName(n, needle) &&
447
+ matchesPath(n, f) &&
448
+ matchesPeople(n, f),
142
449
  );
143
450
  }